Explorar o código

feat(mcp): expose resource catalog API (#35773)

Aiden Cline hai 1 mes
pai
achega
947bbf9490

+ 6 - 0
packages/client/src/effect/api/api.ts

@@ -440,8 +440,14 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query
 export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
 export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
 
+type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
+export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
+export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
+export type ServerMcpCatalogOperation<E = never> = (input?: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
+
 export interface ServerMcpApi<E = never> {
   readonly list: ServerMcpListOperation<E>
+  readonly catalog: ServerMcpCatalogOperation<E>
 }
 
 type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]

+ 6 - 1
packages/client/src/effect/generated/client.ts

@@ -535,7 +535,12 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc
 const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
   raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) })
+type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
+type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
+const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
+  raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
+
+const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) })
 
 type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
 type Endpoint11_0Input = {

+ 14 - 0
packages/client/src/promise/generated/client.ts

@@ -87,6 +87,8 @@ import type {
   IntegrationAttemptCancelOutput,
   ServerMcpListInput,
   ServerMcpListOutput,
+  ServerMcpCatalogInput,
+  ServerMcpCatalogOutput,
   CredentialUpdateInput,
   CredentialUpdateOutput,
   CredentialRemoveInput,
@@ -897,6 +899,18 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ),
+      catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) =>
+        request<ServerMcpCatalogOutput>(
+          {
+            method: "GET",
+            path: `/api/mcp/resource`,
+            query: { location: input?.["location"] },
+            successStatus: 200,
+            declaredStatuses: [401, 400],
+            empty: false,
+          },
+          requestOptions,
+        ),
     },
     credential: {
       update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>

+ 38 - 0
packages/client/src/promise/generated/types.ts

@@ -2487,6 +2487,36 @@ export type ServerMcpListOutput = {
   }>
 }
 
+export type ServerMcpCatalogInput = {
+  readonly location?: {
+    readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+  }["location"]
+}
+
+export type ServerMcpCatalogOutput = {
+  readonly location: {
+    readonly directory: string
+    readonly workspaceID?: string
+    readonly project: { readonly id: string; readonly directory: string }
+  }
+  readonly data: {
+    readonly resources: ReadonlyArray<{
+      readonly server: string
+      readonly name: string
+      readonly uri: string
+      readonly description?: string
+      readonly mimeType?: string
+    }>
+    readonly templates: ReadonlyArray<{
+      readonly server: string
+      readonly name: string
+      readonly uriTemplate: string
+      readonly description?: string
+      readonly mimeType?: string
+    }>
+  }
+}
+
 export type CredentialUpdateInput = {
   readonly credentialID: { readonly credentialID: string }["credentialID"]
   readonly location?: {
@@ -5517,6 +5547,14 @@ export type EventSubscribeOutput =
       readonly location?: { readonly directory: string; readonly workspaceID?: string }
       readonly data: { readonly server: string }
     }
+  | {
+      readonly id: string
+      readonly created: number
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "mcp.resources.changed"
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: { readonly server: string }
+    }
   | {
       readonly id: string
       readonly created: number

+ 23 - 0
packages/client/test/promise.test.ts

@@ -50,6 +50,29 @@ test("exposes every standard HTTP API group", () => {
   expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
 })
 
+test("MCP resource catalog uses the public HTTP contract", async () => {
+  let request: Request | undefined
+  const client = OpenCode.make({
+    baseUrl: "http://localhost:3000",
+    fetch: async (input) => {
+      request = input instanceof Request ? input : new Request(input)
+      return Response.json({
+        location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
+        data: {
+          resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
+          templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
+        },
+      })
+    },
+  })
+
+  const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } })
+
+  expect(result.data.resources[0]?.uri).toBe("docs://readme")
+  expect(request?.method).toBe("GET")
+  expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject")
+})
+
 test("file.read returns binary content from the public HTTP contract", async () => {
   let request: Request | undefined
   const client = OpenCode.make({

+ 15 - 1
packages/protocol/src/groups/mcp.ts

@@ -19,4 +19,18 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
         }),
       ),
   )
-  .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." }))
+  .add(
+    HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", {
+      query: LocationQuery,
+      success: Location.response(Mcp.ResourceCatalog),
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.mcp.resource.catalog",
+          summary: "List MCP resources",
+          description: "Retrieve resources and resource templates from connected MCP servers.",
+        }),
+      ),
+  )
+  .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." }))

+ 1 - 0
packages/protocol/test/event.test.ts

@@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js"
 test("classifies public events by type", () => {
   expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
   expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
+  expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
   expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
 })

+ 1 - 0
packages/schema/src/event-manifest.ts

@@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory(
   ...InstallationEvent.Definitions,
   ...VcsEvent.Definitions,
   McpEvent.StatusChanged,
+  McpEvent.ResourcesChanged,
   // Shared transitional: V1 contracts the current TUI still consumes during
   // the migration (permission.asked/replied, question.asked, session.error).
   // Remove when the TUI moves to the current permission/question surfaces.

+ 1 - 1
packages/schema/test/event-manifest.test.ts

@@ -51,9 +51,9 @@ describe("public event manifest", () => {
     expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
     expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated)
     expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
+    expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
     expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
     expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
-    expect(EventManifest.Server.has("mcp.resources.changed")).toBe(false)
     expect(Agent.Event.Updated.durable).toBeUndefined()
     expect(EventManifest.Durable.has("agent.updated")).toBe(false)
   })

+ 22 - 14
packages/server/src/handlers/mcp.ts

@@ -6,20 +6,28 @@ import { response } from "../location"
 
 export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
   Effect.gen(function* () {
-    return handlers.handle(
-      "mcp.list",
-      Effect.fn(function* () {
-        const service = yield* MCP.Service
-        return yield* response(
-          service
-            .servers()
-            .pipe(
-              Effect.map((servers) =>
-                servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
+    return handlers
+      .handle(
+        "mcp.list",
+        Effect.fn(function* () {
+          const service = yield* MCP.Service
+          return yield* response(
+            service
+              .servers()
+              .pipe(
+                Effect.map((servers) =>
+                  servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
+                ),
               ),
-            ),
-        )
-      }),
-    )
+          )
+        }),
+      )
+      .handle(
+        "mcp.resource.catalog",
+        Effect.fn(function* () {
+          const service = yield* MCP.Service
+          return yield* response(service.resourceCatalog())
+        }),
+      )
   }),
 )