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 banner —
console.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 logger —
INFO/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
- 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.
- Capture the child's stdout in full.
- 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.)
Found while fixing #7645 (PR #7914). Different defect, different seam — filed rather than folded in.
Symptom
With
OS_MCP_STDIO_ENABLED=true,objectstack serveusesprocess.stdoutas 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 mintedosk_key, full child stdout captured). Theinitializeresult arrives on line 517, behind 516 lines of non-protocol text:Why it matters
The MCP stdio transport is newline-delimited JSON. A spec-conforming client's
ReadBufferJSON.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:
console.login the serve command (✓ Server is ready, the plugin table,Press Ctrl+C to stop). Unconditional, so this holds at the defaultOS_LOG_LEVEL=warntoo.INFO/WARNrecords go to stdout (ERRORwas observed on stderr). The capture above usedOS_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,
stdoutis reserved for the protocol and everything human — banner, logs, diagnostics — goes tostderr(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 inpackages/cli/test/json-stdout-purity.e2e.test.tsextend to this case.Repro
node packages/cli/bin/run.js serve -p <port> --devwithstdio: ['pipe','pipe','pipe'],OS_MCP_STDIO_ENABLED=trueand a validosk_key.(On
maintoday, step 3 needs PR #7914 to get a frame at all.)