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
2 changed files with 339 additions and 231 deletions

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)