Skip to content

mcp-stdio-fail-closed c3: the stdio MCP transport starts but is permanently deaf (oclif arg parser pauses process.stdin) #7645

Description

@huangyiirene

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.

  • Observed: initialize, tools/list, resources/list, resources/read all 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.
  • Expected: a started, principal-bound stdio transport answers 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, but process.stdin is paused.

Root cause

oclif's argument parser opens a readline Interface over process.stdin and closes it on its abort signal; Interface.close() calls stdin.pause(). Captured stack:

at Interface.pause (node:internal/readline/interface:539)
at Interface.close (node:internal/readline/interface:525)
at signal.addEventListener.once (@oclif/core/lib/parser/parse.js:64)

So the deafness is in the host process, above the plugin. Two controls isolate it there: (a) a bare @modelcontextprotocol/sdk McpServer + StdioServerTransport answers the identical bytes, and (b) ObjectStack's own MCPServerRuntime({transport:'stdio'}) answers too — both in a plain node process. MCPServerRuntime.start() (packages/mcp/src/mcp-server-runtime.ts:807-811) simply constructs new StdioServerTransport() and connect()s it; nothing resumes process.stdin after oclif's parser has paused it. On origin/main the connect region carries no stdin.resume() mitigation.

Consequence: every objectstack serve/dev stdio transport is started-but-deaf — OS_MCP_STDIO_ENABLED=true advertises 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

  1. Spawn node packages/cli/bin/run.js serve -p <port> --dev with stdio:['pipe','pipe','pipe'], OS_MCP_STDIO_ENABLED=true, and a valid osk_ key.
  2. Wait for [MCP] Server started (transport: stdio).
  3. Write a JSON-RPC initialize line to the child's stdin.

Expected an initialize result. Actual nothing, ever (zero bytes on stdout).

Source

Extracted from the QA run #7627 (framework 92f26f7, console 6314e87f).

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claim: PM loop round 2 — domain:cli seat.

    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 readline Interface over process.stdin and its abort handler calls Interface.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() in mcp-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() === false would pass over a transport that still answers nothing. The pin must be that a spawned CLI process with stdio enabled answers a real initialize over 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

  3. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    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

  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "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

  5. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    ACCEPTED — PR #7914. Landing on gate-green. ⚠️ But read the last section before telling anyone stdio MCP works.

    Reviewed by the domain:cli seat (hotlong, session session_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 initialize when the optional config positional IS supplied (oclif skips tryStdin, 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 to tryStdin → Interface.close() → stdin.pause(), plus the Node rule that a later data listener resumes a stream only while readableFlowing is null — never after an explicit pause.

    The seam choice is argued, and the road not taken is disclosed

    Resume in MCPServerRuntime.start() after connect() — 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), and ignoreStdin: true on the CLI arg would have fixed serve and 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 into packages/cli/src for 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 real osk_ 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 existing packages/mcp stdio 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-debt was 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 than packages/mcp because 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 know

    The same run filed #7915: os serve writes its banner and kernel INFO/WARN records to the same stdout the stdio transport owns — measured at 516 lines ahead of the initialize result. MCP stdio is newline-delimited JSON, so a conforming client's ReadBuffer parse-errors on every one of them, and two independent producers (an unconditional console.log banner; the logger routing INFO/WARN to stdout) mean OS_LOG_LEVEL alone 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 plain os serve does not.

    So the honest statement of this outcome is: the transport now answers; a stock os serve stdio 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions