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