MCP Workspace Budget Guardrails
Overview
WorkspaceMcpBudget (packages/core/src/tools/mcp-workspace-budget.ts) is the workspace-scoped MCP client budget controller from F2 (#4175 commit 6). It owns the same state machine McpClientManager carries inline (slot reservation, 75% hysteresis warning, refused-batch coalescing across a discoverAllMcpTools* pass), but lives once per workspace inside McpTransportPool instead of once per session inside each ACP child’s manager. The pool delegates acquire and release calls here so the cap applies to the workspace, not each session.
The legacy McpClientManager budget machinery stays for standalone qwen and SDK MCP servers (which bypass the pool per commit 4 fix). Pool mode → WorkspaceMcpBudget enforces; standalone / SDK MCP → manager’s inline machinery enforces. No double counting because pool-mode discovery never calls the manager’s tryReserveSlot.
Responsibilities
- Track
reservedSlots: Set<string>of currently-held server NAMES (slot key is per-NAME, matching PR 14 v1). tryReserve(name) → 'reserved' | 'already_held' | 'refused'— atomic and synchronous so concurrentPromise.allacquires cannot pass the cap at an await boundary.release(name) → boolean— idempotent (Set.deletesemantics).- Fire
mcp_budget_warningonce on upward 75% crossing ofreservedSlots.size / clientBudget; re-arm only after a 37.5% downward crossing. - Coalesce per-server refusals across a bulk discovery pass —
beginBulkPass()/endBulkPass()brackets accumulate refusals into a singlemcp_child_refused_batchevent. - Maintain
lastRefusedServerNamesfor snapshot consumers (GET /workspace/mcp) — cleared at the START of the next bulk pass, NOT on emit, so a snapshot between passes still sees the last refusal set.
Architecture
Configuration
new WorkspaceMcpBudget({
clientBudget?: number, // undefined = unlimited
mode: 'off' | 'warn' | 'enforce',
onEvent?: (event: McpBudgetEvent) => void,
});mode semantics:
off— every method no-ops;tryReservereturns'reserved'unconditionally; no events fire.warn— slots are tracked andmcp_budget_warningfires at 75%, buttryReserveNEVER refuses.enforce—tryReserverefuses pastclientBudget;recordRefusalqueues per-server refusals;endBulkPassemitsmcp_child_refused_batch.
Constants from mcp-client-manager.ts
MCP_BUDGET_WARN_FRACTION = 0.75— upward threshold.MCP_BUDGET_REARM_FRACTION = 0.375— downward hysteresis re-arm.McpBudgetMode = 'off' | 'warn' | 'enforce'.
Internal state
| State | Purpose |
|---|---|
reservedSlots: Set<string> | Authoritative reservation set; hysteresis evaluates size / clientBudget. |
pendingRefusalNames: Set<string> | Refusal names accumulated during the current beginBulkPass/endBulkPass window; drained on endBulkPass. |
pendingRefusalTransports: Map<string, transport> | Sidecar so the emitted batch carries each refused server’s transport. |
lastRefusedServerNames: readonly string[] | Snapshot-visible refusal list from the most recent completed pass. Cleared at the start of the next pass. |
warnArmed: boolean | Hysteresis state — true = ready to fire, false = already fired since last 37.5% drain. |
bulkPassDepth: number | Re-entrancy counter for nested bulk passes (nested passes must not double-emit). |
Workflow
tryReserve
tryReserve is synchronous. Pool’s acquire is async, but reservation happens before any await, so two concurrent Promise.all acquires for different names cannot both pass the cap.
Hysteresis
Hysteresis avoids repeated warnings when a workload oscillates around 75%. The first crossing fires; subsequent crossings without dropping to 37.5% do not.
Refused-batch coalescing
Out-of-pass refusals (e.g. lazy readResource spawn that bypasses the bulk pass entirely) emit length-1 batches inline for shape consistency. Nested passes (bulkPassDepth > 0) do not fire; only the outermost end-of-pass emits the coalesced batch.
State & Lifecycle
- Budget controller is constructed once per workspace at pool init.
clientBudgetis immutable after construction; runtime changes require pool reconstruction.modeis also immutable (onEventis stashed asundefinedwhenmode === 'off'as defense in depth).warnArmedstarts true; resets to true via the 37.5% downward crossing.lastRefusedServerNamesis NOT cleared onendBulkPassemit — only at the START of the next bulk pass. This lets a snapshot route called between passes still report the last refusal set (otherwise dashboards would show empty refusals immediately after a refused-batch event was delivered).
Dependencies
packages/core/src/tools/mcp-client-manager.ts— re-usesMcpBudgetEvent,McpBudgetMode,McpRefusedServer,MCP_BUDGET_WARN_FRACTION,MCP_BUDGET_REARM_FRACTION,BudgetExhaustedError(thrown by pool’sacquireon refusal).packages/core/src/tools/mcp-transport-pool.ts— consumes the budget; passes events through to the daemon EventBus via the pool’sonEventplumbing.- Daemon snapshot route
GET /workspace/mcp— readsgetReservedSlots(),getRefusedServerNames(),getReservedCount(),getBudget(),getMode().
Configuration
| Source | Knob | Effect |
|---|---|---|
| Flag | --mcp-client-budget=N | Sets clientBudget for the workspace controller. |
| Flag | --mcp-budget-mode={off,warn,enforce} | Sets mode. enforce requires a positive clientBudget; otherwise boot fails explicitly. |
| Env | QWEN_SERVE_MCP_CLIENT_BUDGET, QWEN_SERVE_MCP_BUDGET_MODE | Forwarded to ACP child via childEnvOverrides; child’s readBudgetFromEnv() picks them up. |
| Capability tags | mcp_guardrails (always; modes: ['warn', 'enforce']), mcp_guardrail_events (always) | See 11-capabilities-versioning.md. |
Caveats & Known Limits
- Reservation key is per-NAME. Two pool entries with the same server name but different fingerprints (e.g. sessions injecting divergent OAuth headers) consume ONE slot together. Subprocess accounting is exposed separately via the pool snapshot’s
subprocessCount. Operators should think of budget as “configured server slots” not “subprocess count”. - Hysteresis triggers on reservation count, not live (CONNECTED) count. Reservations include in-flight connects and survive transient disconnects, so hysteresis stays stable across reconnect cycles. Live count is exposed in event payloads as
liveCountfor SDK consumers that want that lens. warnmode never refuses. It still tracks reservations and firesmcp_budget_warning, buttryReservealways returns'reserved'. Refusal semantics areenforce-only.- Workspace-scoped budget events carry
scope: 'workspace'so they fan out to every attached session simultaneously. SDK reducers’mcpBudgetWarningCount/mcpChildRefusedBatchCountincrement in lockstep across sessions on the same connection. Per-session legacy events fromMcpClientManagercarry noscope(defaults to'session'semantically). - The kill switch
QWEN_SERVE_NO_MCP_POOL=1disables the pool entirely; the workspace budget is also disabled, and the per-sessionMcpClientManagerbudget takes over. The capabilities envelope dropsmcp_workspace_poolandmcp_pool_restartto report this accurately. ServeMcpBudgetStatusCell.scopeis a forward-compatible list shape. Snapshot cells exposebudgets[], not a singlebudget?field. PR 14 v1 emits onescope: 'session'cell for each ACP session becauseacpAgent.newSessionConfig()constructs that session’sConfig/McpClientManager. The'pool'scope is reserved for the Wave 5 PR 23 pool-scoped cell that will sit alongside session-scoped cells. Consumers must tolerate additional unknownscopevalues by dropping them rather than failing.
References
packages/core/src/tools/mcp-workspace-budget.ts(entire class)packages/core/src/tools/mcp-client-manager.ts(BudgetExhaustedError,McpBudgetEvent, hysteresis constants)packages/core/src/tools/mcp-transport-pool.ts(pool’sacquiresite that callstryReserve)- F2 design document (v2.2):
../../design/f2-mcp-transport-pool.md§11 for workspace-level budget and the v2.2 changelog entries about budget and fingerprint follow-ups. - F2 design notes: issue #4175 commit 6.