Skip to content

Streamable HTTP client hangs on a null-id JSON-RPC error in a 200 JSON response #3639

Description

@hyeonsang010716

Initial Checks

Release line

2.x (current stable)

Description

On current main (2118f14f), when a server answers a request POST with a 200 application/json body holding a JSON-RPC error with "id": null, the streamable HTTP client never resolves the call. session.list_tools() (or any request) waits forever, or until a read timeout if one is set.

JSON-RPC 2.0 allows id: null on an error when the server could not determine the request id. Gateways and non-SDK servers can send one with status 200.

_handle_json_response in src/mcp/client/streamable_http.py forwards the parsed message unchanged. The dispatcher then drops it as a response to an unknown id (jsonrpc_dispatcher.py, "unknown/late request id").

The other two response paths in the same transport already handle this:

  • SSE (_handle_sse_event): replaces a response's id with the original request id.
  • Non-2xx JSON body (_handle_post_request): rebuilds the error under the request's id. The comment there says: "The server may have set id: null (request rejected before its id was parsed); use this request's id so correlation works."

So the same error body resolves the call over SSE or with a 4xx status, but hangs with a 200.

Expected: the call raises MCPError with the server's error, the same as the SSE and non-2xx paths.

Proposed fix: in _handle_json_response, when the parsed message is a JSONRPCError, set its id to the POST's request id. A 2xx JSON body answers exactly that POST, so nothing else can be waiting on it. That is a 4-line change plus a regression test next to the existing non-2xx null-id test in tests/client/test_notification_response.py.

I'd like to fix this and have a branch ready: https://github.com/hyeonsang010716/python-sdk/tree/fix/streamable-http-json-null-id

I found and prepared this with AI assistance. I've reviewed the change, checked the repro below fails on main and passes with the fix, and can explain it.

Example Code

import json

import anyio
import httpx2
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route

from mcp import ClientSession, MCPError
from mcp.client.streamable_http import streamable_http_client


async def handle_mcp(request: Request) -> Response:
    data = json.loads(await request.body())
    if data.get("method") == "initialize":
        result = {"protocolVersion": "2025-06-18", "capabilities": {}, "serverInfo": {"name": "s", "version": "1"}}
        return JSONResponse({"jsonrpc": "2.0", "id": data["id"], "result": result})
    if "id" not in data:
        return Response(status_code=202)
    # e.g. a gateway that fails before it has parsed the request id
    return JSONResponse({"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "upstream failed"}})


async def main() -> None:
    app = Starlette(routes=[Route("/mcp", handle_mcp, methods=["POST"])])
    async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app)) as http:
        async with streamable_http_client("http://localhost/mcp", http_client=http) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                try:
                    with anyio.fail_after(3):
                        await session.list_tools()
                except MCPError as exc:
                    print("raised MCPError:", exc.error.message)
                except TimeoutError:
                    print("list_tools() never returned")


anyio.run(main)

Output on main:

list_tools() never returned

Output with the fix:

raised MCPError: upstream failed

Python & MCP Python SDK

Python 3.14.7
mcp main @ 2118f14f (also present in v2.3.0)

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 3, 2026
  2. musi22 commented on Oct 4, 2026

    @musi22

    Hi team,

    I investigated this issue against current main (2118f14f).

    Looking at _handle_json_response in src/mcp/client/streamable_http.py:

    async def _handle_json_response(
        self,
        response: httpx2.Response,
        read_stream_writer: StreamWriter,
        *,
        request_id: RequestId,
    ) -> None:
        content = await response.aread()
        message = jsonrpc_message_adapter.validate_json(content, by_name=False)
        session_message = SessionMessage(message)
        await read_stream_writer.send(session_message)

    The function receives request_id, but does not apply it when the returned JSONRPCError (or response) has id: None. When this happens, _resolve_pending in jsonrpc_dispatcher.py cannot correlate the response, logs "dropping response for unknown/late request id None", and the calling task hangs awaiting the pending future.

    The SSE path (_handle_sse_event, line 193) and the non-2xx branch (_handle_post_request, line 410) already correlate null-id errors with the original request ID.

    I'm putting together a targeted PR that:

    1. Updates _handle_json_response to correlate message.id = request_id when message.id is None.
    2. Adds a regression test in tests/client/test_streamable_http.py verifying that a 200 response with {"jsonrpc": "2.0", "id": null, "error": {...}} cleanly raises McpError rather than hanging.

    Opening a PR shortly with the reproduction test and fix.

  3. Li-john1021 commented on Oct 9, 2026

    @Li-john1021

    I hit the same hang on current main: a 200 application/json body with a JSON-RPC error whose id is null is forwarded unchanged from _handle_json_response, so the in-flight waiter never resolves. The non-2xx branch already remaps id: null to the outbound request id (~L408–411); the 200 JSON path does not.

    Proposed fix: in _handle_json_response, when the parsed message is a JSONRPCError with a null id, rewrite id to the request_id argument before sending on read_stream_writer. Add a regression test where a Streamable HTTP stub returns that 200 body and assert list_tools() (or any request) fails fast with the remapped error instead of hanging.

    If that direction looks right, I'd like to be assigned so I can open the PR under the external-contributor rule.

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

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects 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