MCP-сервер добавил tool, а агент его не видит: обновляем discovery

На MCP-сервере появился инструмент поиска счетов, но агент продолжает отвечать, что умеет только читать заказы. Прямой tools/list уже показывает новый инструмент. Перезапуск host помогает, а через неделю проблема возвращается. Чаще всего сервер и модель видят разные списки: клиент сохранил результат discovery при подключении и больше его не обновляет.

Обновление нужно пройти по всей цепочке: сервер сообщает об изменении, клиент снова получает список, host заменяет доступные модели описания. Наличие нового инструмента в серверном реестре завершает только первый шаг. Даже успешно обновлённый клиентский кэш не поможет, если следующий запрос к модели продолжит использовать прежнюю схему tools.

Посмотрите на согласованные возможности

При initialize сервер, поддерживающий инструменты, объявляет capability tools. Поле listChanged со значением true сообщает, что сервер отправляет уведомления об изменении списка. Уведомление notifications/tools/list_changed содержит название метода, но не новый массив инструментов. Его смысл — сделать прежний список устаревшим и повторить tools/list.

В журнале проблемной сессии найдите ответ initialize, затем первоначальный tools/list и событие изменения. Если capability не объявлена, нельзя ждать уведомления как обязательного механизма. Нужна согласованная стратегия обновления: например, явное обновление пользователем или повторное discovery перед новой задачей. Опрос по таймеру тоже имеет стоимость, особенно на большом числе подключений.

Следующий пример разбирает настоящие формы JSON-RPC сообщений MCP из локального журнала. Это узкая проверка реакции на протокольное уведомление, без SDK и сетевого сервера. До события кэш содержит один инструмент. После события программа создаёт новый запрос tools/list, проверяет соответствующий id ответа и заменяет кэш полным полученным списком.

import json
capabilities = {"tools": {"listChanged": True}}
cache = {"read_order": {"type": "object"}}
notice = json.loads('{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}')
assert "id" not in notice
if (capabilities.get("tools", {}).get("listChanged")
        and notice["method"] == "notifications/tools/list_changed"):
    request = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
else:
    raise AssertionError("refresh was not scheduled")
response = json.loads('''{"jsonrpc":"2.0","id":2,"result":{"tools":[
 {"name":"read_order","inputSchema":{"type":"object"}},
 {"name":"search_invoices","inputSchema":{"type":"object"}}
]}}''')
assert response["id"] == request["id"]
assert "nextCursor" not in response["result"]
cache = {tool["name"]: tool["inputSchema"] for tool in response["result"]["tools"]}
assert "search_invoices" in cache
print(json.dumps(request, separators=(",", ":")))
print(sorted(cache))

Первая строка вывода — JSON-RPC запрос tools/list с id 2. Вторая показывает обновлённые имена: read_order и search_invoices. Новое описание не берётся из уведомления: оно приходит в ответе discovery. В реальном клиенте такую реакцию подключают к обработчику входящих notification, а завершение tools/list — к обновлению реестра host.

Заменяйте список целиком и учитывайте страницы

Инструмент может не только появиться, но и исчезнуть или изменить схему. Простое добавление неизвестных имён оставит в кэше удалённые команды и старые аргументы. Соберите новый снимок, проверьте его и атомарно замените предыдущий. Если ответ tools/list содержит nextCursor, получите все страницы до замены. Частичный список нельзя объявлять завершённым.

Пока выполняется обновление, могут прийти новые уведомления. Полезно хранить признак «появилось ещё одно изменение» и после текущего запроса повторить discovery. Если несколько запросов выполняются параллельно, старый ответ не должен перезаписать более свежий снимок. Для этого используют номер поколения локального обновления либо последовательную очередь запросов.

Отдельно решите судьбу уже подготовленных вызовов. Если модель выбрала инструмент по старой схеме, host должен сверить имя и аргументы с текущим разрешённым контрактом перед исполнением. Появление инструмента также не означает автоматического разрешения для каждого пользователя: ограничения доступа продолжают действовать при каждом обращении.

Проверьте путь до модели

Практический сценарий: пользователь просит «Найди неоплаченные счета по заказу 517». До обновления подключён только read_order, после обновления — ещё search_invoices. Сохраните список имён, переданный именно в следующий запрос к модели. Если там нет search_invoices, исправлять нужно сборку запроса host, даже когда протокольный клиент уже обновил кэш.

Для регрессии воспроизведите добавление, удаление и изменение схемы на тестовом сервере. Удалённый инструмент должен исчезнуть из следующего запроса модели, а смена аргументов — попасть в новое описание. Локальный пример проверяет структуру сообщения и замену кэша; доставку уведомления выбранным транспортом проверяют в интеграционном тесте подключения.

В платном курсе «AI Agent Integrations PRO: MCP» вы соберёте сервер с tools и resources, подключите клиент и пройдёте цикл discovery → tool call → result handling. Следующая задача — связать обновление discovery с реальным агентом и проверить ограничения доступа. Откройте программу курса и соберите интеграцию, которая обновляет контракт предсказуемо.

Продолжить на практике

  • AI Agent Integrations PRO: MCP ↗

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