Все статьи
Артемий Мазаев·· 11 мин чтения

MCP изнутри: протокол по спекам — от JSON-RPC до Tasks 🔌📡

Model Context Protocol по спецификации: архитектура, транспорты, жизненный цикл, эволюция по 4 версиям (2024-11-05 → 2025-11-25) и что нового в Tasks.

Ну чё, малютки, все уже прикрутили MCP-серверы к своему агенту, а что там внутри — знают единицы. Разберём протокол не «на пальцах», а по официальной спецификации: сырые JSON-RPC сообщения, четыре ревизии стандарта с 2024 по 2025 год, и что именно принесла последняя версия 2025-11-25 — включая свежую экспериментальную фичу Tasks, о которой ещё мало кто писал.

🔥Суть за 10 секунд

MCP — открытый протокол на JSON-RPC, который стандартизирует связь между LLM-приложением и внешними тулами/данными. Без него у тебя N агентов × M источников данных = N×M интеграций, каждая своя. С ним — N+M: каждый агент говорит на одном протоколе, каждый источник данных реализует его один раз. Та же идея, что у LSP для языковых серверов, только вместо IDE — LLM.


Зачем это вообще нужно

До MCP каждый агент, который хотел читать файлы, ходить в базу или дёргать API стороннего сервиса, писал под это свою интеграцию. Захотел добавить новый источник данных — переписывай код агента. Захотел использовать тот же источник в другом агенте — переписывай заново. Классическая проблема N×M: N агентов, M источников, N×M кусков клеевого кода.

MCP разрывает эту связь ровно так же, как в своё время Language Server Protocol разорвал связь между IDE и языками программирования: вместо того чтобы каждая IDE писала поддержку каждого языка, появился один протокол — язык реализует его один раз, и с ним работает любая IDE. MCP делает то же самое для LLM-агентов и источников контекста: сервер реализует протокол один раз, и его подключает любой MCP-совместимый клиент — Claude, свой агент на харнесе (я писал об этом отдельно), да хоть llama.cpp.


Архитектура: Host, Client, Server

Три роли, и путать их — типичная ошибка новичков:

HostLLM-приложение целиком — Claude Desktop, твой агент, IDE. Управляет клиентами, хранит контекст разговора, принимает решения об авторизации
ClientКоннектор внутри host'а. На каждый сервер — свой клиент, 1:1. Клиент не видит других клиентов и не видит остальной разговор
ServerОтдельный процесс или сервис, который отдаёт конкретную возможность: файлы, git, база данных, внешний API. Может быть локальным (subprocess) или удалённым

Ключевой принцип дизайна, который часто упускают: сервер не должен видеть весь разговор и не должен видеть другие серверы. Host агрегирует контекст из разных клиентов, но каждый сервер получает только то, что нужно ему для работы. Это осознанная граница безопасности, а не недосмотр.

Host управляет несколькими клиентами, у каждого — своё изолированное соединение 1:1 с сервером. Серверы бывают локальными (subprocess на той же машине) и удалёнными (через сеть).

Серверы предоставляют три вида примитивов, клиенты — три других:

СторонаПримитивЧто это
СерверToolsФункции, которые модель может вызвать
СерверResourcesДанные и контент для модели или пользователя
СерверPromptsШаблонные сообщения и готовые воркфлоу
КлиентSamplingСервер может попросить клиента сходить к LLM самому
КлиентRootsСервер узнаёт, в каких файловых/URI границах ему разрешено работать
КлиентElicitationСервер может запросить у пользователя дополнительные данные посреди работы

Транспорт: как сообщения реально едут

Под капотом — JSON-RPC 2.0, везде в UTF-8. Спецификация определяет два стандартных транспорта:

stdio — клиент запускает сервер как дочерний процесс. Сообщения идут через stdin/stdout, построчно, без embedded-переносов строк. stderr сервер может использовать для любых логов — клиент не обязан считать это ошибкой. Самый простой вариант для локальных серверов: файлы, git, локальные тулы.

Streamable HTTP — сервер живёт отдельным процессом и обслуживает множество клиентов через один HTTP-эндпоинт, который принимает и POST, и GET. Опционально может стримить через Server-Sent Events. Этот транспорт заменил собой более ранний HTTP+SSE (об этом — в разделе версий).

⚠️⚠️ DNS rebinding — реальная угроза для Streamable HTTP

Спецификация прямо требует: сервер обязан валидировать заголовок Origin на каждом входящем соединении и отвечать 403 Forbidden на невалидный, а локальный сервер должен биндиться на 127.0.0.1, а не на 0.0.0.0. Без этого сайт в браузере жертвы может через DNS rebinding обратиться к твоему локальному MCP-серверу от чужого домена.

У Streamable HTTP есть сессии (MCP-Session-Id в заголовке), resumability через Last-Event-ID для восстановления после разрыва, и обязательный с версии 2025-06-18 заголовок MCP-Protocol-Version на каждом запросе — сервер по нему понимает, с какой версией протокола говорит клиент.


Жизненный цикл сессии

Три фазы: Initialization (договорились о версии и возможностях) → Operation (обычная работа) → Shutdown (закрыли соединение). Инициализация — обязательно первое взаимодействие, и до её завершения ни одна сторона не шлёт ничего, кроме ping.

Смотри на реальном обмене сообщениями — от рукопожатия до первого вызова тула:

🤝 Рукопожатие и первый вызов тула
Реальные (сокращённые) JSON-RPC сообщения одной MCP-сессии: от initialize до результата tools/call. Заголовки jsonrpc: "2.0" и id опущены для читаемости.
💻 Клиентinitialize
Клиент открывает сессию: шлёт версию протокола, свои capabilities (что умеет клиент) и информацию о себе
{
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": {}
    },
    "clientInfo": {
      "name": "ExampleClient",
      "version": "1.0.0"
    }
  }
}
Шаг 1/7

Обрати внимание на version negotiation: клиент шлёт свою последнюю поддерживаемую версию, и если сервер её не поддерживает — отвечает своей последней. Если клиент не поддерживает и версию сервера — он обязан отключиться. Никакого «подгоним на лету», версия одна на всю сессию, и с 2025-06-18 она же едет в заголовке MCP-Protocol-Version на каждом HTTP-запросе после инициализации.

Capabilities работают так же честно: если сервер не заявил tools.listChanged, он не имеет права слать уведомление о смене списка тулов — клиент на такое даже не подпишется. Заявленных, но не реализованных возможностей быть не должно.


Примитивы сервера: во что упирается модель

Tools — самый используемый примитив. Каждый тул — это name, description и inputSchema (обычная JSON Schema). Модель узнаёт о тулах через tools/list, вызывает через tools/call. Важный нюанс из спеки: результат может быть structured content (типизированный JSON в поле structuredContent, который сервер обязан провалидировать по заявленной outputSchema) или unstructured — текст, картинка, аудио, или resource_link — ссылка на ресурс, которую клиент может дозапросить отдельно.

{
  "name": "get_weather_data",
  "inputSchema": { "type": "object", "properties": { "location": { "type": "string" } }, "required": ["location"] },
  "outputSchema": {
    "type": "object",
    "properties": { "temperature": { "type": "number" }, "conditions": { "type": "string" } },
    "required": ["temperature", "conditions"]
  }
}

Отдельно спецификация различает два вида ошибок тула. Protocol Errors — стандартная JSON-RPC ошибка: неизвестный тул, битый запрос. Tool Execution ErrorsisError: true прямо в результате: невалидная дата, лимит API, бизнес-логика. Разница принципиальная: execution errors модель может использовать для самокоррекции и повторной попытки с другими аргументами, protocol errors — почти никогда.

Мостик к другой моей статье

Всё это — inputSchema, валидация, structured content — работает благодаря той же механике, что я разбирал в статье про structured outputs: constrained decoding гарантирует, что модель физически не сможет сгенерировать вызов тула, не проходящий по схеме.

Resources — данные, которые сервер отдаёт по URI: файл, строка в базе, страница документа. В отличие от тулов, ресурсы application-controlled или user-controlled, а не model-controlled — обычно их выбирает пользователь или сама логика хоста, а не модель по своей инициативе.

Prompts — готовые шаблоны сообщений, которые сервер предлагает пользователю: не модель их вызывает, а человек выбирает из меню («сделай code review», «резюмируй тред»).


Примитивы клиента: что сервер может попросить у хоста

Sampling — сервер, а не клиент, может инициировать обращение к LLM. Звучит странно, пока не увидишь пример: агент-оркестратор дергает MCP-сервер, а тот сам просит модель сформулировать промежуточный вывод — без того, чтобы городить собственную интеграцию с LLM-провайдером внутри сервера. Пользователь при этом обязан явно одобрить: будет ли сэмплинг вообще, какой промпт уйдёт, что сервер увидит в ответ.

Roots — сервер спрашивает клиента о границах: в какой директории или по каким URI ему разрешено работать. Не «дай мне доступ ко всей файловой системе», а «скажи, где мои границы».

Elicitation (с версии 2025-06-18) — сервер посреди выполнения запроса может попросить у пользователя дополнительные данные: недостающий параметр, подтверждение, выбор из списка. До этого серверу оставалось либо гадать, либо падать с ошибкой валидации.


История версий: четыре ревизии

MCP версионируется не semver’ом, а датой последнего обратно несовместимого изменения — YYYY-MM-DD. Если изменение обратно совместимо, номер версии не растёт вообще. На данный момент таких ревизий четыре:

🕰️ Четыре версии спецификации
MCP версионируется датой ревизии, а не semver: номер меняется только при обратно несовместимых изменениях. Кликай по вехам.
Tasks, тонкая авторизация, зрелая элиситация
Tasks (experimental) — долгие запросы с поллингом вместо висящего соединения
OpenID Connect Discovery поверх OAuth-слоя авторизации
Incremental scope consent — сервер просит доступ по мере необходимости, не всё сразу
Tool calling внутри sampling — сервер может дать модели тулы прямо в запросе к LLM
URL-элиситация, стандартизированный ElicitResult, иконки у тулов/ресурсов/промптов

Заметил забавную деталь в таймлайне? JSON-RPC batching добавили в марте 2025 и выпилили обратно в июне того же года — редкий случай, когда фичу в спецификации прожили одну версию и отменили. А вот elicitation, structured tool output и классификация серверов как OAuth Resource Server — те изменения, которые определили современный MCP таким, какой он есть.


Что нового в последней версии (2025-11-25)

Текущая ревизия принесла россыпь точечных улучшений — OpenID Connect Discovery поверх авторизации, иконки у тулов и ресурсов, incremental scope consent (сервер запрашивает доступ по мере надобности, а не всё разом), tool calling прямо внутри sampling-запроса, стандартизированный ElicitResult с поддержкой multi-select enum’ов. Но главное новое имя — Tasks, и оно пока экспериментальное.

Проблема, которую решают Tasks: что делать с запросом, который выполняется минутами или часами? Держать HTTP-соединение открытым — плохая идея: таймауты, разрывы, дорогая инфраструктура. Tasks превращают долгий запрос в durable state machine: вместо результата клиент сразу получает taskId, дальше поллит статус через tasks/get и забирает результат через tasks/result, когда тот готов.

⏱️ Tasks: state machine долгого запроса
Сценарий: клиент запускает тул как задачу, а сервер по пути упирается в необходимость спросить пользователя — задача не падает, а временно встаёт на паузу.
working
input_required
completed
✖️failed
cancelled
tools/call с полем task → CreateTaskResult
Клиент пометил вызов тула как задачу. Сервер сразу отвечает task-объектом, а не результатом — работа идёт в фоне.
Шаг 1/5

Обрати внимание на статус input_required — задача не падает и не отменяется, если серверу вдруг понадобились данные от пользователя (та самая elicitation, но теперь внутри уже запущенной фоновой задачи). Это ровно тот сценарий, который ломал долгие агентные вызовы раньше: либо ты держишь соединение и надеешься, что не разорвётся, либо теряешь контекст на полпути.

💡Зачем это агентам конкретно

Пока задача в статусе working, host может вернуть управление модели и дать ей заняться другими делами — сервер даже может подсказать промежуточный ответ через служебное поле _meta. Для агентного харнеса это готовый примитив параллелизма: запустил дорогой тул как задачу, не блокируясь пошёл разбирать остальные запросы.


Авторизация: как менялась по версиям

Отдельная большая тема, но эволюция показательна одной таблицей:

ВерсияЧто с авторизацией
2024-11-05Её нет вообще — только локальные доверенные серверы
2025-03-26Полноценный фреймворк на OAuth 2.1 — MCP можно безопасно выставлять наружу
2025-06-18Серверы формально классифицированы как OAuth Resource Server, добавлен discovery, обязательны Resource Indicators (RFC 8707) против кражи токенов
2025-11-25OpenID Connect Discovery, incremental scope consent через WWW-Authenticate, OAuth Client ID Metadata Documents вместо ручной регистрации клиента

Путь понятный: от «доверяем всему локальному» к полноценной модели с дискавери, скоупами и метаданными — протокол взрослел вместе с тем, как MCP-серверы стали разворачивать не только на localhost.

OAuth в MCP защищает канал между клиентом и сервером, но не то, что летит дальше — к самому LLM-провайдеру. Для этого нужен отдельный слой: я разбирал guardrails-llm-filter — прокси, который маскирует PII и секреты по пути к модели, прежде чем они вообще покинут твою инфраструктуру.


Как написать свой сервер

Минимальный сервер на Python SDK — один тул, stdio-транспорт:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather-server")

@mcp.tool()
def get_weather(location: str) -> str:
    """Get current weather for a location."""
    return f"{location}: 18°C, облачно"

if __name__ == "__main__":
    mcp.run(transport="stdio")

Декоратор сам построит inputSchema из тайпхинтов и докстринг превратит в description — всё, что было в сыром JSON-RPC выше, SDK берёт на себя. Для Streamable HTTP меняется одна строка — transport="streamable-http".


Кто уже умеет

MCP поддерживают Claude (Desktop и API через MCP connector), большинство современных агентных харнесов, и — свежая новость — llama.cpp недавно завёз полноценную поддержку MCP прямо в llama-server, так что теперь и локальный опенсорсный стек может подключать те же серверы, что и облачные модели. Экосистема серверов (файлы, git, базы данных, поисковые системы, десятки сторонних сервисов) растёт быстрее, чем успевают выходить новые ревизии спеки.


TL;DR

1Зачем: сводит проблему N агентов × M источников данных к N+M — как LSP для языковых серверов, только для LLM
2Архитектура: Host управляет клиентами 1:1 с серверами; серверы не видят друг друга и не видят весь разговор
3Транспорт: JSON-RPC 2.0 поверх stdio (локально) или Streamable HTTP (сеть, с обязательной защитой от DNS rebinding)
4Примитивы: сервер даёт Tools/Resources/Prompts, клиент — Sampling/Roots/Elicitation
54 версии: 2024-11-05 (запуск) → 2025-03-26 (OAuth, Streamable HTTP) → 2025-06-18 (elicitation, structured output) → 2025-11-25 (Tasks, тонкая авторизация)
6Главное новое: Tasks — долгие запросы без висящих соединений, с поддержкой паузы на input_required

MCP — редкий случай, когда за скучной аббревиатурой стоит действительно продуманный протокол: почитаешь спеку — и видно, что каждое ограничение (серверы не видят разговор, явное согласие на сэмплинг, обязательная валидация Origin) появилось не для галочки, а потому что кто-то уже наступил на эти грабли. 🫡