From f16f1cced3e8750a1a7b13cfc6e4577cf5308af8 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Thu, 16 Apr 2026 13:57:43 -0400 Subject: [PATCH] feat(core): add tools to list and read MCP resources (#25395) --- docs/cli/plan-mode.md | 4 +- docs/reference/tools.md | 7 + docs/sidebar.json | 9 +- docs/tools/mcp-resources.md | 44 ++++ docs/tools/mcp-server.md | 3 +- .../mcp-list-resources.responses | 2 + integration-tests/mcp-read-resource.responses | 2 + integration-tests/mcp-resources.responses | 4 + integration-tests/mcp-resources.test.ts | 178 ++++++++++++++++ packages/core/src/config/config.ts | 8 + .../core/src/policy/policies/read-only.toml | 5 +- packages/core/src/policy/toml-loader.test.ts | 19 ++ .../tools/definitions/base-declarations.ts | 4 + .../core/src/tools/definitions/coreTools.ts | 16 ++ .../model-family-sets/default-legacy.ts | 35 ++++ .../definitions/model-family-sets/gemini-3.ts | 35 ++++ packages/core/src/tools/definitions/types.ts | 2 + .../core/src/tools/list-mcp-resources.test.ts | 156 ++++++++++++++ packages/core/src/tools/list-mcp-resources.ts | 123 +++++++++++ .../core/src/tools/mcp-client-manager.test.ts | 60 ++++++ packages/core/src/tools/mcp-client-manager.ts | 32 ++- .../core/src/tools/read-mcp-resource.test.ts | 194 ++++++++++++++++++ packages/core/src/tools/read-mcp-resource.ts | 169 +++++++++++++++ packages/core/src/tools/tool-error.ts | 1 + packages/core/src/tools/tool-names.ts | 8 + packages/core/src/tools/tool-registry.ts | 12 ++ 26 files changed, 1126 insertions(+), 6 deletions(-) create mode 100644 docs/tools/mcp-resources.md create mode 100644 integration-tests/mcp-list-resources.responses create mode 100644 integration-tests/mcp-read-resource.responses create mode 100644 integration-tests/mcp-resources.responses create mode 100644 integration-tests/mcp-resources.test.ts create mode 100644 packages/core/src/tools/list-mcp-resources.test.ts create mode 100644 packages/core/src/tools/list-mcp-resources.ts create mode 100644 packages/core/src/tools/read-mcp-resource.test.ts create mode 100644 packages/core/src/tools/read-mcp-resource.ts diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 00677943ad..8a6d0b5370 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -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` diff --git a/docs/reference/tools.md b/docs/reference/tools.md index a33742a7a8..46708e16bc 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.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 | diff --git a/docs/sidebar.json b/docs/sidebar.json index ad5741699e..0d94b1ac60 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -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" }, { diff --git a/docs/tools/mcp-resources.md b/docs/tools/mcp-resources.md new file mode 100644 index 0000000000..a6dad26e10 --- /dev/null +++ b/docs/tools/mcp-resources.md @@ -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. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index f74ba1de12..d9d8835c8c 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -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 diff --git a/integration-tests/mcp-list-resources.responses b/integration-tests/mcp-list-resources.responses new file mode 100644 index 0000000000..d3f3e134e9 --- /dev/null +++ b/integration-tests/mcp-list-resources.responses @@ -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}}]} diff --git a/integration-tests/mcp-read-resource.responses b/integration-tests/mcp-read-resource.responses new file mode 100644 index 0000000000..9ba9da205a --- /dev/null +++ b/integration-tests/mcp-read-resource.responses @@ -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}}]} diff --git a/integration-tests/mcp-resources.responses b/integration-tests/mcp-resources.responses new file mode 100644 index 0000000000..6a3307ddb4 --- /dev/null +++ b/integration-tests/mcp-resources.responses @@ -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}}]} diff --git a/integration-tests/mcp-resources.test.ts b/integration-tests/mcp-resources.test.ts new file mode 100644 index 0000000000..ac04e36e38 --- /dev/null +++ b/integration-tests/mcp-resources.test.ts @@ -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); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a9c0b813ee..95b1dae1d6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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)), ); diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 0a8b465fe8..3d010956fc 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -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 diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 1d3c4e0eb6..494f5a9bb5 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -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 }); } diff --git a/packages/core/src/tools/definitions/base-declarations.ts b/packages/core/src/tools/definitions/base-declarations.ts index 89a5aa1614..bb0c0c3c54 100644 --- a/packages/core/src/tools/definitions/base-declarations.ts +++ b/packages/core/src/tools/definitions/base-declarations.ts @@ -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'; diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts index d1b81a6e99..38c2e5798c 100644 --- a/packages/core/src/tools/definitions/coreTools.ts +++ b/packages/core/src/tools/definitions/coreTools.ts @@ -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, +}; diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 60a52fc6ad..aa801de608 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -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: [], + }, + }, }; diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a86a20378e..03872b045d 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -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: [], + }, + }, }; diff --git a/packages/core/src/tools/definitions/types.ts b/packages/core/src/tools/definitions/types.ts index 42c0cc7028..06f946e23f 100644 --- a/packages/core/src/tools/definitions/types.ts +++ b/packages/core/src/tools/definitions/types.ts @@ -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; } diff --git a/packages/core/src/tools/list-mcp-resources.test.ts b/packages/core/src/tools/list-mcp-resources.test.ts new file mode 100644 index 0000000000..abc44842ee --- /dev/null +++ b/packages/core/src/tools/list-mcp-resources.test.ts @@ -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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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'); + }); +}); diff --git a/packages/core/src/tools/list-mcp-resources.ts b/packages/core/src/tools/list-mcp-resources.ts new file mode 100644 index 0000000000..0787bf3900 --- /dev/null +++ b/packages/core/src/tools/list-mcp-resources.ts @@ -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 { + 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.`, + }; + } +} diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index a96f3f7d29..83aa2b59a4 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -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(); + }); + }); }); diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 3e7ef75d4c..b109e2ac03 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -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: { diff --git a/packages/core/src/tools/read-mcp-resource.test.ts b/packages/core/src/tools/read-mcp-resource.test.ts new file mode 100644 index 0000000000..f548b934e2 --- /dev/null +++ b/packages/core/src/tools/read-mcp-resource.test.ts @@ -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) => { + getDescription: () => string; + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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) => { + execute: (options: { abortSignal: AbortSignal }) => Promise; + }; + } + ).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'); + }); +}); diff --git a/packages/core/src/tools/read-mcp-resource.ts b/packages/core/src/tools/read-mcp-resource.ts new file mode 100644 index 0000000000..13105afa10 --- /dev/null +++ b/packages/core/src/tools/read-mcp-resource.ts @@ -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 { + 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, + }, + }; + } + } +} diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 3ab221404a..260d7e2bf0 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -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', diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index faaa90f076..f8337fcf1d 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -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; /** diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index c4c194620f..ea21a5dc3e 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -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; @@ -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) ||