Skip to content

FastMCP: streamable_http_app() silently breaks when BaseHTTPMiddleware is added #2702

Open

Description

@hognek

Description

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

from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware

mcp = FastMCP("test")

# Any BaseHTTPMiddleware — even a no-op passthrough
class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        return await call_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.

Workaround (raw ASGI middleware)

from starlette.types import ASGIApp, Receive, Scope, Send

class RawAuthMiddleware:
    def __init__(self, app: ASGIApp):
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send):
        # auth check here — read headers from scope
        await self.app(scope, receive, send)

mcp.streamable_http_app().add_middleware(RawAuthMiddleware)

Environment

  • mcp / FastMCP from modelcontextprotocol/python-sdk
  • Starlette (any version — this is architectural, not a regression)
  • Python 3.12

Activity

  1. jstar0 commented on May 27, 2026

    @jstar0

    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.

  2. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 28, 2026
  3. ghost added
    potentially closeBot recommends closing — needs maintainer review
    on Jun 1, 2026
  4. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jun 1, 2026
  5. norika1207-lab commented on Jun 2, 2026

    @norika1207-lab
  6. maxisbey commented on Aug 14, 2026

    @maxisbey
    Contributor

    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.

    AI Disclaimer

  7. added
    bugSomething isn't working
    P3Nice to haves, rare edge cases
    needs reproneeds additional information to be able to reproduce bug
    on Aug 14, 2026
  8. HarperZ9 commented on Aug 22, 2026

    @HarperZ9

    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) and
    mcp 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
    """
    import os
    
    import uvicorn
    from starlette.middleware.base import BaseHTTPMiddleware
    
    stateless = os.environ.get("STATELESS") == "1"
    jsonresp = os.environ.get("JSONRESP") == "1"
    
    try:
        from mcp.server.fastmcp import FastMCP  # 1.x
    
        kwargs = {}
        if stateless:
            kwargs["stateless_http"] = True
        if jsonresp:
            kwargs["json_response"] = True
        mcp = FastMCP("matrix-test", **kwargs)
    
        @mcp.tool()
        def add(a: int, b: int) -> int:
            """Add two integers."""
            return a + b
    
        app = mcp.streamable_http_app()
    except ModuleNotFoundError:
        from mcp.server.mcpserver import MCPServer  # 2.x
    
        mcp = MCPServer("matrix-test")
    
        @mcp.tool()
        def add(a: int, b: int) -> int:
            """Add two integers."""
            return a + b
    
        app = mcp.streamable_http_app(stateless_http=stateless, json_response=jsonresp)
    
    if os.environ.get("MW") == "1":
        class NoOpAuthMiddleware(BaseHTTPMiddleware):
            async def dispatch(self, request, call_next):
                return await call_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 a
    standalone GET stream probe, against a real uvicorn server and the SDK's own
    streamable-HTTP client. Prints one row per config plus version stamps."""
    import asyncio
    import importlib.metadata as im
    import itertools
    import os
    import socket
    import subprocess
    import sys
    import time
    from pathlib import Path
    
    import httpx
    
    HERE = Path(__file__).parent
    PY = sys.executable
    
    
    def free_port() -> int:
        s = socket.socket()
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
        s.close()
        return port
    
    
    async def client_ops(port: int) -> str:
        from mcp import ClientSession
    
        url = f"http://127.0.0.1:{port}/mcp"
        try:  # 1.x client shape
            from mcp.client.streamable_http import streamablehttp_client
    
            async with streamablehttp_client(url) as (read, write, _get_session_id):
                async with ClientSession(read, write) as session:
                    return await _session_ops(session)
        except ImportError:  # 2.x client shape
            from mcp.client.streamable_http import streamable_http_client
    
            async with streamable_http_client(url) as (read, write):
                async with ClientSession(read, write) as session:
                    return await _session_ops(session)
    
    
    async def _session_ops(session) -> str:
        await session.initialize()
        tools = await session.list_tools()
        assert any(t.name == "add" for t in tools.tools), "add tool missing"
        result = await session.call_tool("add", {"a": 2, "b": 3})
        text = result.content[0].text if result.content else "?"
        assert text == "5", f"unexpected tool result {text!r}"
        return "ok"
    
    
    async def get_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"
        async with httpx.AsyncClient(timeout=8) as client:
            # initialize first to obtain a session id
            init = await client.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"}
            if sid:
                headers["mcp-session-id"] = sid
            try:
                async with client.stream("GET", url, headers=headers) as resp:
                    status = resp.status_code
                    if status >= 400:
                        return f"GET {status} (clean refusal)"
                    # read a little of the stream, then close
                    try:
                        async for _chunk in resp.aiter_bytes():
                            break
                    except httpx.ReadTimeout:
                        # An idle listen channel with no server events inside the
                        # timeout window: the stream is OPEN and healthy.
                        return f"GET {status} stream open (idle)"
                    except Exception as e:  # mid-stream failure is the symptom class
                        return f"GET stream broke: {type(e).__name__}: {e}"
                    return f"GET {status} stream open"
            except Exception as e:
                return f"GET failed: {type(e).__name__}: {e}"
    
    
    def run_case(mw: bool, stateless: bool, jsonresp: bool) -> tuple[str, str, str]:
        port = free_port()
        env = dict(os.environ, PORT=str(port))
        if mw:
            env["MW"] = "1"
        if stateless:
            env["STATELESS"] = "1"
        if jsonresp:
            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 port
            deadline = time.time() + 15
            while time.time() < deadline:
                try:
                    with socket.create_connection(("127.0.0.1", port), timeout=0.3):
                        break
                except OSError:
                    if proc.poll() is not None:
                        _, err = proc.communicate(timeout=5)
                        return ("SERVER-DIED", err.strip()[-200:], "")
                    time.sleep(0.2)
            try:
                ops = asyncio.run(client_ops(port))
            except Exception as e:
                ops = f"CLIENT-FAIL {type(e).__name__}: {str(e)[:160]}"
            try:
                probe = asyncio.run(get_stream_probe(port))
            except Exception as e:
                probe = f"PROBE-FAIL {type(e).__name__}: {str(e)[:120]}"
            return (ops, "", probe)
        finally:
            proc.kill()
            try:
                _, err = proc.communicate(timeout=5)
            except Exception:
                err = ""
        return ("?", "", "")
    
    
    def main() -> 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")
        for mw, stateless, jsonresp in itertools.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)
            if err:
                print(f"    server stderr tail: {err}")
    
    
    if __name__ == "__main__":
        main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesbugSomething isn't workingneeds reproneeds additional information to be able to reproduce bugpotentially closeBot recommends closing — needs maintainer review

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions