Skip to content
This repository was archived by the owner on Aug 21, 2026. It is now read-only.

Commit 0550d2d

Browse files
committed
feat: map OpenCode native MCP tools to gateway mcp__ names
1 parent 0358b75 commit 0550d2d

7 files changed

Lines changed: 209 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Default live-test target is **Laguna S 2.1 free** (`poolside/laguna-s-2.1-free`,
3030

3131
### Keep agent loops, files, and context moving
3232

33-
OpenCode tool calls park and resume (MCP-prefixed tools are tracked separately). Attachments (images, PDFs, text/binary) pass through. Auto-compact tips and tiered client compact run before the 256k window overflows. Per-turn and session usage come from SSE `usage` plus `GET /v1/usage`.
33+
OpenCode tool calls park and resume. MCP tools configured natively in OpenCode (`mcp:` in `opencode.json`) are mapped both ways — OpenCode's `<server>_<tool>` ↔ the gateway's `mcp__<server>__<tool>` — so the agent sees them and OpenCode executes them with its own MCP clients. Attachments (images, PDFs, text/binary) pass through. Auto-compact tips and tiered client compact run before the 256k window overflows. Per-turn and session usage come from SSE `usage` plus `GET /v1/usage`.
3434

3535
## Quick start
3636

‎src/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
SESSION_HEADER,
2727
} from "./constants.js";
2828
import { log } from "./log.js";
29+
import { setMcpServers } from "./mcp-names.js";
2930
import {
3031
encodeCommandModelSelection,
3132
resolveCommandModelSelection,
@@ -298,6 +299,22 @@ export const CommandCodePlugin: Plugin = async (
298299
// Always seed the live catalog so the provider is discoverable.
299300
refreshCommandModels();
300301
ensureProviderConfig(config as Record<string, any>, getCommandModels());
302+
303+
// Seed native MCP server names (from `mcp:` in opencode config) so the
304+
// proxy can map tool names <server>_<tool> ↔ mcp__<server>__<tool>.
305+
// This is what lets the gateway agent see OpenCode's MCP tools and
306+
// lets OpenCode execute the calls natively via its own MCP clients.
307+
const mcpConfig = (config as Record<string, any>).mcp;
308+
if (mcpConfig && typeof mcpConfig === "object") {
309+
setMcpServers(
310+
Object.entries(mcpConfig as Record<string, unknown>)
311+
.filter(([, entry]) => {
312+
if (!entry || typeof entry !== "object") return true;
313+
return (entry as { enabled?: unknown }).enabled !== false;
314+
})
315+
.map(([name]) => name),
316+
);
317+
}
301318
},
302319

303320
"chat.headers": async (hookInput, output) => {

‎src/mcp-names.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Bidirectional MCP tool-name mapping.
3+
*
4+
* OpenCode exposes MCP tools natively as `sanitize(server)_sanitize(tool)`
5+
* (see McpCatalog.toolName), e.g. `linear_list_issues`.
6+
* The Command Code gateway/CLI expects `mcp__<server>__<tool>`
7+
* (see uniqueRegistryToolName in the `cmd` CLI bundle).
8+
*
9+
* Without this mapping the gateway agent never recognizes OpenCode's MCP
10+
* tools as MCP (they must start with `mcp__`), and OpenCode cannot dispatch
11+
* gateway-emitted `mcp__*` tool calls to its own MCP clients.
12+
*/
13+
14+
/** Same sanitize both ecosystems use: [^a-zA-Z0-9_-] → "_" */
15+
const sanitize = (value: string): string =>
16+
value.replace(/[^a-zA-Z0-9_-]/g, "_");
17+
18+
/** Sanitized MCP server names, longest first for unambiguous prefix match. */
19+
let serverNames: string[] = [];
20+
21+
/** Exact wire → native pairs seen this process (handles ambiguous names). */
22+
const wireToNative = new Map<string, string>();
23+
24+
/**
25+
* Register MCP server names from the merged OpenCode config (`mcp:` keys).
26+
* Called once from the plugin config hook; cheap to re-call.
27+
*/
28+
export function setMcpServers(names: string[]): void {
29+
serverNames = [...new Set(names.map(sanitize).filter(Boolean))].sort(
30+
(a, b) => b.length - a.length,
31+
);
32+
}
33+
34+
export function getMcpServers(): readonly string[] {
35+
return serverNames;
36+
}
37+
38+
function nativeFromParts(server: string, tool: string): string {
39+
return `${server}_${tool}`;
40+
}
41+
42+
/**
43+
* OpenCode native tool name → Command Code wire name.
44+
* Non-MCP tools pass through unchanged.
45+
*/
46+
export function toWireName(nativeName: string): string {
47+
if (!nativeName || nativeName.startsWith("mcp__")) return nativeName;
48+
for (const server of serverNames) {
49+
const prefix = `${server}_`;
50+
if (nativeName.startsWith(prefix) && nativeName.length > prefix.length) {
51+
const wire = `mcp__${server}__${nativeName.slice(prefix.length)}`;
52+
wireToNative.set(wire, nativeName);
53+
return wire;
54+
}
55+
}
56+
return nativeName;
57+
}
58+
59+
/**
60+
* Command Code wire name → OpenCode native tool name.
61+
* Unknown / non-MCP names pass through unchanged.
62+
*/
63+
export function toNativeName(wireName: string): string {
64+
if (!wireName.startsWith("mcp__")) return wireName;
65+
const known = wireToNative.get(wireName);
66+
if (known) return known;
67+
const rest = wireName.slice("mcp__".length);
68+
const sep = rest.indexOf("__");
69+
if (sep <= 0 || sep + 2 >= rest.length) return wireName;
70+
const server = rest.slice(0, sep);
71+
const tool = rest.slice(sep + 2);
72+
if (!serverNames.includes(server)) return wireName;
73+
const native = nativeFromParts(server, tool);
74+
wireToNative.set(wireName, native);
75+
return native;
76+
}
77+
78+
/** True when the native OpenCode tool name belongs to a configured MCP server. */
79+
export function isNativeMcpName(nativeName: string): boolean {
80+
if (!nativeName) return false;
81+
if (nativeName.startsWith("mcp__") || nativeName.startsWith("mcp_")) {
82+
return true;
83+
}
84+
for (const server of serverNames) {
85+
if (nativeName.startsWith(`${server}_`)) return true;
86+
}
87+
return false;
88+
}
89+
90+
/** Test helper. */
91+
export function resetMcpNamesForTests(): void {
92+
serverNames = [];
93+
wireToNative.clear();
94+
}

‎src/prompt.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
WireTool,
1212
WireUserContent,
1313
} from "./gateway-types.js";
14+
import { toWireName } from "./mcp-names.js";
1415

1516
const IMAGE_OMITTED =
1617
"[image omitted: the active model is text-only]";
@@ -301,7 +302,9 @@ export function openaiToolsToWire(tools: OpenAITool[]): WireTool[] {
301302
const name = t.function?.name;
302303
if (!name) return null;
303304
return {
304-
name,
305+
// Gateway expects MCP tools as mcp__<server>__<tool>; OpenCode
306+
// names them <server>_<tool> natively. Map so agents see them.
307+
name: toWireName(name),
305308
description: t.function?.description || name,
306309
input_schema:
307310
(t.function?.parameters as Record<string, unknown>) || {
@@ -359,7 +362,7 @@ export function openaiMessagesToWire(
359362
content.push({
360363
type: "tool-call",
361364
toolCallId: id,
362-
toolName: name,
365+
toolName: toWireName(name),
363366
input,
364367
});
365368
}
@@ -377,7 +380,7 @@ export function openaiMessagesToWire(
377380
{
378381
type: "tool-result",
379382
toolCallId,
380-
toolName: msg.name || "",
383+
toolName: msg.name ? toWireName(msg.name) : "",
381384
output: {
382385
type: "text",
383386
value: extractTextContent(msg.content),

‎src/proxy.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import type { WireMessage, WireTool, WireUsage } from "./gateway-types.js";
4040
import { emptyUsage } from "./gateway-types.js";
4141
import { log } from "./log.js";
42+
import { toNativeName, toWireName } from "./mcp-names.js";
4243
import {
4344
conversationKeyFromMessages,
4445
getSessionUsage,
@@ -447,19 +448,22 @@ async function handleChatCompletions(
447448
continue;
448449
}
449450
if (event.kind === "tool_call") {
451+
// Map gateway mcp__<server>__<tool> back to the OpenCode native
452+
// <server>_<tool> so OpenCode executes it via its own MCP client.
453+
const nativeName = toNativeName(event.name);
450454
if (event.providerExecuted) {
451455
// Server-executed tool — surface as reasoning note, do not park.
452456
yield {
453457
kind: "reasoning",
454-
text: `[mcp/tool] ${event.name} (provider-executed)\n`,
458+
text: `[mcp/tool] ${nativeName} (provider-executed)\n`,
455459
} satisfies GatewayMappedEvent;
456-
recordToolCall(conversationKey, event.name);
460+
recordToolCall(conversationKey, nativeName);
457461
continue;
458462
}
459-
recordToolCall(conversationKey, event.name);
463+
recordToolCall(conversationKey, nativeName);
460464
const pending: ParkedToolCall = {
461465
id: event.id,
462-
name: event.name,
466+
name: nativeName,
463467
arguments: event.arguments,
464468
resolve: () => {},
465469
reject: () => {},
@@ -489,7 +493,7 @@ async function handleChatCompletions(
489493
const assistantContent = assistantToolCalls.map((t) => ({
490494
type: "tool-call" as const,
491495
toolCallId: t.id,
492-
toolName: t.name,
496+
toolName: toWireName(t.name),
493497
input: safeParse(t.arguments),
494498
}));
495499
const nextMessages: WireMessage[] = [
@@ -507,7 +511,7 @@ async function handleChatCompletions(
507511
{
508512
type: "tool-result",
509513
toolCallId: tool.id,
510-
toolName: tool.name,
514+
toolName: toWireName(tool.name),
511515
output: { type: "text", value: result },
512516
},
513517
],

‎src/usage.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
emptyUsage,
88
type WireUsage,
99
} from "./gateway-types.js";
10+
import { isNativeMcpName } from "./mcp-names.js";
1011

1112
export type ToolUsageStat = {
1213
name: string;
@@ -35,7 +36,9 @@ function isMcpToolName(name: string): boolean {
3536
return (
3637
name.startsWith("mcp__") ||
3738
name.startsWith("mcp_") ||
38-
/(^|\.)mcp($|\.)/i.test(name)
39+
/(^|\.)mcp($|\.)/i.test(name) ||
40+
// Native OpenCode MCP names (<server>_<tool>) recorded after mapping.
41+
isNativeMcpName(name)
3942
);
4043
}
4144

‎test/smoke.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ async function main() {
9696
"../src/gateway.ts"
9797
);
9898
const { detectCommandCode } = await import("../src/detect.ts");
99+
const {
100+
setMcpServers,
101+
getMcpServers,
102+
toWireName,
103+
toNativeName,
104+
isNativeMcpName,
105+
resetMcpNamesForTests,
106+
} = await import("../src/mcp-names.ts");
99107

100108
// --- credentials ---
101109
assert.ok(listCommandAuthCandidates().length > 0);
@@ -318,6 +326,75 @@ claude-sonnet-5 recommended
318326
assert.equal(tools.length, 2);
319327
assert.equal(tools[0]?.name, "bash");
320328

329+
// --- MCP tool-name mapping (OpenCode native <-> Command Code wire) ---
330+
resetMcpNamesForTests();
331+
setMcpServers(["linear", "filesystem"]);
332+
assert.deepEqual([...getMcpServers()].sort(), ["filesystem", "linear"]);
333+
assert.equal(toWireName("linear_list_issues"), "mcp__linear__list_issues");
334+
assert.equal(toWireName("filesystem_read"), "mcp__filesystem__read");
335+
assert.equal(toWireName("bash"), "bash");
336+
assert.equal(toWireName("mcp__linear__list_issues"), "mcp__linear__list_issues");
337+
assert.equal(toNativeName("mcp__linear__list_issues"), "linear_list_issues");
338+
assert.equal(toNativeName("mcp__filesystem__read"), "filesystem_read");
339+
assert.equal(toNativeName("mcp__unknown__thing"), "mcp__unknown__thing");
340+
assert.equal(toNativeName("bash"), "bash");
341+
assert.equal(isNativeMcpName("linear_get_issue"), true);
342+
assert.equal(isNativeMcpName("bash"), false);
343+
344+
// Wire conversion must rename MCP tools so the gateway agent sees them.
345+
const mappedTools = openaiToolsToWire([
346+
{
347+
type: "function",
348+
function: {
349+
name: "linear_list_issues",
350+
description: "list issues",
351+
parameters: { type: "object", properties: {} },
352+
},
353+
},
354+
{
355+
type: "function",
356+
function: {
357+
name: "bash",
358+
description: "shell",
359+
parameters: { type: "object", properties: {} },
360+
},
361+
},
362+
]);
363+
assert.equal(mappedTools[0]?.name, "mcp__linear__list_issues");
364+
assert.equal(mappedTools[1]?.name, "bash");
365+
366+
// History conversion must rename MCP tool calls/results consistently.
367+
const wiredHistory = openaiMessagesToWire(
368+
[
369+
{ role: "user", content: "hi" },
370+
{
371+
role: "assistant",
372+
content: "",
373+
tool_calls: [
374+
{
375+
id: "call_1",
376+
type: "function",
377+
function: { name: "linear_list_issues", arguments: "{}" },
378+
},
379+
],
380+
},
381+
{
382+
role: "tool",
383+
tool_call_id: "call_1",
384+
name: "linear_list_issues",
385+
content: "[]",
386+
},
387+
],
388+
{ vision: false },
389+
);
390+
const histAssistant = wiredHistory.messages.find((m) => m.role === "assistant");
391+
const histToolCall = (histAssistant?.content as Array<{ toolName?: string }>)?.[0];
392+
assert.equal(histToolCall?.toolName, "mcp__linear__list_issues");
393+
const histTool = wiredHistory.messages.find((m) => m.role === "tool");
394+
const histResult = (histTool?.content as Array<{ toolName?: string }>)?.[0];
395+
assert.equal(histResult?.toolName, "mcp__linear__list_issues");
396+
resetMcpNamesForTests();
397+
321398
// --- compact / context ---
322399
const adviceOk = assessContext(10_000, 256_000);
323400
assert.equal(adviceOk.tier, "ok");

0 commit comments

Comments
 (0)