mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Make CORS opt-in via middleware parameter (#2150)
This commit is contained in:
parent
09e899a699
commit
254ff1a25d
3 changed files with 87 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue