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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1126 additions and 6 deletions

View file

@ -130,7 +130,9 @@ These are the only allowed tools:
[`cli_help`](../core/subagents.md#cli-help-agent)
- **Interaction:** [`ask_user`](../tools/ask-user.md)
- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
example, `github_read_issue`, `postgres_read_schema`) are allowed.
example, `github_read_issue`, `postgres_read_schema`) and core
[MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`,
`read_mcp_resource`) are allowed.
- **Planning (Write):**
[`write_file`](../tools/file-system.md#3-write_file-writefile) and
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`

View file

@ -92,6 +92,13 @@ each tool.
| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. |
| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. |
### MCP
| Tool | Kind | Description |
| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- |
| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. |
| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. |
### Memory
| Tool | Kind | Description |

View file

@ -122,7 +122,14 @@
}
]
},
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{
"label": "MCP servers",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/tools/mcp-server" },
{ "label": "Resource tools", "slug": "docs/tools/mcp-resources" }
]
},
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{

View file

@ -0,0 +1,44 @@
# MCP resource tools
MCP resource tools let Gemini CLI discover and retrieve data from contextual
resources exposed by Model Context Protocol (MCP) servers.
## 1. `list_mcp_resources` (ListMcpResources)
`list_mcp_resources` retrieves a list of all available resources from connected
MCP servers. This is primarily a discovery tool that helps the model understand
what external data sources are available for reference.
- **Tool name:** `list_mcp_resources`
- **Display name:** List MCP Resources
- **Kind:** `Search`
- **File:** `list-mcp-resources.ts`
- **Parameters:**
- `serverName` (string, optional): An optional filter to list resources from a
specific server.
- **Behavior:**
- Iterates through all connected MCP servers.
- Fetches the list of resources each server exposes.
- Formats the results into a plain-text list of URIs and descriptions.
- **Output (`llmContent`):** A formatted list of available resources, including
their URI, server name, and optional description.
- **Confirmation:** No. This is a read-only discovery tool.
## 2. `read_mcp_resource` (ReadMcpResource)
`read_mcp_resource` retrieves the content of a specific resource identified by
its URI.
- **Tool name:** `read_mcp_resource`
- **Display name:** Read MCP Resource
- **Kind:** `Read`
- **File:** `read-mcp-resource.ts`
- **Parameters:**
- `uri` (string, required): The URI of the MCP resource to read.
- **Behavior:**
- Locates the resource and its associated server by URI.
- Calls the server's `resources/read` method.
- Processes the response, extracting text or binary data.
- **Output (`llmContent`):** The content of the resource. For binary data, it
returns a placeholder indicating the data type.
- **Confirmation:** No. This is a read-only retrieval tool.

View file

@ -64,7 +64,8 @@ Gemini CLI supports three MCP transport types:
Some MCP servers expose contextual “resources” in addition to the tools and
prompts. Gemini CLI discovers these automatically and gives you the possibility
to reference them in the chat.
to reference them in the chat. For more information on the tools used to
interact with these resources, see [MCP resource tools](mcp-resources.md).
### Discovery and listing

View file

@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}

View file

@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}

View file

@ -0,0 +1,4 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}

View file

@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
describe('mcp-resources-integration', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should list mcp resources', async () => {
await rig.setup('mcp-list-resources-test', {
settings: {
model: {
name: 'gemini-3-flash-preview',
},
},
fakeResponsesPath: join(__dirname, 'mcp-list-resources.responses'),
});
// Workaround for ProjectRegistry save issue
const userGeminiDir = join(rig.homeDir!, '.gemini');
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
// Add a dummy server to get setup done
rig.addTestMcpServer('resource-server', {
name: 'resource-server',
tools: [],
});
// Overwrite the script with resource support
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
const scriptContent = `
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListResourcesRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{
name: 'resource-server',
version: '1.0.0',
},
{
capabilities: {
resources: {},
},
},
);
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: 'test://resource1',
name: 'Resource 1',
mimeType: 'text/plain',
description: 'A test resource',
}
],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
`;
fs.writeFileSync(scriptPath, scriptContent);
const output = await rig.run({
args: 'List all available MCP resources.',
env: { GEMINI_API_KEY: 'dummy' },
});
const foundCall = await rig.waitForToolCall('list_mcp_resources');
expect(foundCall).toBeTruthy();
expect(output).toContain('test://resource1');
}, 60000);
it('should read mcp resource', async () => {
await rig.setup('mcp-read-resource-test', {
settings: {
model: {
name: 'gemini-3-flash-preview',
},
},
fakeResponsesPath: join(__dirname, 'mcp-read-resource.responses'),
});
// Workaround for ProjectRegistry save issue
const userGeminiDir = join(rig.homeDir!, '.gemini');
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
// Add a dummy server to get setup done
rig.addTestMcpServer('resource-server', {
name: 'resource-server',
tools: [],
});
// Overwrite the script with resource support
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
const scriptContent = `
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{
name: 'resource-server',
version: '1.0.0',
},
{
capabilities: {
resources: {},
},
},
);
// Need to provide list resources so the tool is active!
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: [
{
uri: 'test://resource1',
name: 'Resource 1',
mimeType: 'text/plain',
description: 'A test resource',
}
],
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (request.params.uri === 'test://resource1') {
return {
contents: [
{
uri: 'test://resource1',
mimeType: 'text/plain',
text: 'This is the content of resource 1',
}
],
};
}
throw new Error('Resource not found');
});
const transport = new StdioServerTransport();
await server.connect(transport);
`;
fs.writeFileSync(scriptPath, scriptContent);
const output = await rig.run({
args: 'Read the MCP resource test://resource1.',
env: { GEMINI_API_KEY: 'dummy' },
});
const foundCall = await rig.waitForToolCall('read_mcp_resource');
expect(foundCall).toBeTruthy();
expect(output).toContain('content of resource 1');
}, 60000);
});

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)),
);

View file

@ -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

View file

@ -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 });
}

View file

@ -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';

View file

@ -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,
};

View file

@ -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: [],
},
},
};

View file

@ -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: [],
},
},
};

View file

@ -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;
}

View file

@ -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');
});
});

View file

@ -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.`,
};
}
}

View file

@ -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();
});
});
});

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: {

View file

@ -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');
});
});

View file

@ -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,
},
};
}
}
}

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',

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;
/**

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) ||