Skip to content

fix(mcp): the stdio transport answers again — resume the stdin it owns (#7645) - #7914

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-7645-mcp-stdio-deaf
Aug 12, 2026
Merged

fix(mcp): the stdio transport answers again — resume the stdin it owns (#7645)#7914
hotlong merged 1 commit into
mainfrom
claude/issue-7645-mcp-stdio-deaf

Conversation

@hotlong

@hotlong hotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #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 requestinitialize, tools/list, resources/list, resources/read all timed out with zero bytes on stdout.

Premise re-verified on current origin/main

The 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 minted osk_ key):

=== boot 2: stdio transport ===
=== stdio transport started ===
=== initialize written ===

=== RESULT ===
NO ANSWER (zero JSON-RPC bytes on stdout)

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 (tryStdincreateInterface({input: process.stdin}), aborted after 10 ms), and Interface.close() calls stdin.pause(). serve declares an optional config positional (packages/cli/src/commands/serve.ts:203-205), so plain objectstack serve --dev leaves process.stdin explicitly paused before the kernel ever boots.

StdioServerTransport.start() only attaches a data listener. 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.

The decisive measurement was a falsification test on the argv, run twice against the unfixed tree:

command oclif reads stdin? initialize
serve -p PORT --dev yes (config positional absent → tryStdin) no answer, zero bytes
serve objectstack.config.ts -p PORT --dev no (positional supplied) answered

Same 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 after connect().

  • The transport is the owner. 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.
  • The pause is not oclif-specific. Any host that touched stdin before start() — a readline prompt, a supervisor, an embedding process — leaves the transport equally, silently deaf. ignoreStdin: true on serve's args would fix serve and leave every other host broken in exactly the same way, with no test anywhere that would notice.
  • After 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.stdinCache where 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 touching packages/cli/src/commands/serve.ts — which the measurement does not justify. Flagged rather than fixed; see open_questions in the report on #7645.

The HTTP transport is unaffected: it is served per-request at /api/v1/mcp and 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_KEY still 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 spawned node packages/cli/bin/run.js serve -p PORT --dev that writes a real JSON-RPC initialize down the child's stdin and requires a real result back (parsed frame for id 1, non-empty protocolVersion + serverInfo.name).

It asserts the consequence, not the mechanism: process.stdin.isPaused() === false would pass over a transport that still answers nothing. It also deliberately passes no positional config path — supplying one makes oclif skip tryStdin and the defect cannot reproduce.

The key is minted through the product route: boot 1 signs in as the --dev seeded admin and POST /keys against a file-backed DB, boot 2 reuses that DB with OS_MCP_STDIO_ENABLED=true. A hand-written sys_api_key row 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

$ npx vitest run test/serve-mcp-stdio-answers.e2e.test.ts        # with the fix
 Test Files  1 passed (1)
      Tests  1 passed (1)
   Duration  14.99s

Reverse-verification — fix reverted (git checkout -- packages/mcp/src/mcp-server-runtime.ts), @objectstack/mcp rebuilt, same test file:

 FAIL  test/serve-mcp-stdio-answers.e2e.test.ts > replies to a real JSON-RPC initialize written to the child process stdin
AssertionError: the stdio transport started but never answered `initialize`
  (#7645: stdin left paused by the host): expected null not to be null
 Test Files  1 failed (1)
   Duration  59.92s

Fix restored and rebuilt before commit.

Other gates and suites:

$ pnpm --filter @objectstack/mcp test
 Test Files  11 passed (11)      Tests  125 passed (125)

$ 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
 Test Files  3 passed (3)        Tests  4 passed (4)

$ pnpm check:startup-registry-verdict          # derived via scripts/pm/dispatch-gates.mjs
✓ startup registry verdicts: 40 seam(s) across 1679 file(s) … none recording a verdict the boot can contradict.

$ pnpm check:type-check-debt
check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured, 1789 raw tsc error(s), none above its recorded number.

$ 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

No debt ceiling was raised. packages/cli's tsconfig.json includes only src, 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 initialize result arrived on line 517, after 516 lines of non-protocol text. A spec-conforming client's newline-delimited ReadBuffer will fail to parse those. Different defect, different seam; filed as #7915.

#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
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 4:04am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/mcp.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/mcp)
  • content/docs/ai/agents.mdx (via @objectstack/mcp)
  • content/docs/ai/connect-mcp.mdx (via @objectstack/mcp)
  • content/docs/ai/index.mdx (via @objectstack/mcp)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/mcp)
  • content/docs/api/index.mdx (via @objectstack/mcp)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/mcp)
  • content/docs/permissions/authorization.mdx (via @objectstack/mcp)
  • content/docs/permissions/system-context.mdx (via packages/mcp)
  • content/docs/plugins/packages.mdx (via @objectstack/mcp)
  • content/docs/protocol/knowledge.mdx (via @objectstack/mcp)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/mcp)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants