diff --git a/packages/core/package.json b/packages/core/package.json index 84a51e61d2..56bcbb53ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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:*", diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index faf0a14598..1c975040dc 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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. diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 6e621d57e6..f0f2d63e7f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index a35ada75c6..eed26170b0 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -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", ) ) }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 560dc061fd..939bb4e731 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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, diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index cc6711acc6..3719322fa1 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -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. diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts deleted file mode 100644 index d991856dd9..0000000000 --- a/packages/core/src/tool/execute.ts +++ /dev/null @@ -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, context?: Context) => { - const tools: Record> = 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 = [] - 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 } diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index f23fe34797..571b747bab 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -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 `_` 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) => + 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 }).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() + const record: Record = {} + 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], +}) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 126317242b..1dc7478c11 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -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 /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: (tools: Readonly>) => Effect.Effect - /** - * 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 - } } 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() - const codeMode = new Map() + const local = new Map>() - 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, - entries: ReadonlyArray, - ) { - 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() 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>() - 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, diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts index 0a904cb2a9..0939ce957b 100644 --- a/packages/core/src/tool/tools.ts +++ b/packages/core/src/tool/tools.ts @@ -3,19 +3,10 @@ export * as Tools from "./tools" import { Context, Effect, Scope } from "effect" import { Tool } from "./tool" -export type CodeModeTools = Readonly>>> - export interface Interface { readonly register: ( tools: Readonly>, ) => Effect.Effect - /** - * 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 - } } /** Narrow registration-only Location capability. */ diff --git a/packages/core/test/mcp-guidance.test.ts b/packages/core/test/mcp-guidance.test.ts deleted file mode 100644 index 67d9d313ce..0000000000 --- a/packages/core/test/mcp-guidance.test.ts +++ /dev/null @@ -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.", - ) - }), - ) -}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 41cf0dfb23..27f1d04061 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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", + ) }), ) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 167f744d38..3c03094b5e 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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", }) diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts deleted file mode 100644 index ca8a90fb99..0000000000 --- a/packages/core/test/tool-execute.test.ts +++ /dev/null @@ -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" } }], - }) - }), - ) -}) diff --git a/packages/core/test/tool-mcp.test.ts b/packages/core/test/tool-mcp.test.ts deleted file mode 100644 index 8d0f9b84c4..0000000000 --- a/packages/core/test/tool-mcp.test.ts +++ /dev/null @@ -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 | 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 { - 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", - ]) - }), - ) - }) -})