Skip to content

Commit 7d6ec91

Browse files
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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(cli): `os start` with the stdio MCP transport answers the `initialize` an MCP client writes when it spawns the server, and boots the configured app
8+
9+
- **What was wrong.** An MCP client writes `initialize` to the server's stdin as soon as it spawns it. `os start` hands its stdin down to the processes it starts, and the CLI's argument parser fills an omitted positional argument from a pending stdin line. So the client's `initialize` became a config path. In a project with no `dist/objectstack.json`, the automatic compile step failed with `Config file not found: …/{"jsonrpc":"2.0",…}` and `os start` exited 1. In a project with a compiled artifact, the server printed `No objectstack.config.ts found — booting from artifact`, booted without the config's own plugins, and never answered `initialize`.
10+
- **What changed.** The automatic compile step now gets no stdin, and `os start` names the config path when it starts `os serve` (the same `objectstack.config.ts` in the working directory that `os serve` used by default). The client's first message stays in the pipe for the stdio transport, and the config is loaded.
11+
- **Unchanged:** the flags and environment variables of `os start`, `os serve` and `os compile`, and which config and artifact a boot uses.

‎packages/cli/src/commands/start.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,19 @@ export default class Start extends Command {
240240
const outputPath = path.resolve(cwd, CONVENTIONAL_ARTIFACT_RELATIVE_PATH);
241241
printStep('Compiling objectstack.config.ts → dist/objectstack.json...');
242242
const binPath = process.argv[1];
243+
// ── stdin belongs to the protocol, never to the compile step (#24002) ──
244+
// This command's stdin is the stdio MCP transport's input once the
245+
// `serve` child below starts it, and an MCP client writes `initialize`
246+
// the moment it spawns `os start` — before this one-shot child runs.
247+
// Inherited, the child's argument parser read that pending line as its
248+
// missing `config` positional (oclif's `tryStdin`), so the compile failed
249+
// on "Config file not found: …/{"jsonrpc":"2.0",…}" and `initialize` was
250+
// consumed before any transport existed. The compile reads no input, so
251+
// it gets none: `'ignore'` keeps every byte in the pipe for `serve`.
243252
const compileResult = spawnSync(
244253
process.execPath,
245254
[binPath, 'compile', '--output', outputPath],
246-
{ stdio: 'inherit', env: process.env },
255+
{ stdio: ['ignore', 'inherit', 'inherit'], env: process.env },
247256
);
248257
if (compileResult.status !== 0) {
249258
printError('Compile failed — fix errors above before starting the server');
@@ -487,11 +496,25 @@ export default class Start extends Command {
487496
// `const child = spawn(` is the anchor
488497
// `utils/port-contract-single-source.test.ts` reads to hold the port
489498
// door above AHEAD of this spawn — keep the spelling.
499+
//
500+
// ── The config positional is NAMED, never left to stdin (#24002) ──
501+
// stdio stays inherited: the child's stdio MCP transport reads this
502+
// process's stdin. But oclif fills an omitted positional from a pending
503+
// stdin line (`tryStdin`), and `serve`'s `config` is one — so with no
504+
// positional here, an MCP client's `initialize`, written at spawn,
505+
// became "the config path": the child booted without the config the
506+
// banner above names ("No objectstack.config.ts found") and the
507+
// transport never saw `initialize`. A supplied positional skips that
508+
// read. `projectConfigPath` is the path `serve` would default to
509+
// (`objectstack.config.ts` under this same cwd), and the same one the
510+
// `Config:` row was judged by, so whether it joins the boot is still
511+
// the child's own `cwdConfigJoinsBoot` reading — exactly as before.
490512
const child = spawn(
491513
process.execPath,
492514
[
493515
binPath,
494516
'serve',
517+
projectConfigPath,
495518
flags.ui ? '--ui' : '--no-ui',
496519
...(flags.verbose ? ['--verbose'] : []),
497520
...(flags['log-level'] ? ['--log-level', flags['log-level']] : []),

0 commit comments

Comments
 (0)