Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/core/src/tool/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tool.Service
Expand Down Expand Up @@ -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],
})
75 changes: 75 additions & 0 deletions packages/core/test/mcp-tool-reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
const reconcileStarted = yield* Deferred.make<void>()
const releaseReconcile = yield* Deferred.make<void>()
const updates = yield* PubSub.unbounded<void>()
let reads = 0
let catalog: Array<MCP.Tool> = []

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))
}),
),
)
})
9 changes: 9 additions & 0 deletions packages/server/src/handlers/mcp.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -30,31 +31,39 @@ 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()
}),
)
.handle(
"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()
}),
)
.handle(
"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()
}),
)
.handle(
"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()
}),
)
Expand Down
Loading