Skip to content

fix!: answer invalid ids and stop a bad line ending the server - #17

Merged
Martin Bens (SpiGAndromeda) merged 2 commits into
mainfrom
fix/request-envelope-validation
Sep 12, 2026
Merged

Martin Bens (SpiGAndromeda) merged 2 commits into
mainfrom
fix/request-envelope-validation

Conversation

@SpiGAndromeda

@SpiGAndromeda Martin Bens (SpiGAndromeda) commented Sep 12, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #7.

process_request now validates the message envelope before dispatch: a line must hold exactly one JSON object, and a present id must be a string or an integer. That started as the fix for #7 and grew twice, each time because a review found the previous step had left something reachable.

What was wrong

The reported defect. jq's // operator substitutes for false as well as for null and an absent key, so jq -c '.id // null' collapsed three message classes onto one value. A request carrying "id": null was read as a notification and never answered, and its client waited on a reply that could not arrive.

Invalid ids were answered as though valid. "id": true, an object and an array received a normal result. That is not just permissive, it is unanswerable: a response must carry its request's own id and a response id may only be a String, Number or Null, so no valid success response for such a request can be built. The server was emitting malformed responses.

One client line could end the server. Two shapes reached it. A line holding two or more JSON documents passed the old jq -e '.' gate, which reports only its last output, so the id extraction emitted one line per document. A document that was valid JSON but not an object made every field extraction yield an empty string. Both reached --argjson with an id it could not parse, and under set -euo pipefail that status propagated out of process_request into run_mcp_server's plain response=$(process_request "$line") assignment. A client that drops one trailing newline produces the first shape, so this needed no malice.

A malformed cancellation stopped an unrelated call. A notifications/cancelled omitting params.requestId matched an in-flight null id, because jq reads the absent key as null.

What changed

The parse gate admits exactly one JSON document of any type, using the same jq -cs + length idiom read_json_file already uses. An object gate runs before any field is read, so the four extractions only ever see an object and none can fail — that is what makes the crash fix a property of process_request rather than of its caller, which matters because bash clears errexit inside command substitution only outside POSIX mode.

The id is read by key presence and carried with tojson, so an absent key leaves an empty sentinel and a present id keeps its JSON type. An id that is not a string or an integer answers -32600 with a null response id. The integer test restates validate_tool_arguments' existing one rather than introducing a new . == floor, which cannot see a fraction at or above 2^52 where the double spacing reaches 1 — a defect this repository has already fixed once. The jsonrpc != "2.0" arm now reflects the id only when it is one a response may carry.

The cancellation matcher requires params.requestId to be present before comparing it.

Behavior table

input before after
"id": null, "id": false no response, client hangs -32600
"id": true, object, array normal result, invalid id echoed -32600
fractional id normal result -32600
string, integer, absent id unchanged unchanged
two JSON documents on a line server exits -32700
non-object document server exits -32600
bare null / false document -32700 -32600
bad jsonrpc with invalid id that id echoed null response id
notifications/cancelled without requestId cancels a null-id call ignored

Integer, float-free number, string, empty-string and "null"-string ids all round-trip byte-identically, as do malformed JSON, both bad-version forms, in-flight cancellation for ordinary ids, and unknown methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jq's `//` operator substitutes for `false` as well as for `null` and for an absent key, so `jq -c '.id // null'` collapsed three message classes onto one value and the notification gate treated all of them as notifications. A request carrying `"id": null` was never answered and its client waited on a reply that could not arrive. The id is now read by key presence and carried with `tojson`, which keeps a present id's JSON type and leaves an absent key as the empty sentinel the notification gate reads.

An id that is present but is not a string or an integer is answered `-32600` with a null response id. For `true`, `false`, an object and an array this is the only legal output rather than a strictness preference: a response must carry its request's own id, and a response id may only be a String, Number or Null, so no valid success response for such a request can be built. A fractional id is rejected by the same gate, which restates `validate_tool_arguments`' integer test. A bare `floor` comparison cannot see a fraction at or above 2^52, where the double spacing reaches 1.

Two line shapes ended the server outright. A line holding more than one JSON document passed the old `jq -e '.'` gate, which reports only its last output, and the id extraction then emitted one line per document. A document that was valid JSON but not an object made every field extraction yield an empty string. Both reached `--argjson` with an id it could not parse, and under `set -euo pipefail` that status propagated out of `process_request` into `run_mcp_server`'s plain `response=$(process_request "$line")` assignment. A client that omits one trailing newline produces the first shape. The parse gate now admits exactly one JSON document, and an object gate runs before any field is read, so the extractions cannot fail and the fix no longer depends on the dispatch site clearing errexit.

A `notifications/cancelled` that omits `params.requestId` cancelled an in-flight call whose id was `null`, because jq reads the absent key as `null` and `null == null` holds. The matcher now requires the key to be present.

BREAKING CHANGE: a request whose `id` is not a string or an integer now returns `-32600` where it previously received a normal result (`true`, an object, an array, a fractional number) or no response at all (`null`, `false`). A line holding more than one JSON document, or one document that is not a JSON object, is answered instead of ending the server, and a bare `null` or `false` document answers `-32600` where it previously answered `-32700`. A bad-`jsonrpc` message carrying an invalid id answers a null response id instead of echoing that id. Check that clients send one JSON object per line and a string or integer `id` before bumping the pin.

Co-Authored-By: Claude <noreply@anthropic.com>
@SpiGAndromeda
Martin Bens (SpiGAndromeda) merged commit b59df2c into main Sep 12, 2026
3 checks passed
@SpiGAndromeda
Martin Bens (SpiGAndromeda) deleted the fix/request-envelope-validation branch September 13, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a request with "id": null is treated as a notification and never answered

1 participant