Repository navigation
fix(mcp): write a command Windows can actually spawn (ALI-1135) - #314
Conversation
`align mcp --setup` wrote `"command": "align"` on every platform. An npm global install on Windows exposes `align.cmd`, a batch shim, and a Windows process spawn resolves a `.cmd` only through a shell - Node's own child_process refuses one outright without `shell: true` since CVE-2024-27980. So the entry named something the client cannot launch, and it failed on OUR side, silently: our write succeeded, so nothing here had an error to report, and the user saw an ENOENT from a config Align wrote for them. That is the first ten minutes after the Kentico demo, whose engineers are on Windows with Copilot in VS Code. On win32 the entry is now `cmd /c align mcp`. `cmd` is a real executable, so it launches whether or not the client spawns through a shell of its own, and cmd resolves `align` -> `align.cmd` via PATH + PATHEXT. That is the form the MCP ecosystem settled on (microsoft/vscode#299595, modelcontextprotocol/servers#3460), and the form this repo's own Windows smoke already relies on. The reported site was VS Code; it was a lower bound. Every writer of this fact is fixed through one `alignSpawn()`: - `alignServerEntry` - vscode, zed, pi, copilot and the mcpServers default, which covers Claude Desktop, Claude Code, Cursor, Windsurf and Gemini CLI - `codexBlock` - the Codex TOML, which built its own `command = "align"` line and would have been left broken by a fix to the JSON half alone - the two hand-over strings a HUMAN pastes: `align mcp --setup` with nothing detected, and `connectDetectedAgents`' no-global-config message - `align mcp --help`'s config snippet One deliberate exception, `alignServerEntry(..., { committed: true })`: the project-local `.mcp.json` stays on the bare form everywhere. It is committed and read by the whole team, and no single string spawns on both platforms, so a Windows machine wrapping it would commit a config that fails for every macOS and Linux checkout. Windows readers get the wrapper from their per-machine config instead. Pinned by a test over all three platforms. The Windows install-smoke leg could not have caught this: `align mcp --setup` writes no config there (the synthetic HOME means detectEditors finds nothing, so only the exit code is exercised), and the handshake step spawns through a shell on win32, which is exactly what VS Code does not do. `smoke-mcp-config-spawn.mjs` now drives the WRITE through the installed package and spawns what it wrote with `shell: false`, with a negative control so a clean pass cannot be a harness that sees nothing. Unverified: nobody has run this on a real Windows desktop. The mechanism is documented and the repo already carried the platform branch in its own smoke, and the new smoke step is what turns that into evidence on every PR. ## Test plan RED first, `src/__tests__/mcp-setup-windows.test.ts` against unmodified `main`, `process.platform` stubbed to win32 rather than inherited: ``` Tests 10 failed | 3 passed (13) AssertionError: expected 'align' not to be 'align' // Object.is equality (all five JSON formats) AssertionError: expected '# >>> align (managed by `align setup`...' to contain 'command = "cmd"' (the Codex TOML block) ``` The 3 that passed are the POSIX side, which must not move. Controls, because a green suite proves nothing about a branch it never took: - disabling the win32 branch in `alignSpawn` reddens 11 tests across the two new suites, including the pasted-config one - flipping `pinPlatform('linux')` to win32 in `mcp-setup.test.ts` reddens 9, so the pin is load-bearing rather than decoration - injecting an unspawnable command into the built writer makes the new smoke script exit 1 with "the config align just wrote is NOT spawnable on linux", and its own negative control fires first Gates, all of the `test` job's, on Linux: - [x] `npm run typecheck` - 0 - [x] `npm run lint` - 0 - [x] `npm run test:coverage` - 213 files, 2490 passed, 3 skipped, thresholds met - [x] `npm run build` - 0 - [x] `scripts/__tests__/test-smoke-timeout.sh` - all passed - [x] `scripts/__tests__/test-smoke-model.sh` - all passed - [x] `scripts/__tests__/test-install-assets.sh` - all passed - [x] `scripts/__tests__/test-check-test-runners.sh` - all passed - [x] `scripts/check-test-runners.sh` - 0 - [x] `scripts/smoke-mcp-config-spawn.mjs` run by hand against the built package in an isolated HOME: green on the real config, red on an injected bad one Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The committed project .mcp.json remains unspawnable on Windows for project-scoped clients such as Claude Code and pi, and the new smoke helper is missing from the existing syntax-test coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
This PR fixes Windows MCP startup by emitting cmd /c align mcp for per-machine configurations.
Changes:
- Centralizes platform-aware MCP command generation across JSON and Codex formats.
- Adds explicit Windows/POSIX tests and an installed-package spawn smoke test.
- Documents Windows configuration behavior and preserves portable project config output.
| File | Description |
|---|---|
src/lib/mcp-setup.ts |
Adds platform-aware command generation. |
src/lib/agent-rules.ts |
Keeps committed project config portable. |
src/commands/mcp.ts |
Renders platform-aware help and manual config. |
src/commands/connect-agents.ts |
Renders platform-aware handoff config. |
src/__tests__/setup.test.ts |
Updates MCP module mocking. |
src/__tests__/mcp-setup.test.ts |
Pins POSIX test behavior. |
src/__tests__/mcp-setup-windows.test.ts |
Adds Windows command and writer coverage. |
src/__tests__/mcp-setup-user-hooks.test.ts |
Pins platform for hook tests. |
src/__tests__/mcp-local-routing.test.ts |
Updates MCP module mocking. |
src/__tests__/helpers/platform.ts |
Adds platform stubbing helpers. |
src/__tests__/connect-agents.test.ts |
Tests platform-specific handoff output. |
src/__tests__/agent-rules.test.ts |
Tests portable project config output. |
scripts/smoke-mcp-config-spawn.mjs |
Verifies written configs spawn without a shell. |
scripts/smoke-install.sh |
Runs the new MCP spawn smoke test. |
docs/mcp.md |
Documents Windows command wrapping. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // `committed: true` keeps the Windows `cmd /c` wrapper OUT of this file (ALI-1135). It is | ||
| // the one MCP config that travels: a wrapper written here by a Windows machine would be | ||
| // committed and then fail to spawn for every teammate on macOS or Linux. Windows readers | ||
| // get the wrapper from their own user-level entry instead. | ||
| servers['align'] = alignServerEntry('mcpServers', env, { committed: true }); |
There was a problem hiding this comment.
Right concern, and you found a real hole in my reasoning: align setup can write .mcp.json on Windows with no global agent config detected, so "their user-level entry covers them" is not guaranteed. Thank you.
Declining the suggested fix, taking the concern.
Why not wrap the project entry. .mcp.json is committed and read by the whole team (see writeProjectMcpConfig's own comment: "it is committed, so one write covers the whole team"). There is no single string that spawns on both platforms, so a Windows machine wrapping it ships a config that fails for every macOS and Linux checkout - trading one person's local problem for a team-wide one, in a file that shows up in a diff long after the machine that wrote it is forgotten.
What was actually wrong is the CLAIM about that file. align setup told a Windows user "Claude Code and pi already read the project's .mcp.json, so those need nothing further", which on Windows is this PR's defect restated in prose - and that branch is the only thing a user with no global agent config is ever told. Fixed in ce0f18e: the sentence is platform-aware, names that Windows cannot spawn the portable command, and points at the per-machine entry, which is printed right below it already wrapped. docs/mcp.md says the same.
Proven by injection rather than by reading: forcing the POSIX wording reddens the Windows case alone (1 failed | 19 passed), reverting restores 20.
Reproduced first, too, since the ticket called the premise unconfirmed. A temporary probe on the windows-latest install-smoke leg (commit 3dfc2d6, reverted in 5a15e22) measured it: PROBE: the OLD form (command:"align", shell:false) on win32: spawned=false ENOENT spawn align ENOENT, against spawned=true on ubuntu.
| #!/usr/bin/env node | ||
| /** | ||
| * ALI-1135: what `align mcp --setup` WRITES must be spawnable by a client that does not use a |
There was a problem hiding this comment.
Correct, and the miss is the point: a hand-written list one entry short goes red nowhere, which is the failure this suite exists to prevent one level up.
Fixed in f20c8c8 by deriving NODE_HELPERS from scripts/*.mjs rather than adding the one entry, so the next helper is covered on arrival. The count is asserted before the parse, so a glob that matched nothing cannot make every assertion vacuous.
Proven by injection: an unbalanced brace appended to smoke-mcp-config-spawn.mjs reddens that file's case (1 failed | 7 passed) and nothing else; reverting restores 8 passed. It also picked up three other .mjs helpers that were never in the list.
…ows (ALI-1135) TEMPORARY, reverted in the next commit. The ticket's first task is to reproduce rather than to fix: nobody had run the old config on a real Windows machine, so the premise was documented upstream and unmeasured here. This asserts the defect, so a PASS on the windows-latest install-smoke leg IS the reproduction, and a PASS on the ubuntu legs is the control that POSIX was never affected. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…sured (ALI-1135) The probe did its job on the previous commit's CI run: windows-latest, node 24: PROBE: the OLD form (command:"align", shell:false) on win32: spawned=false ENOENT spawn align ENOENT ubuntu-latest, node 24: PROBE: the OLD form (command:"align", shell:false) on linux: spawned=true So the premise ALI-1135 called plausible and unconfirmed is now measured: a bare `align` cannot be spawned on Windows without a shell, and POSIX is unaffected. The error is the one a VS Code user sees against a file Align wrote for them. It is reverted rather than kept, because an assertion that a bare `align` FAILS would go red the day npm ships a real executable, and that would be good news. The numbers move into the script's docblock, where the next reader looks. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…sting it (ALI-1135) Copilot, low: smoke-mcp-config-spawn.mjs was added, wired into CI, and left out of NODE_HELPERS, so the parse gate did not cover it. Correct, and the miss is the point: a hand-written list one entry short goes red nowhere, which is the exact failure this suite exists to prevent, one level up. Reading scripts/ covers the next helper on arrival. The count is asserted first, so a glob that matched nothing cannot make every parse assertion vacuous. Proven by injection: an unbalanced brace appended to smoke-mcp-config-spawn.mjs reddens that file's case (1 failed | 7 passed) and nothing else; reverting restores 8 passed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…gh (ALI-1135) Copilot, high, on #314: the portable project file is the one a Windows client cannot spawn, and `align setup` told that user "Claude Code and pi already read the project's .mcp.json, so those need nothing further". On Windows that sentence is the defect this PR fixes, restated in prose - and this branch is the only thing a user with no global agent config is ever told. The suggested fix - wrap the project entry too - is declined, and the concern it comes from is not. That file is committed: a Windows machine writing `cmd /c` into it ships a config that fails for every macOS and Linux checkout, trading a local problem for a team-wide one. What was actually wrong is the claim about it, so the claim is now platform-aware, in the command and in docs/mcp.md. Proven by injection: forcing the POSIX wording reddens the Windows case alone (1 failed | 19 passed); reverting restores 20. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both findings are addressed. The review is frozen on an older commit, and this repo cannot re-review.Anyone reading this PR sees Open (2) describing code that is no longer on the branch. That is not two unresolved findings, and it is not the author ignoring them. Four commits landed after that review, two of which are the fixes. Finding 2, the smoke helper missing from
|


Closes ALI-1135.
The defect
align mcp --setupwrote"command": "align"on every platform. An npm global install onWindows exposes
align.cmd, a batch shim, and a Windows process spawn resolves a.cmdonlythrough a shell - Node's own
child_processrefuses one outright withoutshell: truesinceCVE-2024-27980. So the entry named something the client cannot launch.
It fails on our side, silently. Our write succeeded, so nothing in the CLI has an error to
report; the user just sees an ENOENT from VS Code against a file Align wrote for them. That is
the first ten minutes after the Kentico demo: Windows, GitHub Copilot in VS Code,
npm install -g @aligndottech/clithenalign mcp --setup.src/lib/mcp-setup.ts:29onorigin/main:The fix
cmd /c align mcpon win32, unchanged everywhere else.cmdis a real executable, so itlaunches whether or not the client spawns through a shell of its own, and cmd resolves
align->align.cmdvia PATH + PATHEXT.That form was chosen by reading rather than guessing. It is what the ecosystem settled on for
exactly this failure (microsoft/vscode#299595,
modelcontextprotocol/servers#3460),
and it is what this repo already relies on:
scripts/smoke-install.shspawns the bare namethrough cmd.exe, with a comment saying "cmd resolves
align-> align.cmd via PATH + PATHEXT",and
smoke-mcp-handshake.mjscarriesshell: process.platform === 'win32'for the same reason.align.cmdas a barecommandwas the workaround the docs suggested and is NOT reliable - thebarrier is that a
.cmdneeds cmd.exe at all, not that the filename lacks an extension.Every config writer found, and whether it was affected
The reported site was VS Code. It was a lower bound - all of these spell the same fact.
alignServerEntry-vscodealignServerEntry-mcpServersalignServerEntry-zedalignServerEntry-pialignServerEntry-copilotcodexBlock(TOML)command = "align"line and a fix to the JSON half alone would have left it brokenrunMcpSetup's no-editor messagealignServerEntryconnectDetectedAgents' no-global-config messagealign setupalign mcp --helpsnippetwriteProjectMcpConfig(.mcp.json)user-hooks.tshook entriesalignresolves fineThe judgement call worth a reviewer's attention.
.mcp.jsonis committed and read by thewhole team, and no single string spawns on both platforms. A Windows machine that wrapped it
would commit a config that fails for every macOS and Linux checkout, so it keeps the bare form
on every platform via
alignServerEntry(..., { committed: true }), and Windows readers get thewrapper from their per-machine config. That converts a Windows-only local problem into nothing
for everyone else, at the cost of a Windows user's project-scoped entry still needing the
user-level one. If you would rather wrap it too, it is one flag.
Related, and NOT fixed here.
agent-rules.ts:290and:423generate the pi extension andthe opencode plugin, and both call
execFile("align", ["check", ...])- the same Windows spawnproblem, in a different mechanism with a different failure mode (fail-open: the advisory check
silently returns nothing rather than the MCP server failing to start). Worth its own ticket
rather than widening this diff onto a security-adjacent path.
Why CI could not have caught it, and now can
The Windows install-smoke leg exercises
align mcp --setup's exit code and a shell-spawnedhandshake. Neither can see this: the synthetic
HOMEmeansdetectEditors()finds nothing, sono config is written at all on that leg, and the handshake passes
shell: trueon win32,which is exactly what VS Code does not do.
scripts/smoke-mcp-config-spawn.mjsdrives the WRITE through the installed package, then spawnswhat it wrote with
shell: falseand completes an MCPinitialize. It carries its own negativecontrol, so a pass cannot mean "the harness saw nothing". It runs on the
windows-latestleg,which pull requests reach.
Reproduced, on Windows, before trusting the fix
The ticket called the premise plausible and unconfirmed, and its first task was to reproduce
rather than to fix. A temporary probe (commit 2, reverted in commit 3) spawned the OLD form
with
shell: falseon both legs of the install-smoke matrix:spawn align ENOENTon Windows is exactly what a VS Code user sees against a file Align wrotefor them, and Linux is the control showing POSIX was never affected. The probe was reverted
rather than kept: a permanent assertion that a bare
alignFAILS would go red the day npm shipsa real executable, which would be good news. What it measured is recorded in the smoke script's
docblock.
Still not done here: nobody has driven it by hand through a real VS Code Copilot Chat session on
a Windows desktop. The spawn - the part that was broken - is now measured on Windows in CI on
every PR.
docs/mcp.mddocumented the caveat as unverified and suggestedalign.cmd. It now describeswhat the writer emits, so the doc and the writer agree - which is why the fix was kept out of
#313.
Test plan
RED first,
src/__tests__/mcp-setup-windows.test.tsagainst unmodifiedmain, withprocess.platformstubbed to win32 rather than inherited (a test that only runs the POSIXbranch proves nothing about Windows):
The 3 that passed are the POSIX side, asserted explicitly on both
linuxanddarwinso thehalf that must not move is pinned too.
Controls, because a green suite says nothing about a branch it never took:
alignSpawnreddens 11 tests across the two new suites,including the pasted-config one
pinPlatform('linux')to'win32'inmcp-setup.test.tsreddens 9, so the pinis load-bearing rather than decoration
the config align just wrote is NOT spawnable on linux: spawn ... ENOENT, and its own negativecontrol fires before it
Gates, all of the
testjob's, on Linux:npm run typecheck- 0npm run lint- 0npm run test:coverage- 213 files, 2490 passed, 3 skipped, thresholds metnpm run build- 0scripts/__tests__/test-smoke-timeout.sh- all passedscripts/__tests__/test-smoke-model.sh- all passedscripts/__tests__/test-install-assets.sh- all passedscripts/__tests__/test-check-test-runners.sh- all passedscripts/check-test-runners.sh- 0scripts/smoke-mcp-config-spawn.mjsby hand against the built package in an isolatedHOME: green on the real config, red on an injected bad one
install-smoke (windows-latest, 24),which is the leg that carries the new step
Merging is not shipping. This repo releases through release-please: the fix reaches Kentico
only once the
chore(main): release cli X.Y.ZPR merges andnpm publishruns. Verify withnpm view @aligndottech/cli version.🤖 Generated with Claude Code