Skip to content

Streamable HTTP on Windows: after a tool raises a non-ToolError, no request is answered again (2.1.0+, 2.0.1 fine) #3586

Description

@ryanduguid

Initial Checks

  • I confirm that I am using the newest release of the 2.x line.
  • I confirm that I searched the issue tracker before opening this issue.

Release line

2.x (current stable)

Description

On Windows, over the streamable-http transport, the first tools/call whose handler raises anything other than ToolError never gets a response, and the server then stops answering everything: later tools/call requests on the same session and even initialize on a new session time out. The POST for the crashing call returns 200 with the SSE headers and the stream never carries a result. The server logs the expected Tool 'sync_tool' raised an unexpected exception traceback and nothing after it.

  • Affected: 2.1.0, 2.1.1 and 2.2.0 on Windows 11 with Python 3.12.10.
  • Not affected: 2.0.1 on the same machine (the crash comes back as isError: true with the exception text), and 2.2.0 on Linux (Ubuntu under WSL2, Python 3.14.4), where the crash comes back as isError: true and the session keeps serving.
  • Both def and async def handlers reproduce it, so it is not specific to the worker-thread path added in 2.x.
  • A handler that raises ToolError answers normally on 2.2.0 on Windows, so only the UnexpectedToolError path is affected.
  • stdio is unaffected on Windows: the same handler yields isError: true there.

Expected: the CallToolResult(isError=True) reply that stdio, Linux and 2.0.1 give, with the server continuing to serve.

Found while moving openaccountants-mcp to 2.x. That server now raises ToolError for its own input errors, which sidesteps this for anticipated failures, but any genuine bug in a tool would still take the HTTP server down on Windows.

Example Code

Self-contained: starts the server as a subprocess and talks raw JSON-RPC over streamable-http, so no client-side SDK code is involved. Each call has a 10 s timeout.

"""Minimal reproducer: over streamable-http, a tool that raises anything other
than ToolError never answers on mcp >= 2.1.0 (2.0.1 answers with isError).

Run with a given SDK version, for example:
    uv run --with "mcp==2.2.0" python sdk_hang_repro.py
"""

import importlib.metadata
import json
import os
import socket
import subprocess
import sys
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen

SERVER = """
from mcp.server.mcpserver import MCPServer
import os

mcp = MCPServer("repro")

@mcp.tool()
def sync_tool(x: int) -> str:
    if x < 0:
        raise ValueError("x must be non-negative")
    return str(x)

@mcp.tool()
async def async_tool(x: int) -> str:
    if x < 0:
        raise ValueError("x must be non-negative")
    return str(x)

mcp.run(transport="streamable-http", host="127.0.0.1", port=int(os.environ["PORT"]))
"""

ACCEPT = "application/json, text/event-stream"


def post(url, payload, session_id=None, timeout=10):
    headers = {"Content-Type": "application/json", "Accept": ACCEPT}
    if session_id:
        headers["Mcp-Session-Id"] = session_id
    req = Request(url, data=json.dumps(payload).encode(), headers=headers, method="POST")
    try:
        with urlopen(req, timeout=timeout) as resp:
            return resp.status, dict(resp.headers), resp.read().decode(errors="replace")
    except HTTPError as exc:
        return exc.code, dict(exc.headers), exc.read().decode(errors="replace")


def main():
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]
    proc = subprocess.Popen([sys.executable, "-c", SERVER], env=dict(os.environ, PORT=str(port)),
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    try:
        for _ in range(120):
            try:
                with socket.create_connection(("127.0.0.1", port), timeout=0.5):
                    break
            except OSError:
                time.sleep(0.25)
        url = f"http://127.0.0.1:{port}/mcp"
        print("mcp", importlib.metadata.version("mcp"), "| python", sys.version.split()[0], "|", sys.platform)
        _, headers, _ = post(url, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
            "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "repro", "version": "0"}}})
        sid = headers.get("mcp-session-id") or headers.get("Mcp-Session-Id")
        post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)
        rid = 2
        for tool in ("sync_tool", "async_tool"):
            for x in (1, -1):
                rid += 1
                t0 = time.time()
                try:
                    status, _, body = post(url, {"jsonrpc": "2.0", "id": rid, "method": "tools/call",
                                                 "params": {"name": tool, "arguments": {"x": x}}}, sid)
                    data = [l[6:] for l in body.splitlines() if l.startswith("data: ")]
                    summary = json.loads(data[0])["result"] if data else body[:120]
                    if isinstance(summary, dict):
                        summary = {k: summary[k] for k in ("isError", "content") if k in summary}
                    print(f"{tool}(x={x}): HTTP {status} in {time.time() - t0:.1f}s -> {json.dumps(summary)[:160]}")
                except Exception as exc:
                    print(f"{tool}(x={x}): NO ANSWER after {time.time() - t0:.0f}s ({type(exc).__name__})")
        # Is the damage confined to the session? Open a fresh one and call a healthy tool.
        t0 = time.time()
        try:
            _, headers, _ = post(url, {"jsonrpc": "2.0", "id": 90, "method": "initialize", "params": {
                "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "repro2", "version": "0"}}})
            sid2 = headers.get("mcp-session-id") or headers.get("Mcp-Session-Id")
            print(f"new session: initialize answered in {time.time() - t0:.1f}s")
            post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid2)
            t0 = time.time()
            status, _, body = post(url, {"jsonrpc": "2.0", "id": 91, "method": "tools/call",
                                         "params": {"name": "sync_tool", "arguments": {"x": 2}}}, sid2)
            print(f"new session, sync_tool(x=2): HTTP {status} in {time.time() - t0:.1f}s -> {body[:100].strip()!r}")
        except Exception as exc:
            print(f"new session: NO ANSWER after {time.time() - t0:.0f}s ({type(exc).__name__})")
    finally:
        proc.kill()
        proc.wait(timeout=10)
        raw = proc.stdout.read()
        with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "server-stderr.log"), "w", encoding="utf-8") as fh:
            fh.write(raw)
        table = str.maketrans("", "", "┌─┐│└┘╭╮╰╯")
        lines = [l.translate(table).strip() for l in raw.splitlines()]
        keep = [l for l in lines if l and any(k in l for k in ("Traceback", "File ", "Error", "raise ", "exception"))]
        print("server log (filtered):")
        for l in keep[-14:]:
            print("   ", l[:150])


if __name__ == "__main__":
    main()

Windows 11, uv run --python 3.12 --with "mcp==2.2.0" python sdk_hang_repro.py:

mcp 2.2.0 | python 3.12.10 | win32
sync_tool(x=1): HTTP 200 in 0.0s -> {"isError": false, "content": [{"text": "1", "type": "text"}]}
sync_tool(x=-1): NO ANSWER after 10s (TimeoutError)
async_tool(x=1): NO ANSWER after 10s (TimeoutError)
async_tool(x=-1): NO ANSWER after 10s (TimeoutError)
new session: NO ANSWER after 10s (TimeoutError)

Same machine, mcp==2.0.1:

mcp 2.0.1 | python 3.12.10 | win32
sync_tool(x=1): HTTP 200 in 0.0s -> {"isError": false, "content": [{"text": "1", "type": "text"}]}
sync_tool(x=-1): HTTP 200 in 0.0s -> {"isError": true, "content": [{"text": "Error executing tool sync_tool: x must be non-negative", "type": "text"}]}
async_tool(x=1): HTTP 200 in 0.0s -> {"isError": false, "content": [{"text": "1", "type": "text"}]}
async_tool(x=-1): HTTP 200 in 0.0s -> {"isError": true, "content": [{"text": "Error executing tool async_tool: x must be non-negative", "type": "text"}]}

Ubuntu under WSL2, mcp==2.2.0 in a venv:

mcp 2.2.0 | python 3.14.4 | linux
sync_tool(x=1): HTTP 200 in 0.0s -> {"isError": false, "content": [{"text": "1", "type": "text"}]}
sync_tool(x=-1): HTTP 200 in 0.0s -> {"isError": true, "content": [{"text": "Error executing tool sync_tool", "type": "text"}]}
async_tool(x=1): HTTP 200 in 0.0s -> {"isError": false, "content": [{"text": "1", "type": "text"}]}
async_tool(x=-1): HTTP 200 in 0.0s -> {"isError": true, "content": [{"text": "Error executing tool async_tool", "type": "text"}]}
new session: initialize answered in 0.0s
new session, sync_tool(x=2): HTTP 200 in 0.0s -> 'event: message\r\ndata: {"jsonrpc":"2.0","id":91,"result":{"content":[{"text":"2","type":"text"}],"isE'

Python & MCP Python SDK

  • Windows 11 (10.0.26100), Python 3.12.10, mcp 2.2.0, 2.1.1, 2.1.0 and 2.0.1, each in a fresh uv run --with environment.
  • Ubuntu 26.04 under WSL2, Python 3.14.4, mcp 2.2.0 and 2.0.1 in venvs.

Activity

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

    v2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions