revert(core): remove v2 execute integration
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user