Skip to content

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

Description

@hotlong

Found while fixing #7645 (PR #7914). Different defect, different seam — filed rather than folded in.

Symptom

With OS_MCP_STDIO_ENABLED=true, objectstack serve uses process.stdout as the MCP JSON-RPC channel and as its ordinary human/log output. Both streams are interleaved on the same fd.

Measured on origin/main @ d91fad5 (spawned CLI, stdio: ['pipe','pipe','pipe'], a real minted osk_ key, full child stdout captured). The initialize result arrives on line 517, behind 516 lines of non-protocol text:

  1  (blank)
  2    Loading objectstack.config.ts...
  3  [StandaloneStack] no compiled artifact at '…/dist/objectstack.json' — booting without one …
  4  2026-08-12T04:02:36.854Z INFO Registered metadata loader: filesystem (file:)
     … ~490 more kernel log lines …
499    ✓ Server is ready
512    Plugins: 29 loaded
515    Press Ctrl+C to stop
517  {"result":{"protocolVersion":"2024-11-05", … },"jsonrpc":"2.0","id":1}

Why it matters

The MCP stdio transport is newline-delimited JSON. A spec-conforming client's ReadBuffer JSON.parses every line it reads off the server's stdout, so each of those 516 lines is a parse failure surfaced to the client as a transport error before the one valid frame arrives.

Two independent sources, so fixing one is not enough:

  • the human bannerconsole.log in the serve command (✓ Server is ready, the plugin table, Press Ctrl+C to stop). Unconditional, so this holds at the default OS_LOG_LEVEL=warn too.
  • the kernel loggerINFO/WARN records go to stdout (ERROR was observed on stderr). The capture above used OS_LOG_LEVEL=info; at the default level the WARN records and the boot-diagnostics block still land there.

Note the observed direction of the two defects: #7645 meant the channel carried nothing, which is also why this one had never been seen. With #7645 fixed the channel works and now demonstrably carries noise.

Expected

When the stdio transport is enabled, stdout is reserved for the protocol and everything human — banner, logs, diagnostics — goes to stderr (the convention every other MCP stdio server follows). When it is off, nothing changes.

Not investigated here

Which seam owns it: the serve command's banner/logger wiring, the kernel logger's stream choice, or a mode flag the MCP plugin sets when it claims stdout. Whoever picks it up should also decide whether --json-style stdout-purity rules already in packages/cli/test/json-stdout-purity.e2e.test.ts extend to this case.

Repro

  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. Capture the child's stdout in full.
  3. Every line before the first JSON-RPC frame is protocol garbage the client must parse.

(On main today, step 3 needs PR #7914 to get a frame at all.)

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions