revert(core): remove v2 execute integration

This commit is contained in:
Aiden Cline
2026-07-03 23:48:21 -05:00
parent 0be055a336
commit c7fe65df3e
15 changed files with 151 additions and 869 deletions
-1
View File
@@ -90,7 +90,6 @@
"@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
View File
@@ -43,9 +43,6 @@ 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.
+2
View File
@@ -40,6 +40,7 @@ import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SessionContextEntry } from "./session/context-entry"
import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { ToolOutputStore } from "./tool-output-store"
@@ -85,6 +86,7 @@ const locationServiceNodes = [
QuestionV2.node,
Generate.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionCompaction.node,
+4 -5
View File
@@ -1,12 +1,12 @@
export * as McpGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { makeLocationNode } from "../effect/app-node"
import { Flag } from "../flag/flag"
import { PermissionV2 } from "../permission"
import { SystemContext } from "../system-context/index"
import { McpTool } from "../tool/mcp"
import { MCP } from "./index"
import { SystemContext } from "../system-context/index"
const Summary = Schema.Struct({
server: Schema.String,
@@ -62,7 +62,6 @@ export const layer = Layer.effect(
return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) {
if (Flag.OPENCODE_CODE_MODE) return SystemContext.empty
const agent = selection.info
if (!agent) return SystemContext.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
@@ -76,7 +75,7 @@ export const layer = Layer.effect(
owned.length === 0 ||
owned.some(
(tool) =>
PermissionV2.evaluate(`mcp:${tool.server}:${tool.name}`, "*", agent.permissions).effect !== "deny",
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
)
)
})
-6
View File
@@ -24,7 +24,6 @@ import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { MCP } from "../mcp"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
@@ -51,7 +50,6 @@ import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
import { McpTool } from "../tool/mcp"
import { QuestionTool } from "../tool/question"
import { ReadTool } from "../tool/read"
import { ReadToolFileSystem } from "../tool/read-filesystem"
@@ -79,7 +77,6 @@ export type Requirements =
| Location.Service
| LocationMutation.Service
| ModelsDev.Service
| MCP.Service
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
@@ -115,7 +112,6 @@ const layer = Layer.effectDiscard(
Context.make(Config.Service, yield* Config.Service),
Context.make(Location.Service, yield* Location.Service),
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
Context.make(MCP.Service, yield* MCP.Service),
Context.make(Npm.Service, yield* Npm.Service),
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
@@ -170,7 +166,6 @@ const layer = Layer.effectDiscard(
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
yield* add(McpTool.Plugin)
// Embedder-contributed plugins are added last so they layer over config.
for (const plugin of sdkPlugins.all()) yield* add(plugin)
}),
@@ -193,7 +188,6 @@ export const node = makeLocationNode({
FileMutation.node,
Image.node,
ModelsDev.node,
MCP.node,
Npm.node,
EventV2.node,
FSUtil.node,
+2 -6
View File
@@ -28,11 +28,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins and plugin tools register top-level tools through `Tools.Service.register({ [name]: tool })`. Core producers
may temporarily register tools only inside the aggregate `execute` tool through the private
`Tools.Service.codeMode.register({ [namespace]: tools })` bridge. Do not expose that bridge through `PluginContext`;
it exists until the plugin tool catalog has a general projection mechanism. Both placements store the same canonical
`Tool.AnyTool`; `execute` is derived during materialization rather than owned by any contributor.
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
Registrations are scoped:
@@ -56,5 +52,5 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
## Current Gaps
- Future Session-scoped registrations still need an explicit canonical registration design.
- MCP and 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.
-120
View File
@@ -1,120 +0,0 @@
export * as ExecuteTool from "./execute"
import { CodeMode, Tool, toolError, type ToolDefinition } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
import { definition, make, settle, type Context } from "./tool"
import type { CodeModeTools } from "./tools"
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),
})
type ExecuteCall = typeof Call.Type
const create = (items: CodeModeTools) => {
const createRuntime = (calls: ExecuteCall[], attachments: Array<typeof Attachment.Type>, context?: Context) => {
const tools: Record<string, Record<string, ToolDefinition>> = Object.create(null)
for (const [namespace, members] of Object.entries(items)) {
tools[namespace] = Object.create(null)
for (const [member, item] of Object.entries(members)) {
const info = definition(`${namespace}_${member}`, item)
tools[namespace][member] = Tool.make({
description: info.description,
input: info.inputSchema,
output: info.outputSchema,
run: (input) => {
if (!context) return Effect.die(new Error("Execute tool context is unavailable"))
return settle(item, { type: "tool-call", id: context.toolCallID, name: info.name, input }, context).pipe(
Effect.mapError((error) => toolError(error.message)),
Effect.map((output) => {
attachments.push(
...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 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,
}
}),
})
}
export { create as make }
+77 -104
View File
@@ -1,128 +1,101 @@
export * as McpTool from "./mcp"
import { createHash } from "node:crypto"
import { ToolFailure } from "@opencode-ai/llm"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, Scope, Semaphore, Stream } from "effect"
import { Effect, Exit, type JsonSchema, 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 { ToolRegistry } from "./registry"
export const Plugin = {
id: "core-mcp-tools",
effect: Effect.fn("McpTool.Plugin")(function* (ctx: PluginContext) {
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.
*/
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
}
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) =>
content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim()
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const events = yield* EventV2.Service
const permission = yield* PermissionV2.Service
const tools = yield* Tools.Service
const events = yield* EventV2.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) }
}),
})
// 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 items = (yield* mcp.tools()).map(
(item) =>
[
item,
Tool.withPermission(
Tool.make({
description: item.description ?? "",
jsonSchema:
typeof item.inputSchema === "object" &&
item.inputSchema !== null &&
!Array.isArray(item.inputSchema)
? { ...item.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: `mcp:${item.server}:${item.name}`,
resources: ["*"],
save: ["*"],
metadata: { server: item.server, tool: item.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: item.server, name: item.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 }),
}),
)
const text = result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim()
if (result.isError)
return yield* new ToolFailure({ message: text || "MCP tool returned an error" })
const media = result.content.filter((part) => part.type === "media").length
return {
structured:
result.structured !== undefined
? result.structured
: media === 0
? text
: [text, `[${media} media attachment${media === 1 ? "" : "s"}]`]
.filter(Boolean)
.join("\n"),
content: result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
),
}
}),
}),
`mcp:${item.server}:${item.name}`,
),
] as const,
)
const used = new Set<string>()
const record: Record<string, Tool.AnyTool> = {}
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 next = yield* Scope.fork(scope)
yield* (
Flag.OPENCODE_CODE_MODE
? tools.codeMode.register(
Object.fromEntries(
Array.from(new Set(items.map(([item]) => item.server))).map((server) => [
server,
Object.fromEntries(
items.filter(([item]) => item.server === server).map(([item, tool]) => [item.name, tool]),
),
]),
),
)
: ctx.tool.register(Object.fromEntries(items.map(([item, tool]) => [`${item.server}_${item.name}`, tool])))
).pipe(Scope.provide(next), Effect.orDie)
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile
yield* reconcile.pipe(Effect.forkScoped)
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],
})
+48 -121
View File
@@ -9,10 +9,9 @@ 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, type CodeModeTools } from "./tools"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
import { ExecuteTool } from "./execute"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
@@ -25,13 +24,6 @@ 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>
/**
* Internal only. This is probably the wrong API: it mixes tool registration with CodeMode projection.
* Keep it out of PluginContext until the tool catalog has a proper projection mechanism.
*/
readonly codeMode: {
readonly register: (tools: CodeModeTools) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
}
export interface MaterializeInput {
@@ -58,11 +50,19 @@ const registryLayer = Layer.effect(
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
type Registration = { readonly identity: object; readonly tool: AnyTool }
type Stack = Array<{ readonly token: object; readonly registration: Registration }>
const local = new Map<string, Stack>()
const codeMode = new Map<string, Stack>()
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, 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 beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
@@ -73,16 +73,12 @@ 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 } }),
@@ -92,11 +88,7 @@ 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"
@@ -126,54 +118,28 @@ const registryLayer = Layer.effect(
}
})
const register = Effect.fn("ToolRegistry.register")(function* (
target: Map<string, Stack>,
entries: ReadonlyArray<readonly [string, AnyTool]>,
) {
if (entries.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const [name, tool] of entries)
target.set(name, [...(target.get(name) ?? []), { token, registration: { identity: {}, tool } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = target.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) target.set(name, registrations)
else target.delete(name)
}
}),
)
}),
)
})
const codeModeRegistrations = (rules: PermissionV2.Ruleset) =>
Array.from(codeMode).flatMap(([path, entries]) => {
const registration = entries.at(-1)?.registration
const separator = path.indexOf("\u0000")
const namespace = path.slice(0, separator)
const name = path.slice(separator + 1)
if (!registration || whollyDisabled(permission(registration.tool, `${namespace}.${name}`), rules)) return []
return [{ namespace, name, registration }]
})
return Service.of({
register: (tools) => register(local, registrationEntries(tools)),
codeMode: {
register: (tools) =>
register(
codeMode,
Object.entries(tools).flatMap(([namespace, members]) =>
Object.entries(members).map(
([name, tool]) => [`${executeSegment(namespace)}\u0000${executeSegment(name)}`, tool] as const,
),
),
),
},
register: Effect.fn("ToolRegistry.register")(function* (tools) {
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 } }])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name] of entries) {
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(name, registrations)
else local.delete(name)
}
}),
)
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
const rules = [...(input.permissions ?? [])]
const registrations = new Map<string, Registration>()
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
@@ -183,46 +149,16 @@ const registryLayer = Layer.effect(
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), rules)) registrations.delete(name)
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
registrations.delete(name)
}
const children = codeModeRegistrations(rules)
const executeTools = new Map<string, Record<string, AnyTool>>()
for (const child of children) {
const members = executeTools.get(child.namespace) ?? {}
members[child.name] = child.registration.tool
executeTools.set(child.namespace, members)
}
const executeRegistration =
children.length > 0 && !whollyDisabled("execute", rules)
? { identity: {}, tool: ExecuteTool.make(Object.fromEntries(executeTools)) }
: undefined
if (executeRegistration) registrations.set("execute", executeRegistration)
return {
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) =>
Effect.suspend(() => {
const registration = registrations.get(input.call.name)
if (!registration)
return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } })
if (registration === executeRegistration) {
const current = codeModeRegistrations(rules)
if (
current.length !== children.length ||
current.some((item, index) => item.registration.identity !== children[index]?.registration.identity)
)
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
} else {
if (input.call.name === "execute" && codeModeRegistrations(rules).length > 0)
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
if (local.get(input.call.name)?.at(-1)?.registration.identity !== registration.identity)
return Effect.succeed({
result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` },
})
}
return settleWith(input, registration)
}),
settle: (input) => {
const registration = registrations.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
},
}
}),
})
@@ -231,11 +167,7 @@ const registryLayer = Layer.effect(
const layer = Layer.effect(
Tools.Service,
Service.use((registry) =>
Effect.succeed(
Tools.Service.of({ register: registry.register, codeMode: { register: registry.codeMode.register } }),
),
),
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
@@ -243,11 +175,6 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
return rule?.resource === "*" && rule.effect === "deny"
}
function executeSegment(value: string) {
const name = value.replace(/[^a-zA-Z0-9_-]/g, "_")
return name === "__proto__" || name === "constructor" || name === "prototype" ? `_${name}` : name
}
export const node = makeLocationNode({
service: Service,
layer,
-9
View File
@@ -3,19 +3,10 @@ export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export type CodeModeTools = Readonly<Record<string, Readonly<Record<string, Tool.AnyTool>>>>
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/**
* Internal bridge until the plugin tool catalog can project tools registered by other plugins.
* Do not expose this through PluginContext.
*/
readonly codeMode: {
readonly register: (tools: CodeModeTools) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
}
/** Narrow registration-only Location capability. */
-55
View File
@@ -1,55 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { MCP } from "@opencode-ai/core/mcp/index"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { SystemContext } from "@opencode-ai/core/system-context/index"
import { Effect, Layer } from "effect"
import { testEffect } from "./lib/effect"
const previous = process.env.OPENCODE_CODE_MODE
const id = AgentV2.ID.make("build")
const selection = { id, info: AgentV2.Info.empty(id) }
const it = testEffect(
AppNodeBuilder.build(McpGuidance.node, [
[
MCP.node,
Layer.mock(MCP.Service, {
instructions: () =>
Effect.succeed([
new MCP.ServerInstructions({
server: MCP.ServerName.make("context7"),
instructions: "Call resolve-library-id first.",
}),
]),
tools: () =>
Effect.succeed([new MCP.Tool({ server: MCP.ServerName.make("context7"), name: "resolve-library-id" })]),
}),
],
]),
)
afterEach(() => {
if (previous === undefined) delete process.env.OPENCODE_CODE_MODE
else process.env.OPENCODE_CODE_MODE = previous
})
describe("McpGuidance", () => {
it.effect("omits direct tool instructions in CodeMode", () =>
Effect.gen(function* () {
delete process.env.OPENCODE_CODE_MODE
const guidance = yield* McpGuidance.Service
expect((yield* SystemContext.initialize(yield* guidance.load(selection))).text).toBe("")
}),
)
it.effect("includes server instructions with direct MCP tools", () =>
Effect.gen(function* () {
process.env.OPENCODE_CODE_MODE = "false"
const guidance = yield* McpGuidance.Service
expect((yield* SystemContext.initialize(yield* guidance.load(selection))).text).toContain(
"Call resolve-library-id first.",
)
}),
)
})
+16 -15
View File
@@ -81,26 +81,27 @@ describe("PluginV2", () => {
const plugin = define({
id: "tool-plugin",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool
.register({
plugin_tool: Tool.make({
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
.pipe(Effect.orDie)
}),
ctx.tool
.register({
plugin_tool: Tool.make({
description: "Plugin tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
.pipe(Effect.orDie),
})
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
const active = (yield* registry.materialize({ model: testModel })).definitions
expect(active.map((tool) => tool.name)).toEqual(["plugin_tool"])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
"plugin_tool",
)
yield* plugins.remove(PluginV2.ID.make(plugin.id))
expect((yield* registry.materialize({ model: testModel })).definitions).toEqual([])
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
"plugin_tool",
)
}),
)
@@ -163,39 +163,6 @@ describe("ToolRegistry", () => {
}),
)
it.effect("aggregates scoped CodeMode tools and invalidates changed catalogs", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const alpha = yield* Scope.make()
const beta = yield* Scope.make()
yield* service.codeMode.register({ alpha: { one: make("hidden") } }).pipe(Scope.provide(alpha))
yield* service.codeMode.register({ ["__proto__"]: { constructor: make() } }).pipe(Scope.provide(alpha))
yield* service.codeMode.register({ beta: { two: make() } }).pipe(Scope.provide(beta))
const materialized = yield* service.materialize({ model: testModel })
expect(materialized.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(materialized.definitions[0]?.description).toContain("tools.alpha.one")
expect(materialized.definitions[0]?.description).toContain("tools.beta.two")
expect(materialized.definitions[0]?.description).toContain("tools.___proto__._constructor")
const denied = yield* toolDefinitions(service, [{ action: "hidden", resource: "*", effect: "deny" }])
expect(denied[0]?.description).not.toContain("tools.alpha.one")
expect(denied[0]?.description).toContain("tools.beta.two")
yield* Scope.close(alpha, Exit.void)
expect((yield* toolDefinitions(service))[0]?.description).not.toContain("tools.alpha.one")
expect(
(yield* materialized.settle({
sessionID,
...identity,
call: { type: "tool-call", id: "call-execute", name: "execute", input: { code: "return null" } },
})).result,
).toEqual({ type: "error", value: "Stale tool call: execute" })
yield* Scope.close(beta, Exit.void)
expect(yield* toolDefinitions(service)).toEqual([])
}),
)
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@@ -262,8 +229,8 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
expect("definitions" in service).toBe(false)
expect("execute" in service).toBe(false)
expect("settle" in service).toBe(false)
expect(typeof service.codeMode.register).toBe("function")
expect(typeof service.materialize).toBe("function")
}),
)
@@ -391,10 +358,9 @@ describe("ToolRegistry", () => {
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize({ model: testModel })
const settlement = materialized.settle(call("echo"))
yield* Scope.close(scope, Exit.void)
expect((yield* settlement).result).toEqual({
expect((yield* materialized.settle(call("echo"))).result).toEqual({
type: "error",
value: "Stale tool call: echo",
})
-124
View File
@@ -1,124 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { SessionV2 } from "@opencode-ai/core/session"
import { ExecuteTool } from "@opencode-ai/core/tool/execute"
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 { 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("runs registered tools and returns child metadata and attachments", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register({
execute: ExecuteTool.make({
context7: {
"resolve-library-id": Tool.make({
description: "Resolve a library ID",
input: Schema.Struct({ query: Schema.String, libraryName: Schema.String }),
output: Schema.Struct({ id: Schema.String }),
execute: () => Effect.succeed({ id: "/reactjs/react.dev" }),
toModelOutput: ({ output }) => [
{ type: "text", text: output.id },
{ type: "file", data: "aW1hZ2U=", mime: "image/png" },
],
}),
},
}),
})
const definition = (yield* toolDefinitions(registry))[0]
expect(definition.name).toBe("execute")
expect(definition.description).toContain('tools.context7["resolve-library-id"]')
expect(definition.description).toContain("Do not infer or normalize tool names")
expect(definition.outputSchema).toBeDefined()
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_execute",
name: "execute",
input: {
code: 'return await tools.context7["resolve-library-id"]({ query: "react", libraryName: "react" })',
},
},
})
expect(settlement.output?.structured).toEqual({
output: '{\n "id": "/reactjs/react.dev"\n}',
toolCalls: [
{
tool: "context7.resolve-library-id",
status: "completed",
input: { query: "react", libraryName: "react" },
},
],
})
expect(settlement.output?.content).toEqual([
{ type: "text", text: '{\n "id": "/reactjs/react.dev"\n}' },
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png" },
])
}),
)
it.effect("marks failed programs in structured metadata", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register({ execute: ExecuteTool.make({}) })
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_execute_error", name: "execute", input: { code: "return missing.value" } },
})
expect(settlement.result.type).toBe("text")
expect(settlement.output?.structured).toMatchObject({ error: true, toolCalls: [] })
}),
)
it.effect("reports child tool failures", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* registry.register({
execute: ExecuteTool.make({
github: {
search_issues: Tool.make({
description: "Search issues",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({}),
execute: () => Effect.fail(new Tool.Failure({ message: "Permission denied" })),
}),
},
}),
})
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_execute_denied",
name: "execute",
input: { code: 'return await tools.github.search_issues({ query: "bug" })' },
},
})
expect(settlement.result.type).toBe("text")
expect(settlement.output?.structured).toMatchObject({
error: true,
toolCalls: [{ tool: "github.search_issues", status: "error", input: { query: "bug" } }],
})
}),
)
})
-264
View File
@@ -1,264 +0,0 @@
import { afterEach, beforeEach, describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
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 { 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 { testEffect } from "./lib/effect"
import { registerToolPlugin, 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 catalog: MCP.Tool[] = []
let deny = false
const mcp = Layer.succeed(
MCP.Service,
MCP.Service.of({
servers: () => Effect.succeed([]),
tools: () => Effect.sync(() => catalog),
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: { ok: true },
content: [{ type: "text", text: "ok" }],
})
}),
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 pluginNode = makeLocationNode({
name: "test/mcp-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(McpTool.Plugin)),
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, ToolRegistry.node, ToolRegistry.toolsNode, pluginNode]),
[
[MCP.node, mcp],
[PermissionV2.node, permission],
[ToolOutputStore.node, outputStore],
],
),
)
const sessionID = SessionV2.ID.make("ses_mcp_tool")
function item(server: string, name: string) {
return new MCP.Tool({
server: MCP.ServerName.make(server),
name,
description: `${server} ${name}`,
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
})
}
function waitForDefinition(
registry: ToolRegistry.Interface,
predicate: (definitions: ToolRegistry.Materialization["definitions"]) => boolean,
remaining = 1000,
): Effect.Effect<void, Error> {
return Effect.gen(function* () {
if (predicate(yield* toolDefinitions(registry))) return
if (remaining === 0) {
yield* Effect.fail(new Error("Timed out waiting for MCP tool definitions"))
return
}
yield* Effect.promise(() => Bun.sleep(1))
yield* waitForDefinition(registry, predicate, remaining - 1)
})
}
beforeEach(() => {
delete process.env.OPENCODE_CODE_MODE
calls.length = 0
assertions.length = 0
deny = false
catalog = [item("context7", "resolve-library-id")]
})
afterEach(() => {
if (previous === undefined) delete process.env.OPENCODE_CODE_MODE
else process.env.OPENCODE_CODE_MODE = previous
})
describe("MCP tool plugin", () => {
it.effect("registers execute by default and replaces its catalog on MCP changes", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const events = yield* EventV2.Service
yield* registry.codeMode.register({
user: {
custom: Tool.make({
description: "User tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
},
})
yield* waitForTool(registry, "execute")
const initial = yield* toolDefinitions(registry)
expect(initial.map((definition) => definition.name)).toEqual(["execute"])
expect(initial[0].description).toContain('tools.context7["resolve-library-id"]')
expect(initial[0].description).toContain("tools.user.custom")
catalog = [item("github", "search_issues")]
yield* events.publish(McpEvent.ToolsChanged, { server: MCP.ServerName.make("github") })
yield* waitForDefinition(
registry,
(definitions) => definitions[0]?.description.includes("tools.github.search_issues") ?? false,
)
const refreshed = yield* toolDefinitions(registry)
expect(refreshed.map((definition) => definition.name)).toEqual(["execute"])
expect(refreshed[0].description).not.toContain("resolve-library-id")
expect(refreshed[0].description).toContain("tools.user.custom")
}),
)
describe("with CodeMode-unsafe MCP names", () => {
beforeEach(() => {
catalog = [item("$codemode", "foo.bar")]
})
it.effect("advertises safe aliases while preserving raw call identity", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const definition = (yield* toolDefinitions(registry))[0]
expect(definition.description).toContain("tools._codemode.foo_bar")
const settlement = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_aliased",
name: "execute",
input: { code: 'return await tools._codemode.foo_bar({ query: "react" })' },
},
})
expect(calls).toEqual([{ server: "$codemode", name: "foo.bar", args: { query: "react" } }])
expect(assertions[0]?.action).toBe("mcp:$codemode:foo.bar")
expect(settlement.output?.structured).toMatchObject({
toolCalls: [{ tool: "_codemode.foo_bar", status: "completed", input: { query: "react" } }],
})
}),
)
})
describe("with code mode disabled", () => {
beforeEach(() => {
process.env.OPENCODE_CODE_MODE = "false"
})
it.effect("preserves direct MCP tools and authorizes before calling them", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const events = yield* EventV2.Service
yield* waitForTool(registry, "context7_resolve-library-id")
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual([
"context7_resolve-library-id",
])
deny = true
const denied = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_denied",
name: "context7_resolve-library-id",
input: { query: "react" },
},
})
expect(denied.result).toMatchObject({ type: "error", value: expect.stringContaining("Permission denied") })
expect(calls).toEqual([])
expect(assertions).toEqual([
{
sessionID,
agent: toolIdentity.agent,
action: "mcp:context7:resolve-library-id",
resources: ["*"],
save: ["*"],
metadata: {
server: "context7",
tool: "resolve-library-id",
arguments: { query: "react" },
},
source: { type: "tool", messageID: toolIdentity.assistantMessageID, callID: "call_mcp_denied" },
},
])
deny = false
const allowed = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_allowed",
name: "context7_resolve-library-id",
input: { query: "react" },
},
})
expect(allowed.result).toEqual({ type: "text", value: "ok" })
expect(calls).toEqual([{ server: "context7", name: "resolve-library-id", args: { query: "react" } }])
catalog = [item("github", "search_issues")]
yield* events.publish(McpEvent.ToolsChanged, { server: MCP.ServerName.make("github") })
yield* waitForTool(registry, "github_search_issues")
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual([
"github_search_issues",
])
}),
)
})
})