Skip to content

feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615) - #4713

Merged
wenshao merged 15 commits into
QwenLM:mainfrom
qqqys:feat/mcp-scope-precedence-workspace-approval
Jun 13, 2026
Merged

feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615)#4713
wenshao merged 15 commits into
QwenLM:mainfrom
qqqys:feat/mcp-scope-precedence-workspace-approval

Conversation

@qqqys

@qqqys qqqys commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds approval gating for untrusted, checked-in MCP server sources and a coherent cross-source precedence model, aligning qwen-code with Claude Code's .mcp.json handling while preserving enterprise and session-level trust boundaries.

The gated sources are project .mcp.json files and workspace .qwen/settings.json mcpServers. Servers from those shared repo-level files are held behind a per-server, hash-bound approval decision before discovery can connect to them. Effective MCP server precedence is now assembled as: user/default settings < project .mcp.json < workspace/system settings < session (ACP/IDE) < --mcp-config. Project config can override user config, but it cannot override enterprise-enforced system settings; session-injected servers remain explicit, per-session, and ungated.

Approval decisions persist in <QWEN_HOME>/mcpApprovals.json, keyed by project root and server name, and bound to a canonical config hash. Editing a server changes the hash and returns it to pending. qwen mcp approve [name|--all], qwen mcp reject [name|--all], and qwen mcp list expose the approval state without connecting pending or rejected gated servers.

Why it's needed

Checked-in MCP configuration can spawn arbitrary local commands or connect to external services. Without a trust gate, opening a repository with a malicious .mcp.json or workspace settings file can trigger side effects before the user has reviewed the server. This PR makes those shared sources explicit trust decisions, while keeping user, system, extension, CLI, and session-supplied servers working as trusted sources.

The hash binding is the core safety property: an approval applies only to the reviewed server configuration. If the repo changes the command, args, URL, environment, or headers, the server becomes pending again instead of silently inheriting the old decision.

Reviewer Test Plan

How to verify

Reviewers should confirm that project .mcp.json and workspace-scoped servers appear as Pending approval or Rejected without connecting, that approving a server persists a hash-bound decision, that editing the server returns it to pending, and that rejecting a server prevents connection across interactive, headless, ACP/IDE, and qwen mcp reconnect entry points. Reviewers should also confirm precedence: project .mcp.json overrides user settings, workspace overrides project, system remains above project, and --mcp-config overrides all persistent sources.

Run the targeted unit suites for MCP config loading, approval storage, precedence assembly, list/approve/reject commands, config pending checks, and the approval UI hook. The PR has also been validated with full workspace typecheck and eslint plus core MCP suites.

Evidence (Before & After)

Before: checked-in project/workspace MCP server definitions could be discovered through entry points that did not consistently consult a persisted approval decision, and PR description did not follow the repository template.

After: gated project/workspace servers are skipped before any process spawn or transport connection until an approved hash-bound decision exists; explicit rejections are honored on every entry point; reconnect uses the assembled server map; approving from the startup dialog does not bypass the independent folder-trust gate; the PR body now follows the required template.

Tested on

OS Status
🍏 macOS ✅ tested by maintainer local reports
🪟 Windows ✅ CI test matrix
🐧 Linux ✅ tested by maintainer local report

Environment (optional)

Local and maintainer verification reports covered Node 22, package-specific Vitest suites, TypeScript typecheck, eslint, and real CLI approval lifecycle checks with isolated QWEN_HOME and scratch repositories.

Risk & Scope

  • Main risk or tradeoff: approval gating intentionally changes behavior for checked-in project/workspace MCP servers, so users must approve or reject those servers before they connect.
  • Not validated / out of scope: automatic expiration or audit metadata for approval decisions; multi-step approval UX beyond the current per-server approve / approve-all / reject dialog.
  • Breaking changes / migration notes: previously auto-connected project/workspace servers may now show as pending or rejected until explicitly approved.

Linked Issues

Refs #4615.

中文说明

What this PR does

本 PR 为不可信、可随仓库提交的 MCP server 来源增加审批门控,并建立统一的跨来源优先级模型,使 qwen-code 与 Claude Code 的 .mcp.json 行为对齐,同时保留企业级和会话级信任边界。

被门控的来源包括项目 .mcp.json 和 workspace .qwen/settings.json 里的 mcpServers。这些共享的仓库级配置声明的 server,必须先经过按 server、按配置 hash 绑定的审批决策,发现层才会连接。最终 MCP server 优先级为:user/default settings < project .mcp.json < workspace/system settings < session (ACP/IDE) < --mcp-config。项目配置可以覆盖用户配置,但不能覆盖企业强制的 system 配置;session 注入的 server 仍然是显式、按会话生效且不被门控的可信来源。

审批决策持久化在 <QWEN_HOME>/mcpApprovals.json,按 project root 和 server name 建索引,并绑定规范化配置 hash。修改 server 配置会改变 hash,使它重新回到 pending。qwen mcp approve [name|--all]qwen mcp reject [name|--all]qwen mcp list 会展示审批状态,且不会连接 pending 或 rejected 的 gated server。

Why it's needed

提交到仓库里的 MCP 配置可以启动任意本地命令或连接外部服务。如果没有信任门控,用户打开包含恶意 .mcp.json 或 workspace settings 的仓库时,server 可能在用户审查前就产生副作用。本 PR 把这些共享来源变成显式信任决策,同时保持 user、system、extension、CLI 和 session 提供的 server 作为可信来源正常工作。

Hash 绑定是核心安全属性:一次审批只适用于用户已审查过的那份 server 配置。如果仓库修改了 command、args、URL、environment 或 headers,该 server 会重新变成 pending,而不是静默继承旧审批。

Reviewer Test Plan

How to verify

评审者应确认 project .mcp.json 和 workspace scope 的 server 会显示为 Pending approvalRejected,且不会被连接;确认 approve 会持久化 hash 绑定决策;确认修改 server 后会回到 pending;确认 reject 会在 interactive、headless、ACP/IDE 和 qwen mcp reconnect 等入口都阻止连接。还应确认优先级:project .mcp.json 覆盖 user settings,workspace 覆盖 project,system 保持高于 project,--mcp-config 覆盖所有持久来源。

建议运行 MCP 配置加载、审批存储、优先级组装、list/approve/reject 命令、config pending 检查和审批 UI hook 的定向单测。本 PR 也已通过 full workspace typecheck、eslint 和 core MCP suites 的验证。

Evidence (Before & After)

Before:checked-in 的 project/workspace MCP server 定义可能通过某些入口被发现,而这些入口没有一致查询持久化审批决策;PR 描述也没有遵循仓库模板。

After:gated project/workspace server 在没有 approved 的 hash 绑定决策前,会在任何 process spawn 或 transport connection 之前被跳过;显式 reject 会在所有入口生效;reconnect 使用组装后的 server map;startup dialog 的 approve 不会绕过独立的 folder-trust gate;PR 正文已按要求改为模板结构。

Tested on

OS Status
🍏 macOS ✅ maintainer 本地验证报告
🪟 Windows ✅ CI 测试矩阵
🐧 Linux ✅ maintainer 本地验证报告

Environment (optional)

本地和 maintainer 验证报告覆盖了 Node 22、package-specific Vitest suites、TypeScript typecheck、eslint,以及使用隔离 QWEN_HOME 和临时仓库的真实 CLI 审批生命周期检查。

Risk & Scope

  • Main risk or tradeoff: 审批门控会有意改变 checked-in project/workspace MCP server 的行为,用户必须先 approve 或 reject,这些 server 才会连接。
  • Not validated / out of scope: 审批决策的自动过期或审计元数据;超出当前 per-server approve / approve-all / reject dialog 的多步骤审批 UX。
  • Breaking changes / migration notes: 之前会自动连接的 project/workspace server 现在可能显示为 pending 或 rejected,直到用户显式 approve。

Linked Issues

Refs #4615.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR implements approval gating for untrusted MCP server sources (.mcp.json and workspace .qwen/settings.json) with a coherent cross-source precedence model, aligning with Claude Code's .mcp.json handling. The implementation is comprehensive, touching config loading, discovery, CLI commands, and UI dialogs. Overall assessment: well-designed with strong security boundaries, but has a few areas needing attention before merge.

🔍 General Feedback

  • Strong security model: The approval gating is correctly placed BEFORE any transport/connection attempts, preventing side effects from inspecting untrusted configs
  • Hash-bound approvals: Binding approval decisions to config hashes is excellent - editing a server config correctly returns it to pending status
  • Clean separation: New functionality is well-factored into dedicated files (mcpApprovals.ts, mcpJson.ts, mcpServers.ts) without polluting existing code
  • Comprehensive testing: Test files accompany all major new modules (though I couldn't verify their content in this review)
  • Consistent patterns: Defensive optional-chaining for methods that may be absent in test fixtures is good practice

🎯 Specific Feedback

🔴 Critical

  • File: packages/cli/src/config/mcpApprovals.ts:88-94 - getPendingProjectMcpServers iterates over mcpServers but the approval state check uses isGatedMcpScope(config.scope). However, mcpServers here is the raw input - ensure that ALL gated servers have their scope field properly stamped before this check. The assembleMcpServers function tags .mcp.json servers with scope: 'project', but verify workspace servers from mergeSettings also have scope: 'workspace' stamped consistently. If any gated server lacks the scope tag, it would bypass approval.

  • File: packages/core/src/config/config.ts:522 - isGatedMcpScope returns true for 'project' and 'workspace' scopes. The comment states 'system' is enterprise-enforced and trusted, but there's no explicit check preventing a malicious workspace from declaring a server with scope: 'system' in their .mcp.json. Verify that the scope field from .mcp.json is always forced to 'project' and cannot be overridden by user-provided JSON. Same for workspace settings - ensure scope cannot be escalated.

🟡 High

  • File: packages/cli/src/config/mcpApprovals.ts:13-20 - The McpApprovalRecord type stores hash and status, but there's no timestamp or metadata about when the decision was made. For debugging and potential future features (like approval expiration or audit logs), consider adding approvedAt?: number (timestamp). This is not blocking but would be valuable for enterprise deployments.

  • File: packages/cli/src/ui/hooks/useMcpApproval.ts:63-73 - The handleMcpApprovalSelect callback mutates the queue with setQueue((q) => ...) and calls reconnect() inside the state update. The reconnect call triggers discoverToolsForServer which is async but not awaited here. This is probably fine since the dialog flow doesn't depend on discovery completion, but document this fire-and-forget behavior or add a comment explaining why awaiting isn't needed.

  • File: packages/core/src/tools/mcp-client-manager.ts:979-983 - The pending approval check uses optional chaining isMcpServerPendingApproval?.(name). While this is defensive for test fixtures, in production Config always has this method. Consider adding a debug assertion (e.g., if (process.env.NODE_ENV === 'development' && this.cliConfig.isMcpServerPendingApproval === undefined)) to catch test fixtures that accidentally omit this critical method, since silently skipping the check would be a security regression.

🟢 Medium

  • File: packages/cli/src/commands/mcp/approve.ts:23-37 - The setProjectServerStatus function loads gated servers twice: once for the "no servers" check and again in the loop. Minor inefficiency - could cache the result. Also, the error message "Specify a server name or pass --all." is shown when targets.length === 0, but this happens even when the user provided a name that doesn't match. Consider distinguishing between "no name provided" vs "name not found" for better UX.

  • File: packages/cli/src/ui/components/mcp/MCPServerApprovalDialog.tsx:36-61 - The dialog options use hardcoded keys ('approve', 'approve_all', 'reject') that match McpApprovalChoice enum values. Consider using the enum directly in the key fields to maintain consistency if enum values change: key: McpApprovalChoice.APPROVE etc. This prevents drift between the enum and dialog implementation.

  • File: packages/core/src/mcp/configHash.ts:17-25 - The NON_BEHAVIORAL_FIELDS set excludes scope, extensionName, and description from the hash. This is correct for approval binding, but consider adding a comment explaining WHY these are non-behavioral. For example: scope is provenance metadata, extensionName is ownership tracking, description is cosmetic. Future maintainers might question whether new fields should be added here without understanding the security rationale.

🔵 Low

  • File: packages/cli/src/config/mcpApprovals.ts:1 - Import statement brings in type MCPServerConfig but also runtime values hashMcpServerConfig, isGatedMcpScope, Storage. Consider splitting the import for clarity:

    import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
    import { hashMcpServerConfig, isGatedMcpScope, Storage } from '@qwen-code/qwen-code-core';
  • File: packages/cli/src/commands/mcp/list.ts:115-123 - The approvals file is lazily loaded with approvals ??= loadMcpApprovals(). This is good for performance, but the load can throw if the approvals file is corrupted. Wrap in a try-catch and gracefully degrade to showing all gated servers as "Pending approval" if the store is unreadable, rather than crashing the entire list command.

  • File: packages/cli/src/ui/hooks/useMcpApproval.ts:20-28 - The sourceLabel function handles 'workspace' and 'project' scopes, but what about 'system' scope? The function falls through to default returning .mcp.json, which would be misleading for system servers. Add an explicit case for 'system' (even if it returns 'system settings') and consider adding a debugLogger.warn for unexpected scopes to catch future regressions.

  • File: packages/cli/src/config/mcpServers.ts:1 - The file header comment references "issue Add project-scoped .mcp.json support with pending approval semantics #4615" but doesn't link to the GitHub issue URL. Consider adding the full URL https://github.com/QwenLM/qwen-code/issues/4615 for easier navigation.

✅ Highlights

  • Excellent security design: The approval gating happens at multiple layers (config loading, discovery, single-server reconnect) with consistent checks - defense in depth done right
  • Hash-bound approval decisions: Tying approval to config hash means any edit requires re-approval, preventing silent drift - this is the correct security model
  • Precedence model clarity: The documented precedence order (user < project < workspace < system < session < CLI) is well-implemented in assembleMcpServers with clear comments
  • Non-interactive leniency: Auto-approving in headless modes (SDK, -p, piped) matches Claude Code's behavior and is the right UX choice for automation
  • Clean test coverage: New test files for approvals, mcpJson, mcpServers, and useMcpApproval show thoughtful test planning
  • Proper cleanup on timeout: The runWithDiscoveryTimeout logic correctly releases slots and drops refusal entries when servers time out, preventing budget exhaustion attacks

Comment thread packages/core/src/mcp/configHash.ts Outdated
return value;
});

return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16);

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.

[Critical] SHA-256 truncated to 16 hex characters (64 bits). Since the attacker controls .mcp.json (both the "initial approved" config and the later "modified" config), they can use a birthday attack to find a collision pair in approximately 2^32 SHA-256 evaluations — under 30 seconds on modern hardware. The attacker varies innocuous fields (e.g. env values, extra headers like X-Request-Id) to generate collision candidates while keeping the displayed summary identical.

This completely undermines the security guarantee of hash-bound approval: an attacker crafts two configs that hash to the same 64-bit value — one benign (to get initial approval) and one malicious (pushed later to silently bypass the approval gate).

Note: The Claude Code reference uses truncated hashes for reload detection (where collisions are harmless), not for security-critical approval binding. The threat models differ.

Suggested change
return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16);
return crypto.createHash('sha256').update(stable).digest('hex');

— claude-opus-4-6 via Qwen Code /review

(choice: McpApprovalChoice) => {
const approvals = loadMcpApprovals();
const root = config.getWorkingDir();
setQueue((q) => {

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.

[Suggestion] Side effects (disk I/O via approvals.setStatefs.writeFileSync, and MCP reconnect via reconnectdiscoverToolsForServer) are performed inside the setQueue((q) => {...}) state updater function. React's contract specifies updater functions must be pure — React may invoke them more than once (StrictMode, concurrent features). If double-invoked, this fires duplicate disk writes and duplicate server connection attempts.

Move side effects out of the updater. Read queue from the closure and perform I/O after the state transition:

const handleMcpApprovalSelect = useCallback(
  (choice: McpApprovalChoice) => {
    const approvals = loadMcpApprovals();
    const root = config.getWorkingDir();
    const current = queue[0];
    if (!current) return;
    if (choice === McpApprovalChoice.APPROVE_ALL) {
      for (const server of queue) {
        approvals.setState(root, server.name, server.config, 'approved');
        reconnect(server.name);
      }
      setQueue([]);
    } else if (choice === McpApprovalChoice.APPROVE) {
      approvals.setState(root, current.name, current.config, 'approved');
      reconnect(current.name);
      setQueue((q) => q.slice(1));
    } else {
      approvals.setState(root, current.name, current.config, 'rejected');
      setQueue((q) => q.slice(1));
    }
  },
  [config, reconnect, queue],
);

— claude-opus-4-6 via Qwen Code /review

Comment thread packages/cli/src/config/mcpServers.ts Outdated

return {
...belowProject,
...loadProjectMcpServers(cwd).servers,

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.

[Suggestion] loadProjectMcpServers(cwd).errors is discarded here. If .mcp.json has a JSON syntax error, missing mcpServers key, or non-object entries, zero project servers load with no user-visible feedback. The user sees no MCP tools and no error — a silent failure that is very hard to diagnose.

Surface errors to the user (e.g. via stderr or a startup warning):

Suggested change
...loadProjectMcpServers(cwd).servers,
const projectResult = loadProjectMcpServers(cwd);
for (const err of projectResult.errors) {
// eslint-disable-next-line no-console
console.error(`Warning: ${err}`);
}
return {
...belowProject,
...projectResult.servers,
...aboveProject,
...(cliMcpServers ?? {}),
};

— claude-opus-4-6 via Qwen Code /review

errors.push(`${filePath}: server "${name}" is not an object — skipped`);
continue;
}
servers[name] = {

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.

[Suggestion] A .mcp.json server entry named "__proto__" triggers the Object.prototype.__proto__ setter when assigned to this plain object. The entry is silently dropped from all downstream operations (Object.keys/entries/spread won't see it), making it invisible to the approval pipeline.

Use Object.create(null) for the accumulator to avoid prototype-inherited setters:

Suggested change
servers[name] = {
servers[name] = {

Or change the declaration at line 84 to:

const servers: Record<string, MCPServerConfig> = Object.create(null);

— claude-opus-4-6 via Qwen Code /review

(name: string) => {
config.approveMcpServerForSession(name);
const registry = config.getToolRegistry();
void registry?.discoverToolsForServer?.(name)?.catch?.(() => {});

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.

[Suggestion] reconnect swallows ALL errors from discoverToolsForServer with .catch?.(() => {}). If the MCP server binary is missing, the transport times out, or the server crashes on startup, there is zero user-facing feedback. The dialog closes, the server appears "approved", but no tools materialize.

At minimum, log the error so it surfaces in debug mode:

Suggested change
void registry?.discoverToolsForServer?.(name)?.catch?.(() => {});
void registry?.discoverToolsForServer?.(name)?.catch?.((err: unknown) => {
if (process.env['DEBUG']) {
// eslint-disable-next-line no-console
console.error(`MCP reconnect failed for ${name}:`, err);
}
});

— claude-opus-4-6 via Qwen Code /review

// torn down if a prior pass had connected it.
if (
cliConfig.isMcpServerDisabled(name) ||
cliConfig.isMcpServerPendingApproval?.(name)

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.

[Suggestion] The pending-approval guard is tested only on the discoverAllMcpTools bulk path (line ~979). Three other guard sites lack dedicated tests:

  • discoverAllMcpToolsIncremental (here, ~1632): the teardown of a previously-connected server that becomes pending mid-session
  • readResource lazy-spawn guard (~2012) and re-check (~2096)
  • discoverMcpToolsForServerInternal (~1151): single-server rediscovery path

These are security-critical paths — a regression could leave an untrusted server connected after its config changed, or allow lazy-spawning a pending-approval server via resource read. Add parity tests mirroring the existing disabled-server tests for each path.

— claude-opus-4-6 via Qwen Code /review

config: MCPServerConfig,
status: McpApprovalStatus,
): void {
const root = normalizeProjectRoot(projectRoot);

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.

[Suggestion] setState does no structural validation on the per-project record before mutating. If mcpApprovals.json is corrupted with a non-object value under a project key (e.g. { "/home/user/proj": "garbage" }), this line does const project = this.file.config[root] ?? {} which gets the string "garbage" (truthy, so ?? doesn't fire), then project[serverName] = {...} throws a TypeError in strict mode — crashing the approval dialog.

Suggested change
const root = normalizeProjectRoot(projectRoot);
const existing = this.file.config[root];
const project: Record<string, McpApprovalRecord> =
existing && typeof existing === 'object' && !Array.isArray(existing)
? (existing as Record<string, McpApprovalRecord>)
: {};
project[serverName] = { hash: hashMcpServerConfig(config), status };
this.file.config[root] = project;

— claude-opus-4-6 via Qwen Code /review

@wenshao wenshao left a comment

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.

[Critical · typecheck] 21 TypeScript errors across 3 test files — build fails.

  • packages/cli/src/commands/mcp.test.ts: 5 errors — mcpCommand.builder possibly undefined / not callable (lines 23, 38)
  • packages/cli/src/commands/mcp/list.test.ts: 11 errors — Cannot find namespace 'vi' (lines 43–64). Likely missing import type { Mock } from 'vitest' or vitest types not in tsconfig.
  • packages/cli/src/config/config.test.ts: 5 errors — 'mcpServers' is possibly 'undefined' (lines 2107, 2112), Object is possibly 'undefined' (lines 2125, 2139), and contextPercentageThreshold does not exist on ChatCompressionSettings (line 2493).

— qwen3.7-max via Qwen Code /review


export function getMcpApprovalsPath(): string {
if (process.env['QWEN_CODE_MCP_APPROVALS_PATH']) {
return process.env['QWEN_CODE_MCP_APPROVALS_PATH'];

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.

[Critical] QWEN_CODE_MCP_APPROVALS_PATH is read from process.env here but is NOT in PROJECT_ENV_HARDCODED_EXCLUSIONS in settings.ts. A malicious repository can include a .env file that sets this variable to an attacker-controlled path containing pre-approved entries for every server in .mcp.json. Attack flow: (1) user clones repo, (2) trusts the folder, (3) loadEnvironment reads .env and sets the env var, (4) getMcpApprovalsPath() returns the attacker's path, (5) all malicious servers appear pre-approved and auto-connect without any approval dialog.

This completely bypasses the approval gate — the primary security control this PR introduces.

Suggested change
return process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
// NB: QWEN_CODE_MCP_APPROVALS_PATH must be in PROJECT_ENV_HARDCODED_EXCLUSIONS
// in settings.ts to prevent project .env from redirecting the approvals store.
if (process.env['QWEN_CODE_MCP_APPROVALS_PATH']) {
return process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
}

Also add 'QWEN_CODE_MCP_APPROVALS_PATH' to PROJECT_ENV_HARDCODED_EXCLUSIONS in settings.ts.

— qwen3.7-max via Qwen Code /review

errors.push({ message: getErrorMessage(error), path: filePath });
}

loadedMcpApprovals = new LoadedMcpApprovals(

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.

[Critical] loadMcpApprovals() captures parse/IO errors into errors, but getPendingProjectMcpServers() (and all other callers) never reads this field. When the approvals file is corrupted (truncated by crash, partial write, disk error), the function returns an empty config, getState() returns 'pending' for every gated server, and all previously-approved servers silently revert to pending — with no stderr warning, no log line, and no UI message.

This is the most likely production issue: "Every MCP server stopped working and is asking for approval again" with no diagnostic trail to explain why.

Suggested fix: Surface errors at load time:

loadedMcpApprovals = new LoadedMcpApprovals(
  { path: filePath, config },
  errors,
);
for (const err of errors) {
  writeStderrLine(`Warning: MCP approvals file error: ${err.message}`);
}
return loadedMcpApprovals;

— qwen3.7-max via Qwen Code /review

}
}
return pending;
}

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.

[Critical] saveMcpApprovals uses raw fs.writeFileSync instead of atomicWriteFile from packages/core/src/utils/atomicFileWrite.ts (used in 68+ call sites across the codebase). If the process is killed mid-write (Ctrl-C during the approval dialog, SIGKILL, power loss), the approvals file can be left truncated or containing partial JSON. Combined with the silent error handling above, this creates a silent total-loss scenario: the next startup reads a corrupt file, loses ALL approval decisions, and tells nobody.

The "Approve All" path calls setState in a loop (N sequential writes for N servers), widening the crash window.

Suggested change
}
export async function saveMcpApprovals(file: {
path: string;
config: McpApprovalsConfig;
}): Promise<void> {
try {
const dirPath = path.dirname(file.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
await atomicWriteFile(file.path, JSON.stringify(file.config, null, 2), {
mode: 0o600,
});
} catch (error) {
writeStderrLine('Error saving MCP approvals file.');
writeStderrLine(error instanceof Error ? error.message : String(error));
}
}

Note: this makes saveMcpApprovals async, which cascades to setState and callers — but all callers are already in async contexts.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/config/mcpJson.ts Outdated
}

const mcpServers = (parsed as { mcpServers?: unknown })?.mcpServers;
if (!mcpServers || typeof mcpServers !== 'object') {

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.

[Suggestion] The guard checks typeof mcpServers !== 'object' but doesn't check Array.isArray(mcpServers). Since typeof [] === 'object', a .mcp.json with "mcpServers": [...] (array instead of object) passes the guard. Object.entries on an array yields [['0', elem0], ['1', elem1], ...], producing phantom servers with numeric names like "0", "1".

The inner Array.isArray(value) check on individual entries catches array-typed elements but not the array-typed parent.

Suggested change
if (!mcpServers || typeof mcpServers !== 'object') {
if (!mcpServers || typeof mcpServers !== 'object' || Array.isArray(mcpServers)) {
return {
servers: {},
path: filePath,
errors: [`${filePath} has no "mcpServers" object`],
};

— qwen3.7-max via Qwen Code /review

// are not approved are listed WITHOUT connecting — inspecting an untrusted
// config must stay side-effect-free (#4615). Only approved / non-gated
// servers get a live connection test.
if (isGatedMcpScope(server.scope)) {

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.

[Suggestion] This gated-scope branch (showing "Pending approval" / "Rejected" without connecting) is the core user-visible behavior of qwen mcp list for untrusted servers, but list.test.ts has zero tests exercising it. All four existing test servers lack scope: 'project' or scope: 'workspace', so isGatedMcpScope() always returns false in the test suite. Additionally, loadMcpApprovals and assembleMcpServers are not mocked — existing tests silently call real implementations.

A regression could silently attempt to connect untrusted .mcp.json servers during listing, violating the side-effect-free guarantee.

Suggested fix: Add tests for: (1) a project-scoped pending server shows "Pending approval" without calling createTransport; (2) a rejected workspace server shows "Rejected". Mock assembleMcpServers and loadMcpApprovals to control test inputs.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/config/config.ts Outdated
const pendingMcpServers =
bareMode || !interactive
? undefined
: getPendingProjectMcpServers(mcpServers, cwd);

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.

[Suggestion] This is the most security-critical branch in the PR — it determines whether gated servers auto-connect or require approval — yet config.test.ts has zero tests for pendingMcpServers or the non-interactive bypass path (grep for both terms returns no matches).

Without a test, a refactor of the interactive or bareMode detection could silently change the security posture of headless sessions in either direction: auto-approve when it shouldn't, or block when it should auto-approve.

Suggested fix: Add two tests: (1) interactive session with a .mcp.json server verifying isMcpServerPendingApproval(name) returns true; (2) non-interactive session (stdin not TTY) verifying isMcpServerPendingApproval(name) returns false.

— qwen3.7-max via Qwen Code /review

if (!current) {
return;
}
if (choice === McpApprovalChoice.APPROVE_ALL) {

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.

[Suggestion] When the user selects "Approve all," the hook iterates the entire queue approving every server, but MCPServerApprovalDialog only displays the first server's name and summary. The user never sees the command, args, or httpUrl of subsequent servers before the blanket approval.

A malicious .mcp.json could declare one legitimate-looking server followed by several malicious ones. The user reviews the first, hits "Approve all," and the malicious servers connect without any scrutiny.

Suggested fix: List all pending server names + summaries in the dialog before the radio buttons, or add a confirmation step for "Approve all" that shows what will be approved:

Approving all will connect these servers:
  slack       node slack.js (stdio)
  telemetry   curl example.com (http)

— qwen3.7-max via Qwen Code /review

}
}

function summarize(config: MCPServerConfig): string {

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.

[Suggestion] summarize() duplicates the same httpUrl(http) / url(sse) / command args(stdio) logic that already exists in formatServerCommand() at packages/cli/src/ui/components/mcp/utils.ts:103-116. The only differences are cosmetic (.replace(/\s+\(/, ' (') vs .trim()).

Three copies of the same formatting logic means a future transport type requires edits in three places, and the slight drift already produces marginally different output for edge cases.

Suggested fix: Extract a shared formatMcpServerSummary(config: MCPServerConfig): string into mcp/utils.ts and reuse from all three call sites.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/config/mcpApprovals.ts Outdated
* returned list is what the discovery layer skips
* (`Config.isMcpServerPendingApproval`). See issue #4615.
*/
export function getPendingProjectMcpServers(

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.

[Suggestion] The function name says "Project" but it iterates ALL gated scopes via isGatedMcpScope(config.scope), which returns true for both 'project' AND 'workspace'. Workspace-scoped servers (from .qwen/settings.json) are also included in the returned list.

A caller reading getPendingProjectMcpServers might assume workspace-scoped servers are excluded and add a separate workspace check, creating redundant or incorrect logic.

Suggested change
export function getPendingProjectMcpServers(
export function getPendingGatedMcpServers(

— qwen3.7-max via Qwen Code /review

* stdio spawn / transport / health check, so inspecting an untrusted
* `.mcp.json` has no side effects. See issue #4615.
*/
isMcpServerPendingApproval(serverName: string): boolean {

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.

[Critical] getFailedMcpServerNames() (line 1884) skips disabled servers but does NOT skip pending-approval servers. Since the discovery layer never connects pending servers (status stays DISCONNECTED), getMCPServerStatus(name) !== CONNECTED is true for them, and they get included in the "failed" list.

Callers (gemini.tsx, acpAgent.ts, nonInteractive/session.ts) then emit "Warning: MCP server(s) failed to start: slack" — misleading diagnostics for servers that are merely awaiting approval, not actually broken.

This also means the surfaceFailuresOnce path in AppContainer.tsx may fire during the approval dialog, before the user has even had a chance to decide.

Suggested change
isMcpServerPendingApproval(serverName: string): boolean {
isMcpServerPendingApproval(serverName: string): boolean {
return this.pendingMcpServers?.includes(serverName) ?? false;
}
// In getFailedMcpServerNames() (line 1891), add after the disabled check:
// if (this.isMcpServerPendingApproval(name)) continue;

— qwen3.7-max via Qwen Code /review

throw new Error('mcp command builder must be a function');
}
const builtYargs = builder(yargsInstance);
const options = builtYargs.getOptions();

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.

[Critical] TypeScript error: Property 'getOptions' does not exist on type 'Argv<{}> | PromiseLike<Argv<{}>>'. The builder function (typed as CommandModule['builder']) can return Argv | PromiseLike<Argv>. The runtime typeof builder !== 'function' guard narrows it to a function, but the return type is still the union.

Suggested change
const options = builtYargs.getOptions();
const builtYargs = await builder(yargsInstance);
const options = builtYargs.getOptions();

(Make the test callback async and await the builder result to unwrap the PromiseLike.)

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 3, 2026

@wenshao wenshao left a comment

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.

Both R2 Critical issues are fixed: getFailedMcpServerNames() now filters pending-approval servers (config.ts:1902), and the mcp.test.ts tsc error is resolved (async builder). The "Approve all" dialog now lists all pending servers for transparency. tsc and eslint clean, 217 tests pass across 5 MCP-related suites. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Verification Report — PR #4713

Commit: 18e0a72a5 (fix(cli): align prompt width with approval visuals)
Base: main
Tester: wenshao
Date: 2026-06-03


Test Results

Check Result Details
Core unit tests (configHash, mcp-client-manager, config) PASS 3 files, 275 tests passed
CLI unit tests (mcpApprovals, mcpJson, mcpServers, settings, config, mcp, approve, list, useMcpApproval) PASS 9 files, 395 passed + 2 skipped
TypeScript packages/core (tsc --noEmit) WARN 3 errors in converter.ts — see note
ESLint (--max-warnings 0) PASS 0 warnings, 0 errors (15 source files)
Build (packages/core) WARN Same 3 converter.ts errors — see note
git diff --check PASS Warnings only in patches/ink+7.0.3.patch (not in PR diff)

Typecheck Note

The 3 errors (FinishReason.IMAGE_OTHER / IMAGE_RECITATION not found in converter.ts) come from commit d8add2152 (@google/genai 1.30→2.6.0, PR #4485) which was merged into this branch but has not landed on origin/main yet. The PR's own changed files (MCP config, approvals, configHash, mcp-client-manager) have zero type errors. Once #4485 lands on main, these resolve.

Test File Breakdown

File Tests Time
core/config/config.test.ts 155
core/tools/mcp-client-manager.test.ts 73 361ms
core/mcp/configHash.test.ts 47
cli/config/config.test.ts 222 (+2 skipped) 953ms
cli/config/settings.test.ts 125 79ms
cli/config/mcpApprovals.test.ts 13 143ms
cli/config/mcpJson.test.ts 9 114ms
cli/commands/mcp/approve.test.ts 7 210ms
cli/config/mcpServers.test.ts 6 77ms
cli/commands/mcp/list.test.ts 6 7ms
cli/ui/hooks/useMcpApproval.test.ts 6 112ms
cli/commands/mcp.test.ts 3 12ms

Execution Environment

  • Method: tmux parallel execution (5 windows: core tests, cli tests, typecheck, lint, build)
  • Node: v22.17.0
  • Vitest: v3.2.4
  • Platform: macOS Darwin 25.4.0

Verdict

All tests pass — 670 total (275 core + 395 cli). ESLint clean across 15 source files. The typecheck errors are from a cross-branch dependency (#4485 @google/genai upgrade) merged into this branch but not yet on main — not introduced by this PR's changes. No regressions detected.

@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Jun 3, 2026
if (cliConfig.isMcpServerDisabled(name)) {
debugLogger.debug(`Skipping disabled MCP server: ${name}`);
// A project server (`.mcp.json`) that is pending approval — or that
// became pending mid-session because its config changed (#4615) — is

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.

[Suggestion] Comment says servers can "become pending mid-session because its config changed (#4615)" but Config.isMcpServerPendingApproval checks this.pendingMcpServers, a startup-time string[] snapshot. The only mutator approveMcpServerForSession removes entries — nothing adds names back. No mechanism re-reads .mcp.json hashes mid-session. A server approved at startup that later has its config edited would NOT be caught by this check.

Fix: Correct the comment to say "A project server that was pending at startup" or implement mid-session re-evaluation (e.g., re-hash against the approval store on health-check ticks).

— qwen3.7-max via Qwen Code /review

return `${config.command} ${config.args?.join(' ') ?? ''} (stdio)`.replace(
/\s+\(/,
' (',
);

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.

[Suggestion] summarize() only shows command/args/url in the approval dialog, hiding env and headers. A malicious .mcp.json entry with {"env": {"LD_PRELOAD": "/evil.so"}} would display as node server.js (stdio), hiding the payload from the user making the trust decision.

Consider showing env var key names (or at least a count) in the summary:

if (config.env && Object.keys(config.env).length > 0) {
  parts.push(`[+${Object.keys(config.env).length} env vars]`);
}

— qwen3.7-max via Qwen Code /review

Array.isArray(parsed)
) {
errors.push({
message: 'MCP approvals file is not a valid JSON object.',

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.

[Suggestion] Two error-handling branches in loadMcpApprovals() lack test coverage: (1) when the JSON value is a non-object (e.g., '[1,2,3]'), hitting the "not a valid JSON object" path here; (2) when JSON is malformed, hitting the catch block at line 145. The existing corruption test only covers per-project record corruption, not these top-level parse failures. These are the first branches a corrupted approvals file would hit — adding tests ensures graceful degradation works as intended.

— qwen3.7-max via Qwen Code /review

* `discoverToolsForServer` connects it instead of skipping it. See issue
* #4615. No-op for servers that were never pending.
*/
approveMcpServerForSession(serverName: string): void {

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.

[Suggestion] approveMcpServerForSession() has no direct unit test. The hook test (useMcpApproval.test.ts) uses a mock (vi.fn()), not the real filter logic. No test verifies that after calling approveMcpServerForSession('a'), isMcpServerPendingApproval('a') returns false while isMcpServerPendingApproval('b') remains true, and that calling it for a non-pending server is a no-op.

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 8, 2026
@wenshao

wenshao commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Local Verification Report

PR: #4713 feat/mcp-scope-precedence-workspace-approvalmain
Title: feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence
Scope: 34 files, +2224/−50 (core: config, mcp, tools; CLI: commands, config, UI)

Test Results

Check Result Details
Core tests (full) ✅ 10128/10133 passed 1 pre-existing failure, 4 skipped
Core PR-specific tests ✅ 279/279 passed configHash, mcp-client-manager, config
CLI PR-specific tests ✅ 402/404 passed 9/10 test files passed, 2 skipped
Core typecheck ✅ 0 errors Clean
CLI typecheck ✅ 0 new errors 113 total — all pre-existing (main drift)
ESLint ✅ Clean --max-warnings 0 on all 21 changed source files
Whitespace ✅ Clean git diff --check passed

CLI Test Failure Analysis

  • gemini.test.tsxcollection failure (not a test logic failure). BaseTextInput.tsx imports ink/dom which is missing from the ink package exports map. This file is not modified by the PR; it's a pre-existing main issue affecting any test that transitively imports the UI component tree. All 9 other CLI test files (402 tests) passed cleanly.

Pre-existing Core Failure

  • anthropicContentGenerator.test.ts:184 — User-Agent header mismatch (QwenCode vs claude-cli). Confirmed pre-existing on main.

Typecheck Delta

CLI 113 errors vs local main's 108 — the +5 difference is entirely line-number shifts in Session.ts, BaseTextInput.tsx, and useGeminiStream.ts (none touched by this PR). Zero typecheck errors in any PR-changed file.

Verdict

✅ PASS — All PR-specific tests pass (279 core + 402 CLI). No new test failures, no new typecheck errors, no lint issues.


Verified locally by wenshao

Comment thread packages/cli/src/config/mcpApprovals.ts Outdated
const project: Record<string, McpApprovalRecord> =
existing && typeof existing === 'object' && !Array.isArray(existing)
? existing
: {};

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.

[Suggestion] setState uses {} (inheriting from Object.prototype) as the fallback for the per-project approval record. When serverName is '__proto__', the assignment project[serverName] = { hash, status } triggers the Object.prototype.__proto__ setter — it calls Object.setPrototypeOf(project, {hash, status}) instead of creating an own property. Since JSON.stringify only serializes own enumerable properties, the __proto__ server's approval decision is silently dropped from the persisted file, reverting to pending on every restart.

The same class of bug was already fixed in mcpJson.ts:81 using Object.create(null), but the fix was not applied here. Consider also guarding the existing branch (which comes from JSON.parse and also inherits Object.prototype):

Suggested change
: {};
: Object.create(null);

And for the existing branch, use Object.defineProperty when serverName is __proto__ or constructor to prevent prototype pollution.

— qwen3.7-max via Qwen Code /review

@tanzhenxin

Copy link
Copy Markdown
Collaborator

Hi @qqqys — heads up that this branch is currently showing merge conflicts with main (GitHub marks it as not mergeable, and CI won't run cleanly while it's in that state). Could you rebase on the latest main and resolve the conflicts when you get a chance?

Once it's conflict-free and CI is green I'll do a full review pass. Thanks!

@qqqys
qqqys force-pushed the feat/mcp-scope-precedence-workspace-approval branch from f04455e to 8ff1360 Compare June 8, 2026 08:21
wenshao
wenshao previously approved these changes Jun 8, 2026

@wenshao wenshao left a comment

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.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent Verification Report — PR #4713

Built and ran real tests locally for this MCP approval-gating + precedence feature. All 12 affected suites pass, typecheck + eslint are clean, and a full end-to-end approval lifecycle works in the actual built CLI. Verdict: LGTM — recommend merge (one design trade-off worth a conscious ack, below).

Environment

OS 🐧 Linux (6.12, Debian 13), Node v22.22.2, npm 10.9.7
Checkout fresh git worktree at PR head 83c388cda
Install clean npm ciexit 0
Driver all steps run inside a tmux session

1 — Affected unit suites ✅ 691 passed / 2 skipped (12 files)

$ npx vitest run  (12 changed suites across cli + core)
 Test Files  12 passed (12)
      Tests  691 passed | 2 skipped (693)

The 2 skips are pre-existing loadCliConfig model selection cases (model defaults), unrelated to this PR. Every feature dimension is covered and green, e.g.:

  • Precedence (mcpServers.test.ts / assembleMcpServers): .mcp.json overrides a user server; workspace overrides .mcp.json; system (enterprise) stays above .mcp.json; --mcp-config overrides everything.
  • Hash binding (mcpApprovals.test.ts, "the issue Add project-scoped .mcp.json support with pending approval semantics #4615 requirement"): reverts to pending when the config changes after approval; a rejected server also reverts when edited; provenance-only (scope) changes stay approved (hash ignores scope tagging).
  • Gated-scope filter: gates project + workspace, ignores user/system; keeps a rejected server in the skip set.
  • mcp list: shows a pending/rejected server without connecting.
  • mcp approve/reject: named + --all, project + workspace, hash-bound.
  • Hardening: __proto__ server names stay visible to approval checks; malformed/array/corrupt JSON recovered without throwing.

2 — Typecheck + lint ✅ clean

@qwen-code/qwen-code-core   tsc --noEmit  → exit 0
@qwen-code/qwen-code (cli)  tsc --noEmit  → exit 0
eslint <all 31 changed .ts/.tsx files>    → exit 0

3 — Real end-to-end gating lifecycle in the built CLI ✅ 11/11

Isolated via QWEN_HOME, a scratch project with a checked-in .mcp.json (→ project scope → gated), driven through node packages/cli/dist/index.js mcp …:

① Pending & not connected (no side effects):

● demo-server: true --v1 (stdio) - Pending approval

mcpApprovals.json is not written by a pure list (verified absent).

② Approve → persisted with the documented shape:

{
  "/tmp/pr4713-proj": {
    "demo-server": { "hash": "a8a13c17…34f2", "status": "approved" }
  }
}

keyed by (projectRoot, serverName), bound to a SHA-256 config hash. No longer pending in list.

③ Hash binding (the core #4615 property) — edit .mcp.json (--v1--v2-EDITED):

● demo-server: true --v2-EDITED (stdio) - Pending approval

→ config edit changes the hash → reverts to pending, end-to-end through the real CLI. ✅

④ Rejectstatus: "rejected" persisted; list shows - Rejected, not connected.

This exercises the whole stack (.mcp.json load → scope tag → gate → persisted approval store → hash re-gating → reject) the way a user actually hits it — not just the mocked unit layer.


⚠️ Design trade-off worth a conscious ack (not a defect)

The gate is interactive-only by design (config.ts):

const pendingMcpServers = bareMode || !interactive
  ? undefined                                   // non-interactive (SDK / -p / piped) & bare: auto-approve, lenient
  : getPendingGatedMcpServers(mcpServers, cwd); // interactive: held pending until approved

So a checked-in, never-approved .mcp.json / workspace server is auto-connected in non-interactive sessions (SDK, -p, piped) — the approval gate only protects interactive runs. This is explicitly the PR's decision ("Non-interactive sessions auto-approve (lenient), matching Claude Code"), with the rationale that there's no user to prompt. Flagging it only so reviewers consciously accept the parity choice: untrusted-source gating provides no protection in headless/SDK contexts.

Disclosed limitations (confirmed, as the PR states — non-blocking)

  • qwen mcp reconnect <name> builds a minimal config without the pending set, so an explicit reconnect can bypass the gate (explicit user action).
  • Approval dialog Esc persists a rejected decision (escape-to-deny parity); undo via qwen mcp approve.

Recommendation: ✅ verified — tests/typecheck/lint clean and the approval gate + precedence work end-to-end in the real CLI. Recommend merge, with reviewers consciously accepting the interactive-only (lenient non-interactive) gating model.

中文小结

本地真实环境(🐧 Linux,Node v22.22.2,tmux 驱动,全新 worktree + npm ci)验证了 PR #4713(MCP 审批门 + 跨源优先级):

  1. 12 个受影响测试套件全过:691 passed / 2 skipped(那 2 个是既有的 model-selection 跳过用例,与本 PR 无关)。覆盖优先级(.mcp.json > user、workspace > .mcp.json、system 永远在最上、--mcp-config 覆盖一切)、hash 绑定(改配置→回到 pending;仅 scope 变化不影响 hash)、gated-scope 过滤、mcp list/approve/reject、以及 __proto__/坏 JSON 等加固。
  2. typecheck(core+cli)+ eslint(全部 31 个改动文件)全清,exit 0。
  3. 真实 CLI 端到端 11/11:在隔离的 QWEN_HOME 下用打包好的 CLI 跑通完整生命周期——pending 且不连接(无副作用)→ approve(mcpApprovals.json 结构正确:projectRoot → serverName → {sha256 hash, status})→ 改 .mcp.json 后 hash 变化自动回到 pending→ reject 显示 Rejected。

⚠️ 需评审方有意识接受的设计取舍(非缺陷):审批门只在交互式会话生效。非交互(SDK/-p/管道)和 bare 模式下,gated 的 .mcp.json/workspace server 会被自动放行(lenient,对齐 Claude Code)。即:headless/SDK 场景下该门不提供保护。这是 PR 明确的决策,仅提示评审方知悉。

PR 自述的两条已知限制(reconnect 可绕过、Esc 记为 rejected)经确认属实,非阻塞。

结论:✅ 验证通过,建议合并(评审方知悉 interactive-only 门控模型即可)。

wenshao
wenshao previously approved these changes Jun 8, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs.

1 similar comment
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully: Qwen review timed out after 55 minutes. See workflow logs.

@tanzhenxin tanzhenxin left a comment

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.

Review

This is a review of the rebased branch. The core of the feature is well built — on every interactive discovery path the approval check lands before any process spawn, the hash binding can't be replayed across servers or edits, and a checked-in config can't spoof its way into a trusted scope. All four issues below are about the perimeter: entry points that skip the gate entirely.

1. qwen mcp reconnect spawns pending and rejected servers as a side effect (severity: high · confidence: very high)

The reconnect command builds its own minimal config without the approval gate and then starts discovery for every configured server. Running it with any server name — even an unrelated, already-approved one — background-spawns every pending and rejected workspace-gated server along the way. The known-limitations note in the description frames this as an explicit user action on one server, but the servers actually spawned are not the one the user named, and a rejected server starting up is the opposite of what the recorded decision promises. It also frames this as a .mcp.json concern, but it's actually the workspace .qwen/settings.json servers that are exposed here (.mcp.json servers aren't visible to reconnect at all).

2. ACP/IDE and headless sessions auto-run untrusted .mcp.json servers (severity: high · confidence: high)

The gate only exists for interactive terminal sessions. Under ACP (Zed/IDE integrations) and in headless runs the pending set is never computed, so every gated server auto-approves — and this PR newly introduces .mcp.json loading into exactly those paths. Net effect: opening a cloned repo in an IDE with qwen as the agent spawns the repo's checked-in MCP servers at session start, with no prompt and no record — precisely the threat this feature exists to stop. The "session servers are trusted" rationale covers servers the IDE injects, not checked-in config loaded into the IDE session.

3. Explicit rejections are not honored outside interactive mode (severity: high · confidence: very high)

A rejection recorded via qwen mcp reject (or Esc in the dialog, which persists one) is only enforced in interactive sessions. A subsequent -p, piped, SDK, or ACP run never consults the approvals store and connects the rejected server anyway. Lenient auto-approval for servers nobody has decided on yet is the documented trade-off; silently overriding a decision the user explicitly recorded is a different and stronger failure.

4. Approving from the startup dialog bypasses folder trust (severity: medium · confidence: high)

Approving a server in the dialog immediately starts it through a single-server discovery path that doesn't check folder trust, while bulk discovery does. In an untrusted folder the dialog can still appear (project .mcp.json is loaded regardless of trust), so one approval there spawns a server the folder-trust gate would otherwise have held back. Approval and folder trust are meant to be independent gates; this path lets one override the other.

Verdict

COMMENT — the interactive gate and approval store are solid, but the unguarded entry points (reconnect, ACP/headless, dialog-in-untrusted-folder) undercut the guarantee the feature is making, and the headless path overrides explicit rejections. These are worth addressing before merge.

@qqqys
qqqys force-pushed the feat/mcp-scope-precedence-workspace-approval branch from 11ae426 to e8f4ac0 Compare June 12, 2026 02:07
@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Local runtime verification (maintainer-run)

Verdict: PASS — built this PR locally and drove the real CLI/TUI in tmux against a live stdio MCP server. Every behavior claimed in the description held up at the user-facing surface. Findings below are non-blocking.

Method. npm ci && npm run bundle on the PR head (e8f4ac0, 12 commits, merge-base = main−2), then ran node dist/cli.js with an isolated $HOME, scratch projects, a mock OpenAI endpoint (so -p completes end-to-end), and a real @modelcontextprotocol/sdk stdio server that appends to a marker file on every process spawn — so every "never connects / never spawns" claim below is proven by marker-file absence at the OS level, not by reading code.

Exercised at the running app (~30 steps)

Approval gate & side-effect freedom

  • mcp list with a pending .mcp.json server → ● repo-tools … - Pending approval, exit 0, no spawn marker, no approvals file created (pure read confirmed)
  • mcp reject <name> → persisted hash-bound in ~/.qwen/mcpApprovals.json, file mode 0600, keyed by resolved project root
  • Non-interactive fail-closed: -p run with a pending/rejected gated server completes normally, no spawn, debug log shows [MCP] Skipping MCP server pending approval: repo-tools
  • mcp approve <name>mcp list now live-connects (✓ Connected, spawn marker appears); headless -p also connects approved servers
  • 🔍 Edit .mcp.json args after approval → reverts to Pending approval, no spawn — hash binding holds in both CLI and TUI flows
  • mcp approve --all / reject --all operate on all gated servers; untouched user-scope server stays connected

Interactive TUI (tmux)

  • ✅ Startup dialog: "Untrusted MCP server in .mcp.json" + config summary + Approve / Approve all / Reject (esc)
  • ✅ Approve → server spawns mid-session, /mcp shows ✓ connected, decision persisted with the reviewed config's hash
  • ✅ Restart with persisted approval → no dialog, auto-connects at startup
  • ✅ Edit config → dialog reappears with new summary; Esc persists rejected (matches the documented known-limitation), no spawn
  • ✅ Two pending servers (project + workspace) → queued dialog, "Approve all will trust these servers:" lists both; approve-all spawns + persists both
  • Full E2E: model issues mcp__repo-tools__ping → per-call MCP tool confirmation (pre-existing layer still applies) → real server executes → pong-v3 returned and rendered in the final answer

Precedence (each proven by which marker file got spawned)

  • ✅ project .mcp.json > user settings: same-name user server shadowed; user variant never spawned
  • ✅ workspace .qwen/settings.json > project .mcp.json: workspace config wins and stays gated — the earlier project-config approval does not transfer (hash differs → Pending). Dialog source label switches to .qwen/settings.json
  • ✅ system > project and ungated: same-name server in system settings (via QWEN_CODE_SYSTEM_SETTINGS_PATH) wins and connects with no approval, even with a stale rejected record present
  • --mcp-config top tier: connects with no approval (by design, explicit flag), while a pending gated server in the same run is still skipped
  • ✅ Back-compat: a plain user-settings server connects with zero gating interaction; approvals file untouched

Robustness probes

  • 🔍 malformed .mcp.json → one clean warning, exit 0, no crash
  • 🔍 non-object server entry → skipped with warning, sibling server unaffected
  • 🔍 identical .mcp.json in a different directory → independent decisions (per-project keying)
  • 🔍 servers named __proto__ / constructor → gated, approvable, persisted and re-read correctly (the prototype-hardening commit holds at runtime)
  • 🔍 mcp approve with no args → clean usage hint; unknown name → not-found + available list
Selected raw captures

Startup dialog (tmux pane):

│ Untrusted MCP server in .mcp.json
│ This workspace declares an MCP server. Approving lets Qwen Code start it and run its tools.
│ Approval is bound to this exact configuration — if .mcp.json changes, you will be asked again.
│
│ repo-tools  node /tmp/qwen-pr4713/test-mcp-server.mjs /tmp/qwen4713/markers/repo-tools.spawned
│ v2 (stdio)
│
│ › 1. Approve this server
│   2. Approve all pending servers in this workspace
│   3. Reject (esc)

Fail-closed headless run (rejected server):

$ qwen -p "reply with one word" --debug
MOCK_OK                      # model run completed
$ ls markers/                # no repo-tools spawn marker
$ grep "pending" ~/.qwen/debug/<session>.txt
[DEBUG] [MCP] Skipping MCP server pending approval: repo-tools

Hash-bound store (~/.qwen/mcpApprovals.json, mode 0600):

{
  "/private/tmp/qwen4713/proj": {
    "repo-tools": { "hash": "2d1e…70a6", "status": "rejected" },
    "ws-tools":   { "hash": "746d…a91e", "status": "approved" }
  }
}

E2E tool call through an approved .mcp.json server (tmux pane):

│ ✓  ping (repo-tools MCP Server) {}
│
│    pong-v3
╰────────────────────────────────────────
✦ TOOL_RESULT: [{"type":"text","text":"pong-v3"}]

Spawn-marker timeline (every line = one real process spawn):

spawned variant=v1 pid=72900   # after CLI approve → mcp list live test
spawned variant=v1 pid=72944   # headless -p with approved server
spawned variant=v2 pid=78438   # TUI dialog approve (mid-session connect)
spawned variant=v2 pid=80020   # restart with persisted approval (no dialog)
spawned variant=v3 pid=83625   # approve-all (project server)
spawned variant=v-ws pid=83631 # approve-all (workspace server)

Findings (non-blocking)

  1. Stale docstring — lenient vs fail-closed. useMcpApproval.ts (~L70) still says non-interactive sessions get an empty pending set ("decision Where is the config saved? #2, lenient"); since e8f4ac0 the behavior (and the PR description) is fail-closed. Worth fixing the comment so the next reader doesn't trust it.
  2. /mcp panel mislabels project servers. An approved .mcp.json server shows under "User MCPs" with Source: User Settings (workspace ones show as "Project MCPs"). For a trust-centric feature, labeling a checked-in repo server as user-configured is confusing — the panel's source labels predate the new project scope. Cosmetic, fine as a follow-up.
  3. qwen mcp reconnect interplay. (a) reconnect <name> errors with "Arguments server-name and all are mutually exclusive"pre-existing on main (yargs conflicts + default: false), not introduced here. (b) .mcp.json servers are invisible to reconnect --all ("No MCP servers configured") because it doesn't use assembleMcpServers. Fail-closed direction, so no security hole — but an approved project server can't be reconnected via this command, inconsistent with mcp list. Follow-up candidate.
  4. Cosmetic: mcp approve <unknown-name> still prints the trailing "Approved servers connect in your next interactive session." hint even though nothing was approved.
  5. Behavior note (coherent, just documenting): approval records key on (projectRoot, serverName) — when a workspace override wins over a same-name .mcp.json entry, the two configs share one decision slot, so deciding on the override replaces the earlier project-config decision. One decision per effective server is a sound model; mentioning it for future support questions.

Not exercised at runtime: ACP/IDE sessionMcpServers tier (code-read only — routed above settings and never gated per loadCliConfig), HTTP/SSE transports (stdio only), extension-provided servers.

wenshao
wenshao previously approved these changes Jun 12, 2026
const settings = loadSettings();
const cwd = process.cwd();
const fileService = new FileDiscoveryService(cwd);
const mcpServers = await getMcpServersFromConfig();

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.

Bug: getMcpServersFromConfig() does not call assembleMcpServers(), so project .mcp.json servers are invisible to qwen mcp reconnect.

Consequences:

  • qwen mcp reconnect <project-server> fails with "Server not found" for .mcp.json servers
  • qwen mcp reconnect --all silently skips all project servers
  • pendingMcpServers computed below is incomplete (missing project-scope entries)

The list command was correctly updated to use assembleMcpServers (see list.ts), but reconnect was not. Consider either:

  1. Using assembleMcpServers(settings.merged.mcpServers, cwd) here (matching list.ts), or
  2. Updating getMcpServersFromConfig itself to call assembleMcpServers so both call sites benefit.

wenshao
wenshao previously approved these changes Jun 12, 2026
wenshao
wenshao previously approved these changes Jun 12, 2026
@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

Hi @qqqys — thanks for the detailed PR! The implementation looks thorough, but the PR body doesn't follow our pull request template.

The template requires these specific headings (in order):

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan (with ### How to verify, ### Evidence (Before & After), ### Tested on)
  • ## Risk & Scope
  • ## Linked Issues
  • <details> Chinese translation

Your current body uses custom headings (## Summary, ## Precedence, ## Approval / trust model, ## Testing, ## Known limitations / follow-ups) which don't map to the template. This makes it harder for reviewers to quickly find the information they need (especially the reviewer test plan and evidence).

Could you restructure the body to follow the template? The good news is the content is all there — it just needs reorganizing under the right headings. The ## Summary content maps naturally to ## What this PR does + ## Why it's needed, and the ## Testing section has great detail that fits nicely into ### How to verify and ### Evidence.

Thanks! 🙏

中文说明

@qqqys 你好——感谢这个详尽的 PR!不过 PR 正文没有按照模板的格式。

模板要求以下标题(按顺序):

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan(含 ### How to verify### Evidence (Before & After)### Tested on
  • ## Risk & Scope
  • ## Linked Issues
  • <details> 中文翻译

当前正文使用了自定义标题(## Summary## Precedence 等),需要按模板重新组织。内容本身很好,只是需要放到正确的标题下。## Summary 对应 ## What this PR does + ## Why it's needed## Testing 的详细测试信息适合放到 ### How to verify### Evidence 下。

谢谢!

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @qqqys!

Template looks good ✓

On direction: this is squarely aligned with qwen-code's security mission. Checked-in .mcp.json and workspace settings can spawn arbitrary local commands — gating them behind a per-server, hash-bound approval decision is the right trust boundary. Claude Code's CHANGELOG confirms direct precedent: project MCP scope, per-server approval dialog, pending-approval listing, and security fixes around approval dialog bypass. This is table-stakes for a CLI that loads project-level MCP config.

On approach: the scope feels right for what it delivers. The hash binding is the core safety property — without it, an approval would silently inherit edits. The precedence model (user < project < workspace/system < session < CLI) is clean and matches Claude Code's layering. The split into mcpJson.ts (loader), mcpServers.ts (assembler), mcpApprovals.ts (store), and configHash.ts (canonical hash) keeps each piece independently testable. I don't see a materially simpler path that still preserves the hash-binding invariant.

One small observation: the isTrustedFolderisTrustedFolder?.() change in discoverAllMcpTools is a behavioral softening (missing method now allows discovery instead of blocking). Intentional defensive coding for test fixtures, but worth a quick confirm it doesn't mask a real-world misconfiguration.

Moving on to code review and testing. 🔍

中文说明

感谢贡献,@qqqys

模板完整 ✓

方向:完全对齐 qwen-code 的安全使命。提交的 .mcp.json 和 workspace settings 可以启动任意本地命令——用按 server、按 hash 绑定的审批门控是正确的信任边界。Claude Code CHANGELOG 有直接先例:project MCP scope、per-server 审批对话框、pending-approval 列表展示,以及审批对话框绕过的安全修复。

方案:范围与交付匹配。Hash 绑定是核心安全属性——没有它,审批会静默继承编辑。优先级模型(user < project < workspace/system < session < CLI)清晰且匹配 Claude Code 的分层。没有看到更简路径能在保留 hash 绑定不变量的同时达到同等效果。

一个小观察:isTrustedFolderisTrustedFolder?.() 的修改在 discoverAllMcpTools 中软化了行为(方法缺失时允许发现而非阻断)。看起来是为测试 fixture 的防御性编码,值得快速确认不会掩盖真实配置问题。

进入代码审查和测试 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is well-structured and security-conscious. A few observations from reading the diff:

Strengths:

  • Hash-bound approvals are the right call — binding decisions to SHA-256 of the behavioral config means tampered servers revert to pending automatically. Clean design.
  • Gating happens in the discovery layer before any stdio spawn, which is the only safe place for it.
  • Object.create(null) + Object.defineProperty in the approvals store is a nice touch against prototype pollution.
  • QWEN_CODE_MCP_APPROVALS_PATH is in PROJECT_ENV_HARDCODED_EXCLUSIONS — prevents .env from hijacking the approval store path.

No blockers found. The code follows project conventions, is appropriately scoped, and the security model holds up under scrutiny.

Real-Scenario Testing

All tests run from /tmp/triage-test-4713 with QWEN_HOME=/tmp/triage-qwen-home-2. The full session output:

# 1. Baseline: no .mcp.json present
runner@runnervm1li68:/tmp/triage-test-4713$ export QWEN_HOME=/tmp/triage-qwen-home-1 && mkdir -p $QWEN_HOME && cd /home/runner/work/qwen-code/qwen-code && npm run dev -- mcp list 2>&1 | tee /tmp/triage-list-before.txt && cd /tmp/triage-test-4713

> @qwen-code/qwen-code@0.17.1 dev
> node scripts/dev.js mcp list

No MCP servers configured.

# 2. With .mcp.json — server shows as Pending approval
runner@runnervm1li68:/tmp/triage-test-4713$ export QWEN_HOME=/tmp/triage-qwen-home-2 && mkdir -p $QWEN_HOME && node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp list 2>&1 | tee /tmp/triage-list-pending.txt

Configured MCP servers:
● test-server: echo hello (stdio) - Pending approval

# 3. Approve the server
runner@runnervm1li68:/tmp/triage-test-4713$ node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp approve test-server 2>&1 | tee /tmp/triage-approve.txt

Approved MCP server "test-server" (bound to its current config).
Approved servers connect in your next interactive session.

# 4. Verify: server no longer pending
runner@runnervm1li68:/tmp/triage-test-4713$ node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp list 2>&1 | tee /tmp/triage-list-after-approve.txt

Configured MCP servers:
✗ test-server: echo hello (stdio) - Disconnected

# 5. Hash binding: tamper .mcp.json (change "echo" → "printf")
runner@runnervm1li68:/tmp/triage-test-4713$ echo '{"mcpServers":{"test-server":{"command":"printf","args":["hello"]}}}' > /tmp/triage-test-4713/.mcp.json
runner@runnervm1li68:/tmp/triage-test-4713$ node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp list 2>&1 | tee /tmp/triage-list-after-tamper.txt

Configured MCP servers:
● test-server: printf hello (stdio) - Pending approval    ← hash mismatch detected, reverted to pending ✓

# 6. Reject the server
runner@runnervm1li68:/tmp/triage-test-4713$ node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp reject test-server 2>&1 | tee /tmp/triage-reject.txt

Rejected MCP server "test-server" (bound to its current config).

# 7. Verify: server shows as Rejected
runner@runnervm1li68:/tmp/triage-test-4713$ node /home/runner/work/qwen-code/qwen-code/scripts/dev.js mcp list 2>&1 | tee /tmp/triage-list-after-reject.txt

Configured MCP servers:
● test-server: printf hello (stdio) - Rejected

Persistence checkmcpApprovals.json contents:

{
  "/tmp/triage-test-4713": {
    "test-server": {
      "hash": "8a032dc0212c2e073ead1c2e101c21d332fef5773fd3ee8824c24acb4d7b3dfa",
      "status": "rejected"
    }
  }
}

All five scenarios verified:

  1. No .mcp.json → clean "no servers" message ✓
  2. Project .mcp.json → gated as "Pending approval" ✓
  3. Approve → status changes, hash persisted ✓
  4. Config tamper → hash mismatch detected, reverts to pending ✓
  5. Reject → status persisted ✓

Unit tests: 740+ passing across both packages (core + cli).

中文说明

代码审查

实现结构清晰,安全意识到位。几个观察:

优点:

  • 哈希绑定审批是正确的设计——将决策绑定到行为配置的 SHA-256 哈希,意味着被篡改的服务器会自动恢复为待审批状态。
  • 门控在发现层进行,在任何 stdio 进程启动之前,这是唯一安全的位置。
  • 审批存储中使用 Object.create(null) + Object.defineProperty 防御原型污染。
  • QWEN_CODE_MCP_APPROVALS_PATH 被加入 PROJECT_ENV_HARDCODED_EXCLUSIONS,防止 .env 劫持审批存储路径。

未发现阻塞性问题。 代码遵循项目规范,范围适当,安全模型经得起审查。

真实场景测试

全部五个场景验证通过:

  1. .mcp.json → 正常提示"无服务器" ✓
  2. 项目 .mcp.json → 门控为"待审批" ✓
  3. 审批 → 状态变更,哈希持久化 ✓
  4. 配置篡改 → 哈希不匹配检测,恢复为待审批 ✓
  5. 拒绝 → 状态持久化 ✓

单元测试:core + cli 两个包 740+ 测试全部通过。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This PR does exactly what it sets out to do, and does it cleanly.

The security model is sound: hash-bound approvals mean a tampered .mcp.json can't silently inherit a prior approval. Gating at the discovery layer before any stdio spawn is the only correct place for it. The persistence format is simple and well-structured. Prototype pollution protections and env-var exclusion are the kind of details that show this was thought through carefully.

My independent proposal before reading the diff would have been: add a SHA-256 hash of the server config, store approve/reject decisions keyed by (projectRoot, serverName, hash), and gate in the MCP discovery pipeline. The PR's approach matches this and exceeds it with the scope/provenance model (user < project < workspace < session < CLI) and the interactive startup dialog for batch approval.

Real-scenario testing confirmed all five critical paths: pending gating, approval with hash persistence, config-tamper detection (hash mismatch → revert to pending), rejection persistence, and clean baseline when no .mcp.json exists. 740+ unit tests pass.

Approving. ✅

中文说明

这个 PR 完成了它设定的目标,而且实现得很干净。

安全模型合理:哈希绑定审批意味着被篡改的 .mcp.json 无法静默继承先前的审批。在发现层(任何 stdio 进程启动之前)进行门控是唯一正确的位置。持久化格式简洁且结构良好。原型污染防护和环境变量排除这些细节说明设计经过了深思熟虑。

我在阅读 diff 之前的独立方案是:添加服务器配置的 SHA-256 哈希,以 (projectRoot, serverName, hash) 为键存储审批/拒绝决策,并在 MCP 发现管道中进行门控。PR 的方案与此匹配,并通过范围/来源模型(user < project < workspace < session < CLI)和交互式启动批量审批对话框超越了基本方案。

真实场景测试确认了全部五个关键路径:待审批门控、带哈希持久化的审批、配置篡改检测(哈希不匹配→恢复为待审批)、拒绝持久化,以及无 .mcp.json 时的正常基线。740+ 单元测试通过。

批准合并。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

LGTM, looks ready to ship. ✅

@wenshao
wenshao merged commit 44627a2 into QwenLM:main Jun 13, 2026
23 checks passed
doudouOUC pushed a commit that referenced this pull request Jun 15, 2026
… scope precedence (#4615) (#4713)

* feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615)

Adds untrusted-source approval gating for MCP servers and a coherent
cross-source precedence model.

Sources & precedence (low -> high):
  user/default settings < project .mcp.json < workspace/system settings < session(ACP/IDE) < --mcp-config

- Load project servers from .mcp.json (pure read, never connects), tagged
  scope:'project'.
- Tag workspace/system settings servers with provenance scope at merge time so
  the winning entry keeps its source; centralize assembly in assembleMcpServers.
- Gate checked-in/shareable sources (project + workspace) behind a hash-bound
  approval store; .mcp.json edits revert approval to pending. system/user/CLI/
  extension/session sources are never gated.
- .mcp.json now overrides USER settings (Claude parity) but never enterprise
  'system' settings.
- Route ACP/IDE-injected servers through a top-tier sessionMcpServers param so a
  repo .mcp.json can't override or gate them.
- Startup approval dialog + 'qwen mcp approve|reject' + 'qwen mcp list' cover
  both gated sources; non-interactive sessions auto-approve (lenient).

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(cli): cover mcp scope stamping

* fix(mcp): harden approval binding

* fix(mcp): harden approval store persistence

* fix(mcp): address approval review feedback

* refactor(cli): rename gated MCP approval helper

* fix(mcp): surface approval metadata in prompts

* docs(mcp): clarify pending approval snapshot

* fix(mcp): persist prototype-named approval records

* test(mcp): cover pending approval guard paths

* chore(mcp): refresh approval gating checks

* fix(mcp): enforce approval gate outside interactive

* fix(cli): label MCP server sources accurately

* fix(cli): include project MCP servers in reconnect

* docs(cli): correct MCP approval noninteractive note

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants