Skip to content

Client tool call hangs forever if server crashes or connection dies when using streamable-http #1577

Open

Description

@gyang-xai

Initial Checks

Description

If the server dies during a tool call before it sends a response the client will get stuck forever.

Example Code

# server.py

from mcp.server.fastmcp import FastMCP
import sys

mcp = FastMCP("StatefulServer")

@mcp.tool()
def greet(name: str = "World") -> str:
    """Greet someone by name."""
    sys.exit(0)
    return f"Hello, {name}!"


if __name__ == "__main__":
    mcp.run(transport="streamable-http")


# client.py

import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main():
    # Connect to a streamable HTTP server
    async with streamablehttp_client("http://localhost:8000/mcp") as (
        read_stream,
        write_stream,
        _,
    ):
        # Create a session using the client streams
        async with ClientSession(read_stream, write_stream) as session:
            # Initialize the connection
            await session.initialize()

            res = await session.call_tool("greet", {"name": "World"})
            print(res)


if __name__ == "__main__":
    asyncio.run(main())



# Run server.py then client.py


# client.py will hang forever in the tool call and never complete. I would expect it to throw an error since the connection died and it will never get a response.

Python & MCP Python SDK

Python 3.13.7
mcp 1.20.0

Activity

  1. gyang-xai commented on Nov 5, 2025

    @gyang-xai
    Author

    I have a possible PR that fixes this here: #1578 for reference but not sure if this is the best solution.

  2. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Nov 5, 2025
  3. added a commit that references this issue on Nov 7, 2025
  4. owenliang commented on Feb 18, 2026

    @owenliang

    "I encountered the same issue: exceptions at the underlying connection layer are not being broadcast to every waiting caller. Currently, the only workaround is to set a timeout for call tool operations to actively give up."

  5. astro-angelfish commented on Jul 2, 2026

    @astro-angelfish

    Reproduced on my side, just made my framework hangs forever. Maybe a keep-alive mechanism is required for streamable-http.

  6. ossi-kronlof-fellowmind commented on Oct 27, 2026

    @ossi-kronlof-fellowmind

    We hit this with mcp 1.28.1 against a hosted streamable-HTTP server that answers tools/call with an SSE response without id: fields and stays silent while a tool runs (some calls take several minutes). Details that may help whoever picks this up:

    Paths in mcp/client/streamable_http.py (1.28.1) that leave ClientSession.send_request waiting forever:

    1. _handle_sse_response: when the response stream ends or fails before the JSON-RPC response arrives (httpx.ReadTimeout, a peer reset, an intermediary dropping an idle connection, or a clean end of stream), the exception is only logged at DEBUG. If no SSE event carried an ID, there is no reconnect and nothing is delivered for the pending request.
    2. _handle_reconnection: after MAX_RECONNECTION_ATTEMPTS it logs at DEBUG and returns, again without failing the request.
    3. post_writer → handle_request_async: an exception from _handle_post_request (for example httpx.ReadTimeout while waiting for a JSON response) propagates into the task group and tears down the transport; the pending request is never answered.

    The only mitigation today is a per-request read_timeout_seconds. It has to sit above the longest tool call, so a dropped stream still waits that long.

    Minimal reproduction (no network, httpx.MockTransport; the tools/call response is SSE without event IDs that ends before the result):

    import json
    
    import anyio
    import httpx
    from mcp import ClientSession
    from mcp.client.streamable_http import streamable_http_client
    
    
    async def handler(request: httpx.Request) -> httpx.Response:
        body = json.loads(request.content)
        if body["method"] == "initialize":
            return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {
                "protocolVersion": "2025-06-18", "capabilities": {"tools": {}},
                "serverInfo": {"name": "fake", "version": "1.0.0"}}})
        if body["method"] == "tools/call":
            return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=b": keep-alive\n\n")
        return httpx.Response(202)
    
    
    async def main() -> None:
        async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
            async with streamable_http_client("http://mcp.test/mcp", http_client=client) as (read, write, _):
                async with ClientSession(read, write) as session:
                    await session.initialize()
                    with anyio.fail_after(5):
                        await session.call_tool("slow", {})  # never returns without a fix
    
    
    anyio.run(main)

    On 1.28.1 this ends in the fail_after TimeoutError.

    What works for us: failing the pending request with a JSON-RPC error in those three places when the stream cannot be resumed, and keeping the existing resume behaviour when an event ID is present. The error is not retried, because the tool may already have run on the server. We carry this as a local patch pinned to 1.28.1 until it is fixed here.

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

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions