fix(mcp): apr mcp dropped every tool result on stdin EOF, and one bad byte killed the server - #2434
fix(mcp): apr mcp dropped every tool result on stdin EOF, and one bad byte killed the server#2434noahgift wants to merge 1 commit into
Conversation
… byte killed the server
Driving the shipped 0.63.0 `apr mcp` over stdio, the canonical scripted
invocation loses its answer:
printf '<initialize>\n<tools/call apr.version>\n' | apr mcp
-> exit 0, ONE response line (id=1), the id=2 tool result never written
3/3 deterministic, stderr empty. `tools/call` runs on a worker thread that
writes its own response (server.rs spawn_tools_call_worker), while
initialize/tools/list answer inline on the read loop. The loop returned the
instant stdin closed without joining those workers, so the process exited
while a worker still owed the client a reply. Exit 0 with no output is
indistinguishable, to a client, from a tool that produced nothing — the
failure mode is silent. Holding stdin open was the only working client
pattern and was documented nowhere.
Second transport defect, same loop: a single invalid UTF-8 byte on stdin
terminated the whole server.
rc=1, "error: Aprender error: mcp server: stream did not contain valid UTF-8"
Every request after the bad byte was lost. `BufRead::lines()` surfaces a bad
byte as an io::Error, and `let line = line?` propagated it out of the loop.
The server already handled well-formed-UTF-8 malformed JSON correctly
(-32700, keep serving), so it disagreed with itself about what a malformed
message costs.
Both now: lines are read as BYTES and decoded per line, so a bad line is a
malformed MESSAGE answered with -32700; and EOF joins every in-flight worker
before returning.
Four protocol-layer defects alongside them:
* `initialize` hard-errored -32602 on any protocolVersion other than the
exact literal "2024-11-05" — including OLDER dated versions, so it was not
a floor check but string inequality. The MCP lifecycle makes negotiation a
proposal: a server answers with a version it supports and lets the client
decide. Claude Code and Cursor propose 2025-03-26 / 2025-06-18, so every
client this server advertises itself to (README.md:25) could never connect.
* `ping` returned -32601. It is base protocol, not an advertised capability;
a keepalive client reads the error as a dead server and restarts it.
* Valid JSON missing `jsonrpc` or `method` was reported -32700 Parse error
with id:null, losing the id the client needs to correlate — while a WRONG
`jsonrpc` VALUE was already correctly -32600 with the id echoed. Now both
are -32600 with the id echoed; genuinely malformed JSON stays -32700.
* A JSON-RPC batch array leaked serde's "invalid type: map, expected a
string at line 1 column 1", naming neither batching nor arrays and
pointing at a '[' that is valid JSON. Now declined by name.
And one that made clients loop: a required argument supplied with the wrong
JSON type reported "Missing required argument: model_path" for an argument
the client had plainly sent. Absent, empty and wrong-type were byte-identical,
so an LLM told the argument was missing retries by adding a key it already
sent. `tools::args::require_str` now keeps the two apart, applied at all 8
subprocess wrappers.
Root causes, all in crates/aprender-mcp/src/server.rs at 0.63.0: the read
loop (`for line in stdin.lock().lines()` / `let line = line?`, no worker
join on EOF), handle_initialize's -32602 early return, the absent `ping`
arm, and `serde_json::from_str::<JsonRpcRequest>` mapping a missing field to
a parse error.
run_stdio is now a two-line binding over a new generic `serve_stream<R, W>`.
That is not cosmetic: FALSIFY-MCP-010 and -011 live in the read loop, not in
request handling, so no `handle_request` test can see either — and invalid
UTF-8 cannot even be expressed as a `&str` input. CI's workspace-test runs
`--lib` plus an explicit allowlist that does not include this crate's
tests/ directory, so an integration-only guard for a P0 would never run.
Making the loop generic puts six falsifiers for it in the `--lib` target CI
actually executes.
MUTATION CHECK. Reverted each fix, kept the tests, rebuilt the binary so the
harness could not reuse a fixed one:
serve_stream_answers_tools_call_before_returning_on_eof
tools/call response (id=2) was DROPPED at EOF; got 1 response(s): [...]
serve_stream_answers_every_pipelined_tools_call
id=2 unanswered; got 1 of 6
serve_stream_survives_invalid_utf8_line
serve_stream must not propagate an error out of the session:
stream did not contain valid UTF-8: invalid utf-8 sequence of 1 bytes
falsify_mcp_007_protocol_version_mismatch_negotiates_down
proposing 1999-01-01 must not abort the handshake, got:
Some(JsonRpcError { code: -32602, ... })
wrong_type_names_the_type_and_never_says_missing
left: "Missing required argument: model_path"
right: "Argument model_path must be a string, got number"
Two tests did NOT survive that check and were fixed rather than shipped:
* falsify_m1's FALSIFY-MCP-007 asserted the defect — it required -32602 on
any mismatch, so the gate was actively holding the handshake broken.
Rewritten with its contract entry.
* A tools/call "ordering variant" in the new integration file PASSED against
the unfixed binary: serializing the large tools/list response gives the
worker time to finish before EOF, so it could never observe the drop it
claimed to guard. Removed — a test that stays green on the defect is
theater.
validate.rs's nonstring_model_path_returns_error asserted only
`is_error == Some(true)`, which is shape; it passed while the message
contradicted the request. Strengthened to assert the text.
Contract gains FALSIFY-MCP-010 through -014 (apr-mcp-server-v1.yaml), and
the loader test's hardcoded nine-gate count becomes a named constant.
Verified end-to-end against a binary built from this tree, using the issue's
own repros: all four proposed protocolVersions negotiate to 2024-11-05, ping
pongs, the two missing-field cases are -32600 with id echoed, the batch array
is named, the wrong-type argument names the type, the tools/call result
arrives 3/3 on EOF, and the bad-byte stream answers ids 1 and 2 plus a
-32700 with rc=0.
Fixes #2393
Audit epic: #2373
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Temporarily closing to stop CI contention — the branch is untouched and this will be reopened, nothing is lost. The shared Reopening in batches as the merge queue drains. The work is complete and reviewed; only the CI scheduling is being paced. |
|
Superseded by #2449 — this branch is merged verbatim into that batch. The binding constraint was one ~50-minute Closing rather than leaving open so this PR cannot move #2449's base and force it to re-run. The branch is untouched and this is reopenable if the batch does not land. |
Driving the shipped 0.63.0
apr mcpover stdio, the canonical scripted invocation loses its answer:3/3 deterministic, stderr empty.
tools/callruns on a worker thread that writes its own response, whileinitialize/tools/listanswer inline on the read loop. The loop returned the instant stdin closed without joining those workers, so the process exited while a worker still owed the client a reply. Exit 0 with no output is indistinguishable, to a client, from a tool that produced nothing — the failure is silent. Holding stdin open was the only working client pattern and was documented nowhere.Same loop, second defect: one invalid UTF-8 byte terminated the whole server.
Every request after the bad byte was lost.
BufRead::lines()surfaces a bad byte as anio::Errorandlet line = line?propagated it out. The server already handled well-formed-UTF-8 malformed JSON correctly (-32700, keep serving), so it disagreed with itself about what a malformed message costs.Both now: lines are read as BYTES and decoded per line, so a bad line is a malformed message answered with -32700; and EOF joins every in-flight worker before returning.
The other five
initializenegotiation2024-11-05(including older versions — string inequality, not a floor check)2024-11-05, per the MCP lifecycleping{"result":{}}jsonrpc/methodid:null(while a wrongjsonrpcvalue was already correctly -32600 with id echoed)invalid type: map, expected a string at line 1 column 1Missing required argument: model_pathfor an argument that WAS sentArgument model_path must be a string, got numberThe negotiation defect barred every client this server advertises itself to: Claude Code and Cursor propose 2025-03-26 / 2025-06-18, and
README.md:25names them.Root causes
All in
crates/aprender-mcp/src/server.rsat 0.63.0: the read loop (for line in stdin.lock().lines()/let line = line?, no worker join on EOF),handle_initialize's -32602 early return, the absentpingarm, andserde_json::from_str::<JsonRpcRequest>mapping a missing field to a parse error.Why the loop was made generic
run_stdiois now a two-line binding over a newserve_stream<R, W>. Not cosmetic: FALSIFY-MCP-010 and -011 live in the read loop, not in request handling, so nohandle_requesttest can see either — and invalid UTF-8 cannot even be expressed as a&strinput. CI'sworkspace-testruns--libplus an explicit allowlist that does not include this crate'stests/directory, so an integration-only guard for a P0 would never run. Making the loop generic puts six falsifiers for it in the--libtarget CI actually executes.Mutation check
Reverted each fix, kept the tests, rebuilt the binary so the harness could not reuse a fixed one:
At the binary level the reverted build reproduced the issue verbatim, including
tools/call response (id=2) was DROPPED on stdin EOFand rc=1stream did not contain valid UTF-8.Two tests did not survive that check
falsify_m1's FALSIFY-MCP-007 asserted the defect — it required -32602 on any mismatch, so the gate was actively holding the handshake broken. Rewritten together with its contract entry.tools/call"ordering variant" in the new integration file PASSED against the unfixed binary: serializing the largetools/listresponse gives the worker time to finish before EOF, so it could never observe the drop it claimed to guard. Removed — a test that stays green on the defect is theater.validate.rs/finetune.rswrong-type tests asserted onlyis_error == Some(true), which is shape; they passed while the message contradicted the request. Strengthened to assert the text.Contract
contracts/apr-mcp-server-v1.yamlgains FALSIFY-MCP-010 through -014; the loader test's hardcoded nine-gate count becomes a named constant.pv validateclean.End-to-end verification
Against a binary built from this tree, using the issue's own repros: all four proposed protocolVersions negotiate to 2024-11-05, ping pongs, both missing-field cases are -32600 with id echoed, the batch array is named, the wrong-type argument names the type, the tools/call result arrives 3/3 on EOF, and the bad-byte stream answers ids 1 and 2 plus a -32700 with rc=0.
Note: the crates.io 0.63.0 reference binary was never present at the path the task specified, so the "before" side is the issue's recorded evidence plus a locally rebuilt reverted binary, which reproduced every finding.
Fixes #2393
Audit epic: #2373