Repository navigation
feat(mcp): run MCP servers on Windows inside a kill-on-close Job Object - #563
Merged
Merged
Conversation
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
force-pushed
the
feat/windows-mcp-containment
branch
from
October 28, 2026 14:20
50a359b to
83f59bd
Compare
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
maximal-mcp-broker servecreates a Job Object withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEand assigns its own process to it. It calls kernel32 throughbun:ffi(packages/mcp-broker/src/windows-job.ts), checks the assignment withIsProcessInJob, 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.MCP_BROKER_CONTAINMENT_UNAVAILABLE step=<step> error=<code>to stderr and exits with status 78. On Windows only, the supervisor reports the broker asunavailablewith the problemcontainment-unavailableand doesn't keep retrying. Settings says "MCP servers couldn't start" and points to the MCP server log.TerminateProcesson it. That closes the job, which ends every descendant.servestops (ashutdownframe, stdin closing, the--parent-pidprocess 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
maximal-mcp-broker.exefirst, throughPREINSTALLandPREUNINSTALLhooks (shell/src-tauri/windows/hooks.nsh, wired intauri.conf.json), as the MSI andinstall.ps1already do. A contract test checks the hooks and the wiring.stop_mcp_brokers_for_updateinshell/src-tauri/src/lib.rs). A broker, or a Claude Desktop stdio adapter running the same file, would otherwise holdmaximal-mcp-broker.exeopen, 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 withrustc --target x86_64-pc-windows-msvc, and the Taurispawn_blockingsignature against its source.Other Windows fixes on this path
.cmdand.batservers: the MCP SDK starts stdio servers withcross-spawn. It runs a.cmdfile throughcmd.exe /d /s /cwith its arguments escaped, so these servers work and are safe from CVE-2024-27980. The Windows test runs its server through a.cmdfile in a folder whose name contains a space, to prove this.USERNAME,PROGRAMDATA,PROGRAMFILES,PROGRAMFILES(X86),PROGRAMW6432, andPROCESSOR_ARCHITECTURE.maximal-mcp-broker.exe.workiq.cmdlauncher on PATH to the package in thenode_modulesfolder next to it.%APPDATA%\Claudeand 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
@jessicadowneyshould review the wording.Tests
tests/mcp-broker-supervisor-windows.test.ts. The helpers moved totests/helpers/broker-supervisor.tsto keep the original file under the line limit. They check how the broker is spawned, that stopping it ends inTerminateProcessand 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.tsruns only on win32, against the compiled broker. Its MCP server is started through a.cmdfile 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:--parent-pidprocess dies;IsProcessInJob).test/refuse-job-preload.ts) that makesAssignProcessToJobObjectreport failure. The broker itself has no switch for this.windows-brokerCI job onwindows-latestbuilds the broker and runs the whole broker suite against the compiled.exe. It then runs the containment test again against the minifiedbuild:target --target=bun-windows-x64build that releases ship.kill()isTerminateProcessand runs no handler, and clients stop the adapter by closing stdin, which another test covers.Release-As: 0.5.0