Uh oh!
There was an error while loading. Please reload this page.
fix(cli): serve diagnostics to stderr so the stdio MCP channel carries only protocol (#7915) - #8065
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 27 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — The dispatch's reverse verification was wrong, and the PR says so with proofI asked for: put one Kernel The sweep I asked for was half the problemI framed this as 11 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 The self-inflicted wound that was seen coming
Unconditional, and the pins cannot be satisfied by silenceThe 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:
Consumer sweepControlled and cross-repo — this repo's tests and scripts, Colour following the destination stream is a nice touch: 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
commented
Aug 12, 2026
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 (
Nothing to correct — so nothing rides this PR. The additive opportunity, recorded rather than filed because it makes no existing sentence false: 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 |
Fixes#7915
The completing half of #7645. PR #7914 made the stdio MCP transport answer; until this, a
native
os servestdio session was still unusable — the banner and the kernel's log recordslanded 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
initializeresult arrived on line517, behind 516 lines of non-protocol text.
What changed
os serve's stdout is the protocol's, and nothing else's.console.logsweep thecard asked for: all 11 sites in
packages/cli/src/commands/serve.tsnow go through oneprintDiagnostichelper (stderr, and still suppressed while theos devandos serveswallow every plugin boot-phase log line — WARN-level boot diagnostics are invisible on both CLI entrypoints #4012 boot-quiet window isopen, so a quiet boot stays quiet). The startup banner and the boot-diagnostics replay
(
printServerReady/printBootDiagnosticsand their helpers inutils/format.ts) write withconsole.error.process —
ObjectLogger'sdebug/info/warnrecords and the strayconsole.logs severalpackages emit during boot (
[StandaloneStack] no compiled artifact …is one).LoggerConfighas 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
--jsonalready takes (os migrate recorded-by --json的 stdout 里混着内核 INFO 日志,payload 无法直接 JSON.parse #6217), reused:reserveStdoutForJsonand the newredirectStdoutToStderrare one mechanism.os startinstalls the same policy. It prints a header and a few resolved values, thenspawns
servewith inherited stdio — so its own stdout is the same fd the child's transportwrites 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 interceptsprocess.stdout.writeto move its diagnostics —which is exactly what
servenow does — would otherwise swallow the protocol frames along withthem. The transport claims the channel in every host and on every construction path, so frames
never depend on who booted the plugin.
chalk.level = chalkStderr.level), so aserve > logina terminal is still coloured and a
serve 2> logdoes 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/**, thecreate-objectstacktemplates, CI workflows, and the siblingcloud/objectuirepos):packages/cli/test/**— 8 e2e files + the sharedrunServeharnesscloud/scripts/dev-local/run-stack.sh— grepsServer is ready> cloud.log 2>&1.github/workflows/scaffold-e2e.yml— bootsos start> server.log 2>&1, readiness bycurl /healthdocs/qa/platform-checklist/**stdout; control: 5 hits foros serve)create-objectstacktemplates,examples/,apps/objectstackmatches 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 withOS_MCP_STDIO_ENABLED=trueand a mintedosk_key, speaksinitializedown 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
INFOrecords must be present onstderr, so purity can never be reached by silencing.
packages/mcp/src/protocol-stdout.test.ts(new) — frames reach the real stream while aninterception is installed, the interception still catches everything else, and backpressure
(
false+drain) is reported rather than swallowed.(
not.toContain(BANNER)) had to move too: on an empty stdout they would have passed for thewrong reason.
Verified
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.logbackproves 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 fromserve.run()and the CLI rebuilt, the pin goes red on the purity assertion and enumerates thelines a client would choke on:
Note what those lines are: kernel
INFOrecords and another package'sconsole.log, not thiscommand'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