Skip to content

Modern Streamable HTTP: handler-produced -32602 answers HTTP 400 instead of 200 #3855

Open

Description

@claudia-dribia

Initial Checks

Release line

2.x (current stable)

Description

On the 2026-07-28 (modern) Streamable HTTP path, _write picks the HTTP status from ERROR_CODE_HTTP_STATUS for every JSON-RPC error, ignoring whether it came from the request-validation ladder or from a handler. Since that table maps -32602 INVALID_PARAMS -> 400, a handler-produced "resource not found" is returned as HTTP 400, but the spec requires it in-band on HTTP 200.

-32602 legitimately needs 400 for one origin (malformed request envelope) and 200 for the other (handler error), so one code→status table cannot express the rule.

Actual: 400 {"jsonrpc":"2.0","id":7800,"error":{"code":-32602,"message":"Resource not found: 'demo://does-not-exist'",...}}

Expected: same body, HTTP 200.

Why:

  • Streamable HTTP 2026-07-28 (Sending Messages item 6): a JSON-RPC request MUST return application/json or text/event-stream. Non-200 is assigned only to -32020 HeaderMismatch (400), -32021 MissingRequiredClientCapability (400), -32022 UnsupportedProtocolVersion (400), -32601 MethodNotFound (404).
  • A non-existent resource is -32602 (SEP-2164, resources error handling); no HTTP status is assigned → in-band on 200.
  • -32602 + 400 is required only for a malformed request envelope missing required _meta fields (basic/index, Per-request protocol fields).

Root cause — src/mcp/server/_streamable_http_modern.py, _write:

status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS

Handler errors reach _write via _to_jsonrpc_response; ladder rejections via _write_rejection. Both go through the same table (src/mcp/shared/inbound.py), which includes INVALID_PARAMS: 400 and whose docstring says it is consulted for "classifier-origin and handler-origin errors". That origin conflation is the bug.

Suggested fix: carry the origin into _write (flag set by _to_jsonrpc_response) and apply the spec's status table only to ladder rejections. For handler-origin errors, default to 200, applying a status only for codes the spec defines:

_HANDLER_ERROR_HTTP_STATUS = {
    HEADER_MISMATCH: 400,                    # -32020
    MISSING_REQUIRED_CLIENT_CAPABILITY: 400, # -32021
    UNSUPPORTED_PROTOCOL_VERSION: 400,       # -32022
    METHOD_NOT_FOUND: 404,                   # -32601
}

Verified locally: handler -32602 → 200, envelope -32602 → 400, Mcp-Method mismatch → 400.

Impact: any modern client reading a missing resource gets an HTTP transport error instead of the spec's in-band error. Fails MCPJam conformance check modern-resource-not-found-invalid-params.

Example Code

Minimal FastMCP app with one resource, then POST a modern resources/read for a missing URI:

import asyncio, httpx
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier

mcp = FastMCP("repro", auth=StaticTokenVerifier(
    tokens={"tok": {"client_id": "x", "scopes": ["s"]}}))

@mcp.resource(uri="demo://widget", name="w", description="d")
async def widget() -> str:
    return "<html></html>"

BODY = {"jsonrpc": "2.0", "id": 7800, "method": "resources/read",
    "params": {"uri": "demo://does-not-exist", "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {},
        "io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"}}}}
HEADERS = {"Authorization": "Bearer tok",
    "Accept": "application/json, text/event-stream",
    "Content-Type": "application/json",
    "MCP-Protocol-Version": "2026-07-28",
    "Mcp-Method": "resources/read", "Mcp-Name": "demo://does-not-exist"}

async def main():
    app = mcp.http_app(path="/mcp", stateless_http=True, json_response=True)
    async with app.lifespan(app):
        async with httpx.AsyncClient(
            transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
            r = await c.post("/mcp", json=BODY, headers=HEADERS)
            print(r.status_code, r.text)

asyncio.run(main())

Python & MCP Python SDK

mcp==2.3.0, Python 3.13, modern Streamable HTTP (MCP-Protocol-Version: 2026-07-28). Reproduced via fastmcp 4.1.6, but the defect is in the SDK transport.


AI disclosure: this issue was drafted with AI assistance from a reproduction I ran locally; I have reviewed and can explain its contents.

Activity

  1. changed the title [-][bug] Modern Streamable HTTP: handler-produced -32602 answers HTTP 400 instead of 200[/-] [+]Modern Streamable HTTP: handler-produced -32602 answers HTTP 400 instead of 200[/+] on Nov 13, 2026
  2. added
    bugSomething isn't working
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    v2Affects the v2 line (2.x on main)
    on Nov 13, 2026
  3. XiiiiaoT commented on Nov 13, 2026

    @XiiiiaoT

    Thanks for the detailed report. I traced the current implementation and the reported status mismatch is consistent with the code.

    • Pre-dispatch validation failures go through _write_rejection() and then _write().
    • Requests that pass the validation ladder are handled by serve_one(). Exceptions are converted by _to_jsonrpc_response() and also reach _write().
    • _write() currently selects the HTTP status solely from ERROR_CODE_HTTP_STATUS, which maps INVALID_PARAMS to 400. This makes an application-level MCPError(INVALID_PARAMS) (such as the missing-resource path in MCPServer) indistinguishable from a malformed request-envelope rejection.

    I agree that HTTP status selection needs to account for the error's origin. The fix should preserve the protocol-defined statuses for HEADER_MISMATCH (400), MISSING_REQUIRED_CLIENT_CAPABILITY (400), UNSUPPORTED_PROTOCOL_VERSION (400), and METHOD_NOT_FOUND (404), while allowing a dispatched INVALID_PARAMS error to be returned in-band with HTTP 200.

    One existing test expectation may also need clarification: test_modern_tools_call_mis_shaped_envelope_skips_validation_without_an_error_log currently expects HTTP 400 for an INVALID_PARAMS generated after the classifier accepts the request. Additionally, ServerRunner._on_request performs method-surface and typed-parameter validation before invoking the user handler, and those errors flow through the same _to_jsonrpc_response() path.

    Should all post-classifier JSON-RPC errors use HTTP 200 unless their error codes have an explicitly specified non-200 status, leaving pre-dispatch envelope rejections at HTTP 400? Or should application-handler errors be distinguished from kernel request-validation errors more narrowly?

    This distinction will determine the correct implementation and regression tests.

    AI assistance was used to help trace the implementation and prepare this technical analysis. I have reviewed the referenced code paths and test expectations.

  4. claudia-dribia commented on Nov 13, 2026

    @claudia-dribia
    Author

    To your question, I would say yes to the first and no to the second.

    Yes, errors that come from a handler after the validation ladder accepts the request should stay in band as HTTP 200 unless the error code has a status the spec explicitly defines. The ladder rejections, including a malformed request envelope missing required _meta fields, should stay HTTP 400. The explicit statuses still apply after the ladder, for example 404 for an unknown method. One more reason to keep 400 narrow: the spec's backward-compatibility section has clients use 400 plus a recognized modern error (unsupported version, missing capability, header validation) to identify a modern server, and a handler -32602 is outside that set, so a stray 400 here can make a client mistake the server for a legacy one and fall back to initialize.

    No to the narrower split. I would not separate handler origin errors from kernel request validation errors, because kernel request validation like bad tool arguments still has to come back in band as a 200, so that split would put the wrong errors in the 400 bucket.

    For the test you mentioned, the field is optional and the error shows up after the request is accepted, so a 200 seems consistent to me. I will leave that call to you.

  5. media-ctrl commented on Nov 14, 2026

    @media-ctrl

    Confirmed on main at 77001c9, and the origin split is the right frame. One thing worth adding, because it decides where the fix belongs rather than whether it is correct.

    _write derives the status from the code alone for every error:

    status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, 200) if isinstance(msg, JSONRPCError) else 200

    That one line is reached with three different origins: _write_rejection for the classifier ladder, the json-response branch that writes the handler result directly, and the deferral-window branch where a handler that emitted nothing before the ping interval expires gets written as application/json. A flag set in _to_jsonrpc_response covers the first two, but the third also needs to know the handler completed without emitting, and the rejection path has to keep the ladder table. Threading an origin boolean through all three works; passing the status from the response builder is fewer moving parts, since ladder rejects already know their own status where they are classified.

    The argument a reviewer will ask for is in the streaming path. The same handler error already returns 200 when the handler emits any notification inside the deferral window: the transport commits text/event-stream with a hardcoded "status": 200 and the terminal error goes in-band. So a -32602 from the same resources/read is a 400 or a 200 depending on whether the handler reported progress first. No code-to-status table can express that, which is why origin has to come from the caller rather than from a second table.

    For tests, that gives a concrete pair: the same missing-resource read once with Accept: application/json, and once on a handler that emits one progress notification. Both should be 200 with the in-band error, and today they disagree. Adding them next to the modern-resource-not-found conformance case means the fix cannot regress into the table.

    We hit this through a gateway that maps any 4xx to a client fault and drops the body, so a missing-resource read surfaced as a bare transport error with no JSON-RPC detail. The in-band requirement is what makes that recoverable: a 200 carrying -32602 lets the caller distinguish a wrong URI from a malformed request, and 400 erases that distinction.

  6. XiiiiaoT commented on Nov 14, 2026

    @XiiiiaoT

    Thanks, this is a very useful clarification. I agree that the HTTP status should be determined by the response origin rather than inferred solely from the JSON-RPC error code.

    I checked the existing test structure and propose adding wire-level regression coverage for three paths using the same missing-resource -32602 error:

    1. JSON response mode (Accept: application/json): HTTP 200 with the in-band JSON-RPC error.
    2. SSE mode where the handler emits a progress notification before raising ResourceNotFoundError: HTTP 200 with the progress event and terminal -32602 error.
    3. SSE-preferred mode where the handler completes without emitting before the deferral window expires: the JSON fallback should also return HTTP 200 with the in-band error.

    The existing validation-ladder rejection tests should continue to expect HTTP 400, and the explicit METHOD_NOT_FOUND case should remain HTTP 404.

    For the implementation, I will investigate passing the HTTP status explicitly from the response-producing path, keeping ladder rejections responsible for their own status rather than relying on a single code-to-status mapping in _write.

    I also found the existing resource conformance test at tests/interaction/mcpserver/test_resources.py::test_read_unknown_uri_is_error. I plan to complement its JSON-RPC error assertions with modern HTTP wire-level tests, since the Client abstraction does not expose the raw HTTP status.

    Does this test coverage and implementation direction match the intended fix?

  7. media-ctrl commented on Nov 14, 2026

    @media-ctrl

    Yes, that direction matches, with one constraint that decides where the status must be set.

    The plan to pass the status explicitly from the response-producing path is right, and it must not be unconditional "handler path = 200". On main the deferral branch writes result[0] through the same _write after serve_one has already returned:

    if done:
        # Handler completed within the deferral window without emitting:
        # `application/json` with the table-mapped status. Kernel-dispatch
        # errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS)
        # resolve here in practice.
        await _write(result[0], scope, receive, send)

    So METHOD_NOT_FOUND reaching HTTP 404 depends on that same table lookup on that same line. The tag has to be applied by whichever stage produced the error, not by _write and not by the branch: _to_jsonrpc_response is the natural place, attached at exception-conversion time, so the json branch and the deferral branch both carry it, while _write_rejection keeps the ladder mapping. _write(msg, status=None) falling back to the table would preserve every current call site.

    On the tests, two additions to your three:

    1. A negative test for the ladder: a malformed envelope rejection carrying a -32602-shaped error must stay 400. That is the test that stops a later refactor collapsing the origin split back into one table, and it is the one most likely to be dropped as redundant.
    2. Assert on the wire, not the Client, for all four. The deferral case is exactly the one where a Client-level assertion passes for the wrong reason, because the handler has already returned by the time the bytes are written.

    Your resource conformance test is the right neighbour for this. test_read_unknown_uri_is_error proves the error payload; these prove the envelope it travels in.

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 workingspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects 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