Repository navigation
Commit 7d6ec91
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)
Fixes #24002
Clause-②: no
`os start` with the stdio MCP transport enabled now answers the
`initialize` that an MCP client writes when it spawns the server, and it
boots the configured app.
## What was wrong (measured on `origin/main` `b0fe9fad4`, built CLI
through `bin/run.js`)
- **H1 holds** against the installed `@oclif/core` 5.1.2
(`lib/parser/parse.js`). `_args()` calls `tryStdin` for every positional
that has no token, unless the arg sets `ignoreStdin`. `readStdin` opens
a readline on `process.stdin` and collects lines for
`OCLIF_STDIN_TIMEOUT_MS` (default 10 ms). On abort, `rl.close()` emits
`close` synchronously, and that handler resolves with the lines
collected so far. So a line already waiting in the pipe becomes the
argument's value, and it is consumed.
- **H2 holds.** The `serve` argv was `binPath, 'serve', --ui|--no-ui,
[--verbose], [--log-level]`, with no positional.
- **H3 holds.** `No objectstack.config.ts found — booting from artifact
(default host)...` is printed by `serve.ts` (the `useArtifactFallback &&
!configExists` branch). `anchorServedApp` had resolved the `initialize`
JSON as a config path that does not exist. The parent's banner had
printed `Config: objectstack.config.ts` for the same boot.
- **A second consumer the card did not name.** On a first run (no
`dist/objectstack.json`), `start` auto-compiles through `spawnSync(…,
'compile', '--output', …)` with stdio inherited and no positional.
`compile`'s optional `config` positional reads the line first: `✗ Config
file not found: …/{"jsonrpc":"2.0","id":7,"method":"initialize",…}`, the
compile fails, and `os start` exits 1.
The readings below come from one fixture: a host config (`defineStack`
plus a marker plugin), a key minted through `serve --dev` and `POST
/keys`, and `os start` run with `OS_MCP_STDIO_ENABLED=true` and that
key. The `initialize` is written at spawn in every row.
| shape | before (`b0fe9fad4`) | after |
|---|---|---|
| `os start`, no `dist/` | the compile child reads it: `Config file not
found`, exit 1 | answered (`id` 7) in 8.8 s; `Loading
objectstack.config.ts`; host plugin registered |
| `os start`, `dist/` present (the card's showcase shape) | the serve
child reads it: `No objectstack.config.ts found — booting from
artifact`; host plugin absent; no answer in 60 s | answered (`id` 7) in
6.6 s; `Loading objectstack.config.ts`; host plugin registered |
| control: `os serve objectstack.config.ts` | answered in 4.0 s, config
loaded | unchanged |
| `os serve` with no positional | no answer in 60 s; `No
objectstack.config.ts found` | unchanged: out of scope, see Acceptance
notes |
## The fix (`packages/cli/src/commands/start.ts`)
1. **The compile spawn gets `stdio: ['ignore', 'inherit', 'inherit']`.**
The compile reads no input, so it gets none, and every byte stays in the
pipe for `serve`.
2. **The serve spawn passes `projectConfigPath` as the config
positional.** A supplied positional skips `tryStdin`.
`projectConfigPath` is `path.resolve(cwd, 'objectstack.config.ts')`,
which is exactly what `serve`'s default `objectstack.config.ts` resolves
to in the same cwd (`anchorServedApp` is `path.resolve(process.cwd(),
configArg)`). It is also the path `start`'s own `Config:` row is judged
by. Whether the config joins the boot is still the child's own
`cwdConfigJoinsBoot` reading. No `serve` input surface changes. One
visible difference: the `No default export found in …` refusal now names
the absolute path when it is reached through `start`.
**Why not `ignoreStdin: true` on `serve`'s `config` (the card's
alternative):**
- It changes `serve`'s input surface.
- It would defang the #7645 pin. `serve-mcp-stdio-answers.e2e.test.ts`
deliberately passes no positional, so that `tryStdin` pauses stdin and
the `resume()` in `MCPServerRuntime.start()` is exercised. With
`ignoreStdin`, that pin stays green even with the `resume()` removed.
This route closes `os start`, both children. The bare `os serve` door is
left as a measured finding.
**No feed of a config through stdin found:**
- Shell-piped config into `serve`, `start` or `compile`, regex
`\|\s*(npx\s+)?(os|objectstack|run(-dev)?\.js)\s+(serve|start|compile)\b`
together with a `(serve|compile)` stdin-redirect form: **0** hits.
- `stdin.write(` / `stdin.end(` under `packages/cli`: **5** hits. None
of them is a config path: 3 are login prompts and 2 are JSON-RPC
`initialize` writes.
**#7645 is not regressed.** `packages/mcp`'s `resume()` is untouched.
The `start` → `serve` child now skips `tryStdin` entirely, so stdin is
never paused on that path.
**Surface note.** The claim's file surface named the `spawnChild` argv
(about `:486-500`). The measured fix also needs the compile `spawnSync`
(about `:243`) in the same file, because it is the first stdin consumer
in a fresh project. The pin's built-CLI spawns also join
`check:cli-test-child-env`'s pinned built-entrypoint population, so
`scripts/check-cli-test-child-env.mjs` gains the file, with the `[#N]
Joined the population deliberately` note that every earlier member
carries. The gate's own self-test passes: 152 cases.
## Pin
`packages/cli/test/start-mcp-stdio-early-initialize.integration.test.ts`
is in the `integration` tier: the `vitest-tiers.ts` predicate classifies
it because it spawns `bin/run.js`. It runs one minting `serve --dev`
boot, then one `os start` boot on a first-run host config, with the
`initialize` written at spawn. It asserts:
1. A parseable JSON-RPC frame with the request's own `id` (24002), no
`error`, a `protocolVersion` and a `serverInfo.name`.
2. The host config's plugin wrote its marker from `start()`, so the
config was composed and not just its artifact.
3. `dist/objectstack.json` exists afterwards, as a positive control that
the boot took the auto-compile leg.
The budget line is 20 s: 14.9 s, the slowest of three local runs on a
shared container, x 1.25. There is no CI reading yet.
**Reverse verification.** Fix committed first. Each leg was run through
`scripts/ablation-replace.mjs` and `scripts/ablation-dist-preflight.mjs`
with a trap restore, and the CLI was rebuilt for every leg.
- **compile leg** (stdio back to `'inherit'`): the marker `'ignore',
'inherit', 'inherit'` is absent from `dist/`, and the pin goes **RED**:
`initialize was never answered` / `os start exited 1 before answering`,
with `Config file not found: …/{"jsonrpc":"2.0","id":24002,…}` in the
transcript. Restored: blob equals HEAD (`d874e9853`), `git diff HEAD` is
empty, and after the rebuild the marker is present again.
- **serve leg** (positional line deleted): the marker is absent, and the
pin goes **RED**: `initialize was never answered` / `no answer within
120000 ms`, with `No objectstack.config.ts found — booting from
artifact` in the transcript. Restored the same way.
- **Two serve attempts were voided, and nothing was measured in
either.**
- In the first, the replacement was a substring of the anchor.
`ablation-replace` refused it (a replacement rise of 0) and restored the
file, so the test never ran.
- In the second, the dist preflight's tree reading could not see the
source change, because the indentation differs between `src` and `dist`.
It exited 3, so the test never ran.
- The third attempt passed `--source-marker`.
## Local verification (head `ab0727fa5` unless noted)
- `pnpm --filter @objectstack/cli exec vitest run --project integration
--maxWorkers=2
test/start-mcp-stdio-early-initialize.integration.test.ts`: 1 passed
(14.85 s).
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 309 files / 4591 tests passed, at `fd16e46f6`. The unit
layer is unaffected by the later test-file and gate-script commits. The
`integration` tier is declared to CI apart from the new file.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. `tsc --noEmit
--listFiles -p tsconfig.test.json` includes the new pin.
- `pnpm exec eslint --no-inline-config` over the three changed source
files: exit 0, no output. The repo-wide `pnpm lint` is left to CI.
- The derived gate union (`dispatch-gates.mjs --commands`) is running at
`ab0727fa5`. Its verdicts and the `--ran` reconciliation go in the
`os-dev-report` comment on #24002.
- An earlier run over the 65-command derivation at `0c194a89f` gave 62
exit 0.
- `check:cli-test-child-env` exited 1 because of this pin. It was fixed
in `ab0727fa5`, and the gate is now green: 12 built-CLI spawns, all keep
their child out of `development`/`test`.
- `check:dual-build-cjs-loads` and `check:i18n-coverage` exited 3
(PREREQUISITE NOT MET: packages outside the CLI closure are unbuilt in
this worktree). **NOT MEASURED** locally; CI's `Lint & Repo Gates`
measures them on a full build.
## Acceptance notes
- **`os serve` with no positional loses an early `initialize` the same
way.** Measured: no answer in 60 s, `No objectstack.config.ts found —
booting from artifact` beside a config, host plugin absent. The `os
serve objectstack.config.ts` control answers in 4 s. The fix shape is
`ignoreStdin: true` on `serve`'s `config` positional, together with
re-grounding the #7645 pin's premise (see above). Reported to the seat;
not fixed here.
- **`os start`'s auto-compile writes its progress to stdout, which is
the stdio transport's channel.** In the fixed first-run boot, 27
non-JSON lines (`◆ Compile`, `✓ Build complete`, `Artifact: …`) precede
the `initialize` answer on stdout. A different class: stdout purity, the
#7915 family, not stdin. Reported to the seat; the pin does not assert
purity.
- `os dev`: its own `package` positional (default `all`) and its `serve`
child also read a pending stdin line. `os dev` is not a stdio door
(`docs/qa/platform-checklist/areas/ai.json`: "Not `os dev`", because its
banner owns stdout). Noted only.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2bcaefb commit 7d6ec91
4 files changed
Lines changed: 384 additions & 2 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 | |
|---|---|---|---|
| |||
240 | 240 | | |
241 | 241 | | |
242 | 242 | | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
243 | 252 | | |
244 | 253 | | |
245 | 254 | | |
246 | | - | |
| 255 | + | |
247 | 256 | | |
248 | 257 | | |
249 | 258 | | |
| |||
487 | 496 | | |
488 | 497 | | |
489 | 498 | | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
490 | 512 | | |
491 | 513 | | |
492 | 514 | | |
493 | 515 | | |
494 | 516 | | |
| 517 | + | |
495 | 518 | | |
496 | 519 | | |
497 | 520 | | |
| |||
0 commit comments