66
77from fastapi .responses import HTMLResponse , JSONResponse , FileResponse
88from starlette .applications import Starlette
9+ from starlette .datastructures import MutableHeaders
10+ from starlette .types import Scope , Receive , Send
911from starlette .requests import Request
10- from starlette .responses import Response
1112from starlette .middleware import Middleware
1213
1314from fastmcp import FastMCP
4041session_middleware = Middleware (MCPSessionMiddleware )
4142
4243
43- def _compute_scope_fingerprint () -> str :
44- """Compute a short hash of the current scope configuration for cache-busting."""
45- scopes_str = "," .join (sorted (get_current_scopes ()))
46- return hashlib .sha256 (scopes_str .encode ()).hexdigest ()[:12 ]
44+ class WellKnownCacheControlMiddleware :
45+ """Force no-cache headers for OAuth well-known discovery endpoints."""
4746
47+ def __init__ (self , app ):
48+ self .app = app
4849
49- def _wrap_well_known_endpoint (endpoint , etag : str ):
50- """Wrap a well-known metadata endpoint to prevent browser caching.
50+ async def __call__ (self , scope : Scope , receive : Receive , send : Send ) -> None :
51+ if scope ["type" ] != "http" :
52+ await self .app (scope , receive , send )
53+ return
5154
52- The MCP SDK hardcodes ``Cache-Control: public, max-age=3600`` on discovery
53- responses. When the server restarts with different ``--permissions`` or
54- ``--read-only`` flags, browsers / MCP clients serve stale metadata that
55- advertises the wrong scopes, causing OAuth to silently fail.
55+ path = scope .get ("path" , "" )
56+ is_oauth_well_known = (
57+ path == "/.well-known/oauth-authorization-server"
58+ or path .startswith ("/.well-known/oauth-authorization-server/" )
59+ or path == "/.well-known/oauth-protected-resource"
60+ or path .startswith ("/.well-known/oauth-protected-resource/" )
61+ )
62+ if not is_oauth_well_known :
63+ await self .app (scope , receive , send )
64+ return
5665
57- The wrapper overrides the header to ``no-store`` and adds an ``ETag``
58- derived from the current scope set so intermediary caches that ignore
59- ``no-store`` still see a fingerprint change.
60- """
66+ async def send_with_no_cache_headers (message ):
67+ if message ["type" ] == "http.response.start" :
68+ headers = MutableHeaders (raw = message .setdefault ("headers" , []))
69+ headers ["Cache-Control" ] = "no-store, must-revalidate"
70+ headers ["ETag" ] = f'"{ _compute_scope_fingerprint ()} "'
71+ await send (message )
72+
73+ await self .app (scope , receive , send_with_no_cache_headers )
6174
62- async def _no_cache_endpoint (request : Request ) -> Response :
63- response = await endpoint (request )
64- response .headers ["Cache-Control" ] = "no-store, must-revalidate"
65- response .headers ["ETag" ] = etag
66- return response
6775
68- return _no_cache_endpoint
76+ well_known_cache_control_middleware = Middleware (WellKnownCacheControlMiddleware )
77+
78+
79+ def _compute_scope_fingerprint () -> str :
80+ """Compute a short hash of the current scope configuration for cache-busting."""
81+ scopes_str = "," .join (sorted (get_current_scopes ()))
82+ return hashlib .sha256 (scopes_str .encode ()).hexdigest ()[:12 ]
6983
7084
7185# Custom FastMCP that adds secure middleware stack for OAuth 2.1
@@ -75,12 +89,16 @@ def http_app(self, **kwargs) -> "Starlette":
7589 app = super ().http_app (** kwargs )
7690
7791 # Add middleware in order (first added = outermost layer)
92+ app .user_middleware .insert (0 , well_known_cache_control_middleware )
93+
7894 # Session Management - extracts session info for MCP context
79- app .user_middleware .insert (0 , session_middleware )
95+ app .user_middleware .insert (1 , session_middleware )
8096
8197 # Rebuild middleware stack
8298 app .middleware_stack = app .build_middleware_stack ()
83- logger .info ("Added middleware stack: Session Management" )
99+ logger .info (
100+ "Added middleware stack: WellKnownCacheControl, Session Management"
101+ )
84102 return app
85103
86104
@@ -417,22 +435,6 @@ def validate_and_derive_jwt_key(
417435 "OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth"
418436 )
419437
420- # Mount well-known routes with cache-busting headers.
421- # The MCP SDK hardcodes Cache-Control: public, max-age=3600
422- # on discovery responses which causes stale-scope bugs when
423- # the server is restarted with a different --permissions config.
424- try :
425- scope_etag = f'"{ _compute_scope_fingerprint ()} "'
426- well_known_routes = provider .get_well_known_routes ()
427- for route in well_known_routes :
428- logger .info (f"Mounting OAuth well-known route: { route .path } " )
429- wrapped = _wrap_well_known_endpoint (route .endpoint , scope_etag )
430- server .custom_route (route .path , methods = list (route .methods ))(
431- wrapped
432- )
433- except Exception as e :
434- logger .warning (f"Could not mount well-known routes: { e } " )
435-
436438 # Always set auth provider for token validation in middleware
437439 set_auth_provider (provider )
438440 _auth_provider = provider
0 commit comments