fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 [CONFLICTS] (#21478)

Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
This commit is contained in:
gemini-cli-robot
2026-03-06 19:29:28 -05:00
committed by GitHub
parent c316fc6c51
commit a6256cdabf
20 changed files with 704 additions and 514 deletions
+3 -1
View File
@@ -124,7 +124,9 @@ jobs:
- name: 'Link Checker' - name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with: with:
args: '--verbose --accept 200,503 ./**/*.md' # Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
fail: true fail: true
test_linux: test_linux:
name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}' name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
+3 -1
View File
@@ -22,4 +22,6 @@ jobs:
id: 'lychee' id: 'lychee'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with: with:
args: '--verbose --no-progress --accept 200,503 ./**/*.md' # Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --no-progress --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
+2 -2
View File
@@ -104,7 +104,7 @@ describe('extension reloading', () => {
return ( return (
output.includes( output.includes(
'test-server (from test-extension) - Ready (1 tool)', 'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- hello') ) && output.includes('- mcp_test-server_hello')
); );
}, },
30000, // 30s timeout 30000, // 30s timeout
@@ -148,7 +148,7 @@ describe('extension reloading', () => {
return ( return (
output.includes( output.includes(
'test-server (from test-extension) - Ready (1 tool)', 'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- goodbye') ) && output.includes('- mcp_test-server_goodbye')
); );
}, },
30000, 30000,
@@ -89,13 +89,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from allowed server should be allowed // Tools from allowed server should be allowed
// Tools from allowed server should be allowed // Tools from allowed server should be allowed
expect( expect(
(await engine.check({ name: 'allowed-server__tool1' }, undefined)) (await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
( (
await engine.check( await engine.check(
{ name: 'allowed-server__another_tool' }, { name: 'mcp_allowed-server_another_tool' },
undefined, undefined,
) )
).decision, ).decision,
@@ -103,13 +103,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from trusted server should be allowed // Tools from trusted server should be allowed
expect( expect(
(await engine.check({ name: 'trusted-server__tool1' }, undefined)) (await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
( (
await engine.check( await engine.check(
{ name: 'trusted-server__special_tool' }, { name: 'mcp_trusted-server_special_tool' },
undefined, undefined,
) )
).decision, ).decision,
@@ -117,17 +117,17 @@ describe('Policy Engine Integration Tests', () => {
// Tools from blocked server should be denied // Tools from blocked server should be denied
expect( expect(
(await engine.check({ name: 'blocked-server__tool1' }, undefined)) (await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined)) (await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default // Tools from unknown servers should use default
expect( expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined)) (await engine.check({ name: 'mcp_unknown-server_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ASK_USER); ).toBe(PolicyDecision.ASK_USER);
}); });
@@ -147,12 +147,16 @@ describe('Policy Engine Integration Tests', () => {
// ANY tool with a server name should be allowed // ANY tool with a server name should be allowed
expect( expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server')) (await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server'))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server')) (
.decision, await engine.check(
{ name: 'mcp_another-server_tool' },
'another-server',
)
).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard // Built-in tools should NOT be allowed by the MCP wildcard
@@ -167,7 +171,7 @@ describe('Policy Engine Integration Tests', () => {
allowed: ['my-server'], allowed: ['my-server'],
}, },
tools: { tools: {
exclude: ['my-server__dangerous-tool'], exclude: ['mcp_my-server_dangerous-tool'],
}, },
}; };
@@ -180,20 +184,24 @@ describe('Policy Engine Integration Tests', () => {
// MCP server allowed (priority 4.1) provides general allow for server // MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server // MCP server allowed (priority 4.1) provides general allow for server
expect( expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined)) (await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 4.4) wins over server allow // But specific tool exclude (priority 4.4) wins over server allow
expect( expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) (
.decision, await engine.check(
{ name: 'mcp_my-server_dangerous-tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
}); });
it('should handle complex mixed configurations', async () => { it('should handle complex mixed configurations', async () => {
const settings: Settings = { const settings: Settings = {
tools: { tools: {
allowed: ['custom-tool', 'my-server__special-tool'], allowed: ['custom-tool', 'mcp_my-server_special-tool'],
exclude: ['glob', 'dangerous-tool'], exclude: ['glob', 'dangerous-tool'],
}, },
mcp: { mcp: {
@@ -238,21 +246,21 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'custom-tool' }, undefined)).decision, (await engine.check({ name: 'custom-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined)) (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
// MCP server tools // MCP server tools
expect( expect(
(await engine.check({ name: 'allowed-server__tool' }, undefined)) (await engine.check({ name: 'mcp_allowed-server_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'trusted-server__tool' }, undefined)) (await engine.check({ name: 'mcp_trusted-server_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'blocked-server__tool' }, undefined)) (await engine.check({ name: 'mcp_blocked-server_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
@@ -479,7 +487,7 @@ describe('Policy Engine Integration Tests', () => {
expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude
const blockedServerRule = rules.find( const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*', (r) => r.toolName === 'mcp_blocked-server_*',
); );
expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude
@@ -489,11 +497,13 @@ describe('Policy Engine Integration Tests', () => {
expect(specificToolRule?.priority).toBe(4.3); // Command line allow expect(specificToolRule?.priority).toBe(4.3); // Command line allow
const trustedServerRule = rules.find( const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*', (r) => r.toolName === 'mcp_trusted-server_*',
); );
expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); const mcpServerRule = rules.find(
(r) => r.toolName === 'mcp_mcp-server_*',
);
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
@@ -505,18 +515,19 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'blocked-tool' }, undefined)).decision, (await engine.check({ name: 'blocked-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
(await engine.check({ name: 'blocked-server__any' }, undefined)) (await engine.check({ name: 'mcp_blocked-server_any' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
(await engine.check({ name: 'specific-tool' }, undefined)).decision, (await engine.check({ name: 'specific-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'trusted-server__any' }, undefined)) (await engine.check({ name: 'mcp_trusted-server_any' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'mcp-server__any' }, undefined)).decision, (await engine.check({ name: 'mcp_mcp-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe( expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.ALLOW, PolicyDecision.ALLOW,
@@ -545,7 +556,7 @@ describe('Policy Engine Integration Tests', () => {
// Exclusion (195) should win over trust (90) // Exclusion (195) should win over trust (90)
expect( expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined)) (await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
}); });
@@ -556,7 +567,7 @@ describe('Policy Engine Integration Tests', () => {
excluded: ['my-server'], // Priority 195 - DENY excluded: ['my-server'], // Priority 195 - DENY
}, },
tools: { tools: {
allowed: ['my-server__special-tool'], // Priority 100 - ALLOW allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW
}, },
}; };
@@ -569,11 +580,11 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100) // Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system // This might be counterintuitive but follows the priority system
expect( expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined)) (await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
(await engine.check({ name: 'my-server__other-tool' }, undefined)) (await engine.check({ name: 'mcp_my-server_other-tool' }, undefined))
.decision, .decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
}); });
@@ -643,13 +654,13 @@ describe('Policy Engine Integration Tests', () => {
const tool3Rule = rules.find((r) => r.toolName === 'tool3'); const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier) expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*'); const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*');
expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier) expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1'); const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier) expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*'); const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*');
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob'); const globRule = rules.find((r) => r.toolName === 'glob');
@@ -17,10 +17,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js'; import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
import { makeFakeConfig } from '../test-utils/config.js'; import { makeFakeConfig } from '../test-utils/config.js';
import { ToolRegistry } from '../tools/tool-registry.js'; import { ToolRegistry } from '../tools/tool-registry.js';
import { import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
DiscoveredMCPTool,
MCP_QUALIFIED_NAME_SEPARATOR,
} from '../tools/mcp-tool.js';
import { LSTool } from '../tools/ls.js'; import { LSTool } from '../tools/ls.js';
import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js'; import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
import { import {
@@ -504,7 +501,7 @@ describe('LocalAgentExecutor', () => {
it('should automatically qualify MCP tools in agent definitions', async () => { it('should automatically qualify MCP tools in agent definitions', async () => {
const serverName = 'mcp-server'; const serverName = 'mcp-server';
const toolName = 'mcp-tool'; const toolName = 'mcp-tool';
const qualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`; const qualifiedName = `mcp_${serverName}_${toolName}`;
const mockMcpTool = { const mockMcpTool = {
tool: vi.fn(), tool: vi.fn(),
@@ -103,7 +103,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool> <tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool> <tool>\`write_file\`</tool>
<tool>\`replace\`</tool> <tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool> <tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools> </available_tools>
## Rules ## Rules
@@ -271,7 +271,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool> <tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool> <tool>\`write_file\`</tool>
<tool>\`replace\`</tool> <tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool> <tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools> </available_tools>
## Rules ## Rules
@@ -558,7 +558,7 @@ The following tools are available in Plan Mode:
<tool>\`exit_plan_mode\`</tool> <tool>\`exit_plan_mode\`</tool>
<tool>\`write_file\`</tool> <tool>\`write_file\`</tool>
<tool>\`replace\`</tool> <tool>\`replace\`</tool>
<tool>\`read_data\` (readonly-server)</tool> <tool>\`mcp_readonly-server_read_data\` (readonly-server)</tool>
</available_tools> </available_tools>
## Rules ## Rules
@@ -709,7 +709,7 @@ describe('estimateContextBreakdown', () => {
{ {
functionDeclarations: [ functionDeclarations: [
{ {
name: 'myserver__search', name: 'mcp_myserver_search',
description: 'Search via MCP', description: 'Search via MCP',
parameters: {}, parameters: {},
}, },
@@ -747,8 +747,7 @@ describe('estimateContextBreakdown', () => {
expect(builtinOnly.mcp_servers).toBe(0); expect(builtinOnly.mcp_servers).toBe(0);
}); });
it('should not classify tools with __ in the middle of a segment as MCP', () => { it('should not classify tools without mcp_ prefix as MCP', () => {
// "__" at start or end (not a valid server__tool pattern) should not be MCP
const config = { const config = {
tools: [ tools: [
{ {
@@ -842,7 +841,7 @@ describe('estimateContextBreakdown', () => {
functionDeclarations: [ functionDeclarations: [
{ name: 'read_file', description: 'Read', parameters: {} }, { name: 'read_file', description: 'Read', parameters: {} },
{ {
name: 'myserver__search', name: 'mcp_myserver_search',
description: 'MCP search', description: 'MCP search',
parameters: {}, parameters: {},
}, },
@@ -858,7 +857,7 @@ describe('estimateContextBreakdown', () => {
expect(result.history).toBeGreaterThan(0); expect(result.history).toBeGreaterThan(0);
// tool_calls should only contain non-MCP tools // tool_calls should only contain non-MCP tools
expect(result.tool_calls['read_file']).toBeGreaterThan(0); expect(result.tool_calls['read_file']).toBeGreaterThan(0);
expect(result.tool_calls['myserver__search']).toBeUndefined(); expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP tokens are only in mcp_servers // MCP tokens are only in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0); expect(result.mcp_servers).toBeGreaterThan(0);
}); });
@@ -870,7 +869,7 @@ describe('estimateContextBreakdown', () => {
parts: [ parts: [
{ {
functionCall: { functionCall: {
name: 'myserver__search', name: 'mcp_myserver_search',
args: { query: 'test' }, args: { query: 'test' },
}, },
}, },
@@ -881,7 +880,7 @@ describe('estimateContextBreakdown', () => {
parts: [ parts: [
{ {
functionResponse: { functionResponse: {
name: 'myserver__search', name: 'mcp_myserver_search',
response: { results: [] }, response: { results: [] },
}, },
}, },
@@ -890,7 +889,7 @@ describe('estimateContextBreakdown', () => {
]; ];
const result = estimateContextBreakdown(contents); const result = estimateContextBreakdown(contents);
// MCP tool calls should NOT appear in tool_calls // MCP tool calls should NOT appear in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined(); expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP call tokens should only be counted in mcp_servers // MCP call tokens should only be counted in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0); expect(result.mcp_servers).toBeGreaterThan(0);
}); });
@@ -908,7 +907,7 @@ describe('estimateContextBreakdown', () => {
}, },
{ {
functionCall: { functionCall: {
name: 'myserver__search', name: 'mcp_myserver_search',
args: { q: 'hello' }, args: { q: 'hello' },
}, },
}, },
@@ -919,7 +918,7 @@ describe('estimateContextBreakdown', () => {
// Non-MCP tools should be in tool_calls // Non-MCP tools should be in tool_calls
expect(result.tool_calls['read_file']).toBeGreaterThan(0); expect(result.tool_calls['read_file']).toBeGreaterThan(0);
// MCP tools should NOT be in tool_calls // MCP tools should NOT be in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined(); expect(result.tool_calls['mcp_myserver_search']).toBeUndefined();
// MCP tool calls should only be in mcp_servers // MCP tool calls should only be in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0); expect(result.mcp_servers).toBeGreaterThan(0);
}); });
+12 -4
View File
@@ -465,9 +465,13 @@ describe('Core System Prompt (prompts.ts)', () => {
const prompt = getCoreSystemPrompt(mockConfig); const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('# Active Approval Mode: Plan'); expect(prompt).toContain('# Active Approval Mode: Plan');
// Read-only MCP tool should appear with server name // Read-only MCP tool should appear with server name
expect(prompt).toContain('`read_data` (readonly-server)'); expect(prompt).toContain(
'`mcp_readonly-server_read_data` (readonly-server)',
);
// Non-read-only MCP tool should not appear (excluded by policy) // Non-read-only MCP tool should not appear (excluded by policy)
expect(prompt).not.toContain('`write_data` (nonreadonly-server)'); expect(prompt).not.toContain(
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
);
expect(prompt).toMatchSnapshot(); expect(prompt).toMatchSnapshot();
}); });
@@ -485,8 +489,12 @@ describe('Core System Prompt (prompts.ts)', () => {
const prompt = getCoreSystemPrompt(mockConfig); const prompt = getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('`read_data` (readonly-server)'); expect(prompt).toContain(
expect(prompt).not.toContain('`write_data` (nonreadonly-server)'); '`mcp_readonly-server_read_data` (readonly-server)',
);
expect(prompt).not.toContain(
'`mcp_nonreadonly-server_write_data` (nonreadonly-server)',
);
}); });
it('should only list available tools in PLAN mode', () => { it('should only list available tools in PLAN mode', () => {
+17 -27
View File
@@ -202,8 +202,7 @@ describe('createPolicyEngineConfig', () => {
'/tmp/mock/default/policies', '/tmp/mock/default/policies',
); );
const rule = config.rules?.find( const rule = config.rules?.find(
(r) => (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
); );
expect(rule).toBeDefined(); expect(rule).toBeDefined();
expect(rule?.priority).toBe(4.1); // MCP allowed server expect(rule?.priority).toBe(4.1); // MCP allowed server
@@ -220,8 +219,7 @@ describe('createPolicyEngineConfig', () => {
'/tmp/mock/default/policies', '/tmp/mock/default/policies',
); );
const rule = config.rules?.find( const rule = config.rules?.find(
(r) => (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
); );
expect(rule).toBeDefined(); expect(rule).toBeDefined();
expect(rule?.priority).toBe(4.9); // MCP excluded server expect(rule?.priority).toBe(4.9); // MCP excluded server
@@ -247,8 +245,7 @@ describe('createPolicyEngineConfig', () => {
const trustedRule = config.rules?.find( const trustedRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'trusted-server__*' && r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
r.decision === PolicyDecision.ALLOW,
); );
expect(trustedRule).toBeDefined(); expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
@@ -256,8 +253,7 @@ describe('createPolicyEngineConfig', () => {
// Untrusted server should not have an allow rule // Untrusted server should not have an allow rule
const untrustedRule = config.rules?.find( const untrustedRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'untrusted-server__*' && r.mcpName === 'untrusted-server' && r.decision === PolicyDecision.ALLOW,
r.decision === PolicyDecision.ALLOW,
); );
expect(untrustedRule).toBeUndefined(); expect(untrustedRule).toBeUndefined();
}); });
@@ -284,8 +280,7 @@ describe('createPolicyEngineConfig', () => {
// Check allowed server // Check allowed server
const allowedRule = config.rules?.find( const allowedRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'allowed-server__*' && r.mcpName === 'allowed-server' && r.decision === PolicyDecision.ALLOW,
r.decision === PolicyDecision.ALLOW,
); );
expect(allowedRule).toBeDefined(); expect(allowedRule).toBeDefined();
expect(allowedRule?.priority).toBe(4.1); // MCP allowed server expect(allowedRule?.priority).toBe(4.1); // MCP allowed server
@@ -293,8 +288,7 @@ describe('createPolicyEngineConfig', () => {
// Check trusted server // Check trusted server
const trustedRule = config.rules?.find( const trustedRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'trusted-server__*' && r.mcpName === 'trusted-server' && r.decision === PolicyDecision.ALLOW,
r.decision === PolicyDecision.ALLOW,
); );
expect(trustedRule).toBeDefined(); expect(trustedRule).toBeDefined();
expect(trustedRule?.priority).toBe(4.2); // MCP trusted server expect(trustedRule?.priority).toBe(4.2); // MCP trusted server
@@ -302,8 +296,7 @@ describe('createPolicyEngineConfig', () => {
// Check excluded server // Check excluded server
const excludedRule = config.rules?.find( const excludedRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'excluded-server__*' && r.mcpName === 'excluded-server' && r.decision === PolicyDecision.DENY,
r.decision === PolicyDecision.DENY,
); );
expect(excludedRule).toBeDefined(); expect(excludedRule).toBeDefined();
expect(excludedRule?.priority).toBe(4.9); // MCP excluded server expect(excludedRule?.priority).toBe(4.9); // MCP excluded server
@@ -368,7 +361,7 @@ describe('createPolicyEngineConfig', () => {
const { createPolicyEngineConfig } = await import('./config.js'); const { createPolicyEngineConfig } = await import('./config.js');
const settings: PolicySettings = { const settings: PolicySettings = {
mcp: { excluded: ['my-server'] }, mcp: { excluded: ['my-server'] },
tools: { allowed: ['my-server__specific-tool'] }, tools: { allowed: ['mcp_my-server_specific-tool'] },
}; };
const config = await createPolicyEngineConfig( const config = await createPolicyEngineConfig(
settings, settings,
@@ -377,12 +370,11 @@ describe('createPolicyEngineConfig', () => {
); );
const serverDenyRule = config.rules?.find( const serverDenyRule = config.rules?.find(
(r) => (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.DENY,
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
); );
const toolAllowRule = config.rules?.find( const toolAllowRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'my-server__specific-tool' && r.toolName === 'mcp_my-server_specific-tool' &&
r.decision === PolicyDecision.ALLOW, r.decision === PolicyDecision.ALLOW,
); );
@@ -404,7 +396,7 @@ describe('createPolicyEngineConfig', () => {
trust: true, trust: true,
}, },
}, },
tools: { exclude: ['my-server__dangerous-tool'] }, tools: { exclude: ['mcp_my-server_dangerous-tool'] },
}; };
const config = await createPolicyEngineConfig( const config = await createPolicyEngineConfig(
settings, settings,
@@ -413,12 +405,11 @@ describe('createPolicyEngineConfig', () => {
); );
const serverAllowRule = config.rules?.find( const serverAllowRule = config.rules?.find(
(r) => (r) => r.mcpName === 'my-server' && r.decision === PolicyDecision.ALLOW,
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
); );
const toolDenyRule = config.rules?.find( const toolDenyRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'my-server__dangerous-tool' && r.toolName === 'mcp_my-server_dangerous-tool' &&
r.decision === PolicyDecision.DENY, r.decision === PolicyDecision.DENY,
); );
@@ -432,8 +423,8 @@ describe('createPolicyEngineConfig', () => {
it('should handle complex priority scenarios correctly', async () => { it('should handle complex priority scenarios correctly', async () => {
const settings: PolicySettings = { const settings: PolicySettings = {
tools: { tools: {
allowed: ['my-server__tool1', 'other-tool'], // Priority 4.3 allowed: ['mcp_trusted-server_tool1', 'other-tool'], // Priority 4.3
exclude: ['my-server__tool2', 'glob'], // Priority 4.4 exclude: ['mcp_trusted-server_tool2', 'glob'], // Priority 4.4
}, },
mcp: { mcp: {
allowed: ['allowed-server'], // Priority 4.1 allowed: ['allowed-server'], // Priority 4.1
@@ -564,13 +555,12 @@ describe('createPolicyEngineConfig', () => {
// Neither server should have an allow rule // Neither server should have an allow rule
const noTrustRule = config.rules?.find( const noTrustRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'no-trust-property__*' && r.mcpName === 'no-trust-property' &&
r.decision === PolicyDecision.ALLOW, r.decision === PolicyDecision.ALLOW,
); );
const explicitFalseRule = config.rules?.find( const explicitFalseRule = config.rules?.find(
(r) => (r) =>
r.toolName === 'explicit-false__*' && r.mcpName === 'explicit-false' && r.decision === PolicyDecision.ALLOW,
r.decision === PolicyDecision.ALLOW,
); );
expect(noTrustRule).toBeUndefined(); expect(noTrustRule).toBeUndefined();
+14 -4
View File
@@ -31,6 +31,7 @@ import { debugLogger } from '../utils/debugLogger.js';
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
import { isNodeError } from '../utils/errors.js'; import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX } from '../tools/mcp-tool.js';
import { isDirectorySecure } from '../utils/security.js'; import { isDirectorySecure } from '../utils/security.js';
@@ -342,7 +343,11 @@ export async function createPolicyEngineConfig(
if (settings.mcp?.excluded) { if (settings.mcp?.excluded) {
for (const serverName of settings.mcp.excluded) { for (const serverName of settings.mcp.excluded) {
rules.push({ rules.push({
toolName: `${serverName}__*`, toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: MCP_EXCLUDED_PRIORITY, priority: MCP_EXCLUDED_PRIORITY,
source: 'Settings (MCP Excluded)', source: 'Settings (MCP Excluded)',
@@ -423,9 +428,10 @@ export async function createPolicyEngineConfig(
)) { )) {
if (serverConfig.trust) { if (serverConfig.trust) {
// Trust all tools from this MCP server // Trust all tools from this MCP server
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName" // Using explicit mcpName metadata and FQN mcp_{serverName}_*
rules.push({ rules.push({
toolName: `${serverName}__*`, toolName: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: TRUSTED_MCP_SERVER_PRIORITY, priority: TRUSTED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Trusted)', source: 'Settings (MCP Trusted)',
@@ -439,7 +445,11 @@ export async function createPolicyEngineConfig(
if (settings.mcp?.allowed) { if (settings.mcp?.allowed) {
for (const serverName of settings.mcp.allowed) { for (const serverName of settings.mcp.allowed) {
rules.push({ rules.push({
toolName: `${serverName}__*`, toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY, priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Allowed)', source: 'Settings (MCP Allowed)',
+213 -129
View File
@@ -150,7 +150,8 @@ describe('PolicyEngine', () => {
it('should match unqualified tool names with qualified rules when serverName is provided', async () => { it('should match unqualified tool names with qualified rules when serverName is provided', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'my-server__tool', toolName: 'mcp_my-server_tool',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
}, },
]; ];
@@ -159,23 +160,9 @@ describe('PolicyEngine', () => {
// Match with qualified name (standard) // Match with qualified name (standard)
expect( expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision, (await engine.check({ name: 'mcp_my-server_tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
// Match with unqualified name + serverName (the fix)
expect((await engine.check({ name: 'tool' }, 'my-server')).decision).toBe(
PolicyDecision.ALLOW,
);
// Should NOT match with unqualified name but NO serverName
expect((await engine.check({ name: 'tool' }, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
// Should NOT match with unqualified name but WRONG serverName
expect(
(await engine.check({ name: 'tool' }, 'wrong-server')).decision,
).toBe(PolicyDecision.ASK_USER);
}); });
it('should match by args pattern', async () => { it('should match by args pattern', async () => {
@@ -476,61 +463,41 @@ describe('PolicyEngine', () => {
(await engine.check({ name: 'read_file' }, undefined)).decision, (await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'my-server__tool' }, 'my-server')).decision, (await engine.check({ name: 'mcp_my-server_tool' }, 'my-server'))
.decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
}); });
it('should match any MCP tool when toolName is *__*', async () => { it('should match any MCP tool when toolName is mcp_*', async () => {
engine = new PolicyEngine({ engine = new PolicyEngine({
rules: [ rules: [
{ toolName: '*__*', decision: PolicyDecision.ALLOW, priority: 10 }, { toolName: 'mcp_*', decision: PolicyDecision.ALLOW, priority: 10 },
], ],
defaultDecision: PolicyDecision.DENY, defaultDecision: PolicyDecision.DENY,
}); });
expect((await engine.check({ name: 'mcp__tool' }, 'mcp')).decision).toBe(
PolicyDecision.ALLOW,
);
expect( expect(
(await engine.check({ name: 'other__tool' }, 'other')).decision, (await engine.check({ name: 'mcp_mcp_tool' }, 'mcp')).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp_other_tool' }, 'other')).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'read_file' }, undefined)).decision, (await engine.check({ name: 'read_file' }, undefined)).decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
}); });
it('should match specific tool across all servers when using *__tool', async () => {
engine = new PolicyEngine({
rules: [
{
toolName: '*__search',
decision: PolicyDecision.ALLOW,
priority: 10,
},
],
defaultDecision: PolicyDecision.DENY,
});
expect((await engine.check({ name: 'ws__search' }, 'ws')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__search' }, 'gh')).decision).toBe(
PolicyDecision.ALLOW,
);
expect((await engine.check({ name: 'gh__list' }, 'gh')).decision).toBe(
PolicyDecision.DENY,
);
});
it('should match MCP server wildcard patterns', async () => { it('should match MCP server wildcard patterns', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'my-server__*', toolName: 'mcp_my-server_*',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: 10, priority: 10,
}, },
{ {
toolName: 'blocked-server__*', toolName: 'mcp_blocked-server_*',
mcpName: 'blocked-server',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 20, priority: 20,
}, },
@@ -540,19 +507,23 @@ describe('PolicyEngine', () => {
// Should match my-server tools // Should match my-server tools
expect( expect(
(await engine.check({ name: 'my-server__tool1' }, 'my-server')) (await engine.check({ name: 'mcp_my-server_tool1' }, 'my-server'))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
expect( expect(
(await engine.check({ name: 'my-server__another_tool' }, 'my-server')) (
.decision, await engine.check(
{ name: 'mcp_my-server_another_tool' },
'my-server',
)
).decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
// Should match blocked-server tools // Should match blocked-server tools
expect( expect(
( (
await engine.check( await engine.check(
{ name: 'blocked-server__tool1' }, { name: 'mcp_blocked-server_tool1' },
'blocked-server', 'blocked-server',
) )
).decision, ).decision,
@@ -560,7 +531,7 @@ describe('PolicyEngine', () => {
expect( expect(
( (
await engine.check( await engine.check(
{ name: 'blocked-server__dangerous' }, { name: 'mcp_blocked-server_dangerous' },
'blocked-server', 'blocked-server',
) )
).decision, ).decision,
@@ -568,7 +539,7 @@ describe('PolicyEngine', () => {
// Should not match other patterns // Should not match other patterns
expect( expect(
(await engine.check({ name: 'other-server__tool' }, 'other-server')) (await engine.check({ name: 'mcp_other-server_tool' }, 'other-server'))
.decision, .decision,
).toBe(PolicyDecision.ASK_USER); ).toBe(PolicyDecision.ASK_USER);
expect( expect(
@@ -582,12 +553,14 @@ describe('PolicyEngine', () => {
it('should prioritize specific tool rules over server wildcards', async () => { it('should prioritize specific tool rules over server wildcards', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'my-server__*', toolName: 'mcp_my-server_*',
mcpName: 'my-server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: 10, priority: 10,
}, },
{ {
toolName: 'my-server__dangerous-tool', toolName: 'mcp_my-server_dangerous-tool',
mcpName: 'my-server',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 20, priority: 20,
}, },
@@ -597,33 +570,38 @@ describe('PolicyEngine', () => {
// Specific tool deny should override server allow // Specific tool deny should override server allow
expect( expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, 'my-server')) (
.decision, await engine.check(
{ name: 'mcp_my-server_dangerous-tool' },
'my-server',
)
).decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
(await engine.check({ name: 'my-server__safe-tool' }, 'my-server')) (await engine.check({ name: 'mcp_my-server_safe-tool' }, 'my-server'))
.decision, .decision,
).toBe(PolicyDecision.ALLOW); ).toBe(PolicyDecision.ALLOW);
}); });
it('should NOT match spoofed server names when using wildcards', async () => { it('should NOT match spoofed server names when using wildcards', async () => {
// Vulnerability: A rule for 'prefix__*' matches 'prefix__suffix__tool' // Vulnerability: A rule for 'mcp_prefix_*' matches 'mcp_prefix__suffix_tool'
// effectively allowing a server named 'prefix__suffix' to spoof 'prefix'. // effectively allowing a server named 'mcp_prefix_suffix' to spoof 'prefix'.
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'safe_server__*', toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
}, },
]; ];
engine = new PolicyEngine({ rules }); engine = new PolicyEngine({ rules });
// A tool from a different server 'safe_server__malicious' // A tool from a different server 'mcp_safe_server_malicious'
const spoofedToolCall = { name: 'safe_server__malicious__tool' }; const spoofedToolCall = { name: 'mcp_mcp_safe_server_malicious_tool' };
// CURRENT BEHAVIOR (FIXED): Matches because it starts with 'safe_server__' BUT serverName doesn't match 'safe_server' // CURRENT BEHAVIOR (FIXED): Matches because it starts with 'safe_server__' BUT serverName doesn't match 'safe_server'
// We expect this to FAIL matching the ALLOW rule, thus falling back to default (ASK_USER) // We expect this to FAIL matching the ALLOW rule, thus falling back to default (ASK_USER)
expect( expect(
(await engine.check(spoofedToolCall, 'safe_server__malicious')) (await engine.check(spoofedToolCall, 'mcp_safe_server_malicious'))
.decision, .decision,
).toBe(PolicyDecision.ASK_USER); ).toBe(PolicyDecision.ASK_USER);
}); });
@@ -631,14 +609,15 @@ describe('PolicyEngine', () => {
it('should verify tool name prefix even if serverName matches', async () => { it('should verify tool name prefix even if serverName matches', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'safe_server__*', toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
}, },
]; ];
engine = new PolicyEngine({ rules }); engine = new PolicyEngine({ rules });
// serverName matches, but tool name does not start with prefix // serverName matches, but tool name does not start with prefix
const invalidToolCall = { name: 'other_server__tool' }; const invalidToolCall = { name: 'mcp_other_server_tool' };
expect( expect(
(await engine.check(invalidToolCall, 'safe_server')).decision, (await engine.check(invalidToolCall, 'safe_server')).decision,
).toBe(PolicyDecision.ASK_USER); ).toBe(PolicyDecision.ASK_USER);
@@ -647,13 +626,14 @@ describe('PolicyEngine', () => {
it('should allow when both serverName and tool name prefix match', async () => { it('should allow when both serverName and tool name prefix match', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ {
toolName: 'safe_server__*', toolName: 'mcp_safe_server_*',
mcpName: 'safe_server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
}, },
]; ];
engine = new PolicyEngine({ rules }); engine = new PolicyEngine({ rules });
const validToolCall = { name: 'safe_server__tool' }; const validToolCall = { name: 'mcp_safe_server_tool' };
expect((await engine.check(validToolCall, 'safe_server')).decision).toBe( expect((await engine.check(validToolCall, 'safe_server')).decision).toBe(
PolicyDecision.ALLOW, PolicyDecision.ALLOW,
); );
@@ -2007,11 +1987,16 @@ describe('PolicyEngine', () => {
it('should support wildcard patterns for checkers', async () => { it('should support wildcard patterns for checkers', async () => {
const rules: PolicyRule[] = [ const rules: PolicyRule[] = [
{ toolName: 'server__tool', decision: PolicyDecision.ALLOW }, {
toolName: 'mcp_server_tool',
mcpName: 'server',
decision: PolicyDecision.ALLOW,
},
]; ];
const wildcardChecker: SafetyCheckerRule = { const wildcardChecker: SafetyCheckerRule = {
checker: { type: 'external', name: 'wildcard' }, checker: { type: 'external', name: 'wildcard' },
toolName: 'server__*', toolName: 'mcp_server_*',
mcpName: 'server',
}; };
engine = new PolicyEngine( engine = new PolicyEngine(
@@ -2023,7 +2008,7 @@ describe('PolicyEngine', () => {
decision: SafetyCheckDecision.ALLOW, decision: SafetyCheckDecision.ALLOW,
}); });
await engine.check({ name: 'server__tool' }, 'server'); await engine.check({ name: 'mcp_server_tool' }, 'server');
expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith( expect(mockCheckerRunner.runChecker).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
@@ -2137,6 +2122,8 @@ describe('PolicyEngine', () => {
rules: PolicyRule[]; rules: PolicyRule[];
approvalMode?: ApprovalMode; approvalMode?: ApprovalMode;
nonInteractive?: boolean; nonInteractive?: boolean;
allToolNames?: string[];
metadata?: Map<string, Record<string, unknown>>;
expected: string[]; expected: string[];
} }
@@ -2144,11 +2131,13 @@ describe('PolicyEngine', () => {
{ {
name: 'should return empty set when no rules provided', name: 'should return empty set when no rules provided',
rules: [], rules: [],
allToolNames: ['tool1'],
expected: [], expected: [],
}, },
{ {
name: 'should apply rules without explicit modes to all modes', name: 'should apply rules without explicit modes to all modes',
rules: [{ toolName: 'tool1', decision: PolicyDecision.DENY }], rules: [{ toolName: 'tool1', decision: PolicyDecision.DENY }],
allToolNames: ['tool1', 'tool2'],
expected: ['tool1'], expected: ['tool1'],
}, },
{ {
@@ -2168,6 +2157,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['tool1'],
expected: [], expected: [],
}, },
{ {
@@ -2184,6 +2174,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['tool1', 'tool2', 'tool3'],
expected: ['tool1'], expected: ['tool1'],
}, },
{ {
@@ -2202,6 +2193,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['tool1'],
expected: ['tool1'], expected: ['tool1'],
}, },
{ {
@@ -2220,6 +2212,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['tool1'],
expected: [], expected: [],
}, },
{ {
@@ -2232,7 +2225,8 @@ describe('PolicyEngine', () => {
}, },
], ],
nonInteractive: true, nonInteractive: true,
expected: [], allToolNames: ['tool1'],
expected: ['tool1'],
}, },
{ {
name: 'should ignore rules with argsPattern', name: 'should ignore rules with argsPattern',
@@ -2244,6 +2238,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['tool1'],
expected: [], expected: [],
}, },
{ {
@@ -2256,6 +2251,7 @@ describe('PolicyEngine', () => {
}, },
], ],
approvalMode: ApprovalMode.PLAN, approvalMode: ApprovalMode.PLAN,
allToolNames: ['tool1'],
expected: ['tool1'], expected: ['tool1'],
}, },
{ {
@@ -2268,6 +2264,7 @@ describe('PolicyEngine', () => {
}, },
], ],
approvalMode: ApprovalMode.DEFAULT, approvalMode: ApprovalMode.DEFAULT,
allToolNames: ['tool1'],
expected: [], expected: [],
}, },
{ {
@@ -2286,36 +2283,55 @@ describe('PolicyEngine', () => {
}, },
], ],
approvalMode: ApprovalMode.YOLO, approvalMode: ApprovalMode.YOLO,
allToolNames: ['dangerous-tool', 'safe-tool'],
expected: [], expected: [],
}, },
{ {
name: 'should respect server wildcard DENY', name: 'should respect server wildcard DENY',
rules: [ rules: [
{ {
toolName: 'server__*', toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
expected: ['server__*'], allToolNames: [
'mcp_server_tool1',
'mcp_server_tool2',
'mcp_other_tool',
],
metadata: new Map([
['mcp_server_tool1', { _serverName: 'server' }],
['mcp_server_tool2', { _serverName: 'server' }],
['mcp_other_tool', { _serverName: 'other' }],
]),
expected: ['mcp_server_tool1', 'mcp_server_tool2'],
}, },
{ {
name: 'should expand server wildcard for specific tools if already processed', name: 'should expand server wildcard for specific tools if already processed',
rules: [ rules: [
{ {
toolName: 'server__*', toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 100, priority: 100,
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
{ {
toolName: 'server__tool1', toolName: 'mcp_server_tool1',
decision: PolicyDecision.DENY, mcpName: 'server',
decision: PolicyDecision.DENY, // redundant but tests ordering
priority: 10, priority: 10,
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
expected: ['server__*', 'server__tool1'], allToolNames: ['mcp_server_tool1', 'mcp_server_tool2'],
metadata: new Map([
['mcp_server_tool1', { _serverName: 'server' }],
['mcp_server_tool2', { _serverName: 'server' }],
]),
expected: ['mcp_server_tool1', 'mcp_server_tool2'],
}, },
{ {
name: 'should exclude run_shell_command but NOT write_file in simulated Plan Mode', name: 'should exclude run_shell_command but NOT write_file in simulated Plan Mode',
@@ -2342,24 +2358,29 @@ describe('PolicyEngine', () => {
priority: 10, priority: 10,
}, },
], ],
expected: ['run_shell_command'], allToolNames: ['write_file', 'run_shell_command', 'read_file'],
expected: ['run_shell_command', 'read_file'],
}, },
{ {
name: 'should NOT exclude tool if covered by a higher priority wildcard ALLOW', name: 'should NOT exclude tool if covered by a higher priority wildcard ALLOW',
rules: [ rules: [
{ {
toolName: 'server__*', toolName: 'mcp_server_*',
mcpName: 'server',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: 100, priority: 100,
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
{ {
toolName: 'server__tool1', toolName: 'mcp_server_tool1',
mcpName: 'server',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 10, priority: 10,
modes: [ApprovalMode.DEFAULT], modes: [ApprovalMode.DEFAULT],
}, },
], ],
allToolNames: ['mcp_server_tool1'],
metadata: new Map([['mcp_server_tool1', { _serverName: 'server' }]]),
expected: [], expected: [],
}, },
{ {
@@ -2371,41 +2392,63 @@ describe('PolicyEngine', () => {
priority: 10, priority: 10,
}, },
], ],
expected: ['*'], allToolNames: ['toolA', 'toolB', 'mcp_server_toolC'],
expected: ['toolA', 'toolB', 'mcp_server_toolC'], // all tools denied by *
}, },
{ {
name: 'should handle MCP category wildcard *__* in getExcludedTools', name: 'should handle MCP category wildcard *__* in getExcludedTools',
rules: [ rules: [
{ {
toolName: '*__*', toolName: 'mcp_*',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 10, priority: 10,
}, },
], ],
expected: ['*__*'], allToolNames: ['localTool', 'mcp_myserver_mytool'],
metadata: new Map([
['mcp_myserver_mytool', { _serverName: 'myserver' }],
]),
expected: ['mcp_myserver_mytool'],
}, },
{ {
name: 'should handle tool wildcard *__search in getExcludedTools', name: 'should handle tool wildcard mcp_server_* in getExcludedTools',
rules: [ rules: [
{ {
toolName: '*__search', toolName: 'mcp_server_*',
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 10, priority: 10,
}, },
], ],
expected: ['*__search'], allToolNames: [
'localTool',
'mcp_server_search',
'mcp_otherserver_read',
],
metadata: new Map([
['mcp_server_search', { _serverName: 'server' }],
['mcp_otherserver_read', { _serverName: 'otherserver' }],
]),
expected: ['mcp_server_search'],
}, },
]; ];
it.each(testCases)( it.each(testCases)(
'$name', '$name',
({ rules, approvalMode, nonInteractive, expected }) => { ({
rules,
approvalMode,
nonInteractive,
allToolNames,
metadata,
expected,
}) => {
engine = new PolicyEngine({ engine = new PolicyEngine({
rules, rules,
approvalMode: approvalMode ?? ApprovalMode.DEFAULT, approvalMode: approvalMode ?? ApprovalMode.DEFAULT,
nonInteractive: nonInteractive ?? false, nonInteractive: nonInteractive ?? false,
}); });
const excluded = engine.getExcludedTools(); const toolsSet = allToolNames ? new Set(allToolNames) : undefined;
const excluded = engine.getExcludedTools(metadata, toolsSet);
expect(Array.from(excluded).sort()).toEqual(expected.sort()); expect(Array.from(excluded).sort()).toEqual(expected.sort());
}, },
); );
@@ -2420,7 +2463,10 @@ describe('PolicyEngine', () => {
}, },
], ],
}); });
const excluded = engine.getExcludedTools(); const excluded = engine.getExcludedTools(
undefined,
new Set(['dangerous_tool']),
);
expect(Array.from(excluded)).toEqual([]); expect(Array.from(excluded)).toEqual([]);
}); });
@@ -2438,7 +2484,10 @@ describe('PolicyEngine', () => {
['dangerous_tool', { destructiveHint: true }], ['dangerous_tool', { destructiveHint: true }],
['safe_tool', { readOnlyHint: true }], ['safe_tool', { readOnlyHint: true }],
]); ]);
const excluded = engine.getExcludedTools(metadata); const excluded = engine.getExcludedTools(
metadata,
new Set(['dangerous_tool', 'safe_tool']),
);
expect(Array.from(excluded)).toEqual(['dangerous_tool']); expect(Array.from(excluded)).toEqual(['dangerous_tool']);
}); });
@@ -2455,7 +2504,10 @@ describe('PolicyEngine', () => {
const metadata = new Map<string, Record<string, unknown>>([ const metadata = new Map<string, Record<string, unknown>>([
['safe_tool', { readOnlyHint: true }], ['safe_tool', { readOnlyHint: true }],
]); ]);
const excluded = engine.getExcludedTools(metadata); const excluded = engine.getExcludedTools(
metadata,
new Set(['safe_tool']),
);
expect(Array.from(excluded)).toEqual([]); expect(Array.from(excluded)).toEqual([]);
}); });
@@ -2463,7 +2515,8 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({ engine = new PolicyEngine({
rules: [ rules: [
{ {
toolName: 'server__*', toolName: 'mcp_server_*',
mcpName: 'server',
toolAnnotations: { destructiveHint: true }, toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 10, priority: 10,
@@ -2471,12 +2524,25 @@ describe('PolicyEngine', () => {
], ],
}); });
const metadata = new Map<string, Record<string, unknown>>([ const metadata = new Map<string, Record<string, unknown>>([
['server__dangerous_tool', { destructiveHint: true }], [
['other__dangerous_tool', { destructiveHint: true }], 'mcp_server_dangerous_tool',
['server__safe_tool', { readOnlyHint: true }], { destructiveHint: true, _serverName: 'server' },
],
[
'mcp_other_dangerous_tool',
{ destructiveHint: true, _serverName: 'other' },
],
['mcp_server_safe_tool', { readOnlyHint: true, _serverName: 'server' }],
]); ]);
const excluded = engine.getExcludedTools(metadata); const excluded = engine.getExcludedTools(
expect(Array.from(excluded)).toEqual(['server__dangerous_tool']); metadata,
new Set([
'mcp_server_dangerous_tool',
'mcp_other_dangerous_tool',
'mcp_server_safe_tool',
]),
);
expect(Array.from(excluded)).toEqual(['mcp_server_dangerous_tool']);
}); });
it('should exclude unprocessed tools from allToolNames when global DENY is active', () => { it('should exclude unprocessed tools from allToolNames when global DENY is active', () => {
@@ -2493,8 +2559,8 @@ describe('PolicyEngine', () => {
priority: 70, priority: 70,
}, },
{ {
// Simulates plan.toml: mcpName="*" → toolName="*__*" // Simulates plan.toml: mcpName="*" → toolName="mcp_*"
toolName: '*__*', toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true }, toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER, decision: PolicyDecision.ASK_USER,
priority: 70, priority: 70,
@@ -2505,36 +2571,42 @@ describe('PolicyEngine', () => {
}, },
], ],
}); });
// MCP tools are registered with unqualified names in ToolRegistry // MCP tools are registered with qualified names in ToolRegistry
const allToolNames = new Set([ const allToolNames = new Set([
'glob', 'glob',
'read_file', 'read_file',
'shell', 'shell',
'web_fetch', 'web_fetch',
'read_mcp_tool', 'mcp_my-server_read_mcp_tool',
'write_mcp_tool', 'mcp_my-server_write_mcp_tool',
]); ]);
// buildToolMetadata() includes _serverName for MCP tools // buildToolMetadata() includes _serverName for MCP tools
const toolMetadata = new Map<string, Record<string, unknown>>([ const toolMetadata = new Map<string, Record<string, unknown>>([
['read_mcp_tool', { readOnlyHint: true, _serverName: 'my-server' }], [
['write_mcp_tool', { readOnlyHint: false, _serverName: 'my-server' }], 'mcp_my-server_read_mcp_tool',
{ readOnlyHint: true, _serverName: 'my-server' },
],
[
'mcp_my-server_write_mcp_tool',
{ readOnlyHint: false, _serverName: 'my-server' },
],
]); ]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames); const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
expect(excluded.has('shell')).toBe(true); expect(excluded.has('shell')).toBe(true);
expect(excluded.has('web_fetch')).toBe(true); expect(excluded.has('web_fetch')).toBe(true);
// Non-read-only MCP tool excluded by catch-all DENY // Non-read-only MCP tool excluded by catch-all DENY
expect(excluded.has('write_mcp_tool')).toBe(true); expect(excluded.has('mcp_my-server_write_mcp_tool')).toBe(true);
expect(excluded.has('glob')).toBe(false); expect(excluded.has('glob')).toBe(false);
expect(excluded.has('read_file')).toBe(false); expect(excluded.has('read_file')).toBe(false);
// Read-only MCP tool allowed by annotation rule // Read-only MCP tool allowed by annotation rule
expect(excluded.has('read_mcp_tool')).toBe(false); expect(excluded.has('mcp_my-server_read_mcp_tool')).toBe(false);
}); });
it('should match already-qualified MCP tool names without _serverName', () => { it('should match MCP wildcard rules when explicitly mapped with _serverName', () => {
engine = new PolicyEngine({ engine = new PolicyEngine({
rules: [ rules: [
{ {
toolName: '*__*', toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true }, toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER, decision: PolicyDecision.ASK_USER,
priority: 70, priority: 70,
@@ -2547,17 +2619,23 @@ describe('PolicyEngine', () => {
}); });
// Tool registered with qualified name (collision case) // Tool registered with qualified name (collision case)
const allToolNames = new Set([ const allToolNames = new Set([
'myserver__read_tool', 'mcp_myserver_read_tool',
'myserver__write_tool', 'mcp_myserver_write_tool',
]); ]);
const toolMetadata = new Map<string, Record<string, unknown>>([ const toolMetadata = new Map<string, Record<string, unknown>>([
['myserver__read_tool', { readOnlyHint: true }], [
['myserver__write_tool', { readOnlyHint: false }], 'mcp_myserver_read_tool',
{ readOnlyHint: true, _serverName: 'myserver' },
],
[
'mcp_myserver_write_tool',
{ readOnlyHint: false, _serverName: 'myserver' },
],
]); ]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames); const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
// Qualified name already contains __, matched directly without _serverName // Qualified name matched using explicit _serverName
expect(excluded.has('myserver__read_tool')).toBe(false); expect(excluded.has('mcp_myserver_read_tool')).toBe(false);
expect(excluded.has('myserver__write_tool')).toBe(true); expect(excluded.has('mcp_myserver_write_tool')).toBe(true);
}); });
it('should not exclude unprocessed tools when allToolNames is not provided (backward compat)', () => { it('should not exclude unprocessed tools when allToolNames is not provided (backward compat)', () => {
@@ -2648,7 +2726,7 @@ describe('PolicyEngine', () => {
modes: [ApprovalMode.PLAN], modes: [ApprovalMode.PLAN],
}, },
{ {
toolName: '*__*', toolName: 'mcp_*',
toolAnnotations: { readOnlyHint: true }, toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ASK_USER, decision: PolicyDecision.ASK_USER,
priority: 70, priority: 70,
@@ -2679,13 +2757,19 @@ describe('PolicyEngine', () => {
'write_todos', 'write_todos',
'memory', 'memory',
'save_memory', 'save_memory',
'read_tool', 'mcp_mcp-server_read_tool',
'write_tool', 'mcp_mcp-server_write_tool',
]); ]);
// buildToolMetadata() includes _serverName for MCP tools // buildToolMetadata() includes _serverName for MCP tools
const toolMetadata = new Map<string, Record<string, unknown>>([ const toolMetadata = new Map<string, Record<string, unknown>>([
['read_tool', { readOnlyHint: true, _serverName: 'mcp-server' }], [
['write_tool', { readOnlyHint: false, _serverName: 'mcp-server' }], 'mcp_mcp-server_read_tool',
{ readOnlyHint: true, _serverName: 'mcp-server' },
],
[
'mcp_mcp-server_write_tool',
{ readOnlyHint: false, _serverName: 'mcp-server' },
],
]); ]);
const excluded = engine.getExcludedTools(toolMetadata, allToolNames); const excluded = engine.getExcludedTools(toolMetadata, allToolNames);
// These should be excluded (caught by catch-all DENY) // These should be excluded (caught by catch-all DENY)
@@ -2698,7 +2782,7 @@ describe('PolicyEngine', () => {
expect(excluded.has('write_file')).toBe(true); expect(excluded.has('write_file')).toBe(true);
expect(excluded.has('replace')).toBe(true); expect(excluded.has('replace')).toBe(true);
// Non-read-only MCP tool excluded by catch-all DENY // Non-read-only MCP tool excluded by catch-all DENY
expect(excluded.has('write_tool')).toBe(true); expect(excluded.has('mcp_mcp-server_write_tool')).toBe(true);
// These should NOT be excluded (explicitly allowed) // These should NOT be excluded (explicitly allowed)
expect(excluded.has('glob')).toBe(false); expect(excluded.has('glob')).toBe(false);
expect(excluded.has('grep_search')).toBe(false); expect(excluded.has('grep_search')).toBe(false);
@@ -2710,7 +2794,7 @@ describe('PolicyEngine', () => {
expect(excluded.has('exit_plan_mode')).toBe(false); expect(excluded.has('exit_plan_mode')).toBe(false);
expect(excluded.has('save_memory')).toBe(false); expect(excluded.has('save_memory')).toBe(false);
// Read-only MCP tool allowed by annotation rule (matched via _serverName) // Read-only MCP tool allowed by annotation rule (matched via _serverName)
expect(excluded.has('read_tool')).toBe(false); expect(excluded.has('mcp_mcp-server_read_tool')).toBe(false);
}); });
}); });
@@ -3058,13 +3142,13 @@ describe('PolicyEngine', () => {
engine = new PolicyEngine({ engine = new PolicyEngine({
rules: [ rules: [
{ {
toolName: '*__*', toolName: 'mcp_*',
toolAnnotations: { experimental: true }, toolAnnotations: { experimental: true },
decision: PolicyDecision.DENY, decision: PolicyDecision.DENY,
priority: 20, priority: 20,
}, },
{ {
toolName: '*__*', toolName: 'mcp_*',
decision: PolicyDecision.ALLOW, decision: PolicyDecision.ALLOW,
priority: 10, priority: 10,
}, },
@@ -3073,14 +3157,14 @@ describe('PolicyEngine', () => {
expect( expect(
( (
await engine.check({ name: 'mcp__test' }, 'mcp', { await engine.check({ name: 'mcp_mcp_test' }, 'mcp', {
experimental: true, experimental: true,
}) })
).decision, ).decision,
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
expect( expect(
( (
await engine.check({ name: 'mcp__stable' }, 'mcp', { await engine.check({ name: 'mcp_mcp_stable' }, 'mcp', {
experimental: false, experimental: false,
}) })
).decision, ).decision,
+95 -181
View File
@@ -25,6 +25,11 @@ import {
hasRedirection, hasRedirection,
} from '../utils/shell-utils.js'; } from '../utils/shell-utils.js';
import { getToolAliases } from '../tools/tool-names.js'; import { getToolAliases } from '../tools/tool-names.js';
import {
MCP_TOOL_PREFIX,
isMcpToolAnnotation,
parseMcpToolName,
} from '../tools/mcp-tool.js';
function isWildcardPattern(name: string): boolean { function isWildcardPattern(name: string): boolean {
return name === '*' || name.includes('*'); return name === '*' || name.includes('*');
@@ -32,7 +37,7 @@ function isWildcardPattern(name: string): boolean {
/** /**
* Checks if a tool call matches a wildcard pattern. * Checks if a tool call matches a wildcard pattern.
* Supports global (*) and composite (server__*, *__tool, *__*) patterns. * Supports global (*) and the explicit MCP (*mcp_serverName_**) format.
*/ */
function matchesWildcard( function matchesWildcard(
pattern: string, pattern: string,
@@ -43,59 +48,25 @@ function matchesWildcard(
return true; return true;
} }
if (pattern.includes('__')) { if (pattern === `${MCP_TOOL_PREFIX}*`) {
return matchesCompositePattern(pattern, toolName, serverName); return serverName !== undefined;
} }
if (pattern.startsWith(MCP_TOOL_PREFIX) && pattern.endsWith('_*')) {
const expectedServerName = pattern.slice(MCP_TOOL_PREFIX.length, -2);
// 1. Must be an MCP tool call (has serverName)
// 2. Server name must match
// 3. Tool name must be properly qualified by that server
if (serverName === undefined || serverName !== expectedServerName) {
return false;
}
return toolName.startsWith(`${MCP_TOOL_PREFIX}${expectedServerName}_`);
}
// Not a recognized wildcard pattern, fallback to exact match just in case
return toolName === pattern; return toolName === pattern;
} }
/**
* Matches composite patterns like "server__*", "*__tool", or "*__*".
*/
function matchesCompositePattern(
pattern: string,
toolName: string,
serverName: string | undefined,
): boolean {
const parts = pattern.split('__');
if (parts.length !== 2) return false;
const [patternServer, patternTool] = parts;
// 1. Identify the tool's components
const { actualServer, actualTool } = getToolMetadata(toolName, serverName);
// 2. Composite patterns require a server context
if (actualServer === undefined) {
return false;
}
// 3. Robustness: if serverName is provided, toolName MUST be qualified by it.
// This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious".
if (serverName !== undefined && !toolName.startsWith(serverName + '__')) {
return false;
}
// 4. Match components
const serverMatch = patternServer === '*' || patternServer === actualServer;
const toolMatch = patternTool === '*' || patternTool === actualTool;
return serverMatch && toolMatch;
}
/**
* Extracts the server and unqualified tool name from a tool call context.
*/
function getToolMetadata(toolName: string, serverName: string | undefined) {
const sepIndex = toolName.indexOf('__');
const isQualified = sepIndex !== -1;
return {
actualServer:
serverName ?? (isQualified ? toolName.substring(0, sepIndex) : undefined),
actualTool: isQualified ? toolName.substring(sepIndex + 2) : toolName,
};
}
function ruleMatches( function ruleMatches(
rule: PolicyRule | SafetyCheckerRule, rule: PolicyRule | SafetyCheckerRule,
toolCall: FunctionCall, toolCall: FunctionCall,
@@ -111,9 +82,20 @@ function ruleMatches(
} }
} }
// Strictly enforce mcpName identity if the rule dictates it
if (rule.mcpName) {
if (rule.mcpName === '*') {
// Rule requires it to be ANY MCP tool
if (serverName === undefined) return false;
} else {
// Rule requires it to be a specific MCP server
if (serverName !== rule.mcpName) return false;
}
}
// Check tool name if specified // Check tool name if specified
if (rule.toolName) { if (rule.toolName) {
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool" // Support wildcard patterns: "mcp_serverName_*" matches "mcp_serverName_anyTool"
if (rule.toolName === '*') { if (rule.toolName === '*') {
// Match all tools // Match all tools
} else if (isWildcardPattern(rule.toolName)) { } else if (isWildcardPattern(rule.toolName)) {
@@ -371,6 +353,22 @@ export class PolicyEngine {
serverName: string | undefined, serverName: string | undefined,
toolAnnotations?: Record<string, unknown>, toolAnnotations?: Record<string, unknown>,
): Promise<CheckResult> { ): Promise<CheckResult> {
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
if (!serverName && isMcpToolAnnotation(toolAnnotations)) {
serverName = toolAnnotations._serverName;
}
// Case 2: Fallback for static FQN strings (e.g. from TOML policies or allowed/excluded settings strings).
// These strings don't have active metadata objects associated with them during policy generation,
// so we must extract the server name from the qualified `mcp_{server}_{tool}` format.
if (!serverName && toolCall.name) {
const parsed = parseMcpToolName(toolCall.name);
if (parsed.serverName) {
serverName = parsed.serverName;
}
}
let stringifiedArgs: string | undefined; let stringifiedArgs: string | undefined;
// Compute stringified args once before the loop // Compute stringified args once before the loop
if ( if (
@@ -404,20 +402,12 @@ export class PolicyEngine {
let matchedRule: PolicyRule | undefined; let matchedRule: PolicyRule | undefined;
let decision: PolicyDecision | undefined; let decision: PolicyDecision | undefined;
// For tools with a server name, we want to try matching both the
// original name and the fully qualified name (server__tool).
// We also want to check legacy aliases for the tool name. // We also want to check legacy aliases for the tool name.
const toolNamesToTry = toolCall.name ? getToolAliases(toolCall.name) : []; const toolNamesToTry = toolCall.name ? getToolAliases(toolCall.name) : [];
const toolCallsToTry: FunctionCall[] = []; const toolCallsToTry: FunctionCall[] = [];
for (const name of toolNamesToTry) { for (const name of toolNamesToTry) {
toolCallsToTry.push({ ...toolCall, name }); toolCallsToTry.push({ ...toolCall, name });
if (serverName && !name.includes('__')) {
toolCallsToTry.push({
...toolCall,
name: `${serverName}__${name}`,
});
}
} }
for (const rule of this.rules) { for (const rule of this.rules) {
@@ -654,147 +644,71 @@ export class PolicyEngine {
allToolNames?: Set<string>, allToolNames?: Set<string>,
): Set<string> { ): Set<string> {
const excludedTools = new Set<string>(); const excludedTools = new Set<string>();
const processedTools = new Set<string>();
let globalVerdict: PolicyDecision | undefined;
if (!allToolNames) {
return excludedTools;
}
for (const toolName of allToolNames) {
const annotations = toolMetadata?.get(toolName);
const serverName = isMcpToolAnnotation(annotations)
? annotations._serverName
: undefined;
let staticallyExcluded = false;
let matchFound = false;
// Evaluate rules in priority order (they are already sorted in constructor)
for (const rule of this.rules) { for (const rule of this.rules) {
// Create a copy of the rule without argsPattern to see if it targets the tool
// regardless of the runtime arguments it might receive.
const ruleWithoutArgs: PolicyRule = { ...rule, argsPattern: undefined };
const toolCall: FunctionCall = { name: toolName, args: {} };
const appliesToTool = ruleMatches(
ruleWithoutArgs,
toolCall,
undefined, // stringifiedArgs
serverName,
this.approvalMode,
annotations,
);
if (appliesToTool) {
if (rule.argsPattern) { if (rule.argsPattern) {
if (rule.toolName && rule.decision !== PolicyDecision.DENY) { // Exclusions only apply statically before arguments are known.
processedTools.add(rule.toolName); if (rule.decision !== PolicyDecision.DENY) {
} // Conditionally allowed/asked based on args. Therefore NOT statically excluded.
continue; staticallyExcluded = false;
} matchFound = true;
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
if (!rule.modes.includes(this.approvalMode)) {
continue;
}
}
// Handle annotation-based rules
if (rule.toolAnnotations) {
if (!toolMetadata) {
// Without metadata, we can't evaluate annotation rules — skip (conservative fallback)
continue;
}
// Iterate over all known tools and check if their annotations match this rule
for (const [toolName, annotations] of toolMetadata) {
if (processedTools.has(toolName)) {
continue;
}
// Check if annotations match the rule's toolAnnotations (partial match)
let annotationsMatch = true;
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
if (annotations[key] !== value) {
annotationsMatch = false;
break; break;
} }
} // If it's conditionally DENIED based on args, it means it's not unconditionally denied.
if (!annotationsMatch) { // We must keep evaluating lower priority rules to see the default/unconditional state.
continue; continue;
}
// Check if the tool name matches the rule's toolName pattern (if any)
if (rule.toolName) {
if (isWildcardPattern(rule.toolName)) {
// For composite patterns (e.g. "*__*"), construct a qualified
// name from metadata so matchesWildcard can resolve it.
const rawServerName = annotations['_serverName'];
const serverName =
typeof rawServerName === 'string' ? rawServerName : undefined;
const qualifiedName =
serverName && !toolName.includes('__')
? `${serverName}__${toolName}`
: toolName;
if (!matchesWildcard(rule.toolName, qualifiedName, undefined)) {
continue;
}
} else if (toolName !== rule.toolName) {
continue;
}
}
// Determine decision considering global verdict
let decision: PolicyDecision;
if (globalVerdict !== undefined) {
decision = globalVerdict;
} else { } else {
decision = rule.decision; // Unconditional rule for this tool
} const decision = this.applyNonInteractiveMode(rule.decision);
if (decision === PolicyDecision.DENY) { staticallyExcluded = decision === PolicyDecision.DENY;
excludedTools.add(toolName); matchFound = true;
}
processedTools.add(toolName);
}
continue;
}
// Handle Global Rules
if (!rule.toolName) {
if (globalVerdict === undefined) {
globalVerdict = rule.decision;
if (globalVerdict !== PolicyDecision.DENY) {
// Global ALLOW/ASK found.
// Since rules are sorted by priority, this overrides any lower-priority rules.
// We can stop processing because nothing else will be excluded.
break;
}
// If Global DENY, we continue to find specific tools to add to excluded set
}
continue;
}
const toolName = rule.toolName;
// Check if already processed (exact match)
if (processedTools.has(toolName)) {
continue;
}
// Check if covered by a processed wildcard
let coveredByWildcard = false;
for (const processed of processedTools) {
if (
isWildcardPattern(processed) &&
matchesWildcard(processed, toolName, undefined)
) {
// It's covered by a higher-priority wildcard rule.
// If that wildcard rule resulted in exclusion, this tool should also be excluded.
if (excludedTools.has(processed)) {
excludedTools.add(toolName);
}
coveredByWildcard = true;
break; break;
} }
} }
if (coveredByWildcard) {
continue;
} }
processedTools.add(toolName); if (!matchFound) {
// Fallback to default decision if no rule matches
// Determine decision const defaultDec = this.applyNonInteractiveMode(this.defaultDecision);
let decision: PolicyDecision; if (defaultDec === PolicyDecision.DENY) {
if (globalVerdict !== undefined) { staticallyExcluded = true;
decision = globalVerdict; }
} else {
decision = rule.decision;
} }
if (decision === PolicyDecision.DENY) { if (staticallyExcluded) {
excludedTools.add(toolName); excludedTools.add(toolName);
} }
} }
// If there's a global DENY and we know all tool names, exclude any tool
// that wasn't explicitly allowed by a higher-priority rule.
if (globalVerdict === PolicyDecision.DENY && allToolNames) {
for (const name of allToolNames) {
if (!processedTools.has(name)) {
excludedTools.add(name);
}
}
}
return excludedTools; return excludedTools;
} }
+40 -18
View File
@@ -129,7 +129,7 @@ priority = 10
`); `);
expect(result.rules).toHaveLength(1); expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__*'); expect(result.rules[0].toolName).toBe('mcp_*');
expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER); expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER);
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
}); });
@@ -144,7 +144,7 @@ priority = 10
`); `);
expect(result.rules).toHaveLength(1); expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('*__search'); expect(result.rules[0].toolName).toBe('mcp_*_search');
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
}); });
@@ -215,8 +215,12 @@ priority = 100
`); `);
expect(result.rules).toHaveLength(2); expect(result.rules).toHaveLength(2);
expect(result.rules[0].toolName).toBe('google-workspace__calendar.list'); expect(result.rules[0].toolName).toBe(
expect(result.rules[1].toolName).toBe('google-workspace__calendar.get'); 'mcp_google-workspace_calendar.list',
);
expect(result.rules[1].toolName).toBe(
'mcp_google-workspace_calendar.get',
);
expect(result.errors).toHaveLength(0); expect(result.errors).toHaveLength(0);
}); });
@@ -678,12 +682,12 @@ priority = 100
it('should not warn for MCP format tool names', async () => { it('should not warn for MCP format tool names', async () => {
const result = await runLoadPoliciesFromToml(` const result = await runLoadPoliciesFromToml(`
[[rule]] [[rule]]
toolName = "my-server__my-tool" toolName = "mcp_my-server_my-tool"
decision = "allow" decision = "allow"
priority = 100 priority = 100
[[rule]] [[rule]]
toolName = "my-server__*" toolName = "mcp_my-server_*"
decision = "allow" decision = "allow"
priority = 100 priority = 100
`); `);
@@ -822,7 +826,7 @@ priority = 100
annotationRule, annotationRule,
'Should have loaded a rule with toolAnnotations', 'Should have loaded a rule with toolAnnotations',
).toBeDefined(); ).toBeDefined();
expect(annotationRule!.toolName).toBe('*__*'); expect(annotationRule!.toolName).toBe('mcp_*');
expect(annotationRule!.toolAnnotations).toEqual({ expect(annotationRule!.toolAnnotations).toEqual({
readOnlyHint: true, readOnlyHint: true,
}); });
@@ -863,7 +867,7 @@ priority = 100
// 4. MCP tool WITHOUT annotations should be DENIED // 4. MCP tool WITHOUT annotations should be DENIED
const denyResult = await engine.check( const denyResult = await engine.check(
{ name: 'github__create_issue' }, { name: 'mcp_github_create_issue' },
'github', 'github',
undefined, undefined,
); );
@@ -874,7 +878,7 @@ priority = 100
// 5. MCP tool with readOnlyHint=false should also be DENIED // 5. MCP tool with readOnlyHint=false should also be DENIED
const denyResult2 = await engine.check( const denyResult2 = await engine.check(
{ name: 'github__delete_issue' }, { name: 'mcp_github_delete_issue' },
'github', 'github',
{ readOnlyHint: false }, { readOnlyHint: false },
); );
@@ -883,9 +887,9 @@ priority = 100
'MCP tool with readOnlyHint=false should be DENIED in Plan Mode', 'MCP tool with readOnlyHint=false should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY); ).toBe(PolicyDecision.DENY);
// 6. Test with qualified tool name format (server__tool) but no separate serverName // 6. Test with qualified tool name format (mcp_server_tool) but no separate serverName
const qualifiedResult = await engine.check( const qualifiedResult = await engine.check(
{ name: 'github__list_repos' }, { name: 'mcp_github_list_repos' },
undefined, undefined,
{ readOnlyHint: true }, { readOnlyHint: true },
); );
@@ -990,7 +994,8 @@ priority = 100
['people.getMe', 'calendar.list', 'calendar.get'], ['people.getMe', 'calendar.list', 'calendar.get'],
[ [
{ {
toolName: 'google-workspace__people.getxMe', toolName: 'mcp_google-workspace_people.getxMe',
mcpName: 'google-workspace',
source: 'User: workspace.toml', source: 'User: workspace.toml',
}, },
], ],
@@ -1007,8 +1012,14 @@ priority = 100
'google-workspace', 'google-workspace',
['people.getMe', 'calendar.list'], ['people.getMe', 'calendar.list'],
[ [
{ toolName: 'google-workspace__people.getMe' }, {
{ toolName: 'google-workspace__calendar.list' }, toolName: 'mcp_google-workspace_people.getMe',
mcpName: 'google-workspace',
},
{
toolName: 'mcp_google-workspace_calendar.list',
mcpName: 'google-workspace',
},
], ],
); );
@@ -1019,7 +1030,7 @@ priority = 100
const warnings = validateMcpPolicyToolNames( const warnings = validateMcpPolicyToolNames(
'my-server', 'my-server',
['tool1', 'tool2'], ['tool1', 'tool2'],
[{ toolName: 'my-server__*' }], [{ toolName: 'mcp_my-server_*', mcpName: 'my-server' }],
); );
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -1029,7 +1040,7 @@ priority = 100
const warnings = validateMcpPolicyToolNames( const warnings = validateMcpPolicyToolNames(
'server-a', 'server-a',
['tool1'], ['tool1'],
[{ toolName: 'server-b__toolx' }], [{ toolName: 'mcp_server-b_toolx', mcpName: 'server-b' }],
); );
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -1039,7 +1050,12 @@ priority = 100
const warnings = validateMcpPolicyToolNames( const warnings = validateMcpPolicyToolNames(
'my-server', 'my-server',
['tool1', 'tool2'], ['tool1', 'tool2'],
[{ toolName: 'my-server__completely_different_name' }], [
{
toolName: 'mcp_my-server_completely_different_name',
mcpName: 'my-server',
},
],
); );
expect(warnings).toHaveLength(0); expect(warnings).toHaveLength(0);
@@ -1059,7 +1075,13 @@ priority = 100
const warnings = validateMcpPolicyToolNames( const warnings = validateMcpPolicyToolNames(
'my-server', 'my-server',
['tool1'], ['tool1'],
[{ toolName: 'my-server__tol1', source: 'User: custom.toml' }], [
{
toolName: 'mcp_my-server_tol1',
mcpName: 'my-server',
source: 'User: custom.toml',
},
],
); );
expect(warnings).toHaveLength(1); expect(warnings).toHaveLength(1);
+36 -17
View File
@@ -24,6 +24,7 @@ import path from 'node:path';
import toml from '@iarna/toml'; import toml from '@iarna/toml';
import { z, type ZodError } from 'zod'; import { z, type ZodError } from 'zod';
import { isNodeError } from '../utils/errors.js'; import { isNodeError } from '../utils/errors.js';
import { MCP_TOOL_PREFIX, formatMcpToolName } from '../tools/mcp-tool.js';
/** /**
* Maximum Levenshtein distance to consider a name a likely typo of a built-in tool. * Maximum Levenshtein distance to consider a name a likely typo of a built-in tool.
@@ -262,11 +263,15 @@ function validateShellCommandSyntax(
* tool name, or null if valid or not close to any built-in name. * tool name, or null if valid or not close to any built-in name.
*/ */
function validateToolName(name: string, ruleIndex: number): string | null { function validateToolName(name: string, ruleIndex: number): string | null {
if (name.includes('__')) {
return `Rule #${ruleIndex + 1}: The "__" syntax for MCP tools is strictly deprecated. Please use the 'mcpName = "..."' property or the 'mcp_server_tool' format instead.`;
}
// A name that looks like an MCP tool (e.g., "re__ad") could be a typo of a // A name that looks like an MCP tool (e.g., "re__ad") could be a typo of a
// built-in tool ("read_file"). We should let such names fall through to the // built-in tool ("read_file"). We should let such names fall through to the
// Levenshtein distance check below. Non-MCP-like names that are valid can // Levenshtein distance check below. Non-MCP-like names that are valid can
// be safely skipped. // be safely skipped.
if (isValidToolName(name, { allowWildcards: true }) && !name.includes('__')) { if (isValidToolName(name, { allowWildcards: true })) {
return null; return null;
} }
@@ -402,8 +407,8 @@ export async function loadPoliciesFromToml(
// Validate tool names in rules // Validate tool names in rules
for (let i = 0; i < tomlRules.length; i++) { for (let i = 0; i < tomlRules.length; i++) {
const rule = tomlRules[i]; const rule = tomlRules[i];
// Skip MCP-scoped rules — MCP tool names are server-defined and dynamic // We no longer skip MCP-scoped rules because we need to specifically
if (rule.mcpName) continue; // warn users if they use deprecated "__" syntax for MCP tool names
const toolNames: string[] = rule.toolName const toolNames: string[] = rule.toolName
? Array.isArray(rule.toolName) ? Array.isArray(rule.toolName)
@@ -447,18 +452,19 @@ export async function loadPoliciesFromToml(
// Create a policy rule for each tool name // Create a policy rule for each tool name
return toolNames.map((toolName) => { return toolNames.map((toolName) => {
// Transform mcpName field to composite toolName format let effectiveToolName: string | undefined = toolName;
let effectiveToolName: string | undefined; const mcpName = rule.mcpName;
if (rule.mcpName && toolName) {
effectiveToolName = `${rule.mcpName}__${toolName}`; if (mcpName) {
} else if (rule.mcpName) { effectiveToolName = formatMcpToolName(
effectiveToolName = `${rule.mcpName}__*`; mcpName,
} else { effectiveToolName,
effectiveToolName = toolName; );
} }
const policyRule: PolicyRule = { const policyRule: PolicyRule = {
toolName: effectiveToolName, toolName: effectiveToolName,
mcpName: rule.mcpName,
decision: rule.decision, decision: rule.decision,
priority: transformPriority(rule.priority, tier), priority: transformPriority(rule.priority, tier),
modes: rule.modes, modes: rule.modes,
@@ -563,15 +569,16 @@ export async function loadPoliciesFromToml(
return toolNames.map((toolName) => { return toolNames.map((toolName) => {
let effectiveToolName: string | undefined; let effectiveToolName: string | undefined;
if (checker.mcpName && toolName) { if (checker.mcpName && toolName) {
effectiveToolName = `${checker.mcpName}__${toolName}`; effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_${toolName}`;
} else if (checker.mcpName) { } else if (checker.mcpName) {
effectiveToolName = `${checker.mcpName}__*`; effectiveToolName = `${MCP_TOOL_PREFIX}${checker.mcpName}_*`;
} else { } else {
effectiveToolName = toolName; effectiveToolName = toolName;
} }
const safetyCheckerRule: SafetyCheckerRule = { const safetyCheckerRule: SafetyCheckerRule = {
toolName: effectiveToolName, toolName: effectiveToolName,
mcpName: checker.mcpName,
priority: transformPriority(checker.priority, tier), priority: transformPriority(checker.priority, tier),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
checker: checker.checker as SafetyCheckerConfig, checker: checker.checker as SafetyCheckerConfig,
@@ -655,16 +662,28 @@ export async function loadPoliciesFromToml(
export function validateMcpPolicyToolNames( export function validateMcpPolicyToolNames(
serverName: string, serverName: string,
discoveredToolNames: string[], discoveredToolNames: string[],
policyRules: ReadonlyArray<{ toolName?: string; source?: string }>, policyRules: ReadonlyArray<{
toolName?: string;
mcpName?: string;
source?: string;
}>,
): string[] { ): string[] {
const prefix = `${serverName}__`; const prefix = `${MCP_TOOL_PREFIX}${serverName}_`;
const warnings: string[] = []; const warnings: string[] = [];
for (const rule of policyRules) { for (const rule of policyRules) {
if (!rule.toolName) continue; if (!rule.toolName) continue;
if (!rule.toolName.startsWith(prefix)) continue;
const toolPart = rule.toolName.slice(prefix.length); let toolPart: string | undefined;
// The toolName is typically transformed into an FQN if mcpName was used.
if (rule.mcpName === serverName && rule.toolName.startsWith(prefix)) {
toolPart = rule.toolName.slice(prefix.length);
} else if (rule.toolName.startsWith(prefix)) {
toolPart = rule.toolName.slice(prefix.length);
} else {
continue;
}
// Skip wildcards // Skip wildcards
if (toolPart === '*') continue; if (toolPart === '*') continue;
+12
View File
@@ -110,6 +110,13 @@ export interface PolicyRule {
*/ */
toolName?: string; toolName?: string;
/**
* Identifies the MCP server this rule applies to.
* Enables precise rule matching against `serverName` metadata instead
* of parsing composite string names.
*/
mcpName?: string;
/** /**
* Pattern to match against tool arguments. * Pattern to match against tool arguments.
* Can be used for more fine-grained control. * Can be used for more fine-grained control.
@@ -166,6 +173,11 @@ export interface SafetyCheckerRule {
*/ */
toolName?: string; toolName?: string;
/**
* Identifies the MCP server this rule applies to.
*/
mcpName?: string;
/** /**
* Pattern to match against tool arguments. * Pattern to match against tool arguments.
* Can be used for more fine-grained control. * Can be used for more fine-grained control.
@@ -153,7 +153,7 @@ describe('PromptProvider', () => {
const provider = new PromptProvider(); const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig); const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('`mcp_read` (my-mcp-server)'); expect(prompt).toContain('`mcp_my-mcp-server_mcp_read` (my-mcp-server)');
}); });
it('should include write constraint message in plan mode prompt', () => { it('should include write constraint message in plan mode prompt', () => {
+51 -18
View File
@@ -8,9 +8,12 @@
import type { Mocked } from 'vitest'; import type { Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { DiscoveredMCPTool, generateValidName } from './mcp-tool.js'; // Added getStringifiedResultForDisplay import {
import type { ToolResult } from './tools.js'; DiscoveredMCPTool,
import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome generateValidName,
formatMcpToolName,
} from './mcp-tool.js'; // Added getStringifiedResultForDisplay
import { ToolConfirmationOutcome, type ToolResult } from './tools.js';
import type { CallableTool, Part } from '@google/genai'; import type { CallableTool, Part } from '@google/genai';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import { import {
@@ -43,23 +46,23 @@ const createSdkResponse = (
describe('generateValidName', () => { describe('generateValidName', () => {
it('should return a valid name for a simple function', () => { it('should return a valid name for a simple function', () => {
expect(generateValidName('myFunction')).toBe('myFunction'); expect(generateValidName('myFunction')).toBe('mcp_myFunction');
}); });
it('should replace invalid characters with underscores', () => { it('should replace invalid characters with underscores', () => {
expect(generateValidName('invalid-name with spaces')).toBe( expect(generateValidName('invalid-name with spaces')).toBe(
'invalid-name_with_spaces', 'mcp_invalid-name_with_spaces',
); );
}); });
it('should truncate long names', () => { it('should truncate long names', () => {
expect(generateValidName('x'.repeat(80))).toBe( expect(generateValidName('x'.repeat(80))).toBe(
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'mcp_xxxxxxxxxxxxxxxxxxxxxxxxxx...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
); );
}); });
it('should handle names with only invalid characters', () => { it('should handle names with only invalid characters', () => {
expect(generateValidName('!@#$%^&*()')).toBe('__________'); expect(generateValidName('!@#$%^&*()')).toBe('mcp___________');
}); });
it.each([ it.each([
@@ -74,6 +77,30 @@ describe('generateValidName', () => {
); );
}); });
describe('formatMcpToolName', () => {
it('should format a fully qualified name', () => {
expect(formatMcpToolName('github', 'list_repos')).toBe(
'mcp_github_list_repos',
);
});
it('should handle global wildcards', () => {
expect(formatMcpToolName('*')).toBe('mcp_*');
});
it('should handle tool-level wildcards', () => {
expect(formatMcpToolName('github', '*')).toBe('mcp_github_*');
});
it('should handle undefined toolName as a tool-level wildcard', () => {
expect(formatMcpToolName('github')).toBe('mcp_github_*');
});
it('should format explicitly global wildcard with specific tool', () => {
expect(formatMcpToolName('*', 'list_repos')).toBe('mcp_*_list_repos');
});
});
describe('DiscoveredMCPTool', () => { describe('DiscoveredMCPTool', () => {
const serverName = 'mock-mcp-server'; const serverName = 'mock-mcp-server';
const serverToolName = 'actual-server-tool-name'; const serverToolName = 'actual-server-tool-name';
@@ -110,8 +137,10 @@ describe('DiscoveredMCPTool', () => {
describe('constructor', () => { describe('constructor', () => {
it('should set properties correctly', () => { it('should set properties correctly', () => {
expect(tool.name).toBe(serverToolName); expect(tool.name).toBe('mcp_mock-mcp-server_actual-server-tool-name');
expect(tool.schema.name).toBe(serverToolName); expect(tool.schema.name).toBe(
'mcp_mock-mcp-server_actual-server-tool-name',
);
expect(tool.schema.description).toBe(baseDescription); expect(tool.schema.description).toBe(baseDescription);
expect(tool.schema.parameters).toBeUndefined(); expect(tool.schema.parameters).toBeUndefined();
expect(tool.schema.parametersJsonSchema).toEqual(inputSchema); expect(tool.schema.parametersJsonSchema).toEqual(inputSchema);
@@ -937,31 +966,35 @@ describe('DiscoveredMCPTool', () => {
describe('MCP Tool Naming Regression Fixes', () => { describe('MCP Tool Naming Regression Fixes', () => {
describe('generateValidName', () => { describe('generateValidName', () => {
it('should replace spaces with underscores', () => { it('should replace spaces with underscores', () => {
expect(generateValidName('My Tool')).toBe('My_Tool'); expect(generateValidName('My Tool')).toBe('mcp_My_Tool');
}); });
it('should allow colons', () => { it('should allow colons', () => {
expect(generateValidName('namespace:tool')).toBe('namespace:tool'); expect(generateValidName('namespace:tool')).toBe('mcp_namespace:tool');
}); });
it('should ensure name starts with a letter or underscore', () => { it('should ensure name starts with a letter or underscore', () => {
expect(generateValidName('123-tool')).toBe('_123-tool'); expect(generateValidName('valid_tool_name')).toBe('mcp_valid_tool_name');
expect(generateValidName('-tool')).toBe('_-tool'); expect(generateValidName('alsoValid-123.name')).toBe(
expect(generateValidName('.tool')).toBe('_.tool'); 'mcp_alsoValid-123.name',
);
expect(generateValidName('another:valid:name')).toBe(
'mcp_another:valid:name',
);
}); });
it('should handle very long names by truncating in the middle', () => { it('should handle very long names by truncating in the middle', () => {
const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40); const longName = 'a'.repeat(40) + '__' + 'b'.repeat(40);
const result = generateValidName(longName); const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63); expect(result.length).toBeLessThanOrEqual(63);
expect(result).toMatch(/^a{30}\.\.\.b{30}$/); expect(result).toMatch(/^mcp_a{26}\.\.\.b{30}$/);
}); });
it('should handle very long names starting with a digit', () => { it('should handle very long names starting with a digit', () => {
const longName = '1' + 'a'.repeat(80); const longName = '1' + 'a'.repeat(80);
const result = generateValidName(longName); const result = generateValidName(longName);
expect(result.length).toBeLessThanOrEqual(63); expect(result.length).toBeLessThanOrEqual(63);
expect(result.startsWith('_1')).toBe(true); expect(result.startsWith('mcp_1')).toBe(true);
}); });
}); });
@@ -977,7 +1010,7 @@ describe('MCP Tool Naming Regression Fixes', () => {
); );
const qn = tool.getFullyQualifiedName(); const qn = tool.getFullyQualifiedName();
expect(qn).toBe('My_Server__my-tool'); expect(qn).toBe('mcp_My_Server_my-tool');
}); });
it('should handle long server and tool names in qualified name', () => { it('should handle long server and tool names in qualified name', () => {
@@ -1008,7 +1041,7 @@ describe('MCP Tool Naming Regression Fixes', () => {
); );
const qn = tool.getFullyQualifiedName(); const qn = tool.getFullyQualifiedName();
expect(qn).toBe('_123-server__tool'); expect(qn).toBe('mcp_123-server_tool');
}); });
}); });
}); });
+98 -11
View File
@@ -5,6 +5,7 @@
*/ */
import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { import type {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolInvocation, ToolInvocation,
@@ -25,18 +26,92 @@ import type { McpContext } from './mcp-client.js';
/** /**
* The separator used to qualify MCP tool names with their server prefix. * The separator used to qualify MCP tool names with their server prefix.
* e.g. "server_name__tool_name" * e.g. "mcp_server_name_tool_name"
*/ */
export const MCP_QUALIFIED_NAME_SEPARATOR = '__'; export const MCP_QUALIFIED_NAME_SEPARATOR = '_';
/** /**
* Returns true if `name` matches the MCP qualified name format: "server__tool", * The strict prefix that all MCP tools must start with.
* i.e. exactly two non-empty parts separated by the MCP_QUALIFIED_NAME_SEPARATOR. */
export const MCP_TOOL_PREFIX = 'mcp_';
/**
* Returns true if `name` matches the MCP qualified name format: "mcp_server_tool",
* i.e. starts with the "mcp_" prefix.
*/ */
export function isMcpToolName(name: string): boolean { export function isMcpToolName(name: string): boolean {
if (!name.includes(MCP_QUALIFIED_NAME_SEPARATOR)) return false; return name.startsWith(MCP_TOOL_PREFIX);
const parts = name.split(MCP_QUALIFIED_NAME_SEPARATOR); }
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
/**
* Extracts the server name and tool name from a fully qualified MCP tool name.
* Expected format: `mcp_{server_name}_{tool_name}`
* @param name The fully qualified tool name.
* @returns An object containing the extracted `serverName` and `toolName`, or
* `undefined` properties if the name doesn't match the expected format.
*/
export function parseMcpToolName(name: string): {
serverName?: string;
toolName?: string;
} {
if (!isMcpToolName(name)) {
return {};
}
// Remove the prefix
const withoutPrefix = name.slice(MCP_TOOL_PREFIX.length);
// The first segment is the server name, the rest is the tool name
const match = withoutPrefix.match(/^([^_]+)_(.+)$/);
if (match) {
return {
serverName: match[1],
toolName: match[2],
};
}
return {};
}
/**
* Assembles a fully qualified MCP tool name (or wildcard pattern) from its server and tool components.
*
* @param serverName The backend MCP server name (can be '*' for global wildcards).
* @param toolName The name of the tool (can be undefined or '*' for tool-level wildcards).
* @returns The fully qualified name (e.g., `mcp_server_tool`, `mcp_*`, `mcp_server_*`).
*/
export function formatMcpToolName(
serverName: string,
toolName?: string,
): string {
if (serverName === '*' && !toolName) {
return `${MCP_TOOL_PREFIX}*`;
} else if (serverName === '*') {
return `${MCP_TOOL_PREFIX}*_${toolName}`;
} else if (!toolName) {
return `${MCP_TOOL_PREFIX}${serverName}_*`;
} else {
return `${MCP_TOOL_PREFIX}${serverName}_${toolName}`;
}
}
/**
* Interface representing metadata annotations specific to an MCP tool.
* Ensures strongly-typed access to server-level properties.
*/
export interface McpToolAnnotation extends Record<string, unknown> {
_serverName: string;
}
/**
* Type guard to check if tool annotations implement McpToolAnnotation.
*/
export function isMcpToolAnnotation(
annotation: unknown,
): annotation is McpToolAnnotation {
return (
typeof annotation === 'object' &&
annotation !== null &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
typeof (annotation as Record<string, unknown>)['_serverName'] === 'string'
);
} }
type ToolParams = Record<string, unknown>; type ToolParams = Record<string, unknown>;
@@ -276,7 +351,10 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
private readonly _toolAnnotations?: Record<string, unknown>, private readonly _toolAnnotations?: Record<string, unknown>,
) { ) {
super( super(
generateValidName(nameOverride ?? serverToolName), nameOverride ??
generateValidName(
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
),
`${serverToolName} (${serverName} MCP Server)`, `${serverToolName} (${serverName} MCP Server)`,
description, description,
Kind.Other, Kind.Other,
@@ -304,7 +382,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
} }
getFullyQualifiedPrefix(): string { getFullyQualifiedPrefix(): string {
return `${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`; return generateValidName(
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`,
);
} }
getFullyQualifiedName(): string { getFullyQualifiedName(): string {
@@ -495,8 +575,12 @@ const MAX_FUNCTION_NAME_LENGTH = 64;
/** Visible for testing */ /** Visible for testing */
export function generateValidName(name: string) { export function generateValidName(name: string) {
// Replace invalid characters (based on 400 error message from Gemini API) with underscores // Enforce the mcp_ prefix for all generated MCP tool names
let validToolname = name.replace(/[^a-zA-Z0-9_.:-]/g, '_'); let validToolname = name.startsWith('mcp_') ? name : `mcp_${name}`;
// Replace invalid characters with underscores to conform to Gemini API:
// ^[a-zA-Z_][a-zA-Z0-9_\-.:]{0,63}$
validToolname = validToolname.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
// Ensure it starts with a letter or underscore // Ensure it starts with a letter or underscore
if (/^[^a-zA-Z_]/.test(validToolname)) { if (/^[^a-zA-Z_]/.test(validToolname)) {
@@ -507,6 +591,9 @@ export function generateValidName(name: string) {
// Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors. // Note: We use 63 instead of 64 to be safe, as some environments have off-by-one behaviors.
const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1; const safeLimit = MAX_FUNCTION_NAME_LENGTH - 1;
if (validToolname.length > safeLimit) { if (validToolname.length > safeLimit) {
debugLogger.warn(
`Truncating MCP tool name "${validToolname}" to fit within the 64 character limit. This tool may require user approval.`,
);
validToolname = validToolname =
validToolname.slice(0, 30) + '...' + validToolname.slice(-30); validToolname.slice(0, 30) + '...' + validToolname.slice(-30);
} }
+31 -33
View File
@@ -13,9 +13,12 @@ import { ApprovalMode } from '../policy/types.js';
import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js'; import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
import { DiscoveredMCPTool, MCP_QUALIFIED_NAME_SEPARATOR } from './mcp-tool.js'; import { DiscoveredMCPTool } from './mcp-tool.js';
import type { FunctionDeclaration, CallableTool } from '@google/genai'; import {
import { mcpToTool } from '@google/genai'; mcpToTool,
type FunctionDeclaration,
type CallableTool,
} from '@google/genai';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -307,7 +310,7 @@ describe('ToolRegistry', () => {
{ {
name: 'should match qualified MCP tool names when qualified or unqualified', name: 'should match qualified MCP tool names when qualified or unqualified',
tools: [mcpTool, mcpTool.asFullyQualifiedTool()], tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
excludedTools: [`${mcpTool.getFullyQualifiedPrefix()}${mcpTool.name}`], excludedTools: [mcpTool.name],
}, },
{ {
name: 'should match class names', name: 'should match class names',
@@ -395,7 +398,7 @@ describe('ToolRegistry', () => {
// Assert that the returned array contains all tool names, with MCP qualified // Assert that the returned array contains all tool names, with MCP qualified
expect(toolNames).toContain('c-tool'); expect(toolNames).toContain('c-tool');
expect(toolNames).toContain('a-tool'); expect(toolNames).toContain('a-tool');
expect(toolNames).toContain('my-server__my-tool'); expect(toolNames).toContain('mcp_my-server_my-tool');
expect(toolNames).toHaveLength(3); expect(toolNames).toHaveLength(3);
}); });
@@ -409,7 +412,7 @@ describe('ToolRegistry', () => {
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool()); toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
const toolNames = toolRegistry.getAllToolNames(); const toolNames = toolRegistry.getAllToolNames();
expect(toolNames).toEqual([`${serverName}__${toolName}`]); expect(toolNames).toEqual([`mcp_${serverName}_${toolName}`]);
}); });
}); });
@@ -439,7 +442,11 @@ describe('ToolRegistry', () => {
// Assert that the array has the correct tools and is sorted by name // Assert that the array has the correct tools and is sorted by name
expect(toolsFromServer1).toHaveLength(3); expect(toolsFromServer1).toHaveLength(3);
expect(toolNames).toEqual(['apple-tool', 'banana-tool', 'zebra-tool']); expect(toolNames).toEqual([
'mcp_mcp-server-uno_apple-tool',
'mcp_mcp-server-uno_banana-tool',
'mcp_mcp-server-uno_zebra-tool',
]);
// Assert that all returned tools are indeed from the correct server // Assert that all returned tools are indeed from the correct server
for (const tool of toolsFromServer1) { for (const tool of toolsFromServer1) {
@@ -481,8 +488,8 @@ describe('ToolRegistry', () => {
'builtin-1', 'builtin-1',
'builtin-2', 'builtin-2',
DISCOVERED_TOOL_PREFIX + 'discovered-1', DISCOVERED_TOOL_PREFIX + 'discovered-1',
'apple-server__mcp-apple', 'mcp_apple-server_mcp-apple',
'zebra-server__mcp-zebra', 'mcp_zebra-server_mcp-zebra',
]); ]);
}); });
}); });
@@ -598,25 +605,20 @@ describe('ToolRegistry', () => {
}); });
describe('getTool', () => { describe('getTool', () => {
it('should retrieve an MCP tool by its fully qualified name even if registered with simple name', () => { it('should retrieve an MCP tool by its fully qualified name', () => {
const serverName = 'my-server'; const serverName = 'my-server';
const toolName = 'my-tool'; const toolName = 'my-tool';
const mcpTool = createMCPTool(serverName, toolName, 'description'); const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as 'my-tool' since no conflict) // Register tool
toolRegistry.registerTool(mcpTool); toolRegistry.registerTool(mcpTool);
// Verify it is available as 'my-tool' // Verify it is available as 'mcp_my-server_my-tool'
expect(toolRegistry.getTool('my-tool')).toBeDefined(); const fullyQualifiedName = `mcp_${serverName}_${toolName}`;
expect(toolRegistry.getTool('my-tool')?.name).toBe('my-tool');
// Verify it is available as 'my-server__my-tool'
const fullyQualifiedName = `${serverName}__${toolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName); const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined(); expect(retrievedTool).toBeDefined();
// The returned tool object is the same, so its name property is still 'my-tool' expect(retrievedTool?.name).toBe(fullyQualifiedName);
expect(retrievedTool?.name).toBe('my-tool');
}); });
it('should retrieve an MCP tool by its fully qualified name when tool name has special characters', () => { it('should retrieve an MCP tool by its fully qualified name when tool name has special characters', () => {
@@ -626,19 +628,15 @@ describe('ToolRegistry', () => {
const validToolName = 'my_tool'; const validToolName = 'my_tool';
const mcpTool = createMCPTool(serverName, toolName, 'description'); const mcpTool = createMCPTool(serverName, toolName, 'description');
// Register tool (will be registered as sanitized name) // Register tool
toolRegistry.registerTool(mcpTool); toolRegistry.registerTool(mcpTool);
// Verify it is available as sanitized name // Verify it is available as 'mcp_my-server_my_tool'
expect(toolRegistry.getTool(validToolName)).toBeDefined(); const fullyQualifiedName = `mcp_${serverName}_${validToolName}`;
expect(toolRegistry.getTool(validToolName)?.name).toBe(validToolName);
// Verify it is available as 'my-server__my_tool'
const fullyQualifiedName = `${serverName}__${validToolName}`;
const retrievedTool = toolRegistry.getTool(fullyQualifiedName); const retrievedTool = toolRegistry.getTool(fullyQualifiedName);
expect(retrievedTool).toBeDefined(); expect(retrievedTool).toBeDefined();
expect(retrievedTool?.name).toBe(validToolName); expect(retrievedTool?.name).toBe(fullyQualifiedName);
}); });
it('should resolve qualified names in getFunctionDeclarationsFiltered', () => { it('should resolve qualified names in getFunctionDeclarationsFiltered', () => {
@@ -648,13 +646,13 @@ describe('ToolRegistry', () => {
toolRegistry.registerTool(mcpTool); toolRegistry.registerTool(mcpTool);
const fullyQualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`; const fullyQualifiedName = `mcp_${serverName}_${toolName}`;
const declarations = toolRegistry.getFunctionDeclarationsFiltered([ const declarations = toolRegistry.getFunctionDeclarationsFiltered([
fullyQualifiedName, fullyQualifiedName,
]); ]);
expect(declarations).toHaveLength(1); expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(toolName); expect(declarations[0].name).toBe(fullyQualifiedName);
}); });
it('should retrieve a tool using its legacy alias', async () => { it('should retrieve a tool using its legacy alias', async () => {
@@ -685,7 +683,7 @@ describe('ToolRegistry', () => {
const declarations = toolRegistry.getFunctionDeclarations(); const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1); expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(`${serverName}__${toolName}`); expect(declarations[0].name).toBe(`mcp_${serverName}_${toolName}`);
}); });
it('should deduplicate MCP tools in declarations', () => { it('should deduplicate MCP tools in declarations', () => {
@@ -699,7 +697,7 @@ describe('ToolRegistry', () => {
const declarations = toolRegistry.getFunctionDeclarations(); const declarations = toolRegistry.getFunctionDeclarations();
expect(declarations).toHaveLength(1); expect(declarations).toHaveLength(1);
expect(declarations[0].name).toBe(`${serverName}__${toolName}`); expect(declarations[0].name).toBe(`mcp_${serverName}_${toolName}`);
}); });
}); });
@@ -752,7 +750,7 @@ describe('ToolRegistry', () => {
const allTools = toolRegistry.getAllTools(); const allTools = toolRegistry.getAllTools();
const toolNames = allTools.map((t) => t.name); const toolNames = allTools.map((t) => t.name);
expect(toolNames).toContain('read-only-tool'); expect(toolNames).toContain('mcp_test-server_read-only-tool');
}); });
it('should exclude non-read-only MCP tools when denied by policy in plan mode', () => { it('should exclude non-read-only MCP tools when denied by policy in plan mode', () => {
@@ -769,7 +767,7 @@ describe('ToolRegistry', () => {
const allTools = toolRegistry.getAllTools(); const allTools = toolRegistry.getAllTools();
const toolNames = allTools.map((t) => t.name); const toolNames = allTools.map((t) => t.name);
expect(toolNames).not.toContain('write-mcp-tool'); expect(toolNames).not.toContain('mcp_test-server_write-mcp-tool');
}); });
}); });
+4 -2
View File
@@ -443,13 +443,15 @@ export class ToolRegistry {
private buildToolMetadata(): Map<string, Record<string, unknown>> { private buildToolMetadata(): Map<string, Record<string, unknown>> {
const toolMetadata = new Map<string, Record<string, unknown>>(); const toolMetadata = new Map<string, Record<string, unknown>>();
for (const [name, tool] of this.allKnownTools) { for (const [name, tool] of this.allKnownTools) {
if (tool.toolAnnotations) { const metadata: Record<string, unknown> = tool.toolAnnotations
const metadata: Record<string, unknown> = { ...tool.toolAnnotations }; ? { ...tool.toolAnnotations }
: {};
// Include server name so the policy engine can resolve composite // Include server name so the policy engine can resolve composite
// wildcard patterns (e.g. "*__*") against unqualified tool names. // wildcard patterns (e.g. "*__*") against unqualified tool names.
if (tool instanceof DiscoveredMCPTool) { if (tool instanceof DiscoveredMCPTool) {
metadata['_serverName'] = tool.serverName; metadata['_serverName'] = tool.serverName;
} }
if (Object.keys(metadata).length > 0) {
toolMetadata.set(name, metadata); toolMetadata.set(name, metadata);
} }
} }