Назад к блогу

Контекстный слой для ИИ-агентов: как LinkedIn построил процедурную память на MCP

Контекстный слой для ИИ-агентов: как LinkedIn построил процедурную память на MCP

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

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

Три контекстных разрыва

Первый разрыв — tribal knowledge. Чтобы выполнить задачу, агенту недостаточно найти код: эти знания разбросаны по документации, вики и тредам в Slack и живут в головах старших инженеров.

Второй — context overload. Вывод каждого инструмента занимает место в ограниченном контекстном окне LLM. Когда окно переполняется, происходит компакция, после которой агент теряет значительную часть информации и вынужден заново делать вызовы инструментов, чтобы восстановить релевантный контекст — иногда зацикливаясь.

Третий — отсутствие долговременной памяти. Даже если агент разобрался со всеми деталями задачи через вызовы инструментов и подсказки инженера, при следующей такой же задаче он выясняет всё заново. Это медленно и дорого по токенам.

Решением автор называет процедурную память. В LinkedIn её реализовали как playbooks — поверх того же MCP, что и инструменты.

Плейбуки как процедурная память

Плейбук состоит из name, description и instructions. Агент вызывает его как обычный инструмент, и инструкции возвращаются как вывод инструмента. С точки зрения агента разницы между вызовом инструмента и вызовом плейбука нет — разница только в том, что возвращается.

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

Плейбуки делятся на два вида. Центральные применимы в нескольких репозиториях или вообще без репозиториев — туда попадают только такие. Локальные нужны только в конкретном репозитории: MCP-сервер при старте определяет рабочую директорию и, если в ней есть каталог плейбуков, загружает их оттуда.

Контроль качества устроен так: дубликаты выявляются на код-ревью человеком и автоматическими проверками, которые просят агента-ревьюера определить дубликат или ненужность, а неиспользуемые плейбуки деприкируются по метрикам использования.

Почему MCP, а не свой протокол

MCP даёт готовый механизм обнаружения возможностей. Каждый запрос несёт версию протокола и клиентские capabilities в поле _meta, а клиент может отправить server/discover, чтобы одним запросом узнать identity, capabilities и поддерживаемые версии сервера. Это и позволило обернуть внутренние системы LinkedIn: обёрнутый в MCP инструмент code search дал агентам возможность искать код по 1 000 репозиториев и читать содержимое файлов, а не полагаться только на предобученные данные.

Проблему масштабирования каталога LinkedIn решил не расширением спецификации, а сокращением: вместо тысяч инструментов агенту показывают три — поиск инструментов и плейбуков, получение схемы и выполнение. Поиск работает по ключевым словам и тегам (домен и действие инструмента), затем агент по имени и описанию выбирает нужный инструмент, получает его схему с аргументами и выполняет. Для выбора достаточно короткого описания — длинное не требуется.

Причина такого решения — цена инструмента в контексте. Каждый инструмент занимает место в контекстном окне, и после примерно 30 инструментов агенты замедляются и деградируют: чаще выполняют компакции, теряют контекст. Спецификация MCP описывает это как progressive tool discovery: клиенты, объединяющие много серверов, могут использовать постепенное обнаружение инструментов вместо загрузки всех сразу.

Уведомления MCP позволяют серверу сообщать клиентам об изменениях: клиент открывает долгоживущий поток subscriptions/listen с указанием нужных типов уведомлений, а сервер шлёт совпадающие уведомления по этому потоку.

Модель владения и рост каталога

За сам MCP-сервер и набор ключевых инструментов отвечает центральная команда. Добавлять инструменты и плейбуки может любой сотрудник по открытой модели вклада — но с обязательным ревью InfoSec и с обязанностью сопровождать добавленное. Неиспользуемые или неподдерживаемые инструменты и плейбуки активно и агрессивно выводят из эксплуатации.

Как сервер регистрирует инструменты и ресурсы

Регистрация инструмента начинается с проверки дубликата: если инструмент с таким именем уже зарегистрирован, бросается ошибка. Затем объект кладётся в хранилище по имени, после чего вызываются установка обработчиков запросов и уведомление об изменении списка. Установка обработчиков защищена флагом, чтобы не выполняться повторно.

Список инструментов формируется перебором зарегистрированных записей с фильтром по включённости. Порядок в списке — это порядок регистрации (вставки), что делает его детерминированным между запросами, пока набор инструментов не менялся. Обработчик вызова ищет инструмент по имени и бросает ProtocolError, если инструмент не найден или отключён.

Ресурсы регистрируются похоже, но с двумя ветками: для строкового URI — по URI, для шаблона — по имени; в обоих случаях проверяется дубликат и после регистрации вызываются установка обработчиков и уведомление. При подключении обработчики инструментов и ресурсов ставятся только если соответствующие capabilities объявлены.

Ограничение на размер входа инструмента

Лимит на суммарное число элементов массивов и членов объектов в аргументах одного вызова задаётся опцией maxToolInputElements. Подсчёт обходит значение итеративно, через стек: скалярные узлы пропускаются, для массива перебираются элементы, для объекта — только собственные ключи. Как только накопленное число превышает лимит, обход немедленно прекращается и возвращается текущий счётчик — то есть на больших структурах работа обрывается рано.

Отсутствие значения или Infinity означают отсутствие лимита; иначе требуется число не меньше 1, а значение меньше 1 приводит к ошибке.

Валидация вывода инструмента

Если у инструмента нет outputSchema, проверка пропускается. Также она пропускается для промежуточного результата, запрашивающего ввод, и для результата с флагом ошибки — structuredContent требуется только на финальном неошибочном результате.

Если схема есть, а structuredContent отсутствует, бросается ProtocolError с кодом InvalidParams и сообщением о том, что у инструмента есть схема вывода, но не предоставлено структурированное содержимое. Если содержимое есть, оно валидируется по схеме, и при несоответствии бросается ProtocolError с тем же кодом и текстом ошибки валидации. Проверка наличия идёт строго на === undefined, а не на ложность: null, 0, false и пустая строка считаются легальными значениями и валидируются схемой.

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

На клиенте своя проверка: при наличии скомпилированного валидатора и отсутствии structuredContent у неошибочного результата бросается ProtocolError с кодом InvalidRequest; при несоответствии схеме — с кодом InvalidParams.

Автодополнение аргументов

Для промпта автодополнение начинается с поиска зарегистрированного промпта по имени: если его нет или он выключен — ошибка InvalidParams. Если у промпта нет схемы аргументов, автодополнение завершается без подсказок. Поле нужного аргумента разворачивается из optional, и если оно не поддерживает автодополнение или у него нет completer, подсказок снова не будет. Иначе вызывается completer, а результат обрезается до 100 значений.

Для шаблона ресурса сначала ищется шаблон с совпадающим URI. Если шаблон не найден, но URI есть среди зарегистрированных ресурсов, автодополнение завершается без подсказок: автодополнение фиксированного URI не считается ошибкой. В остальных случаях, когда не найдено ни шаблона, ни ресурса, бросается InvalidParams. Если шаблон найден, вызывается callback для имени аргумента; при его отсутствии подсказок не будет. При листинге ресурсов из шаблонов метаданные ресурса переопределяют метаданные шаблона.

Согласование версии протокола на клиенте

Эра — это согласованный режим работы соединения: legacy-эра соответствует старому рукопожатию initialize, modern-эра — ревизии 2026-07-28 и новее, выбираемой через server/discover. Клиент выбирает negotiated connect, когда режим согласования не legacy. Если у транспорта уже есть sessionId, подключение восстанавливает ранее согласованную версию без повторного зондирование. Иначе состояние сбрасывается и запускается зондирование — пробный обмен с сервером, чтобы определить, какую эру тот поддерживает: для stdio-транспорта с читаемыми параметрами запуска зондирование идёт через отдельный sibling-процесс, чтобы пробный обмен не затронул основной процесс и его слот версии транспорта, иначе — на месте. После успешного зондирования выполняется подключение.

В режиме старого рукопожатия предлагается только legacy-подмножество версий; если оно пусто, бросается ошибка согласования эры. Иначе отправляется initialize с версией протокола, capabilities и информацией о клиенте. Modern-ветка выбирается, когда эра не legacy: тогда заполняются capabilities сервера, его версия, инструкции и результат обнаружения, устанавливается согласованная версия и она передаётся транспорту.

Подключение по сохранённому вердикту

Если при подключении передан сохранённый вердикт, клиент сначала валидирует его, а затем выбирает ветку по виду. Валидация принимает только два вида: legacy-вердикт без полей поддерживаемых версий и обнаружения, либо modern-вердикт, чей результат обнаружения успешно проходит схему. Повреждённый blob отклоняется типизированной ошибкой до любого изменения состояния — это гарантирует, что испорченный blob никогда не выберет эру.

Legacy-вердикт делегирует в обычное legacy-подключение. Modern-ветка берёт результат обнаружения, сбрасывает состояние и ищет общий современный протокол: первый клиентский современный протокол, который сервер объявил среди поддерживаемых. Если пересечения нет, бросается ошибка согласования эры. При успехе сохраняются результат обнаружения, capabilities, версия сервера, инструкции и согласованная версия, а в кэш пишется идентичность сервера.

Идентичность выводится так: если известна версия сервера — как name@version, иначе берётся sessionId транспорта, а при его отсутствии — суррогат вида anonymous:<timestamp>-<random>. Это значение используется как партиция кэша ответов, чтобы серверы без идентичности не делили пустую партицию.

Сброс состояния соединения

Все поля, относящиеся к конкретному соединению, очищаются в одном месте. Метод вызывается в начале каждого нового (не возобновляемого) подключения и при закрытии, чтобы устаревшая согласованная эра, идентичность сервера или автоматически открытая подписка не пережили переподключение.

Он обнуляет согласованную версию, capabilities сервера, версию сервера, инструкции, результат обнаружения и автоматически открытую подписку. Перед очисткой карты подписок он завершает каждую живую машину состояний прослушивания, создавая ошибку с кодом ConnectionClosed и сообщением о сбросе состояния подписки, и уведомляет каждую запись о причине remote. Затем очищает карту, снимает таймеры дебаунса уведомлений об изменениях и сбрасывает кэш для переподключения. Пользовательское хранилище при этом НЕ очищается — иначе пропал бы весь смысл его передавать; очищается только экземплярное хранилище по умолчанию. При закрытии вызов происходит в блоке finally после закрытия транспорта, чтобы состояние очистилось даже при отклонении закрытия.

Поток subscriptions/listen

Клиент открывает поток через listen(filter, options). Сначала проверяется наличие транспорта, затем что согласованная версия — современная, затем что переданный сигнал отмены ещё не сработал. Создаётся контроллер отмены запроса и строковый идентификатор подписки вида listen:<n> из клиентского счётчика — чтобы не столкнуться с числовым счётчиком протокола.

Состояние подписки регистрируется ДО отправки запроса: это нужно, чтобы синхронно доставленное подтверждение (например, на внутрипроцессном транспорте) не обогнало регистрацию. Машина состояний начинается в opening, и все пути завершения — подтверждение, таймаут подтверждения, отмена сервером, закрытие пользователем, конец потока, обрыв транспорта, ошибка отправки — сходятся в единственный settle. Он идемпотентен: при уже закрытом состоянии возвращается сразу.

При подтверждении settle переводит opening → open и разрешает ожидание. При любом другом исходе состояние становится closed, запись удаляется из карты, отменяется запрос, разрешается промис закрытия с причиной local, graceful или remote, и если состояние было opening — отклоняется ожидание открытия. Закрытие вызывает settle с причиной local и рвёт провод: отменяет запрос и шлёт notifications/cancelled с идентификатором запроса.

Таймаут подтверждения вызывает settle с причиной remote и ошибкой таймаута. Сигнал отмены, переданный вызывающим, абортит подписку в любой момент: в opening отклоняет listen(), в open переводит в closed с причиной local и рвёт провод. Запрос отправляется напрямую транспорту с сигналом отмены и обработчиком конца потока, который сетеллит с причиной remote. Входящее подтверждение маршрутизируется по строковому идентификатору подписки из _meta; входящая отмена с совпадающим живым идентификатором сетеллит с причиной remote. Ошибка JSON-RPC для идентификатора подписки — это отказ сервера до подтверждения; результат JSON-RPC — graceful-закрытие сервером. При обрыве транспорта все живые подписки сетеллятся с причиной remote и карта очищается.

Клиент наблюдает результат через промис закрытия, который разрешается одним из значений local, graceful или remote. При закрытии до подтверждения settle также отклоняет промис открытия. Автоматического переподключения нет — нужно вызывать listen() заново. На 2025-эре соединении метод не поддерживается: выбрасывается ошибка MethodNotSupportedByProtocolVersion без прозрачной подмены, направляющая к resources/subscribe и ClientOptions.listChanged.

Автоматически открытая подписка

autoOpenedSubscription — это подписка, автоматически открытая через ClientOptions.listChanged на современном соединении. Её фильтр — пересечение настроенных подопций и объявленных сервером возможностей listChanged; значение undefined на legacy-соединении, до подключения или при пустом пересечении.

Транспортный демультиплексор перехватывает подтверждение подписки с живым идентификатором и разрешает ожидание подтверждения, а входящую отмену с живым строковым идентификатором выполняет как серверный teardown на stdio. При разрыве обработчик ответов для строкового идентификатора живой записи трактует ошибку JSON-RPC как отказ сервера до подтверждения, а результат — как сигнал graceful-закрытия. Обработчик закрытия при обрыве транспорта проходит по всем живым записям, сетеллит каждую с причиной remote и очищает карту, чтобы висящий listen() или открытая подписка не остались подвешенными.

Fan-out на серверной стороне

Серверная сторона хранит шину подписок в поле self._subscriptions. По умолчанию создаётся InMemorySubscriptionBus(), если параметр subscriptions равен None или False, иначе используется переданная реализация. Шина передаётся в низкоуровневый сервер как обработчик subscriptions/listen. При subscriptions is False обработчик не устанавливается — метод не обслуживается, — но шина остаётся, чтобы ctx.notify_* продолжали работать.

Публикация идёт через Context._bus, который возвращает шину и бросает ValueError, если её нет. Методы уведомления об изменениях списков инструментов, промптов и ресурсов публикуют соответствующие события через шину. Уведомление об обновлении ресурса публикует событие с URI, приведённым к строке; URI сопоставляется как точная строка с фильтром каждого потока и доходит только до потоков subscriptions/listen, тогда как клиенты более ранних версий, использовавшие resources/subscribe, уведомляются отдельно через сессию.

Multi-round-trip и requestState

На клиенте авто-удовлетворение включается через опцию inputRequired. Авто-удовлетворение — это автоматическое продолжение запроса, когда сервер в ответ на вызов просит у клиента дополнительный ввод: вместо того чтобы вернуть эту просьбу вызывающему коду как ошибку, клиент сам отрабатывает её и повторяет запрос. Если autoFulfill выключен, такая просьба превращается в ошибку UnsupportedResultType. При включённом вызывается поток обработки, которому передаются получение обработчика запроса, построение контекста и идентификатор сессии.

_verifyRequestState — серверный метод: он запускает настроенный хук проверки и возвращает его значение, а при отсутствии хука возвращает undefined. При ошибке хука вызывается onerror с сообщением о том, что проверка отклонила метод, а наружу бросается ProtocolError с кодом InvalidParams, текстом Invalid or expired requestState и причиной invalid_request_state. Нестроковое значение requestState трактуется как невалидное независимо от наличия хука. Если хук вернул не-undefined, это значение передаётся обработчику.

Целостность requestState между раундами обеспечивает именно хук: он вызывается на каждом раунде запроса, чей эхо-requestState является строкой, ДО запуска обработчика. Разрешённое значение хука является несущим: если проверка возвращает не undefined (например, декодированную полезную нагрузку), именно это значение отдаётся обработчику; верификатор, не являющийся декодером, должен возвращать undefined, чтобы обработчик остался на сырой строке.

Проверка capability против собственного запроса выполняется в том же методе: если запрос объявляет требующие ввода операции, берётся представление клиентских возможностей для этого запроса. На современной эре это значение из собственного _meta-конверта запроса, на соединении эры 2025 — сохранённое при инициализации состояние. Для каждой требуемой операции извлекаются встроенный запрос и требуемые возможности, и если чего-то не хватает, бросается ошибка с перечнем недостающих возможностей.

Отдельная функция записывает в экземпляр сервера capabilities и информацию о клиенте из валидированного конверта запроса, чтобы устаревшие аксессоры продолжали отвечать на инстансах без рукопожатия инициализации.

Эры протокола: 2025 и 2026

Эра — это состояние экземпляра сервера, а не свойство отдельного запроса: современной эра считается только если согласованная версия уже установлена и является современной ревизией.

Современная ревизия 2026-07-28 и более поздние НИКОГДА не согласуются через legacy initialize: проверка accept и counter-offer смотрят только на legacy-подмножество поддерживаемых версий. Выбранная версия записывается как состояние соединения и передаётся транспорту. Обработчик server/discover отвечает списком только современных ревизий и отдаёт capabilities через отдельную функцию.

Установка современных обработчиков идемпотентно добавляет недостающие современные версии в список поддерживаемых и ставит обработчик server/discover. В конструкторе этот обработчик ставится только если список поддерживаемых версий содержит современную ревизию; иначе legacy-only сервер продолжает отвечать -32601.

Обратную совместимость закрывает лениво создаваемая прослойка, которая переводит результат, требующий ввода, в форму, понятную клиентам 2025-эры. Она получает максимальное число раундов, таймаут раунда, разрешённые возможности клиента, проверку requestState, отправку элиситации, отправку сэмплинга и перечисление корней. На не-современной эре при включённой прослойке результат, требующий ввода, обслуживается ею для развёрнутых 2025-клиентов. Её отключение восстанавливает прежнее громкое падение — потому что в проводе 2025-эры нет словаря для input_required, и лучше упасть явно, чем положить в провод результат неверного типа.

Проверки capabilities по методам

Проверки реализованы как разбор по имени метода: для каждого метода проверяется наличие конкретного поля в объекте capabilities, и при отсутствии бросается ошибка CapabilityNotSupported.

На серверной стороне для вызова клиента требуются: sampling, elicitation, roots. Для уведомлений требуются: logging для сообщений, resources для обновлений и изменений списка ресурсов, tools для изменений списка инструментов, prompts для изменений списка промптов, elicitation.url для завершения элиситации. Для обработчиков запросов требуются: completions для автодополнения, logging для установки уровня, prompts для получения и листинга промптов, resources для листинга, шаблонов и чтения, tools для вызова и листинга.

Явно освобождены от проверки: ping — и для вызовов, и для обработчиков; уведомления об отмене и о прогрессе — всегда разрешены.

На клиентской стороне для вызовов сервера требуются: logging для установки уровня, prompts для получения и листинга, resources для листинга, шаблонов, чтения, подписки и отписки (плюс отдельно возможность подписки для самой подписки), tools для вызова и листинга, completions для автодополнения. Для уведомлений требуются roots.listChanged для изменений списка корней. Для обработчиков требуются: sampling для создания сообщения, elicitation для элиситации, roots для перечисления корней.

Освобождены: initialize, server/discover и ping — для вызовов; уведомление об инициализации, об отмене и о прогрессе — для уведомлений; ping — для обработчиков.

Гейтинг по версии и расширениям в Python SDK

Функция _version_gated принимает привязку метода и возвращает обработчик, который перед вызовом исходного проверяет, что версия протокола текущего запроса входит в список допустимых версий метода. При несовпадении выбрасывается ошибка с кодом METHOD_NOT_FOUND и сообщением Method not found. Это нужно потому, что низкоуровневый словарь обработчиков ключуется только по имени метода, поэтому ограничение по версии делается здесь, а не в таблице границ раннера.

Функция require_client_extension проверяет, что подключённый клиент объявил поддержку указанного расширения: она берёт объявленные клиентом capabilities, смотрит поле расширений и, если расширение не объявлено, формирует данные об ошибке с требуемыми возможностями и выбрасывает ошибку с кодом MISSING_REQUIRED_CLIENT_CAPABILITY и сообщением о том, что клиент не объявил требуемое расширение. Клиент при вызове gated-метода без нужного расширения получает эту ошибку с данными о требуемых возможностях.

Безопасность и контроль доступа

В приложении SSE требование аутентификации включается, когда задан верификатор токена: тогда эндпоинт SSE оборачивается в middleware, требующий аутентификации с нужными scopes и URL метаданных ресурса, и путь сообщений монтируется так же. Без верификатора те же пути добавляются без обёртки. Дополнительно, если сконфигурирован провайдер сервера авторизации, добавляются маршруты авторизации с issuer URL, URL документации сервиса, опциями регистрации клиента, опциями отзыва и флагом поддержки identity assertion. Если задан URL сервера ресурсов, добавляются маршруты защищённого ресурса с URL ресурса, списком серверов авторизации и поддерживаемыми scopes. В приложении streamable HTTP устроено аналогично.

Фильтрация инструментов с некорректными x-mcp заголовками

Фильтрация выполняется только для современной эры, при наличии транспорта и не на stdio — иначе метод сразу возвращает управление, ничего не фильтруя. Затем он проходит по инструментам и для каждого сканирует объявления заголовков в его входной схеме; если сканирование невалидно, инструмент отбрасывается и в консоль выводится предупреждение с именем инструмента и причиной. Если после фильтрации длина массива изменилась, результат заменяется отфильтрованным.

Фильтрация применяется и к агрегированному кэшированному результату листинга, и к каждой странице при явном курсоре. На клиенте при вызове инструмента сканирование берётся из переданного определения инструмента, иначе из закэшированного; при отсутствии определения возвращается undefined. Если сканирование отсутствует, невалидно или не содержит объявлений, запрос уходит без заголовков Mcp-Param-*; иначе заголовки формируются из объявлений и аргументов и добавляются к заголовкам запроса.

Кэш ответов и свежесть

Запись в кэш вычисляется из тела результата: время жизни берётся из поля ttlMs, а если это не число — из значения по умолчанию. Область кэша становится public только при точном значении public, иначе private. Момент истечения — текущее время плюс время жизни, зажатое в диапазон от нуля до 24 часов: отрицательный TTL превращается в немедленно устаревшую запись, верхняя граница — сутки.

Для чтения ресурса на ревизии 2026-07-28 клиент сначала пробует отдать из кэша по ключу из метода и URI, а после запроса вычисляет свежесть и пишет в кэш только если запись ещё не истекла. При режиме обновления и неположительном TTL ключ вытесняется. Подсказка кэша применяется через поле в зарегистрированном ресурсе и его шаблоне — она влияет на результаты чтения ресурса на той же ревизии. На серверной стороне per-method заполнения времени жизни и области кэша применяются раннером после возврата обработчика, причём поля, заданные обработчиком явно, побеждают.

Пагинация листингов

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

Агрегат мутируется на месте: элементы добавляются, следующий курсор удаляется, затем вызывается финализация (для инструментов — та самая фильтрация некорректных заголовков), и результат пишется в кэш с вычисленной свежестью. Ошибки записи в стор передаются в onerror, оборачивая не-Error; ошибка чтения из кэша трактуется как промах и не прерывает диспатч.

Пограничные случаи и компромиссы

Автодополнение фиксированного URI ресурса не считается ошибкой: система просто не предлагает вариантов, хотя по спецификации это, вероятно, должно было бы быть ошибкой.

Валидация вывода инструмента требует structuredContent при наличии схемы и отсутствии ошибки, причём проверка идёт строго на === undefined, чтобы null, 0, false и пустая строка считались легальными значениями и валидировались схемой.

Валидация сохранённого вердикта отклоняет повреждённые блобы типизированной ошибкой до любого изменения состояния: неизвестный вид, legacy-вердикт с полями формы результата обнаружения, либо modern-полезная нагрузка, не проходящая схему.

Для подписок реализована явная машина состояний, где все пути завершения сходятся в единый settle, что делает невозможными зависания при отмене или закрытии до подтверждения. Компромисс: на 2025-эре соединении метод не поддерживается без прозрачной подмены, а автоматического переподключения нет — нужно вызывать listen() заново.

Что из этого следует на практике

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

Граф плейбуков с прогрессивным раскрытием решает проблему контекстного окна на уровне структуры: агент платит за вложенные плейбуки только тогда, когда они нужны. Тот же принцип применён к каталогу инструментов — три инструмента вместо тысяч, поиск по тегам и выбор по короткому описанию.

Число инструментов имеет практический потолок: после примерно 30 агент замедляется и деградирует, потому что каждый инструмент занимает место в контексте и провоцирует более частые компакции. Это ограничение спецификации, а не реализации, и обходится оно не расширением каталога, а его сокращением через поиск.

Согласование версий протокола устроено так, что современная ревизия никогда не согласуется через legacy-рукопожатие — только через server/discover. Legacy-шим закрывает обратную совместимость для развёрнутых клиентов 2025-эры, но его можно отключить, чтобы падать явно вместо отправки результата неверного типа.

Пограничные случаи продуманы до уровня, который важен для агентов: и

Где смотреть в коде

Источники

Похожее