Serve a Python MCP server over Streamable HTTP
- Spec
2026-07-28- Evidence
- E2Reproducedarm64
- Python SDK
mcp@2.3.0- MCP Inspector
@modelcontextprotocol/inspector@2.12.0- Verified
The first Python server speaks stdio. This page serves the same tool over Streamable HTTP at http://127.0.0.1:8000/mcp, first on its own, then inside a larger web app.
Run it on its own
Section titled “Run it on its own”"""An MCP server over Streamable HTTP at http://127.0.0.1:8000/mcp."""
import osfrom typing import TypedDict
from mcp.server import MCPServer
class Sum(TypedDict): sum: float
mcp = MCPServer("hello-python-http", version="1.0.0")
@mcp.tool()def add(a: float, b: float) -> Sum: """Add two numbers and return the sum.""" return {"sum": a + b}
if __name__ == "__main__": # host, port and the HTTP options are arguments of run(), not of MCPServer(). mcp.run( transport="streamable-http", host="127.0.0.1", port=int(os.environ.get("PORT", "8000")), stateless_http=True, )In SDK 2.x, host, port and the HTTP options are arguments of run(). Passing them to MCPServer() fails: we got TypeError: MCPServer.__init__() got an unexpected keyword argument 'host'.
stateless_http=Truecreates a fresh transport for each request and keeps no session. That fits the 2026-07-28 model, and it lets you run several copies behind a load balancer.json_response=True(not used here) asks the SDK to answer with plain JSON instead of an SSE stream.
uv run server.pyExpectedUvicorn reports that it is running on http://127.0.0.1:8000.
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \ http://127.0.0.1:8000/mcp --protocol-era auto --method tools/listExpectedOne tool, add.
We connected with the TypeScript client SDK in its default mode (2025-11-25), in auto mode and pinned to 2026-07-28. All three called add(2, 3) and got {"sum": 5}.
Mount it in a Starlette app
Section titled “Mount it in a Starlette app”If you already have a web app, mount the MCP app inside it instead of running a second server:
"""The same server mounted inside a larger Starlette app. Run: uvicorn app:app --port 8000"""
import contextlib
from starlette.applications import Starlettefrom starlette.responses import PlainTextResponsefrom starlette.routing import Mount, Route
from server import mcp
# Build the MCP app first: mcp.session_manager only exists after this call.mcp_app = mcp.streamable_http_app(stateless_http=True)
@contextlib.asynccontextmanagerasync def lifespan(app: Starlette): # A mounted app's own lifespan never runs, so the host app starts the session manager. async with mcp.session_manager.run(): yield
async def health(request): return PlainTextResponse("ok")
app = Starlette( routes=[Route("/health", health), Mount("/", app=mcp_app)], lifespan=lifespan,)uv run uvicorn app:app --port 8000Two details matter:
- Call
streamable_http_app()before you usemcp.session_manager. The session manager only exists after that call. - Start the session manager in the outer app’s lifespan. A mounted app’s own lifespan never runs. When we left the lifespan out, every MCP request returned
500 Internal Server Error, and the log showed:
RuntimeError: Task group is not initialized. Make sure to use run().The Host header check
Section titled “The Host header check”When the server binds to 127.0.0.1, localhost or ::1, the SDK turns on DNS rebinding protection: requests whose Host header is not local are refused. We sent Host: evil.example and got:
HTTP/1.1 421 Misdirected RequestInvalid Host headerThis protects a server on your own machine from web pages that point their domain at 127.0.0.1. It also means a reverse proxy in front of the server must pass a local Host, or you must configure the allowed hosts.
Sources
MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.