Skip to content

Commit c6cc13e

Browse files
authored
feat(mcp): add resource template listing (#33546)
1 parent dcf7b4e commit c6cc13e

6 files changed

Lines changed: 151 additions & 8 deletions

File tree

packages/opencode/src/mcp/catalog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ export function resources(client: Client, timeout?: number) {
129129
)
130130
}
131131

132+
export function resourceTemplates(client: Client, timeout?: number) {
133+
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
134+
return paginate(
135+
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
136+
(result) => result.resourceTemplates,
137+
)
138+
}
139+
132140
function listTools(client: Client, timeout: number) {
133141
return Effect.tryPromise({
134142
try: () =>

packages/opencode/src/mcp/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
125125
// Prompt cache types
126126
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
127127
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
128+
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
128129
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
129130

130131
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
@@ -162,6 +163,9 @@ export interface Interface {
162163
readonly tools: () => Effect.Effect<Record<string, Tool>>
163164
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
164165
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
166+
readonly resourceTemplates: (
167+
clientName?: string,
168+
) => Effect.Effect<Record<string, ResourceTemplateInfo & { client: string }>>
165169
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
166170
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
167171
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
@@ -690,6 +694,16 @@ export const layer = Layer.effect(
690694
)
691695
})
692696

697+
const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) {
698+
return yield* collectFromConnected(
699+
yield* InstanceState.get(state),
700+
McpCatalog.resourceTemplates,
701+
"resource templates",
702+
(template) => template.uriTemplate,
703+
clientName,
704+
)
705+
})
706+
693707
const withClient = Effect.fnUntraced(function* <A>(
694708
clientName: string,
695709
fn: (client: MCPClient, timeout?: number) => Promise<A>,
@@ -931,6 +945,7 @@ export const layer = Layer.effect(
931945
tools,
932946
prompts,
933947
resources,
948+
resourceTemplates,
934949
add,
935950
connect,
936951
disconnect,

packages/opencode/src/session/tools.ts

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
2222
import { ModelV2 } from "@opencode-ai/core/model"
2323
import { isRecord } from "@/util/record"
2424

25-
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
26-
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
25+
const MCP_RESOURCE_TOOLS = {
26+
list: "list_mcp_resources",
27+
listTemplates: "list_mcp_resource_templates",
28+
read: "read_mcp_resource",
29+
} as const
2730
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
2831
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
2932
"application/pdf",
@@ -130,7 +133,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
130133
(client) => !!client.getServerCapabilities()?.resources,
131134
)
132135
if (hasMcpResourceServer) {
133-
tools[LIST_MCP_RESOURCES_TOOL] = tool({
136+
tools[MCP_RESOURCE_TOOLS.list] = tool({
134137
description:
135138
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
136139
inputSchema: jsonSchema(
@@ -167,7 +170,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
167170
: resourceServers.map((server) => `mcp:${server}:*`)
168171
yield* plugin.trigger(
169172
"tool.execute.before",
170-
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
173+
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
171174
{ args },
172175
)
173176
yield* ctx.ask({
@@ -200,7 +203,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
200203
}
201204
yield* plugin.trigger(
202205
"tool.execute.after",
203-
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
206+
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
204207
output,
205208
)
206209
if (opts.abortSignal?.aborted) {
@@ -212,7 +215,89 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
212215
},
213216
})
214217

215-
tools[READ_MCP_RESOURCE_TOOL] = tool({
218+
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
219+
description:
220+
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
221+
inputSchema: jsonSchema(
222+
ProviderTransform.schema(input.model, {
223+
type: "object",
224+
properties: {
225+
server: {
226+
type: "string",
227+
description: "Optional MCP server name. When omitted, lists resource templates from every connected server.",
228+
},
229+
},
230+
additionalProperties: false,
231+
}),
232+
),
233+
execute(args, opts) {
234+
return run.promise(
235+
Effect.gen(function* () {
236+
const parsed = parseListMcpResourcesArgs(args)
237+
const ctx = context(toRecord(args), opts)
238+
const clients = yield* mcp.clients()
239+
const resourceServers = Object.entries(clients)
240+
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
241+
.map((entry) => entry[0])
242+
.sort((a, b) => a.localeCompare(b))
243+
if (parsed.server && !resourceServers.includes(parsed.server)) {
244+
throw new Error(
245+
resourceServers.length === 0
246+
? `MCP server "${parsed.server}" does not support resources`
247+
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
248+
)
249+
}
250+
const permissionPatterns = parsed.server
251+
? [`mcp:${parsed.server}:*`]
252+
: resourceServers.map((server) => `mcp:${server}:*`)
253+
yield* plugin.trigger(
254+
"tool.execute.before",
255+
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
256+
{ args },
257+
)
258+
yield* ctx.ask({
259+
permission: "read",
260+
metadata: parsed.server ? { server: parsed.server } : {},
261+
patterns: permissionPatterns,
262+
always: permissionPatterns,
263+
})
264+
265+
const templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
266+
const filtered = templates
267+
.filter((template) => !parsed.server || template.client === parsed.server)
268+
.toSorted((a, b) =>
269+
(a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
270+
b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
271+
),
272+
)
273+
const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
274+
const truncated = yield* truncate.output(content, {}, input.agent)
275+
const output = {
276+
title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
277+
metadata: {
278+
count: filtered.length,
279+
servers: resourceServers,
280+
...(parsed.server ? { server: parsed.server } : {}),
281+
truncated: truncated.truncated,
282+
...(truncated.truncated && { outputPath: truncated.outputPath }),
283+
},
284+
output: truncated.content,
285+
}
286+
yield* plugin.trigger(
287+
"tool.execute.after",
288+
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
289+
output,
290+
)
291+
if (opts.abortSignal?.aborted) {
292+
yield* input.processor.completeToolCall(opts.toolCallId, output)
293+
}
294+
return output
295+
}),
296+
)
297+
},
298+
})
299+
300+
tools[MCP_RESOURCE_TOOLS.read] = tool({
216301
description:
217302
"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.",
218303
inputSchema: jsonSchema(
@@ -247,7 +332,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
247332
}
248333
yield* plugin.trigger(
249334
"tool.execute.before",
250-
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
335+
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
251336
{ args },
252337
)
253338
yield* ctx.ask({
@@ -282,7 +367,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
282367
}
283368
yield* plugin.trigger(
284369
"tool.execute.after",
285-
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
370+
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
286371
output,
287372
)
288373
if (opts.abortSignal?.aborted) {
@@ -432,6 +517,11 @@ function formatMcpResource(resource: MCP.Resource) {
432517
return { ...result, server: resource.client }
433518
}
434519

520+
function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
521+
const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
522+
return { ...result, server: template.client }
523+
}
524+
435525
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
436526
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
437527
const text: string[] = []

packages/opencode/test/mcp/lifecycle.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface MockClientState {
1717
listToolsCalls: number
1818
listPromptsCalls: number
1919
listResourcesCalls: number
20+
listResourceTemplatesCalls: number
2021
getPromptTimeout?: number
2122
readResourceTimeout?: number
2223
requestCalls: number
@@ -26,6 +27,7 @@ interface MockClientState {
2627
listResourcesShouldFail: boolean
2728
prompts: Array<{ name: string; description?: string }>
2829
resources: Array<{ name: string; uri: string; description?: string }>
30+
resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
2931
toolPages: Record<
3032
string,
3133
{
@@ -38,6 +40,10 @@ interface MockClientState {
3840
string,
3941
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
4042
>
43+
resourceTemplatePages: Record<
44+
string,
45+
{ resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string }
46+
>
4147
closed: boolean
4248
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
4349
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
@@ -67,16 +73,19 @@ function getOrCreateClientState(name?: string): MockClientState {
6773
listToolsCalls: 0,
6874
listPromptsCalls: 0,
6975
listResourcesCalls: 0,
76+
listResourceTemplatesCalls: 0,
7077
requestCalls: 0,
7178
listToolsShouldFail: false,
7279
listToolsError: "listTools failed",
7380
listPromptsShouldFail: false,
7481
listResourcesShouldFail: false,
7582
prompts: [],
7683
resources: [],
84+
resourceTemplates: [],
7785
toolPages: {},
7886
promptPages: {},
7987
resourcePages: {},
88+
resourceTemplatePages: {},
8089
closed: false,
8190
requestHandlers: new Map(),
8291
notificationHandlers: new Map(),
@@ -224,6 +233,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
224233
return { resources: this._state?.resources ?? [] }
225234
}
226235

236+
async listResourceTemplates(params?: { cursor?: string }) {
237+
if (this._state) this._state.listResourceTemplatesCalls++
238+
const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")]
239+
if (page) return page
240+
return { resourceTemplates: this._state?.resourceTemplates ?? [] }
241+
}
242+
227243
async getPrompt(_params: unknown, options?: { timeout?: number }) {
228244
if (this._state) this._state.getPromptTimeout = options?.timeout
229245
return { messages: [] }
@@ -353,6 +369,13 @@ it.instance(
353369
initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
354370
"resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
355371
}
372+
serverState.resourceTemplatePages = {
373+
initial: {
374+
resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }],
375+
nextCursor: "resource-templates-2",
376+
},
377+
"resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
378+
}
356379

357380
yield* mcp.add("paged-server", {
358381
type: "local",
@@ -362,9 +385,14 @@ it.instance(
362385
expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"])
363386
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
364387
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
388+
expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
389+
"paged-server:test://one/{id}",
390+
"paged-server:test://two/{id}",
391+
])
365392
expect(serverState.listToolsCalls).toBe(2)
366393
expect(serverState.listPromptsCalls).toBe(2)
367394
expect(serverState.listResourcesCalls).toBe(2)
395+
expect(serverState.listResourceTemplatesCalls).toBe(2)
368396
}),
369397
),
370398
{ config: { mcp: {} } },

packages/opencode/test/session/prompt.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ const mcp = Layer.succeed(
116116
tools: () => Effect.succeed({}),
117117
prompts: () => Effect.succeed({}),
118118
resources: () => Effect.succeed({}),
119+
resourceTemplates: () => Effect.succeed({}),
119120
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
120121
connect: () => Effect.void,
121122
disconnect: () => Effect.void,

packages/opencode/test/session/snapshot-tool-race.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const mcp = Layer.succeed(
4040
tools: () => Effect.succeed({}),
4141
prompts: () => Effect.succeed({}),
4242
resources: () => Effect.succeed({}),
43+
resourceTemplates: () => Effect.succeed({}),
4344
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
4445
connect: () => Effect.void,
4546
disconnect: () => Effect.void,

0 commit comments

Comments
 (0)