fix(mcp): the stdio transport answers again — resume the stdin it owns (#7645) - #7914
Conversation
#7645) `objectstack serve` with `OS_MCP_STDIO_ENABLED=true` logged `[MCP] Server started (transport: stdio)`, bound the transport to a real `osk_` identity, and then never answered a single request: `initialize`, `tools/list`, `resources/list` and `resources/read` all timed out with zero bytes on stdout. The pause came from the host, above the plugin. oclif's argument parser reads stdin for any positional argument the caller did not supply (`tryStdin` -> `createInterface({input: process.stdin})`, aborted after 10ms), and `Interface.close()` calls `stdin.pause()`. `serve` declares an optional `config` positional, so plain `objectstack serve --dev` left `process.stdin` explicitly paused before the kernel booted. `StdioServerTransport.start()` only attaches a `data` listener, and Node auto-switches a stream to flowing mode on that listener only while `readableFlowing` is still `null` — never after an explicit `pause()`. Listener attached, `bytesRead` 0, transport deaf. `MCPServerRuntime.start()` now resumes `process.stdin` immediately after `connect()` — the moment the transport takes ownership of it, and after the transport's reader is attached so no byte can flow unread. The resume lives in the runtime rather than in the CLI's argument definitions because the pause is not oclif-specific: any host that touched stdin before `start()` leaves the transport equally deaf. Pinned by a spawned-CLI e2e that writes a real `initialize` down the child's stdin and requires a real result back — the consequence, not the stream flag. Reverse-verified: with the resume removed it times out with zero bytes. The ADR-0101 fail-closed startup contract is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qxkv33VVJAYJptjGmjTzFG
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7645
objectstack servewithOS_MCP_STDIO_ENABLED=truelogged[MCP] Server started (transport: stdio), bound the transport to a realosk_identity, and then never answered a single request —initialize,tools/list,resources/list,resources/readall timed out with zero bytes on stdout.Premise re-verified on current
origin/mainThe card was filed 2026-08-11; this branch is off
d91fad5. Reproduced there first, through the card's own repro (spawned CLI,stdio: ['pipe','pipe','pipe'], a real mintedosk_key):premise_still_valid: true. The connect region carried no mitigation.Root cause, and how it was falsified rather than assumed
oclif's argument parser reads stdin for any positional argument the caller did not supply (
tryStdin→createInterface({input: process.stdin}), aborted after 10 ms), andInterface.close()callsstdin.pause().servedeclares an optionalconfigpositional (packages/cli/src/commands/serve.ts:203-205), so plainobjectstack serve --devleavesprocess.stdinexplicitly paused before the kernel ever boots.StdioServerTransport.start()only attaches adatalistener. Node auto-switches a stream to flowing mode on that listener only whilereadableFlowingis stillnull— never after an explicitpause(). Listener attached,bytesRead0, transport deaf.The decisive measurement was a falsification test on the argv, run twice against the unfixed tree:
initializeserve -p PORT --devconfigpositional absent →tryStdin)serve objectstack.config.ts -p PORT --devSame binary, same key, same DB; the only variable is whether oclif's parser touches stdin. That pins the mechanism exactly, and it also shows the user-visible symptom was argv-dependent — the command worked or didn't depending on an optional positional, which is itself the signature of the fix belonging outside the CLI's argument table.
Seam choice: the runtime, not the CLI
The dispatch left this open, so: the resume goes in
MCPServerRuntime.start(), immediately afterconnect().start()is the moment this process's stdin becomes the MCP channel. That is the one place that knows a long-lived stdio transport was just attached and therefore that stdin must be flowing.start()— a readline prompt, a supervisor, an embedding process — leaves the transport equally, silently deaf.ignoreStdin: trueonserve's args would fixserveand leave every other host broken in exactly the same way, with no test anywhere that would notice.connect(), not before, so the transport's reader is attached before any byte can flow.Can the seam I did not choose still re-break it? Partly — and honestly, yes, in one narrow direction. oclif's readline does not merely pause stdin, it consumes from it during the ~10 ms window at process start, caching whatever it read in
globalThis.oclif.stdinCachewhere the transport will never see it. A client that wrote bytes into the pipe during that window would still lose them. That is not the reported defect (MCP clients write after the server is up, and the transport does not attach until boot completes seconds later), and closing it means touchingpackages/cli/src/commands/serve.ts— which the measurement does not justify. Flagged rather than fixed; seeopen_questionsin the report on #7645.The HTTP transport is unaffected: it is served per-request at
/api/v1/mcpand never touches stdin.What is deliberately NOT changed
The ADR-0101 fail-closed startup contract (clauses 0–2, 7). stdio enabled without
OS_MCP_STDIO_API_KEYstill throws at plugin start, an unknown/revoked key still refuses with no anonymous-but-serving fallback, and a member key still binds the principal to that member.packages/mcp's existing suite (125 tests, incl. the 17 stdio pins) is green unchanged.The pin
packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts— a spawnednode packages/cli/bin/run.js serve -p PORT --devthat writes a real JSON-RPCinitializedown the child's stdin and requires a realresultback (parsed frame for id 1, non-emptyprotocolVersion+serverInfo.name).It asserts the consequence, not the mechanism:
process.stdin.isPaused() === falsewould pass over a transport that still answers nothing. It also deliberately passes no positional config path — supplying one makes oclif skiptryStdinand the defect cannot reproduce.The key is minted through the product route: boot 1 signs in as the
--devseeded admin andPOST /keysagainst a file-backed DB, boot 2 reuses that DB withOS_MCP_STDIO_ENABLED=true. A hand-writtensys_api_keyrow would have to guess the at-rest hashing the mint path owns.packages/mcp's 17 existing stdio pins were green throughout the entire outage because they sit below the gap — they run the runtime in a plain node process where nothing ever paused stdin. They are not coverage for this.Verification record
Reverse-verification — fix reverted (
git checkout -- packages/mcp/src/mcp-server-runtime.ts),@objectstack/mcprebuilt, same test file:Fix restored and rebuilt before commit.
Other gates and suites:
No debt ceiling was raised.
packages/cli'stsconfig.jsonincludes onlysrc, so the new test file is not covered by the package typecheck — it was type-checked standalone under the same compiler options (clean) rather than left unmeasured.Full workspace build green (
pnpm build, 71/71) before and after.Out-of-scope finding (filed separately, not fixed here)
While measuring, boot 2's stdout turned out to carry the CLI's entire human banner and every kernel log line on the same stdout the MCP protocol owns — the
initializeresult arrived on line 517, after 516 lines of non-protocol text. A spec-conforming client's newline-delimitedReadBufferwill fail to parse those. Different defect, different seam; filed as #7915.