Streamable HTTP - improve usability, fast mcp and auth (#641)

This commit is contained in:
ihrpr
2025-05-08 20:43:25 +01:00
committed by GitHub
parent 280bab36f4
commit e4e119b324
7 changed files with 750 additions and 229 deletions

View File

@@ -47,6 +47,8 @@ from mcp.server.lowlevel.server import lifespan as default_lifespan
from mcp.server.session import ServerSession, ServerSessionT
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import (
AnyFunction,
@@ -90,6 +92,13 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
mount_path: str = "/" # Mount path (e.g. "/github", defaults to root path)
sse_path: str = "/sse"
message_path: str = "/messages/"
streamable_http_path: str = "/mcp"
# StreamableHTTP settings
json_response: bool = False
stateless_http: bool = (
False # If True, uses true stateless mode (new transport per request)
)
# resource settings
warn_on_duplicate_resources: bool = True
@@ -131,6 +140,7 @@ class FastMCP:
instructions: str | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
| None = None,
event_store: EventStore | None = None,
**settings: Any,
):
self.settings = Settings(**settings)
@@ -162,8 +172,10 @@ class FastMCP:
"is specified"
)
self._auth_server_provider = auth_server_provider
self._event_store = event_store
self._custom_starlette_routes: list[Route] = []
self.dependencies = self.settings.dependencies
self._session_manager: StreamableHTTPSessionManager | None = None
# Set up MCP protocol handlers
self._setup_handlers()
@@ -179,25 +191,47 @@ class FastMCP:
def instructions(self) -> str | None:
return self._mcp_server.instructions
@property
def session_manager(self) -> StreamableHTTPSessionManager:
"""Get the StreamableHTTP session manager.
This is exposed to enable advanced use cases like mounting multiple
FastMCP servers in a single FastAPI application.
Raises:
RuntimeError: If called before streamable_http_app() has been called.
"""
if self._session_manager is None:
raise RuntimeError(
"Session manager can only be accessed after"
"calling streamable_http_app()."
"The session manager is created lazily"
"to avoid unnecessary initialization."
)
return self._session_manager
def run(
self,
transport: Literal["stdio", "sse"] = "stdio",
transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
mount_path: str | None = None,
) -> None:
"""Run the FastMCP server. Note this is a synchronous function.
Args:
transport: Transport protocol to use ("stdio" or "sse")
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
mount_path: Optional mount path for SSE transport
"""
TRANSPORTS = Literal["stdio", "sse"]
TRANSPORTS = Literal["stdio", "sse", "streamable-http"]
if transport not in TRANSPORTS.__args__: # type: ignore
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
anyio.run(self.run_stdio_async)
else: # transport == "sse"
anyio.run(lambda: self.run_sse_async(mount_path))
match transport:
case "stdio":
anyio.run(self.run_stdio_async)
case "sse":
anyio.run(lambda: self.run_sse_async(mount_path))
case "streamable-http":
anyio.run(self.run_streamable_http_async)
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
@@ -573,6 +607,21 @@ class FastMCP:
server = uvicorn.Server(config)
await server.serve()
async def run_streamable_http_async(self) -> None:
"""Run the server using StreamableHTTP transport."""
import uvicorn
starlette_app = self.streamable_http_app()
config = uvicorn.Config(
starlette_app,
host=self.settings.host,
port=self.settings.port,
log_level=self.settings.log_level.lower(),
)
server = uvicorn.Server(config)
await server.serve()
def _normalize_path(self, mount_path: str, endpoint: str) -> str:
"""
Combine mount path and endpoint to return a normalized path.
@@ -687,9 +736,9 @@ class FastMCP:
else:
# Auth is disabled, no need for RequireAuthMiddleware
# Since handle_sse is an ASGI app, we need to create a compatible endpoint
async def sse_endpoint(request: Request) -> None:
async def sse_endpoint(request: Request) -> Response:
# Convert the Starlette request to ASGI parameters
await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
return await handle_sse(request.scope, request.receive, request._send) # type: ignore[reportPrivateUsage]
routes.append(
Route(
@@ -712,6 +761,80 @@ class FastMCP:
debug=self.settings.debug, routes=routes, middleware=middleware
)
def streamable_http_app(self) -> Starlette:
"""Return an instance of the StreamableHTTP server app."""
from starlette.middleware import Middleware
from starlette.routing import Mount
# Create session manager on first call (lazy initialization)
if self._session_manager is None:
self._session_manager = StreamableHTTPSessionManager(
app=self._mcp_server,
event_store=self._event_store,
json_response=self.settings.json_response,
stateless=self.settings.stateless_http, # Use the stateless setting
)
# Create the ASGI handler
async def handle_streamable_http(
scope: Scope, receive: Receive, send: Send
) -> None:
await self.session_manager.handle_request(scope, receive, send)
# Create routes
routes: list[Route | Mount] = []
middleware: list[Middleware] = []
required_scopes = []
# Add auth endpoints if auth provider is configured
if self._auth_server_provider:
assert self.settings.auth
from mcp.server.auth.routes import create_auth_routes
required_scopes = self.settings.auth.required_scopes or []
middleware = [
Middleware(
AuthenticationMiddleware,
backend=BearerAuthBackend(
provider=self._auth_server_provider,
),
),
Middleware(AuthContextMiddleware),
]
routes.extend(
create_auth_routes(
provider=self._auth_server_provider,
issuer_url=self.settings.auth.issuer_url,
service_documentation_url=self.settings.auth.service_documentation_url,
client_registration_options=self.settings.auth.client_registration_options,
revocation_options=self.settings.auth.revocation_options,
)
)
routes.append(
Mount(
self.settings.streamable_http_path,
app=RequireAuthMiddleware(handle_streamable_http, required_scopes),
)
)
else:
# Auth is disabled, no wrapper needed
routes.append(
Mount(
self.settings.streamable_http_path,
app=handle_streamable_http,
)
)
routes.extend(self._custom_starlette_routes)
return Starlette(
debug=self.settings.debug,
routes=routes,
middleware=middleware,
lifespan=lambda app: self.session_manager.run(),
)
async def list_prompts(self) -> list[MCPPrompt]:
"""List all available prompts."""
prompts = self._prompt_manager.list_prompts()