Руководство

Как MCP-клиент фиксирует OAuth issuer: что остаётся после Python SDK 1.30 и 2.2

OAuth на сервере решает только, кто может вызвать инструмент. Сообщение от 28 сентября спрашивает клиента: принимаете ли вы службу входа, которую только что назвало обнаружение?

28 сентября 2026 сопровождающие официального Python SDK MCP подтвердили: уязвимый HTTP-клиент мог отправить client secret, authorization code и доказательство PKCE, предназначенные настоящей службе входа, на token endpoint, который контролирует другая сторона. Исправленные выпуски — 1.30.0 на линии 1.x и 2.2.0 на линии 2.x. Они вышли 7 сентября, и в заметках к выпуску это назвали изменением поведения. Само сообщение вышло 28 сентября, в тот же день Cycode опубликовала исследование, на следующий день его пересказали профильные издания. Отправная точка — пересказ The Hacker News. Как клиент устроен сейчас, написано в OAuth clients. Как сервер требует токен, по-прежнему в материале MCP OAuth 2.1. Если удалённый сервер вам не принадлежит целиком, форма выкладки — в удалённый MCP в проде.

Сообщение чинит клиента, а не сервер, который вы уже защитили

Затронут процесс, который использует официальный Python SDK как HTTP-клиент и включает OAuth. Он подключается к MCP-серверу, которому не доверяет полностью, и при этом держит учётные данные настоящей службы входа. Сопровождающие пишут: на части путей обнаружения ожидаемая служба входа не была выбрана до того, как клиент принял ту, которую назвал сервер. Исправленные выпуски сначала назначают ожидаемый issuer, потом забирают метаданные сервера авторизации. Поле issuer в документе должно называть именно эту сторону, иначе клиент отказывается. Сохранённые учётные данные помечаются службой входа: запись для A не используется, чтобы обменять токен у B.

Это другой слой, не «добавить OAuth 2.1 на MCP-сервер». Сервер решает, кто может вызвать инструмент и выпущен ли токен для этого ресурса. Клиент решает, тот ли сервер авторизации, который назвало обнаружение, выдал client secret. Нужны оба. Защищённый сервер не мешает клиенту на обнаружении пойти за чужой стороной. Исправленный клиент не закрывает сервер, который по-прежнему не проверяет аудиторию токена.

Изменение, которое вошло в основную линию, — PR 3398. Метаданные сервера авторизации должны называть в issuer тот сервер, с которого их забрали. Правило — RFC 8414, раздел 3.3. При расхождении останавливайтесь. Не добавляйте на клиенте переключатель «игнорировать issuer».

Что вы запускаете Это сообщение Что сделать после обновления
HTTP-клиент, OAuthClientProvider1.9.1–1.29.1 или 2.0.0–2.1.1Перейти на 1.30.0 или 2.2.0 и удалить старый client_info
Client credentials или JWT с закрытым ключомТе же диапазоны версийЕщё передать issuer
Сервер на SDK, stdio или свой токенВне этого сообщенияВсё равно перевести зависимость на исправленный выпуск

Сначала отделите, кто в зоне действия

На 1.x диапазон — с 1.9.1 по 1.29.1. На 2.x — с 2.0.0 по 2.1.1. Классы: OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider и устаревший RFC7523OAuthClientProvider, у которого нет аргумента issuer. Cycode оценила проблему как высокую. Локальный stdio, клиенты, которые сами подставляют токен, и установки, где SDK только пишет сервер, вне этого сообщения.

Узкая зона не значит, что в репозитории можно оставить старый pin. CLI, ночная задача и настольный клиент часто делят одну зависимость mcp в одном lock-файле. На схеме SDK нужен только серверу, а HTTP-клиент на 1.29 всё равно установлен. Поднимайте по версии в lock-файле, а не по роли на слайде.

На старых версиях сообщение даёт одно временное правило: клиент с OAuth подключается только к серверам, которым вы доверяете. Если он уже ходил на сервер, которому вы не доверяете, смените client secret у службы входа и отзовите выданные токены. Этот шаг про учётные данные, которые могли уже покинуть вашу среду. Это не приглашение заново проиграть обнаружение.

После 1.30 или 2.2 машине-машине всё ещё нужен issuer

Браузерный поток OAuthClientProvider в исправленных выпусках сначала выбирает ожидаемую службу входа, потом забирает метаданные. Два потока без браузера так не делают. ClientCredentialsOAuthProvider и PrivateKeyJWTOAuthProvider требуют issuer, равный значению issuer в документе /.well-known/oauth-authorization-server этого сервера авторизации. Обнаружение всё ещё идёт, но запросы токена строятся только из метаданных этой стороны. Если MCP-сервер указывает в другое место, поток останавливается с OAuthFlowError.

Без issuer эти два провайдера и на исправленном выпуске идут за тем, что вернуло обнаружение. Сообщение прямо говорит: без аргумента обновление не доходит до схемы «машина-машина». На 1.30.0 напоминание — обычный DeprecationWarning, который Python по умолчанию прячет, поэтому в логах CI его часто нет. В 3.0 аргумент станет обязательным. Запишите его сейчас. Не ждите, пока мажорная версия уронит задачу.

У RFC7523OAuthClientProvider нет аргумента issuer. Оставаться на нём значит, что обновление не зафиксирует службу входа. Перейдите на ClientCredentialsOAuthProvider или PrivateKeyJWTOAuthProvider и передайте issuer. Секрет читайте из окружения или менеджера секретов. В репозиторий его не кладите.

В метаданных, которым вы доверяете, issuer должен совпасть с адресом, откуда их взяли

Заберите документ с сервера авторизации, которому уже доверяете, например https://auth.example.com/.well-known/oauth-authorization-server. issuer внутри должен быть этим сервером. Сравнение — равенство строк, без нормализации. Хвостовой слэш, другой регистр хоста или префикс www — другое значение. Передавайте клиенту точную строку из документа. Не набирайте адрес, который только выглядит так же.

Этот JSON — метаданные сервера авторизации, которому вы доверяете. issuer совпадает с источником, откуда документ получен, а token endpoint стоит на том же хосте.

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true
}

Метаданные защищённого ресурса только говорят клиенту, какой сервер авторизации защищает этот MCP-сервер. Они не заменяют личность службы входа. Клиент всё равно забирает метаданные той стороны и сверяет issuer. Если метаданные ресурса называют A, а документ сервера авторизации называет себя B, остановитесь. Не фиксируйте B только чтобы интеграция стала зелёной.

Редакция от 28 июля 2026 понижает динамическую регистрацию в пользу Client ID Metadata Document. Вы публикуете один клиентский JSON по стабильному HTTPS-адресу, и этот URL есть client_id. SDK принимает его как client_metadata_url. Когда сервер авторизации объявляет поддержку, клиент больше не просит у /register свежую пару id и secret. URL должен быть HTTPS с путём не в корне, это проверяется при создании. Сохранённый client_info по-прежнему важнее документа: старая запись на месте — и новый issuer не вступит в силу.

Удалите старый client_info, а секреты меняйте отдельным шагом

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

Удалить запись и сменить секрет — разные работы. Очистка client_info нужна, чтобы новый код зарегистрировался заново. Смена client secret и отзыв токенов у службы входа нужны, потому что учётные данные могли уже быть предъявлены серверу, которому вы не доверяете. Без такой истории не меняйте секрет у каждого внутреннего клиента. С такой историей делайте это у службы входа. Не вставляйте secret в переписку.

Верните state и iss из callback провайдеру без изменений. Провайдер сравнивает state со значением, которое сам создал, а iss — с обнаруженным issuer. Эти два поля — защита от подмены, не отладочные поля. Разбор callback, который выбрасывает iss, снимает проверку, которую исправленный выпуск только что добавил.

Положите зафиксированный issuer в репозиторий и ревьюьте его как контракт

У каждой точки MCP одна запись: URL сервера и разрешённый issuer. Значение копируйте дословно из метаданных сервера авторизации. Ревьюйте отформатированный JSON, а не имя хоста из чьих-то заметок. Между двумя выкладками diff показывает, кто перенёс issuer на другой хост.

В репозитории оставьте эту карту. client_secret сюда не кладите. Секрет остаётся в менеджере секретов. Этот файл фиксирует только службу входа.

{
  "clients": [
    {
      "mcp_server": "https://orders.example.com/mcp",
      "issuer": "https://auth.example.com"
    }
  ]
}

Конструктор «машина-машина» и этот список должны использовать одну и ту же строку. В списке https://auth.example.com, а в коде лишний слэш — исправленный клиент считает это другой стороной и останавливается. Такой сбой и нужен: задача краснеет на OAuthFlowError, а не молча меняет token endpoint.

Перед коммитом Форматирование JSON раскладывает метаданные и список issuer, проверка JSON Schema подтверждает, что issuer и token_endpoint — строки, а сравнение JSON показывает, кто сменил службу входа.

Частые вопросы

Если у нас только локальный stdio-сервер, это нас касается?

Клиент stdio вне этого сообщения. Если в той же среде есть HTTP-клиент на этом пакете, поднимайте по его версии. Lock-файл с 1.9.1–1.29.1 или 2.0.0–2.1.1 переводится на соответствующий исправленный выпуск. Не пропускайте шаг из-за подписи «в основном stdio».

Мы перешли на 1.30.0, в логе нет предупреждения. Этого достаточно?

Нет. Если ClientCredentialsOAuthProvider или PrivateKeyJWTOAuthProvider создан без issuer, напоминание — DeprecationWarning, скрытый по умолчанию. Включите предупреждения об устаревании или передайте issuer в конструктор. Тихий лог не значит, что аргумент задан.

Динамическая регистрация или Client ID Metadata Document?

Если сервер авторизации объявляет client_id_metadata_document_supported, опубликуйте клиентский JSON по HTTPS и используйте этот URL как client_id. Вы перестаёте каждый раз просить secret у /register. Если объявления нет, SDK возвращается к динамической регистрации. В обоих случаях сохранённый client_info важнее, поэтому после обновления удалите старую запись.

Как это стыкуется со статьёй про OAuth 2.1 на сервере?

Серверная статья — про то, как сервер ресурса требует access token. Эта — про то, чтобы клиент не пошёл за чужой службой входа. Если удалённый сервер не за вашим шлюзом, читайте обе: сервер проверяет токен, клиент фиксирует issuer в конструкторе и в списке в репозитории.

Итог

Сообщение от 28 сентября убирает из поведения по умолчанию правило «верь тому, кого назвало обнаружение». Переведите HTTP-клиенты на 1.30.0 или 2.2.0. Для схемы «машина-машина» передайте issuer, дословно совпадающий с метаданными. Устаревший провайдер RFC7523 замените.

Затем удалите старый client_info. Если клиент ходил на сервер, которому вы не доверяете, смените секрет и отзовите токены у службы входа. В репозитории оставьте только URL сервера и issuer и следите за этой строкой форматированием, проверкой схемы и сравнением.