[Schema][Server] Preserve request id in JSON-RPC error responses instead of fabricating id:"" - #379
Conversation
soyuka
left a comment
There was a problem hiding this comment.
Automatic review worth a look.
chr-hertel
left a comment
There was a problem hiding this comment.
Correct me if I'm wrong, but I think you missed Mcp\Client\State\ClientState::storeResponse here.
Sorry about PHPStan not catching that - somehow expected with this kind of refactoring tbh.
You're right, fixed - thanks. A null-id error can't be matched to any pending request, so handleResponse() now logs and drops it before it reaches the state. Added a test in tests/Unit/Client/ProtocolTest.php |
…g id:"" When the server received input it could not turn into a valid message, the error response carried a fabricated empty-string id that was never in the request, breaking JSON-RPC client correlation. The id default for the error factories was '' and that default reached the wire unchanged: unrecoverable parse errors emitted id:"", and for invalid- but-parseable messages MessageFactory discarded the decoded id so it also fell back to "". Now unrecoverable parse errors (-32700) return id: null per JSON-RPC 2.0, and invalid-but-parseable requests (-32600) preserve the original request id. Error::$id / getId() and the $id parameter of forParseError() / forInvalidRequest() are widened to string|int|null, and the recoverable id is threaded through InvalidInputMessageException. Fixes modelcontextprotocol#333
…fromArray - Widen the remaining for*() factories (forMethodNotFound, forInvalidParams, forInternalError, forServerError, forResourceNotFound) to string|int|null defaulting to null, so no error path can fabricate id:"" again. The only defaulting call site (ProtocolVersionMiddleware) now emits id:null instead of id:"" for a pre-parse rejection, which is the spec-correct value. - Accept a null id in Error::fromArray(): require the id key to be present (JSON-RPC responses must carry it) but allow its value to be null, so a spec-compliant id:null error response round-trips and incoming error responses with id:null are accepted instead of rejected. - Widen the @phpstan-type ErrorData id to string|int|null to match the ctor, getId(), and jsonSerialize() shape.
1091940 to
a2d01f2
Compare
|
Hey @valeriudev — thanks for tracking this down, the empty-string There's some overlap with #442, which also fixes #333 and just merged. The two took different stances on what an unrecoverable id should serialize as: #442 omits the Since #442 already landed with that semantics, I rebased this branch onto current
Full unit suite, phpstan, and cs-fixer all pass on the rebased branch, force-pushed to this PR. Let me know if you want any of that reworked — happy to iterate. |
The bug
When the server receives input it cannot turn into a valid message, the JSON-RPC error response carries a fabricated empty-string
idthat was never in the request:{"jsonrpc":"2.0","id":"","error":{"code":-32700,...}}The reporter sent an
initializerequest with a real numeric id (900512) nested past PHP'sjson_decode()depth limit; the server replied withid:"". An empty string is not a valid JSON-RPC / MCPRequestId, so this breaks client correlation.Root cause
Two paths both collapsed to
"":Error::forParseError()andError::forInvalidRequest()defaulted$idto'', andError::$idwas typedstring|int(nonull). On an unrecoverable parse failure (\JsonExceptioninProtocol::processInput()) the factory was called with no id, so""reached the wire.MessageFactorythrewInvalidInputMessageExceptionwithout carrying the decodedid, soProtocol::handleInvalidMessage()also fell back to""— even though the real id was recoverable from the decoded payload.The fix
-32700) now returnid: null, per JSON-RPC 2.0 (id isnullwhen it cannot be determined).-32600) now preserve the original requestid.Error::$id/Error::getId()and the$idparameter ofError::forParseError()/Error::forInvalidRequest()are widened tostring|int|null.jsonSerialize()is unchanged — it now emits"id":nullinstead of"id":"".MessageFactorythroughInvalidInputMessageExceptionvia newgetRequestId()/setRequestId(). The id is only attached when the decoded value is a validstring|int(mirroringRequest/Error::fromArrayvalidation);id:0is preserved (type-checked, not truthiness), whilenull/true/array/missing staynull.No
[BC Break]: the type is widened, not narrowed, and the only behavioral change is replacing an invalid fabricated id with a spec-compliant one.Tests
Two regression tests in
tests/Unit/Server/ProtocolTest.php:testParseErrorDoesNotFabricateEmptyStringId— feeds JSON nested 600 deep sojson_decode()throws, assertserror.code === -32700,id !== '', and thatidis null or omitted.testInvalidMessagePreservesRecoverableId— feeds valid JSON with id42but nomethod/result/error, assertserror.code === -32600andid === 42.Both were red for the right reason (got
"") before the fix and are green after. Full unit suite (766 tests), phpstan (level 6), and php-cs-fixer all pass.Fixes #333