Skip to content

fix(mcp): apr mcp dropped every tool result on stdin EOF, and one bad byte killed the server - #2434

Closed
noahgift wants to merge 1 commit into
mainfrom
fix/mcp-protocol-transport-conformance
Closed

fix(mcp): apr mcp dropped every tool result on stdin EOF, and one bad byte killed the server#2434
noahgift wants to merge 1 commit into
mainfrom
fix/mcp-protocol-transport-conformance

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

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, 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 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.

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. 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

Finding Before After
initialize negotiation -32602 on anything but the literal 2024-11-05 (including older versions — string inequality, not a floor check) success carrying 2024-11-05, per the MCP lifecycle
ping -32601 Method not found {"result":{}}
Missing jsonrpc/method -32700 with id:null (while a wrong jsonrpc value was already correctly -32600 with id echoed) -32600 with id echoed; malformed JSON stays -32700
Batch array serde's invalid type: map, expected a string at line 1 column 1 -32600 naming batching
Wrong-type argument Missing required argument: model_path for an argument that WAS sent Argument model_path must be a string, got number

The negotiation defect barred every client this server advertises itself to: Claude Code and Cursor propose 2025-03-26 / 2025-06-18, and README.md:25 names them.

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.

Why the loop was made generic

run_stdio is now a two-line binding over a new serve_stream<R, W>. 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"

At the binary level the reverted build reproduced the issue verbatim, including tools/call response (id=2) was DROPPED on stdin EOF and rc=1 stream 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.
  • 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 / finetune.rs wrong-type tests asserted only is_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.yaml gains FALSIFY-MCP-010 through -014; the loader test's hardcoded nine-gate count becomes a named constant. pv validate clean.

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

… 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>
@noahgift

Copy link
Copy Markdown
Contributor Author

Temporarily closing to stop CI contention — the branch is untouched and this will be reopened, nothing is lost.

The shared clean-room runner host is at 91% disk with a load average over 100, and workspace-test has started timing out at 75 minutes on unrelated PRs (#2385) purely from contention. This audit has 23 PRs open at once and drafts still trigger full CI in this repo, so parking them as drafts did not reduce load.

Reopening in batches as the merge queue drains. The work is complete and reviewed; only the CI scheduling is being paced.

@noahgift

Copy link
Copy Markdown
Contributor Author

Superseded by #2449 — this branch is merged verbatim into that batch.

The binding constraint was one ~50-minute workspace-test per PR on one shared box; nine concurrent PRs starved each other into 75-minute step timeouts (every blocked PR this morning classified as CONTENTION, zero merges in 4.5 hours). #2449 lands 24 branches in a single CI run.

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.

@noahgift noahgift closed this Aug 11, 2026
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.

MCP protocol layer: 7 defects incl. responses dropped on stdin EOF and one bad UTF-8 byte killing the server

1 participant