fix(core): fence runtime MCP tool reconciliation
This commit is contained in:
parent
7aaf4e7750
commit
227598c504
3 changed files with 95 additions and 3 deletions
|
|
@ -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<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
|
||||
|
|
@ -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
packages/core/test/mcp-tool-reconciliation.test.ts
Normal file
75
packages/core/test/mcp-tool-reconciliation.test.ts
Normal file
|
|
@ -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))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue