diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 588d67cad6..8715323adc 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -439,8 +439,28 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query export type Endpoint10_0Output = EffectValue> export type ServerMcpListOperation = (input?: Endpoint10_0Input) => Effect.Effect +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +export type Endpoint10_1Output = EffectValue> +export type ServerMcpResourceCatalogOperation = ( + input?: Endpoint10_1Input, +) => Effect.Effect + +type Endpoint10_2Request = Parameters[0] +export type Endpoint10_2Input = { + readonly location?: Endpoint10_2Request["query"]["location"] + readonly server: Endpoint10_2Request["payload"]["server"] + readonly uri: Endpoint10_2Request["payload"]["uri"] +} +export type Endpoint10_2Output = EffectValue> +export type ServerMcpReadResourceOperation = ( + input: Endpoint10_2Input, +) => Effect.Effect + export interface ServerMcpApi { readonly list: ServerMcpListOperation + readonly resourceCatalog: ServerMcpResourceCatalogOperation + readonly readResource: ServerMcpReadResourceOperation } type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 945bd0ffc0..fb1d16c685 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -529,7 +529,28 @@ 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[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)) + +type Endpoint10_2Request = Parameters[0] +type Endpoint10_2Input = { + readonly location?: Endpoint10_2Request["query"]["location"] + readonly server: Endpoint10_2Request["payload"]["server"] + readonly uri: Endpoint10_2Request["payload"]["uri"] +} +const Endpoint10_2 = (raw: RawClient["server.mcp"]) => (input: Endpoint10_2Input) => + raw["mcp.resource.read"]({ + query: { location: input["location"] }, + payload: { server: input["server"], uri: input["uri"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ + list: Endpoint10_0(raw), + resourceCatalog: Endpoint10_1(raw), + readResource: Endpoint10_2(raw), +}) type Endpoint11_0Request = Parameters[0] type Endpoint11_0Input = { diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 3e47320953..e73fd94a79 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -15,6 +15,7 @@ export type { ProviderApi, ReferenceApi, SessionApi, + ServerMcpApi, SkillApi, } from "./api.js" export { Service } from "./service.js" @@ -27,6 +28,7 @@ export { FileSystem } from "@opencode-ai/schema/filesystem" export { Form } from "@opencode-ai/schema/form" export { Integration } from "@opencode-ai/schema/integration" export { Location } from "@opencode-ai/schema/location" +export { Mcp } from "@opencode-ai/schema/mcp" export { Model } from "@opencode-ai/schema/model" export { Permission } from "@opencode-ai/schema/permission" export { PermissionSaved } from "@opencode-ai/schema/permission-saved" diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index d31a1881fa..039d46c602 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -8,6 +8,7 @@ import type { ProviderApi as EffectProviderApi, ReferenceApi as EffectReferenceApi, SessionApi as EffectSessionApi, + ServerMcpApi as EffectServerMcpApi, SkillApi as EffectSkillApi, } from "../effect/api/api.js" import type { Effect, Stream } from "effect" @@ -37,6 +38,7 @@ export type PluginApi = PromisifyApi> export type ProviderApi = PromisifyApi> export type ReferenceApi = PromisifyApi> export type SessionApi = PromisifyApi> +export type ServerMcpApi = PromisifyApi> export type SkillApi = PromisifyApi> export interface CatalogApi { diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 697d54d732..a5b40717ba 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -87,6 +87,10 @@ import type { IntegrationAttemptCancelOutput, ServerMcpListInput, ServerMcpListOutput, + ServerMcpResourceCatalogInput, + ServerMcpResourceCatalogOutput, + ServerMcpReadResourceInput, + ServerMcpReadResourceOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -890,6 +894,31 @@ export function make(options: ClientOptions) { }, requestOptions, ), + resourceCatalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/mcp/resource`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + readResource: (input: ServerMcpReadResourceInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/mcp/resource/read`, + query: { location: input["location"] }, + body: { server: input["server"], uri: input["uri"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7482d23725..afcdb057cc 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -106,6 +106,14 @@ export type ProviderNotFoundError = { export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProviderNotFoundError" +export type McpServerNotFoundError = { + readonly _tag: "McpServerNotFoundError" + readonly server: string + readonly message: string +} +export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError" + export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string } export const isFormNotFoundError = (value: unknown): value is FormNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError" @@ -2478,6 +2486,60 @@ export type ServerMcpListOutput = { }> } +export type ServerMcpResourceCatalogInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerMcpResourceCatalogOutput = { + 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 ServerMcpReadResourceInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly server: { readonly server: string; readonly uri: string }["server"] + readonly uri: { readonly server: string; readonly uri: string }["uri"] +} + +export type ServerMcpReadResourceOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly server: string + readonly uri: string + readonly contents: ReadonlyArray< + | { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType?: string } + | { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType?: string } + > + } | null +} + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { @@ -5508,6 +5570,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 diff --git a/packages/client/src/promise/index.ts b/packages/client/src/promise/index.ts index fd889c64e5..eaad950a82 100644 --- a/packages/client/src/promise/index.ts +++ b/packages/client/src/promise/index.ts @@ -10,6 +10,7 @@ export type { ProviderApi, ReferenceApi, SessionApi, + ServerMcpApi, SkillApi, } from "./api.js" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index a2afc9a002..1a441c9cac 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -9,6 +9,7 @@ import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/inpu import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" import { Agent } from "@opencode-ai/schema/agent" import { Location } from "@opencode-ai/schema/location" +import { Mcp } from "@opencode-ai/schema/mcp" import { Model } from "@opencode-ai/schema/model" import { Project } from "@opencode-ai/schema/project" import { Provider } from "@opencode-ai/schema/provider" @@ -26,6 +27,7 @@ const Client = await import("../src/effect") test("effect entrypoint exposes canonical Schema contracts", () => { expect(Client.Agent).toBe(Agent) expect(Client.Model).toBe(Model) + expect(Client.Mcp).toBe(Mcp) expect(Client.Session).toBe(Session) }) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4f2419075f..7c2bc3d672 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index" +import { isMcpServerNotFoundError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index" test("exposes every standard HTTP API group", () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000" }) @@ -48,6 +48,74 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) + expect(Object.keys(client["server.mcp"])).toEqual(["list", "resourceCatalog", "readResource"]) +}) + +test("MCP resource methods use the public HTTP contract", async () => { + const requests: Array<{ method: string; url: string; body?: unknown }> = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push({ + method: request.method, + url: request.url, + body: request.method === "POST" ? await request.json() : undefined, + }) + if (request.method === "POST") + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { + server: "docs", + uri: "docs://readme", + contents: [{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }], + }, + }) + 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 location = { directory: "/tmp/project" } + expect((await client["server.mcp"].resourceCatalog({ location })).data.resources[0]?.uri).toBe("docs://readme") + expect( + (await client["server.mcp"].readResource({ server: "docs", uri: "docs://readme", location })).data?.contents, + ).toEqual([{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }]) + expect(requests).toEqual([ + { + method: "GET", + url: "http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject", + body: undefined, + }, + { + method: "POST", + url: "http://localhost:3000/api/mcp/resource/read?location%5Bdirectory%5D=%2Ftmp%2Fproject", + body: { server: "docs", uri: "docs://readme" }, + }, + ]) +}) + +test("MCP resource reads preserve unknown-server errors", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json( + { _tag: "McpServerNotFoundError", server: "missing", message: "MCP server not found: missing" }, + { status: 404 }, + ), + }) + + try { + await client["server.mcp"].readResource({ server: "missing", uri: "docs://readme" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isMcpServerNotFoundError(error)).toBe(true) + } }) test("file.read returns binary content from the public HTTP contract", async () => { diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf666796..b88de39553 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -61,6 +61,8 @@ export const groupNames = { } as const export const endpointNames = { + "mcp.resource.catalog": "resourceCatalog", + "mcp.resource.read": "readResource", "session.messages": "list", "integration.connect.key": "connectKey", "integration.connect.oauth": "connectOauth", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index a21d3d86d2..a9079af7c0 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -61,6 +61,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "McpServerNotFoundError", + { + server: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class SessionNotFoundError extends Schema.TaggedErrorClass()( "SessionNotFoundError", { diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts index d29749ab15..58115b9fd6 100644 --- a/packages/protocol/src/groups/mcp.ts +++ b/packages/protocol/src/groups/mcp.ts @@ -2,6 +2,7 @@ import { Mcp } from "@opencode-ai/schema/mcp" import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { McpServerNotFoundError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" export const McpGroup = HttpApiGroup.make("server.mcp") @@ -19,4 +20,34 @@ 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.", + }), + ), + ) + .add( + HttpApiEndpoint.post("mcp.resource.read", "/api/mcp/resource/read", { + query: LocationQuery, + payload: Schema.Struct({ server: Schema.String, uri: Schema.String }), + success: Location.response(Schema.NullOr(Mcp.ResourceContent)), + error: McpServerNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.resource.read", + summary: "Read MCP resource", + description: "Read the current content of one resource from a connected MCP server.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." })) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 33ece777c4..56213ef4cc 100644 --- a/packages/protocol/test/event.test.ts +++ b/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) }) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 23ed3fd044..035f72568e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/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. diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index f93fb89de7..689b080696 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -53,7 +53,7 @@ describe("public event manifest", () => { expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) 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(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index d8ce5f656b..65bc2a30ee 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -9,6 +9,7 @@ export { Credential } from "@opencode-ai/schema/credential" export { FileSystem } from "@opencode-ai/schema/filesystem" export { Integration } from "@opencode-ai/schema/integration" export { Location } from "@opencode-ai/schema/location" +export { Mcp } from "@opencode-ai/schema/mcp" export { Model } from "@opencode-ai/schema/model" export { Permission } from "@opencode-ai/schema/permission" export { PermissionSaved } from "@opencode-ai/schema/permission-saved" diff --git a/packages/sdk-next/test/contract-identity.test.ts b/packages/sdk-next/test/contract-identity.test.ts index 830fe5f43b..1ea4d7c364 100644 --- a/packages/sdk-next/test/contract-identity.test.ts +++ b/packages/sdk-next/test/contract-identity.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" +import { Mcp } from "@opencode-ai/schema/mcp" import { Session } from "@opencode-ai/schema/session" const SDK = await import("../src/index") @@ -8,6 +9,7 @@ const SDK = await import("../src/index") test("re-exports canonical contracts directly from Schema", () => { expect(SDK.Agent).toBe(Agent) expect(SDK.Model).toBe(Model) + expect(SDK.Mcp).toBe(Mcp) expect(SDK.Session).toBe(Session) expect(Object.keys(SDK).sort()).toEqual([ "AbsolutePath", @@ -18,6 +20,7 @@ test("re-exports canonical contracts directly from Schema", () => { "FileSystem", "Integration", "Location", + "Mcp", "Model", "OpenCode", "Permission", diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 4d702f24c9..2549f6b291 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -2,24 +2,45 @@ import { MCP } from "@opencode-ai/core/mcp/index" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { McpServerNotFoundError } from "@opencode-ai/protocol/errors" 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()) + }), + ) + .handle( + "mcp.resource.read", + Effect.fn(function* (ctx) { + const service = yield* MCP.Service + return yield* response( + service.readResource({ server: ctx.payload.server, uri: ctx.payload.uri }).pipe( + Effect.map((result) => result ?? null), + Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })), ), - ) - }), - ) + ) + }), + ) }), )