🤖 feat: enable Agent Plugin MCP servers globally - #4246
Conversation
Serialize MCP configuration mutations and revoke global enablement during managed uninstall without persisting plugin definitions. Preserve workspace override precedence. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$50.89`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=50.89 -->
Keep plugin definitions read-only while enabling their global toggle. Cover successful toggles and optimistic rollback on backend errors. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$50.89`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=50.89 -->
Document the key-only consent list, workspace override precedence, and managed uninstall behavior. Sync the bundled documentation snapshot. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$50.89`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=50.89 -->
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
This comment has been minimized.
This comment has been minimized.
|
@codex review |
|
@codex security review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee2bd3f8cd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Recorded UAT passed on the rebased commitVerified
All five recordings decode cleanly; sampled and final frames were checked. Owned browser, provider, MCP, and sandbox processes were stopped, and disposable fixtures were removed. Off-host behavior is covered by automated tests, not live SSH/devcontainer UAT. RecordingsDesktop global toggle — 35.4s 02-global-desktop.webmNative 390px toggle — 49.1s 03-global-mobile.webmWorkspace override isolation — 93.5s 04-workspaces.webmMain-chat tool presence matrix — 148.4s 05-prompt-policy.webmManaged consent, uninstall, and reinstall — 179.7s 06-managed-lifecycle.webmGenerated with |
There was a problem hiding this comment.
🛡️ Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: ee2bd3f8cd
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Clear prior-path enablement before a fresh managed install and abort promotion when consent cleanup fails. Strip global plugin enablement during backup restore so a backup cannot grant or resurrect machine-local execution consent. Cover manual removal/reinstall, malformed cleanup, backup injection and approval, redacted consent, and duplicate fields with real-service regression tests. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$121.33`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=121.33 -->
|
@codex review |
|
@codex security review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b19f8dacfc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Readiness: blocked — not ready to merge
Next step, after authorization to continue beyond the review limit: add a deterministic shared-home, two-backend held-tool regression; then fence/re-read global consent at dispatch using the existing MCP config lock, failing closed when consent cannot be established. Prefer this bounded change over a new persisted epoch subsystem. Re-run the affected gates and obtain a clean review. This is a proposed fix, not implemented or verified yet. Generated with |
Read global execution consent under the existing MCP config writer lock at held-tool and prompt admission. Preserve explicit workspace enablement and release admission locks before waiting for the tool result. Cover sibling revocation and re-enablement, malformed consent, writer contention, cancellation, late acquisition cleanup, and both prompt admission paths with real config-service regressions. Complete existing partial prompt fixtures. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$160.30`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=160.30 -->
|
@codex review |
|
@codex security review |
Recorded cross-backend revocation check — passedTested commit: Two separate Bun backends shared a disposable Xum home. A used the real config writer; B used the real config service, manager, plugin discovery, and stdio MCP transport. B kept the same two issued tool objects throughout; neither was re-served.
Baseline and recovery used the same MCP process. All backend, MCP, terminal, browser, and recording processes were stopped; owned ports closed and disposable homes removed. Scope: service/process/stdio boundary using an unmanaged global fixture plugin. This is not a new Settings UI, AI request, managed-plugin lifecycle, or off-host walkthrough. The earlier desktop/mobile UI evidence remains linked in the PR description. Rejection at the held-tool boundaryComplete walkthroughRecordingReal-time terminal walkthrough, 193 seconds at 1600×1200. No replay or time compression. A ttyd-only compatibility shim restored standard WebSocket state constants in the owned browser context; network allowlisting stayed enabled. cross-backend-revocation-final.webmGenerated with |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0469f0068
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
🛡️ Codex Security Review
Here are some automated security review suggestions for this pull request.
Reviewed commit: a0469f0068
ℹ️ About Codex security reviews in GitHub
This is an experimental Codex feature. Security reviews are triggered when:
- You comment "@codex security review"
- A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review
Once complete, Codex will leave suggestions, or a comment if no findings are found.
Readiness after round 7: blocked, not ready to mergeThe held-tool revocation fix is implemented and verified on The new code/security reviews have three unresolved threads representing two distinct issues:
Decision: blocked. Seven combined review rounds have now been used, including the explicitly announced additional cycle. The authorization work is not converging yet, so I am pausing rather than silently starting round 8 or deferring these defects. No merge was performed. The strategic advisor returned no usable output on two attempts; this decision follows the verified code, review, and CI evidence. Proposed next increment: reuse the existing consent fence through actual process/connection initiation, preserving explicit workspace consent; normalize valid empty/comment-only JSONC without accepting malformed documents. Add deterministic startup-race and empty-config regressions before implementation, then rerun the gates and review. Both fixes belong to this feature; no new epoch subsystem or unrelated PR is proposed. They are not implemented or verified yet. Further work/review awaits direction beyond the announced cycle limit. Generated with |
Reuse the existing global/component consent decision through actual process and connection initiation, including automatic transport fallback. Preserve explicit workspace consent and the current bounded startup lock lifetime. Treat valid empty and comment-only MCP JSONC as no consent while still rejecting malformed or non-object content. Preserve comments during toggles and pruning. Add stale-snapshot startup coverage across all transports and override paths, real writer-lock probing at exec, fallback revocation, and empty-config regressions. --- _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$225.83`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=225.83 -->
|
@codex review |
|
@codex security review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1e4c33acb4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Recorded startup and empty-config checks — passedTested commit: Two separate Bun backends shared a disposable home. An injected orchestration barrier paused B after the real MCP config service returned its enabled snapshot, before returning that snapshot to the real manager. A then completed global disable; only afterward did B resume. Plugin discovery, the MCP client, and stdio transport were real.
At the rejected startup boundary there was no live MCP process. Comments-only enable, disable, and prune also succeeded while preserving both comments and the correct effective consent. Stale startup blocked before executionFull result and positive controlsRecordingReal-time terminal actions, approximately 192 seconds, with no replay or time compression. The recorder scaled the view to 1600×722; the PNGs retain 1600×1200. The final video frame retains all terminal content. cold-start-config.webmScope: service/process/stdio boundary using an unmanaged global fixture plugin, with the barrier disclosed above. This is not a full Settings UI, AI request, managed-plugin lifecycle, or off-host walkthrough. All owned processes were stopped, three ports closed, and disposable homes removed. Earlier UAT artifacts are unchanged. Generated with |
Readiness after round 8: blocked, not ready to mergeFinal pushed commit:
The proposed correction stays in this PR: reuse the existing lock around the MCP restore read/merge/write interval, preserving existing backup validation and filesystem protections. Add a deterministic regression proving that a held real consent fence blocks restore, release permits restore to clear consent, and subsequent admission rejects; also prove lock release on failure. No new consent model, persisted state, or unrelated backup refactor is proposed. Then run validation and one further agreed review cycle. The blocker remains open here, not deferred as fixed. Generated with |
Reuse the existing MCP writer lock for restore approval, local merge and persistence so clearing plugin consent is ordered with held invocations and startup. Preserve the complete backup preflight before settings writes. Regressions cover a held real consent fence, a fresh local-only merge, post-restore admission denial, and release after approval/write failures. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `xhigh` • Cost: `$275.67`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=xhigh costs=275.67 -->
|
@codex review |
|
@codex security review |
Recorded backup-restore consent check — passedTested commit: One isolated Bun process ran real MCP configuration/admission and backup restore operations concurrently against a disposable home. Filesystem observation detected the actual lock-publication attempt; neither the lock nor restore implementation was mocked.
This is service/filesystem validation, not full Settings UI, AI, or off-host testing. The regression suite separately proves that restore merges fresh local-only settings after lock acquisition and releases the lock on approval/write failures. All owned processes stopped, both ports closed, and disposable homes were removed. Final assertionsRecording221.5 seconds, 1600×900. The recording includes the held-fence, queued-restore, denial, and fresh-enable stages. restore-consent-ordering.webmGenerated with |
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
CI review-check bookkeeping: resolved findings, stale summary markerOn unchanged head The remaining
I am marking only that stale summary comment Resolved using GitHub's comment-resolution mechanism, which the existing checker supports. The actual findings, replies, and clean verdicts remain intact. No bot text, source, workflow, or branch-protection rule is being changed. The unchanged checker and required CI must still pass before merge. I will inspect the raw summary again before merge; any new finding or change to the reviewed head/review state invalidates this resolution and requires reassessment. Generated with |
Merged — final validation recordMerged through the required queue at 2026-09-14 14:28:22 UTC as
Stopping because the reviewed candidate passed all required gates and GitHub confirmed the merge. No additional implementation or review cycle was needed. Generated with |








Summary
Allow globally installed Agent Plugin MCP servers to be enabled in Settings → MCP, rather than requiring a separate opt-in in every workspace. Plugin definitions stay read-only; only canonical server keys are persisted in
enabledPluginServers.Validation
mainatfbbea2b16403ceb6f52f71af33a13a06c9c466fa.make static-check-fullpassed, including typecheck, lint, formatting, documentation links, and the lockfile-free CLI/bench gate.b19f8dacf, including the new success and backend-error cases. The final backend-only update is covered by current-head CI.ee2bd3f8c(before the backend-only review fixes): global persistence, desktop/390px controls, workspace precedence, actual request tool lists, and managed component consent/uninstall/reinstall. Screenshots and five recordings.Real-process revocation UAT passed on
a0469f006: two independent Bun backends shared one disposable home and real stdio MCP transport. A held tool was rejected after sibling disable without another MCP call; explicit workspace consent and re-enablement both worked without re-serving the held tool. This tests the service/process/transport boundary, not the Settings UI or AI request assembly. Screenshots and real-time recording.Startup/config UAT passed on
1e4c33acb: A completed global disable before B released its captured enabled snapshot. B then produced zero MCP startup, initialization, or call markers. Explicit workspace and re-enabled global startup/calls succeeded, and comments-only enable/disable/prune preserved comments. Screenshots and real-time recording.Restore/fence UAT passed on
21118f908: actual restore queued behind a held real admission fence without changing consent. After release, restore removed consent, the next admission rejected, and fresh explicit enablement permitted admission again. This tests concurrent real service operations in one isolated Bun process, not full UI/AI/off-host behavior. Screenshot and recording. Regressions also cover fresh local-only merge planning and approval/write failure cleanup.The final static/backend gate covers
21118f908; the review fixes do not change the UI.Semantics and risks
Catalog and connection changes apply on the next workspace serve refresh. Global-plugin startup rechecks consent through process/connection initiation. Previously issued tools and prompts recheck at dispatch; already-admitted calls may finish. This does not promise immediate process termination, and explicit workspace enablement still overrides the global default. If an uninstall fails after revocation, the installation is recovered but the user may need to enable its server again. Restoring
mcp.jsoncfrom a settings backup clears global plugin enablement; enable servers again in Settings → MCP. Tests cover concurrent writers, stale-read/prune ordering, malformed/duplicate JSONC, and rollback/recovery paths.📋 Implementation Plan
The accepted plan is reproduced verbatim below. Its status notes and line references predate implementation; current validation is recorded above.
Allow enabling Agent Plugin MCP servers globally (Settings → MCP)
Problem
In Settings → MCP, rows for Agent Plugin servers (e.g.
fux-plugins/coder, badgeplugin · .xum/plugins/fux-plugins) have a disabled toggle with tooltip "Agent Plugin servers are enabled per workspace (Workspace MCP)". Plugin servers can only be enabled one workspace at a time via the Workspace MCP dialog. The user wants to enable them for all (eligible) workspaces from the global settings.Verified facts the design rests on
<xumHome>/plugins,~/.agents/plugins(sourceScope: "global") and<repo>/.xum/plugins,<repo>/.agents/plugins("project") byloadPluginMcpServers(src/node/services/agentPlugins/mcpConfig.ts), keyedplugin:<16-hex instanceId>:<serverName>and hard-codeddisabled: true(~:466, ~:529). Global instance IDs hash the install path → stable across projects;computePluginSourceLocation(:269) renders.xum/plugins/<dir>for~/.xum/plugins/<dir>, matching the screenshot.api.mcp.list({})(noprojectPath) →MCPConfigService.listServerLayersscans global containers only, so only global plugins are ever offered there (src/node/services/mcpConfigService.ts:512-560).listServerLayersmerges{...plugin, ...global, ...project};omitReservedPluginKeys(:44) stripsplugin:*keys from user layers andaddServerrejects them (:573): a plugin server's definition must never come frommcp.jsonc.setServerEnabled(:625-648) only editscfg.servers[name]; plugin keys fail "not found". UI mirrors this withdisabled={isPluginEntry}(src/browser/features/Settings/Sections/MCPSettingsSection.tsx:1195-1228). Edit/remove (:1369) and tool-allowlist (:1450) editors are already hidden for plugin entries.MCPConfigServicehas no write serialization:getGlobalConfig→ mutate →saveGlobalConfig(:417-466,writeFileAtomic,JSON.stringify({servers}), re-normalizes and drops unknown top-level keys) can interleave across concurrent calls and processes.readConfigFile(:379) usesjsonc.parse(raw)without an errors array and returns{servers:{}}on any failure (startup-safe).xum server,ALLOW_MULTIPLE_INSTANCES;mcpServerManager.ts:1050). The repo's pattern for this isacquireCrossProcessLock(src/node/utils/main/crossProcessLock.ts:286), used byWorkspaceMcpOverridesService.runExclusive(workspaceMcpOverridesService.ts:500-535: in-process queue +<xumHome>/mcp-overrides.lock, held across "revision read, validation, write, and prune" precisely to stop a save from re-enabling a server after an uninstall's prune) and byAgentPluginInstallService.runExclusive(installService.ts:769-790, cross-processMUTATION_LOCK_FILE).mcpServerManager.ts): every serve re-reads config from disk (ensureWorkspaceServers:1758 →getAllServers:1558 →configService.listServers), recomputes the enabled set viaapplyServerOverrides(:1625) + policy filter, and rebuilds the workspace's instances when the start-config signature changes (:1844-1870). Global toggles are therefore applied on the next serve, disk-authoritative, in every process; the router'smcp.setEnabledmakes no manager call (router.ts:1020-1025). Off-host runtimes never get plugin servers (:1822-1830).pluginInvalidation(:1057, :1183-1220) retires plugin instances when a sibling process's on-disk mutation token changes, and the in-memory override caches (latestWorkspaceOverrides,loadFirstServeWorkspaceOverrides:1317) concern workspace overrides only — global config is never cached.installService.ts:2798-3160): underrunExclusive, pre-commit checks →stopServersWithKeyPrefix→ journal →renameIntoStaging(targetPath, trashDir)(tree gone from discovery) → optional data staging → registry commit with pessimistic tombstones → post-commit workspace-override prunes.restoreTree(context)(:2896) rolls the tree back on pre-commit failures.Design
Persist a global enable list — never plugin definitions — in
~/.xum/mcp.jsonc, applied only to discovered global-scope plugin servers:{ "servers": { /* unchanged */ }, "enabledPluginServers": ["plugin:0123456789abcdef:coder"] }Effective precedence: plugin default (
disabled: true) < globalenabledPluginServers< workspaceenabledServers/disabledServers. Applying the list inside the plugin layer means every consumer (Settings list, Workspace MCP modal,bun run debug plugins, tool assembly) sees the same effective default; the modal already handles "enabled by default → per-workspace disable" (WorkspaceMCPModal.tsx:122-158).All
mcp.jsoncwriters (existing 4 mutations + the new list update + the uninstall prune) are serialized through a newMCPConfigService.runExclusive(in-process queue + cross-process<xumHome>/mcp-config.lock), mirroringWorkspaceMcpOverridesService. Lock contract is explicit and non-reentrant by convention (same as the overrides service): every public mutation/prune acquiresrunExclusiveexactly once; private*Lockedhelpers never acquire it (documented in their doc comments; a concurrent second request queues, it never fails). The plugin toggle re-discovers the server inside the lock, and the uninstall prunes after the tree is staged out, so no previously persisted or already-validated enable survives a successful uninstall (analysis below).Guarantee scope (stated precisely): no enable that was persisted, or whose in-lock validation observed the old installation, survives a successful uninstall. A toggle whose validation first runs after a same-name reinstall targets the current installation and is honored — rejecting it would need an install-incarnation contract, which is out of scope.
Race analysis (why lock + ordering closes it by construction)
Let T = toggle (
setServerEnabledon a plugin key), E = ordinarymcp.jsoncmutation (whole-file RMW), U = uninstall (stage-out→prune).Err("not found"), nothing written.workspaceMcpOverridesService.ts:500-511).pluginInvalidationretires their running plugin instances.restoreTree; journal consumed only if the restore succeeded (otherwise the journal stays andreconcileJournalsrestores the tree later) → throw. What is promised is installation rollback/recovery, not consent: the prune's write may have landed before a later error (e.g. lock release), so consent may remain revoked and the user re-toggles. Old enablement is never restored as compensation. Any failure after a successful prune (data staging, registry commit) rolls the tree back via the existing paths with the same consequence: an inconvenience, never a silent enable. Crash between stage-out and prune → journal recovery restores the tree; consent still present for a still-installed plugin (consistent).workspaceMcpOverridesService.ts:928-935):jsonc.parseexposes the LAST duplicate of a property whilejsonc.modifyedits the FIRST, so a file with two top-levelenabledPluginServersproperties could be "pruned" while its effective list is unchanged. Mutations therefore reject documents with a duplicated top-levelenabledPluginServersand verify the edited document's effective list equals the intended list before writing; lenient reads grant nothing when the property is duplicated or malformed.createAgentPluginsMcpProvider, no cache;core.ts:327) andclaudeDesign.serverInfo(); neither takes a lock → no re-entrancy/deadlock. U holds the installer lock then takes the config lock; no config-lock holder ever takes the installer lock → no lock-order inversion.Rejected alternatives
servers["plugin:…"] = { disabled: false }entries: breaks the reserved-key design (omitReservedPluginKeys,addServerrejection, uninstall prune-by-shape);normalizeEntryneeds a command/url.plugins.json): only managed installs; drop-in plugins in~/.xum/plugins/~/.agents/pluginsare discovered by directory scan..xum/mcp.jsonc) list / project-scope plugins: not requested; would let a repo auto-enable stdio commands for contributors.readConfigFileparses the field for both files but only the global file's list is applied, and only tosourceScope === "global"entries.Kept intact: agent-plugins experiment gate (provider is only wired when enabled,
core.ts:327-331), transport policy (setEnabledForApi,filterServersByPolicy), project trust, reserved namespace, off-host exclusion, global plugin tool-allowlist editing stays unsupported (workspace allowlists unchanged), no project-level enable. Runtime semantics match ordinary global servers: changes take effect at the next workspace catalog/serve refresh; no immediate process termination is promised (active leases may keep a server alive until its lease ends).Implementation (one PR, three commits with gates) — est. ~+150 net LoC product code (range 130–180; excludes tests and dogfood fixtures)
Review status: design reviewed with the advisor over four rounds and approved as an implementation plan; implementation, validation and dogfooding are pending. No PR is created as part of this work unless the user asks.
Commit 1 — Backend: serialized config writes, enable list, precedence, uninstall prune (~+150 LoC)
src/common/types/mcp.ts:66—MCPConfiggainsenabledPluginServers: string[](normalized in-memory form; optional on disk). OnlymcpConfigService.tsconstructs this type (MCPConfigindi/is an unrelated service tag).src/node/services/mcpConfigService.tsrunExclusive(fn)(private): in-process promise queue +acquireCrossProcessLock({ lockPath: <xumHome>/mcp-config.lock, acquireTimeoutMs: 60_000, staleMs: 5*60_000, timeoutMessage: "Another Mux process is currently updating MCP settings…" }), copied verbatim in shape fromworkspaceMcpOverridesService.ts:514-535(no reentrancy framework, no depth assertions — a second request queues). Each public mutation acquires it exactly once:addServer(:561),setServerEnabled(:625),removeServer(:650),setToolAllowlist(:665) becometry { return await this.runExclusive(async () => { …existing body… }) } catch (e) { return Err(getErrorMessage(e)) }so lock-acquisition failures (timeout) also surface asResult, never as throws. Reads stay lock-free.readConfigFile(:379): lenient, non-crashing.servershandling unchanged, but the early!parsed.servers → return { servers: {} }guard (:389) must not short-circuit the enablement read: a field-only write to a previously missing file legitimately yields{ "enabledPluginServers": [...] }with noserverskey, so read the list independently. ForenabledPluginServers:jsonc.parse(raw, errors)— iferrors.length > 0(partial tree) →[]; elsejsonc.parseTreeand if the top-level property is duplicated (findDuplicateProperty, below) or present-but-not-an-array →[](syntax-invalid or ambiguous config grants no plugin enablement); else keep only strings passingisCanonicalPluginServerKey.saveGlobalConfig(:417-466): carry the list through:JSON.stringify({ servers: output, ...(cfg.enabledPluginServers.length ? { enabledPluginServers: cfg.enabledPluginServers } : {}) })so ordinary mutations don't drop it.updateEnabledPluginServersLocked(update: (current: string[]) => string[]): Promise<void>(private, throws; doc comment: "caller must holdrunExclusive; never acquires"): read raw file (ENOENT →"{}"); strictjsonc.parse(raw, errors)→ throw onerrors.length > 0or non-object root;jsonc.parseTree→ throw if top-levelenabledPluginServersis duplicated (workspaceMcpOverridesService.ts:928-935trap) or present but not an array (checked before any equality/no-op logic); absent →[]; extract current canonical list;next = update(current); return without writing when equal; else field-only patchjsonc.applyEdits(raw, jsonc.modify(raw, ["enabledPluginServers"], next.length ? next : undefined, { formattingOptions })); verify the edited text parses without errors and its effectiveenabledPluginServers(absent ⇒[], which is how removal of the final key is verified) deep-equalsnext(repo lesson: validate after everyjsonc.modify);writeFileAtomic(..., { mode: 0o600 }); bumpglobalConfigGeneration. Preserves comments/unknown fields; never re-serializesservers.duplicateInoffindDuplicateOverrideProperty(workspaceMcpOverridesService.ts:937-955) intosrc/node/utils/main/jsoncDuplicates.tsasfindDuplicateProperty(node, names?); the overrides service calls it (mechanical, behavior-preserving) andmcpConfigService.tsreuses it (DRY per AGENTS.md).listServerLayers(:520-534): afterglobalCfgis read, build a new plugin map: for each key inglobalCfg.enabledPluginServers, ifpluginServers[key]?.plugin?.sourceScope === "global", copy withdisabled: false. Never mutates the provider's map; unmatched keys are ignored (cannot inject or touch user servers). Update the class doc comment (:64-69) — plugin definitions are never persisted; only global enablement keys are.setServerEnabled(:625-648), body insiderunExclusive(outer try/catch →Resultas above): reusemanaged = (await this.listServers())[name](in-lock discovery).if (managed?.plugin):assert(isCanonicalPluginServerKey(name));if (managed.plugin.sourceScope !== "global") return Err("Repo plugin servers are enabled per workspace");await this.updateEnabledPluginServersLocked(list => enabled ? uniq([...list, name]) : list.filter(k => k !== name)); return Ok(). Non-plugin path unchanged;addServer/removeServer/setToolAllowlistkeep rejecting plugin keys.pruneEnabledPluginServers(keyPrefix)(public, throws so uninstall can roll back):assert(isCanonicalPluginServerKeyPrefix(keyPrefix))(agentPlugins/mcpConfig.ts:68);return this.runExclusive(() => this.updateEnabledPluginServersLocked(list => list.filter(k => !k.startsWith(keyPrefix)))). Missing file → no-op; malformed/duplicated → throws (a lenient read would silently keep the stale key).src/node/services/agentPlugins/installService.tsmcpConfigService?: Pick<MCPConfigService, "pruneEnabledPluginServers">.uninstall: insert afterrenameIntoStaging(targetPath, trashDir)and therestoreTreedefinition (:2896-2915) and before the optional plugin-data staging (:2917), so the rollback is single-resource:restoreTreefails (existing contract →reconcileJournalsrestores later). The catch promises installation rollback/recovery only — consent may already be revoked and is never restored as compensation. Failures after this point (data staging, registry commit) use the existing rollback paths with the same consequence (documented in the comment).src/node/services/di/layers/desktop.ts:376-380:mcpConfigService: yield* MCPConfig(tag imported at :82).mcpConfigService.test.ts: switch enable-list fixtures to canonical keys (plugin:0123456789abcdef:srvwithplugin: { sourceScope: "global", … })."plugin servers are never persisted…"(:375):setServerEnabled(plugin, true)succeeds;serverson disk lacks the key;enabledPluginServerscontains it;removeServer/setToolAllowliststill fail.MCPConfigServiceover the same rootDir), including from a previously missingmcp.jsonc(resulting file hasenabledPluginServersbut noservers); disable removes the key and the field is omitted when empty; an ordinaryaddServerafterwards preserves the list.sourceScope: "project"plugin server → no effect; toggling a project-scope plugin server returnsErr.pruneEnabledPluginServersthrows and file bytes unchanged; duplicated top-levelenabledPluginServers→ lenient read grants nothing, toggle returnsErr, prune throws, bytes unchanged; missing file → no-op; prune removes only prefix-matching keys.writeFileAtomicrejected (spy) → toggle returnsErr, file bytes unchanged; same for prune → throws. Lock-acquisition timeout (test holds<root>/mcp-config.lockwith a tinyacquireTimeoutMsoverride) →ResultErr, no throw.fs.promises.readFile/writeFileAtomicformcp.jsonc— assert transaction ordering (each operation's write lands before the next operation's first read), not literally alternating reads/writes, since a toggle may read the file more than once; never assert on "promise still pending"):Promise.allofsetServerEnabled(plugin, true),addServer("x"),removeServer("y")→ final file consistent (all three effects present) and transactions do not interleave in the log.MCPConfigServiceinstances over one rootDir (so the in-process queue cannot mask missing cross-process locking), discovery before stage-out, write delayed: AsetServerEnabled(plugin, true)blocks in its in-lock provider stub on a barrier; BpruneEnabledPluginServers(prefix)is started and the test positively synchronizes on B's lock attempt (spy onacquireCrossProcessLockor on B's firstmcp-config.lockfs access) before releasing A; log shows A's write before B's first read; final file lacks the key.{}(tree gone) →Err("not found"), file bytes unchanged.addServer("x")blocks on a barrier after its in-lock config read (barrier installed via thereadFilespy formcp.jsonc, call-through after release); the test snapshots the file bytes at that moment and retains them; BpruneEnabledPluginServers(prefix)is started and its lock attempt is positively synchronized as in (2); release → log shows B's first read after A's write; final file has serverxand lacks the pruned key (untouched list entries intact); the pre-barrier snapshot still contained the key (proves the stale-read window existed and was serialized away).mcpServerManager.test.ts("Workspace MCP overrides filtering" or sibling): globally-enabled plugin server + workspacedisabledServers→ excluded; globally-disabled + workspaceenabledServers→ included; no overrides → included; off-host runtime → excluded. Warm-manager lifecycle with the existing stdio stub pattern (:3627): serve → enable globally → serve (instance started, tools present) → disable globally → serve (instance retired at that refresh, tools absent).installService.test.ts:MCPConfigServiceover the same temp root and a real provider (createAgentPluginsMcpProvider): install fixture →setServerEnabledon its canonical key → uninstall with zero workspaces →mcp.jsonclacks the key → reinstall →listServers()reports the serverdisabled: true.mcpConfigService: { pruneEnabledPluginServers }, mirroring :1309 / :2397 fixtures): prune is called withplugin:<instanceId>:after the tree is staged out (asserttargetPathabsent at call time) and before data staging / registry commit; rejecting prune → uninstall throws, plugin dir restored, registry entry intact, no lingering uninstall journal; rejecting prune and a failingrestoreTree(target path blocked) → uninstall throws, journal retained, and the nextreconcileJournalsrestores the tree.workspaceMcpOverridesService.test.tsstays green after thefindDuplicatePropertyextraction (its duplicate-property cases exercise the moved code).Gate:
bun test src/node/services/mcpConfigService.test.ts src/node/services/mcpServerManager.test.ts src/node/services/agentPlugins/installService.test.ts src/node/services/agentPlugins/mcpConfig.test.ts src/node/services/workspaceMcpOverridesService.test.ts,make typecheck,make lint.Commit 2 — UI (~−5 LoC)
MCPSettingsSection.tsx:1195-1228: dropdisabled={isPluginEntry}and the plugin tooltip branch (tooltip becomes "Enable server"/"Disable server"); rewrite the comment to "read-only definition (no edit/remove/allowlist); enable state persists toenabledPluginServers". Optimistic update / revert inhandleToggleEnabled(:883-914) already covers backendErr.MCPSettingsSection.stories.tsx:AgentPluginServerstory with a mocked globalplugin:entry. Play (behavioral): switch is enabled; clicking calls the mockedmcp.setEnabledwith the plugin key and the row flips; a second variant where the mock returns{ success: false, error }asserts the optimistic revert and the error banner. Row layout is unchanged, so no new viewport variant; but capture a 390px screenshot in dogfooding.Gate:
make static-check; Storybook test-runner for the new stories.Commit 3 — Docs (~+2 LoC)
docs/config/mcp-servers.mdx:53-62: "Disabled by default — globally installed plugins (~/.xum/plugins) can be enabled for all eligible local workspaces in Settings → MCP (persisted asenabledPluginServersin~/.xum/mcp.jsonc); repo plugins are enabled per workspace via the Workspace MCP dialog; workspace overrides always win"; "Read-only — definitions cannot be edited or removed and are never written intomcp.jsonc; only their keys appear inenabledPluginServers, which a managed uninstall clears". Mention the field in the## Configurationfile example. Keep "Host-only" bullet.Gate:
make static-check(includesmintlify broken-links).Final combined gate (after all three commits, in one run)
make static-check+ the full Commit 1 test list + Storybook test-runner for the new stories, executed against the final tree (not the intermediate backend-only state), then the dogfooding pass below.Acceptance criteria
enabledPluginServers(noserversentry, other file content/comments preserved); toggling off removes the key and the field when empty; a backend error reverts the switch and shows the error.disabledServersand only that workspace loses it; an existing per-workspaceenabledServersentry keeps a server on when the global enable is removed.addServer/removeServer/setToolAllowliststill reject plugin keys; repo-scope plugin servers cannot be enabled globally; a duplicated/malformedenabledPluginServersgrants nothing and is rejected by mutations.mcp.jsoncwriters (toggle, add/remove/allowlist, prune) are serialized bymcp-config.lock— verified by the deterministic two-instance tests above.make static-check, and docs pass.Dogfooding (evidence: desktop + 390px screenshots and a WebM; kept local unless the user later authorizes a PR/upload — this request does not include opening a PR)
KEEP_SANDBOX=1 make dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-providers --clean-projects"(skilldev-server-sandbox); noteXUM_ROOT/VITE_PORT. Create a disposable git repo under$HOME/.cache/restrictions-s18v/dogfood-repoand add it as the only project. Write a sandbox-onlyproviders.jsoncpointinganthropicat a loopback provider stub ($HOME/.cache/restrictions-s18v/provider-stub.ts, ~60 lines: acceptsPOST /v1/messages, appends the request'stools[].nametorequests.jsonl, replies with a one-sentence SSE text response) with a dummy key — no real credentials, no egress.bunx -y): copytests/fixtures/agent-plugins/hello-pluginto<XUM_ROOT>/plugins/hello-pluginand point itsmcp.jsonserver at a ~40-line stdio JSON-RPC script in$HOME/.cache/restrictions-s18v/echo-mcp.ts(handlesinitialize,tools/listwith oneechotool,tools/call), launched with the pinnedbunfrommise.agent-browser open http://localhost:<VITE_PORT>;agent-browser record start $HOME/.cache/restrictions-s18v/dogfood.webm; Settings → Experiments → enable Agent Plugins; Settings → MCP →snapshot -i:hello-plugin/<server>row with an enabled Switch (screenshot01-toggle-enabled.png, plus anagent-browser viewport 390 844variant); click →02-after-enable.png;cat <XUM_ROOT>/mcp.jsoncshowsenabledPluginServersand noplugin:key underservers.03-workspace-default-on.png); disable it →<worktree>/.xum/mcp.local.jsonchasdisabledServers;XUM_ROOT=<sandbox> bun run debug plugins <workspace-id>prints the plugin layer withdisabled: falseand the override.turnRequestBuilder→mcpServerManager→ provider request): in a second workspace (no override) send any prompt; the loopback stub'srequests.jsonlmust list the plugin'sechotool in the assembled request (04-tools-assembled.pngof the transcript + the stub log excerpt). Toggle off globally → send another prompt → the next request lacks it (05-tools-gone.png); toggle on again and disable per-workspace via the Workspace MCP dialog → request lacks it in that workspace only. (Optional, only if the user wants it: repeat one turn with a real provider — never print keys.)mcp.jsoncno longer lists its key; reinstall → server shows disabled (06-reinstall-default-disabled.png).record stop; attach PNGs/WebM in chat withattach_file.echo-mcp.tsprocesses by PID (neverpkill -ffrom the same script), stop the sandbox dev server task, remove the sandboxXUM_ROOTand the disposable repo; keep only the evidence files under$HOME/.cache/restrictions-s18v/.Generated with
xum• Model:coder:openai/gpt-6-astra• Thinking:xhigh• Cost:$282.81