Skip to content

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.

server.py
"""An MCP server over Streamable HTTP at http://127.0.0.1:8000/mcp."""
import os
from 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=True creates 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.
TerminalStart it
Terminal window
uv run server.py

ExpectedUvicorn reports that it is running on http://127.0.0.1:8000.

TerminalList the tools
Terminal window
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \
http://127.0.0.1:8000/mcp --protocol-era auto --method tools/list

ExpectedOne 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}.

If you already have a web app, mount the MCP app inside it instead of running a second server:

app.py
"""The same server mounted inside a larger Starlette app. Run: uvicorn app:app --port 8000"""
import contextlib
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from 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.asynccontextmanager
async 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,
)
Terminal window
uv run uvicorn app:app --port 8000

Two details matter:

  1. Call streamable_http_app() before you use mcp.session_manager. The session manager only exists after that call.
  2. 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().

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 Request
Invalid Host header

This 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.