Skip to content

Commit eccc200

Browse files
authored
feat(core): enforce server prefixes for MCP tools in agent definitions (google-gemini#17574)
1 parent 9fcdc0c commit eccc200

5 files changed

Lines changed: 94 additions & 5 deletions

File tree

packages/core/src/agents/local-executor.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { debugLogger } from '../utils/debugLogger.js';
1717
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
1818
import { makeFakeConfig } from '../test-utils/config.js';
1919
import { ToolRegistry } from '../tools/tool-registry.js';
20+
import {
21+
DiscoveredMCPTool,
22+
MCP_QUALIFIED_NAME_SEPARATOR,
23+
} from '../tools/mcp-tool.js';
2024
import { LSTool } from '../tools/ls.js';
2125
import { LS_TOOL_NAME, READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
2226
import {
@@ -31,6 +35,7 @@ import {
3135
type Content,
3236
type PartListUnion,
3337
type Tool,
38+
type CallableTool,
3439
} from '@google/genai';
3540
import type { Config } from '../config/config.js';
3641
import { MockTool } from '../test-utils/mock-tool.js';
@@ -493,6 +498,56 @@ describe('LocalAgentExecutor', () => {
493498
// Should exclude subagent
494499
expect(agentRegistry.getTool(subAgentName)).toBeUndefined();
495500
});
501+
502+
it('should enforce qualified names for MCP tools in agent definitions', async () => {
503+
const serverName = 'mcp-server';
504+
const toolName = 'mcp-tool';
505+
const qualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`;
506+
507+
const mockMcpTool = {
508+
tool: vi.fn(),
509+
callTool: vi.fn(),
510+
} as unknown as CallableTool;
511+
512+
const mcpTool = new DiscoveredMCPTool(
513+
mockMcpTool,
514+
serverName,
515+
toolName,
516+
'description',
517+
{},
518+
mockConfig.getMessageBus(),
519+
);
520+
521+
// Mock getTool to return our real DiscoveredMCPTool instance
522+
const getToolSpy = vi
523+
.spyOn(parentToolRegistry, 'getTool')
524+
.mockImplementation((name) => {
525+
if (name === toolName || name === qualifiedName) {
526+
return mcpTool;
527+
}
528+
return undefined;
529+
});
530+
531+
// 1. Qualified name works and registers the tool (using short name per status quo)
532+
const definition = createTestDefinition([qualifiedName]);
533+
const executor = await LocalAgentExecutor.create(
534+
definition,
535+
mockConfig,
536+
onActivity,
537+
);
538+
539+
const agentRegistry = executor['toolRegistry'];
540+
// Registry shortening logic means it's registered as 'mcp-tool' internally
541+
expect(agentRegistry.getTool(toolName)).toBeDefined();
542+
543+
// 2. Unqualified name for MCP tool THROWS
544+
const badDefinition = createTestDefinition([toolName]);
545+
await expect(
546+
LocalAgentExecutor.create(badDefinition, mockConfig, onActivity),
547+
).rejects.toThrow(/must be requested with its server prefix/);
548+
549+
getToolSpy.mockRestore();
550+
});
496551
});
497552

498553
describe('run (Execution Loop and Logic)', () => {

packages/core/src/agents/local-executor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import type {
1616
Schema,
1717
} from '@google/genai';
1818
import { ToolRegistry } from '../tools/tool-registry.js';
19+
import {
20+
DiscoveredMCPTool,
21+
MCP_QUALIFIED_NAME_SEPARATOR,
22+
} from '../tools/mcp-tool.js';
1923
import { CompressionStatus } from '../core/turn.js';
2024
import { type ToolCallRequestInfo } from '../scheduler/types.js';
2125
import { ChatCompressionService } from '../services/chatCompressionService.js';
@@ -129,6 +133,14 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
129133
// registry and register it with the agent's isolated registry.
130134
const tool = parentToolRegistry.getTool(toolName);
131135
if (tool) {
136+
if (
137+
tool instanceof DiscoveredMCPTool &&
138+
!toolName.includes(MCP_QUALIFIED_NAME_SEPARATOR)
139+
) {
140+
throw new Error(
141+
`MCP tool '${toolName}' must be requested with its server prefix (e.g., '${tool.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}') in agent '${definition.name}'.`,
142+
);
143+
}
132144
agentToolRegistry.registerTool(tool);
133145
}
134146
};

packages/core/src/tools/mcp-tool.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ import { ToolErrorType } from './tool-error.js';
2323
import type { Config } from '../config/config.js';
2424
import type { MessageBus } from '../confirmation-bus/message-bus.js';
2525

26+
/**
27+
* The separator used to qualify MCP tool names with their server prefix.
28+
* e.g. "server_name__tool_name"
29+
*/
30+
export const MCP_QUALIFIED_NAME_SEPARATOR = '__';
31+
2632
type ToolParams = Record<string, unknown>;
2733

2834
// Discriminated union for MCP Content Blocks to ensure type safety.
@@ -82,7 +88,7 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
8288
super(
8389
params,
8490
messageBus,
85-
`${serverName}__${serverToolName}`,
91+
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
8692
displayName,
8793
serverName,
8894
);
@@ -261,7 +267,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
261267
}
262268

263269
getFullyQualifiedPrefix(): string {
264-
return `${this.serverName}__`;
270+
return `${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`;
265271
}
266272

267273
getFullyQualifiedName(): string {

packages/core/src/tools/tool-registry.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ApprovalMode } from '../policy/types.js';
1313

1414
import { ToolRegistry, DiscoveredTool } from './tool-registry.js';
1515
import { DISCOVERED_TOOL_PREFIX } from './tool-names.js';
16-
import { DiscoveredMCPTool } from './mcp-tool.js';
16+
import { DiscoveredMCPTool, MCP_QUALIFIED_NAME_SEPARATOR } from './mcp-tool.js';
1717
import type { FunctionDeclaration, CallableTool } from '@google/genai';
1818
import { mcpToTool } from '@google/genai';
1919
import { spawn } from 'node:child_process';
@@ -568,6 +568,22 @@ describe('ToolRegistry', () => {
568568
expect(retrievedTool).toBeDefined();
569569
expect(retrievedTool?.name).toBe(validToolName);
570570
});
571+
572+
it('should resolve qualified names in getFunctionDeclarationsFiltered', () => {
573+
const serverName = 'my-server';
574+
const toolName = 'my-tool';
575+
const mcpTool = createMCPTool(serverName, toolName, 'description');
576+
577+
toolRegistry.registerTool(mcpTool);
578+
579+
const fullyQualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`;
580+
const declarations = toolRegistry.getFunctionDeclarationsFiltered([
581+
fullyQualifiedName,
582+
]);
583+
584+
expect(declarations).toHaveLength(1);
585+
expect(declarations[0].name).toBe(toolName);
586+
});
571587
});
572588

573589
describe('DiscoveredToolInvocation', () => {

packages/core/src/tools/tool-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,8 +488,8 @@ export class ToolRegistry {
488488
getFunctionDeclarationsFiltered(toolNames: string[]): FunctionDeclaration[] {
489489
const declarations: FunctionDeclaration[] = [];
490490
for (const name of toolNames) {
491-
const tool = this.allKnownTools.get(name);
492-
if (tool && this.isActiveTool(tool)) {
491+
const tool = this.getTool(name);
492+
if (tool) {
493493
declarations.push(tool.schema);
494494
}
495495
}

0 commit comments

Comments
 (0)