Repository navigation
fix(cli): os serve with the stdio MCP transport and no config argument answers an initialize written at spawn — its config positional no longer reads stdin - #24212
Conversation
… path `serve`'s optional `config` positional now sets `ignoreStdin`. oclif otherwise fills an omitted positional from a line already waiting on stdin, and with the stdio MCP transport that line is the client's `initialize`, written at spawn: it became the config path, the boot fell back to the artifact without the config, and the request was never answered. The new integration pin spawns the built CLI with no positional, writes `initialize` at spawn beside a compiled artifact, and asserts the same-id answer and the config's own plugin starting. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
With `serve` no longer reading stdin for its `config` positional, nothing in the spawned CLI paused stdin before the transport attached, so the pin stayed green with the `resume()` in `MCPServerRuntime.start()` removed. The stdio boot now runs with a `--import` preload that pauses `process.stdin` before the CLI loads and reports `readableFlowing` at each `data`-listener attach; the pin asserts that report reads `false`, so the premise is measured on every run. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…he serve early-initialize pin The pin's budget line carries its measurement (11.6 s, slowest of four local runs, x 1.25). It spawns `bin/run.js`, so it joins check:cli-test-child-env's pinned built-entrypoint population, now twelve files. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
席位核对 — ACCEPT · PR #24212 at
|
main brings #24212, which also joined check:cli-test-child-env's pinned built-entrypoint population (serve-mcp-stdio-early-initialize). Both branches bumped the self-test's count line from "eleven" to "twelve", so git merged the two bumps as one identical change while the list grew to thirteen entries. The count line now reads "thirteen"; both entries keep their deliberate-join notes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Fixes #24191
Clause-②: no
os servewith the stdio MCP transport enabled and no config argument now answers theinitializean MCP client writes when it spawns the server, and it boots the configured app.What was wrong (re-measured on
origin/mainf963d8e2b, built CLI throughbin/run.js)serve'sconfigpositional (theArgs.stringat aboutserve.ts:1400) had noignoreStdin. In@oclif/core5.1.2 (lib/parser/parse.js),_args()callstryStdinfor every positional that has no token, unless the arg setsignoreStdin. A line already waiting in the pipe becomes the value.defineStackplus a plugin that writes a marker fromstart()), a compileddist/objectstack.jsonbeside it, a key minted throughserve --devandPOST /keys, andos servewithOS_MCP_STDIO_ENABLED=true, that key andNODE_ENVunset (production). Theinitializeis written at spawn in both rows.f963d8e2b)os serve -p PORT --no-ui(no config argument)No objectstack.config.ts found — booting from artifact (default host)...beside the config; host plugin marker absentid24191); host plugin marker presentos serve objectstack.config.ts -p PORT --no-uiThe fix (
packages/cli/src/commands/serve.ts)ignoreStdin: trueonserve'sconfigpositional. Stdin belongs to the stdio transport; the config path comes from argv or the default. The fix sits at the producer, the parser declaration that let a protocol message become a config path. Nothing downstream changes, and no consumer gains a fallback.The one input-surface change: an omitted config argument is no longer read from stdin. H2 holds on this tree:
serve,startorcompile(a pipe intoos,objectstackorbin/run(-dev).jsfollowed by one of those commands): 0 hits;os serve);stdin.write(/stdin.end(underpackages/cli: 5 hits, 3 login prompts and 2 JSON-RPCinitializewrites, none of them a config path.The changeset states the change and its remedy: pass the path as the argument.
Re-grounding
serve-mcp-stdio-answers.e2e.test.ts(#7645), not dropping itH3 holds, measured. That file spawns
servewith no positional because oclif'stryStdinused to pause stdin, and a paused stdin is the premise of theresume()inMCPServerRuntime.start(). After this fix nothing in the spawned CLI pauses stdin, so the premise was gone:resume()removed (local ablation ofpackages/mcp, rebuilt, marker in 2 built files)resume()restoredf963d8e2bresume()the stdio transport started but never answered initialize, after its premise assertion passedThe re-grounded stdio boot runs with a
--importpreload, adata:URL so the whole host stays on the page. The preload is the "host that touched stdin beforestart()" that theresume()comment names. It pausesprocess.stdinbefore the CLI loads, which is what readline'sInterface.close()did under oclif. It also printsreadableFlowingeach time adatalistener attaches. The test asserts every reported state isfalse, so the premise is measured on every run. A later change that resumes stdin earlier cannot make the file vacuous again without reddening it. The file still passes no positional, the form users type, and still asserts a parsed answer.The
packages/mcpablation was local only. It was restored throughablation-replace.mjs(blob3ef6ce641equals HEAD), the rebuiltdist/no longer carries the marker (6 files), and the tree is clean. The file is nightly-tier by name, so this PR's CI does not run it. The readings above are local.Pin
packages/cli/test/serve-mcp-stdio-early-initialize.integration.test.tsis in theintegrationtier, because it spawnsbin/run.js. It runs one mintingserve --devboot, oneos compilefor the artifact, and then the boot under test with no config argument and theinitializewritten at spawn. It asserts:id(24191), noerror, aprotocolVersionand aserverInfo.name.Before the boot it asserts the positive control:
dist/objectstack.jsonexists. The artifact is what makes the defect silent. Without one, a swallowed config path is a loud "Nothing to serve" exit.The budget line is 15 s: 11.6 s, the slowest of four local runs on a shared container, x 1.25. There is no CI reading yet. The pin spawns
bin/run.js, soscripts/check-cli-test-child-env.mjs's pinned built-entrypoint population gains it, going from eleven to twelve files, with theJoined the population deliberatelynote every member carries.Reverse verification. The fix was committed first (
49bd321ea). Both legs went throughscripts/ablation-replace.mjs(wrapped restore) andscripts/ablation-dist-preflight.mjs, with the CLI rebuilt for each leg., ignoreStdin: truedeleted): the marker is absent from all 652 built files, and the pin is RED:initialize was never answered/no answer within 60000 ms, withNo objectstack.config.ts found — booting from artifact (default host)...in the transcript.cd1aa3d29equals HEAD andgit diff HEADis empty. The rebuiltdist/carries the marker again, the tree is clean, and the pin then passed three times.Predicted direction: red. Observed: red.
Local verification (head
749f1a281, after mergingorigin/maine46ce7b6c)turbo run build(76/76 tasks) ran first, because the merge movedpackages/spec.pnpm --filter @objectstack/cli typecheck: exit 0.tsc -p tsconfig.test.json --noEmit --listFileslists both changed test files.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 310 files, 4599 tests passed.vitest run --project integrationon the new pin andstart-mcp-stdio-early-initialize.integration.test.ts: 2 passed. Theos startpin was run because itsservechild is the command changed here. The rest of theintegrationtier is declared to CI.OS_TEST_TIERS=nightly vitest run --project integration test/serve-mcp-stdio-answers.e2e.test.ts: 1 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 87 commands, and all 87 exited 0 (each exit captured before any pipe).--ranreconciled:87 derived, 87 run, 0 NOT-MEASURED, 0 UNRUN. This includescheck:cli-test-child-env(15 built-CLI spawns, all out ofdevelopment/test),check:pm-dispatch-gates(1884 cases),check:nul-bytes,check:dual-build-cjs-loadsandcheck:i18n-coverage.node scripts/check-cli-test-child-env.mjs --self-testpassed 152 cases. No test suite exercises the script; its only other mention is prose inserve-node-env-production-default.e2e.test.ts.pnpm exec eslint --no-inline-configover the four changed source files: exit 0, no output. The repo-widepnpm lintis left to CI.Acceptance notes
resume()inpackages/mcp/src/mcp-server-runtime.tsstill says thatos serve --devwith no path leaves stdin paused under oclif. After this PR that is history, not the current mechanism. The comment's general reason still holds: any host that touched stdin beforestart().packages/mcpis outside this card's surface, so it is noted here and not edited.os compile's optionalconfigpositional (no default, noignoreStdin) still reads a pending stdin line.compileis not an MCP door, andos start's compile spawn already gives it no stdin. Noted only.os devis unchanged and is not a stdio door (see PR fix(cli): os start answers an MCP initialize written at spawn — its compile and serve children no longer read stdin as the config path #24184's notes).f963d8e2bpasses withresume()removed.Generated by Claude Code