Skip to content

Commit 34bcf2d

Browse files
committed
refactor: extract auth components and streamable HTTP app helpers
- Add build_auth_components() in mcp.server.auth.components for reusable auth setup (middleware, endpoint wrapper, routes) - Refactor create_streamable_http_app() to take session_manager as first arg with keyword args for app config (removed StreamableHTTPAppConfig) - FastMCP now uses _build_auth_components() helper, reducing duplication between sse_app() and streamable_http_app() - Session manager is now created/owned by caller, passed to app creator - Add unit tests for build_auth_components() - Export AuthComponents, build_auth_components from mcp.server.auth - Export StreamableHTTPSessionManager, create_streamable_http_app from mcp.server Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 14 Claude-Permission-Prompts: 13 Claude-Escapes: 0 Claude-Plan: <claude-plan> # Plan: Extract Auth Helper and Make FastMCP a Thin Wrapper ## Summary Create a shared `build_auth_components()` helper in the auth module that both `sse_app()` and `streamable_http_app()` can use. This removes ~60 lines of duplicated auth logic from FastMCP and makes it a much thinner wrapper. ## Files to Modify 1. **`src/mcp/server/auth/routes.py`** - Add `AuthConfig` dataclass and `build_auth_components()` function 2. **`src/mcp/server/fastmcp/server.py`** - Refactor both `sse_app()` and `streamable_http_app()` to use the helper 3. **`src/mcp/server/__init__.py`** - Export new auth helper for low-level users ## Implementation ### Step 1: Add to `src/mcp/server/auth/routes.py` **Add new dataclass and helper function:** ```python from dataclasses import dataclass, field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.types import ASGIApp @DataClass class AuthConfig: """Configuration for auth components in Starlette apps.""" # Token verification (required) token_verifier: TokenVerifier # Auth settings issuer_url: AnyHttpUrl required_scopes: list[str] = field(default_factory=list) resource_server_url: AnyHttpUrl | None = None # Optional: Full OAuth AS provider for serving auth endpoints auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None service_documentation_url: AnyHttpUrl | None = None client_registration_options: ClientRegistrationOptions | None = None revocation_options: RevocationOptions | None = None @DataClass class AuthComponents: """Auth components ready to be used in a Starlette app.""" routes: list[Route] middleware: list[Middleware] endpoint_wrapper: Callable[[ASGIApp], ASGIApp] def build_auth_components(config: AuthConfig) -> AuthComponents: """ Build auth routes, middleware, and endpoint wrapper from config. Returns an AuthComponents with: - routes: OAuth AS routes (if provider set) + protected resource metadata - middleware: AuthenticationMiddleware + AuthContextMiddleware - endpoint_wrapper: RequireAuthMiddleware wrapper function """ routes: list[Route] = [] # Build middleware middleware = [ Middleware( AuthenticationMiddleware, backend=BearerAuthBackend(config.token_verifier), ), Middleware(AuthContextMiddleware), ] # Add OAuth AS routes if provider is configured if config.auth_server_provider: routes.extend( create_auth_routes( provider=config.auth_server_provider, issuer_url=config.issuer_url, service_documentation_url=config.service_documentation_url, client_registration_options=config.client_registration_options, revocation_options=config.revocation_options, ) ) # Add protected resource metadata routes if resource_server_url is set if config.resource_server_url: routes.extend( create_protected_resource_routes( resource_url=config.resource_server_url, authorization_servers=[config.issuer_url], scopes_supported=config.required_scopes or None, ) ) # Build endpoint wrapper resource_metadata_url = None if config.resource_server_url: resource_metadata_url = build_resource_metadata_url(config.resource_server_url) def endpoint_wrapper(app: ASGIApp) -> ASGIApp: return RequireAuthMiddleware(app, config.required_scopes, resource_metadata_url) return AuthComponents( routes=routes, middleware=middleware, endpoint_wrapper=endpoint_wrapper, ) ``` ### Step 2: Refactor `FastMCP.streamable_http_app()` Replace the ~50 lines of auth logic with: ```python def streamable_http_app(self) -> Starlette: """Return an instance of the StreamableHTTP server app.""" additional_routes: list[Route | Mount] = [] middleware: list[Middleware] = [] endpoint_wrapper: Callable[[ASGIApp], ASGIApp] | None = None # Build auth components if auth is configured if self.settings.auth and self._token_verifier: from mcp.server.auth.routes import AuthConfig, build_auth_components auth_config = AuthConfig( token_verifier=self._token_verifier, issuer_url=self.settings.auth.issuer_url, required_scopes=self.settings.auth.required_scopes or [], resource_server_url=self.settings.auth.resource_server_url, auth_server_provider=self._auth_server_provider, 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, ) auth_components = build_auth_components(auth_config) additional_routes.extend(auth_components.routes) middleware = auth_components.middleware endpoint_wrapper = auth_components.endpoint_wrapper # Add custom routes last additional_routes.extend(self._custom_starlette_routes) # Create config and call low-level function config = StreamableHTTPAppConfig( mcp_server=self._mcp_server, event_store=self._event_store, retry_interval=self._retry_interval, json_response=self.settings.json_response, stateless=self.settings.stateless_http, security_settings=self.settings.transport_security, endpoint_path=self.settings.streamable_http_path, debug=self.settings.debug, additional_routes=additional_routes, middleware=middleware, endpoint_wrapper=endpoint_wrapper, ) starlette_app, session_manager = create_streamable_http_app(config) self._session_manager = session_manager return starlette_app ``` ### Step 3: Refactor `FastMCP.sse_app()` Similar refactor - replace the auth logic with `build_auth_components()`. The SSE app has slightly different route structure (two endpoints: SSE and messages), so the wrapper is applied to each endpoint individually rather than via config: ```python def sse_app(self, mount_path: str | None = None) -> Starlette: """Return an instance of the SSE server app.""" if mount_path is not None: self.settings.mount_path = mount_path normalized_message_endpoint = self._normalize_path( self.settings.mount_path, self.settings.message_path ) sse = SseServerTransport( normalized_message_endpoint, security_settings=self.settings.transport_security, ) async def handle_sse(scope: Scope, receive: Receive, send: Send): async with sse.connect_sse(scope, receive, send) as streams: await self._mcp_server.run( streams[0], streams[1], self._mcp_server.create_initialization_options(), ) return Response() routes: list[Route | Mount] = [] middleware: list[Middleware] = [] # Build auth components if configured if self.settings.auth and self._token_verifier: from mcp.server.auth.routes import AuthConfig, build_auth_components auth_config = AuthConfig( token_verifier=self._token_verifier, issuer_url=self.settings.auth.issuer_url, required_scopes=self.settings.auth.required_scopes or [], resource_server_url=self.settings.auth.resource_server_url, auth_server_provider=self._auth_server_provider, 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, ) auth_components = build_auth_components(auth_config) routes.extend(auth_components.routes) middleware = auth_components.middleware # SSE has two endpoints that need wrapping routes.append(Route( self.settings.sse_path, endpoint=auth_components.endpoint_wrapper(handle_sse), methods=["GET"], )) routes.append(Mount( self.settings.message_path, app=auth_components.endpoint_wrapper(sse.handle_post_message), )) else: # No auth - add routes directly async def sse_endpoint(request: Request) -> Response: return await handle_sse(request.scope, request.receive, request._send) routes.append(Route(self.settings.sse_path, endpoint=sse_endpoint, methods=["GET"])) routes.append(Mount(self.settings.message_path, app=sse.handle_post_message)) routes.extend(self._custom_starlette_routes) return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware) ``` ### Step 4: Update exports in `src/mcp/server/__init__.py` Add the new auth helper to exports: ```python from .auth.routes import AuthConfig, AuthComponents, build_auth_components ``` ## Verification 1. Run existing tests: ```bash PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest tests/server/fastmcp/ ``` 2. Run auth tests specifically: ```bash PYTEST_DISABLE_PLUGIN_AUTOLOAD="" uv run --frozen pytest tests/server/fastmcp/auth/ ``` 3. Run type checking: ```bash uv run --frozen pyright ``` 4. Test low-level usage with auth: ```python from mcp.server import Server, StreamableHTTPAppConfig, create_streamable_http_app from mcp.server.auth.routes import AuthConfig, build_auth_components server = Server('test') auth = build_auth_components(AuthConfig(...)) config = StreamableHTTPAppConfig( mcp_server=server, additional_routes=auth.routes, middleware=auth.middleware, endpoint_wrapper=auth.endpoint_wrapper, ) app, manager = create_streamable_http_app(config) ``` ## Result FastMCP's `streamable_http_app()` goes from ~90 lines to ~30 lines, and `sse_app()` similarly shrinks. The auth logic is now: - Reusable by low-level Server users - Testable in isolation - Shared between SSE and StreamableHTTP transports </claude-plan>
1 parent 2b4c7eb commit 34bcf2d

File tree

6 files changed

+417
-158
lines changed

6 files changed

+417
-158
lines changed

src/mcp/server/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
from .fastmcp import FastMCP
22
from .lowlevel import NotificationOptions, Server
33
from .models import InitializationOptions
4+
from .streamable_http_manager import (
5+
StreamableHTTPASGIApp,
6+
StreamableHTTPSessionManager,
7+
create_streamable_http_app,
8+
)
49

5-
__all__ = ["Server", "FastMCP", "NotificationOptions", "InitializationOptions"]
10+
__all__ = [
11+
"Server",
12+
"FastMCP",
13+
"NotificationOptions",
14+
"InitializationOptions",
15+
"StreamableHTTPASGIApp",
16+
"StreamableHTTPSessionManager",
17+
"create_streamable_http_app",
18+
]

src/mcp/server/auth/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
"""
22
MCP OAuth server authorization components.
33
"""
4+
5+
from mcp.server.auth.components import AuthComponents, build_auth_components
6+
7+
__all__ = [
8+
"AuthComponents",
9+
"build_auth_components",
10+
]

src/mcp/server/auth/components.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Auth components for MCP servers."""
2+
3+
from collections.abc import Callable, Sequence
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
from pydantic import AnyHttpUrl
8+
from starlette.middleware import Middleware
9+
from starlette.middleware.authentication import AuthenticationMiddleware
10+
from starlette.routing import Route
11+
from starlette.types import ASGIApp
12+
13+
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
14+
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
15+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
16+
from mcp.server.auth.routes import (
17+
build_resource_metadata_url,
18+
create_auth_routes,
19+
create_protected_resource_routes,
20+
)
21+
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
22+
23+
24+
@dataclass
25+
class AuthComponents:
26+
"""Auth components ready to be used in a Starlette app.
27+
28+
Attributes:
29+
middleware: Authentication middleware to add to the app.
30+
endpoint_wrapper: Wrapper function to protect endpoints with auth.
31+
routes: OAuth and/or protected resource metadata routes.
32+
"""
33+
34+
middleware: list[Middleware]
35+
endpoint_wrapper: Callable[[Any], ASGIApp]
36+
routes: list[Route]
37+
38+
39+
def build_auth_components(
40+
token_verifier: TokenVerifier,
41+
*,
42+
required_scopes: Sequence[str] = (),
43+
# OAuth AS routes (only if MCP server IS the auth server)
44+
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
45+
issuer_url: AnyHttpUrl | None = None,
46+
service_documentation_url: AnyHttpUrl | None = None,
47+
client_registration_options: ClientRegistrationOptions | None = None,
48+
revocation_options: RevocationOptions | None = None,
49+
# Protected resource metadata routes
50+
resource_server_url: AnyHttpUrl | None = None,
51+
) -> AuthComponents:
52+
"""
53+
Build auth components for a Starlette app.
54+
55+
This function creates the middleware, endpoint wrapper, and routes needed
56+
to add OAuth 2.0 authentication to an MCP server.
57+
58+
Args:
59+
token_verifier: Verifies bearer tokens from requests.
60+
required_scopes: Scopes required to access the MCP endpoint.
61+
auth_server_provider: OAuth AS provider (if MCP server is the auth server).
62+
issuer_url: OAuth issuer URL (required if auth_server_provider is set).
63+
service_documentation_url: URL to service documentation.
64+
client_registration_options: Options for dynamic client registration.
65+
revocation_options: Options for token revocation.
66+
resource_server_url: Resource server URL for protected resource metadata.
67+
68+
Returns:
69+
AuthComponents containing middleware, endpoint wrapper, and routes.
70+
71+
Example:
72+
>>> auth = build_auth_components(
73+
... token_verifier=my_verifier,
74+
... required_scopes=["mcp:read"],
75+
... resource_server_url="https://mcp.example.com",
76+
... )
77+
>>> app = create_streamable_http_app(
78+
... session_manager,
79+
... middleware=auth.middleware,
80+
... endpoint_wrapper=auth.endpoint_wrapper,
81+
... additional_routes=auth.routes,
82+
... )
83+
"""
84+
routes: list[Route] = []
85+
86+
# Build middleware
87+
middleware = [
88+
Middleware(
89+
AuthenticationMiddleware,
90+
backend=BearerAuthBackend(token_verifier),
91+
),
92+
Middleware(AuthContextMiddleware),
93+
]
94+
95+
# Add OAuth AS routes if provider is configured
96+
if auth_server_provider is not None:
97+
if issuer_url is None:
98+
raise ValueError("issuer_url is required when auth_server_provider is set")
99+
routes.extend(
100+
create_auth_routes(
101+
provider=auth_server_provider,
102+
issuer_url=issuer_url,
103+
service_documentation_url=service_documentation_url,
104+
client_registration_options=client_registration_options,
105+
revocation_options=revocation_options,
106+
)
107+
)
108+
109+
# Add protected resource metadata routes if resource_server_url is set
110+
if resource_server_url is not None:
111+
authorization_servers = [issuer_url] if issuer_url is not None else []
112+
routes.extend(
113+
create_protected_resource_routes(
114+
resource_url=resource_server_url,
115+
authorization_servers=authorization_servers,
116+
scopes_supported=list(required_scopes) if required_scopes else None,
117+
)
118+
)
119+
120+
# Build endpoint wrapper
121+
resource_metadata_url = None
122+
if resource_server_url is not None:
123+
resource_metadata_url = build_resource_metadata_url(resource_server_url)
124+
125+
def endpoint_wrapper(app: Any) -> ASGIApp:
126+
return RequireAuthMiddleware(app, list(required_scopes), resource_metadata_url)
127+
128+
return AuthComponents(
129+
middleware=middleware,
130+
endpoint_wrapper=endpoint_wrapper,
131+
routes=routes,
132+
)

0 commit comments

Comments
 (0)