Repository navigation
Commit b7f29dd
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)
Fixes #24191
Clause-②: no
`os serve` with the stdio MCP transport enabled and no config argument
now answers the `initialize` an MCP client writes when it spawns the
server, and it boots the configured app.
## What was wrong (re-measured on `origin/main` `f963d8e2b`, built CLI
through `bin/run.js`)
- **H1 holds.** `serve`'s `config` positional (the `Args.string` at
about `serve.ts:1400`) had no `ignoreStdin`. In `@oclif/core` 5.1.2
(`lib/parser/parse.js`), `_args()` calls `tryStdin` for every positional
that has no token, unless the arg sets `ignoreStdin`. A line already
waiting in the pipe becomes the value.
- The fixture is the shape the card names: a host config (`defineStack`
plus a plugin that writes a marker from `start()`), a compiled
`dist/objectstack.json` beside it, a key minted through `serve --dev`
and `POST /keys`, and `os serve` with `OS_MCP_STDIO_ENABLED=true`, that
key and `NODE_ENV` unset (production). The `initialize` is written at
spawn in both rows.
| shape | before (`f963d8e2b`) | after |
|---|---|---|
| `os serve -p PORT --no-ui` (no config argument) | no answer within 60
s; `No objectstack.config.ts found — booting from artifact (default
host)...` beside the config; host plugin marker absent | answered (`id`
24191); host plugin marker present |
| control: `os serve objectstack.config.ts -p PORT --no-ui` | answered,
config loaded (1 passed) | unchanged |
## The fix (`packages/cli/src/commands/serve.ts`)
`ignoreStdin: true` on `serve`'s `config` positional. 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:
- a config piped into `serve`, `start` or `compile` (a pipe into `os`,
`objectstack` or `bin/run(-dev).js` followed by one of those commands):
**0** hits;
- a stdin redirect into those commands: **0** real hits (the 6 regex
hits are prose placeholders such as a bracketed "config dir" after `os
serve`);
- `stdin.write(` / `stdin.end(` under `packages/cli`: **5** hits, 3
login prompts and 2 JSON-RPC `initialize` writes, 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 it
**H3 holds, measured.** That file spawns `serve` with no positional
because oclif's `tryStdin` used to pause stdin, and a paused stdin is
the premise of the `resume()` in `MCPServerRuntime.start()`. After this
fix nothing in the spawned CLI pauses stdin, so the premise was gone:
| e2e file | `resume()` removed (local ablation of `packages/mcp`,
rebuilt, marker in 2 built files) | `resume()` restored |
|---|---|---|
| as on `f963d8e2b` | **passed**: vacuous, it no longer guards
`resume()` | passed |
| re-grounded (this PR) | **RED**: `the stdio transport started but
never answered initialize`, after its premise assertion passed | passed
|
The re-grounded stdio boot runs with a `--import` preload, a `data:` URL
so the whole host stays on the page. The preload is the "host that
touched stdin before `start()`" that the `resume()` comment names. It
pauses `process.stdin` before the CLI loads, which is what readline's
`Interface.close()` did under oclif. It also prints `readableFlowing`
each time a `data` listener attaches. The test asserts every reported
state is `false`, 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/mcp` ablation was local only. It was restored through
`ablation-replace.mjs` (blob `3ef6ce641` equals HEAD), the rebuilt
`dist/` 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.ts`
is in the `integration` tier, because it spawns `bin/run.js`. It runs
one minting `serve --dev` boot, one `os compile` for the artifact, and
then the boot under test with no config argument and the `initialize`
written at spawn. It asserts:
1. A parseable JSON-RPC frame with the request's own `id` (24191), no
`error`, a `protocolVersion` and a `serverInfo.name`.
2. The host config's plugin marker on stderr: the config was composed,
not the artifact beside it.
Before the boot it asserts the positive control: `dist/objectstack.json`
exists. 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`, so `scripts/check-cli-test-child-env.mjs`'s pinned
built-entrypoint population gains it, going from eleven to twelve files,
with the `Joined the population deliberately` note every member carries.
**Reverse verification.** The fix was committed first (`49bd321ea`).
Both legs went through `scripts/ablation-replace.mjs` (wrapped restore)
and `scripts/ablation-dist-preflight.mjs`, with the CLI rebuilt for each
leg.
- **Mutate** (`, ignoreStdin: true` deleted): the marker is absent from
all 652 built files, and the pin is **RED**: `initialize was never
answered` / `no answer within 60000 ms`, with `No objectstack.config.ts
found — booting from artifact (default host)...` in the transcript.
- **Restore:** blob `cd1aa3d29` equals HEAD and `git diff HEAD` is
empty. The rebuilt `dist/` 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 merging `origin/main`
`e46ce7b6c`)
- A full `turbo run build` (76/76 tasks) ran first, because the merge
moved `packages/spec`.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. `tsc -p
tsconfig.test.json --noEmit --listFiles` lists both changed test files.
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 310 files, 4599 tests passed.
- `vitest run --project integration` on the new pin and
`start-mcp-stdio-early-initialize.integration.test.ts`: 2 passed. The
`os start` pin was run because its `serve` child is the command changed
here. The rest of the `integration` tier 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/objectstack` derived 87 commands, and all 87 exited 0
(each exit captured before any pipe). `--ran` reconciled: `87 derived,
87 run, 0 NOT-MEASURED, 0 UNRUN`. This includes
`check:cli-test-child-env` (15 built-CLI spawns, all out of
`development`/`test`), `check:pm-dispatch-gates` (1884 cases),
`check:nul-bytes`, `check:dual-build-cjs-loads` and
`check:i18n-coverage`.
- Edited gate script: `node scripts/check-cli-test-child-env.mjs
--self-test` passed 152 cases. No test suite exercises the script; its
only other mention is prose in
`serve-node-env-production-default.e2e.test.ts`.
- `pnpm exec eslint --no-inline-config` over the four changed source
files: exit 0, no output. The repo-wide `pnpm lint` is left to CI.
## Acceptance notes
- The comment beside the `resume()` in
`packages/mcp/src/mcp-server-runtime.ts` still says that `os serve
--dev` with 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 before `start()`.
`packages/mcp` is outside this card's surface, so it is noted here and
not edited.
- `os compile`'s optional `config` positional (no default, no
`ignoreStdin`) still reads a pending stdin line. `compile` is not an MCP
door, and `os start`'s compile spawn already gives it no stdin. Noted
only.
- `os dev` is unchanged and is not a stdio door (see PR #24184's notes).
- The e2e's preload stands in for a host that pauses stdin; it is not a
production caller. After this PR no production door pauses stdin before
the transport attaches. Measured: the e2e as it stood on `f963d8e2b`
passes with `resume()` removed.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a19b2a7 commit b7f29dd
5 files changed
Lines changed: 449 additions & 9 deletions
File tree
- .changeset
- packages/cli
- src/commands
- test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1397 | 1397 | | |
1398 | 1398 | | |
1399 | 1399 | | |
1400 | | - | |
| 1400 | + | |
| 1401 | + | |
| 1402 | + | |
| 1403 | + | |
| 1404 | + | |
| 1405 | + | |
| 1406 | + | |
1401 | 1407 | | |
1402 | 1408 | | |
1403 | 1409 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
22 | 37 | | |
23 | 38 | | |
24 | 39 | | |
| |||
30 | 45 | | |
31 | 46 | | |
32 | 47 | | |
33 | | - | |
| 48 | + | |
| 49 | + | |
34 | 50 | | |
35 | 51 | | |
36 | 52 | | |
| |||
93 | 109 | | |
94 | 110 | | |
95 | 111 | | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
96 | 136 | | |
97 | 137 | | |
98 | 138 | | |
| |||
134 | 174 | | |
135 | 175 | | |
136 | 176 | | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
141 | 182 | | |
142 | | - | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
143 | 188 | | |
144 | | - | |
| 189 | + | |
145 | 190 | | |
146 | 191 | | |
147 | 192 | | |
| |||
423 | 468 | | |
424 | 469 | | |
425 | 470 | | |
| 471 | + | |
426 | 472 | | |
427 | 473 | | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
428 | 486 | | |
429 | 487 | | |
430 | 488 | | |
| |||
0 commit comments