128 lines
5 KiB
TypeScript
128 lines
5 KiB
TypeScript
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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
import { Bus } from "../bus"
|
|
|
|
import { MCP } from "../mcp"
|
|
import { Permission } from "../permission"
|
|
import { Tool } from "../tool"
|
|
|
|
/**
|
|
* Registry namespace and permission action names for MCP tools.
|
|
*/
|
|
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(
|
|
Effect.gen(function* () {
|
|
const mcp = yield* MCP.Service
|
|
const tools = yield* Tool.Service
|
|
const bus = yield* Bus.Service
|
|
const permission = yield* Permission.Service
|
|
const scope = yield* Scope.Scope
|
|
const lock = Semaphore.makeUnsafe(1)
|
|
let current: Scope.Closeable | undefined
|
|
|
|
// Register the current tool set under a fresh child scope, then close the previous one so the
|
|
// registry never has a gap where MCP tools disappear mid-swap.
|
|
const reconcile = lock.withPermit(
|
|
Effect.gen(function* () {
|
|
const discovered = yield* mcp.tools()
|
|
const next = yield* Scope.fork(scope)
|
|
yield* tools
|
|
.transform((draft) => {
|
|
for (const tool of discovered) {
|
|
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
|
draft.add({
|
|
name: tool.name,
|
|
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
|
description: tool.description ?? "",
|
|
input: {
|
|
...schema,
|
|
type: "object",
|
|
properties: schema.properties ?? {},
|
|
additionalProperties: false,
|
|
},
|
|
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
|
execute: (input, context) =>
|
|
Effect.gen(function* () {
|
|
yield* permission.assert({
|
|
action: name(tool.server, tool.name),
|
|
resources: ["*"],
|
|
save: ["*"],
|
|
metadata: {},
|
|
sessionID: context.sessionID,
|
|
agent: context.agent,
|
|
source: {
|
|
type: "tool",
|
|
messageID: context.messageID,
|
|
callID: context.callID,
|
|
},
|
|
})
|
|
const result = yield* mcp
|
|
.callTool({
|
|
server: tool.server,
|
|
name: tool.name,
|
|
args: (input ?? {}) as Record<string, unknown>,
|
|
})
|
|
.pipe(
|
|
Effect.catchTags({
|
|
"MCP.NotFoundError": (error) =>
|
|
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
|
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
|
}),
|
|
)
|
|
if (result.isError)
|
|
return yield* new ToolFailure({
|
|
message:
|
|
result.content
|
|
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
.join("\n")
|
|
.trim() || "MCP tool returned an error",
|
|
})
|
|
const content = result.content.map((part) =>
|
|
part.type === "text"
|
|
? { type: "text" as const, text: part.text }
|
|
: {
|
|
type: "file" as const,
|
|
uri: `data:${part.mimeType};base64,${part.data}`,
|
|
mime: part.mimeType,
|
|
},
|
|
)
|
|
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
|
return {
|
|
output: result.structured ?? (text === "" ? null : text),
|
|
...(content.length === 0 ? {} : { content }),
|
|
}
|
|
}).pipe(
|
|
Effect.mapError((error) =>
|
|
error instanceof ToolFailure
|
|
? error
|
|
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
|
),
|
|
),
|
|
})
|
|
}
|
|
})
|
|
.pipe(Scope.provide(next), Effect.orDie)
|
|
if (current) yield* Scope.close(current, Exit.void)
|
|
current = next
|
|
}),
|
|
)
|
|
|
|
yield* reconcile.pipe(Effect.forkScoped)
|
|
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
|
Stream.runForEach(() => reconcile),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
}),
|
|
)
|
|
|
|
export const node = makeLocationNode({
|
|
name: "mcp-tools",
|
|
layer,
|
|
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
|
})
|