Урок курса

Инициализация MCP-клиента и обнаружение возможностей

AI Agent Integrations PRO: MCP

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

Роль MCP-клиента и его место в архитектуре

В уроке про архитектуру мы уже делили систему на Host, Client и Server. Здесь не повторяем эту схему целиком. Клиент интересует нас как конкретный объект в коде: он открывает транспорт, проходит initialization и превращает удалённый MCP-сервер в понятные приложению методы вроде list_tools() или read_resource().

Его зона ответственности практическая:

  • открыть транспортный канал: например, stdio к локальному процессу или SSE к удалённому серверу;
  • дождаться корректного initialize/initialized обмена;
  • получить списки tools и resources, но только если сервер действительно заявил их в capabilities;
  • удерживать состояние соединения и кэши так, чтобы приложение не отправляло рабочие запросы слишком рано.

Сервер при этом остаётся поставщиком возможностей, а host-приложение — владельцем пользовательского сценария. Клиент связывает эти две части, но не принимает продуктовых решений за агента: он не выбирает, какой tool нужен пользователю, а даёт приложению достоверную картину того, что сервер реально предоставляет.

Фаза initialize handshake: протокол согласования

Сам порядок handshake уже знаком; на клиентской стороне важнее, какие решения принимаются после ответа сервера. Клиент отправляет initialize, получает protocolVersion, capabilities и serverInfo, затем подтверждает initialized — и только после этого может переходить к discovery.

Типичный ответ сервера выглядит так:


{
  "protocolVersion": "2024-11-05",
  "capabilities": {
    "tools": { "listChanged": true },
    "resources": { "subscribe": false, "listChanged": false },
    "prompts": {}
  },
  "serverInfo": { "name": "my-mcp-server", "version": "1.0.0" }
}

Клиенту важно не просто сохранить этот объект, а принять по нему решения. protocolVersion нужно сравнить с ожидаемой версией или хотя бы залогировать расхождение. capabilities определяют, какие discovery-запросы вообще уместны: если сервер не заявил resources, не надо вызывать resources/list «на всякий случай». serverInfo полезен для диагностики: когда в окружении несколько серверов, лог с именем и версией быстро показывает, к чему именно подключился клиент.

Распространённая ошибка — считать successful connect достаточным сигналом готовности. Транспорт мог открыться, но пока initialize не завершён и initialized не отправлен, рабочие запросы ещё преждевременны.

Пошаговая реализация инициализации и discover capabilities

В коде здесь важен один барьер: await session.initialize(). Пока он не завершился, клиент ещё не знает, что именно сервер готов публиковать. Полный цикл выглядит так:

  1. Создать параметры запуска сервера — StdioServerParameters с командой и аргументами.
  2. Открыть транспортный канал через stdio_client — он возвращает пару (read, write) потоков.
  3. Создать сессию ClientSession поверх этих потоков.
  4. Вызвать await session.initialize() — именно здесь происходит весь handshake: клиент отправляет initialize, получает ответ сервера и отправляет initialized.
  5. Только после возврата из initialize() вызывать list_tools() и list_resources().

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(command='python', args=['server.py'])

    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            # Шаг 4: handshake — блокирует до получения initialized
            await session.initialize()

            # Шаг 5: discover capabilities
            tools = await session.list_tools()
            resources = await session.list_resources()

            print('Tools:', [t.name for t in tools.tools])
            print('Resources:', [r.uri for r in resources.resources])

asyncio.run(main())

await session.initialize() — это синхронная точка ожидания. До её завершения сессия находится в состоянии «не инициализирована», и сервер отклонит любой другой запрос с ошибкой not initialized. SDK не защищает от этой ошибки автоматически: если вы вызовете list_tools() до initialize(), код отправит запрос, но получит отказ от сервера. После успешной инициализации list_tools() и list_resources() возвращают объекты с полями tools и resources соответственно. Это отдельные запросы к серверу, а не чтение кэша из ответа initialize. Сам ответ initialize говорит только, какие типы возможностей существуют в принципе; конкретный перечень инструментов и ресурсов приходит уже через tools/list и resources/list.

Работа с динамическими capabilities: listChanged и кэширование

После успешной инициализации у вас на руках есть список инструментов из list_tools(). Возникает соблазн сохранить его один раз и переиспользовать на протяжении всей сессии. Это нормально, пока сервер не меняет опубликованный список в runtime. Именно для этого в ответе initialize есть флаг listChanged внутри секции tools:


"capabilities": {
  "tools": { "listChanged": true },
  "resources": { "subscribe": false, "listChanged": false }
}

Если listChanged: true — сервер явно предупреждает: список инструментов может измениться прямо в ходе работы. Это реальный сценарий, например, когда сервер динамически регистрирует или удаляет инструменты в зависимости от состояния системы. При listChanged: true бесконечное кэширование становится источником трудноуловимых ошибок: AI-приложение продолжает считать, что инструмент существует, пытается его вызвать и получает ошибку «tool not found» — уже не на этапе discover, а в середине работы агента. Правильная стратегия зависит от требований к приложению:

  • Если сервер присылает уведомление notifications/tools/list_changed, можно подписаться на него и инвалидировать кэш только по факту изменения.
  • Если уведомления не реализованы, разумно перезапрашивать list_tools() перед каждой новой задачей агента или по истечении TTL.
  • Если listChanged: false — кэш на всю сессию вполне оправдан. Отдельная проблема: многие реализации просто не читают capabilities из ответа initialize и кэшируют список безусловно. Это безопасно только для статичных серверов. Если в проекте используются серверы сторонних разработчиков, проверяйте этот флаг явно при старте сессии и закладывайте стратегию обновления на этапе проектирования клиента, а не после первого инцидента.

Задача 1

Что именно отправляет MCP-клиент в самом первом сообщении при установке соединения с сервером?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Сервер вернул в ответе initialize поле "tools": { "listChanged": true }. Что это означает для стратегии работы клиента со списком инструментов?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Что произойдёт, если вызвать session.list_tools() до завершения await session.initialize()?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Как называется уведомление, которое MCP-клиент отправляет серверу после получения успешного ответа на запрос initialize? Напиши точное название одним словом.

Без регистрации · результат не сохраняется

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку