Repository navigation
fix(mcpl,api): read a JSON-RPC envelope once: answer id null, refuse an invalid request with -32600 - #534
Open
nissa-seru wants to merge 1 commit into
Open
fix(mcpl,api): read a JSON-RPC envelope once: answer id null, refuse an invalid request with -32600#534nissa-seru wants to merge 1 commit into
nissa-seru wants to merge 1 commit into
Conversation
…an invalid request with -32600 Fixes anima-research#475. The MCPL connection told a server's request from a notification by `id != null`, so a request with "id": null was dispatched with no responder and never answered, a request whose id was an object, an array or a boolean was dispatched, and an object id was echoed back in the answer. The MCP server (src/api/mcp-server-core.ts) already answered id null, but echoed an invalid id too, answered a non-string method -32601, answered a response that carried an id as if it were a request, and answered a request named "initialized" with nothing. JSON-RPC 2.0 (§4, §4.1, §5) and MCPL §4.1 define a notification as a request without an id member, and a request's id as a string, a number or null. src/jsonrpc-envelope.ts reads a parsed message's envelope, once, for both endpoints: - A message with an id member and no method is a response, whatever the id's value. A peer answers a request it couldn't read with id null, and taking that answer for a request would answer it in turn. So is an error with no id, which MCP 2025-11-25 lets a peer send for a request it couldn't read, but only in a message whose jsonrpc member is "2.0", since a server's own JSON log lines can carry an error key. Any other object with neither a method nor an id isn't JSON-RPC. - A message with a method is a request when it has an id member, so "id": null is a request, and a notification otherwise. - A message whose method isn't a string, or whose id isn't a string, a number or null, isn't a Request object, and so isn't a notification either, with or without an id: it's answered once with -32600 "Invalid Request: <what was wrong>", carrying its id where that's a string or a number and null otherwise, and isn't dispatched. The spec's own example of an invalid Request object has no id and is answered so. - jsonKind names a value's kind ("an object", "null") for those texts, so an invalid value is never repeated back. It replaces describeMethod. The MCPL connection: - Dispatches on the envelope: an id-null request with responders that answer id null, -32600 and a stderr line (naming the method when that's a string) for an invalid request, and nothing for an object that's neither, as before, like the server's other unrelated output on stdout. Until now a non-string method got -32601 (anima-research#208); its test of that case now leaves it to the envelope. - Answers MCP's ping (anima-research#392) by the same rule: a ping with id null gets its empty result with id null, on a connected transport and during the initialize handshake. During the handshake anything else, an invalid request included, is still ignored, as anima-research#392 has it. - Logs a response whose id is neither a string nor a number, or that has none, and ignores it: the host's own request ids are numbers, so it can't be matched to one (unmatchedResponse). A server sends an error with id null, or under MCP with no id, for a request it couldn't read, so once connected it may be meant for one of the host's requests, which gets nothing from it and ends as any unanswered request does, by its timeout where it has one. Its error member is printed as sent, through logValue, since it's the server's text. It isn't surfaced as an orphaned response, which is a late answer to a request the host can name. - During the initialize handshake, initialize is the only request the host has out and a peer never answers a response, so a JSON-RPC 2.0 error with id null or no id is initialize's answer: the connect fails at once with the server's error, where it waited out the 30 s timeout. Requiring "jsonrpc": "2.0" keeps a server's own JSON log line with an id and an error key from failing it. Any other response the handshake can't match is logged, as on a connected transport. - The id types include null where a request's id travels: JsonRpcRequest and JsonRpcResponse, sendResponse and sendErrorResponse, the framework's InboundResponder and its inference-request responder, and the inference router's Responder, which streams only when the request id isn't null, so a streaming inference/request with id null is answered with its whole result. The MCP server: - Answers an invalid request -32600 by the same rule, and logs a response, an error with no id included, its id and error member printed as sent through logValue, and never answers it, since it sends its client no requests. - Answers a JSON value that isn't an object with -32600 and id null, and a batch with one that says this server doesn't accept batches: a number, a string or null got -32700 "Parse error", though it parsed, and an array got no answer. The MCPL connection still ignores a non-object, since a server's stdout can carry unrelated output. - Drops handleRequest's 'initialized' case, which only a request could reach, since MCP's acknowledgment is the notifications/initialized notification: a request named initialized now gets -32601, where it got no answer. Tests: - test/mcpl-request-envelope.test.ts drives the real connection routing and response encoding with a fake transport, and stdio servers for the handshake: an id-null request answered with id null (dispatched with a responder, and to each removed method), held while the planes are paused and answered when they open, and refused with id null when the grant refuses its event; a notification still dispatched with no responder (control); an object, array or boolean id answered -32600 with id null and not dispatched; a non-string method answered -32600 with the request's id where valid, and with id null when there's no id (the spec's own example); a null-id error response and an object-id response with state each logged, never answered and not surfaced as orphans, beside a number id that still is (control), and an error with no id logged as one; an error with id null or no id while a host request is pending leaving it pending until its own answer settles it; during the handshake, an error with id null, and one with no id, failing the connect at once with the server's error, and otherwise a numbered response that isn't initialize's and a log line with an error key but neither an id nor jsonrpc ignored, a null-id result, two log lines with id null and an error key but no jsonrpc, and an object-id response with a bare-string error logged, and the initialize answer that follows taken; and an object with neither a method nor an id, a log line with an error key among them, and a JSON value that isn't an object, a batch included, each ignored with no log (controls). - test/mcp-server-envelope.test.ts feeds the MCP server's input handler lines as its stdin reader does: an id-null ping answered with id null (control: it already was), invalid ids and methods answered -32600 and not handled, the spec's invalid Request object with no id answered -32600 with id null, an error response with id null or no id, a numbered response, an object-id response with a bare-string error, and a response whose string id and error could pose as log fields each logged, escaped, and not answered, non-object JSON values answered -32600 with id null and a batch refused as one, a request named initialized answered -32601, and controls for a parse error, a notification, an object that's neither, an error without jsonrpc, and an unknown method. - test/mcpl-ping.test.ts: while the planes are paused, a ping with id null answered with id null at once and one with an object id answered -32600, nothing buffered; during the handshake, a ping with id null answered with id null and one with an object id ignored, not echoed. - test/mcpl-inference-router.test.ts: a streamed inference/request with id null answered whole, with no stream opened and no chunks. - test/mcpl-request-handler-errors.test.ts: the -32601 test takes an id-null request for an unhandled method in place of its two non-string methods, and the failure-trace test's id that is neither a string nor a number is null, the one such id a request can now carry. - test/mcp-server-bin.test.ts puts its bundle copy of the server's module at the module's own depth in dist/src/, so the copy resolves the module's new relative imports as the module does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryShares JSON-RPC message checks between the MCPL connection and MCP server. Requests with
No actionable issues found. This review checked source and tests; it did not run tests. Reviews (1) · Last reviewed commit: "fix(mcpl,api): read a JSON-RPC envelope ..." · Reviewed by Greptile |
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #475.
The host now answers an MCPL server's request that has
"id": null, and its answer carriesid: null. Until now the host took such a request for a notification and never answered it. A request whoseidisn't a string, a number or null, or whosemethodisn't a string, now gets one-32600"Invalid Request", and the host doesn't dispatch it. A message with anidand nomethodis a response whatever the id's value, and so is a JSON-RPC 2.0 error with noid, as MCP allows, so the host never answers either. During the initialize handshake, a JSON-RPC 2.0 error that names no request isinitialize's answer, and the connect fails at once with it. The framework's MCP server is the other endpoint that answers JSON-RPC requests. It now reads a message's envelope by the same rule, through one shared check.Problem
McplServerConnectiontold a server's request from a notification byid != null. Onmain(e5f50bf), that's the response check atsrc/mcpl/server-connection.ts:1489, the dispatch at lines 1502 to 1563, and the handshake's ping at line 741. That gave two wrong answers:"id": nullwas dispatched as a notification, with no responder, so it was never answered. JSON-RPC 2.0 (§4.1) and MCPL §4.1 define a notification as a request without anidmember.idwas an object, an array or a boolean was dispatched, and the answer to an objectidcarried that object back. JSON-RPC 2.0 (§4) allows a string, a number or null, and an invalid request gets-32600withid: null(§5).MCP's own spec is stricter than JSON-RPC here: a request's ID "MUST NOT be
null" (2025-11-25, Basic). The host answers one anyway, as JSON-RPC allows and as the framework's MCP server already did, since a peer that sends one is waiting for its answer.I probed this through the real connection routing, under Bun 1.4.3, on
main(e5f50bf). A stdio server sends the host threescope/elevaterequests, which the host answers-32601whatever its grants. The one with id7is answered. The one with idnullgets nothing. The one with id{"nested":[1]}is answered with that object as itsid.src/api/mcp-server-core.ts, the MCP server, already answered"id": null. But it also echoed an invalididback. It answered a non-stringmethodwith -32601 "Method not found", and it answered a response that carried anidwith -32601, as if it were a request. A request namedinitializedgot no answer at all.Changes
src/jsonrpc-envelope.tsreads a parsed message's envelope once, for both endpoints:idmember and nomethodis a response, whatever the id's value. So is an error with noid, which MCP lets a peer send for a request it couldn't read, but only in a message whosejsonrpcmember is"2.0", since a server's own JSON log lines can carry anerrorkey. Any other object with neither amethodnor anidisn't JSON-RPC.methodis a request when it has anidmember, so"id": nullis a request. Without anidmember, it's a notification.methodisn't a string, or whoseidisn't a string, a number or null, isn't a valid request. It's answered once with-32600and isn't dispatched. The answer's message says what was wrong, such asInvalid Request: the id must be a string, a number or null, not an object. It carries the request'sidwhere that's a string or a number, andnullotherwise.id. A notification is a valid request without anid(§4.1), so the rule against answering notifications doesn't cover it, and JSON-RPC 2.0's own example of an invalid request,{"jsonrpc": "2.0", "method": 1, "params": "bar"}, is answered-32600withid: null.The MCPL connection dispatches on the envelope:
id: null.-32600answer and a stderr line, which names the method when that's a string. Onmain, a request whosemethodisn't a string got -32601 "Method not found" (fix(mcpl): contain inbound request handler failures #208), and one without anidwas ignored.methodnor anidis ignored, as before, like a server's other unrelated output on stdout.idis neither a string nor a number, or that has none, can't be matched to a request of the host's, whose ids are numbers. It's logged with its error member, if it has one, and ignored, and it's no longer surfaced as an orphaned response. A server sends an error withid: null, or under MCP with noid, for a request it couldn't read. Once connected, such an error may be meant for one of the host's requests, which gets nothing from it and ends as any unanswered request does, by its timeout where it has one.initializeis the only request the host has out, and a peer never answers a response. So a JSON-RPC 2.0 error withid: nullor noidthen isinitialize's answer, and the connect fails at once with the server's error. Any other response the handshake can't match is logged, as on a connected transport, a server's own log line with anidand anerrorkey included.id: nullgets its empty result withid: null, on a connected transport and during the initialize handshake. During the handshake, every other request and notification is still ignored, an invalid request included, as The host doesn't answer MCP ping from an MCPL server #392 has it.The MCP server answers an invalid request with
-32600by the same rule.-32600andid: null. For an array, JSON-RPC's batch, the answer says this server doesn't accept batches. Until now, a number, a string ornullgot -32700 "Parse error", though it parsed, and an array got no answer.initializednow gets -32601 "Method not found", as any unknown method does. MCP's acknowledgment is thenotifications/initializednotification, which never reached the request handler, so the handler'sinitializedcase only ever met a request, and answered nothing.The id types now include
nullwherever a request's id travels. These are:JsonRpcRequestandJsonRpcResponse, neither of which is exported from the package root;InboundResponderand itsinference/requestresponder;InferenceRouter'sResponder.The router already streams only for a request id that isn't null. So a streaming
inference/requestwithid: nullgets its whole result in one answer.Why each part
idwasn't null. Two copies of the rule were the alternative.id: null(§5). Telling requests by theidmember alone would take that answer for a request and answer it. Two peers that both answer what they can't read would then trade errors.handleResponse. The host's own request ids are numbers. Without the guard, a response whoseidis an object and which carriesstateis surfaced as an orphaned response, as it is onmain. An orphan means a late answer to a request the host can name, and such a response can't be one.initializeanswers it withid: null, or under MCP with noid, and then the connection can't work. Until now the handshake dropped that answer, and the connect failed only at its 30 s timeout, without the server's error. Now it fails at once, in the server's words. It takes only a JSON-RPC 2.0 error that way, so a server's own JSON log line, which an MCPL server's stdout can carry with anidand anerrorkey, can't fail the connect.id. MCP 2025-11-25 lets a peer answer a request it couldn't read with noidat all (Basic, Error Responses), where JSON-RPC 2.0 hasid: null. Without this, a server that follows MCP's newest form and can't readinitializewould still leave the connect to its timeout. Requiring"jsonrpc": "2.0"keeps such log lines out of the envelope's responses.-32600, and of a response that's dropped. Each prints a response's error member as sent, escaped aslogValueescapes a peer's text, so one that isn't the usual{code, message}still reads as what was sent, and none can pose as a log field.id: null, type-checks without casts.describeMethod, whose non-string case now belongs to the envelope, and the MCP server'sinitializedcase.What callers and servers will notice
"id": nullnow gets an answer, withid: null. One that sends an invalididormethodgets-32600saying what was wrong, with or without anid. The host never answers a response.McpServer, which theagent-framework-mcpcommand runs, gets-32600for an invalid request or for a value that isn't an object, and -32601 for a request namedinitialized. It gets no answer to a response.initialize, and answers it with a JSON-RPC 2.0 error withid: nullor noid, gets the connect's rejection at once,MCPL server "<id>" initialize error: <the server's message>, where it got a timeout 30 s later.[mcpl] <server>: answered -32600 to an invalid request …) and for each response whoseidis neither a string nor a number, or that has none (… which can't be matched to a request of ours …). The MCP server logs each response it gets, prefixed[agent-framework-mcp].McplServerConnection's request events can now be handed a responder whoseidisnull. Aninference/requesthandler can be handed arequestIdofnull.idand a stringmethod, notifications with a stringmethod, and responses to its own requests as before.Merge order
This builds on
mainate5f50bf. I merged it with each of the 55 other open pull requests, as of 17:27 UTC on October 18, bygit merge-tree, withmainmerged with each as the control. It adds conflicts with two of them, both insrc/mcpl/server-connection.ts:requestIdand lets the reply helpers'idbenull. The resolution keeps both: the responders passrequestIdand the transport's epoch, andsameTransporttakes anullid too. feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250'sHeldItemgoes besideunmatchedResponse, anddescribeMethodgoes, since feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250 calls it only in the-32601log line this change rewrites.protocol-family.jsbeside this change's import ofjsonrpc-envelope.js. The resolution keeps both.With every other pull request, the merge conflicts in the same files, with as many conflicting hunks, as
main's merge does, or merges cleanly as it does.Tests
test/mcpl-request-envelope.test.ts(new, 14 tests) drives the real connection routing and response encoding with a stand-in transport, and stdio servers for the handshake. It checks:"id": nullis dispatched with a responder and answered withid: null, and so is a request for each removed method;"id": nullheld while the planes are paused is answered withid: nullwhen they open, and one for an event the grant refuses gets the refusal withid: null;idmember is still a notification, dispatched with no responder (a control);idgets one-32600withid: null, and nothing is dispatched;methodgets-32600, with the request'sidwhere that's a string or a number andnullotherwise. When both are wrong, the message names both faults;methodand noid, is answered-32600withid: null;id: nullor with noid, and a response with an objectidandstate, are each logged, never answered and not surfaced as orphans. A numberidstill is surfaced (a control);id: nullor noidwhile a request of the host's is pending leaves that request pending, and its own answer still settles it;id: null, and one with noid, answersinitialize, so the connect fails at once with the server's error. A numbered response that isn't initialize's is ignored, and so is a log line with anerrorkey and neither anidnorjsonrpc. A null-id result, two log lines withid: nulland anerrorkey but nojsonrpc, and an object-id response with a bare-string error are logged, and theinitializeanswer that follows is taken;methodnor anid, a log line with anerrorkey and nojsonrpcamong them, is ignored, with no log, and so is a JSON value that isn't an object, a batch included (two controls).test/mcp-server-envelope.test.ts(new, 8 tests) feeds the MCP server's input handler lines, as its stdin reader does. It checks:pingwithid: nullis answered withid: null(a control: it already was);-32600and not handled;id, is answered-32600withid: null;id: nullor noid, a numbered response, an object-id response whose error is a bare string, and a response whose string id and error could pose as log fields are each logged, escaped, and not answered;-32600withid: null, and a batch's answer says batches aren't accepted;initializedgets -32601, and thenotifications/initializednotification nothing;errorwithoutjsonrpc, and an unknown method.test/mcpl-request-handler-errors.test.ts.-32601test now takes a request withid: nullfor an unhandled method, in place of its two non-string methods. The new file pins those as-32600.null, the one such id a request can now carry.test/mcpl-ping.test.ts. While the planes are paused, a ping withid: nullis answered withid: nullat once, and one with an objectidgets-32600. During the handshake, a ping withid: nullis answered withid: null, and one with an objectidis ignored, not echoed.test/mcpl-inference-router.test.ts. A streamedinference/requestwithid: nullis answered whole, with no stream opened and no chunks.test/mcp-server-bin.test.tsputs its bundle copy of the server's module at the module's own depth indist/src/. The copy then resolves the module's new relative imports the way the module does.main's source (e5f50bf) and the six new and changed test files, 21 of those files' 159 tests fail. They are the new files' tests of an id-null request (answered, held and flushed, and refused), an error that names no request beside a pending request, an invalididormethod(with and without anid), a response, a value that isn't an object, the handshake's failure on an error that names no request (in both forms) and its log, and the request namedinitialized; the two turned-round tests inmcpl-request-handler-errors; and the two new ping tests. The other 138 pass, the controls among them. They include the router's test, sincemain's router already answers a null id whole, which that test pins. Againstmain'sRespondertype, which doesn't allownull, that test has one type error, andtscstill emits it.tscemits. Every one fails a test aimed at it. The pieces are:idmember, themethodcheck, theidcheck, carrying a valididin the-32600, answering an invalid message that has noid, passing the method on for the log,jsonKind's article, taking an error with noidas a response, and taking it only with"jsonrpc": "2.0";-32600answer, its log line and that line's method, the response-id guard, the responder'sid: null, the handshake's ping forid: null, its not echoing an invalidid, the connected ping forid: null, the handshake's log of a response that can't be matched, the error member in that log and its escaping, the handshake's taking an error that names no request asinitialize's answer, its taking one with noidso, its taking only an error so, its taking only a JSON-RPC 2.0 one, and the log's words for a response with noid;-32600answer, the response branch, the error member in its log, the escaping of that member and of a response's stringid, the log's words for a response with noid, and the droppedinitializedcase;npm install, typecheck, build andnpm test, as CI runs them, on Linux with Node 24.22.0. At the branch (72dc84a), 4055 of 4072 tests pass, none fail, and 17 are skipped. Onmain(e5f50bf), run the same way, 4030 of 4047 pass, none fail, and the same 17 are skipped, since this disk tells case and Unicode forms apart. The difference is exactly the 25 new tests: 14 and 8 in the new files, 2 inmcpl-pingand 1 inmcpl-inference-router. The six changed files also pass under Node 20.20.2, 159 of 159.7-32601, idnull-32601 withid: null, and the object id-32600withid: null. The same probe of the MCP server's input handler gets a result withid: nullforinitializewithid: null,-32600withid: nullfor an objectid, and-32600withid: 9for"method": 7.Checks
CI passes at
72dc84a: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39151377641 and 39151377744).Not verified
mcpl-core-ts, neither sends a request withid: null, an invalididor a non-stringmethod. So what they send takes the paths it took before.Out of scope
paramsisn't checked. §4.2 asks for an object or an array, and each method's handler reads its own params, as before.jsonrpcmember isn't checked, as before, except to tell a JSON-RPC error that names no request from a server's own log line.-32600saying it doesn't accept batches.idmatches no pending request of the host's is handled as before.mcpl:request-handler-errortrace's contract says. So a failed request withid: nullis traced and logged without its id.initialize, is read as before. Those paths readcodeandmessagefrom it, so a request's log line readswith error undefined: undefined, its rejection says the server answered "without saying what went wrong", and aninitializeanswered that way fails withinitialize error: undefined. A probe under Bun shows the same atmainand at the branch. The handshake's new failure on a JSON-RPC 2.0 error that names no request reads its error member the same way. Only the log of a response that can't be matched prints the member as sent.JSON.parsecan't hold exactly is answered asJSON.parseread it, as every value the connection reads is. An integer past 2^53 comes back rounded, and1e400, read asInfinity, comes back asnull, since JSON writesInfinitythat way.changelog.d/475-mcpl-request-ids.fixed.mdWritten by Byron-2452 (Claude Opus 5.5), contributing through nissa-seru, with its design settled with Ada-1017 (Claude Opus 5.5). Our internal review read it, and this head takes its fixes. Desmond-2579 (Claude Opus 5.5) and Ogden-2688 (Claude Opus 5.5) reviewed it, the later handshake changes included: Ogden-2688 proposed failing the handshake at once, Desmond-2579 found MCP's error with no
id, and both found that the handshake had to require"jsonrpc": "2.0"too.Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.