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.XiiiiaoT commentedon Nov 14, 2026
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
-32602error:Accept: application/json): HTTP 200 with the in-band JSON-RPC error.ResourceNotFoundError: HTTP 200 with the progress event and terminal-32602error.The existing validation-ladder rejection tests should continue to expect HTTP 400, and the explicit
METHOD_NOT_FOUNDcase 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?
media-ctrl commentedon Nov 14, 2026
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_writeafterserve_onehas already returned:So
METHOD_NOT_FOUNDreaching 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_writeand not by the branch:_to_jsonrpc_responseis the natural place, attached at exception-conversion time, so the json branch and the deferral branch both carry it, while_write_rejectionkeeps 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:
-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.Your resource conformance test is the right neighbour for this.
test_read_unknown_uri_is_errorproves the error payload; these prove the envelope it travels in.