Skip to content

feat(mcp): run MCP servers on Windows inside a kill-on-close Job Object - #563

Merged
stuffbucket merged 13 commits into
mainfrom
feat/windows-mcp-containment
Oct 28, 2026
Merged

stuffbucket merged 13 commits into
mainfrom
feat/windows-mcp-containment

Conversation

@stuffbucket

@stuffbucket stuffbucket commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

On Windows, Settings → Integrations said "MCP servers don't run on Windows yet", so Work IQ and every other MCP server were off limits there. The broker refused to start because nothing guaranteed that the MCP servers it starts, and their children, would end when it did. This change provides that guarantee and removes the gate.

How containment works on Windows

  • Before it starts anything, maximal-mcp-broker serve creates a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and assigns its own process to it. It calls kernel32 through bun:ffi (packages/mcp-broker/src/windows-job.ts), checks the assignment with IsProcessInJob, and keeps the only handle open until it exits. The handle can't be inherited, and the job doesn't allow breakaway. If the broker is already in a job (a Windows runner, or anything that starts Maximal inside one), its job is nested in that one.
  • The MCP servers the broker starts, and anything they start, are in the job. When the broker exits or is terminated, Windows kills all of them.
  • If Windows refuses any step, the broker starts nothing. It writes MCP_BROKER_CONTAINMENT_UNAVAILABLE step=<step> error=<code> to stderr and exits with status 78. On Windows only, the supervisor reports the broker as unavailable with the problem containment-unavailable and doesn't keep retrying. Settings says "MCP servers couldn't start" and points to the MCP server log.
  • When the supervisor stops the broker, it asks it to shut down, waits a bounded time, then calls TerminateProcess on it. That closes the job, which ends every descendant.
  • However serve stops (a shutdown frame, stdin closing, the --parent-pid process gone, a signal, or an error), the broker exits at most 3 seconds after it begins closing, even if an open session holds its HTTP server. A crashed sidecar therefore can't leave a broker behind: its stdin closes, and the parent watchdog notices within a second.

Updates and installers

  • The NSIS installer and uninstaller now end every maximal-mcp-broker.exe first, through PREINSTALL and PREUNINSTALL hooks (shell/src-tauri/windows/hooks.nsh, wired in tauri.conf.json), as the MSI and install.ps1 already do. A contract test checks the hooks and the wiring.
  • The in-place update also ends them right after stopping the sidecar (stop_mcp_brokers_for_update in shell/src-tauri/src/lib.rs). A broker, or a Claude Desktop stdio adapter running the same file, would otherwise hold maximal-mcp-broker.exe open, and the installer couldn't replace it. I couldn't compile the shell for Windows locally (ring needs MSVC headers). I checked the Windows-only std calls with rustc --target x86_64-pc-windows-msvc, and the Tauri spawn_blocking signature against its source.

Other Windows fixes on this path

  • .cmd and .bat servers: the MCP SDK starts stdio servers with cross-spawn. It runs a .cmd file through cmd.exe /d /s /c with its arguments escaped, so these servers work and are safe from CVE-2024-27980. The Windows test runs its server through a .cmd file in a folder whose name contains a space, to prove this.
  • The broker's Windows baseline environment now includes the variables the MCP SDK passes to stdio servers by default: USERNAME, PROGRAMDATA, PROGRAMFILES, PROGRAMFILES(X86), PROGRAMW6432, and PROCESSOR_ARCHITECTURE.
  • On Windows, the development broker build is now found as maximal-mcp-broker.exe.
  • Work IQ detection now also follows npm's workiq.cmd launcher on PATH to the package in the node_modules folder next to it.
  • When Maximal adopts an entry you already had in Claude Desktop's regular config, on Windows it now reads %APPDATA%\Claude and the Microsoft Store app's %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude, not %LOCALAPPDATA%\Claude. The folders it reads are passed in, so tests never read the real user's config.

Settings copy

The MCP panel's broker messages (starting, recovering, keeps stopping, missing, and couldn't start) moved from hardcoded English into the i18n catalogs, in all nine full catalogs. The old "don't run on Windows" copy was never in the catalogs; it is gone from the panel, the CLI, and the API. The translations are mine, so @jessicadowney should review the wording.

Tests

  • Unit tests for the Job Object wrapper with injected kernel32 bindings: the structure layout, the order of calls, and failing closed at each step.
  • Supervisor tests for Windows, in tests/mcp-broker-supervisor-windows.test.ts. The helpers moved to tests/helpers/broker-supervisor.ts to keep the original file under the line limit. They check how the broker is spawned, that stopping it ends in TerminateProcess and never a group kill, that exit status 78 makes the broker unavailable only on Windows, that other crashes still restart it, and that the sidecar and the broker agree on status 78.
  • packages/mcp-broker/test/windows-containment.test.ts runs only on win32, against the compiled broker. Its MCP server is started through a .cmd file and starts a child of its own. The test opens a session, then checks that cmd.exe, the server, and its child are all gone within 5 seconds in each of these cases:
    • the broker is terminated;
    • the --parent-pid process dies;
    • the broker shuts down cleanly;
    • the process that owns the broker's stdin dies;
    • the broker started inside an existing job (the launcher confirms with IsProcessInJob).
  • A sixth case checks that a refused job makes the broker exit with status 78 before it writes anything to stdout. I couldn't make Windows refuse the job for real: it nested even under a job with UI limits. So this case runs the source broker with a test-only Bun preload (test/refuse-job-preload.ts) that makes AssignProcessToJobObject report failure. The broker itself has no switch for this.
  • The windows-broker CI job on windows-latest builds the broker and runs the whole broker suite against the compiled .exe. It then runs the containment test again against the minified build:target --target=bun-windows-x64 build that releases ship.
  • The adapter test that stops the adapter with SIGTERM is skipped on Windows. There kill() is TerminateProcess and runs no handler, and clients stop the adapter by closing stdin, which another test covers.

Release-As: 0.5.0

Comment thread .github/workflows/ci.yml Fixed
Comment thread .github/workflows/ci.yml Fixed
Comment thread .github/workflows/ci.yml Fixed
On Windows the broker now puts itself in a Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (kernel32 through bun:ffi) before it
starts anything, and keeps the only handle until it exits. The MCP servers
it starts and their children are in the job, so when the broker exits or is
terminated, Windows ends all of them. If Windows refuses any step, the
broker starts nothing and exits with status 78, and the supervisor reports
the broker unavailable (containment-unavailable) instead of restarting it.

This removes the Windows gate: Settings, the CLI, the catalog and client
sync no longer treat Windows as unable to run MCP servers.

Also on the Windows path:
- the broker's baseline environment carries the variables the MCP SDK
  passes to stdio servers by default (USERNAME, PROGRAMFILES and others)
- the development broker build is found as maximal-mcp-broker.exe
- Work IQ is found through npm's workiq.cmd launcher on PATH
- Claude Desktop's regular config is read from %APPDATA%\Claude

A windows-broker CI job builds the broker on windows-latest and runs its
tests, including an end-to-end containment test.
…open

The parent watchdog's close waited on the HTTP server, which an open event stream can hold forever, so a broker whose sidecar crashed could outlive it. It now exits after 3 seconds either way, and on Windows that ends its Job Object. The Windows containment test ends its session before a clean shutdown, and the adapter's SIGTERM test is skipped on Windows, where kill() runs no handler.
…its pipes

On Windows a server started through cmd.exe outlives the cmd.exe that is ended when its session closes, and its pipes kept a broker that had already answered shutdown from exiting. Exiting ends the Job Object, which ends that server too.
…ed jobs

The broker now exits at most 3 seconds after it begins closing however serve stops, including stdin closing and a shutdown frame, and exits as soon as a failed serve has written its error code. The supervisor treats exit status 78 as refused containment only on Windows. The Windows tests now cover a parent that owns the broker's stdin and dies, a broker started inside an existing job, and a broker whose job Windows refuses to nest (status 78), and CI runs them against the minified release build too.
The NSIS installer and uninstaller now end every maximal-mcp-broker.exe first through installer hooks, as the MSI and install.ps1 do, and the in-place update ends them after stopping the sidecar. Without this, a broker or a Claude Desktop adapter holding the file could block an update. The Windows containment test now proves the broker is nested in an existing job, and checks the refused-job path with a test-only preload instead of UI limits, which Windows nests under after all.
…e app's Desktop config

The MCP panel's broker messages (starting, recovering, keeps stopping, missing, and couldn't start on Windows) now come from the i18n catalogs in all nine languages. The Windows message no longer promises that a restart helps; it points at the MCP server log. Adopting a Claude Desktop entry on Windows also looks in the Microsoft Store app's redirected config, and the folders it reads are passed in so tests never read the real user's.
- Hold a Work IQ endpoint back from the broker until its setup is done
  (installed, terms accepted, signed in once). On Windows, workiq mcp
  opened Microsoft's sign-in window by itself before the card's steps.
  The server registers a broker config filter; turning Work IQ on reports
  setup-needed, and /mcp/<id> answers 503 MCP_ENDPOINT_SETUP_NEEDED.
- Say beside an app why it can't get an endpoint ("Turn on Claude Code in
  Apps first."), tell a missing claude.exe apart, and look past npm's
  claude.cmd for the native claude.exe.
- Wrap long server paths in the MCP cards and the Add dialog.
- Read config.json and the Claude Code and Claude Desktop files Maximal
  reads when they start with a UTF-8 byte order mark, and refuse to write
  over a config.json that isn't valid JSON.
- Pick Work IQ's win-arm64 build on an Arm PC, from the machine's
  architecture rather than the emulated process's.
- Keep bun:ffi out of the stdio adapter, give the broker --version and
  --help, and report adapter failures with a code and HTTP status.
- Test the adapter from Node and PowerShell pipes on Windows, and run the
  Windows broker job on windows-11-arm too.
… through Maximal

When the stdio adapter can't connect to Maximal's endpoint, it now answers
each request the client already sent (usually initialize) with a JSON-RPC
error naming the reason, writes ADAPTER_CONNECT_FAILED with the HTTP status
to stderr, and exits, instead of closing without a word.

New tests run the adapter the way Claude Desktop does: a Node parent
writes initialize and tools/list through Maximal's /mcp route to a real
broker, on Linux and on both Windows runners against the release build.
The PowerShell pipe client now takes its command line from the
environment, which PowerShell's own argument parsing mangled.
@stuffbucket
stuffbucket force-pushed the feat/windows-mcp-containment branch from 50a359b to 83f59bd Compare October 28, 2026 14:20
Comment thread .github/workflows/ci.yml Fixed
Bun's fetch sends even a 127.0.0.1 request through HTTP_PROXY and the like.
The stdio adapter inherits the environment of the app that starts it
(Claude Desktop passes its own on), so with a proxy set its connection to
Maximal went to the proxy, which can't reach this machine's loopback, and
failed after about 10 seconds. The adapter now drops the proxy variables
and sets NO_PROXY to the loopback hosts, and the server adds them to
NO_PROXY for its own forwarding to the broker.
CodeQL flagged the new windows-broker job for running with the repository's default token permissions. Set contents: read for the workflow, and let the test job also read pull requests, which its commit-subject check needs.
@stuffbucket stuffbucket added this to the v0.5.0 milestone Oct 28, 2026
A PowerShell launch takes about 42 seconds on the windows-11-arm runner, so the PowerShell parent can't answer initialize inside the test's 60 second budget and the job failed. The same parent runs on x64, and Node, which Claude Desktop uses, still runs on Arm.
Bun on the windows-11-arm runner is the emulated x64 build, so process.arch said x64 and the PowerShell parent still ran and timed out. Read the machine's architecture from PROCESSOR_ARCHITEW6432 first, as the Work IQ lookup does, and log what the test sees.
The log showed Bun on the windows-11-arm runner reports AMD64 and no PROCESSOR_ARCHITEW6432, so the test can't tell it is on an Arm PC. The workflow now sets MAXIMAL_TEST_SKIP_POWERSHELL_PARENT for that runner and the test reads it, replacing the architecture check and the diagnostic line.
@stuffbucket
stuffbucket merged commit c1e96c3 into main Oct 28, 2026
9 checks passed
@stuffbucket
stuffbucket deleted the feat/windows-mcp-containment branch October 28, 2026 20:37
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