Repository navigation
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
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/jsonortext/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+400is required only for a malformed request envelope missing required_metafields (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_STATUSHandler 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
[-][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[/+]XiiiiaoT commentedon Nov 13, 2026
Thanks for the detailed report. I traced the current implementation and the reported status mismatch is consistent with the code.
_write_rejection()and then_write().serve_one(). Exceptions are converted by_to_jsonrpc_response()and also reach_write()._write()currently selects the HTTP status solely fromERROR_CODE_HTTP_STATUS, which mapsINVALID_PARAMSto 400. This makes an application-levelMCPError(INVALID_PARAMS)(such as the missing-resource path inMCPServer) 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), andMETHOD_NOT_FOUND(404), while allowing a dispatchedINVALID_PARAMSerror 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_logcurrently expects HTTP 400 for anINVALID_PARAMSgenerated after the classifier accepts the request. Additionally,ServerRunner._on_requestperforms 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.
claudia-dribia commentedon Nov 13, 2026
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
_metafields, 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 use400plus a recognized modern error (unsupported version, missing capability, header validation) to identify a modern server, and a handler-32602is outside that set, so a stray 400 here can make a client mistake the server for a legacy one and fall back toinitialize.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.
media-ctrl commentedon Nov 14, 2026
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.
_writederives the status from the code alone for every error:That one line is reached with three different origins:
_write_rejectionfor 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 asapplication/json. A flag set in_to_jsonrpc_responsecovers 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-streamwith a hardcoded"status": 200and the terminal error goes in-band. So a-32602from the sameresources/readis 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
-32602lets the caller distinguish a wrong URI from a malformed request, and 400 erases that distinction.