You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
FastMCP: streamable_http_app() silently breaks when BaseHTTPMiddleware is added #2702
Adding a Starlette BaseHTTPMiddleware (e.g., for auth) to FastMCP.streamable_http_app() silently breaks the MCP server. Every request crashes with ClosedResourceError — the client sees "peer closed connection without sending complete message body."
Reproduction
frommcp.server.fastmcpimportFastMCPfromstarlette.middleware.baseimportBaseHTTPMiddlewaremcp=FastMCP("test")
# Any BaseHTTPMiddleware — even a no-op passthroughclassAuthMiddleware(BaseHTTPMiddleware):
asyncdefdispatch(self, request, call_next):
returnawaitcall_next(request)
mcp.streamable_http_app().add_middleware(BaseHTTPMiddleware, dispatch=AuthMiddleware)
Run the server, send any MCP initialize request → ClosedResourceError.
Root cause
BaseHTTPMiddleware wraps the ASGI receive/send channels in a way incompatible with SSE streaming. This is a known Starlette limitation (Kludex/starlette#919), but FastMCP users hit it naturally when they reach for the obvious auth pattern.
Expected behavior
At minimum: warn at startup when a BaseHTTPMiddleware subclass is detected on a streamable HTTP app. Ideally: document prominently that users should use FastMCP's own Middleware class (fastmcp.server.middleware.Middleware) instead of Starlette's BaseHTTPMiddleware.
I can take a focused look at this if maintainers agree the right scope is a warning/docs/test change rather than trying to work around Starlette's BaseHTTPMiddleware behavior inside the transport.
The implementation I would aim for is narrow: document the incompatibility for Streamable HTTP apps, point users toward raw ASGI middleware or SDK middleware instead, and add a regression test for the warning/guard path. I will wait for maintainer direction before opening a PR.
Thanks for the report. I couldn't reproduce this as described: a no-op BaseHTTPMiddleware subclass added to streamable_http_app() under uvicorn, with a real streamable-HTTP client doing initialize / list_tools / call_tool, works on both current main (2.x) and 1.29.0. Could you share the actual traceback, your mcp and starlette versions, and whether stateless_http, json_response, or a standalone GET stream is involved? (Also note the snippet as posted passes the class itself as dispatch=, which fails on its own — a class M(BaseHTTPMiddleware) with app.add_middleware(M) is the shape I tested.) Marking as needs repro for now.
Ran the config matrix you asked for, and the result is a bounded non-repro.
Method. A real uvicorn server per config, the SDK's own streamable-HTTP client doing initialize / list_tools / call_tool, plus a standalone GET listen stream opened with the session id. The middleware is the corrected shape you noted: class NoOpAuthMiddleware(BaseHTTPMiddleware) with app.add_middleware(NoOpAuthMiddleware). Axes: middleware on/off x stateless_http x json_response, so every row with mw 0 is a control. "stream open (idle)" means the GET channel returned 200 and stayed open with no events inside an 8s window, which is the healthy outcome for an idle listen channel.
Result: 32 of 32 cases pass. Client ops succeed and the GET stream stays healthy in every combination, middleware included, on all four version stacks:
mcp 2.0.0, starlette 1.6.0
python 3.14.6 | mcp 2.0.0 | starlette 1.6.0 | uvicorn 0.52.4 | platform win32
mw stateless json | ops result | GET-stream probe
0 0 0 | ok | GET 200 stream open (idle)
0 0 1 | ok | GET 200 stream open (idle)
0 1 0 | ok | GET 200 stream open (idle)
0 1 1 | ok | GET 200 stream open (idle)
1 0 0 | ok | GET 200 stream open (idle)
1 0 1 | ok | GET 200 stream open (idle)
1 1 0 | ok | GET 200 stream open (idle)
1 1 1 | ok | GET 200 stream open (idle)
mcp 1.29.0, starlette 1.6.0
python 3.14.6 | mcp 1.29.0 | starlette 1.6.0 | uvicorn 0.52.4 | platform win32
mw stateless json | ops result | GET-stream probe
0 0 0 | ok | GET 200 stream open (idle)
0 0 1 | ok | GET 200 stream open (idle)
0 1 0 | ok | GET 200 stream open (idle)
0 1 1 | ok | GET 200 stream open (idle)
1 0 0 | ok | GET 200 stream open (idle)
1 0 1 | ok | GET 200 stream open (idle)
1 1 0 | ok | GET 200 stream open (idle)
1 1 1 | ok | GET 200 stream open (idle)
mcp 1.27.1 (current when this issue was filed), starlette 1.6.0
python 3.14.6 | mcp 1.27.1 | starlette 1.6.0 | uvicorn 0.52.4 | platform win32
mw stateless json | ops result | GET-stream probe
0 0 0 | ok | GET 200 stream open (idle)
0 0 1 | ok | GET 200 stream open (idle)
0 1 0 | ok | GET 200 stream open (idle)
0 1 1 | ok | GET 200 stream open (idle)
1 0 0 | ok | GET 200 stream open (idle)
1 0 1 | ok | GET 200 stream open (idle)
1 1 0 | ok | GET 200 stream open (idle)
1 1 1 | ok | GET 200 stream open (idle)
mcp 1.27.1, starlette 0.27.0 (the oldest version the SDK allows, the starlette#919-era codebase)
python 3.14.6 | mcp 1.27.1 | starlette 0.27.0 | uvicorn 0.52.4 | platform win32
mw stateless json | ops result | GET-stream probe
0 0 0 | ok | GET 200 stream open (idle)
0 0 1 | ok | GET 200 stream open (idle)
0 1 0 | ok | GET 200 stream open (idle)
0 1 1 | ok | GET 200 stream open (idle)
1 0 0 | ok | GET 200 stream open (idle)
1 0 1 | ok | GET 200 stream open (idle)
1 1 0 | ok | GET 200 stream open (idle)
1 1 1 | ok | GET 200 stream open (idle)
Host: Windows 11, Python 3.14.6, uvicorn 0.52.4. Nothing here is platform-specific in principle, and the harness below is two small files if anyone wants the Linux datapoint.
Given the middleware-on and middleware-off rows are indistinguishable on the filing-era SDK and on the oldest supported starlette, this looks consistent with closing unless the reporter can supply the exact mcp/starlette versions and a traceback. The one failure mode I could not rule out is a different middleware than the no-op shape tested here (one that consumes the request body or replaces the response object would interact differently with streaming).
harness: server_app.py + matrix.py
server_app.py
"""FastMCP/MCPServer streamable-http server for the #2702 repro matrix.Works on mcp 1.x (mcp.server.fastmcp.FastMCP, axes on the constructor) andmcp 2.x (mcp.server.mcpserver.MCPServer, axes on streamable_http_app()).Env vars select the config axis values: MW=1 add a no-op BaseHTTPMiddleware subclass (the issue's shape, corrected per maxisbey: class M(BaseHTTPMiddleware) + add_middleware(M)) STATELESS=1 stateless_http=True JSONRESP=1 json_response=True PORT=<n> uvicorn port"""importosimportuvicornfromstarlette.middleware.baseimportBaseHTTPMiddlewarestateless=os.environ.get("STATELESS") =="1"jsonresp=os.environ.get("JSONRESP") =="1"try:
frommcp.server.fastmcpimportFastMCP# 1.xkwargs= {}
ifstateless:
kwargs["stateless_http"] =Trueifjsonresp:
kwargs["json_response"] =Truemcp=FastMCP("matrix-test", **kwargs)
@mcp.tool()defadd(a: int, b: int) ->int:
"""Add two integers."""returna+bapp=mcp.streamable_http_app()
exceptModuleNotFoundError:
frommcp.server.mcpserverimportMCPServer# 2.xmcp=MCPServer("matrix-test")
@mcp.tool()defadd(a: int, b: int) ->int:
"""Add two integers."""returna+bapp=mcp.streamable_http_app(stateless_http=stateless, json_response=jsonresp)
ifos.environ.get("MW") =="1":
classNoOpAuthMiddleware(BaseHTTPMiddleware):
asyncdefdispatch(self, request, call_next):
returnawaitcall_next(request)
app.add_middleware(NoOpAuthMiddleware)
if__name__=="__main__":
uvicorn.run(app, host="127.0.0.1", port=int(os.environ["PORT"]), log_level="warning")
matrix.py
"""#2702 repro matrix: middleware x stateless_http x json_response, with astandalone GET stream probe, against a real uvicorn server and the SDK's ownstreamable-HTTP client. Prints one row per config plus version stamps."""importasyncioimportimportlib.metadataasimimportitertoolsimportosimportsocketimportsubprocessimportsysimporttimefrompathlibimportPathimporthttpxHERE=Path(__file__).parentPY=sys.executabledeffree_port() ->int:
s=socket.socket()
s.bind(("127.0.0.1", 0))
port=s.getsockname()[1]
s.close()
returnportasyncdefclient_ops(port: int) ->str:
frommcpimportClientSessionurl=f"http://127.0.0.1:{port}/mcp"try: # 1.x client shapefrommcp.client.streamable_httpimportstreamablehttp_clientasyncwithstreamablehttp_client(url) as (read, write, _get_session_id):
asyncwithClientSession(read, write) assession:
returnawait_session_ops(session)
exceptImportError: # 2.x client shapefrommcp.client.streamable_httpimportstreamable_http_clientasyncwithstreamable_http_client(url) as (read, write):
asyncwithClientSession(read, write) assession:
returnawait_session_ops(session)
asyncdef_session_ops(session) ->str:
awaitsession.initialize()
tools=awaitsession.list_tools()
assertany(t.name=="add"fortintools.tools), "add tool missing"result=awaitsession.call_tool("add", {"a": 2, "b": 3})
text=result.content[0].textifresult.contentelse"?"asserttext=="5", f"unexpected tool result {text!r}"return"ok"asyncdefget_stream_probe(port: int) ->str:
"""Standalone GET stream (SSE listen channel) — open it, expect either an event stream or a clean 4xx/405, never a mid-body disconnect."""url=f"http://127.0.0.1:{port}/mcp"asyncwithhttpx.AsyncClient(timeout=8) asclient:
# initialize first to obtain a session idinit=awaitclient.post(
url,
json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "probe", "version": "0"},
},
},
headers={"Accept": "application/json, text/event-stream"},
)
sid=init.headers.get("mcp-session-id")
headers= {"Accept": "text/event-stream"}
ifsid:
headers["mcp-session-id"] =sidtry:
asyncwithclient.stream("GET", url, headers=headers) asresp:
status=resp.status_codeifstatus>=400:
returnf"GET {status} (clean refusal)"# read a little of the stream, then closetry:
asyncfor_chunkinresp.aiter_bytes():
breakexcepthttpx.ReadTimeout:
# An idle listen channel with no server events inside the# timeout window: the stream is OPEN and healthy.returnf"GET {status} stream open (idle)"exceptExceptionase: # mid-stream failure is the symptom classreturnf"GET stream broke: {type(e).__name__}: {e}"returnf"GET {status} stream open"exceptExceptionase:
returnf"GET failed: {type(e).__name__}: {e}"defrun_case(mw: bool, stateless: bool, jsonresp: bool) ->tuple[str, str, str]:
port=free_port()
env=dict(os.environ, PORT=str(port))
ifmw:
env["MW"] ="1"ifstateless:
env["STATELESS"] ="1"ifjsonresp:
env["JSONRESP"] ="1"proc=subprocess.Popen(
[PY, str(HERE/"server_app.py")], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
try:
# wait for the portdeadline=time.time() +15whiletime.time() <deadline:
try:
withsocket.create_connection(("127.0.0.1", port), timeout=0.3):
breakexceptOSError:
ifproc.poll() isnotNone:
_, err=proc.communicate(timeout=5)
return ("SERVER-DIED", err.strip()[-200:], "")
time.sleep(0.2)
try:
ops=asyncio.run(client_ops(port))
exceptExceptionase:
ops=f"CLIENT-FAIL {type(e).__name__}: {str(e)[:160]}"try:
probe=asyncio.run(get_stream_probe(port))
exceptExceptionase:
probe=f"PROBE-FAIL {type(e).__name__}: {str(e)[:120]}"return (ops, "", probe)
finally:
proc.kill()
try:
_, err=proc.communicate(timeout=5)
exceptException:
err=""return ("?", "", "")
defmain() ->None:
print(f"python {sys.version.split()[0]} | mcp {im.version('mcp')} | "f"starlette {im.version('starlette')} | uvicorn {im.version('uvicorn')} | "f"platform {sys.platform}")
print(f"{'mw':3}{'stateless':9}{'json':5} | ops result | GET-stream probe")
formw, stateless, jsonrespinitertools.product((False, True), repeat=3):
ops, err, probe=run_case(mw, stateless, jsonresp)
row=f"{int(mw):<3}{int(stateless):<9}{int(jsonresp):<5} | {ops:<10} | {probe}"print(row)
iferr:
print(f" server stderr tail: {err}")
if__name__=="__main__":
main()
Activity
jstar0 commentedon May 27, 2026
I can take a focused look at this if maintainers agree the right scope is a warning/docs/test change rather than trying to work around Starlette's
BaseHTTPMiddlewarebehavior inside the transport.The implementation I would aim for is narrow: document the incompatibility for Streamable HTTP apps, point users toward raw ASGI middleware or SDK middleware instead, and add a regression test for the warning/guard path. I will wait for maintainer direction before opening a PR.
norika1207-lab commentedon Jun 2, 2026
maxisbey commentedon Aug 14, 2026
Thanks for the report. I couldn't reproduce this as described: a no-op
BaseHTTPMiddlewaresubclass added tostreamable_http_app()under uvicorn, with a real streamable-HTTP client doinginitialize/list_tools/call_tool, works on both currentmain(2.x) and 1.29.0. Could you share the actual traceback, yourmcpandstarletteversions, and whetherstateless_http,json_response, or a standalone GET stream is involved? (Also note the snippet as posted passes the class itself asdispatch=, which fails on its own — aclass M(BaseHTTPMiddleware)withapp.add_middleware(M)is the shape I tested.) Marking as needs repro for now.AI Disclaimer
HarperZ9 commentedon Aug 22, 2026
Ran the config matrix you asked for, and the result is a bounded non-repro.
Method. A real uvicorn server per config, the SDK's own streamable-HTTP client doing
initialize/list_tools/call_tool, plus a standalone GET listen stream opened with the session id. The middleware is the corrected shape you noted:class NoOpAuthMiddleware(BaseHTTPMiddleware)withapp.add_middleware(NoOpAuthMiddleware). Axes: middleware on/off xstateless_httpxjson_response, so every row withmw 0is a control. "stream open (idle)" means the GET channel returned 200 and stayed open with no events inside an 8s window, which is the healthy outcome for an idle listen channel.Result: 32 of 32 cases pass. Client ops succeed and the GET stream stays healthy in every combination, middleware included, on all four version stacks:
mcp 2.0.0, starlette 1.6.0
mcp 1.29.0, starlette 1.6.0
mcp 1.27.1 (current when this issue was filed), starlette 1.6.0
mcp 1.27.1, starlette 0.27.0 (the oldest version the SDK allows, the starlette#919-era codebase)
Host: Windows 11, Python 3.14.6, uvicorn 0.52.4. Nothing here is platform-specific in principle, and the harness below is two small files if anyone wants the Linux datapoint.
Given the middleware-on and middleware-off rows are indistinguishable on the filing-era SDK and on the oldest supported starlette, this looks consistent with closing unless the reporter can supply the exact
mcp/starletteversions and a traceback. The one failure mode I could not rule out is a different middleware than the no-op shape tested here (one that consumes the request body or replaces the response object would interact differently with streaming).harness: server_app.py + matrix.py
server_app.pymatrix.py