diff --git a/README.md b/README.md index dddb33b..812272a 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,10 @@ Claude Code sends 24 core tools (plus user-specific MCP tools). OpenCode has 10. 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 14 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `RemoteTrigger`, `TaskOutput`, `TaskStop`, `WebSearch`. -3. **Sorting all 24 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 9d42d5e..20cb62c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -18,7 +18,13 @@ import { shouldUseClaudeToolSchemas, } from "./claude-tools.js"; import { extractOAuthErrorDetail } from "./oauth.js"; -import { getClaudeToolsForActiveOpenCodeTools, shouldInjectClaudeTools, stripAssistantPrefillForClaude } from "./index.js"; +import { + getClaudeToolsForActiveOpenCodeTools, + getInboundToolNameMapForActiveOpenCodeTools, + mapOutboundToolName, + shouldInjectClaudeTools, + stripAssistantPrefillForClaude, +} from "./index.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; import { deriveModelDisplayName, @@ -235,9 +241,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 088e3d6..6184f05 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", }; @@ -220,21 +226,68 @@ 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) 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>(); @@ -392,7 +445,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; } @@ -644,6 +697,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); @@ -758,6 +812,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; @@ -869,7 +924,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, });