Начало / Как написать свой MCP-сервер на Python / Авторизация MCP-сервера: OAuth 2.1
Авторизация MCP-сервера: OAuth 2.1
Как закрыть удалённый сервер токенами и разграничить права инструментов
Авторизация нужна удалённому серверу с закрытыми данными. В терминах OAuth ваш MCP-сервер — сервер ресурсов: он никого не впускает сам и токенов не выдаёт, а только проверяет токен в каждом запросе. Входом и выдачей токенов занимается сервер авторизации — Keycloak, Auth0, Entra ID или собственный.
Три участника: клиент получает токен у сервера авторизации и передаёт его MCP-серверу, а тот проверяет токен при каждом запросе.
Как клиент находит, где взять токен#
- Клиент обращается к серверу без токена и получает
401с заголовкомWWW-Authenticate, где указан адрес метаданных. - По этому адресу лежат метаданные защищённого ресурса (RFC 9728): какой сервер авторизации выдаёт токены и какие нужны права.
- Клиент проводит пользователя через вход у сервера авторизации и получает токен для этого ресурса.
- Повторяет запрос с заголовком
Authorization: Bearer <токен>.
SDK делает шаги 1 и 2 за вас. Требования спецификации к клиентам и серверам авторизации — в справочнике: «Авторизация в MCP».
Код сервера#
Токен проверяется у сервера авторизации через интроспекцию (RFC 7662): MCP-сервер отправляет токен и получает ответ, активен ли он, кому и с какими правами выдан.
"""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"Метаданные по адресу из заголовка:
{"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); проверьте, что ваш сервер авторизации их поддерживает.