Перейти к содержимому
5 мин чтения

Схема MCP-инструмента должна быть закрытой

Схема MCP-инструмента должна быть закрытой: разбираем JSON Schema, лимиты строк и серверную проверку аргументов модели.

Схема MCP-инструмента должна быть закрытой

Схема MCP-инструмента должна описывать не всё, что ваш внутренний API умеет принять, а только один безопасный запрос, который модель вправе сделать сейчас. Если инструмент принимает произвольный объект, длинный текст и несколько неявных режимов, модель рано или поздно отправит лишнее. Иногда это будет просто шум. Иногда она передаст чужой идентификатор, внутренний фильтр, инструкцию из документа или параметр, который разработчик оставил «на будущее».

Проблема не в том, что модель плохо следует инструкции. MCP-инструмент получает аргументы в машинном интерфейсе, а машинный интерфейс обязан ограничивать вход сам. Промпт может попросить «не передавать служебные поля», но сервер должен отвергнуть их, если они появились. Иначе вы выдали модели более широкие полномочия, чем предполагали.

Спецификация Model Context Protocol определяет inputSchema как JSON Schema для параметров инструмента. В актуальной документации MCP для схем без явного $schema используется диалект JSON Schema 2020-12, а для инструмента без параметров прямо рекомендуют объект с additionalProperties: false. Это правильная отправная точка, но она не заменяет проверку на сервере и не делает широкую операцию безопасной сама по себе.

Широкий объект даёт модели лишние пути

Широкая схема опасна потому, что каждый необязательный параметр становится ещё одним вариантом действия, который модель может выбрать из контекста. Особенно плохо выглядят поля options, filters, metadata, query, payload и params с типом object без вложенной схемы. Они часто появляются как удобный мост к существующему API. На практике этот мост переносит наружу все старые возможности API, включая те, которые вы не собирались давать агенту.

Представьте инструмент для подготовки возврата:

{
  "name": "create_refund",
  "inputSchema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" },
      "options": { "type": "object" }
    },
    "required": ["order_id"]
  }
}

С виду модель должна передавать номер заказа. Но options открывает вопрос: какие именно поля примет нижележащий сервис? refund_to_original_method, reason_code, amount, currency, override_limit, notify_customer, actor_id? Даже если часть из них сервер сегодня игнорирует, вы создаёте зависимость от случайного поведения. После следующего обновления внутреннего API «безобидное» поле может начать работать.

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

Правильнее дать отдельному инструменту короткую работу. Например, create_refund_draft может создавать только черновик на полную сумму заказа и не принимать ни сумму, ни способ возврата, ни произвольные метаданные. Если бизнесу нужен частичный возврат, создайте другой маршрут с отдельной проверкой и подтверждением. Это не избыточная бюрократия. Это граница полномочий, которую можно увидеть, протестировать и объяснить аудитору.

Закрытая схема должна отвергать неизвестные поля

additionalProperties: false запрещает свойства, которые не перечислены в properties и не разрешены через patternProperties. Для плоского объекта это самый прямой способ не дать модели протащить «на всякий случай» чужой параметр.

Ниже схема инструмента, который ищет клиента только по одному идентификатору. Она намеренно не принимает диапазон дат, SQL-подобный фильтр, флаг включения персональных данных и произвольный набор полей.

{
  "name": "get_customer_summary",
  "description": "Возвращает краткую сводку по клиенту, доступному текущему пользователю.",
  "inputSchema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "customer_id": {
        "type": "string",
        "minLength": 1,
        "maxLength": 36,
        "pattern": "^[A-Za-z0-9_-]+$",
        "description": "Идентификатор клиента из текущего рабочего контекста."
      }
    },
    "required": ["customer_id"]
  }
}

Эта схема отвергнет такой вызов до исполнения:

{
  "customer_id": "cust_7D2k",
  "include_pii": true
}

Она также отвергнет опечатку customerId. Это может показаться строгим, но молчаливое принятие почти правильного поля хуже: модель считает, что операция прошла с одним смыслом, а сервер исполняет другой. Если вам нужна совместимость со старым именем, нормализуйте его в отдельном адаптере и не оставляйте оба имени открытыми в публичной схеме.

Закрытие нужно повторять для каждого вложенного объекта. Вот распространённая ошибка:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "recipient": {
      "type": "object",
      "properties": {
        "email": { "type": "string" }
      },
      "required": ["email"]
    }
  },
  "required": ["recipient"]
}

Корневой объект закрыт, но recipient остаётся открытым. В него проходят role, api_key, send_copy_to, is_admin и всё остальное, если прикладной код это читает. Исправление простое: добавьте additionalProperties: false именно внутрь recipient.

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "recipient": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "email": {
          "type": "string",
          "minLength": 3,
          "maxLength": 254
        }
      },
      "required": ["email"]
    }
  },
  "required": ["recipient"]
}

additionalProperties и unevaluatedProperties решают разные задачи

Разработчики часто считают эти два ключевых слова взаимозаменяемыми. Они похожи только в простой схеме. Разница проявляется, когда вы собираете объект через allOf, oneOf, if или $ref.

additionalProperties смотрит на properties и patternProperties в своём месте схемы. Поэтому базовый объект с additionalProperties: false может отвергнуть поле, которое другое подусловие добавляет через allOf. Разработчик обычно отвечает на это тем, что повторяет свойства во внешней схеме. После пары таких правок схема начинает расходиться с реальным контрактом.

unevaluatedProperties: false работает иначе. Он запрещает свойства, которые не были обработаны подходящими подcхемами при вычислении результата. Документация JSON Schema специально выделяет этот случай как решение для расширяемых схем с композициями.

Например, вы хотите разрешить два разных способа адресовать заявку: по короткому ticket_id или по паре project и number.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "oneOf": [
    {
      "properties": {
        "ticket_id": {
          "type": "string",
          "pattern": "^TKT-[0-9]{1,8}$"
        }
      },
      "required": ["ticket_id"]
    },
    {
      "properties": {
        "project": {
          "type": "string",
          "enum": ["billing", "support", "security"]
        },
        "number": {
          "type": "integer",
          "minimum": 1,
          "maximum": 99999999
        }
      },
      "required": ["project", "number"]
    }
  ],
  "unevaluatedProperties": false
}

Здесь oneOf важнее косметики. Он запрещает неоднозначный запрос, где модель передаёт и ticket_id, и project с number. Это отдельное правило безопасности: если сервер получает два способа выбрать объект, он не должен угадывать, какой имеет приоритет.

Но сначала проверьте свой стек. Документ MCP 2025-11-25 и более новые черновики описывают JSON Schema 2020-12, включая более богатые композиции. Старые SDK, прокси и валидаторы нередко обрабатывают только type, properties и required. Если клиент не умеет unevaluatedProperties, он может показать модели красивую схему, но не применить ваш запрет там, где вы рассчитывали. Схема в репозитории не равна схеме, которая реально валидирует запрос.

Ограничения строк уменьшают объём и двусмысленность

Поле типа string без ограничений означает «принимаю любой текст любой длины». Для адреса, идентификатора, темы письма и комментария это четыре разных контракта. Не описывайте их одним типом.

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

Для управляемых значений используйте enum, а не свободный текст. Например, режим доставки не должен быть строкой с описанием:

{
  "delivery_mode": {
    "type": "string",
    "enum": ["email", "sms", "none"]
  }
}

Это лучше, чем pattern: ".*" и описание «допустимые варианты: email, sms, none». Описание помогает модели выбрать значение, но enum заставляет сервер отвергнуть email_and_sms, urgent_sms и текст, принесённый из внешней страницы.

Для кодов и идентификаторов сочетайте длину с точным шаблоном. maxLength сам по себе разрешит пробелы, управляющие символы и фразы. pattern сам по себе может допустить огромную строку, которая соответствует регулярному выражению. Ограничения дополняют друг друга.

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

Свободный текст иногда нужен. Тогда задайте границы честно: minLength, maxLength, понятное назначение и серверную очистку. Никогда не подставляйте такой текст напрямую в SQL, shell-команду, URL, шаблон запроса или системный промпт. Валидная строка всё ещё может быть опасным содержимым в следующем компоненте.

Схема проверяет форму, сервер проверяет разрешение и смысл

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

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

У сервера должны быть три отдельные проверки:

  1. Он валидирует весь входной объект по той же схеме, которую публикует в tools/list.
  2. Он вычисляет субъект доступа из проверенной серверной аутентификации, а не из user_id, tenant_id или actor в аргументах модели.
  3. Он проверяет бизнес-условия перед действием: статус записи, лимит, идемпотентность, согласие пользователя и разрешённый переход состояния.

Разделение важно. Представьте запрос на экспорт:

{
  "report_id": "rpt_4821",
  "format": "csv"
}

Схема подтверждает, что report_id имеет допустимую форму, а format входит в список. Авторизация отвечает, может ли этот пользователь видеть отчёт rpt_4821. Бизнес-проверка отвечает, завершён ли отчёт, не истёк ли срок доступа и не требует ли экспорт отдельного согласия. Если смешать эти уровни в один обработчик с условными операторами, команда начинает чинить исключения вместо контракта.

На практике серверный обработчик должен выглядеть скучно. Это хороший признак.

const parsed = ExportReportInput.safeParse(request.arguments);
if (!parsed.success) {
  return {
    isError: true,
    content: [{ type: "text", text: "Некорректные аргументы инструмента." }]
  };
}

const principal = await requireAuthenticatedPrincipal(request);
const report = await reports.findVisibleTo(principal.orgId, parsed.data.report_id);

if (!report) {
  return {
    isError: true,
    content: [{ type: "text", text: "Отчёт недоступен." }]
  };
}

if (report.status !== "ready") {
  return {
    isError: true,
    content: [{ type: "text", text: "Отчёт ещё нельзя экспортировать." }]
  };
}

return exportReport(report, parsed.data.format);

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

OWASP в материалах по агентным приложениям и проверке LLM отдельно требует валидировать ожидаемую структуру JSON и не принимать лишние свойства. Это не формальность для аудита. Неожиданное поле часто становится либо обходом прикладной логики, либо каналом, по которому недоверенный текст доходит до более привилегированного действия.

Описание инструмента не является механизмом запрета

Хорошее описание нужно, но оно не контролирует вызов. Оно влияет на то, как модель выбирает инструмент и формирует аргументы. Слова «не передавай персональные данные» не остановят поле note, если ваша схема позволяет любую строку до мегабайтного размера. Слова «используй только текущего клиента» не остановят customer_id, если сервер не сверяет его с областью доступа.

Смешение описания и политики создаёт особенно неприятную ошибку. Команда смотрит на текст инструмента, видит ясные ограничения и считает, что они применяются. Затем другой клиент, новая модель или внешняя инструкция формирует валидный JSON с нежелательным полем. Сервер честно исполняет то, что ему разрешили кодом.

Используйте описание для смысла, а схему для формы. Политику доступа держите в серверном коде. Например:

  • описание: «Создаёт черновик сообщения для выбранного получателя»;
  • схема: получатель задан только коротким идентификатором, тема ограничена 120 символами, неизвестные поля запрещены;
  • сервер: получатель доступен текущему пользователю, шаблон разрешён, отправка не запускается.

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

Универсальный action почти всегда размывает границу

Сведите модели в один счёт
Получайте B2B-инвойсинг в тенге по ставкам провайдеров без наценки на API.

Популярная рекомендация звучит так: сделайте один инструмент manage_customer, добавьте поле action, а параметры положите в data. Разработчикам нравится этот подход, потому что он сокращает число деклараций. Модели он тоже иногда нравится, потому что видит один знакомый вход. Но безопасность и поддержка от этого проигрывают.

Вот форма, которую не стоит публиковать:

{
  "type": "object",
  "properties": {
    "action": { "type": "string" },
    "data": { "type": "object" }
  },
  "required": ["action", "data"]
}

Схема не говорит, какие данные нужны для update_email, какие для merge_accounts, какие для delete_customer и какие операции допускаются без согласия. В обработчике быстро появляется ветвление, в каждой ветке немного иной набор проверок. Через несколько месяцев никто не уверен, закрыто ли data везде и одинаково ли проверяется организация.

Если операции действительно различаются по воздействию, создайте разные инструменты. get_customer_summary не должен иметь возможности изменить адрес. create_email_change_draft не должен отправлять письмо. confirm_email_change не должен принимать новый адрес второй раз, а должен подтверждать уже созданный черновик по серверному идентификатору.

Иногда единый инструмент необходим, например для поиска с несколькими взаимоисключающими ключами. Тогда оформите режимы через oneOf, ограничьте каждую ветку, сделайте их взаимоисключающими и всё равно держите права на сервере. Поле action не запрещено само по себе. Открытый data рядом с ним почти всегда означает, что границу забыли спроектировать.

Отрицательные тесты показывают, работает ли запрет

Не смешивайте шлюз и полномочия
Маршрутизируйте модели через один OpenAI-совместимый эндпоинт, а проверку MCP-вызовов оставьте серверу инструмента.

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

Для каждого инструмента заведите небольшой набор отрицательных случаев. Не надо строить генератор ради пяти полей, но нельзя ограничиться тестом счастливого пути.

[
  {
    "name": "лишнее поле",
    "arguments": {
      "customer_id": "cust_7D2k",
      "include_pii": true
    },
    "valid": false
  },
  {
    "name": "слишком длинный идентификатор",
    "arguments": {
      "customer_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    },
    "valid": false
  },
  {
    "name": "объект вместо строки",
    "arguments": {
      "customer_id": { "value": "cust_7D2k" }
    },
    "valid": false
  },
  {
    "name": "допустимый запрос",
    "arguments": {
      "customer_id": "cust_7D2k"
    },
    "valid": true
  }
]

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

Проверьте и несовместимость версий. Если вы используете oneOf, условные схемы или unevaluatedProperties, отправьте реальные объявления в те клиенты и шлюзы, через которые идёт трафик. Спецификация MCP развивается, а ваша безопасность зависит от конкретного валидатора, его версии и места, где он подключён.

Для команд, которые вызывают модели через единый OpenAI-совместимый шлюз, это ещё и причина не растворять проверку в роутере запросов. AI Router может дать единый путь к моделям и контроль на уровне ключа, но контракт MCP-инструмента и решение об исполнении должны оставаться рядом с сервером инструмента. Именно там известны объект, пользователь, организация и последствия вызова.

Сначала уменьшите контракт, потом добавляйте удобство

Начинайте проектирование инструмента с вопроса: какой самый маленький набор данных позволяет выполнить одно полезное действие? После этого добавляйте поле только при наличии конкретного сценария, теста и правила авторизации. Не добавляйте metadata, «настройки на будущее» и необязательные флаги только потому, что они есть во внутреннем API.

Если поле нужно только серверу, не показывайте его модели. Идентификатор организации берите из сессии. Регион, тариф, служебные лимиты и флаги эксперимента определяйте на стороне сервера. Если модель должна выбрать значение, предложите короткий enum или отдельный безопасный идентификатор, а не свободный объект.

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

Часто задаваемые вопросы

Достаточно ли additionalProperties false для MCP-инструмента?

Да. additionalProperties: false запрещает поля, которых нет в properties, но только в области действия конкретного объекта схемы. Если вы собираете объект через allOf, oneOf или ссылки, проверьте поведение валидатора: в таких схемах часто нужен unevaluatedProperties: false на внешнем уровне.

Зачем ограничивать длину строк в аргументах MCP?

Потому что строка без лимита имеет почти бесконечный объём для модели: туда попадает лишний контекст, инструкции из внешнего текста, большие списки и случайные секреты. Для каждого строкового поля задавайте maxLength, а для идентификаторов и кодов добавляйте pattern или enum.

Может ли JSON Schema заменить серверную проверку прав?

Нет. JSON Schema описывает допустимую форму данных, но не проверяет права пользователя, состояние записи, владельца счёта или допустимость операции в текущем бизнес-процессе. Сервер обязан повторно проверить и схему, и авторизацию, и смысл запроса перед побочным действием.

Лучше один универсальный MCP-инструмент или несколько узких?

Для простого инструмента обычно лучше сделать несколько узких операций: get_customer, list_customer_invoices, create_invoice_draft. Универсальный параметр action оправдан, только если варианты действительно используют одинаковые входные данные и одинаковый уровень доступа. Иначе ветки быстро превращаются в обход ограничений.

Нужно ли доверять format email и format uri в JSON Schema?

format полезен как подсказка и как дополнительная проверка, но поддержка форматов зависит от используемого валидатора. Для критичных значений не полагайтесь только на format: email или format: uri: задайте длину, допустимый хост, схему URL и проверьте значение прикладным кодом.

Что должен вернуть сервер при лишнем поле в вызове инструмента?

Возвращайте структурированную ошибку без внутренних деталей: код, имя поля и понятную причину. Не отправляйте модели SQL-текст, стек исключения, внутренние URL, имена таблиц или фрагменты конфигурации. Такие сведения редко помогают исправить аргументы и часто расширяют следующую попытку атаки.

Можно ли ограничить опасный аргумент только описанием поля?

Нет, если модель может передать произвольный текст. Описание влияет на генерацию, но не запрещает аргумент и не ограничивает его размер. Запреты должны находиться в схеме и в коде, который исполняет действие.

Нужно ли подтверждение пользователя для опасного MCP-вызова?

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

Когда в MCP-схеме применять oneOf и if then else?

Да, если различия между вариантами существенны. oneOf хорошо подходит для взаимоисключающих режимов, например поиска по customer_id или по заранее разрешённому email; if и then годятся для зависимых полей. Но сначала убедитесь, что ваш MCP-клиент и валидатор поддерживают JSON Schema 2020-12, а не только базовые properties и required.

Как тестировать строгую схему MCP-инструмента?

Соберите отрицательные тесты рядом со схемой и прогоняйте их в CI: лишнее поле, пустая строка, слишком длинная строка, неверный enum, объект вместо строки, непредусмотренная ветка. Затем добавьте интеграционные тесты, которые доказывают, что сервер не исполняет действие при любой ошибке. Успешные примеры проверяют удобство, отрицательные проверяют границу доступа.