Initial Checks
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.
Initial Checks
Release line
2.x (current stable)
Description
On Windows, over the streamable-http transport, the first
tools/callwhose handler raises anything other thanToolErrornever gets a response, and the server then stops answering everything: latertools/callrequests on the same session and eveninitializeon 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 expectedTool 'sync_tool' raised an unexpected exceptiontraceback and nothing after it.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.defandasync defhandlers reproduce it, so it is not specific to the worker-thread path added in 2.x.ToolErroranswers normally on 2.2.0 on Windows, so only theUnexpectedToolErrorpath is affected.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
ToolErrorfor 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.
Windows 11,
uv run --python 3.12 --with "mcp==2.2.0" python sdk_hang_repro.py:Same machine,
mcp==2.0.1:Ubuntu under WSL2,
mcp==2.2.0in a venv:Python & MCP Python SDK
uv run --withenvironment.