Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
666 changes: 666 additions & 0 deletions docs/design/hot-reload/mcp-runtime-reinitialization.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions integration-tests/cli/_daemon-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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
Expand Down
33 changes: 19 additions & 14 deletions integration-tests/cli/qwen-serve-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
countDescendants,
percentiles,
writeWorkspaceSettings,
approveWorkspaceMcpServers,
gitHead,
makeTempWorkspace,
sleep,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"express": "^5.2.1",
"fast-deep-equal": "^3.1.3",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] One thing I noticed here: this adds a direct packages/cli dependency, but package-lock.json was not updated. I checked in a temporary worktree, and npm install --package-lock-only --ignore-scripts adds the missing packages/cli.dependencies.fast-deep-equal entry. Could we include that lockfile update as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. It has been fixed in the latest code

"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1885,10 +1885,16 @@ export async function loadCliConfig(
toolCallCommand: bareMode ? undefined : settings.tools?.callCommand,
mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand,
mcpServers,
topTierMcpServers,
pendingMcpServers,
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
// The flag ONLY (not the settings-derived list) — the hot-reload upper
// bound. Undefined when `--allowed-mcp-server-names` was not passed.
cliAllowedMcpServerNames: argv.allowedMcpServerNames
? argv.allowedMcpServerNames.filter(Boolean)
: undefined,
excludedMcpServers: excludedMcpServers
? Array.from(excludedMcpServers)
: undefined,
Expand Down
Loading
Loading