Skip to content

Commit d514c62

Browse files
Merge pull request #523 from jack-distl/claude/fix-cors-middleware-error-sPbo6
Fix TypeError: CORSMiddleware.__call__() missing 2 required positiona…
2 parents ab5c95a + e999982 commit d514c62

File tree

3 files changed

+130
-40
lines changed

3 files changed

+130
-40
lines changed

core/server.py

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
88
from starlette.applications import Starlette
9+
from starlette.datastructures import MutableHeaders
10+
from starlette.types import Scope, Receive, Send
911
from starlette.requests import Request
10-
from starlette.responses import Response
1112
from starlette.middleware import Middleware
1213

1314
from fastmcp import FastMCP
@@ -40,32 +41,45 @@
4041
session_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
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import importlib
2+
3+
from starlette.applications import Starlette
4+
from starlette.middleware import Middleware
5+
from starlette.responses import Response
6+
from starlette.routing import Route
7+
from starlette.testclient import TestClient
8+
9+
10+
def test_well_known_cache_control_middleware_rewrites_headers():
11+
from core.server import WellKnownCacheControlMiddleware, _compute_scope_fingerprint
12+
13+
async def well_known_endpoint(request):
14+
response = Response("ok")
15+
response.headers["Cache-Control"] = "public, max-age=3600"
16+
response.set_cookie("a", "1")
17+
response.set_cookie("b", "2")
18+
return response
19+
20+
async def regular_endpoint(request):
21+
response = Response("ok")
22+
response.headers["Cache-Control"] = "public, max-age=3600"
23+
return response
24+
25+
app = Starlette(
26+
routes=[
27+
Route("/.well-known/oauth-authorization-server", well_known_endpoint),
28+
Route("/.well-known/oauth-authorization-server-extra", regular_endpoint),
29+
Route("/health", regular_endpoint),
30+
],
31+
middleware=[Middleware(WellKnownCacheControlMiddleware)],
32+
)
33+
client = TestClient(app)
34+
35+
well_known = client.get("/.well-known/oauth-authorization-server")
36+
assert well_known.status_code == 200
37+
assert well_known.headers["cache-control"] == "no-store, must-revalidate"
38+
assert well_known.headers["etag"] == f'"{_compute_scope_fingerprint()}"'
39+
assert sorted(well_known.headers.get_list("set-cookie")) == sorted(
40+
["a=1; Path=/; SameSite=lax", "b=2; Path=/; SameSite=lax"]
41+
)
42+
43+
regular = client.get("/health")
44+
assert regular.status_code == 200
45+
assert regular.headers["cache-control"] == "public, max-age=3600"
46+
assert "etag" not in regular.headers
47+
48+
extra = client.get("/.well-known/oauth-authorization-server-extra")
49+
assert extra.status_code == 200
50+
assert extra.headers["cache-control"] == "public, max-age=3600"
51+
assert "etag" not in extra.headers
52+
53+
54+
def test_configured_server_applies_no_cache_to_served_oauth_discovery_routes(monkeypatch):
55+
monkeypatch.setenv("MCP_ENABLE_OAUTH21", "true")
56+
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "dummy-client")
57+
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "dummy-secret")
58+
monkeypatch.setenv("WORKSPACE_MCP_BASE_URI", "http://localhost")
59+
monkeypatch.setenv("WORKSPACE_MCP_PORT", "8000")
60+
monkeypatch.delenv("WORKSPACE_EXTERNAL_URL", raising=False)
61+
monkeypatch.setenv("EXTERNAL_OAUTH21_PROVIDER", "false")
62+
63+
import core.server as core_server
64+
from auth.oauth_config import reload_oauth_config
65+
66+
reload_oauth_config()
67+
core_server = importlib.reload(core_server)
68+
core_server.set_transport_mode("streamable-http")
69+
core_server.configure_server_for_http()
70+
71+
app = core_server.server.http_app(transport="streamable-http", path="/mcp")
72+
client = TestClient(app)
73+
74+
authorization_server = client.get("/.well-known/oauth-authorization-server")
75+
assert authorization_server.status_code == 200
76+
assert authorization_server.headers["cache-control"] == "no-store, must-revalidate"
77+
assert authorization_server.headers["etag"].startswith('"')
78+
assert authorization_server.headers["etag"].endswith('"')
79+
80+
protected_resource = client.get("/.well-known/oauth-protected-resource/mcp")
81+
assert protected_resource.status_code == 200
82+
assert protected_resource.headers["cache-control"] == "no-store, must-revalidate"
83+
assert protected_resource.headers["etag"].startswith('"')
84+
assert protected_resource.headers["etag"].endswith('"')
85+
86+
# Ensure we did not create a shadow route at the wrong path.
87+
wrong_path = client.get("/.well-known/oauth-protected-resource")
88+
assert wrong_path.status_code == 404

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)