Перейти к содержанию

OAuth-клиенты

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

Некоторые MCP-серверы защищены. Отправьте им запрос без токена — и в ответ придёт 401 Unauthorized.

OAuthClientProvider — это способ получить токен. Это вовсе не объект MCP. Это httpx2.Auth, стандартный хук httpx2 для задачи «сделать что-то с каждым запросом». Его подключают к httpx2.AsyncClient, передают этот клиент транспорту Streamable HTTP — и больше о нём не думают.

Эта страница — о клиентской стороне. Как заставить собственный сервер требовать токен, описано на странице Авторизация.

Провайдер

client.py
from urllib.parse import parse_qs, urlparse

import httpx2
from pydantic import AnyUrl

from mcp import Client
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken


class InMemoryTokenStorage:
    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info


async def open_browser(authorization_url: str) -> None:
    print(f"Visit: {authorization_url}")


async def wait_for_callback() -> AuthorizationCodeResult:
    redirect_url = input("Paste the URL you were redirected to: ")
    params = parse_qs(urlparse(redirect_url).query)
    return AuthorizationCodeResult(
        code=params["code"][0],
        state=params["state"][0],
        iss=params["iss"][0] if "iss" in params else None,
    )


oauth = OAuthClientProvider(
    server_url="http://localhost:8001/mcp",
    client_metadata=OAuthClientMetadata(
        client_name="Bookshop Agent",
        redirect_uris=[AnyUrl("http://localhost:3030/callback")],
        scope="user",
    ),
    storage=InMemoryTokenStorage(),
    redirect_handler=open_browser,
    callback_handler=wait_for_callback,
)


async def main() -> None:
    async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
        transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
        async with Client(transport) as client:
            result = await client.list_tools()
            print([tool.name for tool in result.tools])

Ему передают четыре вещи:

  • server_url: конечная точка MCP, к которой вы подключаетесь. Всё остальное провайдер выясняет по ней сам.
  • client_metadata: то, что вы ввели бы в форму «зарегистрировать приложение» на сервере авторизации.
  • storage: место, где токены хранятся между запусками.
  • redirect_handler и callback_handler: два момента, когда участвует человек.

Больше нигде в файле OAuth не упоминается. main() токена не видит вовсе.

Метаданные клиента

OAuthClientMetadata — это настоящий регистрационный документ из RFC 7591, оформленный как модель Pydantic.

Вы задаёте три поля. Остальное заполняют значения по умолчанию: grant_types уже равно ["authorization_code", "refresh_token"], а response_types["code"], и это ровно тот сценарий, который выполняет провайдер.

Check

Поскольку это модель Pydantic, она проверяется ещё до того, как хоть один байт уйдёт в сеть. Опустите redirect_uris — и создание объекта тут же завершится ошибкой ValidationError, в которой названо поле:

redirect_uris
  Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]

Браузер не открылся, и на сервере авторизации не осталось недоделанной регистрации.

Хранилище токенов

TokenStorage — это Protocol с четырьмя асинхронными методами. Наследоваться ни от чего не нужно: напишите эти методы — и любой класс станет хранилищем токенов:

  • get_tokens / set_tokens хранят OAuthToken: токен доступа, токен обновления, срок действия, область доступа (scope).
  • get_client_info / set_client_info хранят OAuthClientInformationFull, который сервер авторизации выдал, когда провайдер вас зарегистрировал, — включая ваш client_id.

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

Tip

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

Два обработчика

Сценарию с кодом авторизации человек нужен ровно один раз: кто-то должен войти в систему и нажать «разрешить».

  • redirect_handler асинхронно вызывается с полностью собранным URL авторизации. client_id, redirect_uri, state и PKCE challenge в нём уже есть. Ваша единственная задача — открыть его в браузере. Настольное приложение вызывает webbrowser.open; в этом файле URL просто печатается.
  • callback_handler вызывается следующим. Он ждёт, пока пользователь вернётся на ваш redirect_uri, и возвращает параметры запроса из этого перенаправления в виде AuthorizationCodeResult.

Настоящий клиент вместо вызова input() поднимает небольшой локальный HTTP-сервер на URI перенаправления. Схема та же: принять перенаправление, вернуть code, state и iss.

Warning

Передавайте state и iss ровно в том виде, в каком они пришли. Провайдер сравнивает state со значением, которое сгенерировал сам, а iss — с издателем, которого обнаружил, и при несовпадении отказывает. Это защита от CSRF и от подмены сервера (mix-up).

Подключение к Client

Посмотрите на main(). Провайдер подключается к клиенту httpx2, клиент httpx2 передаётся в streamable_http_client(url, http_client=...), а этот транспорт — в Client.

У streamable_http_client нет именованного параметра auth=. Всё, что относится к уровню HTTP (аутентификация, заголовки, тайм-ауты, прокси), настраивается на httpx2.AsyncClient, который вы приносите сами. Об этом разделении уровней — на странице Транспорты клиента.

Что провайдер делает за вас

Когда Client отправляет первый запрос, сервер отвечает 401. Дальше действует провайдер:

  1. Обнаружение. Он читает заголовок WWW-Authenticate, загружает метаданные защищённого ресурса (Protected Resource Metadata) сервера с /.well-known/oauth-protected-resource, узнаёт, какой сервер авторизации защищает этот ресурс, и загружает метаданные уже того сервера.
  2. Регистрация. В хранилище пусто? Он динамически регистрирует вас с вашим OAuthClientMetadata и сохраняет результат.
  3. Авторизация. Он генерирует пару PKCE и state, собирает URL авторизации, ждёт ваш redirect_handler, а затем ждёт от callback_handler код.
  4. Обмен. Он обменивает код на OAuthToken, сохраняет его и повторяет исходный запрос уже с Authorization: Bearer ....

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

Ничего из этого вы не писали. Остаются два именованных аргумента (client_metadata_url и validate_resource_url), и этому файлу не нужен ни один из них. О client_metadata_url стоит знать — ему посвящён отдельный раздел ниже.

Попробуйте сами

Большинство примеров в этой документации можно проверить с Client(server) в памяти. Этот — нет: весь смысл сценария в HTTP-ответе 401, а между клиентом в памяти и его сервером никакого HTTP нет.

В репозитории есть живая версия. examples/servers/simple-auth/ запускает отдельный сервер авторизации и защищённый MCP-сервер; examples/clients/simple-auth-client/ — это клиент с этой страницы, выросший в небольшой CLI. В его README — две команды: запустите серверы, запустите клиент, подключив его к ним, — и наблюдайте, как проходят все четыре шага.

Client ID Metadata Documents

Ревизия спецификации 2026-07-28 объявляет динамическую регистрацию клиентов устаревшей в пользу Client ID Metadata Documents (CIMD). Вместо того чтобы отправлять POST-запросом новую регистрацию каждому встреченному серверу авторизации, клиент публикует один JSON-документ о себе по стабильному HTTPS URL — и этот URL и есть его client_id. Документ загружает сервер авторизации; провайдер его вообще не трогает.

SDK это уже умеет: передайте URL как client_metadata_url= при создании провайдера. Если метаданные сервера авторизации объявляют client_id_metadata_document_supported: true, провайдер полностью пропускает запрос /register: URL идёт в сценарий как client_id, а client_secret нет вовсе. Если сервер этого не объявляет (большинство пока не объявляет) или URL не передан, провайдер молча откатывается к динамической регистрации, и всё описанное выше работает ровно так, как описано. Сохранённый client_info по-прежнему имеет приоритет над обоими вариантами.

URL должен быть HTTPS и с некорневым путём; всё остальное — ValueError при создании, до любого обращения к сети. Поставляемый пример examples/clients/simple-auth-client/ принимает его в переменной окружения MCP_CLIENT_METADATA_URL.

Межмашинное взаимодействие

Ночное задание, шаг CI, другой сервис. Браузера нет, и нажать «разрешить» некому. Это грант client credentials: client_id и client_secret у вас уже есть, а весь сценарий сводится к конечной точке токенов.

ClientCredentialsOAuthProvider — тот же httpx2.Auth, только без человека:

client.py
import httpx2

from mcp import Client
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken


class InMemoryTokenStorage:
    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info


oauth = ClientCredentialsOAuthProvider(
    server_url="http://localhost:8001/mcp",
    storage=InMemoryTokenStorage(),
    client_id="reporting-agent",
    client_secret="...",
    scope="user",
)


async def main() -> None:
    async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
        transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
        async with Client(transport) as client:
            result = await client.list_tools()
            print([tool.name for tool in result.tools])

Что изменилось:

  • Нет OAuthClientMetadata, нет обработчиков. Вы передаёте client_id и client_secret; провайдер строит вокруг них минимальную регистрацию client_credentials и полностью пропускает динамическую регистрацию.
  • scope — строка с разделением пробелами, формат OAuth для передачи по сети.
  • Всё дальше по цепочке идентично: тот же TokenStorage, тот же httpx2.AsyncClient(auth=...), тот же streamable_http_client.

По умолчанию секрет передаётся в запросе токена через HTTP Basic auth (client_secret_basic). Передайте token_endpoint_auth_method="client_secret_post", чтобы вместо этого поместить его в тело формы. Некоторые серверы авторизации принимают только один из двух способов.

Tip

Читайте client_secret из окружения или менеджера секретов, никогда не из системы контроля версий.

Info

Ещё один провайдер находится в mcp.client.auth.extensions.client_credentials: PrivateKeyJWTOAuthProvider — для клиентов, которые аутентифицируются с помощью JWT вместо общего секрета (private_key_jwt, вариант с парой ключей и workload identity). Схема та же: создайте экземпляр и передайте его в auth=. В том же модуле есть SignedJWTParameters и static_assertion_provider — два вспомогательных средства, которые собирают для него утверждение (assertion).

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

Когда возникает ошибка

Когда сценарий OAuth идёт не так, провайдер выбрасывает OAuthFlowError из mcp.client.auth. У него два подкласса. OAuthRegistrationError означает, что регистрация не дала пригодного клиента: сервер авторизации отказал в регистрации или всё же зарегистрировал вас, но с учётными данными, которые этот сценарий использовать не может (например, с методом аутентификации, который он не реализует). OAuthTokenError означает, что получить токен не удалось: конечная точка токенов ответила отказом, или в сохранённой записи клиента указан метод аутентификации, который этот клиент применить не может, — об этом сообщается при сборке запроса токена, а не после его отправки. Один except OAuthFlowError: охватывает обнаружение, регистрацию, авторизацию и обмен.

Не всё — ошибка сценария. Сеть по-прежнему может подвести; это обычные исключения httpx2, и они проходят насквозь без изменений.

Итоги

  • OAuthClientProvider — это httpx2.Auth. Подключите его к httpx2.AsyncClient, передайте тот в streamable_http_client(url, http_client=...) — и Client так и не узнает, что был OAuth.
  • От вас нужны четыре вещи: URL сервера, OAuthClientMetadata, TokenStorage и пара обработчиков redirect/callback.
  • TokenStorage — это Protocol: четыре асинхронных метода, без базового класса. Сохраняйте client_info наряду с токенами.
  • Обнаружение, регистрация (динамическая или через Client ID Metadata Document), PKCE, проверки state и iss и обновление токенов — забота провайдера, а не ваша.
  • ClientCredentialsOAuthProvider — версия без человека: client_id + client_secret, без обработчиков, без браузера.
  • Любой сбой OAuth — это OAuthFlowError; OAuthRegistrationError и OAuthTokenError — его подклассы.

Вторая половина этого рукопожатия — как заставить ваш сервер требовать токен — на странице Авторизация.