diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index d82e45ae33..abbc78af81 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -2,7 +2,7 @@ export * as McpTool from "./mcp" import { ToolFailure } from "@opencode-ai/ai" import { McpEvent } from "@opencode-ai/schema/mcp-event" -import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" +import { Context, Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Bus } from "../bus" @@ -16,7 +16,14 @@ import { Tool } from "../tool" export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_") export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}` -export const layer = Layer.effectDiscard( +export interface Interface { + readonly reconcile: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/McpTool") {} + +export const layer = Layer.effect( + Service, Effect.gen(function* () { const mcp = yield* MCP.Service const tools = yield* Tool.Service @@ -118,11 +125,12 @@ export const layer = Layer.effectDiscard( Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }), ) + return Service.of({ reconcile }) }), ) export const node = makeLocationNode({ - name: "mcp-tools", + service: Service, layer, deps: [Tool.node, MCP.node, Bus.node, Permission.node], }) diff --git a/packages/core/test/mcp-tool-reconciliation.test.ts b/packages/core/test/mcp-tool-reconciliation.test.ts new file mode 100644 index 0000000000..21da39de7f --- /dev/null +++ b/packages/core/test/mcp-tool-reconciliation.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Bus } from "@opencode-ai/core/bus" +import { Image } from "@opencode-ai/core/image" +import { MCP } from "@opencode-ai/core/mcp/index" +import { Permission } from "@opencode-ai/core/permission" +import { McpTool } from "@opencode-ai/core/tool/mcp" +import { Tool } from "@opencode-ai/core/tool" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect" +import { imagePassthrough } from "./lib/image" + +test("explicitly fences asynchronous MCP tool reconciliation", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const initialRead = yield* Deferred.make() + const reconcileStarted = yield* Deferred.make() + const releaseReconcile = yield* Deferred.make() + const updates = yield* PubSub.unbounded() + let reads = 0 + let catalog: Array = [] + + const layer = AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [ + [ + MCP.node, + Layer.mock(MCP.Service, { + tools: () => + Effect.gen(function* () { + reads += 1 + if (reads === 1) { + const current = catalog + yield* Deferred.succeed(initialRead, undefined) + return current + } + yield* Deferred.succeed(reconcileStarted, undefined) + yield* Deferred.await(releaseReconcile) + return catalog + }), + }), + ], + [Bus.node, Layer.mock(Bus.Service, { subscribe: () => Stream.fromPubSub(updates) as never })], + [Permission.node, Layer.mock(Permission.Service, {})], + [Image.node, imagePassthrough], + ]) + + yield* Effect.gen(function* () { + const registry = yield* Tool.Service + const adapter = yield* McpTool.Service + yield* Deferred.await(initialRead) + catalog = [ + new MCP.Tool({ + server: MCP.ServerName.make("voice"), + name: "list_open_tabs", + inputSchema: { type: "object", properties: {} }, + }), + ] + + yield* PubSub.publish(updates, undefined) + yield* Deferred.await(reconcileStarted) + + const stale = yield* registry.snapshot() + expect(stale.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(false) + + const fence = yield* Effect.forkChild(adapter.reconcile, { startImmediately: true }) + expect(fence.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(releaseReconcile, undefined) + yield* Fiber.join(fence) + const current = yield* registry.snapshot() + expect(current.codeModeCatalog?.some((entry) => entry.path === "voice.list_open_tabs")).toBe(true) + }).pipe(Effect.provide(layer)) + }), + ), + ) +}) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 0bb206997c..b38f150c57 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -1,4 +1,5 @@ import { MCP } from "@opencode-ai/core/mcp/index" +import { McpTool } from "@opencode-ai/core/tool/mcp" import { McpServerNotFoundError } from "@opencode-ai/protocol/errors" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" @@ -30,7 +31,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => "mcp.add", Effect.fn(function* (ctx) { const service = yield* MCP.Service + const tools = yield* McpTool.Service yield* service.add(ctx.params.server, ctx.payload.config) + yield* tools.reconcile return HttpApiSchema.NoContent.make() }), ) @@ -38,7 +41,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => "mcp.remove", Effect.fn(function* (ctx) { const service = yield* MCP.Service + const tools = yield* McpTool.Service yield* notFound(service.remove(ctx.params.server)) + yield* tools.reconcile return HttpApiSchema.NoContent.make() }), ) @@ -46,7 +51,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => "mcp.connect", Effect.fn(function* (ctx) { const service = yield* MCP.Service + const tools = yield* McpTool.Service yield* notFound(service.connect(ctx.params.server)) + yield* tools.reconcile return HttpApiSchema.NoContent.make() }), ) @@ -54,7 +61,9 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => "mcp.disconnect", Effect.fn(function* (ctx) { const service = yield* MCP.Service + const tools = yield* McpTool.Service yield* notFound(service.disconnect(ctx.params.server)) + yield* tools.reconcile return HttpApiSchema.NoContent.make() }), )