feat(mcp): add resource template listing (#33546)

This commit is contained in:
Shoubhit Dash
2026-06-24 01:59:12 +05:30
committed by GitHub
parent dcf7b4e792
commit c6cc13e183
6 changed files with 151 additions and 8 deletions
+8
View File
@@ -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: () =>
+15
View File
@@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
@@ -162,6 +163,9 @@ export interface Interface {
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
clientName?: string,
) => Effect.Effect<Record<string, ResourceTemplateInfo & { client: string }>>
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
@@ -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* <A>(
clientName: string,
fn: (client: MCPClient, timeout?: number) => Promise<A>,
@@ -931,6 +945,7 @@ export const layer = Layer.effect(
tools,
prompts,
resources,
resourceTemplates,
add,
connect,
disconnect,
+98 -8
View File
@@ -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<string, unknown> & { 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[] = []