fix: Add CORS middleware to allow browser-based clients like MCP Inspector

This commit is contained in:
Chris Coutinho
2025-10-23 15:23:41 +02:00
parent 87c6f077f3
commit 053cf7798b
3 changed files with 35 additions and 9 deletions
+31 -5
View File
@@ -11,6 +11,7 @@ from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyHttpUrl from pydantic import AnyHttpUrl
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
@@ -534,10 +535,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
if oauth_enabled: if oauth_enabled:
def oauth_protected_resource_metadata(request): def oauth_protected_resource_metadata(request):
"""RFC 8959 Protected Resource Metadata endpoint.""" """RFC 9728 Protected Resource Metadata endpoint."""
mcp_server_url = os.getenv( mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
) )
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
resource_url = f"{mcp_server_url}/mcp"
# Use PUBLIC_ISSUER_URL for authorization server since external clients # Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs # (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -547,14 +551,24 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
return JSONResponse( return JSONResponse(
{ {
"resource": mcp_server_url, "resource": resource_url,
"scopes_supported": ["nc:read", "nc:write"], "scopes_supported": ["openid", "nc:read", "nc:write"],
"authorization_servers": [public_issuer_url], "authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"], "bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"], "resource_signing_alg_values_supported": ["RS256"],
} }
) )
# Register PRM endpoint at both path-based and root locations per RFC 9728
# Path-based discovery: /.well-known/oauth-protected-resource{path}
routes.append(
Route(
"/.well-known/oauth-protected-resource/mcp",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
# Root discovery (fallback): /.well-known/oauth-protected-resource
routes.append( routes.append(
Route( Route(
"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource",
@@ -562,11 +576,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
methods=["GET"], methods=["GET"],
) )
) )
logger.info("Protected Resource Metadata (PRM) endpoint enabled") logger.info(
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
routes.append(Mount("/", app=mcp_app)) routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan) app = Starlette(routes=routes, lifespan=lifespan)
# Add CORS middleware to allow browser-based clients like MCP Inspector
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Add exception handler for scope challenges (OAuth mode only) # Add exception handler for scope challenges (OAuth mode only)
if oauth_enabled: if oauth_enabled:
@@ -584,7 +610,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"WWW-Authenticate": ( "WWW-Authenticate": (
f'Bearer error="insufficient_scope", ' f'Bearer error="insufficient_scope", '
f'scope="{scope_str}", ' f'scope="{scope_str}", '
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"' f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
) )
}, },
content={ content={
+3 -3
View File
@@ -16,15 +16,15 @@ async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data.""" """Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx import httpx
# Test the PRM endpoint directly # Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.get(
"http://localhost:8001/.well-known/oauth-protected-resource" "http://localhost:8001/.well-known/oauth-protected-resource/mcp"
) )
assert response.status_code == 200 assert response.status_code == 200
prm_data = response.json() prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8001" assert prm_data["resource"] == "http://localhost:8001/mcp"
assert "nc:read" in prm_data["scopes_supported"] assert "nc:read" in prm_data["scopes_supported"]
assert "nc:write" in prm_data["scopes_supported"] assert "nc:write" in prm_data["scopes_supported"]
assert "http://localhost:8080" in prm_data["authorization_servers"] assert "http://localhost:8080" in prm_data["authorization_servers"]