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:
parent
5d80f4efc8
commit
8adb5bdce8
2 changed files with 339 additions and 231 deletions
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue