[Schema][Server] Preserve request id in JSON-RPC error responses instead of fabricating id:""#379
Conversation
…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
soyuka
left a comment
There was a problem hiding this comment.
Automatic review worth a look.
There was a problem hiding this comment.
🟡 for*() factories left behind (L89–L111). forMethodNotFound, forInvalidParams, forInternalError, forServerError, and forResourceNotFound still default $id = ''. Any caller that forgets to pass the id reintroduces the exact bug this PR fixes. Suggest widening them to string|int|null with null default for consistency, even if no current call site hits that path.
🟡 fromArray() no longer round-trips its own output (L60–L65). The ctor and jsonSerialize() now accept/emit id: null, but fromArray() still throws on null (isset($data['id']) is false for null, then the type guard rejects it). Encoding a parse-error response and decoding it back via fromArray() blows up. Either allow null here or document the asymmetry.
🔵 @phpstan-type ErrorData is stale (L19–L25). Still declares id: string|int; widen to string|int|null to match the ctor / getId() / jsonSerialize() shape.
| try { | ||
| $messages[] = $this->createMessage($message); | ||
| } catch (InvalidInputMessageException $e) { | ||
| if (\is_array($message) && isset($message['id']) && (\is_string($message['id']) || \is_int($message['id']))) { |
There was a problem hiding this comment.
❓ JSON-RPC 2.0 allows id: null in requests. Here isset() drops it, so a null-id invalid request falls back to the null default in forInvalidRequest() — same outcome, fine. Intentional? If so, worth a one-line comment so the next reader doesn't "fix" it by switching to array_key_exists.
| // isset() is false for both an absent key and a null value, so this asserts the id is | ||
| // null or omitted (never a fabricated empty string). | ||
| $this->assertFalse( | ||
| isset($decoded['id']), | ||
| 'Unrecoverable parse error id should be null or omitted', | ||
| ); |
There was a problem hiding this comment.
🔵 nit: assertion intent and actual behavior don't quite line up. jsonSerialize() always emits the id key, so the response is always id: null, never omitted — the "or omitted" branch can never trigger. Tighter:
$this->assertArrayHasKey('id', $decoded);
$this->assertNull($decoded['id'], 'Unrecoverable parse error must emit id: null');That also catches a future regression where someone drops the key entirely (which would break clients that strictly validate the JSON-RPC shape).
| $message = json_decode($outgoing[0]['message'], true); | ||
| $this->assertArrayHasKey('error', $message); | ||
| $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); | ||
| $this->assertSame(42, $message['id'], 'Invalid-but-parseable message must preserve its recoverable id, not return ""'); |
There was a problem hiding this comment.
🔵 nit: PR description specifically calls out that id: 0 and string ids are preserved (type-checked, not truthiness), but only int 42 is exercised here. Worth adding a data provider or two extra cases for id: 0 (the truthiness trap) and a string id like "req-1" — both are cheap to assert and lock down the two explicit promises.
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