Как я собрал MCP-коннектор для Claude за вечер: FastMCP, Streamable HTTP и грабли деплоя

от автора

MCP (Model Context Protocol) называют «USB-C для ИИ-агентов»: один протокол, и к модели подключаются десятки готовых интеграций без костылей. Звучит красиво, но настоящее понимание приходит только когда соберёшь сервер руками — где протокол реально экономит, а где придётся повозиться, видно лишь на практике. Я собрал свой за вечер и рассказываю по шагам.

Коннектор отдаёт Claude мою базу знаний — словарь из 90 ИИ-терминов и блок частых вопросов. Спрашиваешь в диалоге «что такое RAG» — и Claude достаёт определение из моей базы, со ссылкой на источник. Дальше — стек, код, деплой за nginx и три грабли, на которых я залип.

Зачем бизнесу свой MCP-сервер

Чтобы данные и инструменты жили прямо внутри ИИ-ассистента, там, где идёт работа, без выгрузки в отдельную систему. Что я вижу у клиентов:

  • внутренний справочник, доступный в Claude без копипасты;

  • доступ к CRM или таблицам «только на чтение» — аналитика прямо в диалоге;

  • каталог, прайс, документация — то, что сотрудник иначе ищет руками.

У меня случай простой — база знаний по ИИ. На ней и покажу.

Стек: FastMCP и Streamable HTTP

Сервер на Python, фреймворк — FastMCP (версия 2.14.7 на момент сборки). Главное решение тут — транспорт. Старый SSE в спецификации MCP уже депрекейтнут, живой вариант — Streamable HTTP. Хотите удалённый коннектор, который подключается по URL, — берите его. В FastMCP это одна строка:

mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)

Endpoint — /mcp, и именно без слэша. /mcp/ отдаёт 307-редирект, а голый GET ловит 406 (нужен заголовок Accept: text/event-stream). Эта мелочь ещё аукнется на healthcheck.

Инструменты: тонкие обёртки с аннотациями

Инструментов четыре, все — только на чтение. Вот регистрация одного:

@mcp.tool(    annotations={        "readOnlyHint": True,        "destructiveHint": False,        "openWorldHint": False,    })def search_glossary(query: str, limit: int = 8) -> str:    """    Search the AI glossary (90 terms) by keyword.    Returns matching terms with short definitions and source URL.    """    ...

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

И ещё мелочь, которая бережёт нервы: логику каждого инструмента я вынес в отдельную функцию, а @mcp.tool оставил тонкой обёрткой. Так тесты гоняет обычный pytest, без подъёма всего MCP-стека.

Данные: грузим один раз на старте

База — статичные JSON (90 терминов плюс 21 вопрос), выгрузил их из генераторов своего сайта. Файлы крошечные, поэтому читаю их в память один раз при старте и там же строю индекс поиска. Никаких обращений к диску на каждый вызов:

# Load data once on import (FastMCP calls tools after startup)load_data()

В каждом ответе — атрибуция и ссылка на источник. Так и пользователю честнее (видно, откуда определение), и мне полезно: ответы ведут на сайт.

Деплой: Docker за nginx и три грабли

Сервер крутится в Docker на обычном VPS, наружу торчит через nginx с TLS. Тут я собрал три неочевидные вещи.

Грабля 1. Docker: «all predefined address pools have been fully subnetted». На сервере с кучей compose-проектов у Docker кончился пул адресов под новые сети. Лечится без боли для соседних контейнеров — сажаем контейнер на дефолтный bridge вместо отдельной сети:

services:  zs-mcp:    network_mode: bridge    ports:      - "127.0.0.1:8096:8000"   # наружу только через nginx

Грабля 2. Healthcheck, который вечно «unhealthy». Первый healthcheck дёргал GET /mcp/ через urllib. Но /mcp/ отдаёт 307, а /mcp без SSE-заголовка — 406. urllib видит ответ мимо 200 и считает контейнер больным. Решение — проверять только то, что порт слушает, мимо протокола:

healthcheck:  test: ["CMD", "python", "-c",    "import socket; socket.create_connection(('localhost', 8000), timeout=5).close()"]

Грабля 3. nginx буферизует SSE. Streamable HTTP отвечает потоком (event: message). nginx по умолчанию буферизует ответ апстрима — и поток залипает. В локации обязательно гасим буферизацию:

location / {    proxy_pass         http://127.0.0.1:8096;    proxy_http_version 1.1;    proxy_set_header   Connection "";    proxy_buffering    off;    proxy_read_timeout 3600s;    chunked_transfer_encoding on;}

TLS — обычный certbot на поддомен. После этого сервер живёт на https://mcp.zinin-shturbin.com/mcp.

Проверка: рукопожатие руками

Прежде чем тащить коннектор в Claude, я проверяю сервер вживую обычным curl. Сначала initialize, ловлю mcp-session-id из заголовка:

curl -sD - -X POST https://mcp.zinin-shturbin.com/mcp \  -H 'Content-Type: application/json' \  -H 'Accept: application/json, text/event-stream' \  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",       "params":{"protocolVersion":"2024-11-05","capabilities":{},                 "clientInfo":{"name":"curl","version":"1"}}}'

В ответе — serverInfo с именем сервера, в заголовках — mcp-session-id. С этим session-id дальше дёргаешь tools/list и живой tools/call. Когда search_glossary по запросу «MCP» вернул определение со ссылкой, стало ясно: вся цепочка (TLS → nginx → контейнер → streamable-http) держит.

Подключение в Claude

В Claude (claude.ai или Claude Desktop): Settings → Connectors → Add custom connector → имя и URL https://mcp.zinin-shturbin.com/mcp. Авторизация тут лишняя (данные публичные, чтение) — подключается одним движением.

Что в итоге

За вечер — рабочий удалённый MCP-сервер: FastMCP плюс Streamable HTTP, четыре read-only инструмента с аннотациями, Docker за nginx с TLS. Грабли, которые держу в голове на будущее:

  • endpoint /mcp без слэша;

  • аннотации на каждом инструменте сразу;

  • network_mode: bridge, когда у Docker кончились подсети;

  • healthcheck через TCP-connect вместо HTTP-запроса к /mcp;

  • proxy_buffering off под SSE-поток.

Начинайте с самого узкого полезного сервера — одна-две функции на чтение. Доводите цепочку до рабочего состояния и только потом расширяйте: так сразу видно, где протокол реально экономит, а где он лишний.

Сам коннектор отдаёт мой словарь ИИ-терминов — он открыт, заходите полистать. Такие штуки я собираю с фаундерами в своей практике обучения работе с ИИ — теми, кому ИИ нужен живым инструментом внутри команды. Ловили грабли, которых тут нет, — пишите в комментарии, добавлю.

ссылка на оригинал статьи https://habr.com/ru/articles/1042470/