mirror of
https://github.com/google-gemini/gemini-cli
synced 2026-04-21 13:37:17 +00:00
feat(core): add tools to list and read MCP resources (#25395)
This commit is contained in:
parent
963631a3d4
commit
f16f1cced3
26 changed files with 1126 additions and 6 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
{
|
||||
|
|
|
|||
44
docs/tools/mcp-resources.md
Normal file
44
docs/tools/mcp-resources.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
integration-tests/mcp-list-resources.responses
Normal file
2
integration-tests/mcp-list-resources.responses
Normal 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}}]}
|
||||
2
integration-tests/mcp-read-resource.responses
Normal file
2
integration-tests/mcp-read-resource.responses
Normal 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}}]}
|
||||
4
integration-tests/mcp-resources.responses
Normal file
4
integration-tests/mcp-resources.responses
Normal 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}}]}
|
||||
178
integration-tests/mcp-resources.test.ts
Normal file
178
integration-tests/mcp-resources.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
156
packages/core/src/tools/list-mcp-resources.test.ts
Normal file
156
packages/core/src/tools/list-mcp-resources.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
123
packages/core/src/tools/list-mcp-resources.ts
Normal file
123
packages/core/src/tools/list-mcp-resources.ts
Normal 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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
194
packages/core/src/tools/read-mcp-resource.test.ts
Normal file
194
packages/core/src/tools/read-mcp-resource.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
169
packages/core/src/tools/read-mcp-resource.ts
Normal file
169
packages/core/src/tools/read-mcp-resource.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue