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
10 changes: 9 additions & 1 deletion packages/core/src/tool/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export const name = (server: string, tool: string) => `${namespace(server)}_${to
export interface Interface {
/** Wait for the initial MCP tool registration to settle. */
readonly flush: Effect.Effect<void>
/**
* Reload the registry from the connected servers and wait for it to apply, without waiting for the
* debounced reload. Routes that change the server set use this so their response means the registry
* already reflects the change.
*/
readonly refresh: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
Expand Down Expand Up @@ -121,6 +127,8 @@ export const layer = Layer.effect(
}),
)
.pipe(Effect.forkScoped)
// Re-reads the connected servers and reloads the registry. Background announcements reach it through
// the debounce below; a caller that just changed the server set awaits it directly via `refresh`.
const reconcile = lock.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
Expand All @@ -141,7 +149,7 @@ export const layer = Layer.effect(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)), refresh: reconcile })
}),
)

Expand Down
37 changes: 37 additions & 0 deletions packages/core/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,43 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
)
})

testEffect(Layer.empty).effect("refreshes the registry for a change that is still debounced", () =>
Effect.gen(function* () {
const demoTool = (name: string) =>
({
server: Mcp.ServerName.make("demo"),
name,
codemode: false,
inputSchema: { type: "object", properties: {} },
}) satisfies Mcp.Tool
const catalog = yield* Ref.make<Array<Mcp.Tool>>([demoTool("before")])

yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const mcpTool = yield* McpTool.Service
const bus = yield* Bus.Service
yield* mcpTool.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_before", "execute"])

// A route that adds a server applies the change and announces it, but the registry only reloads
// once the debounce fires. The route awaits `refresh` instead, so a step right after its response
// already sees the tools.
yield* Ref.set(catalog, [demoTool("after")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* mcpTool.refresh
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_after", "execute"])
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
Mcp.node.replace(Layer.mock(Mcp.Service, { tools: () => Ref.get(catalog) })),
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
Image.node.replace(imagePassthrough),
]),
),
)
}),
)

it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
Expand Down
22 changes: 18 additions & 4 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/core/mcp/index"
import { McpTool } from "@opencode/core/tool/mcp"
import { McpServerNotFoundError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
Expand All @@ -8,6 +9,19 @@ import { response } from "../location"
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))

/**
* Answers a route that changed the server set only once the tool registry reflects it. The registry
* reloads on a debounced event, so waiting for the event alone would let a prompt sent right after the
* response run its first step without the tools of the server the client just added.
*/
const mutate = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const registry = yield* McpTool.Service
const result = yield* effect
yield* registry.refresh
return result
})

export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
Effect.gen(function* () {
return handlers
Expand All @@ -30,31 +44,31 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
"mcp.add",
Effect.fn(function* (ctx) {
const service = yield* Mcp.Service
yield* service.add(ctx.params.server, ctx.payload.config)
yield* mutate(service.add(ctx.params.server, ctx.payload.config))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.remove",
Effect.fn(function* (ctx) {
const service = yield* Mcp.Service
yield* notFound(service.remove(ctx.params.server))
yield* mutate(notFound(service.remove(ctx.params.server)))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.connect",
Effect.fn(function* (ctx) {
const service = yield* Mcp.Service
yield* notFound(service.connect(ctx.params.server))
yield* mutate(notFound(service.connect(ctx.params.server)))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"mcp.disconnect",
Effect.fn(function* (ctx) {
const service = yield* Mcp.Service
yield* notFound(service.disconnect(ctx.params.server))
yield* mutate(notFound(service.disconnect(ctx.params.server)))
return HttpApiSchema.NoContent.make()
}),
)
Expand Down
Loading