Skip to content

Commit f21f682

Browse files
abhipatel12gemini-cli-robot
authored andcommitted
fix(core): resolve MCP tool FQN validation, schema export, and wildcards in subagents (#22069)
1 parent 832e97c commit f21f682

7 files changed

Lines changed: 136 additions & 99 deletions

File tree

packages/core/src/agents/agentLoader.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ const localAgentSchema = z
102102
display_name: z.string().optional(),
103103
tools: z
104104
.array(
105-
z.string().refine((val) => isValidToolName(val), {
106-
message: 'Invalid tool name',
107-
}),
105+
z
106+
.string()
107+
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
108+
message: 'Invalid tool name',
109+
}),
108110
)
109111
.optional(),
110112
model: z.string().optional(),

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

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import type {
1616
Schema,
1717
} from '@google/genai';
1818
import { ToolRegistry } from '../tools/tool-registry.js';
19-
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
19+
import { type AnyDeclarativeTool } from '../tools/tools.js';
20+
import {
21+
DiscoveredMCPTool,
22+
isMcpToolName,
23+
parseMcpToolName,
24+
MCP_TOOL_PREFIX,
25+
} from '../tools/mcp-tool.js';
2026
import { CompressionStatus } from '../core/turn.js';
2127
import { type ToolCallRequestInfo } from '../scheduler/types.js';
2228
import { ChatCompressionService } from '../services/chatCompressionService.js';
@@ -125,28 +131,55 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
125131
runtimeContext.getAgentRegistry().getAllAgentNames(),
126132
);
127133

128-
const registerToolByName = (toolName: string) => {
134+
const registerToolInstance = (tool: AnyDeclarativeTool) => {
129135
// Check if the tool is a subagent to prevent recursion.
130136
// We do not allow agents to call other agents.
131-
if (allAgentNames.has(toolName)) {
137+
if (allAgentNames.has(tool.name)) {
132138
debugLogger.warn(
133-
`[LocalAgentExecutor] Skipping subagent tool '${toolName}' for agent '${definition.name}' to prevent recursion.`,
139+
`[LocalAgentExecutor] Skipping subagent tool '${tool.name}' for agent '${definition.name}' to prevent recursion.`,
134140
);
135141
return;
136142
}
137143

144+
agentToolRegistry.registerTool(tool);
145+
};
146+
147+
const registerToolByName = (toolName: string) => {
148+
// Handle global wildcard
149+
if (toolName === '*') {
150+
for (const tool of parentToolRegistry.getAllTools()) {
151+
registerToolInstance(tool);
152+
}
153+
return;
154+
}
155+
156+
// Handle MCP wildcards
157+
if (isMcpToolName(toolName)) {
158+
if (toolName === `${MCP_TOOL_PREFIX}*`) {
159+
for (const tool of parentToolRegistry.getAllTools()) {
160+
if (tool instanceof DiscoveredMCPTool) {
161+
registerToolInstance(tool);
162+
}
163+
}
164+
return;
165+
}
166+
167+
const parsed = parseMcpToolName(toolName);
168+
if (parsed.serverName && parsed.toolName === '*') {
169+
for (const tool of parentToolRegistry.getToolsByServer(
170+
parsed.serverName,
171+
)) {
172+
registerToolInstance(tool);
173+
}
174+
return;
175+
}
176+
}
177+
138178
// If the tool is referenced by name, retrieve it from the parent
139179
// registry and register it with the agent's isolated registry.
140180
const tool = parentToolRegistry.getTool(toolName);
141181
if (tool) {
142-
if (tool instanceof DiscoveredMCPTool) {
143-
// Subagents MUST use fully qualified names for MCP tools to ensure
144-
// unambiguous tool calls and to comply with policy requirements.
145-
// We automatically "upgrade" any MCP tool to its qualified version.
146-
agentToolRegistry.registerTool(tool.asFullyQualifiedTool());
147-
} else {
148-
agentToolRegistry.registerTool(tool);
149-
}
182+
registerToolInstance(tool);
150183
}
151184
};
152185

@@ -1147,10 +1180,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
11471180
const { toolConfig, outputConfig } = this.definition;
11481181

11491182
if (toolConfig) {
1150-
const toolNamesToLoad: string[] = [];
11511183
for (const toolRef of toolConfig.tools) {
11521184
if (typeof toolRef === 'string') {
1153-
toolNamesToLoad.push(toolRef);
1185+
// The names were already expanded and loaded into the registry during creation.
11541186
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
11551187
// Tool instance with an explicit schema property.
11561188
toolsList.push(toolRef.schema);
@@ -1159,10 +1191,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
11591191
toolsList.push(toolRef);
11601192
}
11611193
}
1162-
// Add schemas from tools that were registered by name.
1163-
toolsList.push(
1164-
...this.toolRegistry.getFunctionDeclarationsFiltered(toolNamesToLoad),
1165-
);
1194+
// Add schemas from tools that were explicitly registered by name or wildcard.
1195+
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
11661196
}
11671197

11681198
// Always inject complete_task.

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

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function parseMcpToolName(name: string): {
6060
// Remove the prefix
6161
const withoutPrefix = name.slice(MCP_TOOL_PREFIX.length);
6262
// The first segment is the server name, the rest is the tool name
63+
// Must be strictly `server_tool` where neither are empty
6364
const match = withoutPrefix.match(/^([^_]+)_(.+)$/);
6465
if (match) {
6566
return {
@@ -392,25 +393,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
392393
`${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${this.serverToolName}`,
393394
);
394395
}
395-
396-
asFullyQualifiedTool(): DiscoveredMCPTool {
397-
return new DiscoveredMCPTool(
398-
this.mcpTool,
399-
this.serverName,
400-
this.serverToolName,
401-
this.description,
402-
this.parameterSchema,
403-
this.messageBus,
404-
this.trust,
405-
this.isReadOnly,
406-
this.getFullyQualifiedName(),
407-
this.cliConfig,
408-
this.extensionName,
409-
this.extensionId,
410-
this._toolAnnotations,
411-
);
412-
}
413-
414396
protected createInvocation(
415397
params: ToolParams,
416398
messageBus: MessageBus,

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ vi.mock('./tool-names.js', async (importOriginal) => {
2525
...actual,
2626
TOOL_LEGACY_ALIASES: mockedAliases,
2727
isValidToolName: vi.fn().mockImplementation((name: string, options) => {
28-
if (mockedAliases[name]) return true;
28+
if (Object.prototype.hasOwnProperty.call(mockedAliases, name))
29+
return true;
2930
return actual.isValidToolName(name, options);
3031
}),
3132
getToolAliases: vi.fn().mockImplementation((name: string) => {
@@ -55,11 +56,9 @@ describe('tool-names', () => {
5556
expect(isValidToolName(`${DISCOVERED_TOOL_PREFIX}my_tool`)).toBe(true);
5657
});
5758

58-
it('should validate MCP tool names (server__tool)', () => {
59-
expect(isValidToolName('server__tool')).toBe(true);
60-
expect(isValidToolName('my-server__my-tool')).toBe(true);
61-
expect(isValidToolName('my.server__my:tool')).toBe(true);
62-
expect(isValidToolName('my-server...truncated__tool')).toBe(true);
59+
it('should validate modern MCP FQNs (mcp_server_tool)', () => {
60+
expect(isValidToolName('mcp_server_tool')).toBe(true);
61+
expect(isValidToolName('mcp_my-server_my-tool')).toBe(true);
6362
});
6463

6564
it('should validate legacy tool aliases', async () => {
@@ -69,28 +68,33 @@ describe('tool-names', () => {
6968
}
7069
});
7170

72-
it('should reject invalid tool names', () => {
73-
expect(isValidToolName('')).toBe(false);
74-
expect(isValidToolName('invalid-name')).toBe(false);
75-
expect(isValidToolName('server__')).toBe(false);
76-
expect(isValidToolName('__tool')).toBe(false);
77-
expect(isValidToolName('server__tool__extra')).toBe(false);
71+
it('should return false for invalid tool names', () => {
72+
expect(isValidToolName('invalid-tool-name')).toBe(false);
73+
expect(isValidToolName('mcp_server')).toBe(false);
74+
expect(isValidToolName('mcp__tool')).toBe(false);
75+
expect(isValidToolName('mcp_invalid server_tool')).toBe(false);
76+
expect(isValidToolName('mcp_server_invalid tool')).toBe(false);
77+
expect(isValidToolName('mcp_server_')).toBe(false);
7878
});
7979

8080
it('should handle wildcards when allowed', () => {
8181
// Default: not allowed
8282
expect(isValidToolName('*')).toBe(false);
83-
expect(isValidToolName('server__*')).toBe(false);
83+
expect(isValidToolName('mcp_*')).toBe(false);
84+
expect(isValidToolName('mcp_server_*')).toBe(false);
8485

8586
// Explicitly allowed
8687
expect(isValidToolName('*', { allowWildcards: true })).toBe(true);
87-
expect(isValidToolName('server__*', { allowWildcards: true })).toBe(true);
88+
expect(isValidToolName('mcp_*', { allowWildcards: true })).toBe(true);
89+
expect(isValidToolName('mcp_server_*', { allowWildcards: true })).toBe(
90+
true,
91+
);
8892

8993
// Invalid wildcards
90-
expect(isValidToolName('__*', { allowWildcards: true })).toBe(false);
91-
expect(isValidToolName('server__tool*', { allowWildcards: true })).toBe(
92-
false,
93-
);
94+
expect(isValidToolName('mcp__*', { allowWildcards: true })).toBe(false);
95+
expect(
96+
isValidToolName('mcp_server_tool*', { allowWildcards: true }),
97+
).toBe(false);
9498
});
9599
});
96100

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

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ export const DISCOVERED_TOOL_PREFIX = 'discovered_tool_';
198198
/**
199199
* List of all built-in tool names.
200200
*/
201+
import {
202+
isMcpToolName,
203+
parseMcpToolName,
204+
MCP_TOOL_PREFIX,
205+
} from './mcp-tool.js';
206+
201207
export const ALL_BUILTIN_TOOL_NAMES = [
202208
GLOB_TOOL_NAME,
203209
WRITE_TODOS_TOOL_NAME,
@@ -246,25 +252,44 @@ export function isValidToolName(
246252
return true;
247253
}
248254

249-
// MCP tools (format: server__tool)
250-
if (name.includes('__')) {
251-
const parts = name.split('__');
252-
if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
255+
// Handle standard MCP FQNs (mcp_server_tool or wildcards mcp_*, mcp_server_*)
256+
if (isMcpToolName(name)) {
257+
// Global wildcard: mcp_*
258+
if (name === `${MCP_TOOL_PREFIX}*` && options.allowWildcards) {
259+
return true;
260+
}
261+
262+
// Explicitly reject names with empty server component (e.g. mcp__tool)
263+
if (name.startsWith(`${MCP_TOOL_PREFIX}_`)) {
253264
return false;
254265
}
255266

256-
const server = parts[0];
257-
const tool = parts[1];
267+
const parsed = parseMcpToolName(name);
268+
// Ensure that both components are populated. parseMcpToolName splits at the second _,
269+
// so `mcp__tool` has serverName="", toolName="tool"
270+
if (parsed.serverName && parsed.toolName) {
271+
// Basic slug validation for server and tool names.
272+
// We allow dots (.) and colons (:) as they are valid in function names and
273+
// used for truncation markers.
274+
const slugRegex = /^[a-z0-9_.:-]+$/i;
275+
276+
if (!slugRegex.test(parsed.serverName)) {
277+
return false;
278+
}
279+
280+
if (parsed.toolName === '*') {
281+
return options.allowWildcards === true;
282+
}
283+
284+
// A tool name consisting only of underscores is invalid.
285+
if (/^_*$/.test(parsed.toolName)) {
286+
return false;
287+
}
258288

259-
if (tool === '*') {
260-
return !!options.allowWildcards;
289+
return slugRegex.test(parsed.toolName);
261290
}
262291

263-
// Basic slug validation for server and tool names.
264-
// We allow dots (.) and colons (:) as they are valid in function names and
265-
// used for truncation markers.
266-
const slugRegex = /^[a-z0-9_.:-]+$/i;
267-
return slugRegex.test(server) && slugRegex.test(tool);
292+
return false;
268293
}
269294

270295
return false;

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,13 @@ describe('ToolRegistry', () => {
303303
excludedTools: ['tool-a'],
304304
},
305305
{
306-
name: 'should match simple MCP tool names, when qualified or unqualified',
307-
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
306+
name: 'should match simple MCP tool names',
307+
tools: [mcpTool],
308308
excludedTools: [mcpTool.name],
309309
},
310310
{
311-
name: 'should match qualified MCP tool names when qualified or unqualified',
312-
tools: [mcpTool, mcpTool.asFullyQualifiedTool()],
311+
name: 'should match qualified MCP tool names',
312+
tools: [mcpTool],
313313
excludedTools: [mcpTool.name],
314314
},
315315
{
@@ -407,9 +407,9 @@ describe('ToolRegistry', () => {
407407
const toolName = 'my-tool';
408408
const mcpTool = createMCPTool(serverName, toolName, 'desc');
409409

410-
// Register same MCP tool twice (one as alias, one as qualified)
410+
// Register same MCP tool twice
411+
toolRegistry.registerTool(mcpTool);
411412
toolRegistry.registerTool(mcpTool);
412-
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
413413

414414
const toolNames = toolRegistry.getAllToolNames();
415415
expect(toolNames).toEqual([`mcp_${serverName}_${toolName}`]);
@@ -691,9 +691,8 @@ describe('ToolRegistry', () => {
691691
const toolName = 'my-tool';
692692
const mcpTool = createMCPTool(serverName, toolName, 'description');
693693

694-
// Register both alias and qualified
695694
toolRegistry.registerTool(mcpTool);
696-
toolRegistry.registerTool(mcpTool.asFullyQualifiedTool());
695+
toolRegistry.registerTool(mcpTool);
697696

698697
const declarations = toolRegistry.getFunctionDeclarations();
699698
expect(declarations).toHaveLength(1);

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

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -220,14 +220,10 @@ export class ToolRegistry {
220220
*/
221221
registerTool(tool: AnyDeclarativeTool): void {
222222
if (this.allKnownTools.has(tool.name)) {
223-
if (tool instanceof DiscoveredMCPTool) {
224-
tool = tool.asFullyQualifiedTool();
225-
} else {
226-
// Decide on behavior: throw error, log warning, or allow overwrite
227-
debugLogger.warn(
228-
`Tool with name "${tool.name}" is already registered. Overwriting.`,
229-
);
230-
}
223+
// Decide on behavior: throw error, log warning, or allow overwrite
224+
debugLogger.warn(
225+
`Tool with name "${tool.name}" is already registered. Overwriting.`,
226+
);
231227
}
232228
this.allKnownTools.set(tool.name, tool);
233229
}
@@ -592,7 +588,17 @@ export class ToolRegistry {
592588
for (const name of toolNames) {
593589
const tool = this.getTool(name);
594590
if (tool) {
595-
declarations.push(tool.getSchema(modelId));
591+
let schema = tool.getSchema(modelId);
592+
593+
// Ensure the schema name matches the qualified name for MCP tools
594+
if (tool instanceof DiscoveredMCPTool) {
595+
schema = {
596+
...schema,
597+
name: tool.getFullyQualifiedName(),
598+
};
599+
}
600+
601+
declarations.push(schema);
596602
}
597603
}
598604
return declarations;
@@ -668,17 +674,6 @@ export class ToolRegistry {
668674
}
669675
}
670676

671-
if (!tool && name.includes('__')) {
672-
for (const t of this.allKnownTools.values()) {
673-
if (t instanceof DiscoveredMCPTool) {
674-
if (t.getFullyQualifiedName() === name) {
675-
tool = t;
676-
break;
677-
}
678-
}
679-
}
680-
}
681-
682677
if (tool && this.isActiveTool(tool)) {
683678
return tool;
684679
}

0 commit comments

Comments
 (0)