Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline e1d1352d8f feat(mcp): expose resource APIs 2026-07-06 23:42:29 -05:00
Aiden Cline ab5bd520e2 feat(core): support MCP resources 2026-07-06 23:34:15 -05:00
28 changed files with 1231 additions and 101 deletions
+20
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 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 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> { export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E> readonly list: ServerMcpListOperation<E>
readonly resourceCatalog: ServerMcpResourceCatalogOperation<E>
readonly readResource: ServerMcpReadResourceOperation<E>
} }
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
+22 -1
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) => 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))
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_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = { type Endpoint11_0Input = {
+2
View File
@@ -15,6 +15,7 @@ export type {
ProviderApi, ProviderApi,
ReferenceApi, ReferenceApi,
SessionApi, SessionApi,
ServerMcpApi,
SkillApi, SkillApi,
} from "./api.js" } from "./api.js"
export { Service } from "./service.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 { Form } from "@opencode-ai/schema/form"
export { Integration } from "@opencode-ai/schema/integration" export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location" export { Location } from "@opencode-ai/schema/location"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model" export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission" export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved" export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
+2
View File
@@ -8,6 +8,7 @@ import type {
ProviderApi as EffectProviderApi, ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi, ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi, SessionApi as EffectSessionApi,
ServerMcpApi as EffectServerMcpApi,
SkillApi as EffectSkillApi, SkillApi as EffectSkillApi,
} from "../effect/api/api.js" } from "../effect/api/api.js"
import type { Effect, Stream } from "effect" import type { Effect, Stream } from "effect"
@@ -37,6 +38,7 @@ export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>> export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>> export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>> export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type ServerMcpApi = PromisifyApi<EffectServerMcpApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>> export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi { export interface CatalogApi {
@@ -87,6 +87,10 @@ import type {
IntegrationAttemptCancelOutput, IntegrationAttemptCancelOutput,
ServerMcpListInput, ServerMcpListInput,
ServerMcpListOutput, ServerMcpListOutput,
ServerMcpResourceCatalogInput,
ServerMcpResourceCatalogOutput,
ServerMcpReadResourceInput,
ServerMcpReadResourceOutput,
CredentialUpdateInput, CredentialUpdateInput,
CredentialUpdateOutput, CredentialUpdateOutput,
CredentialRemoveInput, CredentialRemoveInput,
@@ -890,6 +894,31 @@ export function make(options: ClientOptions) {
}, },
requestOptions, 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: { credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -106,6 +106,14 @@ export type ProviderNotFoundError = {
export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError => export const isProviderNotFoundError = (value: unknown): value is ProviderNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "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 type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError => export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "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 = { export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"] readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: {
@@ -5508,6 +5570,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
+1
View File
@@ -10,6 +10,7 @@ export type {
ProviderApi, ProviderApi,
ReferenceApi, ReferenceApi,
SessionApi, SessionApi,
ServerMcpApi,
SkillApi, SkillApi,
} from "./api.js" } from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
@@ -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 { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location" import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project" import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider" import { Provider } from "@opencode-ai/schema/provider"
@@ -26,6 +27,7 @@ const Client = await import("../src/effect")
test("effect entrypoint exposes canonical Schema contracts", () => { test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent) expect(Client.Agent).toBe(Agent)
expect(Client.Model).toBe(Model) expect(Client.Model).toBe(Model)
expect(Client.Mcp).toBe(Mcp)
expect(Client.Session).toBe(Session) expect(Client.Session).toBe(Session)
}) })
+69 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test" 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", () => { test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" }) 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.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "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.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 () => { test("file.read returns binary content from the public HTTP contract", async () => {
+234 -17
View File
@@ -21,15 +21,24 @@ import {
ListToolsResultSchema, ListToolsResultSchema,
PromptListChangedNotificationSchema, PromptListChangedNotificationSchema,
PromptSchema, PromptSchema,
ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
type LoggingMessageNotification, type LoggingMessageNotification,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
ToolSchema, ToolSchema,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect" import { Cause, Effect, Exit, Schema, Scope, Semaphore } from "effect"
import { ConfigMCP } from "../config/mcp" import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
declare module "@modelcontextprotocol/sdk/client/streamableHttp.js" {
interface StreamableHTTPClientTransport {
/** Added by the repository's MCP SDK session-recovery patch. */
onsessionexpired?: () => Promise<void>
}
}
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000 const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
@@ -68,11 +77,13 @@ export interface ToolDefinition {
export interface PromptDefinition { export interface PromptDefinition {
readonly name: string readonly name: string
readonly description: string | undefined readonly description: string | undefined
readonly arguments: ReadonlyArray<{ readonly arguments:
readonly name: string | ReadonlyArray<{
readonly description: string | undefined readonly name: string
readonly required: boolean | undefined readonly description: string | undefined
}> | undefined readonly required: boolean | undefined
}>
| undefined
} }
export interface PromptMessage { export interface PromptMessage {
@@ -84,6 +95,28 @@ export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage> readonly messages: ReadonlyArray<PromptMessage>
} }
export interface ResourceDefinition {
readonly name: string
readonly uri: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export interface ResourceTemplateDefinition {
readonly name: string
readonly uriTemplate: string
readonly description: string | undefined
readonly mimeType: string | undefined
}
export type ResourceContentPart =
| { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined }
| { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined }
export interface ReadResourceResult {
readonly contents: ReadonlyArray<ResourceContentPart>
}
export type CallToolContent = export type CallToolContent =
| { readonly type: "text"; readonly text: string } | { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string }
@@ -124,6 +157,17 @@ export interface Connection {
readonly tools: () => Effect.Effect<ToolDefinition[], Error> readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error> readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Lists the server's resources; returns [] when the server doesn't advertise resource support. */
readonly resources: () => Effect.Effect<ResourceDefinition[], Error>
/** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */
readonly resourceTemplates: () => Effect.Effect<ResourceTemplateDefinition[], Error>
/** Reads one resource; returns undefined when the server doesn't advertise resource support. */
readonly readResource: (input: { readonly uri: string }) => Effect.Effect<ReadResourceResult | undefined, Error>
/** Subscribes for the lifetime of the current scope; duplicate local listeners share one server subscription. */
readonly subscribeResource: (
input: { readonly uri: string },
callback: () => void,
) => Effect.Effect<boolean, Error, Scope.Scope>
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */ /** Invokes a prompt on the server. Interruption aborts the in-flight request. */
readonly prompt: (input: { readonly prompt: (input: {
readonly name: string readonly name: string
@@ -141,6 +185,8 @@ export interface Connection {
readonly onToolsChanged: (callback: () => void) => void readonly onToolsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
readonly onPromptsChanged: (callback: () => void) => void readonly onPromptsChanged: (callback: () => void) => void
/** Registers a callback fired when the server announces its resource catalog changed or its HTTP session recovers. */
readonly onResourcesChanged: (callback: () => void) => void
} }
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
@@ -168,7 +214,8 @@ export const connect = Effect.fnUntraced(function* (
}, },
}) })
} }
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) if (!URL.canParse(config.url))
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), { return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined, requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider, authProvider,
@@ -202,13 +249,57 @@ export const connect = Effect.fnUntraced(function* (
}).pipe(Effect.exit) }).pipe(Effect.exit)
if (Exit.isSuccess(exit)) { if (Exit.isSuccess(exit)) {
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
cleanupStdioDescendants(transport).pipe( cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
Effect.andThen(Effect.promise(() => client.close())),
Effect.ignore,
),
) )
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
const resourceSubscriptions = new Map<string, Set<() => void>>()
const resourceListChangedCallbacks = new Set<() => void>()
const resourceSubscriptionLock = Semaphore.makeUnsafe(1)
const notify = async (callbacks: Iterable<() => void>) => {
for (const callback of callbacks) {
try {
await callback()
} catch {}
}
}
client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) =>
notify(resourceSubscriptions.get(notification.params.uri) ?? []),
)
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => notify(resourceListChangedCallbacks))
if (transport instanceof StreamableHTTPClientTransport) {
const recover = transport.onsessionexpired
transport.onsessionexpired = async () => {
await recover?.()
await notify(resourceListChangedCallbacks)
if (client.getServerCapabilities()?.resources?.subscribe)
// The transport cannot send ordinary requests until its recovery callback returns.
setTimeout(() => {
Effect.runFork(
resourceSubscriptionLock.withPermit(
Effect.forEach(
resourceSubscriptions.keys(),
(uri) =>
Effect.tryPromise({
try: (signal) => client.subscribeResource({ uri }, { signal, timeout: catalogTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to restore MCP resource subscription", {
server,
uri,
error: error.message,
}),
),
Effect.ignore,
),
{ discard: true },
),
),
)
}, 0)
}
}
return { return {
instructions: client.getInstructions()?.trim() || undefined, instructions: client.getInstructions()?.trim() || undefined,
tools: () => tools: () =>
@@ -257,7 +348,9 @@ export const connect = Effect.fnUntraced(function* (
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), Effect.tapError((error) =>
Effect.logWarning("failed to list MCP prompts", { server, error: error.message }),
),
) )
return prompts.map((prompt) => ({ return prompts.map((prompt) => ({
name: prompt.name, name: prompt.name,
@@ -269,6 +362,127 @@ export const connect = Effect.fnUntraced(function* (
})), })),
})) }))
}), }),
resources: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const resources = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
(result) => result.resources,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resources", { server, error: error.message }),
),
)
return resources.map((resource) => ({
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}))
}),
resourceTemplates: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return []
const templates = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, {
timeout: catalogTimeout,
}),
(result) => result.resourceTemplates,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }),
),
)
return templates.map((template) => ({
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}))
}),
readResource: (input) =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources) return undefined
const result = yield* Effect.tryPromise({
try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }),
),
)
return {
contents: result.contents.map(
(part): ResourceContentPart =>
"text" in part
? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType }
: { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType },
),
}
}),
subscribeResource: (input, callback) =>
Effect.acquireRelease(
resourceSubscriptionLock.withPermit(
Effect.gen(function* () {
if (!client.getServerCapabilities()?.resources?.subscribe) return undefined
const listener = () => callback()
const current = resourceSubscriptions.get(input.uri)
if (current) current.add(listener)
if (!current) {
yield* Effect.tryPromise({
try: (signal) => client.subscribeResource({ uri: input.uri }, { signal, timeout: catalogTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to subscribe to MCP resource", {
server,
uri: input.uri,
error: error.message,
}),
),
)
resourceSubscriptions.set(input.uri, new Set([listener]))
}
return listener
}),
),
(listener) => {
if (!listener) return Effect.void
return resourceSubscriptionLock.withPermit(
Effect.gen(function* () {
const listeners = resourceSubscriptions.get(input.uri)
if (!listeners) return
listeners.delete(listener)
if (listeners.size > 0) return
resourceSubscriptions.delete(input.uri)
yield* Effect.tryPromise({
try: (signal) => client.unsubscribeResource({ uri: input.uri }, { signal, timeout: catalogTimeout }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to unsubscribe from MCP resource", {
server,
uri: input.uri,
error: error.message,
}),
),
Effect.ignore,
)
}),
)
},
{ interruptible: true },
).pipe(Effect.map((listener) => listener !== undefined)),
prompt: (input) => prompt: (input) =>
Effect.tryPromise({ Effect.tryPromise({
try: (signal) => try: (signal) =>
@@ -315,7 +529,10 @@ export const connect = Effect.fnUntraced(function* (
})), })),
), ),
onClose: (callback) => { onClose: (callback) => {
client.onclose = callback client.onclose = () => {
resourceSubscriptions.clear()
callback()
}
}, },
onLog: (callback) => { onLog: (callback) => {
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params)) client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
@@ -328,13 +545,13 @@ export const connect = Effect.fnUntraced(function* (
if (!client.getServerCapabilities()?.prompts?.listChanged) return if (!client.getServerCapabilities()?.prompts?.listChanged) return
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
}, },
onResourcesChanged: (callback) => {
resourceListChangedCallbacks.add(callback)
},
} satisfies Connection } satisfies Connection
} }
yield* cleanupStdioDescendants(transport).pipe( yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
Effect.andThen(Effect.promise(() => transport.close())),
Effect.ignore,
)
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
+105 -50
View File
@@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")
messages: Schema.Array(PromptMessage), messages: Schema.Array(PromptMessage),
}) {} }) {}
export class Resource extends Schema.Class<Resource>("MCP.Resource")({ export const Resource = Mcp.Resource
server: ServerName, export type Resource = Mcp.Resource
name: Schema.String, export const ResourceTemplate = Mcp.ResourceTemplate
uri: Schema.String, export type ResourceTemplate = Mcp.ResourceTemplate
description: Schema.String.pipe(Schema.optional), export const ResourceCatalog = Mcp.ResourceCatalog
mimeType: Schema.String.pipe(Schema.optional), export type ResourceCatalog = Mcp.ResourceCatalog
}) {} export const ResourceContentPart = Mcp.ResourceContentPart
export type ResourceContentPart = Mcp.ResourceContentPart
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({ export const ResourceContent = Mcp.ResourceContent
server: ServerName, export type ResourceContent = Mcp.ResourceContent
name: Schema.String,
uriTemplate: Schema.String,
description: Schema.String.pipe(Schema.optional),
mimeType: Schema.String.pipe(Schema.optional),
}) {}
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
resources: Schema.Array(Resource),
templates: Schema.Array(ResourceTemplate),
}) {}
export const ResourceContentPart = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
uri: Schema.String,
text: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
Schema.Struct({
type: Schema.Literal("blob"),
uri: Schema.String,
blob: Schema.String,
mimeType: Schema.String.pipe(Schema.optional),
}),
]).pipe(Schema.toTaggedUnion("type"))
export type ResourceContentPart = typeof ResourceContentPart.Type
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
server: ServerName,
uri: Schema.String,
contents: Schema.Array(ResourceContentPart),
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", { export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
server: ServerName, server: ServerName,
@@ -148,6 +116,9 @@ type ServerEntry = {
client?: MCPClient.Connection client?: MCPClient.Connection
tools?: ReadonlyArray<Tool> tools?: ReadonlyArray<Tool>
prompts?: ReadonlyArray<Prompt> prompts?: ReadonlyArray<Prompt>
resources?: ReadonlyArray<Resource>
resourceTemplates?: ReadonlyArray<ResourceTemplate>
resourceRevision: number
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store. // Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
integrationID?: Integration.ID integrationID?: Integration.ID
} }
@@ -208,6 +179,7 @@ export const layer = Layer.effect(
config: { ...server, timeout: { ...timeout, ...server.timeout } }, config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "pending" }, status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(), startup: Deferred.makeUnsafe<void>(),
resourceRevision: 0,
}) })
} }
} }
@@ -415,6 +387,55 @@ export const layer = Layer.effect(
), ),
}) })
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
Resource.make({
server,
name: def.name,
uri: def.uri,
description: def.description,
mimeType: def.mimeType,
})
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
ResourceTemplate.make({
server,
name: def.name,
uriTemplate: def.uriTemplate,
description: def.description,
mimeType: def.mimeType,
})
const invalidateResources = (entry: ServerEntry) => {
entry.resourceRevision += 1
entry.resources = undefined
entry.resourceTemplates = undefined
}
const loadResources = Effect.fnUntraced(function* (
name: ServerName,
entry: ServerEntry,
connection: MCPClient.Connection,
) {
const revision = entry.resourceRevision
const result = yield* Effect.all(
{
resources:
entry.resources === undefined
? connection.resources().pipe(Effect.catch(() => Effect.succeed(undefined)))
: Effect.succeed(undefined),
templates:
entry.resourceTemplates === undefined
? connection.resourceTemplates().pipe(Effect.catch(() => Effect.succeed(undefined)))
: Effect.succeed(undefined),
},
{ concurrency: "unbounded" },
)
if (entry.client !== connection || entry.resourceRevision !== revision) return
if (result.resources !== undefined) entry.resources = result.resources.map((def) => toResource(name, def))
if (result.templates !== undefined)
entry.resourceTemplates = result.templates.map((def) => toResourceTemplate(name, def))
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe( connection.tools().pipe(
Effect.map((defs) => { Effect.map((defs) => {
@@ -441,8 +462,10 @@ export const layer = Layer.effect(
entry.client = undefined entry.client = undefined
entry.tools = undefined entry.tools = undefined
entry.prompts = undefined entry.prompts = undefined
invalidateResources(entry)
entry.status = { status: "failed", error: "Connection closed" } entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
}) })
@@ -458,6 +481,11 @@ export const layer = Layer.effect(
connection.onPromptsChanged(() => { connection.onPromptsChanged(() => {
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
}) })
connection.onResourcesChanged(() => {
if (entry.client !== connection) return
invalidateResources(entry)
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
})
} }
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
@@ -491,6 +519,7 @@ export const layer = Layer.effect(
Effect.exit, Effect.exit,
) )
if (Exit.isSuccess(result)) { if (Exit.isSuccess(result)) {
invalidateResources(entry)
entry.client = result.value.connection entry.client = result.value.connection
entry.tools = result.value.tools.map((def) => toTool(name, def)) entry.tools = result.value.tools.map((def) => toTool(name, def))
entry.prompts = [] entry.prompts = []
@@ -501,6 +530,7 @@ export const layer = Layer.effect(
// after the initial registration sweep and emits no list-changed notification would otherwise // after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model. // stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
return return
@@ -541,6 +571,7 @@ export const layer = Layer.effect(
entry.client = undefined entry.client = undefined
entry.tools = undefined entry.tools = undefined
entry.prompts = undefined entry.prompts = undefined
invalidateResources(entry)
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore) yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
} }
yield* startServer(name, entry) yield* startServer(name, entry)
@@ -557,11 +588,6 @@ export const layer = Layer.effect(
concurrency: "unbounded", concurrency: "unbounded",
discard: true, discard: true,
}) })
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
const target = yield* requireServer(server)
yield* Deferred.await(target.entry.startup)
})
return Service.of({ return Service.of({
servers: Effect.fn("MCP.servers")(function* () { servers: Effect.fn("MCP.servers")(function* () {
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
@@ -637,11 +663,40 @@ export const layer = Layer.effect(
}), }),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady yield* whenAllReady
return new ResourceCatalog({ resources: [], templates: [] }) yield* Effect.forEach(
runtime,
([name, entry]) => (entry.client ? loadResources(name, entry, entry.client) : Effect.void),
{ concurrency: "unbounded", discard: true },
)
return ResourceCatalog.make({
resources: Array.from(runtime.values())
.flatMap((entry) => entry.resources ?? [])
.toSorted(
(a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
),
templates: Array.from(runtime.values())
.flatMap((entry) => entry.resourceTemplates ?? [])
.toSorted(
(a, b) =>
a.server.localeCompare(b.server) ||
a.name.localeCompare(b.name) ||
a.uriTemplate.localeCompare(b.uriTemplate),
),
})
}), }),
readResource: Effect.fn("MCP.readResource")(function* (input) { readResource: Effect.fn("MCP.readResource")(function* (input) {
yield* gate(input.server) const target = yield* requireServer(input.server)
return undefined yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.readResource({ uri: input.uri })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!result) return undefined
return ResourceContent.make({
server: target.name,
uri: input.uri,
contents: result.contents,
})
}), }),
}) })
}), }),
+16 -1
View File
@@ -4,16 +4,27 @@ import {
CallToolRequestSchema, CallToolRequestSchema,
GetPromptRequestSchema, GetPromptRequestSchema,
ListPromptsRequestSchema, ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema, ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } }) const server = new Server(
{ name: "timeout", version: "1.0.0" },
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
}) })
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(ListResourcesRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
return { resources: [{ name: "slow", uri: "test://slow" }] }
})
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler(CallToolRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async () => {
await Bun.sleep(100) await Bun.sleep(100)
return { content: [] } return { content: [] }
@@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
await Bun.sleep(100) await Bun.sleep(100)
return { messages: [] } return { messages: [] }
}) })
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
await Bun.sleep(100)
return { contents: [{ uri: request.params.uri, text: "slow" }] }
})
await server.connect(new StdioServerTransport()) await server.connect(new StdioServerTransport())
+2 -5
View File
@@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]), instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]), prompts: () => Effect.succeed([]),
prompt: () => Effect.succeed(undefined), prompt: () => Effect.succeed(undefined),
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.succeed(undefined), readResource: () => Effect.succeed(undefined),
}), }),
) )
export const emptyConfigLayer = Layer.succeed( export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
Config.Service,
Config.Service.of({ entries: () => Effect.succeed([]) }),
)
export const testLocationLayer = Layer.succeed( export const testLocationLayer = Layer.succeed(
Location.Service, Location.Service,
+470 -2
View File
@@ -3,26 +3,203 @@ import { describe, expect, test } from "bun:test"
import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
SubscribeRequestSchema,
UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { ConfigMCP } from "@opencode-ai/core/config/mcp" import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index" import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client" import { MCPClient } from "@opencode-ai/core/mcp/client"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp" import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
let calls = 0 let calls = 0
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
nextCursor?: string
}
type ResourceTemplatePage = {
items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
nextCursor?: string
}
function resourceServer(input: { resources?: boolean; subscribe?: boolean; listChanged?: boolean } = {}) {
return Effect.acquireRelease(
Effect.promise(async () => {
const state = {
resources: [] as ResourcePage["items"],
templates: [] as ResourceTemplatePage["items"],
resourcePages: undefined as Record<string, ResourcePage> | undefined,
templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
contents: [
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
resourceLists: 0,
templateLists: 0,
resourceListFailures: 0,
subscriptions: [] as string[],
unsubscriptions: [] as string[],
onSubscription: undefined as (() => void) | undefined,
}
const makeProtocol = async () => {
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
{
capabilities: {
tools: {},
...(input.resources === false
? {}
: { resources: { subscribe: input.subscribe, listChanged: input.listChanged } }),
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
if (state.resourceListFailures > 0) {
state.resourceListFailures -= 1
throw new Error("resource list failed")
}
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
protocol.setRequestHandler(SubscribeRequestSchema, (request) => {
state.subscriptions.push(request.params.uri)
state.onSubscription?.()
return Promise.resolve({})
})
protocol.setRequestHandler(UnsubscribeRequestSchema, (request) => {
state.unsubscriptions.push(request.params.uri)
return Promise.resolve({})
})
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
return { protocol, transport }
}
let current = await makeProtocol()
let expireSession = false
const http = Bun.serve({
port: 0,
fetch: (request) => {
if (expireSession && request.headers.has("mcp-session-id")) {
expireSession = false
return new Response("session expired", { status: 404 })
}
return current.transport.handleRequest(request)
},
})
return {
state,
url: http.url.toString(),
sendResourceListChanged: () => current.protocol.sendResourceListChanged(),
sendResourceUpdated: (uri: string) => current.protocol.sendResourceUpdated({ uri }),
restart: async () => {
current = await makeProtocol()
expireSession = true
},
close: async () => {
await current.protocol.close().catch(() => {})
http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function resourceMcpLayer(url: string, changed: Deferred.Deferred<void>) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
let resourceChanges = 0
return MCP.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
}),
}),
}),
]),
}),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, {
subscribe: () => Stream.never,
publish: (definition) =>
Effect.sync(() => {
if (definition.type === "mcp.resources.changed" && ++resourceChanges === 2)
Deferred.doneUnsafe(changed, Exit.void)
return undefined as never
}),
}),
Layer.mock(Form.Service, {}),
Layer.mock(Integration.Service, {
connection: {
active: unusedIntegration,
resolve: unusedIntegration,
key: unusedIntegration,
oauth: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
},
attempt: {
status: unusedIntegration,
complete: unusedIntegration,
cancel: unusedIntegration,
},
}),
Layer.mock(Credential.Service, {}),
),
),
)
}
const mcp = Layer.mock(MCP.Service, { const mcp = Layer.mock(MCP.Service, {
tools: () => tools: () =>
Effect.succeed([ Effect.succeed([
@@ -241,6 +418,297 @@ test("applies the configured MCP execution timeout to prompts", async () => {
await expect(result).rejects.toThrow("Request timed out") await expect(result).rejects.toThrow("Request timed out")
}) })
test("applies configured MCP timeouts to resource operations", async () => {
const catalog = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-catalog-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
}),
import.meta.dir,
)
return yield* connection.resources()
}),
),
)
await expect(catalog).rejects.toThrow("Request timed out")
const read = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-read-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.readResource({ uri: "test://slow" })
}),
),
)
await expect(read).rejects.toThrow("Request timed out")
})
test("lists, reads, and invalidates MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ listChanged: true })
server.state.resourcePages = {
initial: {
items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
nextCursor: "resources-2",
},
"resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
}
server.state.templatePages = {
initial: {
items: [{ name: "File", uriTemplate: "docs://{path}" }],
nextCursor: "templates-2",
},
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
}
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([
{ name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
{ name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
])
expect(yield* connection.resourceTemplates()).toEqual([
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
])
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
const changed = yield* Deferred.make<void>()
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
yield* Effect.promise(server.sendResourceListChanged)
yield* Deferred.await(changed)
}),
),
)
})
test("shares scoped MCP resource subscriptions", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ subscribe: true })
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
const firstScope = yield* Scope.make()
const secondScope = yield* Scope.make()
const secondUpdate = yield* Deferred.make<void>()
expect(
yield* connection
.subscribeResource({ uri: "docs://readme" }, () => {
throw new Error("listener failed")
})
.pipe(Scope.provide(firstScope)),
).toBe(true)
expect(
yield* connection
.subscribeResource({ uri: "docs://readme" }, () => Deferred.doneUnsafe(secondUpdate, Exit.void))
.pipe(Scope.provide(secondScope)),
).toBe(true)
expect(server.state.subscriptions).toEqual(["docs://readme"])
yield* Effect.promise(() => server.sendResourceUpdated("docs://readme"))
yield* Deferred.await(secondUpdate)
yield* Scope.close(firstScope, Exit.void)
expect(server.state.unsubscriptions).toEqual([])
yield* Scope.close(secondScope, Exit.void)
expect(server.state.unsubscriptions).toEqual(["docs://readme"])
}),
),
)
})
test("releases MCP resource subscriptions provided a closed scope", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ subscribe: true })
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
const closed = yield* Scope.make()
yield* Scope.close(closed, Exit.void)
expect(
yield* connection
.subscribeResource({ uri: "docs://readme" }, () => {})
.pipe(
Scope.provide(closed),
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.fail(new Error("closed-scope resource subscription deadlocked")),
}),
),
).toBe(true)
expect(server.state.subscriptions).toEqual(["docs://readme"])
expect(server.state.unsubscriptions).toEqual(["docs://readme"])
}),
),
)
})
test("restores MCP resource state after HTTP session recovery", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ subscribe: true, listChanged: true })
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
const changed = yield* Deferred.make<void>()
const subscriptionScope = yield* Scope.make()
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
expect(
yield* connection
.subscribeResource({ uri: "docs://readme" }, () => {})
.pipe(Scope.provide(subscriptionScope)),
).toBe(true)
yield* Effect.promise(server.restart)
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
const resubscribed = yield* Deferred.make<void>()
server.state.onSubscription = () => Deferred.doneUnsafe(resubscribed, Exit.void)
expect((yield* connection.resources()).map((resource) => resource.uri)).toEqual(["docs://guide"])
yield* Deferred.await(changed)
yield* Deferred.await(resubscribed)
expect(server.state.subscriptions).toEqual(["docs://readme", "docs://readme"])
yield* Scope.close(subscriptionScope, Exit.void)
}),
),
)
})
test("skips unsupported MCP resource subscriptions", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.subscribeResource({ uri: "docs://readme" }, () => {})).toBe(false)
expect(server.state.subscriptions).toEqual([])
}),
),
)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false })
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([])
expect(yield* connection.resourceTemplates()).toEqual([])
expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
resources: 0,
templates: 0,
})
}),
),
)
})
test("caches and invalidates MCP resource catalogs", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ listChanged: true, subscribe: true })
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
server.state.resourceListFailures = 1
const changed = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const service = yield* MCP.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [],
templates: [
{
server: "resources",
name: "File",
uriTemplate: "docs://{path}",
description: undefined,
mimeType: undefined,
},
],
})
expect((yield* service.resourceCatalog()).resources).toEqual([
{
server: "resources",
name: "Readme",
uri: "docs://readme",
description: undefined,
mimeType: undefined,
},
])
yield* service.resourceCatalog()
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
resources: 2,
templates: 1,
})
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
yield* Effect.promise(server.sendResourceListChanged)
yield* Deferred.await(changed)
expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
resources: 3,
templates: 2,
})
expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
server: "resources",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
}).pipe(Effect.provide(resourceMcpLayer(server.url, changed)))
}),
),
)
})
it.effect("advertises MCP output schemas to Code Mode", () => it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
+2
View File
@@ -61,6 +61,8 @@ export const groupNames = {
} as const } as const
export const endpointNames = { export const endpointNames = {
"mcp.resource.catalog": "resourceCatalog",
"mcp.resource.read": "readResource",
"session.messages": "list", "session.messages": "list",
"integration.connect.key": "connectKey", "integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth", "integration.connect.oauth": "connectOauth",
+9
View File
@@ -61,6 +61,15 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFo
{ httpApiStatus: 404 }, { httpApiStatus: 404 },
) {} ) {}
export class McpServerNotFoundError extends Schema.TaggedErrorClass<McpServerNotFoundError>()(
"McpServerNotFoundError",
{
server: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()( export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError", "SessionNotFoundError",
{ {
+32 -1
View File
@@ -2,6 +2,7 @@ import { Mcp } from "@opencode-ai/schema/mcp"
import { Location } from "@opencode-ai/schema/location" import { Location } from "@opencode-ai/schema/location"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { McpServerNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js" import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const McpGroup = HttpApiGroup.make("server.mcp") 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." }))
+1
View File
@@ -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)
}) })
+1
View File
@@ -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.
+1
View File
@@ -9,6 +9,7 @@ export { Form } from "./form.js"
export { Integration } from "./integration.js" export { Integration } from "./integration.js"
export { LLM } from "./llm.js" export { LLM } from "./llm.js"
export { Location } from "./location.js" export { Location } from "./location.js"
export { Mcp } from "./mcp.js"
export { Model } from "./model.js" export { Model } from "./model.js"
export { Permission } from "./permission.js" export { Permission } from "./permission.js"
export { PermissionSaved } from "./permission-saved.js" export { PermissionSaved } from "./permission-saved.js"
+8 -1
View File
@@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({
}, },
}) })
export const ResourcesChanged = Event.ephemeral({
type: "mcp.resources.changed",
schema: {
server: Schema.String,
},
})
export const BrowserOpenFailed = Event.ephemeral({ export const BrowserOpenFailed = Event.ephemeral({
type: "mcp.browser.open.failed", type: "mcp.browser.open.failed",
schema: { schema: {
@@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({
}, },
}) })
export const Definitions = Event.inventory(ToolsChanged, StatusChanged) export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged)
+50 -8
View File
@@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) }).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
export type Status = typeof Status.Type export type Status = typeof Status.Type
export const Status = Schema.Union([ export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
Connected, Schema.toTaggedUnion("status"),
Pending, )
Disabled,
Failed,
NeedsAuth,
NeedsClientRegistration,
]).pipe(Schema.toTaggedUnion("status"))
export interface Server extends Schema.Schema.Type<typeof Server> {} export interface Server extends Schema.Schema.Type<typeof Server> {}
export const Server = Schema.Struct({ export const Server = Schema.Struct({
@@ -42,3 +37,50 @@ export const Server = Schema.Struct({
// without matching by name, which could collide with provider or plugin integrations. // without matching by name, which could collide with provider or plugin integrations.
integrationID: optional(IntegrationID), integrationID: optional(IntegrationID),
}).annotate({ identifier: "Mcp.Server" }) }).annotate({ identifier: "Mcp.Server" })
export interface Resource extends Schema.Schema.Type<typeof Resource> {}
export const Resource = Schema.Struct({
server: Schema.String,
name: Schema.String,
uri: Schema.String,
description: optional(Schema.String),
mimeType: optional(Schema.String),
}).annotate({ identifier: "Mcp.Resource" })
export interface ResourceTemplate extends Schema.Schema.Type<typeof ResourceTemplate> {}
export const ResourceTemplate = Schema.Struct({
server: Schema.String,
name: Schema.String,
uriTemplate: Schema.String,
description: optional(Schema.String),
mimeType: optional(Schema.String),
}).annotate({ identifier: "Mcp.ResourceTemplate" })
export interface ResourceCatalog extends Schema.Schema.Type<typeof ResourceCatalog> {}
export const ResourceCatalog = Schema.Struct({
resources: Schema.Array(Resource),
templates: Schema.Array(ResourceTemplate),
}).annotate({ identifier: "Mcp.ResourceCatalog" })
export const ResourceContentPart = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
uri: Schema.String,
text: Schema.String,
mimeType: optional(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("blob"),
uri: Schema.String,
blob: Schema.String,
mimeType: optional(Schema.String),
}),
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" }))
export type ResourceContentPart = typeof ResourceContentPart.Type
export interface ResourceContent extends Schema.Schema.Type<typeof ResourceContent> {}
export const ResourceContent = Schema.Struct({
server: Schema.String,
uri: Schema.String,
contents: Schema.Array(ResourceContentPart),
}).annotate({ identifier: "Mcp.ResourceContent" })
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { DateTime, Schema } from "effect" import { DateTime, Schema } from "effect"
import { Agent } from "../src/agent.js" import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js" import { FileSystem } from "../src/filesystem.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js" import { Model } from "../src/model.js"
import { Project } from "../src/project.js" import { Project } from "../src/project.js"
import { Provider } from "../src/provider.js" import { Provider } from "../src/provider.js"
@@ -51,6 +52,11 @@ describe("contract hygiene", () => {
const identifiers = [ const identifiers = [
Agent.Color, Agent.Color,
FileSystem.Submatch, FileSystem.Submatch,
Mcp.Resource,
Mcp.ResourceTemplate,
Mcp.ResourceCatalog,
Mcp.ResourceContentPart,
Mcp.ResourceContent,
Model.Ref, Model.Ref,
Model.Capabilities, Model.Capabilities,
Model.Cost, Model.Cost,
+2 -1
View File
@@ -53,6 +53,7 @@ describe("public event manifest", () => {
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
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.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
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)
}) })
@@ -76,7 +77,7 @@ describe("public event manifest", () => {
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Mcp } from "../src/mcp.js"
describe("Mcp resources", () => {
test("decodes resource catalogs and omits absent metadata", () => {
const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
})
expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({
resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }],
templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }],
})
})
test("preserves text and base64 blob contents", () => {
expect(
Schema.decodeUnknownSync(Mcp.ResourceContent)({
server: "docs",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
}),
).toEqual({
server: "docs",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
})
})
+1
View File
@@ -9,6 +9,7 @@ export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem" export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration" export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location" export { Location } from "@opencode-ai/schema/location"
export { Mcp } from "@opencode-ai/schema/mcp"
export { Model } from "@opencode-ai/schema/model" export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission" export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved" export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
const SDK = await import("../src/index") const SDK = await import("../src/index")
@@ -8,6 +9,7 @@ const SDK = await import("../src/index")
test("re-exports canonical contracts directly from Schema", () => { test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent) expect(SDK.Agent).toBe(Agent)
expect(SDK.Model).toBe(Model) expect(SDK.Model).toBe(Model)
expect(SDK.Mcp).toBe(Mcp)
expect(SDK.Session).toBe(Session) expect(SDK.Session).toBe(Session)
expect(Object.keys(SDK).sort()).toEqual([ expect(Object.keys(SDK).sort()).toEqual([
"AbsolutePath", "AbsolutePath",
@@ -18,6 +20,7 @@ test("re-exports canonical contracts directly from Schema", () => {
"FileSystem", "FileSystem",
"Integration", "Integration",
"Location", "Location",
"Mcp",
"Model", "Model",
"OpenCode", "OpenCode",
"Permission", "Permission",
+34 -13
View File
@@ -2,24 +2,45 @@ import { MCP } from "@opencode-ai/core/mcp/index"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
import { response } from "../location" 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())
}),
)
.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 })),
), ),
) )
}), }),
) )
}), }),
) )