feat(core): add tools to list and read MCP resources (#25395)

This commit is contained in:
ruomeng
2026-04-16 13:57:43 -04:00
committed by GitHub
parent 963631a3d4
commit f16f1cced3
26 changed files with 1126 additions and 6 deletions
+8
View File
@@ -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();
});
});
});
+30 -2
View File
@@ -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,
},
};
}
}
}
+1
View File
@@ -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',
+8
View File
@@ -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;
/**
+12
View File
@@ -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) ||