MCP Docs ревизия 2026-07-28

Начало / Как написать свой MCP-сервер на Python / Авторизация MCP-сервера: OAuth 2.1

Авторизация MCP-сервера: OAuth 2.1

Как закрыть удалённый сервер токенами и разграничить права инструментов

РуководствоКод проверен: Python 3.13, пакет mcp 2.3.0 — против имитации сервера авторизацииОснова: Документация Python SDK: AuthorizationСверено 13.10.2026, ревизия 2026-07-28Открыть .md

Авторизация нужна удалённому серверу с закрытыми данными. В терминах OAuth ваш MCP-сервер — сервер ресурсов: он никого не впускает сам и токенов не выдаёт, а только проверяет токен в каждом запросе. Входом и выдачей токенов занимается сервер авторизации — Keycloak, Auth0, Entra ID или собственный.

Три участника: клиент получает токен у сервера авторизации и передаёт его MCP-серверу, а тот проверяет токен при каждом запросе.

Как клиент находит, где взять токен#

  1. Клиент обращается к серверу без токена и получает 401 с заголовком WWW-Authenticate, где указан адрес метаданных.
  2. По этому адресу лежат метаданные защищённого ресурса (RFC 9728): какой сервер авторизации выдаёт токены и какие нужны права.
  3. Клиент проводит пользователя через вход у сервера авторизации и получает токен для этого ресурса.
  4. Повторяет запрос с заголовком Authorization: Bearer <токен>.

SDK делает шаги 1 и 2 за вас. Требования спецификации к клиентам и серверам авторизации — в справочнике: «Авторизация в MCP».

Код сервера#

Токен проверяется у сервера авторизации через интроспекцию (RFC 7662): MCP-сервер отправляет токен и получает ответ, активен ли он, кому и с какими правами выдан.

auth_server.py
"""MCP-сервер, закрытый OAuth 2.1: проверка токена через интроспекцию (RFC 7662)."""
import os

import httpx2
from pydantic import AnyHttpUrl

from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver.exceptions import ToolError

# Публичный адрес этого MCP-сервера: ровно тот, к которому подключаются клиенты
RESOURCE = os.environ.get("MCP_RESOURCE", "http://127.0.0.1:8000/mcp")
ISSUER = os.environ.get("OAUTH_ISSUER", "http://127.0.0.1:9000")
INTROSPECT_URL = f"{ISSUER}/introspect"
# Учётные данные, с которыми MCP-сервер обращается к серверу авторизации
CLIENT_ID = os.environ.get("INTROSPECT_CLIENT_ID", "mcp-server")
CLIENT_SECRET = os.environ.get("INTROSPECT_CLIENT_SECRET", "change-me")


class IntrospectionVerifier(TokenVerifier):
    async def verify_token(self, token: str) -> AccessToken | None:
        async with httpx2.AsyncClient(timeout=10) as http:
            r = await http.post(INTROSPECT_URL, data={"token": token},
                                auth=(CLIENT_ID, CLIENT_SECRET))
        if r.status_code != 200:
            return None
        info = r.json()
        if not info.get("active"):
            return None
        aud = info.get("aud")
        if isinstance(aud, list):  # токен выпущен для нескольких ресурсов
            aud = RESOURCE if RESOURCE in aud else None
        return AccessToken(
            token=token,
            client_id=info.get("client_id", ""),
            scopes=info.get("scope", "").split(),
            expires_at=info.get("exp"),
            resource=aud,
            subject=info.get("sub"),
        )


mcp = MCPServer(
    "Заметки",
    version="1.0.0",
    token_verifier=IntrospectionVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl(ISSUER),
        resource_server_url=AnyHttpUrl(RESOURCE),
        required_scopes=["notes:read"],
        validate_token_resource=True,  # отклонять токены, выпущенные для другого сервера
    ),
)


@mcp.tool()
def whoami() -> str:
    """Кто вызывает сервер: пользователь и разрешения токена."""
    token = get_access_token()
    if token is None:
        return "аноним"
    return f"{token.subject} (клиент {token.client_id}, права: {' '.join(token.scopes)})"


@mcp.tool()
def delete_note(note_id: int) -> str:
    """Удалить заметку. Нужно право notes:write."""
    token = get_access_token()
    if token is None or "notes:write" not in token.scopes:
        raise ToolError("Недостаточно прав: нужно право notes:write")
    return f"Заметка {note_id} удалена"


if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="127.0.0.1", port=8000)
  • TokenVerifier — единственное, что нужно реализовать: метод получает токен из заголовка и возвращает AccessToken или None.
  • token_verifier и auth передаются вместе; иначе конструктор откажется работать.
  • validate_token_resource=True отклоняет токен, выпущенный для другого сервера, — так ваш сервер не примет токен, украденный у соседнего.
  • get_access_token() внутри инструмента возвращает того, кто вызывает: по нему проверяются права отдельных инструментов.

Что видит клиент#

Ответы сервера из нашей проверки. Запрос без токена:

Ответ без токена
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"

Метаданные по адресу из заголовка:

GET /.well-known/oauth-protected-resource/mcp
{"resource": "http://127.0.0.1:8000/mcp",
 "authorization_servers": ["http://127.0.0.1:9000/"],
 "scopes_supported": ["notes:read"],
 "bearer_methods_supported": ["header"]}

Токен, выпущенный для другого сервера, и неизвестный токен получают тот же 401. С токеном, где есть только право notes:read:

Результаты вызовов
whoami       → ivanov (клиент claude, права: notes:read)
delete_note  → ошибка: Недостаточно прав: нужно право notes:write

Перед запуском в работу#

  • Только HTTPS. RESOURCE — точный публичный адрес, с которым работают клиенты.
  • Секрет интроспекции храните в переменных окружения или хранилище секретов, не в коде.
  • Минимальные права. Отдельные права (scope) на чтение и на изменение; инструменты, которые меняют данные, проверяют право в коде.
  • Короткоживущие токены. Отзыв доступа у сервера авторизации должен действовать быстро.
  • Регистрация клиентов. Спецификация 2026-07-28 считает устаревшей динамическую регистрацию (DCR) и предлагает документы метаданных клиента (CIMD); проверьте, что ваш сервер авторизации их поддерживает.