mirror of
https://github.com/aljazceru/mcp-python-sdk.git
synced 2025-12-18 06:24:26 +01:00
Add support for serverside oauth (#255)
Co-authored-by: David Soria Parra <davidsp@anthropic.com> Co-authored-by: Basil Hosmer <basil@anthropic.com> Co-authored-by: ihrpr <inna@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -166,4 +166,5 @@ cython_debug/
|
||||
|
||||
# vscode
|
||||
.vscode/
|
||||
.windsurfrules
|
||||
**/CLAUDE.local.md
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -19,7 +19,7 @@ This document contains critical information about working with this codebase. Fo
|
||||
- Line length: 88 chars maximum
|
||||
|
||||
3. Testing Requirements
|
||||
- Framework: `uv run pytest`
|
||||
- Framework: `uv run --frozen pytest`
|
||||
- Async testing: use anyio, not asyncio
|
||||
- Coverage: test edge cases and errors
|
||||
- New features require tests
|
||||
@@ -54,9 +54,9 @@ This document contains critical information about working with this codebase. Fo
|
||||
## Code Formatting
|
||||
|
||||
1. Ruff
|
||||
- Format: `uv run ruff format .`
|
||||
- Check: `uv run ruff check .`
|
||||
- Fix: `uv run ruff check . --fix`
|
||||
- Format: `uv run --frozen ruff format .`
|
||||
- Check: `uv run --frozen ruff check .`
|
||||
- Fix: `uv run --frozen ruff check . --fix`
|
||||
- Critical issues:
|
||||
- Line length (88 chars)
|
||||
- Import sorting (I001)
|
||||
@@ -67,7 +67,7 @@ This document contains critical information about working with this codebase. Fo
|
||||
- Imports: split into multiple lines
|
||||
|
||||
2. Type Checking
|
||||
- Tool: `uv run pyright`
|
||||
- Tool: `uv run --frozen pyright`
|
||||
- Requirements:
|
||||
- Explicit None checks for Optional
|
||||
- Type narrowing for strings
|
||||
@@ -104,6 +104,10 @@ This document contains critical information about working with this codebase. Fo
|
||||
- Add None checks
|
||||
- Narrow string types
|
||||
- Match existing patterns
|
||||
- Pytest:
|
||||
- If the tests aren't finding the anyio pytest mark, try adding PYTEST_DISABLE_PLUGIN_AUTOLOAD=""
|
||||
to the start of the pytest run command eg:
|
||||
`PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest`
|
||||
|
||||
3. Best Practices
|
||||
- Check git status before commits
|
||||
|
||||
27
README.md
27
README.md
@@ -309,6 +309,33 @@ async def long_task(files: list[str], ctx: Context) -> str:
|
||||
return "Processing complete"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Authentication can be used by servers that want to expose tools accessing protected resources.
|
||||
|
||||
`mcp.server.auth` implements an OAuth 2.0 server interface, which servers can use by
|
||||
providing an implementation of the `OAuthServerProvider` protocol.
|
||||
|
||||
```
|
||||
mcp = FastMCP("My App",
|
||||
auth_provider=MyOAuthServerProvider(),
|
||||
auth=AuthSettings(
|
||||
issuer_url="https://myapp.com",
|
||||
revocation_options=RevocationOptions(
|
||||
enabled=True,
|
||||
),
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=["myscope", "myotherscope"],
|
||||
default_scopes=["myscope"],
|
||||
),
|
||||
required_scopes=["myscope"],
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
See [OAuthServerProvider](mcp/server/auth/provider.py) for more details.
|
||||
|
||||
## Running Your Server
|
||||
|
||||
### Development Mode
|
||||
|
||||
@@ -323,8 +323,7 @@ class ChatSession:
|
||||
total = result["total"]
|
||||
percentage = (progress / total) * 100
|
||||
logging.info(
|
||||
f"Progress: {progress}/{total} "
|
||||
f"({percentage:.1f}%)"
|
||||
f"Progress: {progress}/{total} ({percentage:.1f}%)"
|
||||
)
|
||||
|
||||
return f"Tool execution result: {result}"
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"httpx-sse>=0.4",
|
||||
"pydantic>=2.7.2,<3.0.0",
|
||||
"starlette>=0.27",
|
||||
"python-multipart>=0.0.9",
|
||||
"sse-starlette>=1.6.1",
|
||||
"pydantic-settings>=2.5.2",
|
||||
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
|
||||
|
||||
3
src/mcp/server/auth/__init__.py
Normal file
3
src/mcp/server/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
MCP OAuth server authorization components.
|
||||
"""
|
||||
8
src/mcp/server/auth/errors.py
Normal file
8
src/mcp/server/auth/errors.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def stringify_pydantic_error(validation_error: ValidationError) -> str:
|
||||
return "\n".join(
|
||||
f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}"
|
||||
for e in validation_error.errors()
|
||||
)
|
||||
3
src/mcp/server/auth/handlers/__init__.py
Normal file
3
src/mcp/server/auth/handlers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Request handlers for MCP authorization endpoints.
|
||||
"""
|
||||
244
src/mcp/server/auth/handlers/authorize.py
Normal file
244
src/mcp/server/auth/handlers/authorize.py
Normal file
@@ -0,0 +1,244 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError
|
||||
from starlette.datastructures import FormData, QueryParams
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
from mcp.server.auth.errors import (
|
||||
stringify_pydantic_error,
|
||||
)
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.server.auth.provider import (
|
||||
AuthorizationErrorCode,
|
||||
AuthorizationParams,
|
||||
AuthorizeError,
|
||||
OAuthAuthorizationServerProvider,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import (
|
||||
InvalidRedirectUriError,
|
||||
InvalidScopeError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthorizationRequest(BaseModel):
|
||||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||
client_id: str = Field(..., description="The client ID")
|
||||
redirect_uri: AnyHttpUrl | None = Field(
|
||||
None, description="URL to redirect to after authorization"
|
||||
)
|
||||
|
||||
# see OAuthClientMetadata; we only support `code`
|
||||
response_type: Literal["code"] = Field(
|
||||
..., description="Must be 'code' for authorization code flow"
|
||||
)
|
||||
code_challenge: str = Field(..., description="PKCE code challenge")
|
||||
code_challenge_method: Literal["S256"] = Field(
|
||||
"S256", description="PKCE code challenge method, must be S256"
|
||||
)
|
||||
state: str | None = Field(None, description="Optional state parameter")
|
||||
scope: str | None = Field(
|
||||
None,
|
||||
description="Optional scope; if specified, should be "
|
||||
"a space-separated list of scope strings",
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationErrorResponse(BaseModel):
|
||||
error: AuthorizationErrorCode
|
||||
error_description: str | None
|
||||
error_uri: AnyUrl | None = None
|
||||
# must be set if provided in the request
|
||||
state: str | None = None
|
||||
|
||||
|
||||
def best_effort_extract_string(
|
||||
key: str, params: None | FormData | QueryParams
|
||||
) -> str | None:
|
||||
if params is None:
|
||||
return None
|
||||
value = params.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class AnyHttpUrlModel(RootModel[AnyHttpUrl]):
|
||||
root: AnyHttpUrl
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthorizationHandler:
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
# implements authorization requests for grant_type=code;
|
||||
# see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
|
||||
|
||||
state = None
|
||||
redirect_uri = None
|
||||
client = None
|
||||
params = None
|
||||
|
||||
async def error_response(
|
||||
error: AuthorizationErrorCode,
|
||||
error_description: str | None,
|
||||
attempt_load_client: bool = True,
|
||||
):
|
||||
# Error responses take two different formats:
|
||||
# 1. The request has a valid client ID & redirect_uri: we issue a redirect
|
||||
# back to the redirect_uri with the error response fields as query
|
||||
# parameters. This allows the client to be notified of the error.
|
||||
# 2. Otherwise, we return an error response directly to the end user;
|
||||
# we choose to do so in JSON, but this is left undefined in the
|
||||
# specification.
|
||||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
|
||||
#
|
||||
# This logic is a bit awkward to handle, because the error might be thrown
|
||||
# very early in request validation, before we've done the usual Pydantic
|
||||
# validation, loaded the client, etc. To handle this, error_response()
|
||||
# contains fallback logic which attempts to load the parameters directly
|
||||
# from the request.
|
||||
|
||||
nonlocal client, redirect_uri, state
|
||||
if client is None and attempt_load_client:
|
||||
# make last-ditch attempt to load the client
|
||||
client_id = best_effort_extract_string("client_id", params)
|
||||
client = client_id and await self.provider.get_client(client_id)
|
||||
if redirect_uri is None and client:
|
||||
# make last-ditch effort to load the redirect uri
|
||||
try:
|
||||
if params is not None and "redirect_uri" not in params:
|
||||
raw_redirect_uri = None
|
||||
else:
|
||||
raw_redirect_uri = AnyHttpUrlModel.model_validate(
|
||||
best_effort_extract_string("redirect_uri", params)
|
||||
).root
|
||||
redirect_uri = client.validate_redirect_uri(raw_redirect_uri)
|
||||
except (ValidationError, InvalidRedirectUriError):
|
||||
# if the redirect URI is invalid, ignore it & just return the
|
||||
# initial error
|
||||
pass
|
||||
|
||||
# the error response MUST contain the state specified by the client, if any
|
||||
if state is None:
|
||||
# make last-ditch effort to load state
|
||||
state = best_effort_extract_string("state", params)
|
||||
|
||||
error_resp = AuthorizationErrorResponse(
|
||||
error=error,
|
||||
error_description=error_description,
|
||||
state=state,
|
||||
)
|
||||
|
||||
if redirect_uri and client:
|
||||
return RedirectResponse(
|
||||
url=construct_redirect_uri(
|
||||
str(redirect_uri), **error_resp.model_dump(exclude_none=True)
|
||||
),
|
||||
status_code=302,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
else:
|
||||
return PydanticJSONResponse(
|
||||
status_code=400,
|
||||
content=error_resp,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request parameters
|
||||
if request.method == "GET":
|
||||
# Convert query_params to dict for pydantic validation
|
||||
params = request.query_params
|
||||
else:
|
||||
# Parse form data for POST requests
|
||||
params = await request.form()
|
||||
|
||||
# Save state if it exists, even before validation
|
||||
state = best_effort_extract_string("state", params)
|
||||
|
||||
try:
|
||||
auth_request = AuthorizationRequest.model_validate(params)
|
||||
state = auth_request.state # Update with validated state
|
||||
except ValidationError as validation_error:
|
||||
error: AuthorizationErrorCode = "invalid_request"
|
||||
for e in validation_error.errors():
|
||||
if e["loc"] == ("response_type",) and e["type"] == "literal_error":
|
||||
error = "unsupported_response_type"
|
||||
break
|
||||
return await error_response(
|
||||
error, stringify_pydantic_error(validation_error)
|
||||
)
|
||||
|
||||
# Get client information
|
||||
client = await self.provider.get_client(
|
||||
auth_request.client_id,
|
||||
)
|
||||
if not client:
|
||||
# For client_id validation errors, return direct error (no redirect)
|
||||
return await error_response(
|
||||
error="invalid_request",
|
||||
error_description=f"Client ID '{auth_request.client_id}' not found",
|
||||
attempt_load_client=False,
|
||||
)
|
||||
|
||||
# Validate redirect_uri against client's registered URIs
|
||||
try:
|
||||
redirect_uri = client.validate_redirect_uri(auth_request.redirect_uri)
|
||||
except InvalidRedirectUriError as validation_error:
|
||||
# For redirect_uri validation errors, return direct error (no redirect)
|
||||
return await error_response(
|
||||
error="invalid_request",
|
||||
error_description=validation_error.message,
|
||||
)
|
||||
|
||||
# Validate scope - for scope errors, we can redirect
|
||||
try:
|
||||
scopes = client.validate_scope(auth_request.scope)
|
||||
except InvalidScopeError as validation_error:
|
||||
# For scope errors, redirect with error parameters
|
||||
return await error_response(
|
||||
error="invalid_scope",
|
||||
error_description=validation_error.message,
|
||||
)
|
||||
|
||||
# Setup authorization parameters
|
||||
auth_params = AuthorizationParams(
|
||||
state=state,
|
||||
scopes=scopes,
|
||||
code_challenge=auth_request.code_challenge,
|
||||
redirect_uri=redirect_uri,
|
||||
redirect_uri_provided_explicitly=auth_request.redirect_uri is not None,
|
||||
)
|
||||
|
||||
try:
|
||||
# Let the provider pick the next URI to redirect to
|
||||
return RedirectResponse(
|
||||
url=await self.provider.authorize(
|
||||
client,
|
||||
auth_params,
|
||||
),
|
||||
status_code=302,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except AuthorizeError as e:
|
||||
# Handle authorization errors as defined in RFC 6749 Section 4.1.2.1
|
||||
return await error_response(
|
||||
error=e.error,
|
||||
error_description=e.error_description,
|
||||
)
|
||||
|
||||
except Exception as validation_error:
|
||||
# Catch-all for unexpected errors
|
||||
logger.exception(
|
||||
"Unexpected error in authorization_handler", exc_info=validation_error
|
||||
)
|
||||
return await error_response(
|
||||
error="server_error", error_description="An unexpected error occurred"
|
||||
)
|
||||
18
src/mcp/server/auth/handlers/metadata.py
Normal file
18
src/mcp/server/auth/handlers/metadata.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataHandler:
|
||||
metadata: OAuthMetadata
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
return PydanticJSONResponse(
|
||||
content=self.metadata,
|
||||
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
|
||||
)
|
||||
129
src/mcp/server/auth/handlers/register.py
Normal file
129
src/mcp/server/auth/handlers/register.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, RootModel, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from mcp.server.auth.errors import stringify_pydantic_error
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
RegistrationError,
|
||||
RegistrationErrorCode,
|
||||
)
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
||||
|
||||
|
||||
class RegistrationRequest(RootModel[OAuthClientMetadata]):
|
||||
# this wrapper is a no-op; it's just to separate out the types exposed to the
|
||||
# provider from what we use in the HTTP handler
|
||||
root: OAuthClientMetadata
|
||||
|
||||
|
||||
class RegistrationErrorResponse(BaseModel):
|
||||
error: RegistrationErrorCode
|
||||
error_description: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationHandler:
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
options: ClientRegistrationOptions
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
# Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
|
||||
try:
|
||||
# Parse request body as JSON
|
||||
body = await request.json()
|
||||
client_metadata = OAuthClientMetadata.model_validate(body)
|
||||
|
||||
# Scope validation is handled below
|
||||
except ValidationError as validation_error:
|
||||
return PydanticJSONResponse(
|
||||
content=RegistrationErrorResponse(
|
||||
error="invalid_client_metadata",
|
||||
error_description=stringify_pydantic_error(validation_error),
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
client_id = str(uuid4())
|
||||
client_secret = None
|
||||
if client_metadata.token_endpoint_auth_method != "none":
|
||||
# cryptographically secure random 32-byte hex string
|
||||
client_secret = secrets.token_hex(32)
|
||||
|
||||
if client_metadata.scope is None and self.options.default_scopes is not None:
|
||||
client_metadata.scope = " ".join(self.options.default_scopes)
|
||||
elif (
|
||||
client_metadata.scope is not None and self.options.valid_scopes is not None
|
||||
):
|
||||
requested_scopes = set(client_metadata.scope.split())
|
||||
valid_scopes = set(self.options.valid_scopes)
|
||||
if not requested_scopes.issubset(valid_scopes):
|
||||
return PydanticJSONResponse(
|
||||
content=RegistrationErrorResponse(
|
||||
error="invalid_client_metadata",
|
||||
error_description="Requested scopes are not valid: "
|
||||
f"{', '.join(requested_scopes - valid_scopes)}",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
if set(client_metadata.grant_types) != {"authorization_code", "refresh_token"}:
|
||||
return PydanticJSONResponse(
|
||||
content=RegistrationErrorResponse(
|
||||
error="invalid_client_metadata",
|
||||
error_description="grant_types must be authorization_code "
|
||||
"and refresh_token",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
client_id_issued_at = int(time.time())
|
||||
client_secret_expires_at = (
|
||||
client_id_issued_at + self.options.client_secret_expiry_seconds
|
||||
if self.options.client_secret_expiry_seconds is not None
|
||||
else None
|
||||
)
|
||||
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_id_issued_at=client_id_issued_at,
|
||||
client_secret=client_secret,
|
||||
client_secret_expires_at=client_secret_expires_at,
|
||||
# passthrough information from the client request
|
||||
redirect_uris=client_metadata.redirect_uris,
|
||||
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
|
||||
grant_types=client_metadata.grant_types,
|
||||
response_types=client_metadata.response_types,
|
||||
client_name=client_metadata.client_name,
|
||||
client_uri=client_metadata.client_uri,
|
||||
logo_uri=client_metadata.logo_uri,
|
||||
scope=client_metadata.scope,
|
||||
contacts=client_metadata.contacts,
|
||||
tos_uri=client_metadata.tos_uri,
|
||||
policy_uri=client_metadata.policy_uri,
|
||||
jwks_uri=client_metadata.jwks_uri,
|
||||
jwks=client_metadata.jwks,
|
||||
software_id=client_metadata.software_id,
|
||||
software_version=client_metadata.software_version,
|
||||
)
|
||||
try:
|
||||
# Register client
|
||||
await self.provider.register_client(client_info)
|
||||
|
||||
# Return client information
|
||||
return PydanticJSONResponse(content=client_info, status_code=201)
|
||||
except RegistrationError as e:
|
||||
# Handle registration errors as defined in RFC 7591 Section 3.2.2
|
||||
return PydanticJSONResponse(
|
||||
content=RegistrationErrorResponse(
|
||||
error=e.error, error_description=e.error_description
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
101
src/mcp/server/auth/handlers/revoke.py
Normal file
101
src/mcp/server/auth/handlers/revoke.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from mcp.server.auth.errors import (
|
||||
stringify_pydantic_error,
|
||||
)
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.server.auth.middleware.client_auth import (
|
||||
AuthenticationError,
|
||||
ClientAuthenticator,
|
||||
)
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
)
|
||||
|
||||
|
||||
class RevocationRequest(BaseModel):
|
||||
"""
|
||||
# See https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
|
||||
"""
|
||||
|
||||
token: str
|
||||
token_type_hint: Literal["access_token", "refresh_token"] | None = None
|
||||
client_id: str
|
||||
client_secret: str | None
|
||||
|
||||
|
||||
class RevocationErrorResponse(BaseModel):
|
||||
error: Literal["invalid_request", "unauthorized_client"]
|
||||
error_description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RevocationHandler:
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
client_authenticator: ClientAuthenticator
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
"""
|
||||
Handler for the OAuth 2.0 Token Revocation endpoint.
|
||||
"""
|
||||
try:
|
||||
form_data = await request.form()
|
||||
revocation_request = RevocationRequest.model_validate(dict(form_data))
|
||||
except ValidationError as e:
|
||||
return PydanticJSONResponse(
|
||||
status_code=400,
|
||||
content=RevocationErrorResponse(
|
||||
error="invalid_request",
|
||||
error_description=stringify_pydantic_error(e),
|
||||
),
|
||||
)
|
||||
|
||||
# Authenticate client
|
||||
try:
|
||||
client = await self.client_authenticator.authenticate(
|
||||
revocation_request.client_id, revocation_request.client_secret
|
||||
)
|
||||
except AuthenticationError as e:
|
||||
return PydanticJSONResponse(
|
||||
status_code=401,
|
||||
content=RevocationErrorResponse(
|
||||
error="unauthorized_client",
|
||||
error_description=e.message,
|
||||
),
|
||||
)
|
||||
|
||||
loaders = [
|
||||
self.provider.load_access_token,
|
||||
partial(self.provider.load_refresh_token, client),
|
||||
]
|
||||
if revocation_request.token_type_hint == "refresh_token":
|
||||
loaders = reversed(loaders)
|
||||
|
||||
token: None | AccessToken | RefreshToken = None
|
||||
for loader in loaders:
|
||||
token = await loader(revocation_request.token)
|
||||
if token is not None:
|
||||
break
|
||||
|
||||
# if token is not found, just return HTTP 200 per the RFC
|
||||
if token and token.client_id == client.client_id:
|
||||
# Revoke token; provider is not meant to be able to do validation
|
||||
# at this point that would result in an error
|
||||
await self.provider.revoke_token(token)
|
||||
|
||||
# Return successful empty response
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
264
src/mcp/server/auth/handlers/token.py
Normal file
264
src/mcp/server/auth/handlers/token.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field, RootModel, ValidationError
|
||||
from starlette.requests import Request
|
||||
|
||||
from mcp.server.auth.errors import (
|
||||
stringify_pydantic_error,
|
||||
)
|
||||
from mcp.server.auth.json_response import PydanticJSONResponse
|
||||
from mcp.server.auth.middleware.client_auth import (
|
||||
AuthenticationError,
|
||||
ClientAuthenticator,
|
||||
)
|
||||
from mcp.server.auth.provider import (
|
||||
OAuthAuthorizationServerProvider,
|
||||
TokenError,
|
||||
TokenErrorCode,
|
||||
)
|
||||
from mcp.shared.auth import OAuthToken
|
||||
|
||||
|
||||
class AuthorizationCodeRequest(BaseModel):
|
||||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
||||
grant_type: Literal["authorization_code"]
|
||||
code: str = Field(..., description="The authorization code")
|
||||
redirect_uri: AnyHttpUrl | None = Field(
|
||||
None, description="Must be the same as redirect URI provided in /authorize"
|
||||
)
|
||||
client_id: str
|
||||
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
|
||||
client_secret: str | None = None
|
||||
# See https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
|
||||
code_verifier: str = Field(..., description="PKCE code verifier")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
# See https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
||||
grant_type: Literal["refresh_token"]
|
||||
refresh_token: str = Field(..., description="The refresh token")
|
||||
scope: str | None = Field(None, description="Optional scope parameter")
|
||||
client_id: str
|
||||
# we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
|
||||
client_secret: str | None = None
|
||||
|
||||
|
||||
class TokenRequest(
|
||||
RootModel[
|
||||
Annotated[
|
||||
AuthorizationCodeRequest | RefreshTokenRequest,
|
||||
Field(discriminator="grant_type"),
|
||||
]
|
||||
]
|
||||
):
|
||||
root: Annotated[
|
||||
AuthorizationCodeRequest | RefreshTokenRequest,
|
||||
Field(discriminator="grant_type"),
|
||||
]
|
||||
|
||||
|
||||
class TokenErrorResponse(BaseModel):
|
||||
"""
|
||||
See https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||
"""
|
||||
|
||||
error: TokenErrorCode
|
||||
error_description: str | None = None
|
||||
error_uri: AnyHttpUrl | None = None
|
||||
|
||||
|
||||
class TokenSuccessResponse(RootModel[OAuthToken]):
|
||||
# this is just a wrapper over OAuthToken; the only reason we do this
|
||||
# is to have some separation between the HTTP response type, and the
|
||||
# type returned by the provider
|
||||
root: OAuthToken
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenHandler:
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
client_authenticator: ClientAuthenticator
|
||||
|
||||
def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
|
||||
status_code = 200
|
||||
if isinstance(obj, TokenErrorResponse):
|
||||
status_code = 400
|
||||
|
||||
return PydanticJSONResponse(
|
||||
content=obj,
|
||||
status_code=status_code,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
async def handle(self, request: Request):
|
||||
try:
|
||||
form_data = await request.form()
|
||||
token_request = TokenRequest.model_validate(dict(form_data)).root
|
||||
except ValidationError as validation_error:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_request",
|
||||
error_description=stringify_pydantic_error(validation_error),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
client_info = await self.client_authenticator.authenticate(
|
||||
client_id=token_request.client_id,
|
||||
client_secret=token_request.client_secret,
|
||||
)
|
||||
except AuthenticationError as e:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="unauthorized_client",
|
||||
error_description=e.message,
|
||||
)
|
||||
)
|
||||
|
||||
if token_request.grant_type not in client_info.grant_types:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="unsupported_grant_type",
|
||||
error_description=(
|
||||
f"Unsupported grant type (supported grant types are "
|
||||
f"{client_info.grant_types})"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
tokens: OAuthToken
|
||||
|
||||
match token_request:
|
||||
case AuthorizationCodeRequest():
|
||||
auth_code = await self.provider.load_authorization_code(
|
||||
client_info, token_request.code
|
||||
)
|
||||
if auth_code is None or auth_code.client_id != token_request.client_id:
|
||||
# if code belongs to different client, pretend it doesn't exist
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_grant",
|
||||
error_description="authorization code does not exist",
|
||||
)
|
||||
)
|
||||
|
||||
# make auth codes expire after a deadline
|
||||
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.5
|
||||
if auth_code.expires_at < time.time():
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_grant",
|
||||
error_description="authorization code has expired",
|
||||
)
|
||||
)
|
||||
|
||||
# verify redirect_uri doesn't change between /authorize and /tokens
|
||||
# see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6
|
||||
if auth_code.redirect_uri_provided_explicitly:
|
||||
authorize_request_redirect_uri = auth_code.redirect_uri
|
||||
else:
|
||||
authorize_request_redirect_uri = None
|
||||
if token_request.redirect_uri != authorize_request_redirect_uri:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_request",
|
||||
error_description=(
|
||||
"redirect_uri did not match the one "
|
||||
"used when creating auth code"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Verify PKCE code verifier
|
||||
sha256 = hashlib.sha256(token_request.code_verifier.encode()).digest()
|
||||
hashed_code_verifier = (
|
||||
base64.urlsafe_b64encode(sha256).decode().rstrip("=")
|
||||
)
|
||||
|
||||
if hashed_code_verifier != auth_code.code_challenge:
|
||||
# see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_grant",
|
||||
error_description="incorrect code_verifier",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Exchange authorization code for tokens
|
||||
tokens = await self.provider.exchange_authorization_code(
|
||||
client_info, auth_code
|
||||
)
|
||||
except TokenError as e:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error=e.error,
|
||||
error_description=e.error_description,
|
||||
)
|
||||
)
|
||||
|
||||
case RefreshTokenRequest():
|
||||
refresh_token = await self.provider.load_refresh_token(
|
||||
client_info, token_request.refresh_token
|
||||
)
|
||||
if (
|
||||
refresh_token is None
|
||||
or refresh_token.client_id != token_request.client_id
|
||||
):
|
||||
# if token belongs to different client, pretend it doesn't exist
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_grant",
|
||||
error_description="refresh token does not exist",
|
||||
)
|
||||
)
|
||||
|
||||
if refresh_token.expires_at and refresh_token.expires_at < time.time():
|
||||
# if the refresh token has expired, pretend it doesn't exist
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_grant",
|
||||
error_description="refresh token has expired",
|
||||
)
|
||||
)
|
||||
|
||||
# Parse scopes if provided
|
||||
scopes = (
|
||||
token_request.scope.split(" ")
|
||||
if token_request.scope
|
||||
else refresh_token.scopes
|
||||
)
|
||||
|
||||
for scope in scopes:
|
||||
if scope not in refresh_token.scopes:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error="invalid_scope",
|
||||
error_description=(
|
||||
f"cannot request scope `{scope}` "
|
||||
"not provided by refresh token"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Exchange refresh token for new tokens
|
||||
tokens = await self.provider.exchange_refresh_token(
|
||||
client_info, refresh_token, scopes
|
||||
)
|
||||
except TokenError as e:
|
||||
return self.response(
|
||||
TokenErrorResponse(
|
||||
error=e.error,
|
||||
error_description=e.error_description,
|
||||
)
|
||||
)
|
||||
|
||||
return self.response(TokenSuccessResponse(root=tokens))
|
||||
10
src/mcp/server/auth/json_response.py
Normal file
10
src/mcp/server/auth/json_response.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class PydanticJSONResponse(JSONResponse):
|
||||
# use pydantic json serialization instead of the stock `json.dumps`,
|
||||
# so that we can handle serializing pydantic models like AnyHttpUrl
|
||||
def render(self, content: Any) -> bytes:
|
||||
return content.model_dump_json(exclude_none=True).encode("utf-8")
|
||||
3
src/mcp/server/auth/middleware/__init__.py
Normal file
3
src/mcp/server/auth/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Middleware for MCP authorization.
|
||||
"""
|
||||
50
src/mcp/server/auth/middleware/auth_context.py
Normal file
50
src/mcp/server/auth/middleware/auth_context.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import contextvars
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
# Create a contextvar to store the authenticated user
|
||||
# The default is None, indicating no authenticated user is present
|
||||
auth_context_var = contextvars.ContextVar[AuthenticatedUser | None](
|
||||
"auth_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_access_token() -> AccessToken | None:
|
||||
"""
|
||||
Get the access token from the current context.
|
||||
|
||||
Returns:
|
||||
The access token if an authenticated user is available, None otherwise.
|
||||
"""
|
||||
auth_user = auth_context_var.get()
|
||||
return auth_user.access_token if auth_user else None
|
||||
|
||||
|
||||
class AuthContextMiddleware:
|
||||
"""
|
||||
Middleware that extracts the authenticated user from the request
|
||||
and sets it in a contextvar for easy access throughout the request lifecycle.
|
||||
|
||||
This middleware should be added after the AuthenticationMiddleware in the
|
||||
middleware stack to ensure that the user is properly authenticated before
|
||||
being stored in the context.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
user = scope.get("user")
|
||||
if isinstance(user, AuthenticatedUser):
|
||||
# Set the authenticated user in the contextvar
|
||||
token = auth_context_var.set(user)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
auth_context_var.reset(token)
|
||||
else:
|
||||
# No authenticated user, just process the request
|
||||
await self.app(scope, receive, send)
|
||||
89
src/mcp/server/auth/middleware/bearer_auth.py
Normal file
89
src/mcp/server/auth/middleware/bearer_auth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from starlette.authentication import (
|
||||
AuthCredentials,
|
||||
AuthenticationBackend,
|
||||
SimpleUser,
|
||||
)
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider
|
||||
|
||||
|
||||
class AuthenticatedUser(SimpleUser):
|
||||
"""User with authentication info."""
|
||||
|
||||
def __init__(self, auth_info: AccessToken):
|
||||
super().__init__(auth_info.client_id)
|
||||
self.access_token = auth_info
|
||||
self.scopes = auth_info.scopes
|
||||
|
||||
|
||||
class BearerAuthBackend(AuthenticationBackend):
|
||||
"""
|
||||
Authentication backend that validates Bearer tokens.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
):
|
||||
self.provider = provider
|
||||
|
||||
async def authenticate(self, conn: HTTPConnection):
|
||||
auth_header = conn.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = auth_header[7:] # Remove "Bearer " prefix
|
||||
|
||||
# Validate the token with the provider
|
||||
auth_info = await self.provider.load_access_token(token)
|
||||
|
||||
if not auth_info:
|
||||
return None
|
||||
|
||||
if auth_info.expires_at and auth_info.expires_at < int(time.time()):
|
||||
return None
|
||||
|
||||
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
|
||||
|
||||
|
||||
class RequireAuthMiddleware:
|
||||
"""
|
||||
Middleware that requires a valid Bearer token in the Authorization header.
|
||||
|
||||
This will validate the token with the auth provider and store the resulting
|
||||
auth info in the request state.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any, required_scopes: list[str]):
|
||||
"""
|
||||
Initialize the middleware.
|
||||
|
||||
Args:
|
||||
app: ASGI application
|
||||
provider: Authentication provider to validate tokens
|
||||
required_scopes: Optional list of scopes that the token must have
|
||||
"""
|
||||
self.app = app
|
||||
self.required_scopes = required_scopes
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
auth_user = scope.get("user")
|
||||
if not isinstance(auth_user, AuthenticatedUser):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
auth_credentials = scope.get("auth")
|
||||
|
||||
for required_scope in self.required_scopes:
|
||||
# auth_credentials should always be provided; this is just paranoia
|
||||
if (
|
||||
auth_credentials is None
|
||||
or required_scope not in auth_credentials.scopes
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient scope")
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
56
src/mcp/server/auth/middleware/client_auth.py
Normal file
56
src/mcp/server/auth/middleware/client_auth.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
|
||||
|
||||
class ClientAuthenticator:
|
||||
"""
|
||||
ClientAuthenticator is a callable which validates requests from a client
|
||||
application, used to verify /token calls.
|
||||
If, during registration, the client requested to be issued a secret, the
|
||||
authenticator asserts that /token calls must be authenticated with
|
||||
that same token.
|
||||
NOTE: clients can opt for no authentication during registration, in which case this
|
||||
logic is skipped.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: OAuthAuthorizationServerProvider[Any, Any, Any]):
|
||||
"""
|
||||
Initialize the dependency.
|
||||
|
||||
Args:
|
||||
provider: Provider to look up client information
|
||||
"""
|
||||
self.provider = provider
|
||||
|
||||
async def authenticate(
|
||||
self, client_id: str, client_secret: str | None
|
||||
) -> OAuthClientInformationFull:
|
||||
# Look up client information
|
||||
client = await self.provider.get_client(client_id)
|
||||
if not client:
|
||||
raise AuthenticationError("Invalid client_id")
|
||||
|
||||
# If client from the store expects a secret, validate that the request provides
|
||||
# that secret
|
||||
if client.client_secret:
|
||||
if not client_secret:
|
||||
raise AuthenticationError("Client secret is required")
|
||||
|
||||
if client.client_secret != client_secret:
|
||||
raise AuthenticationError("Invalid client_secret")
|
||||
|
||||
if (
|
||||
client.client_secret_expires_at
|
||||
and client.client_secret_expires_at < int(time.time())
|
||||
):
|
||||
raise AuthenticationError("Client secret has expired")
|
||||
|
||||
return client
|
||||
289
src/mcp/server/auth/provider.py
Normal file
289
src/mcp/server/auth/provider.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, Literal, Protocol, TypeVar
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthToken,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationParams(BaseModel):
|
||||
state: str | None
|
||||
scopes: list[str] | None
|
||||
code_challenge: str
|
||||
redirect_uri: AnyHttpUrl
|
||||
redirect_uri_provided_explicitly: bool
|
||||
|
||||
|
||||
class AuthorizationCode(BaseModel):
|
||||
code: str
|
||||
scopes: list[str]
|
||||
expires_at: float
|
||||
client_id: str
|
||||
code_challenge: str
|
||||
redirect_uri: AnyHttpUrl
|
||||
redirect_uri_provided_explicitly: bool
|
||||
|
||||
|
||||
class RefreshToken(BaseModel):
|
||||
token: str
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
class AccessToken(BaseModel):
|
||||
token: str
|
||||
client_id: str
|
||||
scopes: list[str]
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
RegistrationErrorCode = Literal[
|
||||
"invalid_redirect_uri",
|
||||
"invalid_client_metadata",
|
||||
"invalid_software_statement",
|
||||
"unapproved_software_statement",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationError(Exception):
|
||||
error: RegistrationErrorCode
|
||||
error_description: str | None = None
|
||||
|
||||
|
||||
AuthorizationErrorCode = Literal[
|
||||
"invalid_request",
|
||||
"unauthorized_client",
|
||||
"access_denied",
|
||||
"unsupported_response_type",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizeError(Exception):
|
||||
error: AuthorizationErrorCode
|
||||
error_description: str | None = None
|
||||
|
||||
|
||||
TokenErrorCode = Literal[
|
||||
"invalid_request",
|
||||
"invalid_client",
|
||||
"invalid_grant",
|
||||
"unauthorized_client",
|
||||
"unsupported_grant_type",
|
||||
"invalid_scope",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenError(Exception):
|
||||
error: TokenErrorCode
|
||||
error_description: str | None = None
|
||||
|
||||
|
||||
# NOTE: FastMCP doesn't render any of these types in the user response, so it's
|
||||
# OK to add fields to subclasses which should not be exposed externally.
|
||||
AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode)
|
||||
RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken)
|
||||
AccessTokenT = TypeVar("AccessTokenT", bound=AccessToken)
|
||||
|
||||
|
||||
class OAuthAuthorizationServerProvider(
|
||||
Protocol, Generic[AuthorizationCodeT, RefreshTokenT, AccessTokenT]
|
||||
):
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""
|
||||
Retrieves client information by client ID.
|
||||
|
||||
Implementors MAY raise NotImplementedError if dynamic client registration is
|
||||
disabled in ClientRegistrationOptions.
|
||||
|
||||
Args:
|
||||
client_id: The ID of the client to retrieve.
|
||||
|
||||
Returns:
|
||||
The client information, or None if the client does not exist.
|
||||
"""
|
||||
...
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""
|
||||
Saves client information as part of registering it.
|
||||
|
||||
Implementors MAY raise NotImplementedError if dynamic client registration is
|
||||
disabled in ClientRegistrationOptions.
|
||||
|
||||
Args:
|
||||
client_info: The client metadata to register.
|
||||
|
||||
Raises:
|
||||
RegistrationError: If the client metadata is invalid.
|
||||
"""
|
||||
...
|
||||
|
||||
async def authorize(
|
||||
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
||||
) -> str:
|
||||
"""
|
||||
Called as part of the /authorize endpoint, and returns a URL that the client
|
||||
will be redirected to.
|
||||
Many MCP implementations will redirect to a third-party provider to perform
|
||||
a second OAuth exchange with that provider. In this sort of setup, the client
|
||||
has an OAuth connection with the MCP server, and the MCP server has an OAuth
|
||||
connection with the 3rd-party provider. At the end of this flow, the client
|
||||
should be redirected to the redirect_uri from params.redirect_uri.
|
||||
|
||||
+--------+ +------------+ +-------------------+
|
||||
| | | | | |
|
||||
| Client | --> | MCP Server | --> | 3rd Party OAuth |
|
||||
| | | | | Server |
|
||||
+--------+ +------------+ +-------------------+
|
||||
| ^ |
|
||||
+------------+ | | |
|
||||
| | | | Redirect |
|
||||
|redirect_uri|<-----+ +------------------+
|
||||
| |
|
||||
+------------+
|
||||
|
||||
Implementations will need to define another handler on the MCP server return
|
||||
flow to perform the second redirect, and generate and store an authorization
|
||||
code as part of completing the OAuth authorization step.
|
||||
|
||||
Implementations SHOULD generate an authorization code with at least 160 bits of
|
||||
entropy,
|
||||
and MUST generate an authorization code with at least 128 bits of entropy.
|
||||
See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10.
|
||||
|
||||
Args:
|
||||
client: The client requesting authorization.
|
||||
params: The parameters of the authorization request.
|
||||
|
||||
Returns:
|
||||
A URL to redirect the client to for authorization.
|
||||
|
||||
Raises:
|
||||
AuthorizeError: If the authorization request is invalid.
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCodeT | None:
|
||||
"""
|
||||
Loads an AuthorizationCode by its code.
|
||||
|
||||
Args:
|
||||
client: The client that requested the authorization code.
|
||||
authorization_code: The authorization code to get the challenge for.
|
||||
|
||||
Returns:
|
||||
The AuthorizationCode, or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCodeT
|
||||
) -> OAuthToken:
|
||||
"""
|
||||
Exchanges an authorization code for an access token and refresh token.
|
||||
|
||||
Args:
|
||||
client: The client exchanging the authorization code.
|
||||
authorization_code: The authorization code to exchange.
|
||||
|
||||
Returns:
|
||||
The OAuth token, containing access and refresh tokens.
|
||||
|
||||
Raises:
|
||||
TokenError: If the request is invalid
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: str
|
||||
) -> RefreshTokenT | None:
|
||||
"""
|
||||
Loads a RefreshToken by its token string.
|
||||
|
||||
Args:
|
||||
client: The client that is requesting to load the refresh token.
|
||||
refresh_token: The refresh token string to load.
|
||||
|
||||
Returns:
|
||||
The RefreshToken object if found, or None if not found.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: RefreshTokenT,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""
|
||||
Exchanges a refresh token for an access token and refresh token.
|
||||
|
||||
Implementations SHOULD rotate both the access token and refresh token.
|
||||
|
||||
Args:
|
||||
client: The client exchanging the refresh token.
|
||||
refresh_token: The refresh token to exchange.
|
||||
scopes: Optional scopes to request with the new access token.
|
||||
|
||||
Returns:
|
||||
The OAuth token, containing access and refresh tokens.
|
||||
|
||||
Raises:
|
||||
TokenError: If the request is invalid
|
||||
"""
|
||||
...
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessTokenT | None:
|
||||
"""
|
||||
Loads an access token by its token.
|
||||
|
||||
Args:
|
||||
token: The access token to verify.
|
||||
|
||||
Returns:
|
||||
The AuthInfo, or None if the token is invalid.
|
||||
"""
|
||||
...
|
||||
|
||||
async def revoke_token(
|
||||
self,
|
||||
token: AccessTokenT | RefreshTokenT,
|
||||
) -> None:
|
||||
"""
|
||||
Revokes an access or refresh token.
|
||||
|
||||
If the given token is invalid or already revoked, this method should do nothing.
|
||||
|
||||
Implementations SHOULD revoke both the access token and its corresponding
|
||||
refresh token, regardless of which of the access token or refresh token is
|
||||
provided.
|
||||
|
||||
Args:
|
||||
token: the token to revoke
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str:
|
||||
parsed_uri = urlparse(redirect_uri_base)
|
||||
query_params = [(k, v) for k, vs in parse_qs(parsed_uri.query) for v in vs]
|
||||
for k, v in params.items():
|
||||
if v is not None:
|
||||
query_params.append((k, v))
|
||||
|
||||
redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params)))
|
||||
return redirect_uri
|
||||
207
src/mcp/server/auth/routes.py
Normal file
207
src/mcp/server/auth/routes.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Route, request_response # type: ignore
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from mcp.server.auth.handlers.authorize import AuthorizationHandler
|
||||
from mcp.server.auth.handlers.metadata import MetadataHandler
|
||||
from mcp.server.auth.handlers.register import RegistrationHandler
|
||||
from mcp.server.auth.handlers.revoke import RevocationHandler
|
||||
from mcp.server.auth.handlers.token import TokenHandler
|
||||
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
|
||||
def validate_issuer_url(url: AnyHttpUrl):
|
||||
"""
|
||||
Validate that the issuer URL meets OAuth 2.0 requirements.
|
||||
|
||||
Args:
|
||||
url: The issuer URL to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the issuer URL is invalid
|
||||
"""
|
||||
|
||||
# RFC 8414 requires HTTPS, but we allow localhost HTTP for testing
|
||||
if (
|
||||
url.scheme != "https"
|
||||
and url.host != "localhost"
|
||||
and not url.host.startswith("127.0.0.1")
|
||||
):
|
||||
raise ValueError("Issuer URL must be HTTPS")
|
||||
|
||||
# No fragments or query parameters allowed
|
||||
if url.fragment:
|
||||
raise ValueError("Issuer URL must not have a fragment")
|
||||
if url.query:
|
||||
raise ValueError("Issuer URL must not have a query string")
|
||||
|
||||
|
||||
AUTHORIZATION_PATH = "/authorize"
|
||||
TOKEN_PATH = "/token"
|
||||
REGISTRATION_PATH = "/register"
|
||||
REVOCATION_PATH = "/revoke"
|
||||
|
||||
|
||||
def cors_middleware(
|
||||
handler: Callable[[Request], Response | Awaitable[Response]],
|
||||
allow_methods: list[str],
|
||||
) -> ASGIApp:
|
||||
cors_app = CORSMiddleware(
|
||||
app=request_response(handler),
|
||||
allow_origins="*",
|
||||
allow_methods=allow_methods,
|
||||
allow_headers=["mcp-protocol-version"],
|
||||
)
|
||||
return cors_app
|
||||
|
||||
|
||||
def create_auth_routes(
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
issuer_url: AnyHttpUrl,
|
||||
service_documentation_url: AnyHttpUrl | None = None,
|
||||
client_registration_options: ClientRegistrationOptions | None = None,
|
||||
revocation_options: RevocationOptions | None = None,
|
||||
) -> list[Route]:
|
||||
validate_issuer_url(issuer_url)
|
||||
|
||||
client_registration_options = (
|
||||
client_registration_options or ClientRegistrationOptions()
|
||||
)
|
||||
revocation_options = revocation_options or RevocationOptions()
|
||||
metadata = build_metadata(
|
||||
issuer_url,
|
||||
service_documentation_url,
|
||||
client_registration_options,
|
||||
revocation_options,
|
||||
)
|
||||
client_authenticator = ClientAuthenticator(provider)
|
||||
|
||||
# Create routes
|
||||
# Allow CORS requests for endpoints meant to be hit by the OAuth client
|
||||
# (with the client secret). This is intended to support things like MCP Inspector,
|
||||
# where the client runs in a web browser.
|
||||
routes = [
|
||||
Route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
endpoint=cors_middleware(
|
||||
MetadataHandler(metadata).handle,
|
||||
["GET", "OPTIONS"],
|
||||
),
|
||||
methods=["GET", "OPTIONS"],
|
||||
),
|
||||
Route(
|
||||
AUTHORIZATION_PATH,
|
||||
# do not allow CORS for authorization endpoint;
|
||||
# clients should just redirect to this
|
||||
endpoint=AuthorizationHandler(provider).handle,
|
||||
methods=["GET", "POST"],
|
||||
),
|
||||
Route(
|
||||
TOKEN_PATH,
|
||||
endpoint=cors_middleware(
|
||||
TokenHandler(provider, client_authenticator).handle,
|
||||
["POST", "OPTIONS"],
|
||||
),
|
||||
methods=["POST", "OPTIONS"],
|
||||
),
|
||||
]
|
||||
|
||||
if client_registration_options.enabled:
|
||||
registration_handler = RegistrationHandler(
|
||||
provider,
|
||||
options=client_registration_options,
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
REGISTRATION_PATH,
|
||||
endpoint=cors_middleware(
|
||||
registration_handler.handle,
|
||||
["POST", "OPTIONS"],
|
||||
),
|
||||
methods=["POST", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
if revocation_options.enabled:
|
||||
revocation_handler = RevocationHandler(provider, client_authenticator)
|
||||
routes.append(
|
||||
Route(
|
||||
REVOCATION_PATH,
|
||||
endpoint=cors_middleware(
|
||||
revocation_handler.handle,
|
||||
["POST", "OPTIONS"],
|
||||
),
|
||||
methods=["POST", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
|
||||
def modify_url_path(url: AnyHttpUrl, path_mapper: Callable[[str], str]) -> AnyHttpUrl:
|
||||
return AnyHttpUrl.build(
|
||||
scheme=url.scheme,
|
||||
username=url.username,
|
||||
password=url.password,
|
||||
host=url.host,
|
||||
port=url.port,
|
||||
path=path_mapper(url.path or ""),
|
||||
query=url.query,
|
||||
fragment=url.fragment,
|
||||
)
|
||||
|
||||
|
||||
def build_metadata(
|
||||
issuer_url: AnyHttpUrl,
|
||||
service_documentation_url: AnyHttpUrl | None,
|
||||
client_registration_options: ClientRegistrationOptions,
|
||||
revocation_options: RevocationOptions,
|
||||
) -> OAuthMetadata:
|
||||
authorization_url = modify_url_path(
|
||||
issuer_url, lambda path: path.rstrip("/") + AUTHORIZATION_PATH.lstrip("/")
|
||||
)
|
||||
token_url = modify_url_path(
|
||||
issuer_url, lambda path: path.rstrip("/") + TOKEN_PATH.lstrip("/")
|
||||
)
|
||||
# Create metadata
|
||||
metadata = OAuthMetadata(
|
||||
issuer=issuer_url,
|
||||
authorization_endpoint=authorization_url,
|
||||
token_endpoint=token_url,
|
||||
scopes_supported=None,
|
||||
response_types_supported=["code"],
|
||||
response_modes_supported=None,
|
||||
grant_types_supported=["authorization_code", "refresh_token"],
|
||||
token_endpoint_auth_methods_supported=["client_secret_post"],
|
||||
token_endpoint_auth_signing_alg_values_supported=None,
|
||||
service_documentation=service_documentation_url,
|
||||
ui_locales_supported=None,
|
||||
op_policy_uri=None,
|
||||
op_tos_uri=None,
|
||||
introspection_endpoint=None,
|
||||
code_challenge_methods_supported=["S256"],
|
||||
)
|
||||
|
||||
# Add registration endpoint if supported
|
||||
if client_registration_options.enabled:
|
||||
metadata.registration_endpoint = modify_url_path(
|
||||
issuer_url, lambda path: path.rstrip("/") + REGISTRATION_PATH.lstrip("/")
|
||||
)
|
||||
|
||||
# Add revocation endpoint if supported
|
||||
if revocation_options.enabled:
|
||||
metadata.revocation_endpoint = modify_url_path(
|
||||
issuer_url, lambda path: path.rstrip("/") + REVOCATION_PATH.lstrip("/")
|
||||
)
|
||||
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
|
||||
|
||||
return metadata
|
||||
24
src/mcp/server/auth/settings.py
Normal file
24
src/mcp/server/auth/settings.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
|
||||
|
||||
class ClientRegistrationOptions(BaseModel):
|
||||
enabled: bool = False
|
||||
client_secret_expiry_seconds: int | None = None
|
||||
valid_scopes: list[str] | None = None
|
||||
default_scopes: list[str] | None = None
|
||||
|
||||
|
||||
class RevocationOptions(BaseModel):
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class AuthSettings(BaseModel):
|
||||
issuer_url: AnyHttpUrl = Field(
|
||||
...,
|
||||
description="URL advertised as OAuth issuer; this should be the URL the server "
|
||||
"is reachable at",
|
||||
)
|
||||
service_documentation_url: AnyHttpUrl | None = None
|
||||
client_registration_options: ClientRegistrationOptions | None = None
|
||||
revocation_options: RevocationOptions | None = None
|
||||
required_scopes: list[str] | None = None
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations as _annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
|
||||
from contextlib import (
|
||||
AbstractAsyncContextManager,
|
||||
asynccontextmanager,
|
||||
@@ -18,9 +18,22 @@ from pydantic import BaseModel, Field
|
||||
from pydantic.networks import AnyUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
||||
from mcp.server.auth.middleware.bearer_auth import (
|
||||
BearerAuthBackend,
|
||||
RequireAuthMiddleware,
|
||||
)
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
||||
from mcp.server.auth.settings import (
|
||||
AuthSettings,
|
||||
)
|
||||
from mcp.server.fastmcp.exceptions import ResourceError
|
||||
from mcp.server.fastmcp.prompts import Prompt, PromptManager
|
||||
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
|
||||
@@ -62,6 +75,8 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FASTMCP_",
|
||||
env_file=".env",
|
||||
env_nested_delimiter="__",
|
||||
nested_model_default_partial_update=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@@ -93,6 +108,8 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
|
||||
Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]] | None
|
||||
) = Field(None, description="Lifespan context manager")
|
||||
|
||||
auth: AuthSettings | None = None
|
||||
|
||||
|
||||
def lifespan_wrapper(
|
||||
app: FastMCP,
|
||||
@@ -108,7 +125,12 @@ def lifespan_wrapper(
|
||||
|
||||
class FastMCP:
|
||||
def __init__(
|
||||
self, name: str | None = None, instructions: str | None = None, **settings: Any
|
||||
self,
|
||||
name: str | None = None,
|
||||
instructions: str | None = None,
|
||||
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
| None = None,
|
||||
**settings: Any,
|
||||
):
|
||||
self.settings = Settings(**settings)
|
||||
|
||||
@@ -128,6 +150,18 @@ class FastMCP:
|
||||
self._prompt_manager = PromptManager(
|
||||
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
|
||||
)
|
||||
if (self.settings.auth is not None) != (auth_server_provider is not None):
|
||||
# TODO: after we support separate authorization servers (see
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/284)
|
||||
# we should validate that if auth is enabled, we have either an
|
||||
# auth_server_provider to host our own authorization server,
|
||||
# OR the URL of a 3rd party authorization server.
|
||||
raise ValueError(
|
||||
"settings.auth must be specified if and only if auth_server_provider "
|
||||
"is specified"
|
||||
)
|
||||
self._auth_server_provider = auth_server_provider
|
||||
self._custom_starlette_routes: list[Route] = []
|
||||
self.dependencies = self.settings.dependencies
|
||||
|
||||
# Set up MCP protocol handlers
|
||||
@@ -465,6 +499,50 @@ class FastMCP:
|
||||
|
||||
return decorator
|
||||
|
||||
def custom_route(
|
||||
self,
|
||||
path: str,
|
||||
methods: list[str],
|
||||
name: str | None = None,
|
||||
include_in_schema: bool = True,
|
||||
):
|
||||
"""
|
||||
Decorator to register a custom HTTP route on the FastMCP server.
|
||||
|
||||
Allows adding arbitrary HTTP endpoints outside the standard MCP protocol,
|
||||
which can be useful for OAuth callbacks, health checks, or admin APIs.
|
||||
The handler function must be an async function that accepts a Starlette
|
||||
Request and returns a Response.
|
||||
|
||||
Args:
|
||||
path: URL path for the route (e.g., "/oauth/callback")
|
||||
methods: List of HTTP methods to support (e.g., ["GET", "POST"])
|
||||
name: Optional name for the route (to reference this route with
|
||||
Starlette's reverse URL lookup feature)
|
||||
include_in_schema: Whether to include in OpenAPI schema, defaults to True
|
||||
|
||||
Example:
|
||||
@server.custom_route("/health", methods=["GET"])
|
||||
async def health_check(request: Request) -> Response:
|
||||
return JSONResponse({"status": "ok"})
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[Request], Awaitable[Response]],
|
||||
) -> Callable[[Request], Awaitable[Response]]:
|
||||
self._custom_starlette_routes.append(
|
||||
Route(
|
||||
path,
|
||||
endpoint=func,
|
||||
methods=methods,
|
||||
name=name,
|
||||
include_in_schema=include_in_schema,
|
||||
)
|
||||
)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
async def run_stdio_async(self) -> None:
|
||||
"""Run the server using stdio transport."""
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
@@ -491,13 +569,20 @@ class FastMCP:
|
||||
|
||||
def sse_app(self) -> Starlette:
|
||||
"""Return an instance of the SSE server app."""
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
# Set up auth context and dependencies
|
||||
|
||||
sse = SseServerTransport(self.settings.message_path)
|
||||
|
||||
async def handle_sse(request: Request) -> None:
|
||||
async def handle_sse(scope: Scope, receive: Receive, send: Send):
|
||||
# Add client ID from auth context into request context if available
|
||||
|
||||
async with sse.connect_sse(
|
||||
request.scope,
|
||||
request.receive,
|
||||
request._send, # type: ignore[reportPrivateUsage]
|
||||
scope,
|
||||
receive,
|
||||
send,
|
||||
) as streams:
|
||||
await self._mcp_server.run(
|
||||
streams[0],
|
||||
@@ -505,12 +590,59 @@ class FastMCP:
|
||||
self._mcp_server.create_initialization_options(),
|
||||
)
|
||||
|
||||
# Create routes
|
||||
routes: list[Route | Mount] = []
|
||||
middleware: list[Middleware] = []
|
||||
required_scopes = []
|
||||
|
||||
# Add auth endpoints if auth provider is configured
|
||||
if self._auth_server_provider:
|
||||
assert self.settings.auth
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
|
||||
required_scopes = self.settings.auth.required_scopes or []
|
||||
|
||||
middleware = [
|
||||
# extract auth info from request (but do not require it)
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(
|
||||
provider=self._auth_server_provider,
|
||||
),
|
||||
),
|
||||
# Add the auth context middleware to store
|
||||
# authenticated user in a contextvar
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
routes.extend(
|
||||
create_auth_routes(
|
||||
provider=self._auth_server_provider,
|
||||
issuer_url=self.settings.auth.issuer_url,
|
||||
service_documentation_url=self.settings.auth.service_documentation_url,
|
||||
client_registration_options=self.settings.auth.client_registration_options,
|
||||
revocation_options=self.settings.auth.revocation_options,
|
||||
)
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
self.settings.sse_path,
|
||||
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Mount(
|
||||
self.settings.message_path,
|
||||
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
|
||||
)
|
||||
)
|
||||
# mount these routes last, so they have the lowest route matching precedence
|
||||
routes.extend(self._custom_starlette_routes)
|
||||
|
||||
# Create Starlette app with routes and middleware
|
||||
return Starlette(
|
||||
debug=self.settings.debug,
|
||||
routes=[
|
||||
Route(self.settings.sse_path, endpoint=handle_sse),
|
||||
Mount(self.settings.message_path, app=sse.handle_post_message),
|
||||
],
|
||||
debug=self.settings.debug, routes=routes, middleware=middleware
|
||||
)
|
||||
|
||||
async def list_prompts(self) -> list[MCPPrompt]:
|
||||
|
||||
@@ -576,14 +576,12 @@ class Server(Generic[LifespanResultT]):
|
||||
assert type(notify) in self.notification_handlers
|
||||
|
||||
handler = self.notification_handlers[type(notify)]
|
||||
logger.debug(
|
||||
f"Dispatching notification of type " f"{type(notify).__name__}"
|
||||
)
|
||||
logger.debug(f"Dispatching notification of type {type(notify).__name__}")
|
||||
|
||||
try:
|
||||
await handler(notify)
|
||||
except Exception as err:
|
||||
logger.error(f"Uncaught exception in notification handler: " f"{err}")
|
||||
logger.error(f"Uncaught exception in notification handler: {err}")
|
||||
|
||||
|
||||
async def _ping_handler(request: types.PingRequest) -> types.ServerResult:
|
||||
|
||||
213
src/mcp/server/streaming_asgi_transport.py
Normal file
213
src/mcp/server/streaming_asgi_transport.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
A modified version of httpx.ASGITransport that supports streaming responses.
|
||||
|
||||
This transport runs the ASGI app as a separate anyio task, allowing it to
|
||||
handle streaming responses like SSE where the app doesn't terminate until
|
||||
the connection is closed.
|
||||
|
||||
This is only intended for writing tests for the SSE transport.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
import anyio.streams.memory
|
||||
from httpx._models import Request, Response
|
||||
from httpx._transports.base import AsyncBaseTransport
|
||||
from httpx._types import AsyncByteStream
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
class StreamingASGITransport(AsyncBaseTransport):
|
||||
"""
|
||||
A custom AsyncTransport that handles sending requests directly to an ASGI app
|
||||
and supports streaming responses like SSE.
|
||||
|
||||
Unlike the standard ASGITransport, this transport runs the ASGI app in a
|
||||
separate anyio task, allowing it to handle responses from apps that don't
|
||||
terminate immediately (like SSE endpoints).
|
||||
|
||||
Arguments:
|
||||
|
||||
* `app` - The ASGI application.
|
||||
* `raise_app_exceptions` - Boolean indicating if exceptions in the application
|
||||
should be raised. Default to `True`. Can be set to `False` for use cases
|
||||
such as testing the content of a client 500 response.
|
||||
* `root_path` - The root path on which the ASGI application should be mounted.
|
||||
* `client` - A two-tuple indicating the client IP and port of incoming requests.
|
||||
* `response_timeout` - Timeout in seconds to wait for the initial response.
|
||||
Default is 10 seconds.
|
||||
|
||||
TODO: https://github.com/encode/httpx/pull/3059 is adding something similar to
|
||||
upstream httpx. When that merges, we should delete this & switch back to the
|
||||
upstream implementation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
task_group: anyio.abc.TaskGroup,
|
||||
raise_app_exceptions: bool = True,
|
||||
root_path: str = "",
|
||||
client: tuple[str, int] = ("127.0.0.1", 123),
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.raise_app_exceptions = raise_app_exceptions
|
||||
self.root_path = root_path
|
||||
self.client = client
|
||||
self.task_group = task_group
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: Request,
|
||||
) -> Response:
|
||||
assert isinstance(request.stream, AsyncByteStream)
|
||||
|
||||
# ASGI scope.
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": request.method,
|
||||
"headers": [(k.lower(), v) for (k, v) in request.headers.raw],
|
||||
"scheme": request.url.scheme,
|
||||
"path": request.url.path,
|
||||
"raw_path": request.url.raw_path.split(b"?")[0],
|
||||
"query_string": request.url.query,
|
||||
"server": (request.url.host, request.url.port),
|
||||
"client": self.client,
|
||||
"root_path": self.root_path,
|
||||
}
|
||||
|
||||
# Request body
|
||||
request_body_chunks = request.stream.__aiter__()
|
||||
request_complete = False
|
||||
|
||||
# Response state
|
||||
status_code = 499
|
||||
response_headers = None
|
||||
response_started = False
|
||||
response_complete = anyio.Event()
|
||||
initial_response_ready = anyio.Event()
|
||||
|
||||
# Synchronization for streaming response
|
||||
asgi_send_channel, asgi_receive_channel = anyio.create_memory_object_stream[
|
||||
dict[str, Any]
|
||||
](100)
|
||||
content_send_channel, content_receive_channel = (
|
||||
anyio.create_memory_object_stream[bytes](100)
|
||||
)
|
||||
|
||||
# ASGI callables.
|
||||
async def receive() -> dict[str, Any]:
|
||||
nonlocal request_complete
|
||||
|
||||
if request_complete:
|
||||
await response_complete.wait()
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
try:
|
||||
body = await request_body_chunks.__anext__()
|
||||
except StopAsyncIteration:
|
||||
request_complete = True
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
return {"type": "http.request", "body": body, "more_body": True}
|
||||
|
||||
async def send(message: dict[str, Any]) -> None:
|
||||
nonlocal status_code, response_headers, response_started
|
||||
|
||||
await asgi_send_channel.send(message)
|
||||
|
||||
# Start the ASGI application in a separate task
|
||||
async def run_app() -> None:
|
||||
try:
|
||||
# Cast the receive and send functions to the ASGI types
|
||||
await self.app(
|
||||
cast(Scope, scope), cast(Receive, receive), cast(Send, send)
|
||||
)
|
||||
except Exception:
|
||||
if self.raise_app_exceptions:
|
||||
raise
|
||||
|
||||
if not response_started:
|
||||
await asgi_send_channel.send(
|
||||
{"type": "http.response.start", "status": 500, "headers": []}
|
||||
)
|
||||
|
||||
await asgi_send_channel.send(
|
||||
{"type": "http.response.body", "body": b"", "more_body": False}
|
||||
)
|
||||
finally:
|
||||
await asgi_send_channel.aclose()
|
||||
|
||||
# Process messages from the ASGI app
|
||||
async def process_messages() -> None:
|
||||
nonlocal status_code, response_headers, response_started
|
||||
|
||||
try:
|
||||
async with asgi_receive_channel:
|
||||
async for message in asgi_receive_channel:
|
||||
if message["type"] == "http.response.start":
|
||||
assert not response_started
|
||||
status_code = message["status"]
|
||||
response_headers = message.get("headers", [])
|
||||
response_started = True
|
||||
|
||||
# As soon as we have headers, we can return a response
|
||||
initial_response_ready.set()
|
||||
|
||||
elif message["type"] == "http.response.body":
|
||||
body = message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
|
||||
if body and request.method != "HEAD":
|
||||
await content_send_channel.send(body)
|
||||
|
||||
if not more_body:
|
||||
response_complete.set()
|
||||
await content_send_channel.aclose()
|
||||
break
|
||||
finally:
|
||||
# Ensure events are set even if there's an error
|
||||
initial_response_ready.set()
|
||||
response_complete.set()
|
||||
await content_send_channel.aclose()
|
||||
|
||||
# Create tasks for running the app and processing messages
|
||||
self.task_group.start_soon(run_app)
|
||||
self.task_group.start_soon(process_messages)
|
||||
|
||||
# Wait for the initial response or timeout
|
||||
await initial_response_ready.wait()
|
||||
|
||||
# Create a streaming response
|
||||
return Response(
|
||||
status_code,
|
||||
headers=response_headers,
|
||||
stream=StreamingASGIResponseStream(content_receive_channel),
|
||||
)
|
||||
|
||||
|
||||
class StreamingASGIResponseStream(AsyncByteStream):
|
||||
"""
|
||||
A modified ASGIResponseStream that supports streaming responses.
|
||||
|
||||
This class extends the standard ASGIResponseStream to handle cases where
|
||||
the response body continues to be generated after the initial response
|
||||
is returned.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receive_channel: anyio.streams.memory.MemoryObjectReceiveStream[bytes],
|
||||
) -> None:
|
||||
self.receive_channel = receive_channel
|
||||
|
||||
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
|
||||
try:
|
||||
async for chunk in self.receive_channel:
|
||||
yield chunk
|
||||
finally:
|
||||
await self.receive_channel.aclose()
|
||||
137
src/mcp/shared/auth.py
Normal file
137
src/mcp/shared/auth.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
|
||||
|
||||
class OAuthToken(BaseModel):
|
||||
"""
|
||||
See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
token_type: Literal["bearer"] = "bearer"
|
||||
expires_in: int | None = None
|
||||
scope: str | None = None
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
class InvalidScopeError(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
|
||||
|
||||
class InvalidRedirectUriError(Exception):
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
|
||||
|
||||
class OAuthClientMetadata(BaseModel):
|
||||
"""
|
||||
RFC 7591 OAuth 2.0 Dynamic Client Registration metadata.
|
||||
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
|
||||
for the full specification.
|
||||
"""
|
||||
|
||||
redirect_uris: list[AnyHttpUrl] = Field(..., min_length=1)
|
||||
# token_endpoint_auth_method: this implementation only supports none &
|
||||
# client_secret_post;
|
||||
# ie: we do not support client_secret_basic
|
||||
token_endpoint_auth_method: Literal["none", "client_secret_post"] = (
|
||||
"client_secret_post"
|
||||
)
|
||||
# grant_types: this implementation only supports authorization_code & refresh_token
|
||||
grant_types: list[Literal["authorization_code", "refresh_token"]] = [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
]
|
||||
# this implementation only supports code; ie: it does not support implicit grants
|
||||
response_types: list[Literal["code"]] = ["code"]
|
||||
scope: str | None = None
|
||||
|
||||
# these fields are currently unused, but we support & store them for potential
|
||||
# future use
|
||||
client_name: str | None = None
|
||||
client_uri: AnyHttpUrl | None = None
|
||||
logo_uri: AnyHttpUrl | None = None
|
||||
contacts: list[str] | None = None
|
||||
tos_uri: AnyHttpUrl | None = None
|
||||
policy_uri: AnyHttpUrl | None = None
|
||||
jwks_uri: AnyHttpUrl | None = None
|
||||
jwks: Any | None = None
|
||||
software_id: str | None = None
|
||||
software_version: str | None = None
|
||||
|
||||
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
|
||||
if requested_scope is None:
|
||||
return None
|
||||
requested_scopes = requested_scope.split(" ")
|
||||
allowed_scopes = [] if self.scope is None else self.scope.split(" ")
|
||||
for scope in requested_scopes:
|
||||
if scope not in allowed_scopes:
|
||||
raise InvalidScopeError(f"Client was not registered with scope {scope}")
|
||||
return requested_scopes
|
||||
|
||||
def validate_redirect_uri(self, redirect_uri: AnyHttpUrl | None) -> AnyHttpUrl:
|
||||
if redirect_uri is not None:
|
||||
# Validate redirect_uri against client's registered redirect URIs
|
||||
if redirect_uri not in self.redirect_uris:
|
||||
raise InvalidRedirectUriError(
|
||||
f"Redirect URI '{redirect_uri}' not registered for client"
|
||||
)
|
||||
return redirect_uri
|
||||
elif len(self.redirect_uris) == 1:
|
||||
return self.redirect_uris[0]
|
||||
else:
|
||||
raise InvalidRedirectUriError(
|
||||
"redirect_uri must be specified when client "
|
||||
"has multiple registered URIs"
|
||||
)
|
||||
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadata):
|
||||
"""
|
||||
RFC 7591 OAuth 2.0 Dynamic Client Registration full response
|
||||
(client information plus metadata).
|
||||
"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
client_id_issued_at: int | None = None
|
||||
client_secret_expires_at: int | None = None
|
||||
|
||||
|
||||
class OAuthMetadata(BaseModel):
|
||||
"""
|
||||
RFC 8414 OAuth 2.0 Authorization Server Metadata.
|
||||
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
|
||||
"""
|
||||
|
||||
issuer: AnyHttpUrl
|
||||
authorization_endpoint: AnyHttpUrl
|
||||
token_endpoint: AnyHttpUrl
|
||||
registration_endpoint: AnyHttpUrl | None = None
|
||||
scopes_supported: list[str] | None = None
|
||||
response_types_supported: list[Literal["code"]] = ["code"]
|
||||
response_modes_supported: list[Literal["query", "fragment"]] | None = None
|
||||
grant_types_supported: (
|
||||
list[Literal["authorization_code", "refresh_token"]] | None
|
||||
) = None
|
||||
token_endpoint_auth_methods_supported: (
|
||||
list[Literal["none", "client_secret_post"]] | None
|
||||
) = None
|
||||
token_endpoint_auth_signing_alg_values_supported: None = None
|
||||
service_documentation: AnyHttpUrl | None = None
|
||||
ui_locales_supported: list[str] | None = None
|
||||
op_policy_uri: AnyHttpUrl | None = None
|
||||
op_tos_uri: AnyHttpUrl | None = None
|
||||
revocation_endpoint: AnyHttpUrl | None = None
|
||||
revocation_endpoint_auth_methods_supported: (
|
||||
list[Literal["client_secret_post"]] | None
|
||||
) = None
|
||||
revocation_endpoint_auth_signing_alg_values_supported: None = None
|
||||
introspection_endpoint: AnyHttpUrl | None = None
|
||||
introspection_endpoint_auth_methods_supported: (
|
||||
list[Literal["client_secret_post"]] | None
|
||||
) = None
|
||||
introspection_endpoint_auth_signing_alg_values_supported: None = None
|
||||
code_challenge_methods_supported: list[Literal["S256"]] | None = None
|
||||
122
tests/server/auth/middleware/test_auth_context.py
Normal file
122
tests/server/auth/middleware/test_auth_context.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Tests for the AuthContext middleware components.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import (
|
||||
AuthContextMiddleware,
|
||||
auth_context_var,
|
||||
get_access_token,
|
||||
)
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Mock ASGI app for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
self.scope: Scope | None = None
|
||||
self.receive: Receive | None = None
|
||||
self.send: Send | None = None
|
||||
self.access_token_during_call: AccessToken | None = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
self.called = True
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.send = send
|
||||
# Check the context during the call
|
||||
self.access_token_during_call = get_access_token()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_access_token() -> AccessToken:
|
||||
"""Create a valid access token."""
|
||||
return AccessToken(
|
||||
token="valid_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=int(time.time()) + 3600, # 1 hour from now
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestAuthContextMiddleware:
|
||||
"""Tests for the AuthContextMiddleware class."""
|
||||
|
||||
async def test_with_authenticated_user(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with an authenticated user in scope."""
|
||||
app = MockApp()
|
||||
middleware = AuthContextMiddleware(app)
|
||||
|
||||
# Create an authenticated user
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
|
||||
scope: Scope = {"type": "http", "user": user}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
# Verify context is empty before middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
# Run the middleware
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Verify the app was called
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
# Verify the access token was available during the call
|
||||
assert app.access_token_during_call == valid_access_token
|
||||
|
||||
# Verify context is reset after middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
async def test_with_no_user(self):
|
||||
"""Test middleware with no user in scope."""
|
||||
app = MockApp()
|
||||
middleware = AuthContextMiddleware(app)
|
||||
|
||||
scope: Scope = {"type": "http"} # No user
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
# Verify context is empty before middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
|
||||
# Run the middleware
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
# Verify the app was called
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
# Verify the access token was not available during the call
|
||||
assert app.access_token_during_call is None
|
||||
|
||||
# Verify context is still empty after middleware
|
||||
assert auth_context_var.get() is None
|
||||
assert get_access_token() is None
|
||||
391
tests/server/auth/middleware/test_bearer_auth.py
Normal file
391
tests/server/auth/middleware/test_bearer_auth.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Tests for the BearerAuth middleware components.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from starlette.authentication import AuthCredentials
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from mcp.server.auth.middleware.bearer_auth import (
|
||||
AuthenticatedUser,
|
||||
BearerAuthBackend,
|
||||
RequireAuthMiddleware,
|
||||
)
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
OAuthAuthorizationServerProvider,
|
||||
)
|
||||
|
||||
|
||||
class MockOAuthProvider:
|
||||
"""Mock OAuth provider for testing.
|
||||
|
||||
This is a simplified version that only implements the methods needed for testing
|
||||
the BearerAuthMiddleware components.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens = {} # token -> AccessToken
|
||||
|
||||
def add_token(self, token: str, access_token: AccessToken) -> None:
|
||||
"""Add a token to the provider."""
|
||||
self.tokens[token] = access_token
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Load an access token."""
|
||||
return self.tokens.get(token)
|
||||
|
||||
|
||||
def add_token_to_provider(
|
||||
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
token: str,
|
||||
access_token: AccessToken,
|
||||
) -> None:
|
||||
"""Helper function to add a token to a provider.
|
||||
|
||||
This is used to work around type checking issues with our mock provider.
|
||||
"""
|
||||
# We know this is actually a MockOAuthProvider
|
||||
mock_provider = cast(MockOAuthProvider, provider)
|
||||
mock_provider.add_token(token, access_token)
|
||||
|
||||
|
||||
class MockApp:
|
||||
"""Mock ASGI app for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
self.scope: Scope | None = None
|
||||
self.receive: Receive | None = None
|
||||
self.send: Send | None = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
self.called = True
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.send = send
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oauth_provider() -> OAuthAuthorizationServerProvider[Any, Any, Any]:
|
||||
"""Create a mock OAuth provider."""
|
||||
# Use type casting to satisfy the type checker
|
||||
return cast(OAuthAuthorizationServerProvider[Any, Any, Any], MockOAuthProvider())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_access_token() -> AccessToken:
|
||||
"""Create a valid access token."""
|
||||
return AccessToken(
|
||||
token="valid_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=int(time.time()) + 3600, # 1 hour from now
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_access_token() -> AccessToken:
|
||||
"""Create an expired access token."""
|
||||
return AccessToken(
|
||||
token="expired_token",
|
||||
client_id="test_client",
|
||||
scopes=["read"],
|
||||
expires_at=int(time.time()) - 3600, # 1 hour ago
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_expiry_access_token() -> AccessToken:
|
||||
"""Create an access token with no expiry."""
|
||||
return AccessToken(
|
||||
token="no_expiry_token",
|
||||
client_id="test_client",
|
||||
scopes=["read", "write"],
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestBearerAuthBackend:
|
||||
"""Tests for the BearerAuthBackend class."""
|
||||
|
||||
async def test_no_auth_header(
|
||||
self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
):
|
||||
"""Test authentication with no Authorization header."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
request = Request({"type": "http", "headers": []})
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_non_bearer_auth_header(
|
||||
self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
):
|
||||
"""Test authentication with non-Bearer Authorization header."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_invalid_token(
|
||||
self, mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
|
||||
):
|
||||
"""Test authentication with invalid token."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer invalid_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_expired_token(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
expired_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with expired token."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
add_token_to_provider(
|
||||
mock_oauth_provider, "expired_token", expired_access_token
|
||||
)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer expired_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is None
|
||||
|
||||
async def test_valid_token(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
valid_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with valid token."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
add_token_to_provider(mock_oauth_provider, "valid_token", valid_access_token)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer valid_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == valid_access_token
|
||||
assert user.scopes == ["read", "write"]
|
||||
|
||||
async def test_token_without_expiry(
|
||||
self,
|
||||
mock_oauth_provider: OAuthAuthorizationServerProvider[Any, Any, Any],
|
||||
no_expiry_access_token: AccessToken,
|
||||
):
|
||||
"""Test authentication with token that has no expiry."""
|
||||
backend = BearerAuthBackend(provider=mock_oauth_provider)
|
||||
add_token_to_provider(
|
||||
mock_oauth_provider, "no_expiry_token", no_expiry_access_token
|
||||
)
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"authorization", b"Bearer no_expiry_token")],
|
||||
}
|
||||
)
|
||||
result = await backend.authenticate(request)
|
||||
assert result is not None
|
||||
credentials, user = result
|
||||
assert isinstance(credentials, AuthCredentials)
|
||||
assert isinstance(user, AuthenticatedUser)
|
||||
assert credentials.scopes == ["read", "write"]
|
||||
assert user.display_name == "test_client"
|
||||
assert user.access_token == no_expiry_access_token
|
||||
assert user.scopes == ["read", "write"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
class TestRequireAuthMiddleware:
|
||||
"""Tests for the RequireAuthMiddleware class."""
|
||||
|
||||
async def test_no_user(self):
|
||||
"""Test middleware with no user in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
scope: Scope = {"type": "http"}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert excinfo.value.status_code == 401
|
||||
assert excinfo.value.detail == "Unauthorized"
|
||||
assert not app.called
|
||||
|
||||
async def test_non_authenticated_user(self):
|
||||
"""Test middleware with non-authenticated user in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
scope: Scope = {"type": "http", "user": object()}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert excinfo.value.status_code == 401
|
||||
assert excinfo.value.detail == "Unauthorized"
|
||||
assert not app.called
|
||||
|
||||
async def test_missing_required_scope(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with user missing required scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["admin"])
|
||||
|
||||
# Create a user with read/write scopes but not admin
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert excinfo.value.status_code == 403
|
||||
assert excinfo.value.detail == "Insufficient scope"
|
||||
assert not app.called
|
||||
|
||||
async def test_no_auth_credentials(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with no auth credentials in scope."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
|
||||
scope: Scope = {"type": "http", "user": user} # No auth credentials
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert excinfo.value.status_code == 403
|
||||
assert excinfo.value.detail == "Insufficient scope"
|
||||
assert not app.called
|
||||
|
||||
async def test_has_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with user having all required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
async def test_multiple_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with multiple required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=["read", "write"])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
|
||||
async def test_no_required_scopes(self, valid_access_token: AccessToken):
|
||||
"""Test middleware with no required scopes."""
|
||||
app = MockApp()
|
||||
middleware = RequireAuthMiddleware(app, required_scopes=[])
|
||||
|
||||
# Create a user with read/write scopes
|
||||
user = AuthenticatedUser(valid_access_token)
|
||||
auth = AuthCredentials(["read", "write"])
|
||||
|
||||
scope: Scope = {"type": "http", "user": user, "auth": auth}
|
||||
|
||||
# Create dummy async functions for receive and send
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request"}
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
pass
|
||||
|
||||
await middleware(scope, receive, send)
|
||||
|
||||
assert app.called
|
||||
assert app.scope == scope
|
||||
assert app.receive == receive
|
||||
assert app.send == send
|
||||
294
tests/server/auth/test_error_handling.py
Normal file
294
tests/server/auth/test_error_handling.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Tests for OAuth error handling in the auth handlers.
|
||||
"""
|
||||
|
||||
import unittest.mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AuthorizeError,
|
||||
RegistrationError,
|
||||
TokenError,
|
||||
)
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
from tests.server.fastmcp.auth.test_auth_integration import (
|
||||
MockOAuthProvider,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_provider():
|
||||
"""Return a MockOAuthProvider instance that can be configured to raise errors."""
|
||||
return MockOAuthProvider()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(oauth_provider):
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
|
||||
# Enable client registration
|
||||
client_registration_options = ClientRegistrationOptions(enabled=True)
|
||||
revocation_options = RevocationOptions(enabled=True)
|
||||
|
||||
# Create auth routes
|
||||
auth_routes = create_auth_routes(
|
||||
oauth_provider,
|
||||
issuer_url=AnyHttpUrl("http://localhost"),
|
||||
client_registration_options=client_registration_options,
|
||||
revocation_options=revocation_options,
|
||||
)
|
||||
|
||||
# Create Starlette app with routes directly
|
||||
return Starlette(routes=auth_routes)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
transport = ASGITransport(app=app)
|
||||
# Use base_url without a path since routes are directly on the app
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://localhost")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pkce_challenge():
|
||||
"""Create a PKCE challenge with code_verifier and code_challenge."""
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
# Generate a code verifier
|
||||
code_verifier = secrets.token_urlsafe(64)[:128]
|
||||
|
||||
# Create code challenge using S256 method
|
||||
code_verifier_bytes = code_verifier.encode("ascii")
|
||||
sha256 = hashlib.sha256(code_verifier_bytes).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(sha256).decode().rstrip("=")
|
||||
|
||||
return {"code_verifier": code_verifier, "code_challenge": code_challenge}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def registered_client(client):
|
||||
"""Create and register a test client."""
|
||||
# Default client metadata
|
||||
client_metadata = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": "Test Client",
|
||||
}
|
||||
|
||||
response = await client.post("/register", json=client_metadata)
|
||||
assert response.status_code == 201, f"Failed to register client: {response.content}"
|
||||
|
||||
client_info = response.json()
|
||||
return client_info
|
||||
|
||||
|
||||
class TestRegistrationErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_registration_error_handling(self, client, oauth_provider):
|
||||
# Mock the register_client method to raise a registration error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"register_client",
|
||||
side_effect=RegistrationError(
|
||||
error="invalid_redirect_uri",
|
||||
error_description="The redirect URI is invalid",
|
||||
),
|
||||
):
|
||||
# Prepare a client registration request
|
||||
client_data = {
|
||||
"redirect_uris": ["https://client.example.com/callback"],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"client_name": "Test Client",
|
||||
}
|
||||
|
||||
# Send the registration request
|
||||
response = await client.post(
|
||||
"/register",
|
||||
json=client_data,
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert response.status_code == 400, response.content
|
||||
data = response.json()
|
||||
assert data["error"] == "invalid_redirect_uri"
|
||||
assert data["error_description"] == "The redirect URI is invalid"
|
||||
|
||||
|
||||
class TestAuthorizeErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_authorize_error_handling(
|
||||
self, client, oauth_provider, registered_client, pkce_challenge
|
||||
):
|
||||
# Mock the authorize method to raise an authorize error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"authorize",
|
||||
side_effect=AuthorizeError(
|
||||
error="access_denied", error_description="The user denied the request"
|
||||
),
|
||||
):
|
||||
# Register the client
|
||||
client_id = registered_client["client_id"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# Prepare an authorization request
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
}
|
||||
|
||||
# Send the authorization request
|
||||
response = await client.get("/authorize", params=params)
|
||||
|
||||
# Verify the response is a redirect with error parameters
|
||||
assert response.status_code == 302
|
||||
redirect_url = response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
assert query_params["error"][0] == "access_denied"
|
||||
assert "error_description" in query_params
|
||||
assert query_params["state"][0] == "test_state"
|
||||
|
||||
|
||||
class TestTokenErrorHandling:
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_auth_code(
|
||||
self, client, oauth_provider, registered_client, pkce_challenge
|
||||
):
|
||||
# Register the client and get an auth code
|
||||
client_id = registered_client["client_id"]
|
||||
client_secret = registered_client["client_secret"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# First get an authorization code
|
||||
auth_response = await client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
},
|
||||
)
|
||||
|
||||
redirect_url = auth_response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
code = query_params["code"][0]
|
||||
|
||||
# Mock the exchange_authorization_code method to raise a token error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"exchange_authorization_code",
|
||||
side_effect=TokenError(
|
||||
error="invalid_grant",
|
||||
error_description="The authorization code is invalid",
|
||||
),
|
||||
):
|
||||
# Try to exchange the code for tokens
|
||||
token_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert token_response.status_code == 400
|
||||
data = token_response.json()
|
||||
assert data["error"] == "invalid_grant"
|
||||
assert data["error_description"] == "The authorization code is invalid"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_token_error_handling_refresh_token(
|
||||
self, client, oauth_provider, registered_client, pkce_challenge
|
||||
):
|
||||
# Register the client and get tokens
|
||||
client_id = registered_client["client_id"]
|
||||
client_secret = registered_client["client_secret"]
|
||||
redirect_uri = registered_client["redirect_uris"][0]
|
||||
|
||||
# First get an authorization code
|
||||
auth_response = await client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce_challenge["code_challenge"],
|
||||
"code_challenge_method": "S256",
|
||||
"state": "test_state",
|
||||
},
|
||||
)
|
||||
assert auth_response.status_code == 302, auth_response.content
|
||||
|
||||
redirect_url = auth_response.headers["location"]
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
code = query_params["code"][0]
|
||||
|
||||
# Exchange the code for tokens
|
||||
token_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code_verifier": pkce_challenge["code_verifier"],
|
||||
},
|
||||
)
|
||||
|
||||
tokens = token_response.json()
|
||||
refresh_token = tokens["refresh_token"]
|
||||
|
||||
# Mock the exchange_refresh_token method to raise a token error
|
||||
with unittest.mock.patch.object(
|
||||
oauth_provider,
|
||||
"exchange_refresh_token",
|
||||
side_effect=TokenError(
|
||||
error="invalid_scope",
|
||||
error_description="The requested scope is invalid",
|
||||
),
|
||||
):
|
||||
# Try to use the refresh token
|
||||
refresh_response = await client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert refresh_response.status_code == 400
|
||||
data = refresh_response.json()
|
||||
assert data["error"] == "invalid_scope"
|
||||
assert data["error_description"] == "The requested scope is invalid"
|
||||
3
tests/server/fastmcp/auth/__init__.py
Normal file
3
tests/server/fastmcp/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Tests for the MCP server auth components.
|
||||
"""
|
||||
1267
tests/server/fastmcp/auth/test_auth_integration.py
Normal file
1267
tests/server/fastmcp/auth/test_auth_integration.py
Normal file
File diff suppressed because it is too large
Load Diff
11
uv.lock
generated
11
uv.lock
generated
@@ -494,6 +494,7 @@ dependencies = [
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
@@ -537,6 +538,7 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.7.2,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.5.2" },
|
||||
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||
{ name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" },
|
||||
{ name = "sse-starlette", specifier = ">=1.6.1" },
|
||||
{ name = "starlette", specifier = ">=0.27" },
|
||||
@@ -1180,6 +1182,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/0f/9c55ac6c84c0336e22a26fa84ca6c51d58d7ac3a2d78b0dfa8748826c883/python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026", size = 31516 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215", size = 22299 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
|
||||
Reference in New Issue
Block a user