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.

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