MCP server separation into Authorization Server (AS) and Resource Server (RS) roles per spec PR #338 (#982)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Inna Harper 2025-06-23 14:19:03 +01:00 • committed by GitHub
commit 17f9c00c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1851 additions and 1603 deletions

View file

@ -4,7 +4,7 @@ from starlette.requests import Request
from starlette.responses import Response
from mcp.server.auth.json_response import PydanticJSONResponse
from mcp.shared.auth import OAuthMetadata
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
@dataclass
@ -16,3 +16,14 @@ class MetadataHandler:
content=self.metadata,
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
)
@dataclass
class ProtectedResourceMetadataHandler:
metadata: ProtectedResourceMetadata
async def handle(self, request: Request) -> Response:
return PydanticJSONResponse(
content=self.metadata,
headers={"Cache-Control": "public, max-age=3600"}, # Cache for 1 hour
)

View file

@ -151,7 +151,14 @@ class TokenHandler:
authorize_request_redirect_uri = auth_code.redirect_uri
else:
authorize_request_redirect_uri = None
if token_request.redirect_uri != authorize_request_redirect_uri:
# Convert both sides to strings for comparison to handle AnyUrl vs string issues
token_redirect_str = str(token_request.redirect_uri) if token_request.redirect_uri is not None else None
auth_redirect_str = (
str(authorize_request_redirect_uri) if authorize_request_redirect_uri is not None else None
)
if token_redirect_str != auth_redirect_str:
return self.response(
TokenErrorResponse(
error="invalid_request",

View file

@ -1,12 +1,13 @@
import json
import time
from typing import Any
from pydantic import AnyHttpUrl
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
from mcp.server.auth.provider import AccessToken, TokenVerifier
class AuthenticatedUser(SimpleUser):
@ -20,14 +21,11 @@ class AuthenticatedUser(SimpleUser):
class BearerAuthBackend(AuthenticationBackend):
"""
Authentication backend that validates Bearer tokens.
Authentication backend that validates Bearer tokens using a TokenVerifier.
"""
def __init__(
self,
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
):
self.provider = provider
def __init__(self, token_verifier: TokenVerifier):
self.token_verifier = token_verifier
async def authenticate(self, conn: HTTPConnection):
auth_header = next(
@ -39,8 +37,8 @@ class BearerAuthBackend(AuthenticationBackend):
token = auth_header[7:] # Remove "Bearer " prefix
# Validate the token with the provider
auth_info = await self.provider.load_access_token(token)
# Validate the token with the verifier
auth_info = await self.token_verifier.verify_token(token)
if not auth_info:
return None
@ -59,27 +57,72 @@ class RequireAuthMiddleware:
auth info in the request state.
"""
def __init__(self, app: Any, required_scopes: list[str]):
def __init__(
self,
app: Any,
required_scopes: list[str],
resource_metadata_url: AnyHttpUrl | None = None,
):
"""
Initialize the middleware.
Args:
app: ASGI application
provider: Authentication provider to validate tokens
required_scopes: Optional list of scopes that the token must have
required_scopes: List of scopes that the token must have
resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header
"""
self.app = app
self.required_scopes = required_scopes
self.resource_metadata_url = resource_metadata_url
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")
await self._send_auth_error(
send, status_code=401, error="invalid_token", description="Authentication required"
)
return
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._send_auth_error(
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
)
return
await self.app(scope, receive, send)
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
"""Send an authentication error response with WWW-Authenticate header."""
# Build WWW-Authenticate header value
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
if self.resource_metadata_url:
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
# Send response
body = {"error": error, "error_description": description}
body_bytes = json.dumps(body).encode()
await send(
{
"type": "http.response.start",
"status": status_code,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body_bytes)).encode()),
(b"www-authenticate", www_authenticate.encode()),
],
}
)
await send(
{
"type": "http.response.body",
"body": body_bytes,
}
)

View file

@ -86,6 +86,13 @@ class TokenError(Exception):
error_description: str | None = None
class TokenVerifier(Protocol):
"""Protocol for verifying bearer tokens."""
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify a bearer token and return access info if valid."""
# 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)
@ -278,3 +285,19 @@ def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str:
redirect_uri = urlunparse(parsed_uri._replace(query=urlencode(query_params)))
return redirect_uri
class ProviderTokenVerifier(TokenVerifier):
"""Token verifier that uses an OAuthAuthorizationServerProvider.
This is provided for backwards compatibility with existing auth_server_provider
configurations. For new implementations using AS/RS separation, consider using
the TokenVerifier protocol with a dedicated implementation like IntrospectionTokenVerifier.
"""
def __init__(self, provider: "OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]"):
self.provider = provider
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token using the provider's load_access_token method."""
return await self.provider.load_access_token(token)

View file

@ -180,3 +180,40 @@ def build_metadata(
metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post"]
return metadata
def create_protected_resource_routes(
resource_url: AnyHttpUrl,
authorization_servers: list[AnyHttpUrl],
scopes_supported: list[str] | None = None,
) -> list[Route]:
"""
Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728).
Args:
resource_url: The URL of this resource server
authorization_servers: List of authorization servers that can issue tokens
scopes_supported: Optional list of scopes supported by this resource
Returns:
List of Starlette routes for protected resource metadata
"""
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
from mcp.shared.auth import ProtectedResourceMetadata
metadata = ProtectedResourceMetadata(
resource=resource_url,
authorization_servers=authorization_servers,
scopes_supported=scopes_supported,
# bearer_methods_supported defaults to ["header"] in the model
)
handler = ProtectedResourceMetadataHandler(metadata)
return [
Route(
"/.well-known/oauth-protected-resource",
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
methods=["GET", "OPTIONS"],
)
]

View file

@ -15,9 +15,16 @@ class RevocationOptions(BaseModel):
class AuthSettings(BaseModel):
issuer_url: AnyHttpUrl = Field(
...,
description="URL advertised as OAuth issuer; this should be the URL the server " "is reachable at",
description="OAuth authorization server URL that issues tokens for this resource server.",
)
service_documentation_url: AnyHttpUrl | None = None
client_registration_options: ClientRegistrationOptions | None = None
revocation_options: RevocationOptions | None = None
required_scopes: list[str] | None = None
# Resource Server settings (when operating as RS only)
resource_server_url: AnyHttpUrl | None = Field(
...,
description="The URL of the MCP server to be used as the resource identifier "
"and base route to look up OAuth Protected Resource Metadata.",
)

View file

@ -30,10 +30,8 @@ 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.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation
from mcp.server.fastmcp.exceptions import ResourceError
from mcp.server.fastmcp.prompts import Prompt, PromptManager
@ -141,6 +139,7 @@ class FastMCP:
name: str | None = None,
instructions: str | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
token_verifier: TokenVerifier | None = None,
event_store: EventStore | None = None,
*,
tools: list[Tool] | None = None,
@ -156,14 +155,22 @@ class FastMCP:
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
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")
# Validate auth configuration
if self.settings.auth is not None:
if auth_server_provider and token_verifier:
raise ValueError("Cannot specify both auth_server_provider and token_verifier")
if not auth_server_provider and not token_verifier:
raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled")
else:
if auth_server_provider or token_verifier:
raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings")
self._auth_server_provider = auth_server_provider
self._token_verifier = token_verifier
# Create token verifier from provider if needed (backwards compatibility)
if auth_server_provider and not token_verifier:
self._token_verifier = ProviderTokenVerifier(auth_server_provider)
self._event_store = event_store
self._custom_starlette_routes: list[Route] = []
self.dependencies = self.settings.dependencies
@ -701,49 +708,60 @@ class FastMCP:
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
# Set up auth if configured
if self.settings.auth:
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 auth middleware if token verifier is available
if self._token_verifier:
middleware = [
# extract auth info from request (but do not require it)
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(self._token_verifier),
),
),
# 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,
)
)
# Add the auth context middleware to store
# authenticated user in a contextvar
Middleware(AuthContextMiddleware),
]
# Add auth endpoints if auth server provider is configured
if self._auth_server_provider:
from mcp.server.auth.routes import create_auth_routes
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,
)
)
# When auth is configured, require authentication
if self._token_verifier:
# Determine resource metadata URL
resource_metadata_url = None
if self.settings.auth and self.settings.auth.resource_server_url:
from pydantic import AnyHttpUrl
resource_metadata_url = AnyHttpUrl(
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
)
# When auth is not configured, we shouldn't require auth
if self._auth_server_provider:
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
routes.append(
Route(
self.settings.sse_path,
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url),
methods=["GET"],
)
)
routes.append(
Mount(
self.settings.message_path,
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes),
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url),
)
)
else:
@ -766,6 +784,18 @@ class FastMCP:
app=sse.handle_post_message,
)
)
# Add protected resource metadata endpoint if configured as RS
if self.settings.auth and self.settings.auth.resource_server_url:
from mcp.server.auth.routes import create_protected_resource_routes
routes.extend(
create_protected_resource_routes(
resource_url=self.settings.auth.resource_server_url,
authorization_servers=[self.settings.auth.issuer_url],
scopes_supported=self.settings.auth.required_scopes,
)
)
# mount these routes last, so they have the lowest route matching precedence
routes.extend(self._custom_starlette_routes)
@ -796,35 +826,49 @@ class FastMCP:
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
# Set up auth if configured
if self.settings.auth:
required_scopes = self.settings.auth.required_scopes or []
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(
provider=self._auth_server_provider,
# Add auth middleware if token verifier is available
if self._token_verifier:
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(self._token_verifier),
),
),
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,
Middleware(AuthContextMiddleware),
]
# Add auth endpoints if auth server provider is configured
if self._auth_server_provider:
from mcp.server.auth.routes import create_auth_routes
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,
)
)
)
# Set up routes with or without auth
if self._token_verifier:
# Determine resource metadata URL
resource_metadata_url = None
if self.settings.auth and self.settings.auth.resource_server_url:
from pydantic import AnyHttpUrl
resource_metadata_url = AnyHttpUrl(
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
)
routes.append(
Mount(
self.settings.streamable_http_path,
app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
app=RequireAuthMiddleware(handle_streamable_http, required_scopes, resource_metadata_url),
)
)
else:
@ -836,6 +880,28 @@ class FastMCP:
)
)
# Add protected resource metadata endpoint if configured as RS
if self.settings.auth and self.settings.auth.resource_server_url:
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
from mcp.server.auth.routes import cors_middleware
from mcp.shared.auth import ProtectedResourceMetadata
protected_resource_metadata = ProtectedResourceMetadata(
resource=self.settings.auth.resource_server_url,
authorization_servers=[self.settings.auth.issuer_url],
scopes_supported=self.settings.auth.required_scopes,
)
routes.append(
Route(
"/.well-known/oauth-protected-resource",
endpoint=cors_middleware(
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
["GET", "OPTIONS"],
),
methods=["GET", "OPTIONS"],
)
)
routes.extend(self._custom_starlette_routes)
return Starlette(