feat(mcp): reconcile MCP servers live on settings change (#5561)

* feat(mcp): reconcile MCP servers live on settings change

Hot-reload MCP servers when settings.json changes (issue #3696 sub-task 3):
editing mcpServers / mcp.allowed / mcp.excluded now connects, disconnects, or
restarts only the affected servers in place, without restarting the session or
losing conversation context.

- Part A: Config runtime setters + reinitializeMcpServers incremental
  reconcile; align the shared-pool path with the #4615 pending-approval gate
- Part B: SettingsWatcher subscriber (hotReload.ts), gated on a mcpServers +
  gating-list diff; flip the three MCP schema keys to hot-reloadable
- Part D: re-fire the approval modal for a gated server left pending by an edit
- Part E: /mcp shows why a gated server was skipped (pending / rejected)
- Record connection fingerprints on the bulk and lazy-connect paths so an edit
  to a server first connected via those paths is not silently dropped
- Design doc (en/zh) incl. the admission-stance boundary clarification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	packages/cli/src/config/settingsSchema.test.ts

# Conflicts:
#	packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx

# Conflicts:
#	packages/cli/src/gemini.tsx

* feat(mcp): reconcile MCP servers live on settings change

Hot-reload MCP servers when settings.json changes (issue #3696 sub-task 3):
editing mcpServers / mcp.allowed / mcp.excluded now connects, disconnects, or
restarts only the affected servers in place, without restarting the session or
losing conversation context.

- Part A: Config runtime setters + reinitializeMcpServers incremental
  reconcile; align the shared-pool path with the #4615 pending-approval gate
- Part B: SettingsWatcher subscriber (hotReload.ts), gated on a mcpServers +
  gating-list diff; flip the three MCP schema keys to hot-reloadable
- Part D: re-fire the approval modal for a gated server left pending by an edit
- Part E: /mcp shows why a gated server was skipped (pending / rejected)
- Record connection fingerprints on the bulk and lazy-connect paths so an edit
  to a server first connected via those paths is not silently dropped
- Design doc (en/zh) incl. the admission-stance boundary clarification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(mcp): harden hot-reload teardown and reconcile (review follow-ups)

Address reviewer findings on the MCP hot-reload changes:

- Extract purgeServerRegistries() and use it at every teardown path, fixing
  the discovery-timeout handler which leaked prompts/resources (only tools
  were purged) for a server that stalled tools/list past the timeout.
- Surface reconcile failures via AppEvent.LogError so a failed settings edit
  is visible to the user, not just under --debug.
- Make a single-session config edit to a discovery filter (trust /
  includeTools / excludeTools) reconnect the server so discover() re-applies
  it: connectionIdOf stays transport-only; add singleSessionConnectedKeyOf and
  rename connectionFingerprints -> connectedConfigKeys.
- Make a coalesced reinitializeMcpServers await the in-flight pass + its drain
  (store mcpReconcilePromise) so the caller no longer emits approval events /
  logs "complete" before its change is applied; coalesced callers share the
  failure.
- Assert removeResourcesByServer in the fingerprint-change tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(mcp): bound hot-reload MCP admission and explain why servers are unavailable (#3696) (#5561)

- K: treat the startup --allowed-mcp-server-names flag as an immutable upper
  bound — a runtime settings edit may narrow MCP admission within it but never
  widen beyond it; with no flag, settings fully drive admission.
- H: preserve an explicit `mcp.allowed: []` as deny-all (don't collapse to
  undefined / allow-all), matching boot semantics, and make mcpGatingEqual
  distinguish absent (allow-all) from [] (deny-all) so the change reconciles.
- B: classify why an MCP server is unavailable (removed / not_allowed /
  excluded / pending_approval) and route the tool-not-found message to the
  right recovery action; track removals against the gating-independent merged
  map (dropping the prev-effective snapshot param).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(mcp): document hot-reload admission bound, deny-all, and unavailable reasons (Part F)

Reflect the K/H/B changes in the sub-task 3 design doc: add Part F (CLI
--allowed-mcp-server-names as an immutable upper bound, mcp.allowed: [] as
deny-all, and getMcpServerUnavailableReason routing the tool-not-found message),
and fix the now-superseded "settings can widen beyond the startup allowlist"
admission-stance note and verification item 11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(serve): pre-approve gated MCP servers in daemon baseline harness

The pool/daemon discovery path now honors #4615 pending-approval gating, so the workspace-scoped MCP servers the amplification suite declares in .qwen/settings.json are skipped as pending and never spawn (the suite timed out waiting for grandchildren). Add approveWorkspaceMcpServers() to the harness (keyed by the realpath workspace to match the daemon's canonicalized --workspace) and pre-approve the fixtures before boot, mirroring simple-mcp-server.test.ts.

---------

Co-authored-by: heyang.why <heyang.why@alibaba-inc.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Heyang Wang 2026-06-25 22:50:37 +08:00 committed by GitHub
parent ecdf470bb6
commit 1344f34147
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3351 additions and 121 deletions

View file

@ -41,6 +41,10 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DaemonClient, type SubscribeOptions } from '@qwen-code/sdk';
import {
hashMcpServerConfig,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -234,6 +238,41 @@ export function writeWorkspaceSettings(
return settingsPath;
}
/**
* Pre-approve gated (workspace / project scope, #4615) MCP servers for
* `workspaceCwd` so the daemon's `qwen --acp` child connects them instead of
* skipping them as pending-approval. Servers declared in `.qwen/settings.json`
* are workspace-scoped and therefore gated: absent a stored approval, discovery
* skips them BEFORE any spawn, which makes the MCP-amplification suite time out
* waiting for grandchildren that never appear.
*
* Writes a standalone approvals file (NOT the developer's global
* `~/.qwen/mcpApprovals.json`) under the workspace and returns the env that
* points the daemon and, by inheritance, its acp child at it. Pass the
* returned env to `spawnDaemon({ env })`. The approval hash binds to the same
* behavioral fields the child hashes (`scope` is provenance-only and excluded),
* so the plain settings config is sufficient. Mirrors the pre-approval pattern
* in `simple-mcp-server.test.ts`.
*/
export function approveWorkspaceMcpServers(
workspaceCwd: string,
servers: Record<string, MCPServerConfig>,
): Record<string, string> {
const approvalsPath = path.join(workspaceCwd, '.qwen', 'mcpApprovals.json');
const project: Record<string, { hash: string; status: 'approved' }> = {};
for (const [name, config] of Object.entries(servers)) {
project[name] = { hash: hashMcpServerConfig(config), status: 'approved' };
}
// Key by the canonical (realpath) workspace, NOT `path.resolve`: the daemon
// canonicalizes `--workspace` (e.g. macOS `/var` → `/private/var`) and the
// acp child looks approvals up under that resolved path. Keying by the
// un-resolved temp path would miss, leaving the servers pending.
const root = fs.realpathSync(workspaceCwd);
fs.mkdirSync(path.dirname(approvalsPath), { recursive: true });
fs.writeFileSync(approvalsPath, JSON.stringify({ [root]: project }, null, 2));
return { QWEN_CODE_MCP_APPROVALS_PATH: approvalsPath };
}
/**
* One-shot RSS read via `ps -o rss= -p <pid>`. Returns megabytes (rounded
* to 1 decimal). Returns NaN if the process is gone or `ps` errored call

View file

@ -46,6 +46,7 @@ import {
countDescendants,
percentiles,
writeWorkspaceSettings,
approveWorkspaceMcpServers,
gitHead,
makeTempWorkspace,
sleep,
@ -386,13 +387,15 @@ async function measureRssAtSessionCount(sessionCount: number): Promise<{
const ws = makeTempWorkspace('mcp');
let daemon: SpawnedDaemon | undefined;
try {
writeWorkspaceSettings(ws, {
mcpServers: {
idle1: { command: 'node', args: [IDLE_MCP_PATH] },
idle2: { command: 'node', args: [IDLE_MCP_PATH] },
},
});
daemon = await spawnDaemon({ workspaceCwd: ws });
const mcpServers = {
idle1: { command: 'node', args: [IDLE_MCP_PATH] },
idle2: { command: 'node', args: [IDLE_MCP_PATH] },
};
writeWorkspaceSettings(ws, { mcpServers });
// Workspace-scoped servers are gated (#4615); pre-approve so the
// daemon's acp child connects them instead of skipping as pending.
const env = approveWorkspaceMcpServers(ws, mcpServers);
daemon = await spawnDaemon({ workspaceCwd: ws, env });
await daemon.client.createOrAttachSession({ workspaceCwd: ws });
const at1 = await waitForMcpGrandchildren(
@ -475,13 +478,15 @@ async function measureRssAtSessionCount(sessionCount: number): Promise<{
const ws = makeTempWorkspace('mcp-counter');
let daemon: SpawnedDaemon | undefined;
try {
writeWorkspaceSettings(ws, {
mcpServers: {
idle1: { command: 'node', args: [IDLE_MCP_PATH] },
idle2: { command: 'node', args: [IDLE_MCP_PATH] },
},
});
daemon = await spawnDaemon({ workspaceCwd: ws });
const mcpServers = {
idle1: { command: 'node', args: [IDLE_MCP_PATH] },
idle2: { command: 'node', args: [IDLE_MCP_PATH] },
};
writeWorkspaceSettings(ws, { mcpServers });
// Workspace-scoped servers are gated (#4615); pre-approve so the
// daemon's acp child connects them instead of skipping as pending.
const env = approveWorkspaceMcpServers(ws, mcpServers);
daemon = await spawnDaemon({ workspaceCwd: ws, env });
await daemon.client.createOrAttachSession({ workspaceCwd: ws });
// Wait until the OS sees the full pooled set