feat(mcp): expose resource catalog API (#35773)
This commit is contained in:
@@ -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 Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
||||||
export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
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> {
|
export interface ServerMcpApi<E = never> {
|
||||||
readonly list: ServerMcpListOperation<E>
|
readonly list: ServerMcpListOperation<E>
|
||||||
|
readonly catalog: ServerMcpCatalogOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
|
|||||||
@@ -535,7 +535,12 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc
|
|||||||
const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
|
const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) =>
|
||||||
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
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_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||||
type Endpoint11_0Input = {
|
type Endpoint11_0Input = {
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ import type {
|
|||||||
IntegrationAttemptCancelOutput,
|
IntegrationAttemptCancelOutput,
|
||||||
ServerMcpListInput,
|
ServerMcpListInput,
|
||||||
ServerMcpListOutput,
|
ServerMcpListOutput,
|
||||||
|
ServerMcpCatalogInput,
|
||||||
|
ServerMcpCatalogOutput,
|
||||||
CredentialUpdateInput,
|
CredentialUpdateInput,
|
||||||
CredentialUpdateOutput,
|
CredentialUpdateOutput,
|
||||||
CredentialRemoveInput,
|
CredentialRemoveInput,
|
||||||
@@ -897,6 +899,18 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
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: {
|
credential: {
|
||||||
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
|
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
|
||||||
|
|||||||
@@ -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 = {
|
export type CredentialUpdateInput = {
|
||||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
@@ -5517,6 +5547,14 @@ export type EventSubscribeOutput =
|
|||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly server: 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 id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
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 () => {
|
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -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." }))
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js"
|
|||||||
test("classifies public events by type", () => {
|
test("classifies public events by type", () => {
|
||||||
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
||||||
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).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)
|
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory(
|
|||||||
...InstallationEvent.Definitions,
|
...InstallationEvent.Definitions,
|
||||||
...VcsEvent.Definitions,
|
...VcsEvent.Definitions,
|
||||||
McpEvent.StatusChanged,
|
McpEvent.StatusChanged,
|
||||||
|
McpEvent.ResourcesChanged,
|
||||||
// Shared transitional: V1 contracts the current TUI still consumes during
|
// Shared transitional: V1 contracts the current TUI still consumes during
|
||||||
// the migration (permission.asked/replied, question.asked, session.error).
|
// the migration (permission.asked/replied, question.asked, session.error).
|
||||||
// Remove when the TUI moves to the current permission/question surfaces.
|
// Remove when the TUI moves to the current permission/question surfaces.
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ describe("public event manifest", () => {
|
|||||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||||
expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.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.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.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||||
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
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(Agent.Event.Updated.durable).toBeUndefined()
|
||||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,20 +6,28 @@ import { response } from "../location"
|
|||||||
|
|
||||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return handlers.handle(
|
return handlers
|
||||||
"mcp.list",
|
.handle(
|
||||||
Effect.fn(function* () {
|
"mcp.list",
|
||||||
const service = yield* MCP.Service
|
Effect.fn(function* () {
|
||||||
return yield* response(
|
const service = yield* MCP.Service
|
||||||
service
|
return yield* response(
|
||||||
.servers()
|
service
|
||||||
.pipe(
|
.servers()
|
||||||
Effect.map((servers) =>
|
.pipe(
|
||||||
servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
|
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())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user