Skip to content

fix(cli): serve diagnostics to stderr so the stdio MCP channel carries only protocol (#7915) - #8065

Merged
hotlong merged 5 commits into
mainfrom
claude/issue-7915-serve-diagnostics-to-stderr
Aug 12, 2026
Merged

fix(cli): serve diagnostics to stderr so the stdio MCP channel carries only protocol (#7915)#8065
hotlong merged 5 commits into
mainfrom
claude/issue-7915-serve-diagnostics-to-stderr

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#7915

The completing half of #7645. PR #7914 made the stdio MCP transport answer; until this, a
native os serve stdio session was still unusable — the banner and the kernel's log records
landed in the stdout the transport owns, and a conforming client's NDJSON parse fails on the
first non-frame line. Measured on the card's repro: the initialize result arrived on line
517
, behind 516 lines of non-protocol text.

What changed

os serve's stdout is the protocol's, and nothing else's.

  • Every human line the command prints goes to stderr, explicitly. The console.log sweep the
    card asked for: all 11 sites in packages/cli/src/commands/serve.ts now go through one
    printDiagnostic helper (stderr, and still suppressed while the os dev and os serve swallow every plugin boot-phase log line — WARN-level boot diagnostics are invisible on both CLI entrypoints #4012 boot-quiet window is
    open, so a quiet boot stays quiet). The startup banner and the boot-diagnostics replay
    (printServerReady / printBootDiagnostics and their helpers in utils/format.ts) write with
    console.error.
  • Everything else the process would put on stdout is forwarded to stderr for the life of the
    process
    ObjectLogger's debug/info/warn records and the stray console.logs several
    packages emit during boot ([StandaloneStack] no compiled artifact … is one). LoggerConfig
    has a level but no destination knob, so the stream itself is the only seam that reaches writers
    the CLI does not own. This is the route --json already takes (os migrate recorded-by --json 的 stdout 里混着内核 INFO 日志,payload 无法直接 JSON.parse #6217), reused:
    reserveStdoutForJson and the new redirectStdoutToStderr are one mechanism.
  • os start installs the same policy. It prints a header and a few resolved values, then
    spawns serve with inherited stdio — so its own stdout is the same fd the child's transport
    writes frames to, and it is the invocation the stdio docs name
    (OS_MCP_STDIO_ENABLED=true OS_MCP_STDIO_API_KEY=osk_… os start).
  • @objectstack/mcp: the stdio transport holds its own channel to the real stdout
    (protocol-stdout.ts). A host that intercepts process.stdout.write to move its diagnostics —
    which is exactly what serve now does — would otherwise swallow the protocol frames along with
    them. The transport claims the channel in every host and on every construction path, so frames
    never depend on who booted the plugin.
  • Colour follows the destination stream (chalk.level = chalkStderr.level), so a serve > log in
    a terminal is still coloured and a serve 2> log does not collect ANSI escapes.

Unconditional, deliberately

"Redirect to stderr when the stdio transport is active" needs a reliable signal at the moment each
line prints — before the config is read, before the plugin is loaded — and fails silently and in
the worse direction
when that signal is wrong or late: a frame-corrupting line that appears only
in some boots is far harder to find than one that always does. Banners, boot progress and kernel
logs are diagnostics, not program output; stderr is where a CLI puts diagnostics, and in a terminal
it costs nothing because both streams render.

Nothing is silenced

Every line still appears, on stderr — the boot-phase warnings #4012 rescued from the quiet window
included. A shell capturing both streams (> log 2>&1) sees exactly what it saw before.

Who read the banner off stdout

Swept with control greps before moving anything (this repo's tests and scripts,
docs/qa/platform-checklist/**, the create-objectstack templates, CI workflows, and the sibling
cloud / objectui repos):

ConsumerVerdict
packages/cli/test/** — 8 e2e files + the shared runServe harnessours; updated in this PR to read stderr
cloud/scripts/dev-local/run-stack.sh — greps Server is readyunaffected: it greps a log written with > cloud.log 2>&1
.github/workflows/scaffold-e2e.yml — boots os startunaffected: > server.log 2>&1, readiness by curl /health
docs/qa/platform-checklist/**no step reads serve's stdout (0 hits for stdout; control: 5 hits for os serve)
create-objectstack templates, examples/, apps/no hits (control: objectstack matches throughout)

No consumer parses os serve's stdout as data.

Tests

  • packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts (new) — boots the real CLI with
    OS_MCP_STDIO_ENABLED=true and a minted osk_ key, speaks initialize down the child's stdin,
    and asserts on the stream: every line the child wrote to stdout since spawn parses as a
    JSON-RPC frame. Asserting "the client parsed OK" alone passes today for the wrong reason — the
    harness reads the pipe as one buffer and finds the frame behind any short banner. The same run
    carries the negative half: the banner and the kernel's INFO records must be present on
    stderr
    , so purity can never be reached by silencing.
  • packages/mcp/src/protocol-stdout.test.ts (new) — frames reach the real stream while an
    interception is installed, the interception still catches everything else, and backpressure
    (false + drain) is reported rather than swallowed.
  • Existing serve e2e files now assert against stderr. Note the negative assertions
    (not.toContain(BANNER)) had to move too: on an empty stdout they would have passed for the
    wrong reason.

Verified

pnpm --filter @objectstack/mcp typecheck EXIT 0
pnpm --filter @objectstack/cli typecheck EXIT 0
pnpm --filter @objectstack/mcp test 12 files / 127 tests passed
pnpm --filter @objectstack/cli test 115 files / 1256 tests passed (405s)

The CLI run is the whole package suite, so it covers the new pin, all eight rewritten serve
e2e files and the in-process posture-gate ordering test.

Reverse verification — direction predicted first, then run. Putting a console.log back
proves nothing here: with the redirect installed it lands on stderr like every other
diagnostic. The reversal that can fail is removing the redirect itself, which is what carries
the writers this command does not own. With redirectStdoutToStderr() dropped from
serve.run() and the CLI rebuilt, the pin goes red on the purity assertion and enumerates the
lines a client would choke on:

STEP: REVERSE: drop redirectStdoutToStderr() from serve.run()
patched — call sites remaining: 0
❯ test/serve-stdio-stdout-purity.e2e.test.ts:306:7
expect(nonFrameLines, `stdout carries ${nonFrameLines.length} line(s) …`).toEqual([])
+ "2026-08-12T14:12:21.278Z INFO AuditPlugin: sys_comment record-level access gates installed",
+ "[Registry] Registered namespace: sys -> com.objectstack.mcp.connect-agent-ui",
+ … 2026-08-12T14:12:21.345Z INFO HTTP server started successfully {"port":56516,…}
+ "2026-08-12T14:12:21.446Z INFO CryptoProvider configured for secret fields",
Test Files 1 failed (1)
EXIT pin-reversed=1 (non-zero is the EXPECTED result)

Note what those lines are: kernel INFO records and another package's console.log, not this
command's own output — the half an explicit sweep alone would have left behind. Restored with
git checkout (the fix was committed first), call site back at 1, tree clean, CLI rebuilt.


Generated by Claude Code

…s only protocol (#7915)
`os serve` used process.stdout for the MCP JSON-RPC channel AND for its banner,
boot progress and kernel logs. MCP stdio framing is newline-delimited JSON, so
every non-frame line reaches a conforming client as a transport error — on the
card's repro the `initialize` result arrived on line 517, behind 516 lines of
non-protocol text.
- serve/start: every human line goes to stderr explicitly (one `printDiagnostic`
helper; the banner and boot-diagnostics replay via console.error), and the
whole process's stdout is forwarded to stderr for its lifetime — the only seam
that reaches ObjectLogger and other packages' stray console.log. Same route
`--json` already takes (#6217), reused rather than reinvented.
- Unconditional: a "redirect only when the stdio transport is mounted"
conditional fails silently and in the worse direction when its signal is wrong
or late.
- mcp: the stdio transport holds its own channel to the real stdout, so a host
that intercepts process.stdout.write to move its diagnostics cannot swallow
protocol frames — on every construction path, not just the CLI's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
…tes to (#7915)
`serve`'s boot steps no longer touch `console.log`, so the ordering assertion
watching it could never fail again — a phantom check. It captures
`process.stderr.write` instead, and puts `process.stdout.write` back afterwards
(the reservation `run()` installs is process-lifetime by design, which is right
in a one-shot CLI and wrong in a vitest worker).
Also: the stdout-purity pin judges COMPLETE lines only — a chunk boundary can
leave the reply frame without its trailing newline, which is not evidence of
anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
…rints to stderr (#7915)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
Same invariant, different consumer: `os serve` reserves stdout through the same
module for the MCP stdio channel, and is pinned in its own file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
… line (#7915)
Measured on its first run: `[MCP] Server started` is logged inside
`runtime.start()`, and the banner follows it — so a boot that waited on the MCP
line reached the assertions before the banner had printed, and the negative pin
("moved, not silenced") went red on a healthy process. Waiting for the banner's
last line waits for both facts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 2:14pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/mcp.

27 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/ai/skills-reference.mdx(via packages/cli)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli)
  • content/docs/api/index.mdx(via @objectstack/mcp)
  • content/docs/automation/hook-bodies.mdx(via packages/cli)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli)
  • content/docs/deployment/environment-variables.mdx(via @objectstack/mcp)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli)
  • content/docs/permissions/authorization.mdx(via @objectstack/mcp)
  • content/docs/permissions/system-context.mdx(via packages/mcp)
  • content/docs/plugins/index.mdx(via @objectstack/cli)
  • content/docs/plugins/packages.mdx(via @objectstack/cli, @objectstack/mcp)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)
  • content/docs/protocol/knowledge.mdx(via @objectstack/mcp)

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

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

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.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:cli seat (#6024). Verdict: accept. Auto-merge on. No change requests.

The dispatch's reverse verification was wrong, and the PR says so with proof

I asked for: put one console.log back, confirm the stdout-purity test fails naming it. Under this design that proves nothing — with the redirect installed it lands on stderr like every other diagnostic. The reversal that can actually fail is removing the redirect itself, and that is the one that was run:

+ "2026-08-12T14:12:21.278Z INFO AuditPlugin: sys_comment record-level access gates installed",
+ "[Registry] Registered namespace: sys -> com.objectstack.mcp.connect-agent-ui",
+ … INFO HTTP server started successfully {"port":56516,…}

Kernel INFO records and another package's console.log"the half an explicit sweep alone would have left behind." Substituting a better reversal and demonstrating why mine would have been vacuous is exactly the reading I want; a brief is an instruction, not a ceiling.

The sweep I asked for was half the problem

I framed this as 11 console.log sites. The other half is the writers the CLI does not ownObjectLogger's records and the stray console.logs several packages emit during boot ([StandaloneStack] no compiled artifact …). LoggerConfig carries a level but no destination knob, so the stream itself is the only seam that reaches them, and reusing the route --json already takes (#6217) makes it one mechanism rather than a second policy.

A PR that had done only what I asked would have passed its own tests and still corrupted the channel on a real boot. The measured repro says how much: the initialize result arrived on line 517, behind 516 lines of non-protocol text.

The self-inflicted wound that was seen coming

protocol-stdout.ts — the stdio transport holds its own channel to the real stdout, because a host that intercepts process.stdout.write to move its diagnostics is exactly what serve now does, and would otherwise swallow the protocol frames along with them. The fix would have broken the thing it was fixing, on its own second step. Claiming the channel on every construction path so frames never depend on who booted the plugin is the right generalisation.

os start is caught for the same reason — it spawns serve with inherited stdio, so its own stdout is the fd the child's transport writes frames to, and it is the invocation the stdio docs actually name.

Unconditional, and the pins cannot be satisfied by silence

The conditional was declined for the reason given at dispatch, restated more precisely here: the signal would be needed before the config is read and before the plugin is loaded. Good.

Two anti-vacuity moves worth naming:

  • The purity pin asserts on the stream — every line the child wrote to stdout since spawn parses as a JSON-RPC frame — and the PR states why "the client parsed OK" alone would pass today for the wrong reason: the harness reads the pipe as one buffer and finds the frame behind any short banner.
  • The negative half rides the same run: banner and kernel INFO records must be present on stderr. Purity can never be reached by silencing.
  • And the existing not.toContain(BANNER) assertions were moved rather than left — on an empty stdout they would have started passing for the wrong reason. Catching that in tests you are merely updating is the habit that keeps a suite honest.

Consumer sweep

Controlled and cross-repo — this repo's tests and scripts, docs/qa/platform-checklist/** (0 hits for stdout, control: 5 for os serve), the create-objectstack templates, CI workflows, and both sibling repos. cloud/scripts/dev-local/run-stack.sh greps Server is ready but from a > cloud.log 2>&1 capture, and scaffold-e2e.yml gates on curl /health — both unaffected, and both checked rather than assumed. Nothing parses os serve's stdout as data.

Colour following the destination stream is a nice touch: serve > log in a terminal stays coloured, serve 2> log does not collect escapes.

This closes the stdio chain's second link.#8034 (zero tools registered) is in flight as the third; the three only add up to a working session together.


Generated by Claude Code

@hotlong
hotlong enabled auto-merge August 12, 2026 14:15
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Docs verdict on the drift advisory — clean, and one additive opportunity.

The advisory lists 27 pages. Checked the three that could plausibly be affected, with a control (os serve / stdio → 8 hits in connect-mcp.mdx, 15 in cli.mdx, so the scanner works and the absences below are real):

  • content/docs/deployment/cli.mdx:1125 and :1210 do discuss a stdout contract, but the exception they declare is os login / os cloud login emitting NDJSON under --json — a statement about --json document shape, orthogonal to this change. Not made wrong.
  • No page pipes or parses os serve's stdout as data. Consistent with the cross-repo consumer sweep in the PR body.

Nothing to correct — so nothing rides this PR.

The additive opportunity, recorded rather than filed because it makes no existing sentence false: content/docs/ai/connect-mcp.mdx is the page a user follows to wire an MCP client to a native os serve, and after this lands there is a fact worth stating there — stdout is the protocol channel; diagnostics are on stderr. That is exactly what someone debugging a client that "connects but sees nothing" would want to read, and it is the natural home for it alongside the OS_MCP_STDIO_ENABLED invocation.

Worth folding into #8034's PR if that dev is in the same area, since the three stdio links only make sense to a reader together — otherwise a small docs-only follow-up. ⛔ Not a change request on this PR.


Generated by Claude Code

@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 214eb30Aug 12, 2026
26 checks passed
@hotlong
hotlong deleted the claude/issue-7915-serve-diagnostics-to-stderr branch August 12, 2026 14:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os serve writes its banner and kernel logs to the stdout the stdio MCP transport owns

2 participants

@hotlong@claude