From 98450751266dd0242f8cc770c8be512b285c85e2 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 24 May 2026 18:20:23 +0400 Subject: [PATCH 1/2] fix: preserve MCP tools for Claude requests Keep active OpenCode custom/MCP tools in Claude-compatible requests instead of filtering them down to Claude core schemas. Continue replacing active built-in tools with Claude wire schemas, while preserving MCP-prefixed names and exact-name custom tool collisions. --- README.md | 7 +-- src/index.test.ts | 99 +++++++++++++++++++++++++++++++++++++++++- src/index.ts | 108 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 185 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 3c8df9a..f0122e0 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,10 @@ Claude Code sends 26 captured core tools (plus user-specific MCP tools). OpenCod The bridge resolves this by: -1. **Replacing OpenCode's tool definitions only for Claude-compatible targets** with Claude Code's exact wire-captured definitions — matching descriptions, JSON schemas, parameter names, and required fields. -2. **Adding 16 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `PushNotification`, `RemoteTrigger`, `ScheduleWakeup`, `TaskOutput`, `TaskStop`, `WebSearch`. -3. **Sorting all 26 tools alphabetically** to match Claude Code's ordering. +1. **Replacing active OpenCode shared core tool definitions only for Claude-compatible targets** with Claude Code's exact wire-captured definitions — matching descriptions, JSON schemas, parameter names, and required fields. +2. **Preserving active OpenCode custom/MCP tool definitions** so project-specific MCP servers remain available to Claude models. +3. **Selecting Claude-only alias/stub schemas only when the matching OpenCode tool is active** (for example `question` → `AskUserQuestion`, `plan_enter` → `EnterPlanMode`). +4. **Sorting selected tools alphabetically** to match Claude Code's ordering. If the model calls a stub tool, OpenCode's built-in error handling catches it, tells the model the tool is unavailable, and the model adapts on the next turn. diff --git a/src/index.test.ts b/src/index.test.ts index cca17e3..21cbbed 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,6 +21,8 @@ import { extractOAuthErrorDetail } from "./oauth.js"; import { deriveModelDisplayName, getClaudeToolsForActiveOpenCodeTools, + getInboundToolNameMapForActiveOpenCodeTools, + mapOutboundToolName, rewriteSystemBlocksForModel, shouldInjectClaudeTools, stripAssistantPrefillForClaude, @@ -250,9 +252,102 @@ describe("tool schema selection", () => { it("does not advertise WebSearch when only a custom websearch_cited tool is active", () => { const out = getClaudeToolsForActiveOpenCodeTools([ { name: "webfetch" }, - { name: "websearch_cited" }, + { + name: "websearch_cited", + description: "Search the web with citations", + input_schema: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, ]).map((tool) => tool.name).sort(); - assert.deepEqual(out, ["WebFetch"]); + assert.deepEqual(out, ["WebFetch", "websearch_cited"]); + }); + + it("preserves active MCP and custom OpenCode tools", () => { + const customTool = { + name: "custom_mcp_query_record", + description: "Get a record by ID", + input_schema: { + type: "object", + properties: { recordId: { type: "string" } }, + required: ["recordId"], + }, + }; + const out = getClaudeToolsForActiveOpenCodeTools([ + { name: "bash", description: "OpenCode bash", input_schema: { type: "object" } }, + customTool, + ]); + + const bashTool = out.find((tool) => tool.name === "Bash"); + const preservedTool = out.find((tool) => tool.name === customTool.name); + + assert.ok(bashTool); + assert.notEqual(bashTool.description, "OpenCode bash"); + assert.deepEqual(preservedTool, customTool); + }); + + it("preserves MCP tools that have no description", () => { + const out = getClaudeToolsForActiveOpenCodeTools([ + { + name: "custom_mcp_get_timestamp", + input_schema: { type: "object", properties: {} }, + }, + ]); + + assert.deepEqual(out, [ + { + name: "custom_mcp_get_timestamp", + description: "", + input_schema: { type: "object", properties: {} }, + }, + ]); + }); + + it("preserves MCP tools whose names look like prefixed core tools", () => { + const mcpTool = { + name: "mcp_bash", + description: "Run a command through an MCP server", + input_schema: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }; + const out = getClaudeToolsForActiveOpenCodeTools([mcpTool]); + + assert.deepEqual(out, [mcpTool]); + }); + + it("preserves custom tools whose names collide with Claude core tool names", () => { + const customTool = { + name: "Bash", + description: "Custom Bash-like MCP tool", + input_schema: { + type: "object", + properties: { script: { type: "string" } }, + required: ["script"], + }, + }; + const out = getClaudeToolsForActiveOpenCodeTools([customTool]); + + assert.deepEqual(out, [customTool]); + }); + + it("maps inbound Claude core names only for active OpenCode core tools", () => { + assert.deepEqual(getInboundToolNameMapForActiveOpenCodeTools([ + { name: "bash" }, + { name: "mcp_bash", input_schema: { type: "object" } }, + ]), { Bash: "bash" }); + assert.deepEqual(getInboundToolNameMapForActiveOpenCodeTools([ + { name: "Bash", input_schema: { type: "object" } }, + ]), {}); + }); + + it("maps outbound OpenCode core history but preserves MCP-prefixed names", () => { + assert.equal(mapOutboundToolName("bash"), "Bash"); + assert.equal(mapOutboundToolName("mcp_bash"), "mcp_bash"); }); it("does not advertise AskUserQuestion when OpenCode did not enable question", () => { diff --git a/src/index.ts b/src/index.ts index c790582..080fdbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { extractFirstUserMessageText, shouldUseClaudeToolSchemas, } from "./claude-tools.js"; +import type { ToolDefinition } from "./claude-tools.js"; import { createSseProcessor } from "./stream.js"; // ── Types ────────────────────────────────────────────────────────── @@ -79,18 +80,23 @@ const OUTBOUND_TOOL_NAME_MAP: Record = { webfetch: "WebFetch", todowrite: "TodoWrite", skill: "Skill", - mcp_bash: "Bash", - mcp_read: "Read", - mcp_glob: "Glob", - mcp_grep: "Grep", - mcp_edit: "Edit", - mcp_write: "Write", - mcp_task: "Agent", - mcp_webfetch: "WebFetch", - mcp_todowrite: "TodoWrite", - mcp_skill: "Skill", question: "AskUserQuestion", - mcp_question: "AskUserQuestion", + plan_enter: "EnterPlanMode", + plan_exit: "ExitPlanMode", +}; + +const ACTIVE_TOOL_SCHEMA_NAME_MAP: Record = { + bash: "Bash", + read: "Read", + glob: "Glob", + grep: "Grep", + edit: "Edit", + write: "Write", + task: "Agent", + webfetch: "WebFetch", + todowrite: "TodoWrite", + skill: "Skill", + question: "AskUserQuestion", plan_enter: "EnterPlanMode", plan_exit: "ExitPlanMode", }; @@ -230,21 +236,73 @@ export function shouldInjectClaudeTools(input: { return Array.isArray(input.tools) && input.tools.length > 0; } +function getToolName(tool: unknown): string | undefined { + if (!tool || typeof tool !== "object") return undefined; + const name = (tool as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + +function getToolInputSchema(tool: unknown): Record | undefined { + if (!tool || typeof tool !== "object") return undefined; + const inputSchema = (tool as { input_schema?: unknown }).input_schema; + if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) return undefined; + return inputSchema as Record; +} + export function getClaudeToolsForActiveOpenCodeTools( tools: unknown, -): ReturnType { +): ToolDefinition[] { if (!Array.isArray(tools)) return []; - const activeClaudeNames = new Set( - tools - .map((tool) => { - if (!tool || typeof tool !== "object") return undefined; - const name = (tool as { name?: unknown }).name; - if (typeof name !== "string") return undefined; - return OUTBOUND_TOOL_NAME_MAP[name] || name; - }) - .filter((name): name is string => typeof name === "string"), + const claudeToolsByName = new Map(getClaudeTools().map((tool) => [tool.name, tool])); + const selectedToolsByName = new Map(); + + for (const tool of tools) { + const name = getToolName(tool); + if (!name) continue; + + const claudeName = ACTIVE_TOOL_SCHEMA_NAME_MAP[name]; + if (claudeName) { + const claudeTool = claudeToolsByName.get(claudeName); + if (!claudeTool) continue; + selectedToolsByName.set(claudeName, claudeTool); + continue; + } + + const inputSchema = getToolInputSchema(tool); + if (!inputSchema) { + console.error( + `[opencode-claude-bridge] Dropping active tool "${name}" — missing or invalid input_schema`, + ); + continue; + } + + const description = (tool as { description?: unknown }).description; + selectedToolsByName.set(name, { + name, + description: typeof description === "string" ? description : "", + input_schema: inputSchema, + }); + } + + return Array.from(selectedToolsByName.values()).sort((a, b) => + a.name.localeCompare(b.name), ); - return getClaudeTools().filter((tool) => activeClaudeNames.has(tool.name)); +} + +export function getInboundToolNameMapForActiveOpenCodeTools( + tools: unknown, +): Record { + if (!Array.isArray(tools)) return {}; + const inboundToolNameMap: Record = {}; + + for (const tool of tools) { + const name = getToolName(tool); + if (!name) continue; + const claudeName = ACTIVE_TOOL_SCHEMA_NAME_MAP[name]; + if (claudeName) inboundToolNameMap[claudeName] = name; + } + + return inboundToolNameMap; } const oauthProfileCache = new Map>(); @@ -411,7 +469,7 @@ function deduplicatePrefix(text: string): string { return text; } -function mapOutboundToolName(name: string | undefined): string | undefined { +export function mapOutboundToolName(name: string | undefined): string | undefined { if (!name) return name; return OUTBOUND_TOOL_NAME_MAP[name] || name; } @@ -664,6 +722,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // ── Body ── let body = init?.body; + let inboundToolNameMap = INBOUND_TOOL_NAME_MAP; if (body && typeof body === "string") { try { const parsed = JSON.parse(body); @@ -779,6 +838,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // requests or Claude-family models on Anthropic-compatible // routers such as OpenRouter. if (shouldInjectClaudeTools({ model: parsed.model, requestUrl, tools: parsed.tools })) { + inboundToolNameMap = getInboundToolNameMapForActiveOpenCodeTools(parsed.tools); parsed.tools = getClaudeToolsForActiveOpenCodeTools(parsed.tools); } delete parsed.tool_choice; @@ -890,7 +950,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { // split across TCP chunks) that regex-on-raw-bytes can't // handle. See src/stream.ts for the processor implementation. const processor = createSseProcessor({ - inboundToolNameMap: INBOUND_TOOL_NAME_MAP, + inboundToolNameMap, translateToolArgs: translateToolArgsJsonString, }); From 82475237e9e71aa98d1164036a5c8b792b1022b3 Mon Sep 17 00:00:00 2001 From: Sergei Solomatkin Date: Mon, 14 Sep 2026 15:11:57 +0400 Subject: [PATCH 2/2] fix: restore plugin loading on current opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin failed to initialise at all, so none of its hooks ever ran — model injection, credential sync and request transformation were all dead. opencode logged one line and carried on without it: failed to load plugin modelId.match is not a function Cause: opencode only recognises a plugin whose default export is an object shaped { id, server }. A bare function default export drops it into the legacy loader, which walks every named export of the module and calls each one as a plugin factory with (input, options). The first helper it reaches, deriveModelDisplayName, receives an object where it expects a model id and throws, taking the whole plugin down. Export the module shape opencode looks for instead. The id is mandatory for plugins referenced by filesystem path — opencode rejects those without one. Guarded by a test on the default export shape. Nothing covered it before, which is why this went unnoticed. --- src/constants.ts | 6 ++++++ src/index.test.ts | 15 +++++++++++++++ src/index.ts | 14 +++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/constants.ts b/src/constants.ts index 1a62217..3289378 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,6 +26,12 @@ function detectClaudeVersion(): string { return "2.1.98"; } +/** + * Identity opencode registers this plugin under. Required: opencode refuses to + * load a plugin referenced by filesystem path unless its module exports an id. + */ +export const PLUGIN_ID = "opencode-claude-bridge"; + export const CLIENT_ID = process.env.ANTHROPIC_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; diff --git a/src/index.test.ts b/src/index.test.ts index 21cbbed..d975af6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -19,6 +19,7 @@ import { } from "./claude-tools.js"; import { extractOAuthErrorDetail } from "./oauth.js"; import { + default as pluginModule, deriveModelDisplayName, getClaudeToolsForActiveOpenCodeTools, getInboundToolNameMapForActiveOpenCodeTools, @@ -1223,3 +1224,17 @@ describe("rewriteSystemBlocksForModel", () => { assert.equal(out[1].text, "no identity line here"); }); }); + +describe("plugin module shape", () => { + it("default-exports { id, server } so opencode's loader recognises it", () => { + // A bare function default export sends opencode down its legacy path, where + // it calls every named export of this module as a plugin factory — which + // crashes on the helpers above and takes the whole plugin down with it. + assert.equal(typeof pluginModule, "object"); + assert.equal(typeof pluginModule.server, "function"); + }); + + it("carries an id, which opencode requires for path-referenced plugins", () => { + assert.equal(pluginModule.id, "opencode-claude-bridge"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 080fdbf..d9fcaac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { DEFAULT_EFFORT, CLEAR_THINKING_TYPE, SESSION_ID, + PLUGIN_ID, } from "./constants.js"; import { getClaudeTools, @@ -1134,4 +1135,15 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { }; }; -export default OpenCodeClaudeBridge; +/** + * opencode plugin module. + * + * The `{ id, server }` shape is what opencode's plugin loader looks for. A bare + * function default export makes it fall back to its legacy path, which treats + * every named export of this module as a plugin factory and invokes it with + * `(input, options)` — crashing on the helpers exported here for tests. + */ +export default { + id: PLUGIN_ID, + server: OpenCodeClaudeBridge, +};