feat(mcp): expose resource APIs

This commit is contained in:
Aiden Cline 2026-07-06 23:42:29 -05:00
commit e1d1352d8f
18 changed files with 301 additions and 17 deletions

View file

@ -439,8 +439,28 @@ 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 ServerMcpResourceCatalogOperation<E = never> = (
input?: Endpoint10_1Input,
) => Effect.Effect<Endpoint10_1Output, E>
type Endpoint10_2Request = Parameters<RawClient["server.mcp"]["mcp.resource.read"]>[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<ReturnType<RawClient["server.mcp"]["mcp.resource.read"]>>
export type ServerMcpReadResourceOperation<E = never> = (
input: Endpoint10_2Input,
) => Effect.Effect<Endpoint10_2Output, E>
export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E>
readonly resourceCatalog: ServerMcpResourceCatalogOperation<E>
readonly readResource: ServerMcpReadResourceOperation<E>
}
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]

View file

@ -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<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))
type Endpoint10_2Request = Parameters<RawClient["server.mcp"]["mcp.resource.read"]>[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<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = {

View file

@ -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"

View file

@ -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<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type ServerMcpApi = PromisifyApi<EffectServerMcpApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {

View file

@ -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<ServerMcpResourceCatalogOutput>(
{
method: "GET",
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
readResource: (input: ServerMcpReadResourceInput, requestOptions?: RequestOptions) =>
request<ServerMcpReadResourceOutput>(
{
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) =>

View file

@ -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

View file

@ -10,6 +10,7 @@ export type {
ProviderApi,
ReferenceApi,
SessionApi,
ServerMcpApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"

View file

@ -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)
})

View file

@ -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 () => {