Make CORS opt-in via middleware parameter (#2150)

This commit is contained in:
Jeremiah Lowin 2025-10-20 15:33:13 -04:00 committed by GitHub
commit 254ff1a25d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 87 additions and 18 deletions

View file

@ -145,6 +145,59 @@ middleware = [
http_app = mcp.http_app(middleware=middleware)
```
### CORS for Browser-Based Clients
<Tip>
Most MCP clients, including those that you access through a browser like ChatGPT or Claude, don't need CORS configuration. Only enable CORS if you're working with an MCP client that connects directly from a browser, such as debugging tools or inspectors.
</Tip>
CORS (Cross-Origin Resource Sharing) is needed when JavaScript running in a web browser connects directly to your MCP server. This is different from using an LLM through a browser—in that case, the browser connects to the LLM service, and the LLM service connects to your MCP server (no CORS needed).
Browser-based MCP clients that need CORS include:
- **MCP Inspector** - Browser-based debugging tool for testing MCP servers
- **Custom browser-based MCP clients** - If you're building a web app that directly connects to MCP servers
For these scenarios, add CORS middleware with the specific headers required for MCP protocol:
```python
from fastmcp import FastMCP
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
mcp = FastMCP("MyServer")
# Configure CORS for browser-based clients
middleware = [
Middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins; use specific origins for security
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=[
"mcp-protocol-version",
"mcp-session-id",
"Authorization",
"Content-Type",
],
expose_headers=["mcp-session-id"],
)
]
app = mcp.http_app(middleware=middleware)
```
**Key configuration details:**
- **`allow_origins`**: Specify exact origins (e.g., `["http://localhost:3000"]`) rather than `["*"]` for production deployments
- **`allow_headers`**: Must include `mcp-protocol-version`, `mcp-session-id`, and `Authorization` (for authenticated servers)
- **`expose_headers`**: Must include `mcp-session-id` so JavaScript can read the session ID from responses and send it in subsequent requests
Without `expose_headers=["mcp-session-id"]`, browsers will receive the session ID but JavaScript won't be able to access it, causing session management to fail.
<Warning>
**Production Security**: Never use `allow_origins=["*"]` in production. Specify the exact origins of your browser-based clients. Using wildcards exposes your server to unauthorized access from any website.
</Warning>
## Integration with Web Frameworks
If you already have a web application running, you can add MCP capabilities by mounting a FastMCP server as a sub-application. This allows you to expose MCP tools alongside your existing API endpoints, sharing the same domain and infrastructure. The MCP server becomes just another route in your application, making it easy to manage and deploy.

View file

@ -9,7 +9,9 @@ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
from mcp.server.auth.routes import build_resource_metadata_url
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http import (
EventStore,
)
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.middleware import Middleware
@ -179,7 +181,7 @@ def create_sse_app(
build_resource_metadata_url(resource_url) if resource_url else None
)
# Create protected SSE endpoint route with GET method only
# Create protected SSE endpoint route
server_routes.append(
Route(
sse_path,
@ -316,6 +318,7 @@ def create_streamable_http_app(
auth.required_scopes,
resource_metadata_url,
),
methods=["GET", "POST", "DELETE"],
)
)
else:

View file

@ -1,6 +1,7 @@
import pytest
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
from starlette.routing import Route
from starlette.testclient import TestClient
from fastmcp.server import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
@ -25,9 +26,10 @@ class TestStreamableHTTPAppResourceMetadataURL:
)
return provider
def test_require_auth_middleware_receives_resource_metadata_url(
def test_auth_endpoint_wrapped_with_require_auth_middleware(
self, bearer_auth_provider
):
"""Test that auth-protected endpoints use RequireAuthMiddleware."""
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
@ -38,14 +40,11 @@ class TestStreamableHTTPAppResourceMetadataURL:
route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp")
# When auth is enabled, endpoint should use RequireAuthMiddleware
assert isinstance(route.endpoint, RequireAuthMiddleware)
# The metadata URL includes the resource path per RFC 9728
assert (
str(route.endpoint.resource_metadata_url)
== "https://resource.example.com/.well-known/oauth-protected-resource/mcp"
)
def test_trailing_slash_handling_in_resource_server_url(self, rsa_key_pair):
def test_auth_endpoint_has_correct_methods(self, rsa_key_pair):
"""Test that auth-protected endpoints have correct HTTP methods."""
provider = JWTVerifier(
public_key=rsa_key_pair.public_key,
issuer="https://issuer",
@ -59,17 +58,15 @@ class TestStreamableHTTPAppResourceMetadataURL:
auth=provider,
)
route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp")
assert isinstance(route.endpoint, RequireAuthMiddleware)
# The metadata URL includes the resource path per RFC 9728
# Trailing slash in base_url is normalized
assert (
str(route.endpoint.resource_metadata_url)
== "https://resource.example.com/.well-known/oauth-protected-resource/mcp"
)
def test_no_auth_provider_mounts_without_require_auth_middleware(
self, rsa_key_pair
):
# Verify RequireAuthMiddleware is applied
assert isinstance(route.endpoint, RequireAuthMiddleware)
# Verify methods include GET, POST, DELETE for streamable-http
expected_methods = {"GET", "POST", "DELETE"}
assert expected_methods.issubset(set(route.methods))
def test_no_auth_provider_mounts_without_middleware(self, rsa_key_pair):
"""Test that endpoints without auth are not wrapped with middleware."""
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
server=server,
@ -77,4 +74,20 @@ class TestStreamableHTTPAppResourceMetadataURL:
auth=None,
)
route = next(r for r in app.routes if isinstance(r, Route) and r.path == "/mcp")
# Without auth, no RequireAuthMiddleware should be applied
assert not isinstance(route.endpoint, RequireAuthMiddleware)
def test_authenticated_requests_still_require_auth(self, bearer_auth_provider):
"""Test that actual requests (not OPTIONS) still require authentication."""
server = FastMCP(name="TestServer")
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
auth=bearer_auth_provider,
)
# Test POST request without auth - should fail with 401
with TestClient(app) as client:
response = client.post("/mcp")
assert response.status_code == 401
assert "www-authenticate" in response.headers