Skip to content

feat(mcp): S2 — hand-rolled stdio client (JSON-RPC) + lifecycle#30

Merged
ndemianc merged 3 commits into
developfrom
feat/mcp-s2-client
Jul 23, 2026
Merged

feat(mcp): S2 — hand-rolled stdio client (JSON-RPC) + lifecycle#30
ndemianc merged 3 commits into
developfrom
feat/mcp-s2-client

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

Second MCP slice (docs/MCP.md). Stacked on #29 — the diff here is S2 only. Still wired to nothing; S3 is the router line in agent.js.

Pure / IO split

Following the codebase's own pattern (sse.js thin IO, translate.js pure), the parts most likely to be wrong are testable without spawning anything:

mcpProtocol.js (pure) — framing + result flattening. Both are easy to get wrong:

  • A pipe doesn't respect message boundaries, so a framer that assumes one chunk = one message works right until a big tools/list arrives. And a stray non-JSON log line on stdout — a well-known MCP footgun — must be skipped, not desync the stream.
  • A tool result is typed content blocks, but tool_result is a string. Non-text degrades to a placeholder (never raw base64 into the transcript), failures take the agent's ERROR: prefix, and the result is capped — the generic tool path bounds nothing.

mcpClient.js (IO) — spawn, handshake, request/response, teardown.

Four things it owes the agent

Why
Per-call timeout Tools run sequentially in agent.js — one wedged server would hang the entire run
Capped, flattened string tool_result is text
call() never throws A failure returns ERROR: …, so one bad server can't break the turn loop
reapMcp() A detached child outlives the window otherwise — the same reason reapCommands() exists

Security choices

  • shell:false — args are passed as argv, never re-parsed by a shell.
  • Server→client requests are REFUSED, not ignored — a server cannot drive our model (sampling/*) or prompt the user (elicitation/*). It gets a clean JSON-RPC error instead of hanging.
  • This module deliberately does not decide whether a server may start — that gate is S4.

Tested against a real subprocess, not mocks

test/fixtures/mock-mcp-server.js speaks just enough protocol to drive the client, with tools that succeed, fail, and never answer, plus --noise / --crash / --sample modes. So the handshake, framing over a live pipe, the timeout, the sampling refusal, and process teardown are genuinely exercised.

Verification

  • 15 protocol + 13 client tests pass; full CI gate: 18 extension suites, 0 failures
  • Requires are child_process + the pure module only — no vscode
  • ps is clean after the run: dispose() and reapMcp() really do kill the process group, asserted in-test by polling the pid past the SIGTERM→SIGKILL grace

Next

S3 wires it in — TOOLS/TOOLS_TOKENS_EST become per-run, and the router goes immediately before the unknown tool fallthrough at agent.js:442. Then S4 (trust-on-first-use + the approval card), which must not be skipped.

🤖 Generated with Claude Code

ndemianc and others added 3 commits July 22, 2026 23:17
Second slice (docs/MCP.md). Still wired to nothing: S3 is the router line in
agent.js. Split pure/IO the way the codebase already does (sse.js thin IO,
translate.js pure), so the parts most likely to be wrong are testable without
spawning:

- mcpProtocol.js (pure) — newline-delimited JSON-RPC framing and tool-result
  flattening. Both are genuinely easy to get wrong: a pipe does not respect
  message boundaries, so a framer that assumes "one chunk = one message" works
  until a big tools/list arrives; and a stray non-JSON log line on stdout (a
  well-known MCP footgun) must be skipped rather than desync the stream. Results
  are typed content blocks but tool_result is a STRING, so non-text degrades to a
  placeholder (never raw base64), failures take the agent's `ERROR: ` prefix, and
  the whole thing is capped — the generic tool path bounds nothing.
- mcpClient.js (IO) — spawn, handshake, request/response, teardown.

Four things it owes the agent that the generic tool path does not provide:
timeouts (tools run sequentially, so one wedged server would hang the whole run),
a capped flattened string, call() that NEVER throws (a failure returns an
`ERROR: ` string so one bad server cannot break the turn loop), and deterministic
reaping via reapMcp() for New Chat / deactivate — a detached child otherwise
outlives the window, exactly the reason reapCommands() exists.

Security choices: shell:false (args are argv, never re-parsed by a shell), and
server→client requests (sampling/*, elicitation/*) are explicitly REFUSED rather
than ignored — a server must not be able to drive our model or prompt the user.
This module deliberately does not decide whether a server MAY start; that gate is
S4.

Tested against a real subprocess, not mocks: test/fixtures/mock-mcp-server.js
speaks just enough protocol to drive the client, with tools that succeed, fail,
and never answer, plus --noise/--crash/--sample modes. So the handshake, framing
over a live pipe, the timeout, the sampling refusal and process teardown are all
actually exercised.

Verified: 15 protocol + 13 client tests pass; the full CI gate runs 18 extension
suites with 0 failures; requires are child_process + the pure module only (no
vscode); and `ps` is clean after the run — dispose() and reapMcp() genuinely kill
the process group, asserted in-test by polling the pid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from feat/mcp-s1-config to develop July 23, 2026 03:47
@ndemianc
ndemianc merged commit 5c512ba into develop Jul 23, 2026
@ndemianc
ndemianc deleted the feat/mcp-s2-client branch July 23, 2026 03: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.

1 participant