Repository navigation
fix(core): warn when tools.eager entries match no discovered tool - #12451
Conversation
`settings.tools.eager` only shape-checks its entries: an entry that parses cleanly is kept even when it names no tool. Because the allowlist demotes every non-exempt tool it does not name, one misspelt dynamic entry (`mcp__githb__create_issue`) silently shrinks the whole eager tool surface and nothing at startup says so. Add a registry-aware existence check that runs when an MCP discovery pass reaches COMPLETED (bulk, pooled and incremental), so it can never fire before dynamic tools are registered. Matching mirrors `getToolRegistrationStatus`: `toolMatchesRuleToolName` over canonical registered names, so meta-categories (`Read`, `Bash`) and aliases (`ReadFile`) are never reported, and built-ins that are still unwarmed lazy factories still count as registered. Entries whose MCP server is configured but not connected (refused by the client budget, failed handshake, disabled) are skipped: that is an infrastructure outcome, not a spelling mistake. The skip is not recorded in the warn-once ledger, so a later pass still reports the entry once the server is up and the name turns out to be wrong. `computer_use__*` entries are skipped because the generated cua-driver surface is not part of the registry at this boundary, so any report would be a false positive. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-issue-patrol/jmuc7mwe8dh
Picks up #12446 (scripts/tests/corepack-warmup.js) so the Test lane stops failing on the runner's corepack pnpm cache wreckage (#12436). Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmucahs3hdl
|
The Test lane failure was runner infra, not this PR — base merged to pick up the fix
That is #12436. CI routes This PR did not cause it: its diff touches 0 files under So this is a base merge only; no product code was changed and no test was touched. Merge commit: Proof the merge is purely mechanical (no conflict resolution smuggled in):
Focused local verification at the merge commit: Delivered as a single non-force push: |
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review at ea5d4262, base a64d8dec.
Verdict: APPROVE — no review or thread has ever been filed against this PR, so there is no historical blocker to re-check, and my own scan found no Critical. The triage pass reported no blockers either; I did not rely on that and verified the load-bearing claims in code.
The diagnostic cannot break discovery
This was the first thing to establish, because all three call sites sit on completion paths where an exception would be attributed to discovery rather than to a warning. The whole body of warnOnUnmatchedEagerToolEntries is wrapped in try { … } catch {} and documented as never throwing. The three insertions are one import and three one-line calls, each after the registry has settled: the per-session bulk pass after its finally has decremented bulkPassDepth and flushed any refused batch; the pooled pass after setMCPDiscoveryState(COMPLETED) and its mcp-client-update emit; and the incremental pass after the emit that the AppContainer batch-flush subscriber depends on for observing the terminal state. Nothing existing is reordered, and the diff is purely additive at +510/-0.
The matcher is the same oracle as the gate it warns about
A warning that fires on correctly spelled entries would be worse than no warning, so I checked the matching rather than trusting the description. toolMatchesRuleToolName(ruleToolName, contextToolName) is the signature in rule-parser.ts:415, and the new code calls it as toolMatchesRuleToolName(entry, name) with entry the parsed rule name and name a registered tool name — the same order permission-manager.ts:949 uses for (eagerName, canonicalName). keptEagerEntries also mirrors the manager: it skips rule.invalid and pushes rule.toolName, which is what permission-manager.ts:604 and 1333 do. Reversed arguments here would have produced one spurious warning per entry per session, and they are not reversed.
Candidates come from registry.getAllToolNames() rather than from instantiated-tool membership, which is the right choice and the one the module documents: set membership would false-positive on meta-categories and aliases and would miss built-ins that are still unwarmed lazy factories. The test at line 200 pins exactly that case.
The two suppression classes are sound, and asymmetric in the right direction
isOnUnconnectedServer suppresses only when the server name is a configured key and its status is not CONNECTED. It tests that with Object.prototype.hasOwnProperty.call(servers, serverName) rather than in, so a server literally named constructor or toString cannot false-match off the prototype and silently suppress a real typo. An entry whose server is not configured at all stays reportable, which is correct: that is a spelling mistake, not an outage. Empty server names and non-mcp__ entries both return false early.
Suppression is deliberately not written to the warn-once ledger, so a later pass — once the server is up — still reports the entry if the name really is wrong. computer_use__* entries are skipped by the same continue that precedes the ledger write, so they are re-evaluated each pass rather than permanently silenced. Both asymmetries are the safe direction: a suppressed entry can still be reported later, and nothing is recorded as warned unless a warning actually went out.
Warn-once is per session and cannot leak or repeat
The ledger is a WeakMap<Config, Set<string>>, so it is keyed per session, dies with it, and never touches global scope. A /mcp reconnect, a hot-reload reconcile or a second startup pass therefore does not repeat a warning the operator has already seen, and the retained set is bounded by the number of eager entries. Cost is only incurred when it can matter: three early returns (no permission manager or the allowlist is inactive, no registry, no entries) precede any work, and the loop is small in both dimensions.
Two smaller things I checked rather than assumed
console.warn is the established operator-facing channel in this package, not a new convention — there are 26 non-test uses under packages/core/src, including the message display dispatcher and the stream guards. And Config.getEagerTools() returns readonly string[] | undefined, which is what the ?? [] and the typeof raw !== 'string' guard in keptEagerEntries are written against.
Test coverage
The suite pins every branch in the implementation rather than a happy path: warns on an unmatched dynamic entry, on the legacy blocking pass, and on a misspelt built-in; stays quiet when every entry resolves, for meta-category and alias entries, for a tool that is only an unwarmed lazy factory, and when no allowlist is active; warns each entry once across re-discovery; does not re-report entries initialize() already dropped. The unconnected-server group covers a correctly spelled tool on a down server, a client-budget refusal, the case where the server is connected and simply has no such tool, an unknown server name as a typo, and the re-check-after-suppression behaviour. computer_use__* has its own case. I found no assertion that could pass against a broken implementation.
CI
Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox) and both Desktop Shell lanes pass at this head. web-shell E2E Smoke is still pending and I did not wait on it; nothing about this change touches the web shell. The red Test lane the author reported on the previous commit was in scripts/tests/package-scripts.test.js, which none of these three files can influence, and the lane is green after the base merge — that is the only part of that account I needed to confirm, and it holds.
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
APPROVE at ea5d426.
- Head re-verified immediately before submitting: ea5d426, OPEN and mergeable. No formal review and no inline thread existed on this PR before mine, so there was no historical blocker to clear.
- Required CI green on this head, with review-pr excluded as reviewer-bot infrastructure per my standing disclosure; Test (macos/windows) and Integration Tests (CLI, No Sandbox) are skipped repo-wide.
- No new Critical. The load-bearing property of this change is that the existence check cannot disagree with the mechanism it describes, and it holds. The deferral decision at permission-manager.ts:945-952 is eagerToolAllowList.some(eagerName => toolMatchesRuleToolName(eagerName, canonicalName)), and the allowlist is built by the same shape filter that keptEagerEntries re-derives verbatim: drop non-strings and empties, drop what parseRule marks invalid, otherwise keep rule.toolName. Same matcher, same argument order, same candidate names. An entry that really does keep a tool eager therefore cannot be reported. The corollary settles the wildcard question, which is the first thing I went looking for: toolMatchesRuleToolName has no glob branch, so an mcp__* style entry keeps nothing eager today either, and reporting it is correct rather than a false positive.
- The other two false-positive risks are handled and each is pinned by its own test: meta-category and alias entries match through the same READ_TOOLS, EDIT_TOOLS and shell-covers-monitor branches the mechanism uses, and a built-in that is still an unwarmed lazy factory still matches because the candidate set is getAllToolNames rather than the set of instantiated tools.
- The suppression logic points the right way. A correctly spelled tool on a configured server that is not connected is an outage rather than a typo and stays quiet, and that suppression is deliberately not written into the warn-once ledger, so a later pass still reports the entry if it proves wrong once the server is up; an entry whose server is not configured at all stays reportable. The computer_use__ exclusion states its reason instead of hiding it.
- It cannot damage what it runs inside. The whole body is wrapped so a diagnostic can never fail a discovery pass, it returns early unless the allowlist is active, and the warn-once ledger is a WeakMap keyed by Config, so a reconnect or a hot-reload reconcile neither repeats a warning nor leaks past the session. Emitting through console.warn with that eslint-disable reasoning matches the sibling allowlist warning already at permission-manager.ts:344, so it follows the established pattern for this feature rather than introducing one.
- All three call sites are settled-registry points (the per-session bulk pass, the legacy blocking pass, and the startup plus hot-reload reconcile path), which is where this has to run because MCP tools are discovered after initialize().
|
Post-merge review — head No blocking findings. Would have been an APPROVE on the open PR. What was checkedNew file:
Three call sites in
Test file:
Cross-file: Non-blocking observationsNone. |
What this PR does
Adds an existence check for
settings.tools.eagerentries that runs when an MCP discovery pass reachesCOMPLETED(the bulk pass behindToolRegistry.discoverAllTools(), the pooled pass used by daemon/ACP sessions, and the incremental pass used by the default background startup and by MCP hot-reload). Any active allowlist entry that matches no registered tool is reported on the console, once per entry per session. The check lives in a new filepackages/core/src/permissions/eager-allowlist-coverage.ts; the only change to existing code is one import plus three one-line calls at those three discovery boundaries inmcp-client-manager.ts.Matching deliberately mirrors
PermissionManager.getToolRegistrationStatus:toolMatchesRuleToolName(entry, canonicalRegisteredName)overToolRegistry.getAllToolNames(). So meta-categories (Read,Bash) and aliases (ReadFile,ListFiles) are never reported, and built-ins that are still unwarmed lazy factories count as registered. The same check covers built-in typos (read_flie) — there is nomcp__-specific validation branch.Two classes of entry are intentionally not reported: (1) an entry whose MCP server is configured but not
CONNECTED(refused by the client budget, failed handshake, disabled, still connecting) — that is an infrastructure outcome, not a spelling mistake, and the skip is not recorded in the warn-once ledger, so a later pass reports it if the name really is wrong; (2)computer_use__*entries — the generated cua-driver surface is not part of the tool registry at the MCP discovery boundary, so there is no reliable candidate set and any report would be a false positive. Entriesinitialize()already dropped as unusable are skipped too, so one mistake is never reported twice.Why it's needed
tools.eageronly shape-checks its entries today: an entry that parses cleanly is kept even if it names nothing. Because an active allowlist demotes every non-exempt tool it does not name, a single typo shrinks the whole eager tool surface — and nothing at startup says so.{"tools":{"eager":["mcp__githb__create_issue"]}}activates the allowlist, defersread_fileand friends totool_search, and produces zero diagnostics.Semantics were settled by the issue author in #12435 (comment) — check only after discovery reaches
COMPLETED(never ininitialize(), where dynamic tools are not registered yet), match withtoolMatchesRuleToolNamerather than set membership, do not blame the operator for a server that was refused or never connected, excludecomputer_use__*when its registration is not observable at this boundary, and warn once per entry across re-discovery.Reviewer Test Plan
How to verify
New test file
packages/core/src/permissions/eager-allowlist-warning.test.ts(16 tests). It wires a realConfig(makeFakeConfig), a realPermissionManagerand a realToolRegistrythe wayConfig.initialize()does, then drives a real discovery pass throughMcpClientManager.discoverAllMcpToolsIncremental()(default startup) anddiscoverAllMcpTools()(legacy blocking / re-discovery) and capturesconsole.warn.Before the fix the same file fails with the allowlist active,
read_filedeferred, no dropped-entry warning, and zero warnings about existence. Two red variants, because the fix is a new file plus three call sites: with only the threemcp-client-manager.tscall sites reverted (helper and tests kept) the five behavioural assertions fail —5 failed | 11 passed (16); on pristinemain(7837c6c200afb3853a126fc2dc01a155204aaaed, helper file absent) it is11 failed | 5 passed (16), the extra six being the cases that call the helper directly. Both were re-run independently after the fact; green is16 passed (16).Mutation-checked (each mutant applied alone, then reverted): replacing
toolMatchesRuleToolNamewith name-equality set membership killsstays quiet for meta-category and alias entries; dropping the warn-once ledger killswarns each unmatched entry only once across re-discoveryand the suppressed-entry re-check; dropping the unconnected-server suppression kills all three refused/disconnected-server cases; dropping thecomputer_use__*skip kills that case.Guardrails, all green with the change applied:
permission-manager.test.ts+mcp-client-manager.test.ts+eager-surface-report= 638 passed (638);tool-registry.test.ts= 73 passed;config.test.ts= 825 passed;tsc --noEmit -p packages/core= exit 0 with zero output;prettier --writeandeslintclean on all three touched files.Not run: no live stdio stub MCP server and no real CLI session were used — the discovery boundary is exercised through the real
McpClientManagerincremental and bulk passes on a realConfig/PermissionManager/ToolRegistry, with no server configured. A typo on a server that is actually up and connected is therefore covered by unit-level status injection (updateMCPServerStatus) rather than by a real handshake.Evidence (Before & After)
N/A — non-UI change (console warning only). Test output above.
Tested on
Environment (optional)
Unit tests only (
vitestinpackages/core).Risk & Scope
getMCPServerStatus(name) !== CONNECTEDfor a server present inconfig.getMcpServers(). A correctly spelled entry on a server that never comes up stays silent for the whole session — that is the intended tradeoff (an outage must not be reported as a user typo), and the entry is re-checked on every later pass rather than being remembered as warned.computer_use__*typos stay undetected for the same "no false positives" reason.ToolRegistry.discoverToolsForServer()(/mcp reconnectfor a single server) does not run the check; the eager allowlist is restart-scoped, so a startup pass has already reported every unmatched entry by then, and adding a fourth hook to a hot file did not seem worth it. No new settings key, no new public API, no telemetry.console.warnfor configurations that were already silently misbehaving.Size:
git diff --stat origin/main...HEAD→ 3 files changed, 510 insertions(+) (145 source, 355 test, 10 inmcp-client-manager.ts).Linked Issues
Fixes #12435
中文说明
这个 PR 做了什么
给
settings.tools.eager加了一次「存在性校验」,只在 MCP 发现流程走到COMPLETED时执行(ToolRegistry.discoverAllTools()背后的批量发现、daemon/ACP 使用的连接池发现、以及默认后台启动与 MCP 热重载使用的增量发现)。激活的 allowlist 里凡是匹配不到任何已注册工具的条目,都会在控制台告警,每个条目每个会话只告警一次。校验逻辑放在新文件packages/core/src/permissions/eager-allowlist-coverage.ts;对既有代码的改动只有一个 import 加mcp-client-manager.ts里三个发现边界上各一行调用。匹配方式刻意与
PermissionManager.getToolRegistrationStatus保持一致:用toolMatchesRuleToolName(entry, 已注册工具的 canonical name)对ToolRegistry.getAllToolNames()求值。因此 meta-category(Read、Bash)与 alias(ReadFile、ListFiles)不会被误报,尚未 warm 的惰性 factory 形式的内置工具也算已注册。同一个校验也覆盖内置名拼错(read_flie)——没有mcp__专属的校验分支。有两类条目刻意不告警:(1) 条目所属的 MCP server 已配置但不是
CONNECTED(被连接预算拒掉、握手失败、被禁用、仍在连接中)——这是基础设施结果而不是拼写错误;并且这种跳过不记入 warn-once 集合,所以后续发现流程如果确认名字确实写错了仍会告警。(2)computer_use__*条目——cua-driver 生成的那批工具在 MCP 发现边界上并不在 tool registry 里,没有可靠的候选集合,报出来必然是假阳性。initialize()已经作为「不可用条目」丢弃并告警过的条目也会跳过,同一个错误不会被报两次。为什么需要
今天
tools.eager只做形状校验:只要能解析通过就保留,哪怕它什么都没指到。而激活的 allowlist 会把它没点名的所有非豁免工具降级,于是一个拼写错误就能把整个 eager 工具面缩小,启动期却没有任何提示。{"tools":{"eager":["mcp__githb__create_issue"]}}会激活 allowlist、把read_file等工具推迟到tool_search,并且零诊断输出。语义由 issue 作者在 #12435 (comment) 拍板:只在发现流程
COMPLETED之后校验(绝不在initialize()里,那时动态工具还没注册);用toolMatchesRuleToolName而不是集合成员判断;被拒或没连上的 server 不把责任推给使用者;computer_use__*在该边界拿不到可靠注册结果时排除;每个条目跨 re-discovery 只告警一次。评审验证方案
新测试文件
packages/core/src/permissions/eager-allowlist-warning.test.ts(16 个用例)。它按Config.initialize()的方式装配真Config(makeFakeConfig)、真PermissionManager、真ToolRegistry,再通过McpClientManager.discoverAllMcpToolsIncremental()(默认启动)与discoverAllMcpTools()(旧的阻塞式/重新发现)跑真实发现流程,并捕获console.warn。修复前同一文件失败(allowlist 已激活、read_file已 deferred、没有 dropped-entry 告警、也没有任何存在性告警):11 failed | 5 passed。修复后 16 passed。变异检验(每个 mutant 单独施加后立刻还原):把
toolMatchesRuleToolName换成名字相等的集合判断 → meta-category/alias 用例变红;去掉 warn-once 集合 → 「只告警一次」与被跳过条目的复查用例变红;去掉未连接 server 的抑制 → 三个 refused/disconnected 用例变红;去掉computer_use__*跳过 → 对应用例变红。修复前的红有两种形态(因为修复 = 一个新文件 + 三行调用):只把
mcp-client-manager.ts的三处调用还原(保留 helper 与测试)⇒ 五条行为断言变红,5 failed | 11 passed (16);在原始main(7837c6c200afb3853a126fc2dc01a155204aaaed,helper 文件不存在)⇒11 failed | 5 passed (16),多出的 6 条是直接调用 helper 的用例。两种都在事后被独立重跑复核过;修复后16 passed (16)。护栏(带上本改动全绿):
permission-manager.test.ts+mcp-client-manager.test.ts+eager-surface-report= 638 passed (638);tool-registry.test.ts= 73 passed;config.test.ts= 825 passed;tsc --noEmit -p packages/core= exit 0、零输出;三个改动文件的prettier --write与eslint均干净。未跑项:没有起真实的 stdio stub MCP server,也没有跑真实 CLI 会话——发现边界是通过真实
McpClientManager的增量与批量两条流程、在真Config/PermissionManager/ToolRegistry(未配置任何 server)上驱动的。因此「server 真的连上了但工具名拼错」这一情形是用单测里注入状态(updateMCPServerStatus)覆盖的,不是真握手。风险与范围
config.getMcpServers()里存在该 server 且getMCPServerStatus(name) !== CONNECTED」。拼写正确但 server 始终起不来时,整个会话都不会告警——这正是想要的取舍(不能把服务故障报成用户配置错误),而且该条目在后续每一轮都会重新校验,不会被记成已告警。computer_use__*的拼写错误同样检测不到,理由都是「宁可少报也不要假阳性」。ToolRegistry.discoverToolsForServer()(/mcp reconnect单服务器重连)不跑该校验;eager allowlist 是重启生效的,启动那一轮已经报过所有未匹配条目,为一个热点文件再加第四个钩子不划算。没有新增 settings 键、没有新增公共 API、没有新增 telemetry。console.warn。规模:
git diff --stat origin/main...HEAD→ 3 files changed, 510 insertions(+)(源码 145 行、测试 355 行、mcp-client-manager.ts10 行)。关联 issue
Fixes #12435