feat(opencode): add code-mode MCP adapter (#35085)

This commit is contained in:
Aiden Cline 2026-07-03 04:24:27 -05:00 committed by GitHub
parent 30936a9bca
commit abaab29cb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1370 additions and 0 deletions

View file

@ -593,6 +593,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

View file

@ -84,6 +84,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

View file

@ -213,6 +213,11 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
)
}
export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
const hidden = disabled(Object.keys(tools), ruleset)
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
}
export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })
export * as Permission from "."

View file

@ -0,0 +1,322 @@
import * as Tool from "./tool"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
Tool as SandboxTool,
toolError,
type ExecuteResult,
type JsonSchema,
type ToolDefinition,
} from "@opencode-ai/codemode"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
export const CODE_MODE_TOOL = "execute"
const DESCRIPTION = [
"Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.",
"The full usage guide and the catalog of available tools follow below.",
].join("\n")
export const Parameters = Schema.Struct({
code: Schema.String.annotate({
description: [
"JavaScript source to execute.",
"Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.",
"Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.",
].join(" "),
}),
})
type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
type Metadata = {
toolCalls: CallEntry[]
error?: boolean
}
type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]
type CatalogEntry = {
path: string
key: string
server: string
local: string
tool: MCP.McpTool
}
function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server =
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
tool: mcpTools[key]!,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}
export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
return CodeMode.make({
tools: toolTree(
[...groupByServer(mcpTools, servers).values()].flat(),
() => () => Effect.fail(toolError("Tool preview is not executable.")),
),
}).instructions()
}
const lastSegment = (uri: string) => {
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
return segment.length > 0 ? segment : undefined
}
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
const text: string[] = []
let files = 0
let images = 0
const push = (attachment: Attachment) => {
files += 1
if (attachment.mime.startsWith("image/")) images += 1
collect(attachment)
}
for (const block of result.content) {
switch (block.type) {
case "text":
text.push(block.text)
break
case "image":
case "audio":
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
break
case "resource": {
if ("text" in block.resource) {
text.push(block.resource.text)
break
}
const mime = block.resource.mimeType ?? "application/octet-stream"
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
break
}
case "resource_link":
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel.
text.push(`${block.name}: ${block.uri}`)
break
}
}
if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
if (text.length > 0) return text.join("\n")
if (files > 0) {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
return null
}
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
const tree: Record<string, Record<string, ToolDefinition>> = {}
for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({
description: entry.tool.def.description ?? "",
input: entry.tool.def.inputSchema as JsonSchema,
output: entry.tool.def.outputSchema as JsonSchema | undefined,
run: run(entry),
})
}
return tree
}
const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: Record<string, unknown>
callID: string
ctx: Tool.Context
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
{ args: input.args },
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": input.entry.key,
"tool.call_id": input.callID,
"session.id": input.ctx.sessionID,
"message.id": input.ctx.messageID,
},
}),
)
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})
export const CodeModeTool = Tool.define(
CODE_MODE_TOOL,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const agents = yield* Agent.Service
const sessions = yield* Session.Service
const plugin = yield* Plugin.Service
const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
description: DESCRIPTION,
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: [], error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
const agent = yield* agents.get(ctx.agent)
const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie)
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset)
const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)
const catalog = [...groupByServer(mcpTools, servers).values()].flat()
const calls: CallEntry[] = []
const attachments: Attachment[] = []
const publish = () =>
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
let childCalls = 0
const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () {
childCalls += 1
const result = yield* invokeChildTool({
plugin,
entry,
args: (input ?? {}) as Record<string, unknown>,
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
ctx,
})
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment))
}).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
const error = Cause.squash(cause)
return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error))
}),
)
const runtime = CodeMode.make({
tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => {
const shown = (() => {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
return Object.keys(value).length > 0 ? value : undefined
}
return { input }
})()
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return publish()
}),
onToolCallEnd: ({ index, outcome }) =>
Effect.suspend(() => {
const current = calls[index]
if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
return publish()
}),
})
const abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const cancelled = (): ExecuteResult => ({
ok: false,
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
toolCalls: calls.map((call) => ({ name: call.tool })),
})
const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
const logs = result.logs ?? []
const attached = attachments.length > 0 ? { attachments } : {}
const hints = result.ok
? []
: (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
const metadata: Metadata = result.ok ? { toolCalls: calls } : { toolCalls: calls, error: true }
let output: string
if (result.ok) {
if (typeof result.value === "string") output = result.value
else if (result.value === undefined) output = "undefined"
else {
try {
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
} catch {
output = String(result.value)
}
}
} else {
output = [result.error.message, ...hints].join("\n")
}
if (logs.length > 0)
output = output.length > 0 ? `${output}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
return {
title: CODE_MODE_TOOL,
metadata,
output,
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}),
}
return init
}),
)

View file

@ -0,0 +1,327 @@
import { beforeAll, describe, expect, test } from "bun:test"
import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
import { McpCatalog } from "@/mcp/catalog"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
import {
CallToolRequestSchema,
LATEST_PROTOCOL_VERSION,
ListToolsRequestSchema,
type Tool as MCPToolDef,
} from "@modelcontextprotocol/sdk/types.js"
import { Effect, Layer } from "effect"
const PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
const SERVER = "fixtures"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode-int"),
messageID: MessageID.make("msg_code-mode-int"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode_int",
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
// Avoid the SDK Client here; other MCP tests mock it process-globally.
class RawJsonRpcClient {
private nextId = 1
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>()
constructor(private transport: InMemoryTransport) {}
async connect() {
this.transport.onmessage = (message) => {
const msg = message as { id?: number; result?: unknown; error?: { message: string } }
if (msg.id === undefined) return
const entry = this.pending.get(msg.id)
if (!entry) return
this.pending.delete(msg.id)
if (msg.error) entry.reject(new Error(msg.error.message))
else entry.resolve(msg.result)
}
await this.transport.start()
await this.request("initialize", {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "test-client", version: "1.0.0" },
})
await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" })
}
private request(method: string, params: unknown): Promise<any> {
const id = this.nextId++
const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }))
void this.transport.send({ jsonrpc: "2.0", id, method, params } as never)
return result
}
listTools() {
return this.request("tools/list", {})
}
callTool(params: { name: string; arguments?: Record<string, unknown> }, _schema?: unknown, _options?: unknown) {
return this.request("tools/call", params)
}
}
const TOOL_DEFS: MCPToolDef[] = [
{
name: "get_text",
description: "Greet someone and return the greeting as text",
inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
},
{
name: "add",
description: "Add two numbers and return the structured sum",
inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] },
outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] },
},
{
name: "screenshot",
description: "Capture a screenshot and return it as an image",
inputSchema: { type: "object", properties: {} },
},
{
name: "boom",
description: "A tool that always fails",
inputSchema: { type: "object", properties: {} },
},
] as MCPToolDef[]
function handleCall(name: string, args: Record<string, unknown>) {
switch (name) {
case "get_text":
return { content: [{ type: "text", text: `hello ${args.name}` }] }
case "add": {
const sum = (args.a as number) + (args.b as number)
return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } }
}
case "screenshot":
return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] }
case "boom":
return { content: [{ type: "text", text: "kaboom" }], isError: true }
default:
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true }
}
}
let tool: Awaited<ReturnType<typeof buildTool>>["tool"]
let description: string
async function buildTool() {
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
server.setRequestHandler(CallToolRequestSchema, async (req) =>
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await server.connect(serverTransport)
const client = new RawJsonRpcClient(clientTransport)
await client.connect()
const listed = (await client.listTools()).tools as MCPToolDef[]
const mcpTools: Record<string, MCP.McpTool> = {}
for (const def of listed) {
mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client }
}
const layer = Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: (((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", permission: [] } as any) }),
Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }),
Layer.mock(MCP.Service, {
tools: () => Effect.succeed(mcpTools),
clients: () => Effect.succeed({ [SERVER]: {} as any }),
}),
)
return {
tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))),
description: describeCatalog(mcpTools, [SERVER]),
}
}
const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx))
beforeAll(async () => {
const built = await buildTool()
tool = built.tool
description = built.description
})
describe("code mode integration (real MCP server)", () => {
test("the appended catalog inlines full signatures with real MCP schemas", () => {
expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- fixtures (4 tools)")
expect(description).toContain(
"tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>",
)
expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise<unknown>")
expect(description).toContain("// Add two numbers and return the structured sum")
expect(description).not.toContain("$codemode")
expect(description).toContain("## Workflow")
expect(description).toContain("`const res = await tools.<namespace>.<tool>(input)`")
expect(description).not.toContain("total_count")
})
test("calls a text tool and receives its text as the native result", async () => {
const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r")
expect(out.output).toBe("hello world")
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } },
])
expect(out.attachments).toBeUndefined()
})
test("exposes structured data natively from a tool with an outputSchema", async () => {
const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.sum")
expect(out.output).toBe("5")
})
test("composes multiple structured calls and returns a plain object", async () => {
const out = await run(`
const first = await tools.fixtures.add({ a: 1, b: 2 })
const second = await tools.fixtures.add({ a: first.sum, b: 10 })
return { total: second.sum }
`)
expect(JSON.parse(out.output)).toEqual({ total: 13 })
expect(out.metadata.toolCalls).toEqual([
{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("an image result becomes an execute attachment and a marker in the sandbox", async () => {
const out = await run("return await tools.fixtures.screenshot({})")
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }])
})
test("image bytes never enter the sandbox or the model-facing output", async () => {
const out = await run(`
const shot = await tools.fixtures.screenshot({})
return { sawMarker: typeof shot === 'string' && shot.includes('attached'), value: shot }
`)
expect(JSON.parse(out.output)).toEqual({
sawMarker: true,
value: "[1 image attached to the result]",
})
expect(out.output).not.toContain(PNG)
expect(out.attachments).toHaveLength(1)
})
test("attachments accumulate even when the program returns something else", async () => {
const out = await run("await tools.fixtures.screenshot({}); return 'captured'")
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("runs calls in parallel and accumulates every attachment", async () => {
const out = await run(`
const both = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})])
return 'two shots: ' + both.length
`)
expect(out.output).toBe("two shots: 2")
expect(out.attachments).toHaveLength(2)
expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"])
})
test("propagates an MCP isError into the program as a catchable error", async () => {
const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }")
expect(out.output).toBe("caught: kaboom")
})
test("an uncaught MCP error surfaces as a failed execution", async () => {
const out = await run("await tools.fixtures.boom({}); return 'unreachable'")
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
})
test("console output is captured and appended as a Logs section after the result", async () => {
const out = await run(`
console.log("looking up", { name: "world" })
const r = await tools.fixtures.get_text({ name: "world" })
console.warn("got", r)
return r
`)
expect(out.output).toBe('hello world\n\nLogs:\nlooking up {"name":"world"}\n[warn] got hello world')
expect(out.metadata.error).toBeUndefined()
})
test("console output is preserved on the error path", async () => {
const out = await run(`
console.log("before the throw")
await tools.fixtures.boom({})
return "unreachable"
`)
expect(out.metadata.error).toBe(true)
expect(out.output).toContain("kaboom")
expect(out.output).toContain("Logs:\nbefore the throw")
})
test("a program that logs nothing gets no Logs section", async () => {
const out = await run("return 'quiet'")
expect(out.output).toBe("quiet")
expect(out.output).not.toContain("Logs:")
})
test("console does not consume the tool-call metadata (logging is not a tool call)", async () => {
const out = await run("console.log('hi'); console.error('bye'); return 'ok'")
expect(out.output).toBe("ok\n\nLogs:\nhi\n[error] bye")
expect(out.metadata.toolCalls).toEqual([])
})
test("asks permission for each MCP call, keyed by the flat catalog name", async () => {
const asked: string[] = []
const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) }
await Effect.runPromise(
tool.execute(
{
code: `
await tools.fixtures.add({ a: 1, b: 1 })
await tools.fixtures.get_text({ name: 'x' })
return 'done'
`,
},
permCtx,
),
)
expect(asked).toEqual(["fixtures_add", "fixtures_get_text"])
})
test("streams running/completed metadata for child calls over a real transport", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
await Effect.runPromise(
tool.execute({ code: "await tools.fixtures.add({ a: 1, b: 2 }); return 'done'" }, recordingCtx),
)
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "running", input: { a: 1, b: 2 } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }],
})
})
})

View file

@ -0,0 +1,714 @@
import { describe, expect, test } from "bun:test"
import {
CODE_MODE_TOOL,
CodeModeTool,
Parameters,
describeCatalog,
} from "@/tool/code-mode"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Session } from "@/session/session"
import { Tool } from "@/tool/tool"
import * as Truncate from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { Effect, Layer, Schema } from "effect"
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_code-mode"),
messageID: MessageID.make("msg_code-mode"),
agent: "build",
abort: new AbortController().signal,
callID: "call_code_mode",
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
function mcpTool(
name: string,
handler: (args: Record<string, unknown>) => unknown,
inputSchema: Record<string, unknown> = { type: "object", properties: {} },
outputSchema?: Record<string, unknown>,
): MCP.McpTool {
return {
def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
client: {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
} as unknown as MCP.McpTool["client"],
}
}
function harness(input: {
mcpTools: Record<string, MCP.McpTool>
servers: string[]
permission?: PermissionV1.Rule[]
trigger?: Plugin.Interface["trigger"]
}) {
return Layer.mergeAll(
Layer.mock(Plugin.Service, {
trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
}),
Layer.mock(Truncate.Service, {
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
}),
Layer.mock(Agent.Service, {
get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any),
}),
Layer.mock(Session.Service, {
get: () => Effect.succeed({ permission: [] } as any),
}),
Layer.mock(MCP.Service, {
tools: () => Effect.succeed(input.mcpTools),
clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
}),
)
}
function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
}
function build(
mcpTools: Record<string, MCP.McpTool>,
servers?: string[],
permission?: PermissionV1.Rule[],
trigger?: Plugin.Interface["trigger"],
) {
const names = serverNames(mcpTools, servers)
return Effect.runPromise(
CodeModeTool.pipe(
Effect.flatMap(Tool.init),
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
),
)
}
function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
}
describe("code mode execute", () => {
test("defines execute input with an Effect schema", async () => {
const decode = Schema.decodeUnknownEffect(Parameters)
await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" })
await expect(Effect.runPromise(decode({}))).rejects.toThrow()
})
test("groups multi-underscore server names by longest matching prefix", () => {
const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
expect(description).toContain("- my_server (1 tool)")
expect(description).toContain("tools.my_server.do_thing(")
})
test("groupByServer uses the whole key as the server name when it has no underscore", () => {
const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
expect(description).toContain("- standalone (1 tool)")
expect(description).toContain("tools.standalone.standalone(")
})
test("describeCatalog carries the raw MCP schemas for rendering", () => {
const description = describeFor(
{
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{ type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
),
},
["weather"],
)
expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
})
test("the static base description carries no catalog; the registry appends it", async () => {
const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") })
expect(tool.id).toBe(CODE_MODE_TOOL)
expect(tool.description).toContain("confined runtime")
expect(tool.description).not.toContain("Available tools")
expect(tool.description).not.toContain("list_issues")
})
test("small catalogs inline every full signature in the appended catalog", () => {
const description = describeFor({
github_create_issue: mcpTool("create_issue", () => "", {
type: "object",
properties: { title: { type: "string" }, body: { type: "string" } },
required: ["title"],
}),
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
expect(description).toContain("Available tools (COMPLETE list")
expect(description).toContain("- github (2 tools)")
expect(description).toContain("- linear (1 tool)")
expect(description).toContain(
"tools.github.create_issue(input: { title: string; body?: string }): Promise<unknown>",
)
expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("tools.linear.search(")
expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>")
expect(description).not.toContain("$codemode")
expect(description).not.toContain("Browse one namespace")
expect(description).toContain("## Workflow")
expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string')
expect(description).toContain("Return only the fields you need")
expect(description).not.toContain("total_count")
})
test("signatures render the declared outputSchema as the return type", () => {
const description = describeFor({
weather_current: mcpTool(
"current",
() => "",
{ type: "object", properties: { city: { type: "string" } }, required: ["city"] },
{
type: "object",
properties: { tempC: { type: "number" }, summary: { type: "string" } },
required: ["tempC"],
},
),
})
expect(description).toContain(
"tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
)
})
test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
const tools: Record<string, MCP.McpTool> = {}
const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
for (let i = 0; i < 150; i++) {
tools[`alpha_op_${i}`] = {
def: {
name: `op_${i}`,
description: `${filler}${i}`,
inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
} as MCPToolDef,
client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
}
}
tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
type: "object",
properties: { topic: { type: "string", description: "Subject to look up" } },
required: ["topic"],
})
const description = describeFor(tools, ["alpha", "zeta"])
expect(description).toContain("Available tools (PARTIAL - ")
expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
expect(description).toContain("- zeta (1 tool)\n")
expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
expect(description).toContain("tools.$codemode.search(")
expect(description).toContain("1. Find a tool (skip when it is already listed below)")
expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.')
expect(description).not.toContain("total_count")
expect(description).toContain("tools.alpha.op_0(")
expect(description).not.toContain("tools.alpha.op_99(")
const tool = await build(tools, ["alpha", "zeta"])
const out = await Effect.runPromise(
tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
)
const result = JSON.parse(out.output)
expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool")
expect(result.items[0].signature).toContain("tools.")
const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature
expect(signature).toContain("tools.zeta.only_tool(input: {\n")
expect(signature).toContain(" /** Subject to look up */\n topic: string")
expect(description).not.toContain("/**")
expect(out.metadata.toolCalls).toEqual([
{ tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } },
])
})
test("runs plain JavaScript and returns the value as text", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx))
expect(output.output).toBe("3")
expect(output.metadata.toolCalls).toEqual([])
})
test("Object.keys(tools) enumerates the MCP server namespaces", async () => {
const tool = await build({
github_list_issues: mcpTool("list_issues", () => ""),
linear_search: mcpTool("search", () => ""),
})
const output = await Effect.runPromise(
tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx),
)
expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 })
})
test("calls a namespaced MCP tool and flows its text result back into the program", async () => {
const seen: Record<string, unknown>[] = []
const tool = await build({
greeter_hello: mcpTool("hello", (args) => {
seen.push(args)
return { content: [{ type: "text", text: `hello ${args.name}` }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx),
)
expect(seen).toEqual([{ name: "world" }])
expect(output.output).toBe("HELLO WORLD")
expect(output.metadata.toolCalls).toEqual([
{ tool: "greeter.hello", status: "completed", input: { name: "world" } },
])
})
test("exposes structured content as native data and composes multiple calls", async () => {
const tool = await build({
math_add: mcpTool("add", (args) => ({
content: [],
structuredContent: { sum: (args.a as number) + (args.b as number) },
})),
})
const output = await Effect.runPromise(
tool.execute(
{
code: `
const first = await tools.math.add({ a: 1, b: 2 })
const second = await tools.math.add({ a: first.sum, b: 10 })
return { total: second.sum }
`,
},
ctx,
),
)
expect(JSON.parse(output.output)).toEqual({ total: 13 })
expect(output.metadata.toolCalls).toEqual([
{ tool: "math.add", status: "completed", input: { a: 1, b: 2 } },
{ tool: "math.add", status: "completed", input: { a: 3, b: 10 } },
])
})
test("runs tool calls in parallel with Promise.all", async () => {
const tool = await build({
echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })),
echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" },
ctx,
),
)
expect(output.output).toBe("12")
expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"])
expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
})
test("returns a readable error when the program throws", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx))
expect(output.output).toBe("Uncaught: boom")
expect(output.metadata.error).toBe(true)
})
test("reports an unknown tool as a failed execution", async () => {
const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
expect(output.metadata.error).toBe(true)
expect(output.output).toContain("Unknown tool 'known.missing'")
})
test("propagates an MCP tool error into the program as a catchable failure", async () => {
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx),
)
expect(output.output).toBe("caught: server exploded")
})
test("asks permission before each child tool call", async () => {
const asked: unknown[] = []
const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) }
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) })
await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx),
)
expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"])
})
test("a denied permission fails the child call with a catchable message, not the whole execute", async () => {
const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) }
const called: string[] = []
const tool = await build({
a_tool: mcpTool("a", () => {
called.push("a")
return { content: [{ type: "text", text: "ok" }] }
}),
})
const output = await Effect.runPromise(
tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx),
)
expect(output.output).toBe("denied: permission denied by user")
expect(output.metadata.error).toBeUndefined()
expect(called).toEqual([])
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
})
test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
const events: { name: string; input: any; output: any }[] = []
const trigger = ((name: unknown, input: unknown, output: unknown) =>
Effect.sync(() => {
events.push({ name: name as string, input, output })
return output
})) as Plugin.Interface["trigger"]
const tool = await build(
{
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
},
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
)
expect(out.output).toBe("done")
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
["tool.execute.before", "a_tool", "call_code_mode/1"],
["tool.execute.after", "a_tool", "call_code_mode/1"],
["tool.execute.before", "b_tool", "call_code_mode/2"],
["tool.execute.after", "b_tool", "call_code_mode/2"],
])
const [before, after] = events
expect(before!.input.sessionID).toBe(ctx.sessionID)
expect(before!.output).toEqual({ args: { x: 1 } })
expect(after!.input.args).toEqual({ x: 1 })
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
})
test("a failing before hook fails only that child call as a catchable in-program error", async () => {
const trigger = ((name: unknown, input: any, output: unknown) => {
if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
return Effect.succeed(output)
}) as Plugin.Interface["trigger"]
const called: string[] = []
const record = (name: string) => () => {
called.push(name)
return { content: [{ type: "text", text: "ok" }] }
}
const tool = await build(
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
undefined,
undefined,
trigger,
)
const out = await Effect.runPromise(
tool.execute(
{
code: `
let caught
try { await tools.a.tool({}) } catch (e) { caught = e.message }
const r = await tools.b.tool({})
return caught + " / " + r
`,
},
ctx,
),
)
expect(out.metadata.error).toBeUndefined()
expect(out.output).toBe("hook exploded / ok")
expect(called).toEqual(["b"])
})
test("streams live per-call metadata as a call starts and finishes", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) })
await Effect.runPromise(
tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx),
)
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }],
})
expect(snapshots).toContainEqual({
toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }],
})
})
test("marks a failed child call as error in the live metadata", async () => {
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
const recordingCtx: Tool.Context = {
...ctx,
metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
}
const tool = await build({
bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })),
})
await Effect.runPromise(
tool.execute(
{ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" },
recordingCtx,
),
)
expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] })
})
test("accumulates stripped media as execute attachments the sandbox never sees", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({
content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }],
structuredContent: { name: "shot.png" },
})),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
expect(JSON.parse(out.output)).toEqual({ name: "shot.png" })
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
expect(out.output).not.toContain("PNGDATA")
})
test("a media-only result returns a text marker so the program knows it succeeded", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
expect(out.output).toBe("[1 image attached to the result]")
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
})
test("media-only markers distinguish all-image from mixed attachments", async () => {
const tool = await build({
media_images: mcpTool("images", () => ({
content: [
{ type: "image", data: "PNG1", mimeType: "image/png" },
{ type: "image", data: "PNG2", mimeType: "image/png" },
],
})),
media_mixed: mcpTool("mixed", () => ({
content: [
{ type: "image", data: "PNG3", mimeType: "image/png" },
{ type: "resource", resource: { uri: "file:///tmp/report.pdf", mimeType: "application/pdf", blob: "PDF1" } },
],
})),
})
const out = await Effect.runPromise(
tool.execute(
{
code: `
const images = await tools.media.images({})
const mixed = await tools.media.mixed({})
return { images, mixed }
`,
},
ctx,
),
)
expect(JSON.parse(out.output)).toEqual({
images: "[2 images attached to the result]",
mixed: "[2 files attached to the result]",
})
expect(out.output).not.toContain("PNG")
expect(out.attachments).toEqual([
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
{ type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF1", filename: "report.pdf" },
])
})
test("resource links flow to the program as text, never as attachments", async () => {
const tool = await build({
docs_find: mcpTool("find", () => ({
content: [
{ type: "resource_link", uri: "https://example.com/guide.pdf", name: "guide.pdf", mimeType: "application/pdf" },
{ type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
],
})),
})
const out = await Effect.runPromise(tool.execute({ code: "return await tools.docs.find({})" }, ctx))
expect(out.output).toBe("guide.pdf: https://example.com/guide.pdf\nnotes.md: file:///tmp/notes.md")
expect(out.attachments).toBeUndefined()
})
test("attachments still flow when the program returns something else entirely", async () => {
const tool = await build({
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
})
const out = await Effect.runPromise(
tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx),
)
expect(out.output).toBe("captured")
expect(out.attachments).toHaveLength(1)
})
test("isolates the sandbox from host globals", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx))
expect(output.metadata.error).toBe(true)
})
test("cancelling via ctx.abort interrupts the running program", async () => {
const controller = new AbortController()
const tool = await build({
host_trigger: mcpTool("trigger", () => {
controller.abort()
return new Promise(() => {})
}),
})
const output = await Effect.runPromise(
tool.execute(
{ code: "try { await tools.host.trigger({}) } catch {} while (true) {}" },
{ ...ctx, abort: controller.signal },
),
)
expect(output.output).toBe("Execution cancelled.")
expect(output.metadata.error).toBe(true)
expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }])
})
test("a pre-aborted signal cancels before the program runs", async () => {
const controller = new AbortController()
controller.abort()
const ran: string[] = []
const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) })
const output = await Effect.runPromise(
tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }),
)
expect(output.output).toBe("Execution cancelled.")
expect(ran).toEqual([])
})
test("leaves oversized results to OpenCode's native tool-output truncation", async () => {
const tool = await build({})
const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
expect(output.metadata.error).toBeUndefined()
expect(output.output).not.toContain("[result truncated:")
expect(output.output.length).toBeGreaterThanOrEqual(40_000)
})
test("appends logs after the result on success and after the message on error", async () => {
const tool = await build({})
const ok = await Effect.runPromise(
tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx),
)
expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
const err = await Effect.runPromise(
tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx),
)
expect(err.metadata.error).toBe(true)
expect(err.output).toContain("Uncaught: boom")
expect(err.output).toContain("Logs:\nbefore the throw")
})
})
describe("code mode permission visibility", () => {
const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" })
const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" })
const ok = () => ({ content: [{ type: "text", text: "ok" }] })
test("a hard-denied tool never enters the catalog or its search index", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
expect(description).toContain("tools.github.list_issues(")
expect(description).not.toContain("create_issue")
expect(description).toContain("- github (1 tool)")
})
test("an ask-level tool stays fully visible in the catalog", () => {
const mcpTools = {
github_create_issue: mcpTool("create_issue", ok),
github_list_issues: mcpTool("list_issues", ok),
}
const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
expect(description).toContain("tools.github.create_issue(")
expect(description).toContain("tools.github.list_issues(")
expect(description).toContain("- github (2 tools)")
})
test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => {
const called: string[] = []
const tool = await build(
{
github_create_issue: mcpTool("create_issue", () => {
called.push("create_issue")
return ok()
}),
github_list_issues: mcpTool("list_issues", ok),
},
["github"],
[deny("github_create_issue")],
)
const denied = await Effect.runPromise(
tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx),
)
expect(denied.metadata.error).toBe(true)
expect(denied.output).toContain("Unknown tool 'github.create_issue'")
expect(denied.output).not.toContain("permission")
expect(called).toEqual([])
const allowed = await Effect.runPromise(
tool.execute({ code: "return await tools.github.list_issues({})" }, ctx),
)
expect(allowed.metadata.error).toBeUndefined()
expect(allowed.output).toBe("ok")
})
test("an ask-level tool remains callable and still prompts via ctx.ask", async () => {
const asked: string[] = []
const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
const tool = await build(
{ github_list_issues: mcpTool("list_issues", ok) },
["github"],
[askRule("github_list_issues")],
)
const out = await Effect.runPromise(
tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx),
)
expect(out.output).toBe("ok")
expect(asked).toEqual(["github_list_issues"])
})
test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => {
const tools = { a_tool: 1, b_tool: 2, c_tool: 3 }
const visible = Permission.visibleTools(tools, [
deny("a_tool"),
askRule("b_tool"),
{ permission: "c_tool", pattern: "something", action: "deny" },
])
expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"])
})
})