-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(core): support glob patterns in mcp.allowed and mcp.excluded #6012
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e58acf2
1f1286b
1f2908e
840c0ab
0a22670
4264185
64ab9fe
7e64cea
11ba2ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -60,6 +60,7 @@ import { | |||||||||
| unregisterGoalHook, | ||||||||||
| ToolNames, | ||||||||||
| FORK_SUBAGENT_TYPE, | ||||||||||
| matchesAnyServerPattern, | ||||||||||
| } from '@qwen-code/qwen-code-core'; | ||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||
| import { randomUUID } from 'node:crypto'; | ||||||||||
| import type { | ||||||||||
|
|
@@ -5596,23 +5597,34 @@ class QwenAgent implements Agent { | |||||||||
|
|
||||||||||
| if (action === 'enable') { | ||||||||||
| const settings = loadSettings(this.config.getTargetDir()); | ||||||||||
| let settingsChanged = false; | ||||||||||
| for (const scope of [SettingScope.User, SettingScope.Workspace]) { | ||||||||||
| const scopeSettings = settings.forScope(scope).settings; | ||||||||||
| const currentExcluded = scopeSettings.mcp?.excluded || []; | ||||||||||
| if (currentExcluded.includes(serverName)) { | ||||||||||
| settings.setValue( | ||||||||||
| scope, | ||||||||||
| 'mcp.excluded', | ||||||||||
| currentExcluded.filter((name: string) => name !== serverName), | ||||||||||
| ); | ||||||||||
| const filtered = currentExcluded.filter( | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Enable action uses exact string comparison to filter This is the headline scenario of the PR: an admin excludes servers via glob (the new feature), then can't selectively re-enable one through the normal enable flow. The collateral-damage fix (exact-only removal) solved the opposite problem but created this gap.
Suggested change
Apply the same change to the runtime filter at line 5614. Trade-off: removing — qwen3.7-max via Qwen Code /review |
||||||||||
| (pattern: string) => pattern !== serverName, | ||||||||||
| ); | ||||||||||
| if (filtered.length !== currentExcluded.length) { | ||||||||||
| settings.setValue(scope, 'mcp.excluded', filtered); | ||||||||||
| settingsChanged = true; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| const currentExcluded = this.config.getExcludedMcpServers() || []; | ||||||||||
| this.config.setExcludedMcpServers( | ||||||||||
| currentExcluded.filter((name: string) => name !== serverName), | ||||||||||
| const runtimeFiltered = currentExcluded.filter( | ||||||||||
| (pattern: string) => pattern !== serverName, | ||||||||||
| ); | ||||||||||
| let runtimeChanged = false; | ||||||||||
| if (runtimeFiltered.length !== currentExcluded.length) { | ||||||||||
| this.config.setExcludedMcpServers(runtimeFiltered); | ||||||||||
| runtimeChanged = true; | ||||||||||
| } | ||||||||||
| await toolRegistry.discoverToolsForServer(serverName); | ||||||||||
| return { serverName, action, ok: true, changed: true }; | ||||||||||
| return { | ||||||||||
| serverName, | ||||||||||
| action, | ||||||||||
| ok: true, | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The Add test cases asserting — qwen3.7-max via Qwen Code /review |
||||||||||
| changed: settingsChanged || runtimeChanged, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (action === 'disable') { | ||||||||||
|
|
@@ -5635,18 +5647,27 @@ class QwenAgent implements Agent { | |||||||||
| } | ||||||||||
| const scopeSettings = settings.forScope(targetScope).settings; | ||||||||||
| const currentExcluded = scopeSettings.mcp?.excluded || []; | ||||||||||
| if (!currentExcluded.includes(serverName)) { | ||||||||||
| let settingsChanged = false; | ||||||||||
| if (!matchesAnyServerPattern(serverName, currentExcluded)) { | ||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||
| settings.setValue(targetScope, 'mcp.excluded', [ | ||||||||||
| ...currentExcluded, | ||||||||||
| serverName, | ||||||||||
| ]); | ||||||||||
| settingsChanged = true; | ||||||||||
| } | ||||||||||
| const runtimeExcluded = this.config.getExcludedMcpServers() || []; | ||||||||||
| if (!runtimeExcluded.includes(serverName)) { | ||||||||||
| let runtimeChanged = false; | ||||||||||
| if (!matchesAnyServerPattern(serverName, runtimeExcluded)) { | ||||||||||
| this.config.setExcludedMcpServers([...runtimeExcluded, serverName]); | ||||||||||
| runtimeChanged = true; | ||||||||||
| } | ||||||||||
| await toolRegistry.disableMcpServer(serverName); | ||||||||||
| return { serverName, action, ok: true, changed: true }; | ||||||||||
| return { | ||||||||||
| serverName, | ||||||||||
| action, | ||||||||||
| ok: true, | ||||||||||
| changed: settingsChanged || runtimeChanged, | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (action === 'clear-auth') { | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,8 @@ import { | |||||||||||||||||||||||||
| APPROVAL_MODE_INFO, | ||||||||||||||||||||||||||
| MCPServerConfig, | ||||||||||||||||||||||||||
| TrustGateError, | ||||||||||||||||||||||||||
| matchesServerPattern, | ||||||||||||||||||||||||||
| matchesAnyServerPattern, | ||||||||||||||||||||||||||
| } from './config.js'; | ||||||||||||||||||||||||||
| import { Storage } from './storage.js'; | ||||||||||||||||||||||||||
| import * as fs from 'node:fs'; | ||||||||||||||||||||||||||
|
|
@@ -344,6 +346,92 @@ vi.mock('../core/toolHookTriggers.js', () => ({ | |||||||||||||||||||||||||
| fireNotificationHook: vi.fn().mockResolvedValue({}), | ||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| describe('matchesServerPattern', () => { | ||||||||||||||||||||||||||
| it('exact match when no glob characters', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('puppeteer', 'puppeteer')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('puppeteer', 'playwright')).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('* matches any sequence including empty', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('puppeteer', '*puppeteer*')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('my-puppeteer-server', '*puppeteer*')).toBe( | ||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('playwright', '*puppeteer*')).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('anything', '*')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('prefix-suffix', 'prefix*')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('prefix-suffix', '*suffix')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('? matches exactly one character', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', 'a?c')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('ac', 'a?c')).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('axc', 'a?c')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('escapes regex special characters', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('my.server', 'my.server')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('myXserver', 'my.server')).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('a+b', 'a+b')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('a^b', 'a^b')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('a$b', 'a$b')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('aXb', 'a$b')).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('combines glob with exact segments', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('foo-bar-baz', 'foo-*-baz')).toBe(true); | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
| expect(matchesServerPattern('foo-bar-qux', 'foo-*-baz')).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('handles empty name', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('', '*')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('', '?')).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('', '')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('handles consecutive * in pattern', () => { | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| expect(matchesServerPattern('puppeteer', '**puppeteer**')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', 'a**c')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('handles ? at pattern boundaries', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', '?bc')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', 'ab?')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', '???')).toBe(true); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('ab', '???')).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('rejects when pattern is longer than name', () => { | ||||||||||||||||||||||||||
| expect(matchesServerPattern('ab', 'a*b*c')).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesServerPattern('abc', 'a*b*c')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| describe('matchesAnyServerPattern', () => { | ||||||||||||||||||||||||||
| it('returns false for undefined or empty list', () => { | ||||||||||||||||||||||||||
| expect(matchesAnyServerPattern('puppeteer', undefined)).toBe(false); | ||||||||||||||||||||||||||
| expect(matchesAnyServerPattern('puppeteer', [])).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('matches if any pattern matches', () => { | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| matchesAnyServerPattern('puppeteer', ['playwright', '*puppeteer*']), | ||||||||||||||||||||||||||
| ).toBe(true); | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| matchesAnyServerPattern('chrome', ['playwright', '*puppeteer*']), | ||||||||||||||||||||||||||
| ).toBe(false); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('works with mixed exact and glob patterns', () => { | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| matchesAnyServerPattern('playwright', ['playwright', '*puppeteer*']), | ||||||||||||||||||||||||||
| ).toBe(true); | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| matchesAnyServerPattern('my-puppeteer', ['playwright', '*puppeteer*']), | ||||||||||||||||||||||||||
| ).toBe(true); | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| describe('Server Config (config.ts)', () => { | ||||||||||||||||||||||||||
| const MODEL = 'qwen3-coder-plus'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -1060,6 +1148,125 @@ describe('Server Config (config.ts)', () => { | |||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| expect(config.getAllowedMcpServers()).toEqual(['y']); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('getMcpServers filters by glob pattern in allowedMcpServers', async () => { | ||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| 'my-puppeteer-server': srvB, | ||||||||||||||||||||||||||
| playwright: srvA, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| config.setAllowedMcpServers(['*puppeteer*']); | ||||||||||||||||||||||||||
| const result = config.getMcpServers(); | ||||||||||||||||||||||||||
| expect(Object.keys(result!)).toEqual([ | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
| 'puppeteer', | ||||||||||||||||||||||||||
| 'my-puppeteer-server', | ||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||
| expect(Object.keys(result!)).not.toContain('playwright'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => { | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| 'my-puppeteer': srvA, | ||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| config.setExcludedMcpServers(['*puppeteer*']); | ||||||||||||||||||||||||||
| expect(config.isMcpServerDisabled('puppeteer')).toBe(true); | ||||||||||||||||||||||||||
| expect(config.isMcpServerDisabled('my-puppeteer')).toBe(true); | ||||||||||||||||||||||||||
| expect(config.isMcpServerDisabled('playwright')).toBe(false); | ||||||||||||||||||||||||||
| expect(config.getMcpServers()!['puppeteer']).toBeDefined(); | ||||||||||||||||||||||||||
| expect(config.getMcpServers()!['my-puppeteer']).toBeDefined(); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('getMcpServerUnavailableReason classifies by glob match', async () => { | ||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
|
DennisYu07 marked this conversation as resolved.
|
||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| chrome: srvA, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| await config.reinitializeMcpServers({ | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| chrome: srvA, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| config.setAllowedMcpServers(['play*']); | ||||||||||||||||||||||||||
| expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( | ||||||||||||||||||||||||||
| 'not_allowed', | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| config.getMcpServerUnavailableReason('playwright'), | ||||||||||||||||||||||||||
| ).toBeUndefined(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Clear allow-list so the excluded check is reached. | ||||||||||||||||||||||||||
| config.setAllowedMcpServers(undefined); | ||||||||||||||||||||||||||
| config.setExcludedMcpServers(['*chrome*']); | ||||||||||||||||||||||||||
| expect(config.getMcpServerUnavailableReason('chrome')).toBe('excluded'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('exclude takes precedence over allow with glob patterns', async () => { | ||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { puppeteer: srvA, playwright: srvB }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| await config.reinitializeMcpServers({ | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| config.setAllowedMcpServers(['*']); | ||||||||||||||||||||||||||
| config.setExcludedMcpServers(['puppeteer']); | ||||||||||||||||||||||||||
| expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( | ||||||||||||||||||||||||||
| 'excluded', | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| expect( | ||||||||||||||||||||||||||
| config.getMcpServerUnavailableReason('playwright'), | ||||||||||||||||||||||||||
| ).toBeUndefined(); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('exclude takes precedence when both lists use globs', async () => { | ||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { puppeteer: srvA, playwright: srvB }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| await config.reinitializeMcpServers({ | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| config.setAllowedMcpServers(['*puppeteer*']); | ||||||||||||||||||||||||||
| config.setExcludedMcpServers(['puppeteer']); | ||||||||||||||||||||||||||
| expect(config.getMcpServerUnavailableReason('puppeteer')).toBe( | ||||||||||||||||||||||||||
| 'excluded', | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| expect(config.isMcpServerDisabled('puppeteer')).toBe(true); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| it('getBlockedMcpServers returns servers not matching allowed glob', () => { | ||||||||||||||||||||||||||
| const config = new Config({ | ||||||||||||||||||||||||||
| ...baseParams, | ||||||||||||||||||||||||||
| mcpServers: { | ||||||||||||||||||||||||||
| puppeteer: srvA, | ||||||||||||||||||||||||||
| 'my-puppeteer': srvA, | ||||||||||||||||||||||||||
| playwright: srvB, | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| config.setAllowedMcpServers(['*puppeteer*']); | ||||||||||||||||||||||||||
| const blocked = config.getBlockedMcpServers(); | ||||||||||||||||||||||||||
| const blockedNames = blocked.map((s) => s.name); | ||||||||||||||||||||||||||
| expect(blockedNames).toContain('playwright'); | ||||||||||||||||||||||||||
| expect(blockedNames).not.toContain('puppeteer'); | ||||||||||||||||||||||||||
| expect(blockedNames).not.toContain('my-puppeteer'); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| describe('MemoryPressureMonitor isolation', () => { | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] This import is used to update 4 call sites in this PR, but 7 additional call sites across 3 UI components still use
.includes()for the same exclusion/allowance checks:MCPManagementDialog.tsx(lines 455, 538, 584)McpServerActionsView.tsx(lines 292, 329)InstalledTab.tsx(lines 569, 584)This creates a behavioral inconsistency: enabling a glob-excluded server via the ACP API removes the glob pattern (with the collateral-damage issue flagged elsewhere), while the TUI enable at
MCPManagementDialog.tsx:455(currentExcluded.includes(server.name)) fails to find the glob entry and silently no-ops — the server stays excluded with no user feedback. The same user action produces different results depending on which UI surface they use.Suggested fix: apply the same
matchesAnyServerPatterncheck to all 7 remaining call sites, or extract a shared helper.— qwen3.7-max via Qwen Code /review