Skip to content

fix(mcp): write a command Windows can actually spawn (ALI-1135) - #314

Merged
tom-at-align merged 5 commits into
mainfrom
tom/ali-1135-vscode-windows-command
Sep 21, 2026
Merged

tom-at-align merged 5 commits into
mainfrom
tom/ali-1135-vscode-windows-command

Conversation

@tom-at-align

@tom-at-align tom-at-align commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes ALI-1135.

The defect

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.

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/cli then align mcp --setup.

src/lib/mcp-setup.ts:29 on origin/main:

case 'vscode':
  return { type: 'stdio', command: 'align', args };

The fix

cmd /c align mcp on win32, unchanged everywhere else. 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 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.sh spawns the bare name
through cmd.exe, with a comment saying "cmd resolves align -> align.cmd via PATH + PATHEXT",
and smoke-mcp-handshake.mjs carries shell: process.platform === 'win32' for the same reason.
align.cmd as a bare command was the workaround the docs suggested and is NOT reliable - the
barrier is that a .cmd needs 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.

Writer Reaches Affected Now
alignServerEntry - vscode VS Code / Copilot Chat yes wrapped
alignServerEntry - mcpServers Claude Desktop, Claude Code, Cursor, Windsurf, Gemini CLI yes wrapped
alignServerEntry - zed Zed yes wrapped
alignServerEntry - pi pi yes wrapped
alignServerEntry - copilot GitHub Copilot CLI yes wrapped
codexBlock (TOML) Codex CLI yes - a second writer, it built its own command = "align" line and a fix to the JSON half alone would have left it broken wrapped, through the same helper
runMcpSetup's no-editor message a human pasting by hand yes - and worst here, since no writer is left to correct it rendered from alignServerEntry
connectDetectedAgents' no-global-config message a human pasting by hand, during align setup yes rendered
align mcp --help snippet a human pasting by hand yes rendered
writeProjectMcpConfig (.mcp.json) Claude Code, pi, project-scoped yes, and deliberately left bare - see below unchanged, pinned by a test
user-hooks.ts hook entries Codex, Cursor, Copilot CLI no - these are shell command STRINGS the host runs through a shell, where align resolves fine unchanged

The judgement call worth a reviewer's attention. .mcp.json is committed and read by the
whole 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 the
wrapper 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:290 and :423 generate the pi extension and
the opencode plugin, and both call execFile("align", ["check", ...]) - the same Windows spawn
problem, 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-spawned
handshake. Neither can see this: the synthetic HOME means detectEditors() finds nothing, so
no config is written at all on that leg, and the handshake passes shell: true on win32,
which is exactly what VS Code does not do.

scripts/smoke-mcp-config-spawn.mjs drives the WRITE through the installed package, then spawns
what it wrote with shell: false and completes an MCP initialize. It carries its own negative
control, so a pass cannot mean "the harness saw nothing". It runs on the windows-latest leg,
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: false on both legs of the install-smoke matrix:

install-smoke (windows-latest, 24)
  PROBE: the OLD form (command:"align", shell:false) on win32: spawned=false ENOENT spawn align ENOENT
  smoke-mcp-config-spawn: wrote {"type":"stdio","command":"cmd","args":["/c","align","mcp","--env","local"]}
  smoke-mcp-config-spawn: OK - the written config spawned without a shell and answered initialize on win32

install-smoke (ubuntu-latest, 24)
  PROBE: the OLD form (command:"align", shell:false) on linux: spawned=true

spawn align ENOENT on Windows is exactly what a VS Code user sees against a file Align wrote
for them, and Linux is the control showing POSIX was never affected. The probe was reverted
rather than kept: a permanent assertion that a bare align FAILS would go red the day npm ships
a 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.md documented the caveat as unverified and suggested align.cmd. It now describes
what 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.ts against unmodified main, with
process.platform stubbed to win32 rather than inherited (a test that only runs the POSIX
branch proves nothing about Windows):

Tests  10 failed | 3 passed (13)

AssertionError: expected 'align' not to be 'align' // Object.is equality
   (all five JSON formats)
AssertionError: expected { type: 'stdio', ...(2) } to deeply equal { type: 'stdio', command: 'cmd', ...(1) }
AssertionError: expected '# >>> align (managed by `align setup`...' to contain 'command = "cmd"'

The 3 that passed are the POSIX side, asserted explicitly on both linux and darwin so the
half that must not move is pinned too.

Controls, because a green suite says 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: spawn ... ENOENT, and its own negative
    control fires before it

Gates, all of the test job's, on Linux:

  • npm run typecheck - 0
  • npm run lint - 0
  • npm run test:coverage - 213 files, 2490 passed, 3 skipped, thresholds met
  • npm run build - 0
  • scripts/__tests__/test-smoke-timeout.sh - all passed
  • scripts/__tests__/test-smoke-model.sh - all passed
  • scripts/__tests__/test-install-assets.sh - all passed
  • scripts/__tests__/test-check-test-runners.sh - all passed
  • scripts/check-test-runners.sh - 0
  • scripts/smoke-mcp-config-spawn.mjs by hand against the built package in an isolated
    HOME: green on the real config, red on an injected bad one
  • CI on this branch: every check green, including 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.Z PR merges and npm publish runs. Verify with
npm view @aligndottech/cli version.

🤖 Generated with Claude Code

`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]>
Copilot AI lite review requested due to automatic review settings September 21, 2026 18:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 1 Low severity

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.

Comment thread src/lib/agent-rules.ts
Comment on lines +244 to +248
// `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 });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +3
#!/usr/bin/env node
/**
* ALI-1135: what `align mcp --setup` WRITES must be spawnable by a client that does not use a

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

tom-at-align and others added 4 commits September 21, 2026 19:53
…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]>
@tom-at-align

Copy link
Copy Markdown
Contributor Author

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.

PR head            ce0f18e0c
Copilot's review   f0b8fd56d   at 18:52:23Z

Four commits landed after that review, two of which are the fixes.

Finding 2, the smoke helper missing from NODE_HELPERS - fixed, and better than asked

src/__tests__/smoke-script-syntax.test.ts:31 now derives the list:

const NODE_HELPERS = readdirSync(join(ROOT, 'scripts'))

with an explicit pin at line 48 that scripts/smoke-mcp-config-spawn.mjs is in it. The finding asked for one entry to be added; deriving the list means the next helper is covered on the day it lands rather than the day someone remembers. Commit f20c8c8.

Finding 1, the project .mcp.json staying bare on Windows - concern taken, fix declined with a reason

The fix was declined deliberately: no single string spawns on both platforms, so a Windows machine wrapping the committed file would commit a config that fails for every macOS and Linux checkout. That is one flag to reverse if the call is wrong.

The concern was real and is fixed. align setup was telling Windows users that Claude Code and pi already read .mcp.json so nothing further was needed, which is false on Windows. Commit ce0f18e - "stop telling a Windows user the project .mcp.json is enough" - and docs/mcp.md:36-41 now carries the caveat in the form the reader needs:

Every block below is the macOS and Linux form. On Windows, replace... a Windows process spawn resolves a .cmd only through a shell, so a bare align is not enough.

Why the review is stuck, which is the part worth fixing

This repo's ruleset 20917995 has copilot_code_review with review_on_push: false. So Copilot reviews once when a PR opens and never again: a push does not trigger a re-review, and a REST re-request does not either - measured on this PR, 13 minutes after the call with no result.

That is the same defect ALI-1170 found and fixed on align-stack's ruleset. It was never applied here, because a ruleset is per-repo and nothing enumerated that. ALI-1185 tracks it; the change needs Tom's authorisation.

Until then a finding on this repo stays Open against a stale commit until a human clicks re-review, and the PR misreports itself. Worth knowing before reading any align-cli review today.

@tom-at-align
tom-at-align merged commit 53c4bf6 into main Sep 21, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants