Три уровня абстракции: зачем они разделены

MCP — это не монолит. Протокол сознательно разделён на три независимых уровня, которые можно заменять и комбинировать независимо друг от друга. Это ключевое архитектурное решение, которое нужно понять, прежде чем смотреть на код.

Приложение
Бизнес-логика (твой код)

Функции, которые делают что-то полезное: читают файл, делают SQL-запрос, вызывают API. Этот уровень не знает ничего об MCP — просто Python-функции. Ты регистрируешь их через декораторы, и они становятся инструментами.

async def read_file(path: str) → str: ...
Сервер / Клиент
Protocol Layer — MCP-логика

Реестр инструментов, маршрутизация запросов, сериализация/десериализация, capability negotiation. Этот уровень реализует спецификацию MCP: знает про tools/list, tools/call, initialize. Не знает, как именно приходят байты — только что с ними делать.

Server, ClientSession, RequestRouter, ToolRegistry
Транспорт
Transport Layer — доставка байтов

Отвечает только за одно: принять строку JSON от протокольного уровня и доставить её на другую сторону. Не интерпретирует содержимое. Два встроенных транспорта: stdio (через процессные потоки) и HTTP+SSE (через сеть). Можно написать свой — например, WebSocket или Unix socket.

StdioServerTransport, SseServerTransport, WebSocketTransport

Разделение критично для переиспользования: одна и та же серверная логика работает через любой транспорт без изменений. Переключиться с stdio на HTTP+SSE — это изменить одну строку запуска, не трогая реестр инструментов.

100%
колёсико — масштаб · зажать и тянуть — перемещение
MCP HOST + CLIENT LLM / User "tools/call read_file" MCP Client (ClientSession) Session Manager lifecycle, init handshake Request Queue pending id→future map Discovery Cache tools list (ttl cache) Serializer Python → JSON-RPC Transport Adapter stdio: asyncio subprocess http: httpx AsyncClient + SSE reader JSON-RPC string response TRANSPORT LAYER stdio Transport Host spawns: python server.py Messages: newline-delimited JSON → stdin pipe {"jsonrpc":"2.0","method":"tools/call",...}\n ← stdout pipe {"jsonrpc":"2.0","result":{...}}\n stderr отдельно (только для логов, не парсится) HTTP + SSE Transport Server: отдельный HTTP-процесс (uvicorn) → POST /message body: {"jsonrpc":"2.0","method":"tools/call",...} response: 202 Accepted + session_id header ← GET /sse (persistent EventStream) data: {"jsonrpc":"2.0","result":{...}} data: {"jsonrpc":"2.0","method":"ping"} MCP SERVER Transport Handler читает поток, парсит JSON-RPC строки Request Router initialize → capability_handler() tools/list → list_tools_handler() tools/call → call_tool_handler() Tool Registry read_file → async fn(path) → TextContent write_file → async fn(path,content) → TextContent list_tools (метаданные + JSON Schema) Response Serializer {"result":{"content":[{"type":"text","text":"..."}]}} → пишет в stdout / SSE stream

Анатомия 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}
        )
asyncio.Future — ключевая деталь. Отправив запрос, клиент не блокирует поток ожиданием. Он регистрирует Future в словаре _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() — один вызов = одно полное сообщение.

Поток stdin: три запроса подряд
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}\n
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file",...}}\n
↑ каждое сообщение — ровно одна строка. Перенос строки = конец сообщения.
Сообщение не может содержать буквальный \n внутри. Строки в JSON должны быть экранированы: вместо символа новой строки внутри строкового значения пиши \\n. Python json.dumps() делает это автоматически.

HTTP+SSE: два канала, одна сессия

HTTP не удобен для двунаправленной коммуникации: один запрос — один ответ. MCP решает это разделением на два канала:

Канал 1: POST /message — отправка запросов (client → server)
POST /message HTTP/1.1
Content-Type: application/json
X-Session-Id: sess_abc123

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{...}}

HTTP/1.1 202 Accepted ← сервер принял, обработает асинхронно
Канал 2: GET /sse — получение ответов (server → client, постоянное соединение)
GET /sse HTTP/1.1
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:

stdio Transport — детали реализации
Запуск сервера
asyncio.create_subprocess_exec() с stdin=PIPE, stdout=PIPE, stderr=PIPE
Чтение ответов
process.stdout.readline() — читает до \n
Отправка запросов
process.stdin.write(msg + b"\n") + drain()
Stderr
Читается отдельной задачей, пишется в лог хоста — не парсится как MCP
Завершение
process.stdin.close() → сервер получает EOF и завершается чисто
Crash сервера
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. Минимальный рабочий пример:

HTTP+SSE Transport — детали реализации
Endpoint для запросов
POST /message — принимает JSON-RPC, возвращает 202
Endpoint для ответов
GET /sse — постоянное SSE-соединение, клиент слушает события
Session ID
UUID генерируется при подключении к /sse, передаётся в первом SSE-событии
Heartbeat
Пустые SSE-события каждые 15 сек — держат соединение живым через NAT/proxy
CORS
Нужно настроить для браузерных клиентов; для Python-клиентов не требуется
"""
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: обмениваются информацией о том, что умеет каждая сторона. Это позволяет использовать новые возможности там, где они поддерживаются, и деградировать до базового поведения там, где нет.

Полный handshake при подключении
C→S
initialize · params.protocolVersion: "2024-11-05"
params.capabilities: {roots: {listChanged: true}, sampling: {}}
params.clientInfo: {name: "my-agent", version: "0.1"}
S→C
initialize result · protocolVersion: "2024-11-05"
result.capabilities: {tools: {}, resources: {subscribe: true}, logging: {}}
result.serverInfo: {name: "file-tools", version: "1.2.0"}
C→S
notifications/initialized ← notification (без id, без ответа)
Клиент говорит: "принял, можем работать"
SYS
Handshake завершён. Теперь можно вызывать tools/list, tools/call, resources/list и т.д.

Вот ключевые 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 завершатся с исключением.

ConnectionError, ProcessCrashError, asyncio.TimeoutError
Протокольная ошибка (JSON-RPC error)

Запрос получен, но не может быть обработан на уровне протокола: неизвестный метод, невалидный JSON, несовместимая версия. Сервер возвращает объект error вместо result. Стандартные коды: -32700 Parse error, -32600 Invalid Request, -32601 Method not found, -32603 Internal error.

{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
Ошибка инструмента (tool error)

Запрос успешно обработан протоколом, инструмент вызван, но выполнение провалилось. Это не JSON-RPC error — ответ приходит с result, но внутри isError: true и текстом ошибки в content. Именно так правильно возвращать бизнес-ошибки из инструментов.

{"result":{"content":[{"type":"text","text":"FileNotFoundError: ..."}],"isError":true}}
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 в заголовках)

Типичные ошибки

1. Писать в stdout из инструмента (print, logging → stdout)
В stdio-транспорте stdout — это протокольный канал. Любой print() или logging.StreamHandler(sys.stdout) испортит поток JSON-сообщений: клиент получит невалидный JSON и упадёт.
Все логи только в stderr: logging.StreamHandler(sys.stderr) или print("debug", file=sys.stderr). SDK читает stderr отдельно и логирует его на стороне хоста.
2. Не закрывать SSE-соединение при ошибке сервера
Если сервер падает, SSE-соединение может зависнуть: TCP-соединение не разорвано, но данные не поступают. Клиент вечно ждёт pending futures.
Устанавливай timeout на call_tool: await asyncio.wait_for(session.call_tool(...), timeout=30). На стороне сервера — heartbeat и явный close при shutdown.
3. Выбрасывать исключение вместо isError в инструменте
Необработанное исключение в @server.call_tool() превращается в JSON-RPC error (-32603). Клиент получает протокольную ошибку, а не текст с объяснением. LLM видит технический код, а не «файл не найден».
Всегда оборачивай тело инструмента в try/except и возвращай TextContent(text="Error: ...", isError=True). Исключения — только для действительно непредвиденных ситуаций.
4. Вызывать initialize вручную вместо context manager
ClientSession — это async context manager. Если создать его вручную через __init__ и забыть вызвать initialize, все последующие вызовы провалятся с «session not initialized».
Всегда используй async with ClientSession(...) as session:. Context manager автоматически вызывает initialize при входе и shutdown при выходе.

Шпаргалка

Архитектура MCP — краткая выжимка
  • 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 для логов

Практика

Задание 1. Напиши MCP-сервер с логированием: при каждом вызове инструмента записывай в файл mcp_calls.log строку вида 2026-03-29 12:00:00 | tools/call | read_file | {"path": "/etc/hosts"} | OK | 0.012s. Убедись, что логи идут в файл, а не в stdout (иначе stdio-транспорт сломается).
Задание 2. Запусти один и тот же сервер через оба транспорта и замерь задержку. Напиши клиент, который 100 раз вызывает read_file и считает среднюю задержку. Запусти тест для stdio-версии и HTTP+SSE-версии (localhost). На сколько процентов stdio быстрее? (Ожидаемый результат: 5–30% разницы на localhost.)
Задание 3 (продвинутый). Реализуй кастомный транспорт на базе Unix Domain Socket. UnixSocketTransport должен реализовать тот же интерфейс, что и StdioServerTransport: методы connect(), возвращающий пару (read_stream, write_stream). Framing — тот же newline-delimited JSON, что и в stdio. Подключи к нему стандартный сервер из урока — без изменений бизнес-логики.