Блог • Agent Security

После того как MCP-клиент отправил учётные данные OAuth не на тот сервер авторизации, на каком шаге JSON-журнала остановить вызов инструмента?

30 сентября 2026 года MCP TypeScript SDK опубликовал GHSA-6qxp-vccf-f47h, уровень High, CVSS 7.5. В затронутых версиях OAuth-клиент не привязывал сохранённые учётные данные к их серверу авторизации. MCP-сервер в соединении мог назвать другой сервер, и клиент отправлял учётные данные без действия пользователя. После сообщения оставляют строку JSON, которая останавливает вызов инструмента, а не запрос, который можно повторить.

Кто отвечает за права MCP, написано в статье о безопасности MCP. Решение о правах после прохождения схемы — в предыдущей статье. Эта статья начинается с журнала после сообщения: если credential_check не проходит, следующего вызова инструмента быть не должно.

Решение: если issuer различаются, остановитесь на credential_check. decision равен deny, credentialsSent равен false, и вызова инструмента дальше нет.

Что писать в первой строке журнала после сообщения?

Затронуты приложения, которые используют SDK как OAuth-клиент по HTTP, могут подключиться к MCP-серверу, которому не полностью доверяют, и хранят учётные данные легитимного сервера авторизации. Входы: authProvider на транспорте, middleware withOAuth и прямые вызовы auth или fetchToken. MCP-серверы, собранные этим SDK, и клиенты stdio вне этого сообщения. В 1.x версии 1.12.0–1.30.1 исправлены в 1.31.0. В 2.x client 2.0.0–2.1.0 затронут только для встроенных провайдеров без expectedIssuer, сохранённых данных без issuer, прямых вызовов fetchToken или провайдеров, которые читают хранилище через OAuthTokensSchema или OAuthClientInformationSchema. Исправление в 2.2.0. Если core импортируется напрямую, его тоже поднимают до 2.2.0. Текст сообщения: GHSA-6qxp-vccf-f47h.

Остановка, когда issuer различаются. Оба адреса — примеры, не серверы для подключения.
{
  "step": "credential_check",
  "savedIssuer": "https://auth.example.com",
  "namedAuthorizationServer": "https://other.example.net",
  "issuerMatch": false,
  "credentialsSent": false,
  "decision": "deny",
  "reason": "issuer_mismatch"
}

Эта строка записывает только остановку. savedIssuer — сервер авторизации, которому принадлежат учётные данные. namedAuthorizationServer — тот, который назвал этот MCP-сервер. Если строки различаются, issuerMatch равен false. decision равен deny, credentialsSent равен false. В поля не кладут ни токен, ни client secret. Списки уязвимостей обозначают ту же проблему как CVE-2026-104850. Статья следует версиям и исправлению из сообщения на GitHub.

Какая схема фиксирует форму этой строки JSON?

Используйте JSON Schema, чтобы зафиксировать перечисления и обязательные поля. Она доказывает, что это credential_check. decision может быть только allow, deny или ask. reason — только issuer_mismatch, issuer_match или awaiting_user. Она не доказывает, что этот шаг должен остановиться.

Форма остановки. issuerMatch false и decision allow всё ещё проходят эти перечисления.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["step", "savedIssuer", "namedAuthorizationServer", "issuerMatch", "credentialsSent", "decision", "reason"],
  "properties": {
    "step": { "enum": ["credential_check"] },
    "savedIssuer": { "type": "string" },
    "namedAuthorizationServer": { "type": "string" },
    "issuerMatch": { "type": "boolean" },
    "credentialsSent": { "type": "boolean" },
    "decision": { "enum": ["allow", "deny", "ask"] },
    "reason": { "enum": ["issuer_mismatch", "issuer_match", "awaiting_user"] }
  }
}

Прохождение схемы значит только то, что поля лежат в перечислениях. issuerMatch false, credentialsSent true и decision allow — всё ещё верная форма. Отправку останавливает правило приложения в следующем разделе, а не эта проверка формы.

Какие три правила всё равно останавливают шаг, когда форма верна?

Исправление в сообщении: клиент записывает сервер авторизации как issuer на учётных данных и не отправляет их другому. Пользователь входит снова, или вызов завершается ошибкой. Сравнение происходит вне модели. То, что модель скажет позже, не превращает шаг, уже отмеченный deny, в отправку.

Что видно в записиСхемаПравило приложения
issuerMatch равен false, а credentialsSent равен trueПроходит. true — это boolean.Верните false. Не отправляйте ни refresh_token, ни client_secret, ни подписанное утверждение.
issuerMatch равен false, а decision равен allowПроходит. allow есть в перечислении.Смените на deny и оставьте reason равным issuer_mismatch.
credential_check уже deny, а следом есть строка tool_callСхема одной строки не видит следующую. Если поставить step равным tool_call, эта запись сама не проходит.Журнал заканчивается на credential_check. Вызов инструмента не добавляют. Более поздний вызов, у которого issuer уже совпали, получает отдельное решение о правах.

После обновления версии какие три настройки всё ещё пишут вручную?

Поднимите 1.x до @modelcontextprotocol/sdk 1.31.0 или новее. Поднимите 2.x до @modelcontextprotocol/client 2.2.0 или новее. Обновление пакета не закрывает три строки ниже. Источник по-прежнему то же сообщение.

Что ещё остаётсяЧто требует сообщение
Встроенные провайдерыПередайте expectedIssuer в ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider и CrossAppAccessProvider, например https://auth.example.com. Без этого они по-прежнему берут сервер авторизации, который назвал MCP-сервер.
Сохранённые данные без issuerТокены и сведения о клиенте, сохранённые до обновления в файле, связке ключей или базе, при первом использовании всё ещё уходят на тогда названный сервер авторизации. Добавьте issuer или сотрите их, чтобы пользователь вошёл снова. Свой OAuthClientProvider сохраняет ровно то, что дают saveTokens и saveClientInformation, включая issuer. Заранее зарегистрированные данные включают issuer в том, что возвращает clientInformation.
Новый интерактивный входЭто исправление не покрывает новый интерактивный вход. Он по-прежнему идёт на сервер авторизации, который назвал MCP-сервер. Завершайте его только для серверов, которым доверяете. Если клиент мог подключиться к недоверенному MCP-серверу, смените client secret или ключ подписи и отзовите токены. Если обновиться пока нельзя, подключайте OAuth-клиенты только к MCP-серверам, которым доверяете. 2.0.0 и 2.1.0 уже принимают expectedIssuer.

Прямые вызовы refreshAuthorization или exchangeAuthorization тоже вне этого исправления. То же для 2.x, если skipIssuerMetadataValidation равен true. Не оставляйте ни один из случаев модели, чтобы она решала, отправлять ли учётные данные.

Чем это отличается от двух уже опубликованных статей?

Зачем нужна схемаБезопасность MCPЭта статья
ОтвечаетЗачем выводу нужна формаКто должен держать границу правКогда issuer различаются, какой шаг журнала останавливает вызов инструмента
Не пишетНе остановку этого OAuth-сообщенияНе порядок журнала после сообщенияНе запрос, который можно повторить, и не снова о том, хватает ли схемы

Как проверить эту остановку в JSONNote?

  1. 1
    Выбрать нужную операцию

    Откройте JSON Schema. Переключите действовать на Проверьте с помощью Schema. Остановку выше — в первое поле, схему — во второе. step может быть только credential_check. decision — только allow, deny или ask.

  2. 2
    Прогнать четыре плохих документа

    Поставьте decision равным yes, credentialsSent — строке false, удалите decision или поставьте step равным tool_call. Все четыре не проходят. Запись deny выше проходит. Прохождение значит только верную форму.

  3. 3
    Сравнить deny и allow с верной формой

    Откройте JSON Сравнить Diff. В первое поле — остановку deny. Во втором поменяйте только credentialsSent на true и decision на allow. reason остаётся issuer_mismatch. Оба проходят схему. Отправку останавливает правило приложения: если issuer различаются, решение должно быть deny, и ничего не отправляют.

Вопросы

Нужно ли клиенту stdio писать эту строку журнала?

Нет. Сообщение говорит, что клиенты stdio и MCP-серверы, собранные этим SDK, вне зоны. Строка credential_check нужна OAuth-клиенту по HTTP, который может подключиться к MCP-серверу без полного доверия и при этом хранит учётные данные легитимного сервера авторизации.

Достаточно ли обновиться до 1.31.0 или 2.2.0?

Нет. Встроенным провайдерам нужен expectedIssuer. Учётным данным без issuer нужно это поле, или их стирают. Новый интерактивный вход по-прежнему завершают только для доверенных серверов. Прямые вызовы refreshAuthorization или exchangeAuthorization и skipIssuerMetadataValidation со значением true вне этого исправления.

Если issuer совпали, можно ли выполнять вызов инструмента?

Не из этого одного. Совпадение issuer значит только то, что учётные данные не ушли на другой сервер авторизации. Вызову инструмента всё ещё нужно отдельное решение о правах: инструмент в списке разрешённых, и есть ли эта способность в grant. Это предыдущая статья. Эта не записывает тот вызов после deny.

Это та же статья, что о безопасности MCP?

Нет. Та статья о том, кто должен держать границу прав. Эта — о том, какой шаг JSON-журнала останавливает вызов инструмента после сообщения от 30 сентября 2026 года. Обе ведут к инструментам, но срез другой.

Нужно ли хранить refresh_token в журнале?

Нет. Остановка хранит только две строки issuer, совпали ли они, было ли что-то отправлено, decision и reason. Токены и client secret в этот JSON не входят. Если клиент мог подключиться к недоверенному серверу, смените ключ и отзовите токены, а не копируйте их в журнал.

Итог

Учётные данные сначала привязывают к их исходному issuer. Если MCP-сервер называет другой сервер авторизации, журнал останавливается на credential_check: deny, не отправлять, не записывать вызов инструмента. Обновление версии помещает это сравнение в клиент. expectedIssuer и старые сохранения по-прежнему заполняете вы.

Решение: сначала сравните issuer; если они различаются, deny; credentialsSent держите false; не добавляйте вызов инструмента; запишите expectedIssuer у встроенных провайдеров; если клиент мог подключиться к недоверенному серверу, смените ключ и отзовите токены.

Дальше: зафиксируйте эту остановку схемой, затем через Diff посмотрите запись deny рядом с записью верной формы, у которой decision равен allow.

Назад в блог