From c6cc13e18341badce7c0c33efd2690d2b95438ae Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 24 Jun 2026 01:59:12 +0530 Subject: [PATCH] feat(mcp): add resource template listing (#33546) --- packages/opencode/src/mcp/catalog.ts | 8 ++ packages/opencode/src/mcp/index.ts | 15 +++ packages/opencode/src/session/tools.ts | 106 ++++++++++++++++-- packages/opencode/test/mcp/lifecycle.test.ts | 28 +++++ packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index b09adb221..0cd2238ed 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -129,6 +129,14 @@ export function resources(client: Client, timeout?: number) { ) } +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) +} + function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ed2cdf1f9..7970b9ba2 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map() // Prompt cache types type PromptInfo = Awaited>["prompts"][number] type ResourceInfo = Awaited>["resources"][number] +type ResourceTemplateInfo = Awaited>["resourceTemplates"][number] type McpEntry = NonNullable[string] function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { @@ -162,6 +163,9 @@ export interface Interface { readonly tools: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> + readonly resourceTemplates: ( + clientName?: string, + ) => Effect.Effect> readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record | Status }> readonly connect: (name: string) => Effect.Effect readonly disconnect: (name: string) => Effect.Effect @@ -690,6 +694,16 @@ export const layer = Layer.effect( ) }) + const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) { + return yield* collectFromConnected( + yield* InstanceState.get(state), + McpCatalog.resourceTemplates, + "resource templates", + (template) => template.uriTemplate, + clientName, + ) + }) + const withClient = Effect.fnUntraced(function* ( clientName: string, fn: (client: MCPClient, timeout?: number) => Promise, @@ -931,6 +945,7 @@ export const layer = Layer.effect( tools, prompts, resources, + resourceTemplates, add, connect, disconnect, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 371a6c9a3..484a3466a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -22,8 +22,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" -const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources" -const READ_MCP_RESOURCE_TOOL = "read_mcp_resource" +const MCP_RESOURCE_TOOLS = { + list: "list_mcp_resources", + listTemplates: "list_mcp_resource_templates", + read: "read_mcp_resource", +} as const const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024 const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ "application/pdf", @@ -130,7 +133,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { (client) => !!client.getServerCapabilities()?.resources, ) if (hasMcpResourceServer) { - tools[LIST_MCP_RESOURCES_TOOL] = tool({ + tools[MCP_RESOURCE_TOOLS.list] = tool({ description: "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.", inputSchema: jsonSchema( @@ -167,7 +170,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { : resourceServers.map((server) => `mcp:${server}:*`) yield* plugin.trigger( "tool.execute.before", - { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) yield* ctx.ask({ @@ -200,7 +203,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.after", - { tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + { tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, output, ) if (opts.abortSignal?.aborted) { @@ -212,7 +215,89 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }, }) - tools[READ_MCP_RESOURCE_TOOL] = tool({ + tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({ + description: + "Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.", + inputSchema: jsonSchema( + ProviderTransform.schema(input.model, { + type: "object", + properties: { + server: { + type: "string", + description: "Optional MCP server name. When omitted, lists resource templates from every connected server.", + }, + }, + additionalProperties: false, + }), + ), + execute(args, opts) { + return run.promise( + Effect.gen(function* () { + const parsed = parseListMcpResourcesArgs(args) + const ctx = context(toRecord(args), opts) + const clients = yield* mcp.clients() + const resourceServers = Object.entries(clients) + .filter((entry) => !!entry[1].getServerCapabilities()?.resources) + .map((entry) => entry[0]) + .sort((a, b) => a.localeCompare(b)) + if (parsed.server && !resourceServers.includes(parsed.server)) { + throw new Error( + resourceServers.length === 0 + ? `MCP server "${parsed.server}" does not support resources` + : `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`, + ) + } + const permissionPatterns = parsed.server + ? [`mcp:${parsed.server}:*`] + : resourceServers.map((server) => `mcp:${server}:*`) + yield* plugin.trigger( + "tool.execute.before", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { args }, + ) + yield* ctx.ask({ + permission: "read", + metadata: parsed.server ? { server: parsed.server } : {}, + patterns: permissionPatterns, + always: permissionPatterns, + }) + + const templates = Object.values(yield* mcp.resourceTemplates(parsed.server)) + const filtered = templates + .filter((template) => !parsed.server || template.client === parsed.server) + .toSorted((a, b) => + (a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare( + b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate, + ), + ) + const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2) + const truncated = yield* truncate.output(content, {}, input.agent) + const output = { + title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates", + metadata: { + count: filtered.length, + servers: resourceServers, + ...(parsed.server ? { server: parsed.server } : {}), + truncated: truncated.truncated, + ...(truncated.truncated && { outputPath: truncated.outputPath }), + }, + output: truncated.content, + } + yield* plugin.trigger( + "tool.execute.after", + { tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + output, + ) + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output + }), + ) + }, + }) + + tools[MCP_RESOURCE_TOOLS.read] = tool({ description: "Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.", inputSchema: jsonSchema( @@ -247,7 +332,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.before", - { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId }, + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) yield* ctx.ask({ @@ -282,7 +367,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { } yield* plugin.trigger( "tool.execute.after", - { tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, + { tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, output, ) if (opts.abortSignal?.aborted) { @@ -432,6 +517,11 @@ function formatMcpResource(resource: MCP.Resource) { return { ...result, server: resource.client } } +function formatMcpResourceTemplate(template: Record & { client: string }) { + const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client")) + return { ...result, server: template.client } +} + function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) { const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord) const text: string[] = [] diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index e40bc5121..9d19bf79a 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -17,6 +17,7 @@ interface MockClientState { listToolsCalls: number listPromptsCalls: number listResourcesCalls: number + listResourceTemplatesCalls: number getPromptTimeout?: number readResourceTimeout?: number requestCalls: number @@ -26,6 +27,7 @@ interface MockClientState { listResourcesShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> + resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }> toolPages: Record< string, { @@ -38,6 +40,10 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > + resourceTemplatePages: Record< + string, + { resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string } + > closed: boolean clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } requestHandlers: Map Promise> @@ -67,6 +73,7 @@ function getOrCreateClientState(name?: string): MockClientState { listToolsCalls: 0, listPromptsCalls: 0, listResourcesCalls: 0, + listResourceTemplatesCalls: 0, requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", @@ -74,9 +81,11 @@ function getOrCreateClientState(name?: string): MockClientState { listResourcesShouldFail: false, prompts: [], resources: [], + resourceTemplates: [], toolPages: {}, promptPages: {}, resourcePages: {}, + resourceTemplatePages: {}, closed: false, requestHandlers: new Map(), notificationHandlers: new Map(), @@ -224,6 +233,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async listResourceTemplates(params?: { cursor?: string }) { + if (this._state) this._state.listResourceTemplatesCalls++ + const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")] + if (page) return page + return { resourceTemplates: this._state?.resourceTemplates ?? [] } + } + async getPrompt(_params: unknown, options?: { timeout?: number }) { if (this._state) this._state.getPromptTimeout = options?.timeout return { messages: [] } @@ -353,6 +369,13 @@ it.instance( initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, } + serverState.resourceTemplatePages = { + initial: { + resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }], + nextCursor: "resource-templates-2", + }, + "resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] }, + } yield* mcp.add("paged-server", { type: "local", @@ -362,9 +385,14 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"]) + expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([ + "paged-server:test://one/{id}", + "paged-server:test://two/{id}", + ]) expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) + expect(serverState.listResourceTemplatesCalls).toBe(2) }), ), { config: { mcp: {} } }, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 0a3af92a0..ca1683e90 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -116,6 +116,7 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 98233f352..9f5b36f35 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -40,6 +40,7 @@ const mcp = Layer.succeed( tools: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), + resourceTemplates: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), connect: () => Effect.void, disconnect: () => Effect.void,