better cachce management
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Callable, List, Optional
|
from typing import List, Optional
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
@@ -46,31 +46,27 @@ def _compute_scope_fingerprint() -> str:
|
|||||||
return hashlib.sha256(scopes_str.encode()).hexdigest()[:12]
|
return hashlib.sha256(scopes_str.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
class OAuthMetadataCacheBustMiddleware(BaseHTTPMiddleware):
|
def _wrap_well_known_endpoint(endpoint, etag: str):
|
||||||
"""Override the upstream 1-hour Cache-Control on OAuth discovery endpoints.
|
"""Wrap a well-known metadata endpoint to prevent browser caching.
|
||||||
|
|
||||||
The MCP SDK sets ``Cache-Control: public, max-age=3600`` on the
|
The MCP SDK hardcodes ``Cache-Control: public, max-age=3600`` on discovery
|
||||||
``.well-known`` metadata responses. When the server is restarted with a
|
responses. When the server restarts with different ``--permissions`` or
|
||||||
different ``--permissions`` or ``--read-only`` configuration, browsers /
|
``--read-only`` flags, browsers / MCP clients serve stale metadata that
|
||||||
MCP clients can serve stale discovery docs that advertise the wrong
|
advertises the wrong scopes, causing OAuth to silently fail.
|
||||||
scopes, causing the OAuth flow to silently fail.
|
|
||||||
|
|
||||||
This middleware replaces the cache header with ``no-store`` and adds an
|
The wrapper overrides the header to ``no-store`` and adds an ``ETag``
|
||||||
``ETag`` derived from the current scope set so that intermediary caches
|
derived from the current scope set so intermediary caches that ignore
|
||||||
that *do* store the response will still invalidate on config change.
|
``no-store`` still see a fingerprint change.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app: Starlette, scope_fingerprint: str) -> None:
|
async def _no_cache_endpoint(request: Request) -> Response:
|
||||||
super().__init__(app)
|
response = await endpoint(request)
|
||||||
self._etag = f'"{scope_fingerprint}"'
|
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
||||||
|
response.headers["ETag"] = etag
|
||||||
async def dispatch(self, request: Request, call_next: Callable):
|
|
||||||
response = await call_next(request)
|
|
||||||
if request.url.path.startswith("/.well-known/"):
|
|
||||||
response.headers["Cache-Control"] = "no-store, must-revalidate"
|
|
||||||
response.headers["ETag"] = self._etag
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
return _no_cache_endpoint
|
||||||
|
|
||||||
|
|
||||||
# Custom FastMCP that adds secure middleware stack for OAuth 2.1
|
# Custom FastMCP that adds secure middleware stack for OAuth 2.1
|
||||||
class SecureFastMCP(FastMCP):
|
class SecureFastMCP(FastMCP):
|
||||||
@@ -82,13 +78,6 @@ class SecureFastMCP(FastMCP):
|
|||||||
# Session Management - extracts session info for MCP context
|
# Session Management - extracts session info for MCP context
|
||||||
app.user_middleware.insert(0, session_middleware)
|
app.user_middleware.insert(0, session_middleware)
|
||||||
|
|
||||||
# Prevent browser caching of OAuth discovery endpoints across config changes
|
|
||||||
fingerprint = _compute_scope_fingerprint()
|
|
||||||
app.user_middleware.insert(
|
|
||||||
0,
|
|
||||||
Middleware(OAuthMetadataCacheBustMiddleware, scope_fingerprint=fingerprint),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Rebuild middleware stack
|
# Rebuild middleware stack
|
||||||
app.middleware_stack = app.build_middleware_stack()
|
app.middleware_stack = app.build_middleware_stack()
|
||||||
logger.info("Added middleware stack: Session Management")
|
logger.info("Added middleware stack: Session Management")
|
||||||
@@ -428,14 +417,18 @@ def configure_server_for_http():
|
|||||||
"OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth"
|
"OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Explicitly mount well-known routes from the OAuth provider
|
# Mount well-known routes with cache-busting headers.
|
||||||
# These should be auto-mounted but we ensure they're available
|
# The MCP SDK hardcodes Cache-Control: public, max-age=3600
|
||||||
|
# on discovery responses which causes stale-scope bugs when
|
||||||
|
# the server is restarted with a different --permissions config.
|
||||||
try:
|
try:
|
||||||
|
scope_etag = f'"{_compute_scope_fingerprint()}"'
|
||||||
well_known_routes = provider.get_well_known_routes()
|
well_known_routes = provider.get_well_known_routes()
|
||||||
for route in well_known_routes:
|
for route in well_known_routes:
|
||||||
logger.info(f"Mounting OAuth well-known route: {route.path}")
|
logger.info(f"Mounting OAuth well-known route: {route.path}")
|
||||||
|
wrapped = _wrap_well_known_endpoint(route.endpoint, scope_etag)
|
||||||
server.custom_route(route.path, methods=list(route.methods))(
|
server.custom_route(route.path, methods=list(route.methods))(
|
||||||
route.endpoint
|
wrapped
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not mount well-known routes: {e}")
|
logger.warning(f"Could not mount well-known routes: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user