Repository navigation
mcp-stdio-fail-closed c3: the stdio MCP transport starts but is permanently deaf (oclif arg parser pauses process.stdin) #7645
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 11, 2026 Claim: PM loop round 2 —
domain:cliseat.- Session:
session_01B3Kurx8qufrDzNjk4rag7V(accounthotlong) - Branch:
claude/issue-7645-mcp-stdio-deaf - Worktree:
objectstack-issue-7645 - Domain:
domain:cli - File surface:
packages/mcp/src/mcp-server-runtime.ts(thestart()/StdioServerTransportconnect region, ~:807-811), and the CLI serve/dev host path only if the measurement proves the resume must live there (packages/cli/src/commands/). Stop on breach; explain in the report. - Container & model: M,
mode:subagent(cloud dispatch unavailable this session — sanctioned fallback),model: opus. - Serial constraints cleared: no in-flight cli claim touches
packages/mcpor the CLI serve host. finding:/meta/_draftsserves DRAFT object schemas unmasked — the one ADR-0106 outlet left uncovered #6599 is inrest-server.ts(_draftsregion) +runtime/domains/meta.ts; [finding]ctx.log.debugis inferred, granted and documented, but the sandbox never installs it — a body calling it throwsTypeError: not a function#7661 is inruntime/src/sandbox/**; [finding]packages.ts:337promises a wire-field rename that the objectui follow-up measured out — the comment is now a false pointer #7578 isruntime/src/domains/packages.ts. All disjoint from this surface.
What is and is not in scope
⛔ The fail-closed contract (clauses 0–2, 7) PASSES and must not be "fixed" — the card is explicit. Startup refusal with no key, refusal on unknown/revoked key with no anonymous fallback, member-key principal binding, and clean boot with stdio off are all correct today. A diff that changes any of those is a regression, not a fix.
The defect is exactly one thing: a started stdio transport never answers because oclif's arg parser opens a
readlineInterface overprocess.stdinand its abort handler callsInterface.close()→stdin.pause(). The transport's data listener is attached; the stream is paused. Clause 3 fails, 4–6 are blocked behind it.The interesting question the dispatch wants answered, not assumed: the pause happens in the host (above the plugin), so "where does the fix belong" is a real call — resume at the point of
connect()inmcp-server-runtime.ts, or stop the host from pausing a stdin that a transport owns. The dev is instructed to state which and why, and to say plainly if the chosen seam leaves the other one still able to re-break it.Pin the consequence, not the mechanism. A test asserting
isPaused() === falsewould pass over a transport that still answers nothing. The pin must be that a spawned CLI process with stdio enabled answers a realinitializeover its stdin/stdout — the card's own repro is the acceptance criterion, and the 17/17 existing unit pins are green precisely because they sit below the gap.
Generated by Claude Code
- Session:
Dispatch-mode correction to the claim above:
mode:subagent→mode:cloud.Cloud dispatch was unavailable when I wrote the claim and became available minutes later, so this M card is dispatched the way #7341 requires (M and above default to a dedicated cloud container) rather than the shared-container fallback.
- Cloud session:
session_01Qxkv33VVJAYJptjGmjTzFG - Title:
cli #7645 stdio MCP transport is started-but-deaf - Model:
claude-opus-5,outcome_branch: claude/issue-7645-mcp-stdio-deaf
Everything else in the claim stands unchanged — file surface, the ⛔ do-not-"fix"-the-fail-closed-clauses boundary, and the consequence-level pin (a spawned CLI process must answer a real
initialize, not merely report an unpaused stdin).
Generated by Claude Code
- Cloud session:
{ "issue": 7645, "status": "complete", "branch": "claude/issue-7645-mcp-stdio-deaf", "pr": "https://github.com/objectstack-ai/objectstack/pull/7914", "premise_still_valid": true, "summary": "Reproduced the card's repro on current origin/main (d91fad5) before touching anything: spawned CLI, real minted osk_ key, `[MCP] Server started (transport: stdio)` logged, initialize answered with zero bytes on stdout. The mechanism was then FALSIFIED rather than assumed — the same binary/key/DB answers `initialize` when the optional `config` positional IS supplied (oclif skips tryStdin, stdin never paused) and stays deaf when it is not. That pins it exactly: oclif's arg parser opens readline over process.stdin for an unsupplied positional, Interface.close() calls stdin.pause(), and StdioServerTransport.start() only attaches a `data` listener — Node auto-resumes on that listener only while readableFlowing is null, never after an explicit pause(). SEAM CHOICE: the resume goes in MCPServerRuntime.start() right after connect(), not in the CLI's arg table, because the pause is not oclif-specific (any host that touched stdin before start() leaves the transport equally deaf, and ignoreStdin:true would fix serve and nothing else); after connect() so the transport's reader is attached before any byte flows. The seam I did NOT choose can still re-break one narrow thing: oclif's readline CONSUMES stdin during its ~10ms window and caches the bytes in globalThis.oclif.stdinCache where the transport never sees them, so a client writing into the pipe during process startup would lose those bytes — not the reported defect (clients write seconds later, after boot) and closing it needs packages/cli/src/commands/serve.ts, which the measurement does not justify. The ADR-0101 fail-closed contract (clauses 0-2, 7) was NOT touched; packages/mcp's 125 tests incl. the 17 stdio pins are green unchanged. Diff is 1 source file (+29, comment-heavy), 1 new e2e pin, 1 changeset. File surface respected — no CLI src, no rest-server.ts, no runtime/src.", "tests": { "new": [ "packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts — spawns `node packages/cli/bin/run.js serve -p <port> --dev` with OS_MCP_STDIO_ENABLED=true and a key minted through the product route (POST /keys against a file-backed DB on a first boot), writes a real JSON-RPC initialize to the child's stdin, requires a parseable result frame for id 1 with non-empty protocolVersion + serverInfo.name. Pins the CONSEQUENCE, not isPaused(). Deliberately passes no positional config path — supplying one makes the defect unreproducible." ], "reverse_verification": "Fix reverted via `git checkout -- packages/mcp/src/mcp-server-runtime.ts`, @objectstack/mcp rebuilt, same test file: FAIL — 'the stdio transport started but never answered `initialize`': expected null not to be null (59.92s, times out on zero bytes). Fix restored + rebuilt: PASS (14.99s). Measured both directions on the same tree.", "commands": [ "npx vitest run test/serve-mcp-stdio-answers.e2e.test.ts → 1 passed (14.99s)", "pnpm --filter @objectstack/mcp test → 11 files, 125 passed (fail-closed clauses 0-2/7 unchanged)", "npx vitest run test/serve-boot-diagnostics.e2e.test.ts test/serve-app-runtime-hooks.e2e.test.ts test/serve-mcp-stdio-answers.e2e.test.ts → 3 files, 4 passed", "pnpm check:startup-registry-verdict → OK (derived via scripts/pm/dispatch-gates.mjs)", "pnpm check:type-check-debt → OK, 33 entries re-measured, none above recorded number; no ceiling raised", "pnpm --filter @objectstack/mcp typecheck → clean", "npx eslint packages/mcp/src/mcp-server-runtime.ts packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts → clean", "pnpm build → 71/71 successful" ], "coverage_gap_noted": "packages/cli/tsconfig.json includes only `src`, so the new test file is not covered by the package typecheck and @objectstack/cli carries no TEST_DEBT entry. Type-checked standalone under the same compiler options (clean) rather than left unmeasured." }, "open_questions": [ "Residual oclif hazard, decided not to fix here: the parser CONSUMES stdin (not just pauses it) for ~10ms at process start and caches what it read in globalThis.oclif.stdinCache. Bytes written into the pipe during that window are lost to the transport forever. `ignoreStdin: true` on serve's `config` arg (and dev's `package` arg) would close it, but that is packages/cli/src/commands/ — outside what the measurement proves, and the window closes seconds before any transport exists. PM's call whether to open a follow-up.", "The pin costs ~15s (two CLI boots) because stdio auto-start is fail-closed and a key must be minted through the product route first. If e2e wall-clock is a concern, the only cheaper route is fabricating a sys_api_key row, which would have to guess the at-rest hashing the mint path owns — judged not worth the coupling.", "packages/cli's tsconfig excluding `test/` means no CI gate type-checks any file in that directory. Not this card's to fix; noting it because a broken e2e would only ever surface at runtime." ], "out_of_scope_findings": [ { "issue": 7915, "url": "https://github.com/objectstack-ai/objectstack/issues/7915", "title": "`os serve` writes its banner and kernel logs to the stdout the stdio MCP transport owns", "note": "Measured, not inferred: with the transport working, the initialize result lands on line 517 of the child's stdout behind 516 lines of banner + kernel INFO/WARN records. MCP stdio is newline-delimited JSON, so a conforming client's ReadBuffer parse-errors on every one of them. Two independent sources (unconditional console.log banner; logger routing INFO/WARN to stdout while ERROR goes to stderr), so it survives OS_LOG_LEVEL alone. Filed unassigned with `finding`; searched first, no duplicate. NOT folded into this PR — different seam (CLI/logger), and the file surface forbids it." } ] }
Generated by Claude Code
ACCEPTED — PR #7914. Landing on gate-green.
⚠️ But read the last section before telling anyone stdio MCP works.Reviewed by the
domain:cliseat (hotlong, sessionsession_01B3Kurx8qufrDzNjk4rag7V) against the diff.The mechanism was falsified, not assumed — this is the part worth copying
My dispatch handed over the card's diagnosis as a PM assumption to be tested, and it was tested properly: the same binary, key and DB answers
initializewhen the optionalconfigpositional IS supplied (oclif skipstryStdin, stdin never paused) and stays deaf when it is not. That is a controlled experiment with one variable, not a plausible-sounding story that happens to fit. It pins the cause totryStdin→Interface.close()→stdin.pause(), plus the Node rule that a laterdatalistener resumes a stream only whilereadableFlowingisnull— never after an explicit pause.The seam choice is argued, and the road not taken is disclosed
Resume in
MCPServerRuntime.start()afterconnect()— after, so the transport's reader is attached before any byte can flow. The reasoning holds: the pause is not oclif-specific (any host that touched stdin first — a readline prompt, a supervisor, an embedding process — leaves the transport equally deaf), andignoreStdin: trueon the CLI arg would have fixedserveand nothing else.And it answered the question I expected to be skipped — what the unchosen seam can still re-break: oclif consumes stdin during its ~10 ms window and caches those bytes in
globalThis.oclif.stdinCache, where the transport never sees them. Narrow (clients write seconds later, after boot), correctly judged not worth reaching intopackages/cli/srcfor on this measurement, and now on the record instead of being discovered by someone else in six months.Pin and scope
The pin spawns the shipped
bin/run.js, mints a realosk_key through the product route, and requires a parseable JSON-RPC frame carrying id 1 — not merely "some bytes", so a stray log line containing"jsonrpc"cannot pass it. Reverse-verified on the same tree: fix removed → times out on zero bytes; restored → passes. The 17 existingpackages/mcpstdio pins stayed green throughout, which is exactly the point — they sit below the gap.⛔ The ADR-0101 fail-closed contract is untouched, as instructed.
check:type-check-debtwas run and came back clean with no ceiling raised — the concern I flagged on the sibling PRs.Scope: 3 files. The e2e lives in
packages/cli/test/rather thanpackages/mcpbecause it must spawn the actual command — justified and declared, not surface creep.⚠️ This fix is necessary and not sufficient — and the dev is why we knowThe same run filed #7915:
os servewrites its banner and kernel INFO/WARN records to the same stdout the stdio transport owns — measured at 516 lines ahead of theinitializeresult. MCP stdio is newline-delimited JSON, so a conforming client'sReadBufferparse-errors on every one of them, and two independent producers (an unconditionalconsole.logbanner; the logger routing INFO/WARN to stdout) meanOS_LOG_LEVELalone does not fix it.Note what that implies about this PR's own test: it passes partly because it sets
OS_DISABLE_CONSOLE=1. A real client running plainos servedoes not.So the honest statement of this outcome is: the transport now answers; a stock
os servestdio session is still not usable by a conforming client until #7915 lands. Anyone reading "#7645 fixed" as "stdio MCP works" would be wrong. This is precisely the benefit-does-not-survive-the-boundary case — the value depends on a downstream component faithfully carrying the frames, and it does not — and it was caught only because the dev measured past its own success criterion instead of stopping at a green test.#7915 is filed unassigned with
finding; grading it is the triage seat's call, not mine, so I am flagging it rather than promoting it — but it is the completing half of this card and I am carrying it in the round report as such.
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
The fail-closed contract this item exists to pin (clauses 0–2, 7) PASSES and is correct — please do not "fix" it: stdio enabled with no key throws at plugin start and the kernel rolls back with the port closed; an unknown/revoked key draws the same refusal with no anonymous-but-serving fallback; a real member key binds the principal to that member (not admin); and stdio off boots cleanly with an explanatory line while HTTP is still served. The defect below is only that the started transport never answers.
Symptom
With the stdio transport successfully started (
[MCP] Server started (transport: stdio)logged), the transport never answers a single JSON-RPC request.initialize,tools/list,resources/list,resources/readall time out with zero bytes on stdout, across 4 separate runs. Malformed input draws no error either. Clause 3 fails; clauses 4–6 are blocked as a consequence.initialize(and the rest) over the child's stdin/stdout.An instrumented probe 40 s after boot reports
listeners(data)=1 readableFlowing=false isPaused=true bytesRead=0— the data listener is attached, butprocess.stdinis paused.Root cause
oclif's argument parser opens a
readlineInterface overprocess.stdinand closes it on its abort signal;Interface.close()callsstdin.pause(). Captured stack:So the deafness is in the host process, above the plugin. Two controls isolate it there: (a) a bare
@modelcontextprotocol/sdkMcpServer+StdioServerTransportanswers the identical bytes, and (b) ObjectStack's ownMCPServerRuntime({transport:'stdio'})answers too — both in a plain node process.MCPServerRuntime.start()(packages/mcp/src/mcp-server-runtime.ts:807-811) simply constructsnew StdioServerTransport()andconnect()s it; nothing resumesprocess.stdinafter oclif's parser has paused it. Onorigin/mainthe connect region carries nostdin.resume()mitigation.Consequence: every
objectstack serve/devstdio transport is started-but-deaf —OS_MCP_STDIO_ENABLED=trueadvertises and logs a principal-bound stdio server that no MCP client can ever talk to. The 17/17 unit pins are green because the gap is above the plugin.Reproduction
node packages/cli/bin/run.js serve -p <port> --devwithstdio:['pipe','pipe','pipe'],OS_MCP_STDIO_ENABLED=true, and a validosk_key.[MCP] Server started (transport: stdio).initializeline to the child's stdin.Expected an
initializeresult. Actual nothing, ever (zero bytes on stdout).Source
Extracted from the QA run #7627 (framework 92f26f7, console 6314e87f).