Проектирование MCP-инструментов, которые не взрывают контекстное окно агента
Агент вызывает один инструмент. Инструмент делает ровно то, для чего создан, и возвращает все записи, которые у него есть. Один ответ оказывается больше всего контекстного окна модели — и сессия обрывается на месте, забирая с собой рабочее состояние агента.
Именно этот сценарий сбоя команда Runpod обнаружила в собственном MCP-сервере (Model Context Protocol). Внутренний аудит в июне показал: один вызов списка без пагинации возвращал ответ примерно в 15 раз больше контекстного окна Claude Code на реальном аккаунте. Проблема перестала быть теоретической ещё и потому, что использование MCP на платформе с июня выросло в 300 раз, а агентный трафик — растущая доля общего трафика.
Этот сбой прячется в каждом MCP-инструменте, который оборачивает REST-эндпоинт, спроектированный до эпохи токенных бюджетов. Эндпоинты «верни всё», хвосты логов, поисковые выдачи и деревья файлов исходят из предположения, что потребитель умеет бегло просматривать данные. Агент просматривать не умеет, а контекст — это ресурс, который ваш инструмент тратит от имени агента. Если вы не зададите бюджет, его не задаст никто. И если переполнение случится посреди деплоя инфраструктуры или миграции, уборка обойдётся дороже перезапуска.
Почему агенты не умеют «просматривать»
Человек, попавший на непагинированный REST-эндпоинт в браузере, просто пролистывает лишнее. Ответ может быть уродливым, но его игнорирование ничего не стоит. У агента такой опции нет: большинство MCP-клиентов пересылают весь результат вызова инструмента прямо в контекстное окно LLM на следующем ходу. Модель платит токенами за каждый байт ответа — независимо от того, полезен он или нет.
В обсуждении на GitHub о лимитах размера ответов MCP разработчики сообщают, что инструменты браузерной автоматизации расходуют 50–500 тысяч токенов за одно чтение страницы, когда возвращают сырой контент. На верхней границе одно чтение страницы стоит больше двух контекстных окон по 200 тысяч токенов — ещё до того, как модель вообще приступила к рассуждению.
Протокол даёт курсор, но не лимит
Спецификация MCP определяет курсорную пагинацию для операций списков: запрос несёт непрозрачную строку курсора, ответ содержит nextCursor, если есть ещё данные, а размер страницы решает сервер. Чего в спецификации нет — так это лимита на размер ответа. Открытое предложение в репозитории MCP добавит capability max_response_bytes, согласуемую при инициализации: сервер сможет выбирать — пагинировать, суммировать или вернуть ошибку вместо «простыни». Пока это не принято, ограничивать размер ответа — ответственность автора инструмента: протокол за вас этого не сделает.
Как выглядело исправление
После аудита каждый list-инструмент сервера Runpod получил два параметра: limit со значением по умолчанию 20 и жёстким потолком 100, а также непрозрачный курсор. Ответы переехали в конверт (envelope), который несёт элементы плюс метаданные, нужные агенту, чтобы понять, что именно он получил:
{
"items": ["..."],
"totalCount": 3187,
"returned": 20,
"offset": 0,
"truncated": true,
"nextCursor": "eyJvZmZzZXQiOjIwfQ=="
}
Значения здесь иллюстративные, но набор полей — реальный контракт. Агент, читающий такой конверт, знает, сколько данных существует, сколько он получил и как запросить ещё.
Потолок при этом enforced на клиентской стороне, в обёртке самого инструмента: нижележащий REST API в момент исправления ещё не поддерживал серверную пагинацию. Обёртка получает данные, нарезает и «оконивает» их до того, как что-то дойдёт до модели. Это прагматичный ход на случай, когда вы не контролируете вышележащий API.
Молчаливая обрезка хуже ошибки
Клиент или сервер, который молча отбрасывает данные за пределами лимита, хуже того, который падает с ошибкой. Ошибка видна. Молча обрезанный результат убеждает модель, что она рассуждает над полной картиной, когда это не так, — и каждый вывод ниже по течению наследует этот пробел. Флаг truncated плюс nextCursor решают проблему: агент видит, что ответ частичный, и сам решает, требует ли задача остальной части.
Каждый зарегистрированный инструмент — налог на контекст
Пагинация ограничивает стоимость вызова инструмента. Но регистрация стоит контекста ещё до всякого вызова. Каждый инструмент, который вы показываете, отправляет в контекст модели своё имя, описание и полную схему параметров, а затем модель тратит токены рассуждения на перечитывание описаний при выборе. Это измеримо: сериализуйте полный список инструментов так, как его отправляет ваш MCP-клиент, и посчитайте токены.
Практические выводы: не показывайте 40 узких инструментов, когда шесть хорошо спроектированных покрывают ту же поверхность, и не раздувайте описание деталями вывода, которые модели не нужны. Описание существует, чтобы ответить на один вопрос — стоит ли модели выбирать этот инструмент для этой задачи.
Ссылки вместо данных
Для результатов, слишком больших для полезного встраивания в ответ, текущие рекомендации MCP-инструментария и фикс Runpod указывают в одну сторону: вернуть resource URI, который агент может запросить по требованию, вместо того чтобы вкладывать payload в ответ инструмента. Содержимое файлов, логи и сгенерированные изображения — всё это подходит под паттерн.
Инструментация: кто зовёт и сколько тратит
Вторая половина исправления ничего не предотвратила, но оказалась важной. Каждый запрос теперь несёт структурированный идентификатор вида caller=mcp; client=<name>; client_version=<ver>; transport=<stdio|http>. Имя и версию клиента сервер берёт из initialize-рукопожатия, поэтому обращаться с ними стоит как с самопровозглашёнными телеметрическими метками, а не как с границей доверия.
Атрибуция — это то, как команда Runpod вообще заметила паттерны агентного трафика, и то, как они теперь следят за поведением пагинированных ответов в продакшене вместо того, чтобы предполагать, что фикс сработал. Заодно стоит логировать ещё одно поле: размер каждого результата инструмента в байтах или токенах рядом с меткой вызывающего. Атрибуция говорит, кто зовёт; размер ответа — какой инструмент тратит больше всего контекста ваших пользователей.
Как выглядит взрыв со стороны сервера
Со стороны сервера этот сбой невидим. Вызов инструмента завершается успешно, ответ уходит, а сессия умирает позже, на клиенте, когда oversized-результат пересылают в контекст модели. В логах — успешно завершённый запрос. Пользователь видит сессию, которая резко обрывается сразу после одного вызова инструмента, часто с ошибкой превышения длины контекста от клиента, — и ничто не связывает эти два события, если вы не построили связь сами.
Если считать перцентили размера ответов по каждому инструменту, «убийца сессий» проявится как выброс задолго до тикета от пользователя, который никто не может воспроизвести. Аудит Runpod выявил 15-кратное превышение, измерив ответы реального аккаунта, — тот же чек можно запустить по своему крупнейшему тенанту уже сегодня.
Чек-лист перед релизом MCP-инструмента
Вот правила, которым команда Runpod теперь подчиняет собственные инструменты:
- Пагинируйте каждый list-инструмент с ограниченным
limitи непрозрачным курсором. - Дефолтный
limitделайте маленьким: 20 элементов, о которых модель может рассуждать, лучше 100, сквозь которые ей придётся продираться. - Возвращайте флаг
truncatedиnextCursorвместо молчаливого отбрасывания данных. - Для больших или бинарных результатов возвращайте ссылку, которую агент запросит по требованию, а не сам payload.
- Держите количество инструментов и объём описаний минимальными: и то и другое тратит контекст ещё до вызова.
- Ставьте метки
caller,clientиtransportна каждый запрос — телеметрия покажет проблему раньше, чем пользователь пожалуется на мёртвую сессию. - Логируйте размер каждого результата рядом с меткой вызывающего: выброс по конкретному инструменту — это то, как вы находите убийцу сессий.