Просмотр исходного кода

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

Shoubhit Dash 2 месяцев назад
Родитель
Сommit
c6cc13e183

+ 8 - 0
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) {
 function listTools(client: Client, timeout: number) {
   return Effect.tryPromise({
   return Effect.tryPromise({
     try: () =>
     try: () =>

+ 15 - 0
packages/opencode/src/mcp/index.ts

@@ -125,6 +125,7 @@ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
 // Prompt cache types
 // Prompt cache types
 type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
 type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
 type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
 type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
+type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
 type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
 type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
 
 
 function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
 function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
@@ -162,6 +163,9 @@ export interface Interface {
   readonly tools: () => Effect.Effect<Record<string, Tool>>
   readonly tools: () => Effect.Effect<Record<string, Tool>>
   readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
   readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
   readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { 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 add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
   readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
   readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
   readonly disconnect: (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>(
     const withClient = Effect.fnUntraced(function* <A>(
       clientName: string,
       clientName: string,
       fn: (client: MCPClient, timeout?: number) => Promise<A>,
       fn: (client: MCPClient, timeout?: number) => Promise<A>,
@@ -931,6 +945,7 @@ export const layer = Layer.effect(
       tools,
       tools,
       prompts,
       prompts,
       resources,
       resources,
+      resourceTemplates,
       add,
       add,
       connect,
       connect,
       disconnect,
       disconnect,

+ 98 - 8
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 { ModelV2 } from "@opencode-ai/core/model"
 import { isRecord } from "@/util/record"
 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 MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
 const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
 const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
   "application/pdf",
   "application/pdf",
@@ -130,7 +133,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
     (client) => !!client.getServerCapabilities()?.resources,
     (client) => !!client.getServerCapabilities()?.resources,
   )
   )
   if (hasMcpResourceServer) {
   if (hasMcpResourceServer) {
-    tools[LIST_MCP_RESOURCES_TOOL] = tool({
+    tools[MCP_RESOURCE_TOOLS.list] = tool({
       description:
       description:
         "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
         "Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
       inputSchema: jsonSchema(
       inputSchema: jsonSchema(
@@ -167,7 +170,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
               : resourceServers.map((server) => `mcp:${server}:*`)
               : resourceServers.map((server) => `mcp:${server}:*`)
             yield* plugin.trigger(
             yield* plugin.trigger(
               "tool.execute.before",
               "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 },
               { args },
             )
             )
             yield* ctx.ask({
             yield* ctx.ask({
@@ -200,7 +203,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
             }
             }
             yield* plugin.trigger(
             yield* plugin.trigger(
               "tool.execute.after",
               "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,
               output,
             )
             )
             if (opts.abortSignal?.aborted) {
             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:
       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.",
         "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(
       inputSchema: jsonSchema(
@@ -247,7 +332,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
             }
             }
             yield* plugin.trigger(
             yield* plugin.trigger(
               "tool.execute.before",
               "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 },
               { args },
             )
             )
             yield* ctx.ask({
             yield* ctx.ask({
@@ -282,7 +367,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
             }
             }
             yield* plugin.trigger(
             yield* plugin.trigger(
               "tool.execute.after",
               "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,
               output,
             )
             )
             if (opts.abortSignal?.aborted) {
             if (opts.abortSignal?.aborted) {
@@ -432,6 +517,11 @@ function formatMcpResource(resource: MCP.Resource) {
   return { ...result, server: resource.client }
   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 }) {
 function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
   const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
   const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
   const text: string[] = []
   const text: string[] = []

+ 28 - 0
packages/opencode/test/mcp/lifecycle.test.ts

@@ -17,6 +17,7 @@ interface MockClientState {
   listToolsCalls: number
   listToolsCalls: number
   listPromptsCalls: number
   listPromptsCalls: number
   listResourcesCalls: number
   listResourcesCalls: number
+  listResourceTemplatesCalls: number
   getPromptTimeout?: number
   getPromptTimeout?: number
   readResourceTimeout?: number
   readResourceTimeout?: number
   requestCalls: number
   requestCalls: number
@@ -26,6 +27,7 @@ interface MockClientState {
   listResourcesShouldFail: boolean
   listResourcesShouldFail: boolean
   prompts: Array<{ name: string; description?: string }>
   prompts: Array<{ name: string; description?: string }>
   resources: Array<{ name: string; uri: string; description?: string }>
   resources: Array<{ name: string; uri: string; description?: string }>
+  resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
   toolPages: Record<
   toolPages: Record<
     string,
     string,
     {
     {
@@ -38,6 +40,10 @@ interface MockClientState {
     string,
     string,
     { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: 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
   closed: boolean
   clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
   clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
   requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
   requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
@@ -67,6 +73,7 @@ function getOrCreateClientState(name?: string): MockClientState {
       listToolsCalls: 0,
       listToolsCalls: 0,
       listPromptsCalls: 0,
       listPromptsCalls: 0,
       listResourcesCalls: 0,
       listResourcesCalls: 0,
+      listResourceTemplatesCalls: 0,
       requestCalls: 0,
       requestCalls: 0,
       listToolsShouldFail: false,
       listToolsShouldFail: false,
       listToolsError: "listTools failed",
       listToolsError: "listTools failed",
@@ -74,9 +81,11 @@ function getOrCreateClientState(name?: string): MockClientState {
       listResourcesShouldFail: false,
       listResourcesShouldFail: false,
       prompts: [],
       prompts: [],
       resources: [],
       resources: [],
+      resourceTemplates: [],
       toolPages: {},
       toolPages: {},
       promptPages: {},
       promptPages: {},
       resourcePages: {},
       resourcePages: {},
+      resourceTemplatePages: {},
       closed: false,
       closed: false,
       requestHandlers: new Map(),
       requestHandlers: new Map(),
       notificationHandlers: new Map(),
       notificationHandlers: new Map(),
@@ -224,6 +233,13 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
       return { resources: this._state?.resources ?? [] }
       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 }) {
     async getPrompt(_params: unknown, options?: { timeout?: number }) {
       if (this._state) this._state.getPromptTimeout = options?.timeout
       if (this._state) this._state.getPromptTimeout = options?.timeout
       return { messages: [] }
       return { messages: [] }
@@ -353,6 +369,13 @@ it.instance(
           initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
           initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
           "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
           "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", {
         yield* mcp.add("paged-server", {
           type: "local",
           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.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.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.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.listToolsCalls).toBe(2)
         expect(serverState.listPromptsCalls).toBe(2)
         expect(serverState.listPromptsCalls).toBe(2)
         expect(serverState.listResourcesCalls).toBe(2)
         expect(serverState.listResourcesCalls).toBe(2)
+        expect(serverState.listResourceTemplatesCalls).toBe(2)
       }),
       }),
     ),
     ),
   { config: { mcp: {} } },
   { config: { mcp: {} } },

+ 1 - 0
packages/opencode/test/session/prompt.test.ts

@@ -116,6 +116,7 @@ const mcp = Layer.succeed(
     tools: () => Effect.succeed({}),
     tools: () => Effect.succeed({}),
     prompts: () => Effect.succeed({}),
     prompts: () => Effect.succeed({}),
     resources: () => Effect.succeed({}),
     resources: () => Effect.succeed({}),
+    resourceTemplates: () => Effect.succeed({}),
     add: () => Effect.succeed({ status: { status: "disabled" as const } }),
     add: () => Effect.succeed({ status: { status: "disabled" as const } }),
     connect: () => Effect.void,
     connect: () => Effect.void,
     disconnect: () => Effect.void,
     disconnect: () => Effect.void,

+ 1 - 0
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -40,6 +40,7 @@ const mcp = Layer.succeed(
     tools: () => Effect.succeed({}),
     tools: () => Effect.succeed({}),
     prompts: () => Effect.succeed({}),
     prompts: () => Effect.succeed({}),
     resources: () => Effect.succeed({}),
     resources: () => Effect.succeed({}),
+    resourceTemplates: () => Effect.succeed({}),
     add: () => Effect.succeed({ status: { status: "disabled" as const } }),
     add: () => Effect.succeed({ status: { status: "disabled" as const } }),
     connect: () => Effect.void,
     connect: () => Effect.void,
     disconnect: () => Effect.void,
     disconnect: () => Effect.void,