Repository navigation
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
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: truewith the exception text), and 2.2.0 on Linux (Ubuntu under WSL2, Python 3.14.4), where the crash comes back asisError: trueand the session keeps serving. - Both
defandasync defhandlers reproduce it, so it is not specific to the worker-thread path added in 2.x. - A handler that raises
ToolErroranswers normally on 2.2.0 on Windows, so only theUnexpectedToolErrorpath is affected. - stdio is unaffected on Windows: the same handler yields
isError: truethere.
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 --withenvironment. - Ubuntu 26.04 under WSL2, Python 3.14.4, mcp 2.2.0 and 2.0.1 in venvs.
Activity
Ben-Home commentedon Sep 28, 2026
The version and platform matrix here is very clean and it points at the streamable-HTTP response writer rather than tool dispatch itself: only non-ToolError exceptions trigger it, only Windows triggers it, 2.0.1 predates the break, and stdio is unaffected. On the same machine ToolError raises answer normally, so the dispatch boundary converts ToolError fine; the unexpected-exception path is the one that never emits a result event.
My hypothesis: on the unexpected-exception path, the exception escapes the response writer task before the JSON-RPC error envelope (or the terminal SSE event) is written, and on Windows the Proactor event loop surfaces the resulting connection reset differently than Linux's selector loop, which is why the same code serves fine on WSL. The "server stops answering everything, including initialize on a new session" detail suggests the session is left poisoned after the writer task dies.
Two quick tests that would split the bug space in half:
The invariant that should hold: any handler exception is caught at the dispatch boundary, converted into CallToolResult(isError=True), and the stream is closed with the terminal event, so a dying worker task can never take the session down with it. That is the rule we enforce on the CorpusIQ MCP server (corpusiq.io), which runs 36 connectors over streamable HTTP: no exception ever escapes the tool-call boundary.
liwenjie200543 commentedon Sep 30, 2026
I reproduced the hang with the reproducer above on Windows 10 (Python 3.12.14, mcp 2.2.0): 3/3 runs stop answering at
async_tool(x=-1), and the server ignores everything afterwards — matching the report.While bisecting the setup I found the trigger is the server's stdout channel, not the async tool path itself. Running the identical reproducer with the server's stdout redirected to a file instead of the parent's undrained
subprocess.PIPEflips the outcome (verified in 3 consecutive runs):sync_tool(x=-1)andasync_tool(x=-1)both answerisError: truein 0.0s and a fresh session initializes immediately.Likely mechanism (inference from that experiment, not from a direct stack dump): 2.x crash-path logging writes a full
logger.exceptiontraceback (~4 KB) to stdout from inside the event-loop thread. 2.1.0 replaced the old one-linelogger.info("Tool %r failed: %r", ...)with that traceback, so two crashes in a row are enough to fill the OS pipe buffer when the consumer drains it only at the end — after that, the next log write blocks the loop thread and the server stops answering. 2.0.1 would wedge the same way under an undrained pipe given enough crash logs; 2.1.0 just gets there after two crashes, which reads like a 2.x async regression.If that holds, the operational fix is "don't give an MCP server an undrained stdout" (Docker/CI harnesses drain or drop it), and the SDK-side question for maintainers is whether the crash path still needs the full traceback in the request handler, or whether it should be a single line with
exc_inforeserved for debug logging. Happy to prepare the latter if wanted.maxisbey commentedon Oct 1, 2026
I reproduced this on Windows. The hang comes from how the reproduction script launches the server, not from the SDK: the server's output goes to a pipe that nothing reads, and the server blocks once that pipe is full.
The script starts the server with
stdout=subprocess.PIPE, stderr=subprocess.STDOUTand only reads the pipe after killing the process. On Windows that pipe holds 4096 bytes. Since 2.1.0 an unexpected tool exception is logged with its traceback (#3314), which is 2 to 3 KB, or about 10 KB withrichinstalled. When that log write doesn't fit, it blocks the event loop thread, so nothing is answered afterwards, new sessions included.Three things point at the pipe and not the transport:
DEVNULL, every call answers and the crashes come back asisError: true.So 2.1.0 reaches the limit sooner because a crash now writes a traceback, but a server whose output goes to a console, a file or a pipe that is being read isn't affected.
Here's a fixed version of your script. The only change is that the server's output goes straight to
server-stderr.loginstead of a pipe:sdk_hang_repro.py
I'm closing this since it isn't an SDK bug. If you still see it with the output drained or redirected, comment here and I'll reopen it.
AI Disclaimer
Jaswanth1902 commentedon Oct 2, 2026