refactor: move transport-specific parameters from FastMCP constructor to run() (#1898)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Marcelo Trylesinski 2026-01-16 16:16:20 +01:00 • committed by GitHub
commit 8adb5bdce8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 339 additions and 231 deletions

View file

@ -146,7 +146,7 @@ Run from the repository root:
from mcp.server.fastmcp import FastMCP
# Create an MCP server
mcp = FastMCP("Demo", json_response=True)
mcp = FastMCP("Demo")
# Add an addition tool
@ -178,7 +178,7 @@ def greet_user(name: str, style: str = "friendly") -> str:
# Run with streamable HTTP transport
if __name__ == "__main__":
mcp.run(transport="streamable-http")
mcp.run(transport="streamable-http", json_response=True)
```
_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/fastmcp_quickstart.py)_
@ -1026,7 +1026,6 @@ class SimpleTokenVerifier(TokenVerifier):
# Create FastMCP instance as a Resource Server
mcp = FastMCP(
"Weather Service",
json_response=True,
# Token verifier for authentication
token_verifier=SimpleTokenVerifier(),
# Auth settings for RFC 9728 Protected Resource Metadata
@ -1050,7 +1049,7 @@ async def get_weather(city: str = "London") -> dict[str, str]:
if __name__ == "__main__":
mcp.run(transport="streamable-http")
mcp.run(transport="streamable-http", json_response=True)
```
_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_
@ -1253,15 +1252,7 @@ Run from the repository root:
from mcp.server.fastmcp import FastMCP
# Stateless server with JSON responses (recommended)
mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True)
# Other configuration options:
# Stateless server with SSE streaming responses
# mcp = FastMCP("StatelessServer", stateless_http=True)
# Stateful server with session persistence
# mcp = FastMCP("StatefulServer")
mcp = FastMCP("StatelessServer")
# Add a simple tool to demonstrate the server
@ -1272,8 +1263,17 @@ def greet(name: str = "World") -> str:
# Run server with streamable_http transport
# Transport-specific options (stateless_http, json_response) are passed to run()
if __name__ == "__main__":
mcp.run(transport="streamable-http")
# Stateless server with JSON responses (recommended)
mcp.run(transport="streamable-http", stateless_http=True, json_response=True)
# Other configuration options:
# Stateless server with SSE streaming responses
# mcp.run(transport="streamable-http", stateless_http=True)
# Stateful server with session persistence
# mcp.run(transport="streamable-http")
```
_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_
@ -1296,7 +1296,7 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create the Echo server
echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True)
echo_mcp = FastMCP(name="EchoServer")
@echo_mcp.tool()
@ -1306,7 +1306,7 @@ def echo(message: str) -> str:
# Create the Math server
math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True)
math_mcp = FastMCP(name="MathServer")
@math_mcp.tool()
@ -1327,16 +1327,16 @@ async def lifespan(app: Starlette):
# Create the Starlette app and mount the MCP servers
app = Starlette(
routes=[
Mount("/echo", echo_mcp.streamable_http_app()),
Mount("/math", math_mcp.streamable_http_app()),
Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)),
Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)),
],
lifespan=lifespan,
)
# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp
# To mount at the root of each path (e.g., /echo instead of /echo/mcp):
# echo_mcp.settings.streamable_http_path = "/"
# math_mcp.settings.streamable_http_path = "/"
# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True)
# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True)
```
_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_
@ -1409,7 +1409,7 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create MCP server
mcp = FastMCP("My App", json_response=True)
mcp = FastMCP("My App")
@mcp.tool()
@ -1426,9 +1426,10 @@ async def lifespan(app: Starlette):
# Mount the StreamableHTTP server to the existing ASGI server
# Transport-specific options are passed to streamable_http_app()
app = Starlette(
routes=[
Mount("/", app=mcp.streamable_http_app()),
Mount("/", app=mcp.streamable_http_app(json_response=True)),
],
lifespan=lifespan,
)
@ -1456,7 +1457,7 @@ from starlette.routing import Host
from mcp.server.fastmcp import FastMCP
# Create MCP server
mcp = FastMCP("MCP Host App", json_response=True)
mcp = FastMCP("MCP Host App")
@mcp.tool()
@ -1473,9 +1474,10 @@ async def lifespan(app: Starlette):
# Mount using Host-based routing
# Transport-specific options are passed to streamable_http_app()
app = Starlette(
routes=[
Host("mcp.acme.corp", app=mcp.streamable_http_app()),
Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)),
],
lifespan=lifespan,
)
@ -1503,8 +1505,8 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create multiple MCP servers
api_mcp = FastMCP("API Server", json_response=True)
chat_mcp = FastMCP("Chat Server", json_response=True)
api_mcp = FastMCP("API Server")
chat_mcp = FastMCP("Chat Server")
@api_mcp.tool()
@ -1519,12 +1521,6 @@ def send_message(message: str) -> str:
return f"Message sent: {message}"
# Configure servers to mount at the root of each path
# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp
api_mcp.settings.streamable_http_path = "/"
chat_mcp.settings.streamable_http_path = "/"
# Create a combined lifespan to manage both session managers
@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
@ -1534,11 +1530,12 @@ async def lifespan(app: Starlette):
yield
# Mount the servers
# Mount the servers with transport-specific options passed to streamable_http_app()
# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp
app = Starlette(
routes=[
Mount("/api", app=api_mcp.streamable_http_app()),
Mount("/chat", app=chat_mcp.streamable_http_app()),
Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")),
Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")),
],
lifespan=lifespan,
)
@ -1552,7 +1549,7 @@ _Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](h
<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py -->
```python
"""
Example showing path configuration during FastMCP initialization.
Example showing path configuration when mounting FastMCP.
Run from the repository root:
uvicorn examples.snippets.servers.streamable_http_path_config:app --reload
@ -1563,13 +1560,8 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Configure streamable_http_path during initialization
# This server will mount at the root of wherever it's mounted
mcp_at_root = FastMCP(
"My Server",
json_response=True,
streamable_http_path="/",
)
# Create a simple FastMCP server
mcp_at_root = FastMCP("My Server")
@mcp_at_root.tool()
@ -1578,10 +1570,14 @@ def process_data(data: str) -> str:
return f"Processed: {data}"
# Mount at /process - endpoints will be at /process instead of /process/mcp
# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp)
# Transport-specific options like json_response are passed to streamable_http_app()
app = Starlette(
routes=[
Mount("/process", app=mcp_at_root.streamable_http_app()),
Mount(
"/process",
app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"),
),
]
)
```

View file

@ -122,6 +122,63 @@ The `mount_path` parameter has been removed from `FastMCP.__init__()`, `FastMCP.
This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path.
### Transport-specific parameters moved from FastMCP constructor to run()/app methods
Transport-specific parameters have been moved from the `FastMCP` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server.
**Parameters moved:**
- `host`, `port` - HTTP server binding
- `sse_path`, `message_path` - SSE transport paths
- `streamable_http_path` - StreamableHTTP endpoint path
- `json_response`, `stateless_http` - StreamableHTTP behavior
- `event_store`, `retry_interval` - StreamableHTTP event handling
- `transport_security` - DNS rebinding protection
**Before (v1):**
```python
from mcp.server.fastmcp import FastMCP
# Transport params in constructor
mcp = FastMCP("Demo", json_response=True, stateless_http=True)
mcp.run(transport="streamable-http")
# Or for SSE
mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events")
mcp.run(transport="sse")
```
**After (v2):**
```python
from mcp.server.fastmcp import FastMCP
# Transport params passed to run()
mcp = FastMCP("Demo")
mcp.run(transport="streamable-http", json_response=True, stateless_http=True)
# Or for SSE
mcp = FastMCP("Server")
mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events")
```
**For mounted apps:**
When mounting FastMCP in a Starlette app, pass transport params to the app methods:
```python
# Before (v1)
mcp = FastMCP("App", json_response=True)
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
# After (v2)
mcp = FastMCP("App")
app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))])
```
**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor.
### Resource URI type changed from `AnyUrl` to `str`
The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected.

View file

@ -83,8 +83,6 @@ event_store = InMemoryEventStore()
mcp = FastMCP(
name="mcp-conformance-test-server",
event_store=event_store,
retry_interval=100, # 100ms retry interval for SSE polling
)
@ -448,8 +446,12 @@ def main(port: int, log_level: str) -> int:
logger.info(f"Starting MCP Everything Server on port {port}")
logger.info(f"Endpoint will be: http://localhost:{port}/mcp")
mcp.settings.port = port
mcp.run(transport="streamable-http")
mcp.run(
transport="streamable-http",
port=port,
event_store=event_store,
retry_interval=100, # 100ms retry interval for SSE polling
)
return 0

View file

@ -66,11 +66,11 @@ def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: Sim
name="Simple Auth MCP Server",
instructions="A simple MCP server with simple credential authentication",
auth_server_provider=oauth_provider,
host=server_settings.host,
port=server_settings.port,
debug=True,
auth=mcp_auth_settings,
)
# Store server settings for later use in run()
app._server_settings = server_settings # type: ignore[attr-defined]
@app.custom_route("/login", methods=["GET"])
async def login_page_handler(request: Request) -> Response:
@ -131,7 +131,7 @@ def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
mcp_server = create_simple_mcp_server(server_settings, auth_settings)
logger.info(f"🚀 MCP Legacy Server running on {server_url}")
mcp_server.run(transport=transport)
mcp_server.run(transport=transport, host=host, port=port)
return 0

View file

@ -66,8 +66,6 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
app = FastMCP(
name="MCP Resource Server",
instructions="Resource Server that validates tokens via Authorization Server introspection",
host=settings.host,
port=settings.port,
debug=True,
# Auth configuration for RS mode
token_verifier=token_verifier,
@ -77,6 +75,8 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
resource_server_url=settings.server_url,
),
)
# Store settings for later use in run()
app._resource_server_settings = settings # type: ignore[attr-defined]
@app.tool()
async def get_time() -> dict[str, Any]:
@ -153,7 +153,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
# Run the server - this should block and keep running
mcp_server.run(transport=transport)
mcp_server.run(transport=transport, host=host, port=port)
logger.info("Server stopped")
return 0
except Exception:

View file

@ -6,6 +6,7 @@ from typing import Any
import anyio
import click
import mcp.types as types
import uvicorn
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
@ -33,7 +34,7 @@ def main(
port: int,
log_level: str,
json_response: bool,
) -> int:
) -> None:
# Configure logging
logging.basicConfig(
level=getattr(logging, log_level.upper()),
@ -118,9 +119,7 @@ def main(
# Create an ASGI application using the transport
starlette_app = Starlette(
debug=True,
routes=[
Mount("/mcp", app=handle_streamable_http),
],
routes=[Mount("/mcp", app=handle_streamable_http)],
lifespan=lifespan,
)
@ -133,8 +132,4 @@ def main(
expose_headers=["Mcp-Session-Id"],
)
import uvicorn
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
return 0

View file

@ -8,7 +8,7 @@ Run from the repository root:
from mcp.server.fastmcp import FastMCP
# Create an MCP server
mcp = FastMCP("Demo", json_response=True)
mcp = FastMCP("Demo")
# Add an addition tool
@ -40,4 +40,4 @@ def greet_user(name: str, style: str = "friendly") -> str:
# Run with streamable HTTP transport
if __name__ == "__main__":
mcp.run(transport="streamable-http")
mcp.run(transport="streamable-http", json_response=True)

View file

@ -20,7 +20,6 @@ class SimpleTokenVerifier(TokenVerifier):
# Create FastMCP instance as a Resource Server
mcp = FastMCP(
"Weather Service",
json_response=True,
# Token verifier for authentication
token_verifier=SimpleTokenVerifier(),
# Auth settings for RFC 9728 Protected Resource Metadata
@ -44,4 +43,4 @@ async def get_weather(city: str = "London") -> dict[str, str]:
if __name__ == "__main__":
mcp.run(transport="streamable-http")
mcp.run(transport="streamable-http", json_response=True)

View file

@ -5,15 +5,7 @@ Run from the repository root:
from mcp.server.fastmcp import FastMCP
# Stateless server with JSON responses (recommended)
mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True)
# Other configuration options:
# Stateless server with SSE streaming responses
# mcp = FastMCP("StatelessServer", stateless_http=True)
# Stateful server with session persistence
# mcp = FastMCP("StatefulServer")
mcp = FastMCP("StatelessServer")
# Add a simple tool to demonstrate the server
@ -24,5 +16,14 @@ def greet(name: str = "World") -> str:
# Run server with streamable_http transport
# Transport-specific options (stateless_http, json_response) are passed to run()
if __name__ == "__main__":
mcp.run(transport="streamable-http")
# Stateless server with JSON responses (recommended)
mcp.run(transport="streamable-http", stateless_http=True, json_response=True)
# Other configuration options:
# Stateless server with SSE streaming responses
# mcp.run(transport="streamable-http", stateless_http=True)
# Stateful server with session persistence
# mcp.run(transport="streamable-http")

View file

@ -13,7 +13,7 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create MCP server
mcp = FastMCP("My App", json_response=True)
mcp = FastMCP("My App")
@mcp.tool()
@ -30,9 +30,10 @@ async def lifespan(app: Starlette):
# Mount the StreamableHTTP server to the existing ASGI server
# Transport-specific options are passed to streamable_http_app()
app = Starlette(
routes=[
Mount("/", app=mcp.streamable_http_app()),
Mount("/", app=mcp.streamable_http_app(json_response=True)),
],
lifespan=lifespan,
)

View file

@ -13,7 +13,7 @@ from starlette.routing import Host
from mcp.server.fastmcp import FastMCP
# Create MCP server
mcp = FastMCP("MCP Host App", json_response=True)
mcp = FastMCP("MCP Host App")
@mcp.tool()
@ -30,9 +30,10 @@ async def lifespan(app: Starlette):
# Mount using Host-based routing
# Transport-specific options are passed to streamable_http_app()
app = Starlette(
routes=[
Host("mcp.acme.corp", app=mcp.streamable_http_app()),
Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)),
],
lifespan=lifespan,
)

View file

@ -13,8 +13,8 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create multiple MCP servers
api_mcp = FastMCP("API Server", json_response=True)
chat_mcp = FastMCP("Chat Server", json_response=True)
api_mcp = FastMCP("API Server")
chat_mcp = FastMCP("Chat Server")
@api_mcp.tool()
@ -29,12 +29,6 @@ def send_message(message: str) -> str:
return f"Message sent: {message}"
# Configure servers to mount at the root of each path
# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp
api_mcp.settings.streamable_http_path = "/"
chat_mcp.settings.streamable_http_path = "/"
# Create a combined lifespan to manage both session managers
@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
@ -44,11 +38,12 @@ async def lifespan(app: Starlette):
yield
# Mount the servers
# Mount the servers with transport-specific options passed to streamable_http_app()
# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp
app = Starlette(
routes=[
Mount("/api", app=api_mcp.streamable_http_app()),
Mount("/chat", app=chat_mcp.streamable_http_app()),
Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")),
Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")),
],
lifespan=lifespan,
)

View file

@ -1,5 +1,5 @@
"""
Example showing path configuration during FastMCP initialization.
Example showing path configuration when mounting FastMCP.
Run from the repository root:
uvicorn examples.snippets.servers.streamable_http_path_config:app --reload
@ -10,13 +10,8 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Configure streamable_http_path during initialization
# This server will mount at the root of wherever it's mounted
mcp_at_root = FastMCP(
"My Server",
json_response=True,
streamable_http_path="/",
)
# Create a simple FastMCP server
mcp_at_root = FastMCP("My Server")
@mcp_at_root.tool()
@ -25,9 +20,13 @@ def process_data(data: str) -> str:
return f"Processed: {data}"
# Mount at /process - endpoints will be at /process instead of /process/mcp
# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp)
# Transport-specific options like json_response are passed to streamable_http_app()
app = Starlette(
routes=[
Mount("/process", app=mcp_at_root.streamable_http_app()),
Mount(
"/process",
app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"),
),
]
)

View file

@ -11,7 +11,7 @@ from starlette.routing import Mount
from mcp.server.fastmcp import FastMCP
# Create the Echo server
echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True)
echo_mcp = FastMCP(name="EchoServer")
@echo_mcp.tool()
@ -21,7 +21,7 @@ def echo(message: str) -> str:
# Create the Math server
math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True)
math_mcp = FastMCP(name="MathServer")
@math_mcp.tool()
@ -42,13 +42,13 @@ async def lifespan(app: Starlette):
# Create the Starlette app and mount the MCP servers
app = Starlette(
routes=[
Mount("/echo", echo_mcp.streamable_http_app()),
Mount("/math", math_mcp.streamable_http_app()),
Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)),
Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)),
],
lifespan=lifespan,
)
# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp
# To mount at the root of each path (e.g., /echo instead of /echo/mcp):
# echo_mcp.settings.streamable_http_path = "/"
# math_mcp.settings.streamable_http_path = "/"
# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True)
# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True)

View file

@ -6,7 +6,7 @@ import inspect
import re
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any, Generic, Literal
from typing import Any, Generic, Literal, overload
import anyio
import pydantic_core
@ -73,18 +73,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
debug: bool
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# HTTP settings
host: str
port: int
sse_path: str
message_path: str
streamable_http_path: str
# StreamableHTTP settings
json_response: bool
stateless_http: bool
"""Define if the server should create a new transport per request."""
# resource settings
warn_on_duplicate_resources: bool
@ -99,9 +87,6 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
auth: AuthSettings | None
# Transport security settings (DNS rebinding protection)
transport_security: TransportSecuritySettings | None
def lifespan_wrapper(
app: FastMCP[LifespanResultT],
@ -118,7 +103,7 @@ def lifespan_wrapper(
class FastMCP(Generic[LifespanResultT]):
def __init__( # noqa: PLR0913
def __init__(
self,
name: str | None = None,
title: str | None = None,
@ -129,50 +114,24 @@ class FastMCP(Generic[LifespanResultT]):
version: str | None = None,
auth_server_provider: (OAuthAuthorizationServerProvider[Any, Any, Any] | None) = None,
token_verifier: TokenVerifier | None = None,
event_store: EventStore | None = None,
retry_interval: int | None = None,
*,
tools: list[Tool] | None = None,
debug: bool = False,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO",
host: str = "127.0.0.1",
port: int = 8000,
sse_path: str = "/sse",
message_path: str = "/messages/",
streamable_http_path: str = "/mcp",
json_response: bool = False,
stateless_http: bool = False,
warn_on_duplicate_resources: bool = True,
warn_on_duplicate_tools: bool = True,
warn_on_duplicate_prompts: bool = True,
lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None,
auth: AuthSettings | None = None,
transport_security: TransportSecuritySettings | None = None,
):
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
self.settings = Settings(
debug=debug,
log_level=log_level,
host=host,
port=port,
sse_path=sse_path,
message_path=message_path,
streamable_http_path=streamable_http_path,
json_response=json_response,
stateless_http=stateless_http,
warn_on_duplicate_resources=warn_on_duplicate_resources,
warn_on_duplicate_tools=warn_on_duplicate_tools,
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
lifespan=lifespan,
auth=auth,
transport_security=transport_security,
)
self._mcp_server = MCPServer(
@ -205,8 +164,6 @@ class FastMCP(Generic[LifespanResultT]):
# Create token verifier from provider if needed (backwards compatibility)
if auth_server_provider and not token_verifier: # pragma: no cover
self._token_verifier = ProviderTokenVerifier(auth_server_provider)
self._event_store = event_store
self._retry_interval = retry_interval
self._custom_starlette_routes: list[Route] = []
self._session_manager: StreamableHTTPSessionManager | None = None
@ -263,14 +220,46 @@ class FastMCP(Generic[LifespanResultT]):
)
return self._session_manager # pragma: no cover
@overload
def run(self, transport: Literal["stdio"] = ...) -> None: ...
@overload
def run(
self,
transport: Literal["sse"],
*,
host: str = ...,
port: int = ...,
sse_path: str = ...,
message_path: str = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...
@overload
def run(
self,
transport: Literal["streamable-http"],
*,
host: str = ...,
port: int = ...,
streamable_http_path: str = ...,
json_response: bool = ...,
stateless_http: bool = ...,
event_store: EventStore | None = ...,
retry_interval: int | None = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...
def run(
self,
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
**kwargs: Any,
) -> None:
"""Run the FastMCP server. Note this is a synchronous function.
Args:
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
**kwargs: Transport-specific options (see overloads for details)
"""
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover
@ -280,9 +269,9 @@ class FastMCP(Generic[LifespanResultT]):
case "stdio":
anyio.run(self.run_stdio_async)
case "sse": # pragma: no cover
anyio.run(self.run_sse_async)
anyio.run(lambda: self.run_sse_async(**kwargs))
case "streamable-http": # pragma: no cover
anyio.run(self.run_streamable_http_async)
anyio.run(lambda: self.run_streamable_http_async(**kwargs))
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
@ -744,40 +733,86 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server.create_initialization_options(),
)
async def run_sse_async(self) -> None: # pragma: no cover
async def run_sse_async( # pragma: no cover
self,
*,
host: str = "127.0.0.1",
port: int = 8000,
sse_path: str = "/sse",
message_path: str = "/messages/",
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using SSE transport."""
import uvicorn
starlette_app = self.sse_app()
starlette_app = self.sse_app(
sse_path=sse_path,
message_path=message_path,
transport_security=transport_security,
host=host,
)
config = uvicorn.Config(
starlette_app,
host=self.settings.host,
port=self.settings.port,
host=host,
port=port,
log_level=self.settings.log_level.lower(),
)
server = uvicorn.Server(config)
await server.serve()
async def run_streamable_http_async(self) -> None: # pragma: no cover
async def run_streamable_http_async( # pragma: no cover
self,
*,
host: str = "127.0.0.1",
port: int = 8000,
streamable_http_path: str = "/mcp",
json_response: bool = False,
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using StreamableHTTP transport."""
import uvicorn
starlette_app = self.streamable_http_app()
starlette_app = self.streamable_http_app(
streamable_http_path=streamable_http_path,
json_response=json_response,
stateless_http=stateless_http,
event_store=event_store,
retry_interval=retry_interval,
transport_security=transport_security,
host=host,
)
config = uvicorn.Config(
starlette_app,
host=self.settings.host,
port=self.settings.port,
host=host,
port=port,
log_level=self.settings.log_level.lower(),
)
server = uvicorn.Server(config)
await server.serve()
def sse_app(self) -> Starlette:
def sse_app(
self,
*,
sse_path: str = "/sse",
message_path: str = "/messages/",
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
"""Return an instance of the SSE server app."""
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
sse = SseServerTransport(self.settings.message_path, security_settings=self.settings.transport_security)
sse = SseServerTransport(message_path, security_settings=transport_security)
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
# Add client ID from auth context into request context if available
@ -789,7 +824,7 @@ class FastMCP(Generic[LifespanResultT]):
# Create routes
routes: list[Route | Mount] = []
middleware: list[Middleware] = []
required_scopes = []
required_scopes: list[str] = []
# Set up auth if configured
if self.settings.auth: # pragma: no cover
@ -835,14 +870,14 @@ class FastMCP(Generic[LifespanResultT]):
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
routes.append(
Route(
self.settings.sse_path,
sse_path,
endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url),
methods=["GET"],
)
)
routes.append(
Mount(
self.settings.message_path,
message_path,
app=RequireAuthMiddleware(sse.handle_post_message, required_scopes, resource_metadata_url),
)
)
@ -855,14 +890,14 @@ class FastMCP(Generic[LifespanResultT]):
routes.append(
Route(
self.settings.sse_path,
sse_path,
endpoint=sse_endpoint,
methods=["GET"],
)
)
routes.append(
Mount(
self.settings.message_path,
message_path,
app=sse.handle_post_message,
)
)
@ -884,19 +919,37 @@ class FastMCP(Generic[LifespanResultT]):
# Create Starlette app with routes and middleware
return Starlette(debug=self.settings.debug, routes=routes, middleware=middleware)
def streamable_http_app(self) -> Starlette:
def streamable_http_app(
self,
*,
streamable_http_path: str = "/mcp",
json_response: bool = False,
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
"""Return an instance of the StreamableHTTP server app."""
from starlette.middleware import Middleware
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
# Create session manager on first call (lazy initialization)
if self._session_manager is None: # pragma: no branch
self._session_manager = StreamableHTTPSessionManager(
app=self._mcp_server,
event_store=self._event_store,
retry_interval=self._retry_interval,
json_response=self.settings.json_response,
stateless=self.settings.stateless_http, # Use the stateless setting
security_settings=self.settings.transport_security,
event_store=event_store,
retry_interval=retry_interval,
json_response=json_response,
stateless=stateless_http,
security_settings=transport_security,
)
# Create the ASGI handler
@ -905,7 +958,7 @@ class FastMCP(Generic[LifespanResultT]):
# Create routes
routes: list[Route | Mount] = []
middleware: list[Middleware] = []
required_scopes = []
required_scopes: list[str] = []
# Set up auth if configured
if self.settings.auth: # pragma: no cover
@ -947,7 +1000,7 @@ class FastMCP(Generic[LifespanResultT]):
routes.append(
Route(
self.settings.streamable_http_path,
streamable_http_path,
endpoint=RequireAuthMiddleware(streamable_http_app, required_scopes, resource_metadata_url),
)
)
@ -955,7 +1008,7 @@ class FastMCP(Generic[LifespanResultT]):
# Auth is disabled, no wrapper needed
routes.append(
Route(
self.settings.streamable_http_path,
streamable_http_path,
endpoint=streamable_http_app,
)
)

View file

@ -36,8 +36,4 @@ def configure_logging(
if not handlers: # pragma: no cover
handlers.append(logging.StreamHandler())
logging.basicConfig(
level=level,
format="%(message)s",
handlers=handlers,
)
logging.basicConfig(level=level, format="%(message)s", handlers=handlers)

View file

@ -53,8 +53,9 @@ class TestServer:
@pytest.mark.anyio
async def test_sse_app_returns_starlette_app(self):
"""Test that sse_app returns a Starlette application with correct routes."""
mcp = FastMCP("test", host="0.0.0.0") # Use 0.0.0.0 to avoid auto DNS protection
app = mcp.sse_app()
mcp = FastMCP("test")
# Use host="0.0.0.0" to avoid auto DNS protection
app = mcp.sse_app(host="0.0.0.0")
assert isinstance(app, Starlette)
@ -133,49 +134,64 @@ class TestServer:
class TestDnsRebindingProtection:
"""Tests for automatic DNS rebinding protection on localhost."""
"""Tests for automatic DNS rebinding protection on localhost.
def test_auto_enabled_for_127_0_0_1(self):
"""DNS rebinding protection should auto-enable for host=127.0.0.1."""
mcp = FastMCP(host="127.0.0.1")
assert mcp.settings.transport_security is not None
assert mcp.settings.transport_security.enable_dns_rebinding_protection is True
assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts
assert "localhost:*" in mcp.settings.transport_security.allowed_hosts
assert "http://127.0.0.1:*" in mcp.settings.transport_security.allowed_origins
assert "http://localhost:*" in mcp.settings.transport_security.allowed_origins
DNS rebinding protection is now configured in sse_app() and streamable_http_app()
based on the host parameter passed to those methods.
"""
def test_auto_enabled_for_localhost(self):
"""DNS rebinding protection should auto-enable for host=localhost."""
mcp = FastMCP(host="localhost")
assert mcp.settings.transport_security is not None
assert mcp.settings.transport_security.enable_dns_rebinding_protection is True
assert "127.0.0.1:*" in mcp.settings.transport_security.allowed_hosts
assert "localhost:*" in mcp.settings.transport_security.allowed_hosts
def test_auto_enabled_for_127_0_0_1_sse(self):
"""DNS rebinding protection should auto-enable for host=127.0.0.1 in SSE app."""
mcp = FastMCP()
# Call sse_app with host=127.0.0.1 to trigger auto-config
# We can't directly inspect the transport_security, but we can verify
# the app is created without error
app = mcp.sse_app(host="127.0.0.1")
assert app is not None
def test_auto_enabled_for_ipv6_localhost(self):
"""DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost)."""
mcp = FastMCP(host="::1")
assert mcp.settings.transport_security is not None
assert mcp.settings.transport_security.enable_dns_rebinding_protection is True
assert "[::1]:*" in mcp.settings.transport_security.allowed_hosts
assert "http://[::1]:*" in mcp.settings.transport_security.allowed_origins
def test_auto_enabled_for_127_0_0_1_streamable_http(self):
"""DNS rebinding protection should auto-enable for host=127.0.0.1 in StreamableHTTP app."""
mcp = FastMCP()
app = mcp.streamable_http_app(host="127.0.0.1")
assert app is not None
def test_not_auto_enabled_for_other_hosts(self):
"""DNS rebinding protection should NOT auto-enable for other hosts."""
mcp = FastMCP(host="0.0.0.0")
assert mcp.settings.transport_security is None
def test_auto_enabled_for_localhost_sse(self):
"""DNS rebinding protection should auto-enable for host=localhost in SSE app."""
mcp = FastMCP()
app = mcp.sse_app(host="localhost")
assert app is not None
def test_explicit_settings_not_overridden(self):
"""Explicit transport_security settings should not be overridden."""
def test_auto_enabled_for_ipv6_localhost_sse(self):
"""DNS rebinding protection should auto-enable for host=::1 (IPv6 localhost) in SSE app."""
mcp = FastMCP()
app = mcp.sse_app(host="::1")
assert app is not None
def test_not_auto_enabled_for_other_hosts_sse(self):
"""DNS rebinding protection should NOT auto-enable for other hosts in SSE app."""
mcp = FastMCP()
app = mcp.sse_app(host="0.0.0.0")
assert app is not None
def test_explicit_settings_not_overridden_sse(self):
"""Explicit transport_security settings should not be overridden in SSE app."""
custom_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=False,
)
mcp = FastMCP(host="127.0.0.1", transport_security=custom_settings)
# Settings are copied by pydantic, so check values not identity
assert mcp.settings.transport_security is not None
assert mcp.settings.transport_security.enable_dns_rebinding_protection is False
assert mcp.settings.transport_security.allowed_hosts == []
mcp = FastMCP()
# Explicit transport_security passed to sse_app should be used as-is
app = mcp.sse_app(host="127.0.0.1", transport_security=custom_settings)
assert app is not None
def test_explicit_settings_not_overridden_streamable_http(self):
"""Explicit transport_security settings should not be overridden in StreamableHTTP app."""
custom_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=False,
)
mcp = FastMCP()
# Explicit transport_security passed to streamable_http_app should be used as-is
app = mcp.streamable_http_app(host="127.0.0.1", transport_security=custom_settings)
assert app is not None
def tool_fn(x: int, y: int) -> int:
@ -1423,14 +1439,11 @@ class TestServerPrompts:
def test_streamable_http_no_redirect() -> None:
"""Test that streamable HTTP routes are correctly configured."""
mcp = FastMCP()
# streamable_http_path defaults to "/mcp"
app = mcp.streamable_http_app()
# Find routes by type - streamable_http_app creates Route objects, not Mount objects
streamable_routes = [
r
for r in app.routes
if isinstance(r, Route) and hasattr(r, "path") and r.path == mcp.settings.streamable_http_path
]
streamable_routes = [r for r in app.routes if isinstance(r, Route) and hasattr(r, "path") and r.path == "/mcp"]
# Verify routes exist
assert len(streamable_routes) == 1, "Should have one streamable route"