mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 03:06:10 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09ebbea3c | ||
|
|
42b63d6660 | ||
|
|
e113aad5e0 |
@@ -101,6 +101,7 @@
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export * as CodeModeHost from "./code-mode"
|
||||
|
||||
import { NodeHttpClient } from "@effect/platform-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { OpenAPI, Tool } from "@opencode-ai/codemode"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import type { Server } from "node:http"
|
||||
|
||||
export function replacements(server: Server, password: string): LayerNode.Replacements {
|
||||
return [ToolRegistry.codeModeReplacement(makeTools(client(server), password))]
|
||||
}
|
||||
|
||||
export function makeTools(client: Layer.Layer<HttpClient.HttpClient>, password: string): ToolRegistry.CodeModeTools {
|
||||
return {
|
||||
opencode: bindTools(
|
||||
OpenAPI.fromSpec({
|
||||
spec: { ...OpenApi.fromApi(Api) },
|
||||
baseUrl: "http://opencode.local",
|
||||
headers: ServerAuth.headers({ username: "opencode", password }),
|
||||
}).tools,
|
||||
client,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function client(server: Server) {
|
||||
return Layer.effect(
|
||||
HttpClient.HttpClient,
|
||||
Effect.gen(function* () {
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return HttpClient.mapRequest(client, (request) => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("OpenCode server is not listening")
|
||||
const local =
|
||||
address.address === "0.0.0.0" ? "127.0.0.1" : address.address === "::" ? "::1" : address.address
|
||||
const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local
|
||||
const url = new URL(request.url)
|
||||
return HttpClientRequest.setUrl(
|
||||
request,
|
||||
new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`),
|
||||
)
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(NodeHttpClient.layerNodeHttp))
|
||||
}
|
||||
|
||||
function bindTools(tools: OpenAPI.Tools, client: Layer.Layer<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([name, value]) => [
|
||||
name,
|
||||
Tool.isDefinition<HttpClient.HttpClient>(value)
|
||||
? Tool.make({
|
||||
description: value.description,
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
run: (input) => value.run(input).pipe(Effect.provide(client)),
|
||||
})
|
||||
: bindTools(value, client),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { CodeModeHost } from "./code-mode"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { Updater } from "./services/updater"
|
||||
@@ -63,6 +64,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
||||
port: Option.fromNullishOr(options.port ?? config.port),
|
||||
password,
|
||||
replacements: (server) => CodeModeHost.replacements(server, password),
|
||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
||||
if (options.mode === "service") yield* register(address, password)
|
||||
const url = HttpServer.formatAddress(address)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CodeMode } from "@opencode-ai/codemode"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { CodeModeHost } from "../src/code-mode"
|
||||
|
||||
test("exposes the authenticated OpenCode API through CodeMode", async () => {
|
||||
const requests: Array<{ readonly url: string; readonly authorization?: string }> = []
|
||||
const client = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
requests.push({ url: request.url, authorization: request.headers.authorization })
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ healthy: true, version: "test", pid: 1 },
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = await CodeMode.make({ tools: CodeModeHost.makeTools(client, "secret") })
|
||||
.execute("return await tools.opencode.v2.health.get({})")
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { healthy: true, version: "test", pid: 1 },
|
||||
toolCalls: [{ name: "opencode.v2.health.get" }],
|
||||
})
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://opencode.local/api/health",
|
||||
authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -439,28 +439,8 @@ 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]
|
||||
|
||||
@@ -529,28 +529,7 @@ 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))
|
||||
|
||||
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),
|
||||
})
|
||||
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) })
|
||||
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
type Endpoint11_0Input = {
|
||||
|
||||
@@ -15,7 +15,6 @@ export type {
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
ServerMcpApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export { Service } from "./service.js"
|
||||
@@ -28,7 +27,6 @@ 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"
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -38,7 +37,6 @@ 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 {
|
||||
|
||||
@@ -87,10 +87,6 @@ import type {
|
||||
IntegrationAttemptCancelOutput,
|
||||
ServerMcpListInput,
|
||||
ServerMcpListOutput,
|
||||
ServerMcpResourceCatalogInput,
|
||||
ServerMcpResourceCatalogOutput,
|
||||
ServerMcpReadResourceInput,
|
||||
ServerMcpReadResourceOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialRemoveInput,
|
||||
@@ -894,31 +890,6 @@ 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) =>
|
||||
|
||||
@@ -106,14 +106,6 @@ 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"
|
||||
@@ -2486,60 +2478,6 @@ 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?: {
|
||||
@@ -5570,14 +5508,6 @@ 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
|
||||
|
||||
@@ -10,7 +10,6 @@ export type {
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
SessionApi,
|
||||
ServerMcpApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
@@ -27,7 +26,6 @@ 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isMcpServerNotFoundError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
|
||||
|
||||
test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
@@ -48,74 +48,6 @@ 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 () => {
|
||||
|
||||
+17
-234
@@ -21,24 +21,15 @@ import {
|
||||
ListToolsResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
PromptSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema, Scope, Semaphore } from "effect"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
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_CATALOG_TIMEOUT = 30_000
|
||||
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
|
||||
@@ -77,13 +68,11 @@ export interface ToolDefinition {
|
||||
export interface PromptDefinition {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly arguments:
|
||||
| ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean | undefined
|
||||
}>
|
||||
| undefined
|
||||
readonly arguments: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly required: boolean | undefined
|
||||
}> | undefined
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
@@ -95,28 +84,6 @@ export interface PromptResult {
|
||||
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 =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||
@@ -157,17 +124,6 @@ export interface Connection {
|
||||
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. */
|
||||
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. */
|
||||
readonly prompt: (input: {
|
||||
readonly name: string
|
||||
@@ -185,8 +141,6 @@ export interface Connection {
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
|
||||
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. */
|
||||
@@ -214,8 +168,7 @@ 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), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
@@ -249,57 +202,13 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
|
||||
cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => client.close())),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_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 {
|
||||
instructions: client.getInstructions()?.trim() || undefined,
|
||||
tools: () =>
|
||||
@@ -348,9 +257,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).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) => ({
|
||||
name: prompt.name,
|
||||
@@ -362,127 +269,6 @@ 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) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
@@ -529,10 +315,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
})),
|
||||
),
|
||||
onClose: (callback) => {
|
||||
client.onclose = () => {
|
||||
resourceSubscriptions.clear()
|
||||
callback()
|
||||
}
|
||||
client.onclose = callback
|
||||
},
|
||||
onLog: (callback) => {
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
||||
@@ -545,13 +328,13 @@ export const connect = Effect.fnUntraced(function* (
|
||||
if (!client.getServerCapabilities()?.prompts?.listChanged) return
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
|
||||
},
|
||||
onResourcesChanged: (callback) => {
|
||||
resourceListChangedCallbacks.add(callback)
|
||||
},
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
|
||||
yield* cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => transport.close())),
|
||||
Effect.ignore,
|
||||
)
|
||||
const error = Cause.squash(exit.cause)
|
||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
|
||||
+50
-105
@@ -83,16 +83,48 @@ export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")
|
||||
messages: Schema.Array(PromptMessage),
|
||||
}) {}
|
||||
|
||||
export const Resource = Mcp.Resource
|
||||
export type Resource = Mcp.Resource
|
||||
export const ResourceTemplate = Mcp.ResourceTemplate
|
||||
export type ResourceTemplate = Mcp.ResourceTemplate
|
||||
export const ResourceCatalog = Mcp.ResourceCatalog
|
||||
export type ResourceCatalog = Mcp.ResourceCatalog
|
||||
export const ResourceContentPart = Mcp.ResourceContentPart
|
||||
export type ResourceContentPart = Mcp.ResourceContentPart
|
||||
export const ResourceContent = Mcp.ResourceContent
|
||||
export type ResourceContent = Mcp.ResourceContent
|
||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
||||
server: ServerName,
|
||||
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", {
|
||||
server: ServerName,
|
||||
@@ -116,9 +148,6 @@ type ServerEntry = {
|
||||
client?: MCPClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
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.
|
||||
integrationID?: Integration.ID
|
||||
}
|
||||
@@ -179,7 +208,6 @@ export const layer = Layer.effect(
|
||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
resourceRevision: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,55 +415,6 @@ 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) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
@@ -462,10 +441,8 @@ export const layer = Layer.effect(
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
invalidateResources(entry)
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
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(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
@@ -481,11 +458,6 @@ export const layer = Layer.effect(
|
||||
connection.onPromptsChanged(() => {
|
||||
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) => {
|
||||
@@ -519,7 +491,6 @@ export const layer = Layer.effect(
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(result)) {
|
||||
invalidateResources(entry)
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
||||
entry.prompts = []
|
||||
@@ -530,7 +501,6 @@ export const layer = Layer.effect(
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
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)
|
||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
||||
return
|
||||
@@ -571,7 +541,6 @@ export const layer = Layer.effect(
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
invalidateResources(entry)
|
||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
@@ -588,6 +557,11 @@ export const layer = Layer.effect(
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const target = yield* requireServer(server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
@@ -663,40 +637,11 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||
yield* whenAllReady
|
||||
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),
|
||||
),
|
||||
})
|
||||
return new ResourceCatalog({ resources: [], templates: [] })
|
||||
}),
|
||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
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,
|
||||
})
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -43,15 +43,20 @@ export interface Registration {
|
||||
readonly group?: string
|
||||
}
|
||||
|
||||
export interface CodeModeTools {
|
||||
[name: string]: Tool.Definition<never> | CodeModeTools
|
||||
}
|
||||
|
||||
export const create = (options: {
|
||||
readonly registrations: ReadonlyMap<string, Registration>
|
||||
readonly current: (name: string) => Registration | undefined
|
||||
readonly tools: CodeModeTools
|
||||
}) => {
|
||||
const runtime = (
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) => {
|
||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
||||
const tools = cloneTools(options.tools)
|
||||
for (const [name, registration] of options.registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const value = Tool.make({
|
||||
@@ -163,6 +168,15 @@ export const create = (options: {
|
||||
})
|
||||
}
|
||||
|
||||
function cloneTools(tools: CodeModeTools): CodeModeTools {
|
||||
return Object.assign(
|
||||
Object.create(null),
|
||||
Object.fromEntries(
|
||||
Object.entries(tools).map(([name, value]) => [name, Tool.isDefinition(value) ? value : cloneTools(value)]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * as ToolRegistry from "./registry"
|
||||
export type { CodeModeTools } from "./execute"
|
||||
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
@@ -9,11 +10,12 @@ import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ExecuteTool } from "./execute"
|
||||
import { ExecuteTool, type CodeModeTools } from "./execute"
|
||||
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { toSessionError } from "../session/to-session-error"
|
||||
|
||||
@@ -51,10 +53,20 @@ export interface Settlement {
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools: CodeModeTools }>()(
|
||||
"@opencode/v2/CodeModeCatalog",
|
||||
) {}
|
||||
|
||||
const codeModeCatalogNode = makeLocationNode({
|
||||
service: CodeModeCatalog,
|
||||
layer: Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: {} })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeModeTools = (yield* CodeModeCatalog).tools
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = {
|
||||
@@ -204,11 +216,13 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
|
||||
const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred))
|
||||
const tools = Flag.CODEMODE_ENABLED ? codeModeTools : {}
|
||||
const execute =
|
||||
deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
||||
(deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? [])
|
||||
? ExecuteTool.create({
|
||||
registrations: deferred,
|
||||
current: (name) => local.get(name)?.at(-1)?.registration,
|
||||
tools,
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
@@ -241,14 +255,18 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement {
|
||||
return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))]
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
|
||||
})
|
||||
|
||||
@@ -4,27 +4,16 @@ import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server(
|
||||
{ name: "timeout", version: "1.0.0" },
|
||||
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
|
||||
)
|
||||
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
|
||||
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
|
||||
})
|
||||
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 () => {
|
||||
await Bun.sleep(100)
|
||||
return { content: [] }
|
||||
@@ -33,9 +22,5 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
|
||||
await Bun.sleep(100)
|
||||
return { messages: [] }
|
||||
})
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
await Bun.sleep(100)
|
||||
return { contents: [{ uri: request.params.uri, text: "slow" }] }
|
||||
})
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
|
||||
@@ -14,12 +14,15 @@ export const emptyMcpLayer = Layer.succeed(
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.succeed(undefined),
|
||||
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
export const emptyConfigLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
|
||||
export const testLocationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
|
||||
@@ -3,203 +3,26 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
SubscribeRequestSchema,
|
||||
UnsubscribeRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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 { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
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, {
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
@@ -418,297 +241,6 @@ test("applies the configured MCP execution timeout to prompts", async () => {
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
@@ -30,6 +30,27 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
||||
const it = testEffect(registryLayer)
|
||||
const codeModeTools: ToolRegistry.CodeModeTools = {
|
||||
opencode: {
|
||||
v2: {
|
||||
health: {
|
||||
get: {
|
||||
_tag: "CodeModeTool" as const,
|
||||
description: "Get server health",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ healthy: Schema.Boolean }),
|
||||
run: () => Effect.succeed({ healthy: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const codeModeIt = testEffect(
|
||||
AppNodeBuilder.build(ToolRegistry.node, [
|
||||
[ToolOutputStore.node, outputStore],
|
||||
ToolRegistry.codeModeReplacement(codeModeTools),
|
||||
]),
|
||||
)
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
@@ -53,6 +74,50 @@ const make = (permission?: string) => {
|
||||
}
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
codeModeIt.effect("includes host Code Mode trees without hosted tool registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const definitions = yield* toolDefinitions(service)
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(definitions[0]?.description).toContain("tools.opencode.v2.health.get")
|
||||
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-opencode-health",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.opencode.v2.health.get({})" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: '{\n "healthy": true\n}' })
|
||||
}),
|
||||
)
|
||||
|
||||
codeModeIt.effect("keeps host Code Mode trees immutable while merging deferred tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() }, { group: "opencode", deferred: true })
|
||||
|
||||
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
|
||||
expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo")
|
||||
expect(
|
||||
yield* executeTool(service, {
|
||||
sessionID,
|
||||
...identity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-opencode-echo",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.opencode.echo({ text: "hello" })' },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "text", value: '{\n "text": "hello"\n}' })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
||||
@@ -61,8 +61,6 @@ export const groupNames = {
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
"mcp.resource.catalog": "resourceCatalog",
|
||||
"mcp.resource.read": "readResource",
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
|
||||
@@ -61,15 +61,6 @@ export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFo
|
||||
{ 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>()(
|
||||
"SessionNotFoundError",
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { McpServerNotFoundError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const McpGroup = HttpApiGroup.make("server.mcp")
|
||||
@@ -20,34 +19,4 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.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." }))
|
||||
.annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." }))
|
||||
|
||||
@@ -4,6 +4,5 @@ import { isOpenCodeEvent } from "../src/groups/event.js"
|
||||
test("classifies public events by type", () => {
|
||||
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
||||
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
|
||||
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
|
||||
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ export const ServerDefinitions = Event.inventory(
|
||||
...InstallationEvent.Definitions,
|
||||
...VcsEvent.Definitions,
|
||||
McpEvent.StatusChanged,
|
||||
McpEvent.ResourcesChanged,
|
||||
// Shared transitional: V1 contracts the current TUI still consumes during
|
||||
// the migration (permission.asked/replied, question.asked, session.error).
|
||||
// Remove when the TUI moves to the current permission/question surfaces.
|
||||
|
||||
@@ -9,7 +9,6 @@ export { Form } from "./form.js"
|
||||
export { Integration } from "./integration.js"
|
||||
export { LLM } from "./llm.js"
|
||||
export { Location } from "./location.js"
|
||||
export { Mcp } from "./mcp.js"
|
||||
export { Model } from "./model.js"
|
||||
export { Permission } from "./permission.js"
|
||||
export { PermissionSaved } from "./permission-saved.js"
|
||||
|
||||
@@ -10,13 +10,6 @@ export const ToolsChanged = Event.ephemeral({
|
||||
},
|
||||
})
|
||||
|
||||
export const ResourcesChanged = Event.ephemeral({
|
||||
type: "mcp.resources.changed",
|
||||
schema: {
|
||||
server: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
export const BrowserOpenFailed = Event.ephemeral({
|
||||
type: "mcp.browser.open.failed",
|
||||
schema: {
|
||||
@@ -34,4 +27,4 @@ export const StatusChanged = Event.ephemeral({
|
||||
},
|
||||
})
|
||||
|
||||
export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged)
|
||||
export const Definitions = Event.inventory(ToolsChanged, StatusChanged)
|
||||
|
||||
@@ -25,9 +25,14 @@ const NeedsClientRegistration = Schema.Struct({
|
||||
}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" })
|
||||
|
||||
export type Status = typeof Status.Type
|
||||
export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe(
|
||||
Schema.toTaggedUnion("status"),
|
||||
)
|
||||
export const Status = Schema.Union([
|
||||
Connected,
|
||||
Pending,
|
||||
Disabled,
|
||||
Failed,
|
||||
NeedsAuth,
|
||||
NeedsClientRegistration,
|
||||
]).pipe(Schema.toTaggedUnion("status"))
|
||||
|
||||
export interface Server extends Schema.Schema.Type<typeof Server> {}
|
||||
export const Server = Schema.Struct({
|
||||
@@ -37,50 +42,3 @@ export const Server = Schema.Struct({
|
||||
// without matching by name, which could collide with provider or plugin integrations.
|
||||
integrationID: optional(IntegrationID),
|
||||
}).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,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { Model } from "../src/model.js"
|
||||
import { Project } from "../src/project.js"
|
||||
import { Provider } from "../src/provider.js"
|
||||
@@ -52,11 +51,6 @@ describe("contract hygiene", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
FileSystem.Submatch,
|
||||
Mcp.Resource,
|
||||
Mcp.ResourceTemplate,
|
||||
Mcp.ResourceCatalog,
|
||||
Mcp.ResourceContentPart,
|
||||
Mcp.ResourceContent,
|
||||
Model.Ref,
|
||||
Model.Capabilities,
|
||||
Model.Cost,
|
||||
|
||||
@@ -53,7 +53,6 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
|
||||
expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted)
|
||||
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
||||
expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged)
|
||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||
})
|
||||
@@ -77,7 +76,7 @@ describe("public event manifest", () => {
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||
expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated])
|
||||
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged])
|
||||
expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged])
|
||||
expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false)
|
||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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" },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Mcp } from "@opencode-ai/schema/mcp"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
const SDK = await import("../src/index")
|
||||
@@ -9,7 +8,6 @@ const SDK = await import("../src/index")
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.Mcp).toBe(Mcp)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -20,7 +18,6 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"FileSystem",
|
||||
"Integration",
|
||||
"Location",
|
||||
"Mcp",
|
||||
"Model",
|
||||
"OpenCode",
|
||||
"Permission",
|
||||
|
||||
@@ -2,45 +2,24 @@ import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response } from "../location"
|
||||
|
||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
return handlers
|
||||
.handle(
|
||||
"mcp.list",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
return yield* response(
|
||||
service
|
||||
.servers()
|
||||
.pipe(
|
||||
Effect.map((servers) =>
|
||||
servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
|
||||
),
|
||||
return handlers.handle(
|
||||
"mcp.list",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
return yield* response(
|
||||
service
|
||||
.servers()
|
||||
.pipe(
|
||||
Effect.map((servers) =>
|
||||
servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"mcp.resource.catalog",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
return yield* response(service.resourceCatalog())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"mcp.resource.read",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
return yield* response(
|
||||
service.readResource({ server: ctx.payload.server, uri: ctx.payload.uri }).pipe(
|
||||
Effect.map((result) => result ?? null),
|
||||
Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { HealthGroup } from "@opencode-ai/protocol/groups/health"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { createServer } from "node:http"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { createRoutes } from "./routes"
|
||||
|
||||
@@ -18,6 +18,7 @@ export type Options = {
|
||||
readonly hostname: string
|
||||
readonly port: Option.Option<number>
|
||||
readonly password: string
|
||||
readonly replacements?: (server: Server) => LayerNode.Replacements
|
||||
}
|
||||
|
||||
const ReadinessApi = HttpApi.make("readiness").add(HealthGroup)
|
||||
@@ -42,19 +43,21 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: Option
|
||||
})
|
||||
|
||||
function listen(options: Options) {
|
||||
if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password)
|
||||
if (Option.isSome(options.port)) return bind(options, options.port.value)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
bind(options.hostname, port, options.password).pipe(
|
||||
bind(options, port).pipe(
|
||||
Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
function bind(options: Options, port: number) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
|
||||
HttpRouter.serve(createRoutes(options.password, options.replacements?.(server)), {
|
||||
disableListenLog: true,
|
||||
}).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })),
|
||||
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
|
||||
),
|
||||
).pipe(
|
||||
|
||||
@@ -47,21 +47,28 @@ const applicationServices = LayerNode.group([
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
|
||||
export function createRoutes(password?: string) {
|
||||
export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) {
|
||||
return makeRoutes(
|
||||
password
|
||||
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
||||
: ServerAuth.Config.layer,
|
||||
undefined,
|
||||
replacements,
|
||||
)
|
||||
}
|
||||
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store, replacements: LayerNode.Replacements = []) {
|
||||
return makeRoutes(
|
||||
ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }),
|
||||
sdkPlugins,
|
||||
replacements,
|
||||
)
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
sdkPlugins?: SdkPlugins.Store,
|
||||
hostReplacements: LayerNode.Replacements = [],
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
@@ -69,6 +76,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||
...hostReplacements,
|
||||
]
|
||||
const serviceLayer = simulateEnabled()
|
||||
? Layer.unwrap(
|
||||
|
||||
Reference in New Issue
Block a user