From e58acf235e1e1279f5d5a43a2588e1bbe05359f4 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 29 Jun 2026 20:31:19 +0800 Subject: [PATCH 1/6] feat(core): support glob patterns in mcp.allowed and mcp.excluded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add matchesServerPattern/matchesAnyServerPattern helpers that support * (any sequence) and ? (single char) glob syntax. Apply symmetrically to both allow and deny predicates in getMcpServers, isMcpServerDisabled, and getMcpServerUnavailableReason. Existing exact-match configs are unaffected (no glob chars → string equality). Update settings schema descriptions to document the new glob support. Closes #4940 (retargeted to glob matching; the deny-list capability already existed as mcp.excluded). --- packages/cli/src/config/settingsSchema.ts | 6 +- packages/core/src/config/config.test.ts | 117 ++++++++++++++++++++++ packages/core/src/config/config.ts | 39 +++++++- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 60dbe7f6203..f3cb9d04ef4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2416,7 +2416,8 @@ const SETTINGS_SCHEMA = { // restart-required. requiresRestart: false, default: undefined as string[] | undefined, - description: 'A list of MCP servers to allow.', + description: + 'A list of MCP servers to allow. Supports glob patterns (e.g. "*puppeteer*").', showInDialog: false, mergeStrategy: MergeStrategy.CONCAT, }, @@ -2429,7 +2430,8 @@ const SETTINGS_SCHEMA = { // restart-required. requiresRestart: false, default: undefined as string[] | undefined, - description: 'A list of MCP servers to exclude.', + description: + 'A list of MCP servers to exclude. Supports glob patterns (e.g. "*puppeteer*"). Takes precedence over mcp.allowed.', showInDialog: false, mergeStrategy: MergeStrategy.CONCAT, }, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 7a7631afa92..a1d5150f70d 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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,67 @@ 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); + }); + + it('combines glob with exact segments', () => { + expect(matchesServerPattern('foo-bar-baz', 'foo-*-baz')).toBe(true); + expect(matchesServerPattern('foo-bar-qux', 'foo-*-baz')).toBe(false); + }); +}); + +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); + }); +}); + describe('Server Config (config.ts)', () => { const MODEL = 'qwen3-coder-plus'; @@ -1060,6 +1123,60 @@ 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([ + 'puppeteer', + 'my-puppeteer-server', + ]); + }); + + it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => { + const config = new Config({ ...baseParams }); + config.setExcludedMcpServers(['*puppeteer*']); + expect(config.isMcpServerDisabled('puppeteer')).toBe(true); + expect(config.isMcpServerDisabled('my-puppeteer')).toBe(true); + expect(config.isMcpServerDisabled('playwright')).toBe(false); + }); + + it('getMcpServerUnavailableReason classifies by glob match', async () => { + const config = new Config({ + ...baseParams, + mcpServers: { + puppeteer: srvA, + 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'); + }); }); describe('MemoryPressureMonitor isolation', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 21d3c99fbdd..b831209e480 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -630,6 +630,35 @@ export function isGatedMcpScope(scope: McpServerScope | undefined): boolean { return scope === 'project' || scope === 'workspace'; } +/** + * Test whether a server name matches a single pattern. Patterns use simple + * glob semantics: `*` matches any sequence of characters (including empty), + * `?` matches exactly one character. A pattern without glob characters is + * compared as an exact string (no behavior change for existing configs). + */ +export function matchesServerPattern(name: string, pattern: string): boolean { + if (!pattern.includes('*') && !pattern.includes('?')) { + return name === pattern; + } + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`).test(name); +} + +/** + * Test whether a server name matches any pattern in the given list. + * Returns false for an empty or undefined list. + */ +export function matchesAnyServerPattern( + name: string, + patterns: string[] | undefined, +): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((p) => matchesServerPattern(name, p)); +} + export class MCPServerConfig { constructor( // For stdio transport @@ -3724,7 +3753,7 @@ export class Config { if (this.allowedMcpServers) { mcpServers = Object.fromEntries( Object.entries(mcpServers).filter(([key]) => - this.allowedMcpServers?.includes(key), + matchesAnyServerPattern(key, this.allowedMcpServers), ), ); } @@ -3745,7 +3774,8 @@ export class Config { } isMcpServerDisabled(serverName: string): boolean { - if (this.excludedMcpServers?.includes(serverName)) return true; + if (matchesAnyServerPattern(serverName, this.excludedMcpServers)) + return true; // Extension-bundled servers can be disabled individually via extension // preferences. Only the extension that actually contributed the server is // consulted, so a same-named server from another source (e.g. a shadowing @@ -3897,11 +3927,12 @@ export class Config { if (!(serverName in this.getMergedMcpServers())) return undefined; if ( this.allowedMcpServers && - !this.allowedMcpServers.includes(serverName) + !matchesAnyServerPattern(serverName, this.allowedMcpServers) ) { return 'not_allowed'; } - if (this.excludedMcpServers?.includes(serverName)) return 'excluded'; + if (matchesAnyServerPattern(serverName, this.excludedMcpServers)) + return 'excluded'; if (this.isMcpServerPendingApproval(serverName)) return 'pending_approval'; return undefined; } From 840c0ab560c41c28cfa1abc90a7fb5fa3c8bcde9 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 29 Jun 2026 21:51:34 +0800 Subject: [PATCH 2/6] chore: regenerate settings schema for glob pattern descriptions --- packages/vscode-ide-companion/schemas/settings.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 87585980165..294692ab945 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1119,14 +1119,14 @@ "type": "string" }, "allowed": { - "description": "A list of MCP servers to allow.", + "description": "A list of MCP servers to allow. Supports glob patterns (e.g. \"*puppeteer*\").", "type": "array", "items": { "type": "string" } }, "excluded": { - "description": "A list of MCP servers to exclude.", + "description": "A list of MCP servers to exclude. Supports glob patterns (e.g. \"*puppeteer*\"). Takes precedence over mcp.allowed.", "type": "array", "items": { "type": "string" From 0a226706dbe505741cc78701e5c5f8c016882d93 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 29 Jun 2026 21:56:29 +0800 Subject: [PATCH 3/6] fix(core): fix missed .includes() call sites for glob MCP pattern matching - getBlockedMcpServers() used exact match, causing UI inconsistency when mcp.allowed contained glob patterns (critical) - tool-registry.ts disable action added redundant exact entries when a glob already covered the server - acpAgent.ts enable/disable paths silently misbehaved with globs: enable was a no-op, disable persisted stale exact entries Also add test for exclude-takes-precedence-over-allow with glob patterns. --- packages/cli/src/acp-integration/acpAgent.ts | 17 ++++++++++++----- packages/core/src/config/config.test.ts | 20 ++++++++++++++++++++ packages/core/src/config/config.ts | 2 +- packages/core/src/tools/tool-registry.ts | 4 ++-- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 76c96e27f03..95921561e6d 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -60,6 +60,8 @@ import { unregisterGoalHook, ToolNames, FORK_SUBAGENT_TYPE, + matchesAnyServerPattern, + matchesServerPattern, } from '@qwen-code/qwen-code-core'; import { randomUUID } from 'node:crypto'; import type { @@ -5596,17 +5598,22 @@ class QwenAgent implements Agent { for (const scope of [SettingScope.User, SettingScope.Workspace]) { const scopeSettings = settings.forScope(scope).settings; const currentExcluded = scopeSettings.mcp?.excluded || []; - if (currentExcluded.includes(serverName)) { + if (matchesAnyServerPattern(serverName, currentExcluded)) { settings.setValue( scope, 'mcp.excluded', - currentExcluded.filter((name: string) => name !== serverName), + currentExcluded.filter( + (pattern: string) => + !matchesServerPattern(serverName, pattern), + ), ); } } const currentExcluded = this.config.getExcludedMcpServers() || []; this.config.setExcludedMcpServers( - currentExcluded.filter((name: string) => name !== serverName), + currentExcluded.filter( + (pattern: string) => !matchesServerPattern(serverName, pattern), + ), ); await toolRegistry.discoverToolsForServer(serverName); return { serverName, action, ok: true, changed: true }; @@ -5632,14 +5639,14 @@ class QwenAgent implements Agent { } const scopeSettings = settings.forScope(targetScope).settings; const currentExcluded = scopeSettings.mcp?.excluded || []; - if (!currentExcluded.includes(serverName)) { + if (!matchesAnyServerPattern(serverName, currentExcluded)) { settings.setValue(targetScope, 'mcp.excluded', [ ...currentExcluded, serverName, ]); } const runtimeExcluded = this.config.getExcludedMcpServers() || []; - if (!runtimeExcluded.includes(serverName)) { + if (!matchesAnyServerPattern(serverName, runtimeExcluded)) { this.config.setExcludedMcpServers([...runtimeExcluded, serverName]); } await toolRegistry.disableMcpServer(serverName); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index a1d5150f70d..396031ac9cf 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1177,6 +1177,26 @@ describe('Server Config (config.ts)', () => { 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(); + }); }); describe('MemoryPressureMonitor isolation', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 505e0faece5..30e2cea16c4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -5123,7 +5123,7 @@ export class Config { if (this.allowedMcpServers) { Object.entries(mcpServers).forEach(([key, server]) => { - const isAllowed = this.allowedMcpServers?.includes(key); + const isAllowed = matchesAnyServerPattern(key, this.allowedMcpServers); if (!isAllowed) { blockedMcpServers.push({ name: key, diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index a5afbd2e579..cd494cf6520 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -12,7 +12,7 @@ import type { ToolInvocation, } from './tools.js'; import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js'; -import type { Config } from '../config/config.js'; +import { type Config, matchesAnyServerPattern } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import type { SendSdkMcpMessage } from './mcp-client.js'; @@ -449,7 +449,7 @@ export class ToolRegistry { // while isMcpServerDisabled still returns false, mis-reporting // an intentional disable as a connectivity failure. const currentExcluded = this.config.getExcludedMcpServers() || []; - if (!currentExcluded.includes(serverName)) { + if (!matchesAnyServerPattern(serverName, currentExcluded)) { this.config.setExcludedMcpServers([...currentExcluded, serverName]); } } finally { From 42641852d5d4a505dc963de78954142695dc58b6 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Mon, 29 Jun 2026 21:59:39 +0800 Subject: [PATCH 4/6] fix(core): replace regex glob matcher with two-pointer to prevent ReDoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex-based glob conversion (*→.*, ?→.) was vulnerable to catastrophic backtracking with pathological patterns like *?*?*?*. Replaced with an iterative two-pointer algorithm that runs in O(n×m) worst case with no backtracking. --- OpenClaw-Query-Submit | 1 + packages/core/src/config/config.ts | 30 +++++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) create mode 160000 OpenClaw-Query-Submit diff --git a/OpenClaw-Query-Submit b/OpenClaw-Query-Submit new file mode 160000 index 00000000000..be66572805c --- /dev/null +++ b/OpenClaw-Query-Submit @@ -0,0 +1 @@ +Subproject commit be66572805c50ad714c98dd1db059c8dad9919c9 diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 30e2cea16c4..26d3ad813ca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -636,16 +636,36 @@ export function isGatedMcpScope(scope: McpServerScope | undefined): boolean { * glob semantics: `*` matches any sequence of characters (including empty), * `?` matches exactly one character. A pattern without glob characters is * compared as an exact string (no behavior change for existing configs). + * Uses an iterative two-pointer algorithm — O(n×m) worst case, no regex, + * no backtracking vulnerability. */ export function matchesServerPattern(name: string, pattern: string): boolean { if (!pattern.includes('*') && !pattern.includes('?')) { return name === pattern; } - const escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.'); - return new RegExp(`^${escaped}$`).test(name); + let ni = 0; + let pi = 0; + let starNi = -1; + let starPi = -1; + while (ni < name.length) { + if ( + pi < pattern.length && + (pattern[pi] === '?' || pattern[pi] === name[ni]) + ) { + ni++; + pi++; + } else if (pi < pattern.length && pattern[pi] === '*') { + starPi = pi++; + starNi = ni; + } else if (starPi !== -1) { + pi = starPi + 1; + ni = ++starNi; + } else { + return false; + } + } + while (pi < pattern.length && pattern[pi] === '*') pi++; + return pi === pattern.length; } /** From 7e64cead2aa4fe9f2aa5d0de26f3a800c88c98c8 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Tue, 30 Jun 2026 11:29:47 +0800 Subject: [PATCH 5/6] fix(cli): fix enable/disable glob handling and add test coverage - Enable action: only remove exact-match patterns from exclusion list, preserving glob patterns to prevent collateral server re-enable - Disable action: return accurate changed status (false when glob already covers the server, no write needed) - Add 8 new test cases: two-pointer edge cases, negative assertions, getBlockedMcpServers glob, exclude-over-allow with both globs, regex special char $ coverage Addresses PR #6012 review feedback. --- packages/cli/src/acp-integration/acpAgent.ts | 46 ++++++++----- packages/core/src/config/config.test.ts | 72 +++++++++++++++++++- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 6cfc3d474a4..6243ae73cfa 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -61,7 +61,6 @@ import { ToolNames, FORK_SUBAGENT_TYPE, matchesAnyServerPattern, - matchesServerPattern, } from '@qwen-code/qwen-code-core'; import { randomUUID } from 'node:crypto'; import type { @@ -5598,28 +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 (matchesAnyServerPattern(serverName, currentExcluded)) { - settings.setValue( - scope, - 'mcp.excluded', - currentExcluded.filter( - (pattern: string) => - !matchesServerPattern(serverName, pattern), - ), - ); + const filtered = currentExcluded.filter( + (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( - (pattern: string) => !matchesServerPattern(serverName, pattern), - ), + 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, + changed: settingsChanged || runtimeChanged, + }; } if (action === 'disable') { @@ -5642,18 +5647,27 @@ class QwenAgent implements Agent { } const scopeSettings = settings.forScope(targetScope).settings; const currentExcluded = scopeSettings.mcp?.excluded || []; + let settingsChanged = false; if (!matchesAnyServerPattern(serverName, currentExcluded)) { settings.setValue(targetScope, 'mcp.excluded', [ ...currentExcluded, serverName, ]); + settingsChanged = true; } const runtimeExcluded = this.config.getExcludedMcpServers() || []; + 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') { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 396031ac9cf..c5c1c29e944 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -374,12 +374,37 @@ describe('matchesServerPattern', () => { 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); 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', () => { + 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); + }); }); describe('matchesAnyServerPattern', () => { @@ -1139,14 +1164,24 @@ describe('Server Config (config.ts)', () => { 'puppeteer', 'my-puppeteer-server', ]); + expect(Object.keys(result!)).not.toContain('playwright'); }); it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => { - const config = new Config({ ...baseParams }); + 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 () => { @@ -1197,6 +1232,41 @@ describe('Server Config (config.ts)', () => { 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', () => { From 11ba2abe5a03de32a3d857abcc36ce1c310f0a7a Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Tue, 30 Jun 2026 11:58:50 +0800 Subject: [PATCH 6/6] fix(cli): use matchesAnyServerPattern in UI exclusion write guards Replace .includes() with matchesAnyServerPattern() in 4 UI call sites (McpServerActionsView, InstalledTab, MCPManagementDialog) so that disabling a server already covered by a glob pattern does not add a redundant exact-match entry. Addresses wenshao review on PR #6012. --- .../src/ui/components/extensions/tabs/InstalledTab.tsx | 3 ++- .../components/extensions/views/McpServerActionsView.tsx | 3 ++- .../cli/src/ui/components/mcp/MCPManagementDialog.tsx | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index e93542ce436..4a4db387859 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -26,6 +26,7 @@ import { mcpServerRequiresOAuth, MCPOAuthTokenStorage, createDebugLogger, + matchesAnyServerPattern, } from '@qwen-code/qwen-code-core'; import { loadSettings, @@ -566,7 +567,7 @@ export const InstalledTab = ({ // Disable: add to excluded + disconnect. const excluded = settings.forScope(targetScope).settings.mcp?.excluded ?? []; - if (!excluded.includes(item.name)) { + if (!matchesAnyServerPattern(item.name, excluded)) { settings.setValue(targetScope, 'mcp.excluded', [ ...excluded, item.name, diff --git a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx index 81a6c6d5c3f..09d491b3043 100644 --- a/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/McpServerActionsView.tsx @@ -19,6 +19,7 @@ import { DiscoveredMCPTool, MCPOAuthTokenStorage, createDebugLogger, + matchesAnyServerPattern, } from '@qwen-code/qwen-code-core'; import { loadSettings, SettingScope } from '../../../../config/settings.js'; import { getErrorMessage } from '../../../../utils/errors.js'; @@ -326,7 +327,7 @@ export const McpServerActionsView = ({ : SettingScope.User; const settings = loadSettings(); const excluded = settings.forScope(scope).settings.mcp?.excluded || []; - if (!excluded.includes(serverName)) { + if (!matchesAnyServerPattern(serverName, excluded)) { settings.setValue(scope, 'mcp.excluded', [...excluded, serverName]); } await toolRegistry?.disableMcpServer(serverName); diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index cbe0aaabec4..36797744685 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -39,6 +39,7 @@ import { type AnyDeclarativeTool, type DiscoveredMCPPrompt, createDebugLogger, + matchesAnyServerPattern, } from '@qwen-code/qwen-code-core'; import { loadSettings, SettingScope } from '../../../config/settings.js'; import { loadMcpApprovals } from '../../../config/mcpApprovals.js'; @@ -534,8 +535,8 @@ export const MCPManagementDialog: React.FC = ({ ).settings; const currentExcluded = scopeSettings.mcp?.excluded || []; - // If server is not in exclusion list, add it - if (!currentExcluded.includes(server.name)) { + // If server is not already covered by an exclusion pattern, add it + if (!matchesAnyServerPattern(server.name, currentExcluded)) { const newExcluded = [...currentExcluded, server.name]; settings.setValue( targetScope === 'user' ? SettingScope.User : SettingScope.Workspace, @@ -580,8 +581,8 @@ export const MCPManagementDialog: React.FC = ({ ).settings; const currentExcluded = scopeSettings.mcp?.excluded || []; - // If server is not in exclusion list, add it - if (!currentExcluded.includes(server.name)) { + // If server is not already covered by an exclusion pattern, add it + if (!matchesAnyServerPattern(server.name, currentExcluded)) { const newExcluded = [...currentExcluded, server.name]; settings.setValue( scope === 'user' ? SettingScope.User : SettingScope.Workspace,