Skip to content

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

Open
qqqys wants to merge 8 commits into
QwenLM:mainfrom
qqqys:feat/mcp-scope-precedence-workspace-approval
Open

feat(mcp): project .mcp.json + workspace approval gating with aligned scope precedence (#4615)#4713
qqqys wants to merge 8 commits into
QwenLM:mainfrom
qqqys:feat/mcp-scope-precedence-workspace-approval

Conversation

@qqqys
Copy link
Copy Markdown
Collaborator

@qqqys qqqys commented Jun 2, 2026

Summary

Adds approval gating for untrusted, checked-in MCP server sources and a coherent cross-source precedence model, aligning behavior with Claude Code's .mcp.json handling. Refs #4615.

Two source layers are now treated as untrusted-until-approved:

  • project .mcp.json (repo root)
  • workspace .qwen/settings.json mcpServers

Both are checked into / shared via the repo, so a server they declare is held behind a per-server, hash-bound approval gate before the discovery layer will connect it.

Precedence

Effective MCP server map is assembled in one place (assembleMcpServers), lowest → highest (later wins on a name clash):

user/default settings  <  project .mcp.json  <  workspace/system settings  <  session (ACP/IDE)  <  --mcp-config

Key points:

  • .mcp.json now overrides user-level settings (Claude parity: project > user) but never overrides enterprise-enforced system settings.
  • Session-injected servers (ACP / IDE clients) sit at the top tier and are never gated — they are explicit and per-session.

Approval / trust model

  • Decisions persist in <QWEN_HOME>/mcpApprovals.json, keyed by (projectRoot, serverName) and bound to a canonical config hash. Editing the server in its source file changes the hash and reverts it to pending.
  • Only project + workspace scopes are gated (isGatedMcpScope). system / user / extension / CLI / session sources are trusted and connect as before.
  • The discovery layer skips pending servers before any process spawn / transport / health check, so inspecting an untrusted config has no side effects.
  • Interactive sessions get a startup approval dialog (per server: approve / approve-all / reject). Non-interactive sessions (SDK, -p, piped) auto-approve (lenient), matching Claude Code.
  • New CLI: qwen mcp approve [name|--all] / qwen mcp reject [name|--all]; qwen mcp list shows Pending approval / Rejected for gated servers without connecting them.

How provenance is tracked

mergeSettings stamps each settings scope's servers with their origin (workspace / system) before the shallow merge, so the winning entry keeps the scope it actually came from. .mcp.json servers are tagged project at load time. assembleMcpServers then partitions by scope to realize the precedence above.

Testing

  • New/updated unit suites: mcpServers (precedence), mcpApprovals (hash binding + gated-scope filter), mcpJson, approve (project + workspace), useMcpApproval, plus loadCliConfig session-tier coverage and settings merge tagging.
  • Full workspace typecheck + eslint clean; core MCP suites (mcp-client-manager, configHash) green.

Known limitations / follow-ups

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

… scope precedence (QwenLM#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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

📋 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

Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

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
Copy link
Copy Markdown
Collaborator

@wenshao wenshao left a comment

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
Copy link
Copy Markdown
Collaborator

wenshao commented Jun 3, 2026

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
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.

3 participants