mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(core): add tools to list and read MCP resources (#25395)
This commit is contained in:
@@ -30,6 +30,8 @@ import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadMcpResourceTool } from '../tools/read-mcp-resource.js';
|
||||
import { ListMcpResourcesTool } from '../tools/list-mcp-resources.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
@@ -3579,6 +3581,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
maybeRegister(WebFetchTool, () =>
|
||||
registry.registerTool(new WebFetchTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ReadMcpResourceTool, () =>
|
||||
registry.registerTool(new ReadMcpResourceTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ListMcpResourcesTool, () =>
|
||||
registry.registerTool(new ListMcpResourcesTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ShellTool, () =>
|
||||
registry.registerTool(new ShellTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
@@ -47,7 +47,10 @@ toolName = [
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
"update_topic",
|
||||
# Core agent lifecycle tool
|
||||
"complete_task"
|
||||
"complete_task",
|
||||
# MCP resource tools
|
||||
"read_mcp_resource",
|
||||
"list_mcp_resources"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
@@ -1057,6 +1057,25 @@ priority = 100
|
||||
cliHelpResult.decision,
|
||||
'cli_help should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// 7. Verify MCP resource tools are ALLOWED
|
||||
const listMcpResult = await engine.check(
|
||||
{ name: 'list_mcp_resources' },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
listMcpResult.decision,
|
||||
'list_mcp_resources should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
const readMcpResult = await engine.check(
|
||||
{ name: 'read_mcp_resource', args: { uri: 'test://resource' } },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
readMcpResult.decision,
|
||||
'read_mcp_resource should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
} finally {
|
||||
await fs.rm(tempPolicyDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -137,3 +137,7 @@ export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
|
||||
// -- complete_task --
|
||||
export const COMPLETE_TASK_TOOL_NAME = 'complete_task';
|
||||
export const COMPLETE_TASK_DISPLAY_NAME = 'Complete Task';
|
||||
|
||||
// -- MCP Resources --
|
||||
export const READ_MCP_RESOURCE_TOOL_NAME = 'read_mcp_resource';
|
||||
export const LIST_MCP_RESOURCES_TOOL_NAME = 'list_mcp_resources';
|
||||
|
||||
@@ -43,6 +43,8 @@ export {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -280,3 +282,17 @@ export function getActivateSkillDefinition(
|
||||
overrides: (modelId) => getToolSet(modelId).activate_skill(skillNames),
|
||||
};
|
||||
}
|
||||
|
||||
export const READ_MCP_RESOURCE_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.read_mcp_resource;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).read_mcp_resource,
|
||||
};
|
||||
|
||||
export const LIST_MCP_RESOURCES_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.list_mcp_resources;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).list_mcp_resources,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -756,4 +758,37 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
|
||||
exit_plan_mode: () => getExitPlanModeDeclaration(),
|
||||
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
|
||||
|
||||
read_mcp_resource: {
|
||||
name: READ_MCP_RESOURCE_TOOL_NAME,
|
||||
description:
|
||||
'Reads the content of a specified Model Context Protocol (MCP) resource.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uri: {
|
||||
description: 'The URI of the MCP resource to read.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['uri'],
|
||||
},
|
||||
},
|
||||
|
||||
list_mcp_resources: {
|
||||
name: LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
description:
|
||||
'Lists all available resources exposed by connected MCP servers.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverName: {
|
||||
description:
|
||||
'Optional filter to list resources from a specific server.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -733,4 +735,37 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
exit_plan_mode: () => getExitPlanModeDeclaration(),
|
||||
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
|
||||
update_topic: getUpdateTopicDeclaration(),
|
||||
|
||||
read_mcp_resource: {
|
||||
name: READ_MCP_RESOURCE_TOOL_NAME,
|
||||
description:
|
||||
'Reads the content of a specified Model Context Protocol (MCP) resource.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uri: {
|
||||
description: 'The URI of the MCP resource to read.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['uri'],
|
||||
},
|
||||
},
|
||||
|
||||
list_mcp_resources: {
|
||||
name: LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
description:
|
||||
'Lists all available resources exposed by connected MCP servers.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverName: {
|
||||
description:
|
||||
'Optional filter to list resources from a specific server.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,5 +50,7 @@ export interface CoreToolSet {
|
||||
enter_plan_mode: FunctionDeclaration;
|
||||
exit_plan_mode: () => FunctionDeclaration;
|
||||
activate_skill: (skillNames: string[]) => FunctionDeclaration;
|
||||
read_mcp_resource: FunctionDeclaration;
|
||||
list_mcp_resources: FunctionDeclaration;
|
||||
update_topic?: FunctionDeclaration;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { ListMcpResourcesTool } from './list-mcp-resources.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
describe('ListMcpResourcesTool', () => {
|
||||
let tool: ListMcpResourcesTool;
|
||||
let mockContext: {
|
||||
config: {
|
||||
getMcpClientManager: Mock;
|
||||
};
|
||||
};
|
||||
let mockMcpManager: {
|
||||
getAllResources: Mock;
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMcpManager = {
|
||||
getAllResources: vi.fn(),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
config: {
|
||||
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
|
||||
},
|
||||
};
|
||||
|
||||
tool = new ListMcpResourcesTool(
|
||||
mockContext as unknown as AgentLoopContext,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully list all resources', async () => {
|
||||
const resources = [
|
||||
{
|
||||
uri: 'protocol://r1',
|
||||
serverName: 'server1',
|
||||
name: 'R1',
|
||||
description: 'D1',
|
||||
},
|
||||
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
|
||||
];
|
||||
mockMcpManager.getAllResources.mockReturnValue(resources);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.getAllResources).toHaveBeenCalled();
|
||||
expect(result.llmContent).toContain('Available MCP Resources:');
|
||||
expect(result.llmContent).toContain('protocol://r1');
|
||||
expect(result.llmContent).toContain('protocol://r2');
|
||||
expect(result.returnDisplay).toBe('Listed 2 resources.');
|
||||
});
|
||||
|
||||
it('should filter by server name', async () => {
|
||||
const resources = [
|
||||
{ uri: 'protocol://r1', serverName: 'server1', name: 'R1' },
|
||||
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
|
||||
];
|
||||
mockMcpManager.getAllResources.mockReturnValue(resources);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ serverName: 'server1' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toContain('protocol://r1');
|
||||
expect(result.llmContent).not.toContain('protocol://r2');
|
||||
expect(result.returnDisplay).toBe('Listed 1 resources.');
|
||||
});
|
||||
|
||||
it('should return message if no resources found', async () => {
|
||||
mockMcpManager.getAllResources.mockReturnValue([]);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toBe('No MCP resources found.');
|
||||
expect(result.returnDisplay).toBe('No MCP resources found.');
|
||||
});
|
||||
|
||||
it('should return message if no resources found for server', async () => {
|
||||
mockMcpManager.getAllResources.mockReturnValue([]);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ serverName: 'nonexistent' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toBe(
|
||||
'No resources found for server: nonexistent',
|
||||
);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'No resources found for server: nonexistent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if MCP Client Manager not available', async () => {
|
||||
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
|
||||
expect(result.error?.message).toContain('MCP Client Manager not available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { LIST_MCP_RESOURCES_TOOL_NAME } from './tool-names.js';
|
||||
import { LIST_MCP_RESOURCES_DEFINITION } from './definitions/coreTools.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
export interface ListMcpResourcesParams {
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export class ListMcpResourcesTool extends BaseDeclarativeTool<
|
||||
ListMcpResourcesParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = LIST_MCP_RESOURCES_TOOL_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ListMcpResourcesTool.Name,
|
||||
'List MCP Resources',
|
||||
LIST_MCP_RESOURCES_DEFINITION.base.description!,
|
||||
Kind.Search,
|
||||
LIST_MCP_RESOURCES_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ListMcpResourcesParams,
|
||||
): ListMcpResourcesToolInvocation {
|
||||
return new ListMcpResourcesToolInvocation(
|
||||
this.context,
|
||||
params,
|
||||
this.messageBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListMcpResourcesToolInvocation extends BaseToolInvocation<
|
||||
ListMcpResourcesParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: ListMcpResourcesParams,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(params, messageBus, ListMcpResourcesTool.Name, 'List MCP Resources');
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'List MCP resources';
|
||||
}
|
||||
|
||||
async execute({
|
||||
abortSignal: _abortSignal,
|
||||
}: ExecuteOptions): Promise<ToolResult> {
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
if (!mcpManager) {
|
||||
return {
|
||||
llmContent: 'Error: MCP Client Manager not available.',
|
||||
returnDisplay: 'Error: MCP Client Manager not available.',
|
||||
error: {
|
||||
message: 'MCP Client Manager not available.',
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let resources = mcpManager.getAllResources();
|
||||
|
||||
const serverName = this.params.serverName;
|
||||
if (serverName) {
|
||||
resources = resources.filter((r) => r.serverName === serverName);
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
const msg = serverName
|
||||
? `No resources found for server: ${serverName}`
|
||||
: 'No MCP resources found.';
|
||||
return {
|
||||
llmContent: msg,
|
||||
returnDisplay: msg,
|
||||
};
|
||||
}
|
||||
|
||||
// Format the list
|
||||
let content = 'Available MCP Resources:\n';
|
||||
for (const resource of resources) {
|
||||
content += `- ${resource.serverName}:${resource.uri}`;
|
||||
if (resource.name) {
|
||||
content += ` | ${resource.name}`;
|
||||
}
|
||||
if (resource.description) {
|
||||
content += ` | ${resource.description}`;
|
||||
}
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: content,
|
||||
returnDisplay: `Listed ${resources.length} resources.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -821,4 +821,64 @@ describe('McpClientManager', () => {
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(2); // Now the actual error
|
||||
});
|
||||
});
|
||||
|
||||
describe('findResourceByUri', () => {
|
||||
it('should find resource by exact URI match', () => {
|
||||
const mockResource = { uri: 'test://resource1', name: 'Resource 1' };
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([mockResource]),
|
||||
findResourceByUri: vi.fn(),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('test://resource1');
|
||||
expect(result).toBe(mockResource);
|
||||
});
|
||||
|
||||
it('should try ResourceRegistry.findResourceByUri first', () => {
|
||||
const mockResourceQualified = {
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1 Qualified',
|
||||
};
|
||||
const mockResourceDirect = {
|
||||
uri: 'test-server:test://resource1',
|
||||
name: 'Resource 1 Direct',
|
||||
};
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([mockResourceDirect]),
|
||||
findResourceByUri: vi.fn().mockReturnValue(mockResourceQualified),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('test-server:test://resource1');
|
||||
expect(result).toBe(mockResourceQualified);
|
||||
expect(mockResourceRegistry.findResourceByUri).toHaveBeenCalledWith(
|
||||
'test-server:test://resource1',
|
||||
);
|
||||
expect(mockResourceRegistry.getAllResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined if both fail', () => {
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([]),
|
||||
findResourceByUri: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { stableStringify } from '../policy/stable-stringify.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import type {
|
||||
ResourceRegistry,
|
||||
MCPResource,
|
||||
} from '../resources/resource-registry.js';
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of multiple MCP clients, including local child processes.
|
||||
@@ -161,7 +164,32 @@ export class McpClientManager {
|
||||
}
|
||||
|
||||
getClient(serverName: string): McpClient | undefined {
|
||||
return this.clients.get(serverName);
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.getServerName() === serverName) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findResourceByUri(uri: string): MCPResource | undefined {
|
||||
if (!this.mainResourceRegistry) return undefined;
|
||||
|
||||
// Try serverName:uri format first
|
||||
const qualifiedMatch = this.mainResourceRegistry.findResourceByUri(uri);
|
||||
if (qualifiedMatch) {
|
||||
return qualifiedMatch;
|
||||
}
|
||||
|
||||
// Try direct URI match
|
||||
return this.mainResourceRegistry
|
||||
.getAllResources()
|
||||
.find((r) => r.uri === uri);
|
||||
}
|
||||
|
||||
getAllResources(): MCPResource[] {
|
||||
if (!this.mainResourceRegistry) return [];
|
||||
return this.mainResourceRegistry.getAllResources();
|
||||
}
|
||||
|
||||
removeRegistries(registries: {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { ReadMcpResourceTool } from './read-mcp-resource.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
describe('ReadMcpResourceTool', () => {
|
||||
let tool: ReadMcpResourceTool;
|
||||
let mockContext: {
|
||||
config: {
|
||||
getMcpClientManager: Mock;
|
||||
};
|
||||
};
|
||||
let mockMcpManager: {
|
||||
findResourceByUri: Mock;
|
||||
getClient: Mock;
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMcpManager = {
|
||||
findResourceByUri: vi.fn(),
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
config: {
|
||||
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
|
||||
},
|
||||
};
|
||||
|
||||
tool = new ReadMcpResourceTool(
|
||||
mockContext as unknown as AgentLoopContext,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully read a resource', async () => {
|
||||
const uri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
const resourceName = 'Test Resource';
|
||||
const resourceContent = 'Resource Content';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri,
|
||||
serverName,
|
||||
name: resourceName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
contents: [{ text: resourceContent }],
|
||||
}),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
getDescription: () => string;
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri });
|
||||
|
||||
// Verify description
|
||||
expect(invocation.getDescription()).toBe(
|
||||
`Read MCP resource "${resourceName}" from server "${serverName}"`,
|
||||
);
|
||||
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(uri);
|
||||
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
|
||||
expect(mockClient.readResource).toHaveBeenCalledWith(uri);
|
||||
expect(result).toEqual({
|
||||
llmContent: resourceContent + '\n',
|
||||
returnDisplay: `Successfully read resource "${resourceName}" from server "${serverName}"`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass raw URI to client when using qualified URI', async () => {
|
||||
const qualifiedUri = 'test-server:protocol://resource';
|
||||
const rawUri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
const resourceName = 'Test Resource';
|
||||
const resourceContent = 'Resource Content';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri: rawUri,
|
||||
serverName,
|
||||
name: resourceName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
contents: [{ text: resourceContent }],
|
||||
}),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: qualifiedUri });
|
||||
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(qualifiedUri);
|
||||
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
|
||||
expect(mockClient.readResource).toHaveBeenCalledWith(rawUri);
|
||||
expect(result.llmContent).toBe(resourceContent + '\n');
|
||||
});
|
||||
|
||||
it('should return error if MCP Client Manager not available', async () => {
|
||||
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: 'uri' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
|
||||
expect(result.error?.message).toContain('MCP Client Manager not available');
|
||||
});
|
||||
|
||||
it('should return error if resource not found', async () => {
|
||||
mockMcpManager.findResourceByUri.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: 'uri' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.MCP_RESOURCE_NOT_FOUND);
|
||||
expect(result.error?.message).toContain('Resource not found');
|
||||
});
|
||||
|
||||
it('should return error if reading fails', async () => {
|
||||
const uri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri,
|
||||
serverName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockRejectedValue(new Error('Failed to read')),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
|
||||
expect(result.error?.message).toContain('Failed to read resource');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { READ_MCP_RESOURCE_TOOL_NAME } from './tool-names.js';
|
||||
import { READ_MCP_RESOURCE_DEFINITION } from './definitions/coreTools.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { MCPResource } from '../resources/resource-registry.js';
|
||||
|
||||
export interface ReadMcpResourceParams {
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export class ReadMcpResourceTool extends BaseDeclarativeTool<
|
||||
ReadMcpResourceParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = READ_MCP_RESOURCE_TOOL_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ReadMcpResourceTool.Name,
|
||||
'Read MCP Resource',
|
||||
READ_MCP_RESOURCE_DEFINITION.base.description!,
|
||||
Kind.Read,
|
||||
READ_MCP_RESOURCE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ReadMcpResourceParams,
|
||||
): ReadMcpResourceToolInvocation {
|
||||
return new ReadMcpResourceToolInvocation(
|
||||
this.context,
|
||||
params,
|
||||
this.messageBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReadMcpResourceToolInvocation extends BaseToolInvocation<
|
||||
ReadMcpResourceParams,
|
||||
ToolResult
|
||||
> {
|
||||
private resource: MCPResource | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: ReadMcpResourceParams,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(params, messageBus, ReadMcpResourceTool.Name, 'Read MCP Resource');
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
this.resource = mcpManager?.findResourceByUri(params.uri);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.resource) {
|
||||
return `Read MCP resource "${this.resource.name}" from server "${this.resource.serverName}"`;
|
||||
}
|
||||
return `Read MCP resource: ${this.params.uri}`;
|
||||
}
|
||||
|
||||
async execute({
|
||||
abortSignal: _abortSignal,
|
||||
}: ExecuteOptions): Promise<ToolResult> {
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
if (!mcpManager) {
|
||||
return {
|
||||
llmContent: 'Error: MCP Client Manager not available.',
|
||||
returnDisplay: 'Error: MCP Client Manager not available.',
|
||||
error: {
|
||||
message: 'MCP Client Manager not available.',
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uri = this.params.uri;
|
||||
if (!uri) {
|
||||
return {
|
||||
llmContent: 'Error: No URI provided.',
|
||||
returnDisplay: 'Error: No URI provided.',
|
||||
error: {
|
||||
message: 'No URI provided.',
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resource = mcpManager.findResourceByUri(uri);
|
||||
if (!resource) {
|
||||
const errorMessage = `Resource not found for URI: ${uri}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MCP_RESOURCE_NOT_FOUND,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const client = mcpManager.getClient(resource.serverName);
|
||||
if (!client) {
|
||||
const errorMessage = `MCP Client not found for server: ${resource.serverName}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.readResource(resource.uri);
|
||||
// The result should contain contents.
|
||||
// Let's assume it returns a string or an object with contents.
|
||||
// According to MCP spec, it returns { contents: [...] }.
|
||||
// We should format it nicely.
|
||||
let contentText = '';
|
||||
if (result && result.contents) {
|
||||
for (const content of result.contents) {
|
||||
if ('text' in content && content.text) {
|
||||
contentText += content.text + '\n';
|
||||
} else if ('blob' in content && content.blob) {
|
||||
contentText += `[Binary Data (${content.mimeType})]` + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: contentText || 'No content returned from resource.',
|
||||
returnDisplay: this.resource
|
||||
? `Successfully read resource "${this.resource.name}" from server "${this.resource.serverName}"`
|
||||
: `Successfully read resource: ${uri}`,
|
||||
};
|
||||
} catch (e) {
|
||||
const errorMessage = `Failed to read resource: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MCP_TOOL_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export enum ToolErrorType {
|
||||
|
||||
// MCP-specific Errors
|
||||
MCP_TOOL_ERROR = 'mcp_tool_error',
|
||||
MCP_RESOURCE_NOT_FOUND = 'mcp_resource_not_found',
|
||||
|
||||
// Memory-specific Errors
|
||||
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
|
||||
|
||||
@@ -79,6 +79,8 @@ import {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
@@ -106,6 +108,8 @@ export {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -272,6 +276,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
AGENT_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -291,6 +297,8 @@ export const PLAN_MODE_TOOLS = [
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
'codebase_investigator',
|
||||
'cli_help',
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
} from './tool-names.js';
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
@@ -602,6 +604,16 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
tool.name === READ_MCP_RESOURCE_TOOL_NAME ||
|
||||
tool.name === LIST_MCP_RESOURCES_TOOL_NAME
|
||||
) {
|
||||
const mcpManager = this.config.getMcpClientManager();
|
||||
if (!mcpManager || mcpManager.getAllResources().length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
if (
|
||||
(tool.name === ENTER_PLAN_MODE_TOOL_NAME && isPlanMode) ||
|
||||
|
||||
Reference in New Issue
Block a user