Compare commits

...

13 Commits

Author SHA1 Message Date
Aiden Cline ce351802d9 refactor(core): simplify mcp tool names 2026-07-04 00:08:36 -05:00
Aiden Cline d6013dc281 feat(core): project tools through execute 2026-07-04 00:00:57 -05:00
Aiden Cline c7fe65df3e revert(core): remove v2 execute integration 2026-07-03 23:48:21 -05:00
Aiden Cline 0be055a336 docs(core): clarify internal codemode registration 2026-07-03 23:25:00 -05:00
Aiden Cline 1bf70d83fd fix(core): use default codemode limits 2026-07-03 23:24:07 -05:00
Aiden Cline 9c9fcc2968 refactor(core): keep codemode registration internal 2026-07-03 23:17:52 -05:00
Aiden Cline 9924437035 refactor(core): generalize execute tool aggregation 2026-07-03 19:31:49 -05:00
Aiden Cline 0da23bb4f0 refactor(core): simplify mcp tool wiring 2026-07-03 19:08:22 -05:00
Aiden Cline 319f14952f refactor(core): clean up mcp execute adapter 2026-07-03 18:24:56 -05:00
Aiden Cline 1218c4cfd9 refactor(core): simplify mcp permission keys 2026-07-03 18:14:52 -05:00
Aiden Cline 57ed778c51 refactor(core): register mcp tools through plugins 2026-07-03 18:10:19 -05:00
Aiden Cline 947d11a675 refactor(core): make code mode mcp default 2026-07-03 15:30:10 -05:00
Aiden Cline bd7af5f557 feat(core): wire execute tool for v2 mcp 2026-07-03 15:19:28 -05:00
11 changed files with 674 additions and 81 deletions
+1
View File
@@ -322,6 +322,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
+1
View File
@@ -90,6 +90,7 @@
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
+3 -2
View File
@@ -1,5 +1,3 @@
import { Config } from "effect"
export function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
@@ -43,6 +41,9 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
get OPENCODE_CODE_MODE() {
return process.env["OPENCODE_CODE_MODE"] === undefined ? true : truthy("OPENCODE_CODE_MODE")
},
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+4 -4
View File
@@ -8,7 +8,7 @@ This folder owns Core's one local tool representation, process and Location regi
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
Do not add a second executable entry type, registration store, authorization callback, output-path callback, or legacy normalization path.
## Construction
@@ -28,7 +28,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Core producers may attach an internal `execute` path as registration metadata; this changes model projection only and does not duplicate the canonical registration.
Registrations are scoped:
@@ -46,11 +46,11 @@ Definition filtering is catalog visibility, not execution authorization. A call
## Output
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the execution and generic model-output bounding boundary and owns managed retention paths. Calls nested under `execute` re-enter the same registry settlement path so hooks and leaf authorization remain intact.
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
## Current Gaps
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- Future Session-scoped registrations still need an explicit canonical registration design.
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
+134
View File
@@ -0,0 +1,134 @@
export * as ExecuteTool from "./execute"
import { CodeMode, Tool, toolError, type ToolDefinition } from "@opencode-ai/codemode"
import { type ToolOutput, type ToolResultValue } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { make, type Context } from "./tool"
export const Input = Schema.Struct({
code: Schema.String.annotate({ description: "Code to execute using the available tools" }),
})
const Call = Schema.Struct({
tool: Schema.String,
status: Schema.Literals(["running", "completed", "error"]),
input: Schema.Unknown.pipe(Schema.optional),
})
const Attachment = Schema.Struct({
data: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
})
export const Output = Schema.Struct({
output: Schema.String,
toolCalls: Schema.Array(Call),
error: Schema.Literal(true).pipe(Schema.optional),
attachments: Schema.Array(Attachment),
})
export interface Invocation {
readonly result: ToolResultValue
readonly output?: ToolOutput
}
export interface Item {
readonly description: string
readonly input: ToolDefinition["input"]
readonly output: ToolDefinition["output"]
readonly invoke: (input: unknown, callID: string, context: Context) => Effect.Effect<Invocation, unknown>
}
export type Items = Readonly<Record<string, Readonly<Record<string, Item>>>>
type ExecuteCall = typeof Call.Type
export const makeTool = (items: Items) => {
const createRuntime = (calls: ExecuteCall[], attachments: Array<typeof Attachment.Type>, context?: Context) => {
const tools: Record<string, Record<string, ToolDefinition>> = Object.create(null)
let child = 0
for (const [namespace, members] of Object.entries(items)) {
tools[namespace] = Object.create(null)
for (const [member, item] of Object.entries(members)) {
tools[namespace][member] = Tool.make({
description: item.description,
input: item.input,
output: item.output,
run: (input) => {
if (!context) return Effect.die(new Error("Execute tool context is unavailable"))
const callID = `${context.toolCallID}/${child++}`
return item.invoke(input, callID, context).pipe(
Effect.mapError((error) => toolError("Tool execution failed", error)),
Effect.flatMap((invocation) => {
if (invocation.result.type === "error") return Effect.fail(toolError(invocation.result.value))
if (!invocation.output) return Effect.succeed(invocation.result.value)
attachments.push(
...invocation.output.content.flatMap((part) => {
if (part.type !== "file") return []
const prefix = `data:${part.mime};base64,`
if (!part.uri.startsWith(prefix)) return []
return [{ data: part.uri.slice(prefix.length), mime: part.mime, name: part.name }]
}),
)
return Effect.succeed(invocation.output.structured)
}),
)
},
})
}
}
return CodeMode.make({
tools,
onToolCallStart: (call) =>
Effect.sync(() => {
calls[call.index] = { tool: call.name, status: "running", input: call.input }
}),
onToolCallEnd: (call) =>
Effect.sync(() => {
calls[call.index] = {
tool: call.name,
status: call.outcome === "failure" ? "error" : "completed",
input: call.input,
}
}),
})
}
return make({
description: createRuntime([], []).instructions(),
input: Input,
output: Output,
structured: Schema.Struct({
output: Output.fields.output,
toolCalls: Output.fields.toolCalls,
error: Output.fields.error,
}),
toStructuredOutput: ({ output }) => ({
output: output.output,
toolCalls: output.toolCalls,
...(output.error ? { error: true as const } : {}),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
...output.attachments.map((attachment) => ({ type: "file" as const, ...attachment })),
],
execute: (input, context) =>
Effect.gen(function* () {
const calls: ExecuteCall[] = []
const attachments: Array<typeof Attachment.Type> = []
const result = yield* createRuntime(calls, attachments, context).execute(input.code)
const logs = result.logs?.length ? `\n\nLogs:\n${result.logs.join("\n")}` : ""
return {
output: !result.ok
? `${result.error.message}${logs}`
: typeof result.value === "string"
? result.value + logs
: `${JSON.stringify(result.value, null, 2) ?? "null"}${logs}`,
toolCalls: calls,
...(result.ok ? {} : { error: true as const }),
attachments,
}
}),
})
}
+65 -46
View File
@@ -1,36 +1,23 @@
export * as McpTool from "./mcp"
import { createHash } from "node:crypto"
import { ToolFailure } from "@opencode-ai/llm"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Effect, Exit, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { EventV2 } from "../event"
import { Flag } from "../flag/flag"
import { MCP } from "../mcp"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { Tools, type ExecutePath } from "./tools"
import { ToolRegistry } from "./registry"
const MAX_NAME_LENGTH = 64
const HASH_LENGTH = 8
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
/**
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
* and hashed down when it would exceed the 64-char limit.
* V1-compatible registry and permission name: `<server>_<tool>` with unsupported characters replaced.
*/
export const name = (server: string, tool: string) => {
const joined = sanitize(server) + "_" + sanitize(tool)
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
}
export const name = (server: string, tool: string) => sanitize(server) + "_" + sanitize(tool)
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
@@ -46,56 +33,88 @@ export const layer = Layer.effectDiscard(
const mcp = yield* MCP.Service
const tools = yield* Tools.Service
const events = yield* EventV2.Service
const permission = yield* PermissionV2.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
Tool.make({
description: tool.description ?? "",
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.callTool({ 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: errorText(result.content) || "MCP tool returned an error" })
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
}),
})
const make = (server: MCP.ServerName, tool: MCP.Tool, action: string) =>
Tool.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema:
typeof tool.inputSchema === "object" && tool.inputSchema !== null && !Array.isArray(tool.inputSchema)
? { ...tool.inputSchema }
: { type: "object", properties: {} },
execute: (input, context) =>
Effect.gen(function* () {
const args = typeof input === "object" && input !== null && !Array.isArray(input) ? { ...input } : {}
yield* permission
.assert({
sessionID: context.sessionID,
agent: context.agent,
action,
resources: ["*"],
save: ["*"],
metadata: { server, tool: tool.name, arguments: args },
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
.pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: error instanceof PermissionV2.CorrectedError ? error.feedback : "Permission denied",
}),
),
)
const result = yield* mcp.callTool({ server, name: tool.name, args }).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: errorText(result.content) || "MCP tool returned an error" })
return {
structured: result.structured !== undefined ? result.structured : errorText(result.content),
content: result.content.map(toContent),
}
}),
}),
action,
)
// 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 used = new Set<string>()
const record: Record<string, Tool.AnyTool> = {}
const execute: Record<string, ExecutePath> = {}
for (const tool of yield* mcp.tools()) {
const initial = name(tool.server, tool.name)
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
used.add(key)
record[key] = make(tool.server, tool)
const key = name(tool.server, tool.name)
record[key] = make(tool.server, tool, key)
execute[key] = [sanitize(tool.server), sanitize(tool.name)]
}
const next = yield* Scope.fork(scope)
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
yield* tools
.register(record, Flag.OPENCODE_CODE_MODE ? { execute } : undefined)
.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
yield* reconcile.pipe(Effect.forkScoped)
yield* events
.subscribe(McpEvent.ToolsChanged)
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
export const node = makeLocationNode({
name: "mcp-tools",
layer,
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node],
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
})
+101 -29
View File
@@ -9,9 +9,10 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
import { Tools, type ExecutePath, type RegisterOptions } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
import { ExecuteTool } from "./execute"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
@@ -23,7 +24,10 @@ export type ExecuteInput = {
export interface Interface {
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface MaterializeInput {
@@ -49,21 +53,14 @@ const registryLayer = Layer.effect(
Effect.gen(function* () {
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = { readonly identity: object; readonly tool: AnyTool }
type Registration = { readonly identity: object; readonly tool: AnyTool; readonly execute?: ExecutePath }
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration)
return {
result: {
type: "error" as const,
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
},
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
const settleRegistration = Effect.fn("ToolRegistry.settleRegistration")(function* (
input: ExecuteInput,
registration: Registration,
) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach the registry.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
@@ -73,12 +70,16 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
}).pipe(
const pending = yield* settle(
registration.tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
},
).pipe(
Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
@@ -88,7 +89,11 @@ const registryLayer = Layer.effect(
if ("result" in pending) {
settlement = pending
} else {
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
@@ -118,15 +123,32 @@ const registryLayer = Layer.effect(
}
})
const settleLocal = Effect.fn("ToolRegistry.settleLocal")(function* (input: ExecuteInput, advertised?: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration)
return {
result: {
type: "error" as const,
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
},
}
if (advertised && registration.identity !== advertised)
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
return yield* settleRegistration(input, registration)
})
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools) {
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
const entries = registrationEntries(tools)
if (entries.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
local.set(name, [
...(local.get(name) ?? []),
{ token, registration: { identity: {}, tool, execute: options?.execute?.[name] } },
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
@@ -140,23 +162,73 @@ const registryLayer = Layer.effect(
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
const eligible = (name: string, registration: Registration) => {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
return !wrongEditTool && !whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
}
const hasProjectedTools = () =>
Array.from(local).some(([name, entries]) => {
const registration = entries.at(-1)?.registration
return registration?.execute !== undefined && eligible(name, registration)
})
const registrations = new Map<string, Registration>()
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
registrations.delete(name)
if (!eligible(name, registration)) registrations.delete(name)
}
const children = Array.from(registrations).flatMap(([name, registration]) =>
registration.execute ? [{ name, path: registration.execute, registration }] : [],
)
const executeItems: Record<string, Record<string, ExecuteTool.Item>> = Object.create(null)
for (const child of children) {
registrations.delete(child.name)
const [namespace, member] = child.path
executeItems[namespace] ??= Object.create(null)
const info = definition(child.name, child.registration.tool)
executeItems[namespace][member] = {
description: info.description,
input: info.inputSchema,
output: info.outputSchema,
invoke: (value, callID, context) =>
settleLocal(
{
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
call: { type: "tool-call", id: callID, name: child.name, input: value },
},
child.registration.identity,
),
}
}
const executeRegistration =
children.length > 0 && !whollyDisabled("execute", input.permissions ?? [])
? { identity: {}, tool: ExecuteTool.makeTool(executeItems) }
: undefined
if (executeRegistration) registrations.set("execute", executeRegistration)
return {
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
if (executeRegistration && registration === executeRegistration) {
if (
children.some(
(child) => local.get(child.name)?.at(-1)?.registration.identity !== child.registration.identity,
)
)
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
return settleRegistration(input, registration)
}
if (registration && input.call.name === "execute" && hasProjectedTools())
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
if (registration) return settleLocal(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
},
}
+8
View File
@@ -3,9 +3,17 @@ export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export type ExecutePath = readonly [namespace: string, name: string]
export interface RegisterOptions {
/** Internal projection metadata. The tool remains one canonical registration. */
readonly execute?: Readonly<Record<string, ExecutePath>>
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
options?: RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
+60
View File
@@ -7,6 +7,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Tools } from "@opencode-ai/core/tool/tools"
import { testEffect } from "./lib/effect"
import { testModel } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
@@ -168,4 +169,63 @@ describe("PluginV2", () => {
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
}),
)
it.effect("fires plugin hooks around tools nested under execute", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const tools = yield* Tools.Service
const events: string[] = []
const executed: unknown[] = []
yield* plugins.add(
PluginV2.ID.make("execute-hooks"),
define({
id: "execute-hooks",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.execute.before((event) => {
events.push(`before:${event.tool}:${event.toolCallID}`)
if (event.tool === "echo") event.input = { text: "mutated" }
})
yield* ctx.tool.execute.after((event) => {
events.push(`after:${event.tool}:${event.toolCallID}`)
})
}),
}).effect,
)
yield* tools.register(
{
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
}),
},
{ execute: { echo: ["test", "echo"] } },
)
const materialized = yield* registry.materialize({ model: testModel })
yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_execute_hooks"),
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_execute_hooks"),
call: {
type: "tool-call",
id: "call-execute-hooks",
name: "execute",
input: { code: 'return await tools.test.echo({ text: "original" })' },
},
})
expect(executed).toEqual([{ text: "mutated" }])
expect(events).toEqual([
"before:execute:call-execute-hooks",
"before:echo:call-execute-hooks/0",
"after:echo:call-execute-hooks/0",
"after:execute:call-execute-hooks",
])
}),
)
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { SessionV2 } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool/tool"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect, Layer, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
})
const it = testEffect(AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]))
const sessionID = SessionV2.ID.make("ses_execute")
describe("execute tool", () => {
it.effect("projects any canonical tool without registering it twice", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register(
{
lookup: Tool.make({
description: "Look up a package",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ id: Schema.String }),
execute: ({ name }) => Effect.succeed({ id: `pkg:${name}` }),
}),
},
{ execute: { lookup: ["packages", "lookup"] } },
)
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((item) => item.name)).toEqual(["execute"])
expect(definitions[0]?.description).toContain("tools.packages.lookup")
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_execute",
name: "execute",
input: { code: 'return await tools.packages.lookup({ name: "effect" })' },
},
})
expect(settlement.output?.structured).toEqual({
output: '{\n "id": "pkg:effect"\n}',
toolCalls: [{ tool: "packages.lookup", status: "completed", input: { name: "effect" } }],
})
}),
)
it.effect("omits execute when every projected child is disabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register(
{
lookup: Tool.make({
description: "Look up a package",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ id: Schema.String }),
execute: ({ name }) => Effect.succeed({ id: name }),
}),
},
{ execute: { lookup: ["packages", "lookup"] } },
)
expect(yield* toolDefinitions(registry, [{ action: "lookup", resource: "*", effect: "deny" }])).toEqual([])
}),
)
it.effect("rejects a previously materialized direct execute after projection takes over", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register({
execute: Tool.make({
description: "Direct execute",
input: Schema.Struct({}),
output: Schema.Struct({ direct: Schema.Boolean }),
execute: () => Effect.succeed({ direct: true }),
}),
})
const materialized = yield* registry.materialize({ model: { id: "claude-test", provider: "anthropic" } })
yield* registry.register(
{
lookup: Tool.make({
description: "Look up a package",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
},
{ execute: { lookup: ["packages", "lookup"] } },
)
expect(
yield* materialized.settle({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_stale_execute", name: "execute", input: {} },
}),
).toEqual({ result: { type: "error", value: "Stale tool call: execute" } })
}),
)
})
+190
View File
@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect } from "bun:test"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { MCP } from "@opencode-ai/core/mcp/index"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect, Layer } from "effect"
import { testEffect } from "./lib/effect"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
const previous = process.env.OPENCODE_CODE_MODE
const calls: Array<{ server: string; name: string; args: Record<string, unknown> | undefined }> = []
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const mcp = Layer.succeed(
MCP.Service,
MCP.Service.of({
servers: () => Effect.succeed([]),
tools: () =>
Effect.succeed([
new MCP.Tool({
server: MCP.ServerName.make("context7"),
name: "resolve-library-id",
description: "Resolve a library ID",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
}),
]),
callTool: (input) =>
Effect.sync(() => {
calls.push({ server: input.server.toString(), name: input.name, args: input.args })
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server.toString()),
tool: input.name,
isError: false,
structured: { id: "/reactjs/react.dev" },
content: [{ type: "text", text: "/reactjs/react.dev" }],
})
}),
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.succeed(undefined),
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
readResource: () => Effect.succeed(undefined),
}),
)
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused permission.ask"),
reply: () => Effect.die("unused permission.reply"),
get: () => Effect.die("unused permission.get"),
forSession: () => Effect.die("unused permission.forSession"),
list: () => Effect.die("unused permission.list"),
}),
)
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]),
[
[MCP.node, mcp],
[PermissionV2.node, permission],
[ToolOutputStore.node, outputStore],
],
),
)
const sessionID = SessionV2.ID.make("ses_mcp_tool")
beforeEach(() => {
delete process.env.OPENCODE_CODE_MODE
calls.length = 0
assertions.length = 0
deny = false
})
afterEach(() => {
if (previous === undefined) delete process.env.OPENCODE_CODE_MODE
else process.env.OPENCODE_CODE_MODE = previous
})
describe("MCP tools", () => {
it.effect("projects MCP into execute while preserving canonical permission identity", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((item) => item.name)).toEqual(["execute"])
expect(definitions[0]?.description).toContain('tools.context7["resolve-library-id"]')
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp",
name: "execute",
input: { code: 'return await tools.context7["resolve-library-id"]({ query: "react" })' },
},
})
expect(settlement.output?.structured).toMatchObject({
output: '{\n "id": "/reactjs/react.dev"\n}',
toolCalls: [{ tool: "context7.resolve-library-id", status: "completed" }],
})
expect(calls).toEqual([{ server: "context7", name: "resolve-library-id", args: { query: "react" } }])
expect(assertions[0]).toMatchObject({
action: "context7_resolve-library-id",
source: { type: "tool", messageID: toolIdentity.assistantMessageID, callID: "call_mcp/0" },
})
expect(
yield* toolDefinitions(registry, [{ action: "context7_resolve-library-id", resource: "*", effect: "deny" }]),
).toEqual([])
}),
)
it.effect("does not let execute bypass a denied MCP child", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
deny = true
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_denied",
name: "execute",
input: { code: 'return await tools.context7["resolve-library-id"]({ query: "react" })' },
},
})
expect(calls).toEqual([])
expect(settlement.output?.structured).toMatchObject({
error: true,
toolCalls: [{ tool: "context7.resolve-library-id", status: "error" }],
})
}),
)
describe("with CodeMode disabled", () => {
beforeEach(() => {
process.env.OPENCODE_CODE_MODE = "false"
})
it.effect("keeps the same canonical MCP tool and permission action", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "context7_resolve-library-id")
expect((yield* toolDefinitions(registry)).map((item) => item.name)).toEqual(["context7_resolve-library-id"])
yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_direct",
name: "context7_resolve-library-id",
input: { query: "react" },
},
})
expect(assertions[0]).toMatchObject({
action: "context7_resolve-library-id",
source: { callID: "call_direct" },
})
}),
)
})
})