Зачем это вообще

У нашего пользователя (веб-студия, на мониторинге пара десятков клиентских сайтов) есть дашборд: мониторы, события, аптайм. И есть ассистент — Claude Code, Cursor, что угодно — в котором он и так сидит полдня. Вопрос «что сейчас в сбое у моих клиентов?» естественнее задать ассистенту, чем идти кликать по вкладкам. А вопрос «какой аптайм был у сайта клиента X за месяц и что там падало?» ассистент ещё и сам скомпонует в абзац для письма клиенту.

Чтобы LLM могла ответить, ей нужен доступ к данным. Способов накормить модель своим API исторически было много и все разные: у каждого вендора свой формат function calling, свои плагины, свои манифесты. MCP (Model Context Protocol) — попытка это стандартизировать: сервер декларирует инструменты, любой MCP-клиент их находит и вызывает. Claude Code, Claude Desktop, Cursor, VS Code — все уже умеют быть клиентами. То есть один эндпоинт — и мы «в комплекте» у всех сразу.

Мы посмотрели на спеку, на официальные SDK, на примеры «поднимите отдельный MCP-сервис» — и сделали иначе: один HTTP-хендлер внутри существующего Go-монолита, ~200 строк, без единой новой зависимости. Вечер работы, включая тесты curl-ом. Ниже — почему так и что реально пришлось учесть.

Решения и компромиссы

Почему не отдельный сервис. У нас монолит (один бинарник hub, модули внутри), и это осознанная архитектура — деплой одной командой, ноль сетевых границ внутри. Отдельный MCP-сервис означал бы: свой деплой, свой TLS, свой доступ к базе или прокси через внутренний API, свой мониторинг (мониторинг мониторинга, да). Ради четырёх read-only инструментов — не смешно, а грустно. MCP-хендлер живёт рядом с остальным API и переиспользует всё: пул соединений к Postgres, авторизацию токенов, таймауты запросов.

Почему не официальный SDK. SDK для Go существует, но тянет за собой транспортную машинерию: сессии, SSE-стриминг, resumability. Всё это нужно, когда инструменты долгие или сервер хочет пушить события. Наши тулзы — SELECT из базы, отвечают за десятки миллисекунд. Спека MCP (ревизия 2025-03-26) прямо разрешает простейшую форму Streamable HTTP: клиент POST-ит JSON-RPC, сервер отвечает одним JSON-документом, без SSE. Никакого стриминга — никакого SDK.

Почему stateless. По спеке сервер может выдавать Mcp-Session-Id и вести сессию. А может не выдавать — тогда клиент обязан работать без неё. Мы не выдаём: каждый запрос авторизуется Bearer-токеном заново, никакого состояния между запросами. Это не только меньше кода — это ещё и «бесплатная» горизонтальная масштабируемость и отсутствие класса багов «сессия протухла».

Какие методы реально нужны. Спека большая, но обязательный минимум для tools-only сервера скромный:

  • initialize — рукопожатие, версия протокола и capabilities;

  • notifications/initialized — уведомление от клиента, на которое не надо отвечать;

  • ping — keepalive;

  • tools/list — каталог инструментов;

  • tools/call — вызов.

Всё. Ни resources, ни prompts, ни sampling нам не нужны — capabilities честно декларируют только tools.

Код

Весь сервер — один файл internal/api/mcp.go. Начинается с конвертов JSON-RPC:

type rpcReq struct {
    JSONRPC string          `json:"jsonrpc"`
    ID      json.RawMessage `json:"id"` // null/отсутствует = notification
    Method  string          `json:"method"`
    Params  json.RawMessage `json:"params"`
}

type rpcErr struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func writeRPC(w http.ResponseWriter, id json.RawMessage, result any, rerr *rpcErr) {
    w.Header().Set("Content-Type", "application/json")
    resp := map[string]any{"jsonrpc": "2.0", "id": id}
    if rerr != nil {
        resp["error"] = rerr
    } else {
        resp["result"] = result
    }
    _ = json.NewEncoder(w).Encode(resp)
}

Обратите внимание на ID json.RawMessage: id в JSON-RPC может быть числом, строкой или отсутствовать. Нам не нужно его интерпретировать — только вернуть как есть. RawMessage избавляет от типовой возни целиком.

Описания инструментов — просто данные. Никаких reflection-генераторов схем: инструментов четыре, схемы у них тривиальные, руками честнее. Имена и схемы считаем стабильным контрактом — как protobuf, только менять нельзя ещё сильнее (на них завязаны чужие промпты):

var mcpTools = []map[string]any{
    {
        "name":        "list_monitors",
        "description": "Список проверок (мониторов) организации: имя, тип, цель, текущий статус (up/down), клиентская метка.",
        "inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
    },
    {
        "name":        "list_incidents",
        "description": "События мониторинга (инциденты): что сломалось и когда, что уже восстановилось. По умолчанию открытые; days охватывает закрытые за период.",
        "inputSchema": map[string]any{"type": "object", "properties": map[string]any{
            "status": map[string]any{"type": "string", "enum": []string{"open", "all"}},
            "days":   map[string]any{"type": "integer", "minimum": 1, "maximum": 90},
        }},
    },
    // list_hosts, uptime_summary — аналогично
}

Описания, кстати, — это UX. Их читает не человек, а модель, и от формулировки зависит, догадается ли она позвать uptime_summary на вопрос «как дела у сайтов за месяц». Мы пишем их как микро-документацию: что вернётся и когда это уместно.

Сам хендлер. Первое и принципиальное: авторизация до парсинга тела. Неавторизованный клиент не должен узнать о сервере ничего — даже формат его ошибок:

// POST /mcp — единственная точка входа MCP.
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
    const prefix = "Bearer "
    authz := r.Header.Get("Authorization")
    if !strings.HasPrefix(authz, prefix) {
        w.Header().Set("WWW-Authenticate", `Bearer realm="pingvera-mcp"`)
        writeErr(w, http.StatusUnauthorized, "нужен API-токен со скоупом read:monitoring")
        return
    }
    tok, err := s.lookupAPIToken(r.Context(), authz[len(prefix):])
    if err != nil {
        writeErr(w, http.StatusUnauthorized, "invalid token")
        return
    }
    if !hasScope(tok.scopes, ScopeReadMonitoring) {
        writeErr(w, http.StatusForbidden, "missing scope "+ScopeReadMonitoring)
        return
    }

    var req rpcReq
    if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
        writeRPC(w, nil, nil, &rpcErr{Code: -32700, Message: "parse error"})
        return
    }

    switch req.Method {
    case "initialize":
        writeRPC(w, req.ID, map[string]any{
            "protocolVersion": mcpProtocolVersion, // "2025-03-26"
            "capabilities":    map[string]any{"tools": map[string]any{}},
            "serverInfo":      map[string]any{"name": "pingvera", "version": "1.0.0"},
        }, nil)
    case "notifications/initialized", "notifications/cancelled":
        w.WriteHeader(http.StatusAccepted) // уведомления без ответа
    case "ping":
        writeRPC(w, req.ID, map[string]any{}, nil)
    case "tools/list":
        writeRPC(w, req.ID, map[string]any{"tools": mcpTools}, nil)
    case "tools/call":
        // ... ниже
    default:
        writeRPC(w, req.ID, nil, &rpcErr{Code: -32601, Message: "method not found: " + req.Method})
    }
}

http.MaxBytesReader на 64 КиБ — потому что легитимный запрос MCP это пара сотен байт, а анонимный… стоп, анонимных тут уже не бывает, auth выше. Но и авторизованному клиенту незачем присылать мегабайты.

Самое неочевидное место спеки — обработка ошибок в tools/call. Интуиция говорит: инструмент упал — верни JSON-RPC error. Спека говорит: нет. Ошибка инструмента — это успешный result с флагом isError, потому что её адресат — модель, а не транспорт. LLM прочитает текст ошибки и отреагирует осмысленно: переформулирует запрос, скажет пользователю «телеметрия временно недоступна». JSON-RPC-ошибки же зарезервированы для проблем протокола — их клиентская обвязка обычно до модели не доносит:

case "tools/call":
    var p struct {
        Name string          `json:"name"`
        Args json.RawMessage `json:"arguments"`
    }
    if err := json.Unmarshal(req.Params, &p); err != nil {
        writeRPC(w, req.ID, nil, &rpcErr{Code: -32602, Message: "bad params"})
        return
    }
    out, terr := s.mcpCallTool(r.Context(), tok.orgID, p.Name, p.Args)
    if terr != nil {
        // Ошибка инструмента — result с isError (спека MCP), не RPC-ошибка.
        writeRPC(w, req.ID, map[string]any{
            "content": []map[string]any{{"type": "text", "text": terr.Error()}},
            "isError": true,
        }, nil)
        return
    }
    writeRPC(w, req.ID, map[string]any{
        "content": []map[string]any{{"type": "text", "text": out}},
    }, nil)

Диспетчер тулз скучен, и это комплимент. Аргументы парсим снисходительно (_ = json.Unmarshal) — модель иногда шлёт лишние поля или мусор, ронять вызов из-за этого глупо, дефолты надёжнее. Заметьте tok.orgID в сигнатуре: тенант приходит из токена и прошивается в каждый SQL-запрос, у инструмента физически нет способа заглянуть в чужую организацию:

func (s *Server) mcpCallTool(ctx context.Context, orgID uint64, name string, args json.RawMessage) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, queryTimeout)
    defer cancel()
    switch name {
    case "list_monitors":
        return s.mcpListMonitors(ctx, orgID)
    case "list_incidents":
        var a struct {
            Status string `json:"status"`
            Days   int    `json:"days"`
        }
        _ = json.Unmarshal(args, &a)
        if a.Days < 1 || a.Days > 90 {
            a.Days = 7
        }
        return s.mcpListIncidents(ctx, orgID, a.Status == "all", a.Days)
    // list_hosts, uptime_summary...
    default:
        return "", fmt.Errorf("неизвестный инструмент %q", name)
    }
}

Сами инструменты — обычные SELECT-ы, возвращающие indent-нутый JSON строкой (модель парсит его без проблем, а человеку в логах читаемо). Один нюанс из uptime_summary: у нас телеметрия лежит в ClickHouse, и его недоступность по нашим инвариантам не валит основной сервис. Инструмент это честно транслирует — как раз через тот самый isError:

func (s *Server) mcpUptime(ctx context.Context, orgID uint64, days int) (string, error) {
    if !s.chReady() {
        return "", fmt.Errorf("хранилище телеметрии временно недоступно — аптайм не рассчитать (мониторинг и события работают)")
    }
    // ...
}

Модель, получив такое, отвечает пользователю по-человечески, а не пятисоткой.

Регистрация — одна строка в роутере, stdlib net/http:

mux.HandleFunc("POST /mcp", s.handleMCP)

Безопасность как свойство конструкции

Тут у нас позиция, которую хочется проговорить отдельно, потому что она отличается от «мы добавили гардрейлы».

Write-инструментов нет. Вообще. Не потому что не успели — потому что в продукте нечего писать. Наша модель мониторинга механическая: инцидент — это запись «упало → само поднялось», без ручных resolve, без «принять в работу». А агент на серверах клиентов строго односторонний: он шлёт данные нам, и hub физически не умеет ему ничего командовать — ни remote exec, ни автообновлений. Следствие для MCP приятное: утёкший токен даёт злоумышленнику (или сошедшей с ума LLM) ровно ничего, кроме чтения статусов. Нельзя удалить монитор, закрыть инцидент, что-то запустить на хосте. Безопасность здесь — не middleware, а отсутствие поверхности.

Это, на наш взгляд, правильный порядок рассуждений для любого MCP-сервера: сначала спросить «какой минимальный доступ решает задачу пользователя?», и только потом — «как его защитить». Read-only решает 90% сценариев «спросить ассистента о продукте».

Поверх этого — обычная гигиена токенов, общая со всем нашим API Pingvera:

// Скоупы валидируются по закрытому списку: опечатка в скоупе должна
// падать при создании токена, а не молча давать токен-пустышку.
var validScopes = map[string]bool{
    "read:metrics":    true,
    "read:hosts":      true,
    "read:monitoring": true,
    "write:monitors":  true, // задел, пока не принимается ни одной ручкой
}

Секрет токена показывается один раз при создании, в базе — только sha256. Отзыв мгновенный: никакого кэша токенов, lookupAPIToken — один индексный SELECT на запрос (revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())). Для наших нагрузок это честная цена; кэш появился бы вместе с окном, в котором отозванный токен ещё жив, — и вот это для «токена, лежащего в конфиге чужого ассистента» уже неприемлемо.

И здесь же — обязательная плата за «SELECT на каждый запрос»: rate-limit на эндпоинт. Раз каждый невалидный Bearer всё равно бьёт в базу, /mcp без лимита — это приглашение перебирать токены за наш счёт. У нас он живёт за nginx-зоной (легитимный ассистент делает единицы-десятки вызовов в минуту, так что потолок щедрый, но перебор отсекает). Если будете повторять паттерн «auth до парсинга + SELECT на запрос» — не забудьте лимит перед ним, иначе красивая ранняя авторизация оборачивается дешёвым DoS-вектором на вашу БД.

Подключение

Claude Code — одна команда:

claude mcp add --transport http pingvera https://app.<ваш-домен>/mcp \
  --header "Authorization: Bearer pv_..."

Cursor и прочие клиенты с JSON-конфигом:

{
  "mcpServers": {
    "pingvera": {
      "url": "https://app.<ваш-домен>/mcp",
      "headers": { "Authorization": "Bearer pv_..." }
    }
  }
}

После этого «что сейчас в сбое?» в чате ассистента превращается в tools/call list_incidents и ответ человеческим языком.

А GigaChat и YandexGPT? MCP они (пока) не говорят, но им он и не нужен: у обоих есть function calling, а функции удобно описывать OpenAPI-спекой. Мы отдаём её рукописной константой на GET /api/v1/openapi.json — без auth (это документация), с кэшем и CORS:

func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.Header().Set("Cache-Control", "public, max-age=3600")
    w.Header().Set("Access-Control-Allow-Origin", "*") // спека — публичный документ
    _, _ = w.Write([]byte(openAPISpec))
}

Да, спека — string-константа в Go-файле. Эндпоинтов пять, менять их — событие; генератор спек был бы автоматизацией ради автоматизации. Важная честность: описываем только токенные эндпоинты, сессионный API дашборда в спеку не входит — он внутренний, и обещать его стабильность мы не хотим. Итого одна и та же линия данных обслуживает и MCP-клиентов, и function calling: MCP-тулзы и REST-ручки ходят в одни и те же запросы.

Грабли и наблюдения

Мы прогнали curl-ом все методы до подключения живого клиента — и правильно сделали, потому что пара мест в спеке контринтуитивна:

  1. Notification — это запрос без ответа. На notifications/initialized нельзя возвращать JSON-RPC-конверт (там нет id, отвечать некому) — только пустой 202 Accepted. Первый вариант кода честно отвечал {"jsonrpc":"2.0","id":null,...} — клиенты такое переживают по-разному, спека такое запрещает однозначно.

  2. Parse error — единственный случай, когда id по-настоящему null. Если тело не распарсилось, id взять неоткуда; JSON-RPC предписывает вернуть ошибку -32700 с "id": null. Приятный бонус json.RawMessage: nil сериализуется в null сам.

  3. isError — это про доверие модели. Пока ошибки тулз возвращались как RPC-ошибки, ассистент на упавший ClickHouse отвечал «инструмент недоступен» и сдавался. С isError и человеческим текстом — пересказывает причину и предлагает посмотреть события вместо аптайма. Одно и то же событие, совершенно разное поведение.

  4. Версию протокола лучше прибить константой (2025-03-26) и осознанно обновлять по changelog-у, чем эхом возвращать то, что прислал клиент: вы декларируете то, что реально реализовали.

  5. Никакого кэша токенов — см. выше. Соблазн был.

Итого

~200 строк Go в существующем монолите, ноль новых зависимостей, ноль новых процессов в деплое. Один вечер: половина — код, половина — чтение спеки и curl. Взамен — продукт, к которому из коробки подключается любой MCP-клиент, плюс OpenAPI для function calling тем моделям, что MCP не говорят.

Главный вывод даже не про MCP. Протокол оказался достаточно простым, чтобы его минимальная честная реализация была меньше, чем обвязка любого SDK. Если у вас монолит и read-only сценарий — начните с одного хендлера. Отдельный сервис и стриминг успеете добавить, когда (если) появятся инструменты, которым они нужны. У нас пока не появились.