mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
ecdf470bb6
commit
1344f34147
41 changed files with 3351 additions and 121 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue