Три уровня абстракции: зачем они разделены
MCP — это не монолит. Протокол сознательно разделён на три независимых уровня, которые можно заменять и комбинировать независимо друг от друга. Это ключевое архитектурное решение, которое нужно понять, прежде чем смотреть на код.
Функции, которые делают что-то полезное: читают файл, делают SQL-запрос, вызывают API. Этот уровень не знает ничего об MCP — просто Python-функции. Ты регистрируешь их через декораторы, и они становятся инструментами.
async def read_file(path: str) → str: ...
Реестр инструментов, маршрутизация запросов, сериализация/десериализация,
capability negotiation. Этот уровень реализует спецификацию MCP:
знает про tools/list, tools/call, initialize.
Не знает, как именно приходят байты — только что с ними делать.
Отвечает только за одно: принять строку JSON от протокольного уровня и доставить её на другую сторону. Не интерпретирует содержимое. Два встроенных транспорта: stdio (через процессные потоки) и HTTP+SSE (через сеть). Можно написать свой — например, WebSocket или Unix socket.
StdioServerTransport, SseServerTransport, WebSocketTransportРазделение критично для переиспользования: одна и та же серверная логика работает через любой транспорт без изменений. Переключиться с stdio на HTTP+SSE — это изменить одну строку запуска, не трогая реестр инструментов.
Анатомия MCP-сервера изнутри
Когда ты пишешь @server.list_tools(), происходит
конкретная цепочка событий. Разберём, что именно делает SDK под капотом.
Реестр инструментов
Декоратор @server.list_tools() не вызывает функцию — он регистрирует её
как обработчик для метода tools/list. Аналогично @server.call_tool()
регистрирует диспетчер для tools/call.
Внутри Server хранится словарь request_handlers:
"""
Упрощённая модель того, что делает mcp.Server под капотом.
Реальный код SDK сложнее, но принцип такой.
"""
from typing import Callable, Awaitable, Any
class MCPServerCore:
def __init__(self, name: str):
self.name = name
# Словарь: метод → обработчик
self._handlers: dict[str, Callable] = {}
# Реестр инструментов: имя → (schema, handler_fn)
self._tools: dict[str, tuple[dict, Callable]] = {}
# ── Декораторы для регистрации ──
def list_tools(self):
"""Регистрирует функцию как обработчик tools/list."""
def decorator(fn: Callable) -> Callable:
self._handlers["tools/list"] = fn
return fn
return decorator
def call_tool(self):
"""Регистрирует диспетчер для tools/call."""
def decorator(fn: Callable) -> Callable:
self._handlers["tools/call"] = fn
return fn
return decorator
# ── Роутер запросов ──
async def dispatch(self, method: str, params: dict) -> Any:
"""Получает JSON-RPC метод и направляет к нужному обработчику."""
handler = self._handlers.get(method)
if handler is None:
raise MethodNotFoundError(f"Unknown method: {method}")
if method == "tools/call":
# Для tools/call SDK передаёт name и arguments из params
return await handler(
name=params["name"],
arguments=params.get("arguments", {})
)
else:
return await handler()
# ── Инициализация (handshake) ──
async def handle_initialize(self, client_info: dict) -> dict:
"""Отвечает на initialize: возвращаем свои capabilities."""
return {
"protocolVersion": "2024-11-05",
"serverInfo": {"name": self.name, "version": "1.0.0"},
"capabilities": {
"tools": {}, # поддерживаем tools
"resources": {}, # поддерживаем resources
}
}
Главное для понимания: сервер — это роутер. Он получает строку с именем метода и направляет её к зарегистрированному обработчику. Никакой магии — просто словарь функций.
Основной цикл сервера
После старта сервер входит в бесконечный цикл чтения сообщений из транспорта. Каждое сообщение — это строка JSON. Сервер парсит её, вызывает роутер, сериализует ответ и отправляет обратно:
import asyncio, json
async def server_run_loop(read_stream, write_stream, server: MCPServerCore):
"""
Упрощённая версия основного цикла MCP-сервера.
read_stream: откуда читаем (stdin или HTTP body)
write_stream: куда пишем (stdout или SSE stream)
"""
async for raw_line in read_stream:
# Парсим JSON-RPC запрос
try:
request = json.loads(raw_line)
except json.JSONDecodeError:
continue # игнорируем невалидный JSON
req_id = request.get("id")
method = request.get("method", "")
params = request.get("params", {})
# Специальный случай: notifications (уведомления без id)
# Они не требуют ответа
if req_id is None:
if method == "notifications/initialized":
# клиент подтвердил инициализацию
pass
continue
# Вызываем роутер и формируем ответ
try:
if method == "initialize":
result = await server.handle_initialize(params)
else:
result = await server.dispatch(method, params)
response = {
"jsonrpc": "2.0",
"id": req_id,
"result": result
}
except Exception as e:
# JSON-RPC error response
response = {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32603, # Internal error
"message": str(e)
}
}
# Отправляем ответ
line = json.dumps(response, ensure_ascii=False) + "\n"
await write_stream.write(line.encode())
Анатомия MCP-клиента
Клиент сложнее сервера: он должен сопоставлять отправленные запросы с пришедшими ответами, кэшировать результаты discovery и управлять временем жизни соединения.
Ключевая сложность: запросы и ответы асинхронны.
Клиент отправил запрос с id: 1, продолжает работу,
и только потом получает ответ с id: 1.
Нужно отобразить ответ на правильный ожидающий вызов.
import asyncio, json, itertools
from typing import Any
class MCPClientCore:
"""Упрощённая модель MCP-клиента."""
def __init__(self):
self._id_counter = itertools.count(1)
# Карта id → asyncio.Future (ожидающие ответа)
self._pending: dict[int, asyncio.Future] = {}
# Кэш инструментов (заполняется при discovery)
self._tools_cache: list | None = None
# Транспорт (устанавливается при подключении)
self._write_stream = None
async def send_request(self, method: str, params: dict) -> Any:
"""
Отправляет JSON-RPC запрос и ЖДЁТ ответ.
Неблокирующий: другие корутины могут работать пока ждём.
"""
req_id = next(self._id_counter)
request = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params
}
# Создаём Future — будет разрешён когда придёт ответ
future: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[req_id] = future
# Отправляем запрос
line = json.dumps(request, ensure_ascii=False) + "\n"
await self._write_stream.write(line.encode())
# Ждём ответа (не блокируем event loop)
return await future
async def _response_reader(self, read_stream):
"""
Фоновая задача: читает входящие ответы и разрешает futures.
Запускается в asyncio.create_task() при подключении.
"""
async for raw_line in read_stream:
try:
msg = json.loads(raw_line)
except json.JSONDecodeError:
continue
msg_id = msg.get("id")
if msg_id in self._pending:
future = self._pending.pop(msg_id)
if "error" in msg:
future.set_exception(
MCPError(msg["error"]["message"], msg["error"]["code"])
)
else:
future.set_result(msg.get("result"))
# ── Public API ──
async def list_tools(self) -> list:
"""Получает список инструментов (с кэшированием)."""
if self._tools_cache is None:
response = await self.send_request("tools/list", {})
self._tools_cache = response.get("tools", [])
return self._tools_cache
async def call_tool(self, name: str, arguments: dict) -> Any:
"""Вызывает инструмент на сервере."""
return await self.send_request(
"tools/call",
{"name": name, "arguments": arguments}
)
_pending и возвращает управление.
Когда фоновая задача _response_reader получает ответ с нужным id,
она вызывает future.set_result() — и все, кто ждут этот Future,
пробуждаются.
Framing сообщений: как JSON попадает в транспорт
Оба транспорта передают JSON-RPC сообщения. Но между ними принципиальная разница в том, как они разграничивают сообщения — где заканчивается одно и начинается другое.
stdio: newline-delimited JSON
В stdio каждое сообщение — одна строка, завершённая символом новой строки \n.
Это newline-delimited JSON (NDJSON). Сервер читает строки с помощью
asyncio.StreamReader.readline() — один вызов = одно полное сообщение.
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file",...}}\n
↑ каждое сообщение — ровно одна строка. Перенос строки = конец сообщения.
\\n.
Python json.dumps() делает это автоматически.
HTTP+SSE: два канала, одна сессия
HTTP не удобен для двунаправленной коммуникации: один запрос — один ответ. MCP решает это разделением на два канала:
Content-Type: application/json
X-Session-Id: sess_abc123
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{...}}
HTTP/1.1 202 Accepted ← сервер принял, обработает асинхронно
Accept: text/event-stream
--- соединение открыто, сервер стримит события ---
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"file content"}]}}
data: {"jsonrpc":"2.0","method":"notifications/progress","params":{...}}
Session ID связывает два канала в одну сессию.
Клиент получает session_id при первом подключении к GET /sse
и включает его в заголовок каждого POST /message.
Сервер знает, в какой SSE-поток направить ответ.
stdio в деталях: управление процессом
Когда хост запускает сервер через stdio, он создаёт дочерний процесс и захватывает его stdin и stdout. Рассмотрим, как именно это устроено с точки зрения Python asyncio:
asyncio.create_subprocess_exec() с stdin=PIPE, stdout=PIPE, stderr=PIPEprocess.stdout.readline() — читает до \nprocess.stdin.write(msg + b"\n") + drain()process.stdin.close() → сервер получает EOF и завершается чистоprocess.returncode != None → клиент поднимает исключение для всех pending futures"""
Запуск MCP-сервера через stdio и вызов инструмента.
Показывает низкоуровневое взаимодействие с процессом.
"""
import asyncio
import json
from mcp import ClientSession
from mcp.client.stdio import stdio_client
async def demo_stdio():
# stdio_client запускает server.py как subprocess
# и создаёт пару (read_stream, write_stream) над его stdio
async with stdio_client(
command="python",
args=["file_server.py"]
# Опционально: env={"MY_VAR": "value"} — переменные окружения
# cwd="/path/to/workdir" — рабочая директория
) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
# initialize: handshake с согласованием capabilities
init_result = await session.initialize()
print(f"Server: {init_result.serverInfo.name} v{init_result.serverInfo.version}")
print(f"Capabilities: {init_result.capabilities}")
# tools/list: получаем реестр инструментов
tools = await session.list_tools()
print(f"\nAvailable tools: {[t.name for t in tools.tools]}")
# tools/call: вызываем конкретный инструмент
result = await session.call_tool(
"read_file",
arguments={"path": "/etc/hostname"}
)
print(f"\nread_file result: {result.content[0].text}")
# Сервер работает до тех пор, пока открыт context manager.
# При выходе из 'async with stdio_client(...)' процесс завершается.
asyncio.run(demo_stdio())
HTTP+SSE в деталях: полный сервер
HTTP+SSE требует запустить отдельный HTTP-сервер.
MCP SDK предоставляет SseServerTransport для Starlette/ASGI.
Минимальный рабочий пример:
POST /message — принимает JSON-RPC, возвращает 202GET /sse — постоянное SSE-соединение, клиент слушает события/sse, передаётся в первом SSE-событии"""
MCP-сервер через HTTP+SSE транспорт.
Установи: pip install mcp starlette uvicorn
Запуск: python http_server.py
Сервер будет доступен на http://localhost:8000
"""
import asyncio
import uvicorn
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp import types
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.requests import Request
# ── Логика сервера (одинаковая для любого транспорта!) ──
server = Server("http-file-tools")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="read_file",
description="Читает файл по пути",
inputSchema={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "read_file":
try:
with open(arguments["path"], encoding="utf-8") as f:
return [types.TextContent(type="text", text=f.read())]
except Exception as e:
return [types.TextContent(type="text", text=f"Error: {e}")]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
# ── HTTP+SSE транспорт ──
# SseServerTransport создаёт два ASGI-handler'а
sse = SseServerTransport("/message")
async def handle_sse(request: Request):
"""GET /sse — клиент подключается и слушает события."""
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
# Запускаем основной цикл сервера на этом соединении
await server.run(
streams[0], # read
streams[1], # write
server.create_initialization_options()
)
async def handle_message(request: Request):
"""POST /message — клиент отправляет запросы."""
await sse.handle_post_message(
request.scope, request.receive, request._send
)
# Starlette app с двумя маршрутами
app = Starlette(routes=[
Route("/sse", endpoint=handle_sse),
Route("/message", endpoint=handle_message, methods=["POST"]),
])
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
Подключение клиента к HTTP-серверу:
import asyncio
from mcp import ClientSession
from mcp.client.sse import sse_client
async def demo_http():
# sse_client подключается к /sse и создаёт пару потоков
# Требует: pip install mcp[client] httpx
async with sse_client("http://localhost:8000/sse") as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print([t.name for t in tools.tools])
result = await session.call_tool(
"read_file",
arguments={"path": "/etc/hostname"}
)
print(result.content[0].text)
asyncio.run(demo_http())
list_tools и call_tool в HTTP-сервере
идентичны stdio-версии из предыдущего урока.
Поменялся только способ запуска: stdio_server() → SseServerTransport.
Это и есть ценность разделения уровней.
Capability Negotiation: согласование возможностей
При каждом подключении клиент и сервер выполняют handshake: обмениваются информацией о том, что умеет каждая сторона. Это позволяет использовать новые возможности там, где они поддерживаются, и деградировать до базового поведения там, где нет.
Вот ключевые capabilities и что они означают:
| Capability | Кто объявляет | Что означает |
|---|---|---|
| tools | server | Сервер поддерживает tools/list и tools/call |
| resources | server | Сервер предоставляет resources/list и resources/read |
| resources.subscribe | server | Клиент может подписаться на изменения ресурса |
| prompts | server | Сервер предоставляет prompts/list и prompts/get |
| logging | server | Сервер может отправлять notifications/message (логи) |
| sampling | client | Клиент поддерживает sampling/createMessage (сервер может попросить LLM-вызов) |
| roots | client | Клиент предоставляет список корневых директорий (контекст для файловых операций) |
from mcp.server import Server
from mcp.server.models import InitializationOptions
import mcp.types as types
server = Server("my-server")
# Можно явно задать capabilities через InitializationOptions
# (по умолчанию SDK формирует их автоматически из зарегистрированных handlers)
def create_init_options() -> InitializationOptions:
return InitializationOptions(
server_name="my-server",
server_version="1.0.0",
capabilities=types.ServerCapabilities(
tools=types.ToolsCapability(listChanged=True),
# listChanged=True означает что сервер будет слать
# notifications/tools/list_changed если набор инструментов изменится
resources=types.ResourcesCapability(
subscribe=False,
listChanged=False
),
logging=types.LoggingCapability(),
)
)
Три уровня ошибок
В MCP ошибки могут возникнуть на трёх разных уровнях, и каждый из них требует разной обработки:
Соединение разорвалось до того, как пришёл ответ. Для stdio: процесс упал (returncode != 0) или EOF. Для HTTP+SSE: разрыв соединения, timeout. Клиент не получит ответ — все pending futures завершатся с исключением.
Запрос получен, но не может быть обработан на уровне протокола:
неизвестный метод, невалидный JSON, несовместимая версия.
Сервер возвращает объект error вместо result.
Стандартные коды: -32700 Parse error, -32600 Invalid Request,
-32601 Method not found, -32603 Internal error.
Запрос успешно обработан протоколом, инструмент вызван, но выполнение провалилось.
Это не JSON-RPC error — ответ приходит с result, но
внутри isError: true и текстом ошибки в content.
Именно так правильно возвращать бизнес-ошибки из инструментов.
from mcp import types, ClientSession
async def robust_tool_call(session: ClientSession, name: str, args: dict) -> str:
"""
Вызывает инструмент с обработкой всех трёх уровней ошибок.
"""
try:
result = await session.call_tool(name, args)
# Проверяем ошибку уровня инструмента
if result.isError:
# Инструмент вернул ошибку — это бизнес-ошибка, не крэш
error_text = result.content[0].text if result.content else "unknown tool error"
return f"[TOOL ERROR] {error_text}"
# Успешный результат
return "\n".join(
item.text for item in result.content
if hasattr(item, "text")
)
except Exception as e:
# Транспортная или протокольная ошибка
# Сессия может быть в неопределённом состоянии — лучше переподключиться
return f"[PROTOCOL/TRANSPORT ERROR] {type(e).__name__}: {e}"
Как выбирать транспорт на практике
Вопрос «когда stdio, когда HTTP+SSE» встаёт в каждом проекте. Вот дерево принятия решений:
Инструмент используется только из одного агента/приложения?
│
├─ ДА: нужна ли сетевая доступность (другой хост/контейнер)?
│ │
│ ├─ НЕТ → stdio (самый простой вариант, рекомендуется по умолчанию)
│ │
│ └─ ДА → HTTP+SSE (с uvicorn/starlette)
│
└─ НЕТ: несколько агентов используют один сервер →
│
├─ В одном k8s/docker → HTTP+SSE как internal service
│
└─ Публичный сервис → HTTP+SSE с аутентификацией (Bearer token в заголовках)
Типичные ошибки
print() или logging.StreamHandler(sys.stdout)
испортит поток JSON-сообщений: клиент получит невалидный JSON и упадёт.
logging.StreamHandler(sys.stderr)
или print("debug", file=sys.stderr).
SDK читает stderr отдельно и логирует его на стороне хоста.
call_tool:
await asyncio.wait_for(session.call_tool(...), timeout=30).
На стороне сервера — heartbeat и явный close при shutdown.
@server.call_tool() превращается
в JSON-RPC error (-32603). Клиент получает протокольную ошибку,
а не текст с объяснением. LLM видит технический код, а не «файл не найден».
TextContent(text="Error: ...", isError=True).
Исключения — только для действительно непредвиденных ситуаций.
ClientSession — это async context manager.
Если создать его вручную через __init__ и забыть вызвать initialize,
все последующие вызовы провалятся с «session not initialized».
async with ClientSession(...) as session:.
Context manager автоматически вызывает initialize при входе
и shutdown при выходе.
Шпаргалка
- 3 уровня: бизнес-логика → protocol layer (Server/Client) → transport layer
- Сервер = роутер: @list_tools → registry entry; incoming method → dispatch → handler
- Клиент = future map: send request + register Future, reader task resolves it on response
- stdio framing: newline-delimited JSON; каждая строка = одно сообщение
- HTTP+SSE framing: POST /message (request) + GET /sse (response stream)
- Handshake: initialize → initialized notification → работа
- Capabilities: server объявляет tools/resources/prompts; client объявляет sampling/roots
- 3 уровня ошибок: transport (разрыв) → protocol (JSON-RPC error) → tool (isError: true)
- Выбор транспорта: stdio по умолчанию; HTTP+SSE для сети/мультиклиента
- НИКОГДА: не писать в stdout в stdio-сервере; только stderr для логов
Практика
mcp_calls.log строку вида
2026-03-29 12:00:00 | tools/call | read_file | {"path": "/etc/hosts"} | OK | 0.012s.
Убедись, что логи идут в файл, а не в stdout (иначе stdio-транспорт сломается).
read_file и считает среднюю задержку.
Запусти тест для stdio-версии и HTTP+SSE-версии (localhost).
На сколько процентов stdio быстрее? (Ожидаемый результат: 5–30% разницы на localhost.)
UnixSocketTransport должен реализовать тот же интерфейс,
что и StdioServerTransport: методы connect(),
возвращающий пару (read_stream, write_stream).
Framing — тот же newline-delimited JSON, что и в stdio.
Подключи к нему стандартный сервер из урока — без изменений бизнес-логики.