@khalilgharbaoui/opencode-claude-code-plugin 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -114,6 +114,7 @@ interface ClaudeCodeConfig {
114
114
  proxyTools?: string[];
115
115
  webSearch?: WebSearchRouting;
116
116
  hotReloadMcp?: boolean;
117
+ proxyOpencodeMcpTools?: boolean;
117
118
  }
118
119
  type WebSearchRouting = "claude" | "disabled" | (string & {});
119
120
  interface ClaudeCodeProviderSettings {
@@ -191,6 +192,21 @@ interface ClaudeCodeProviderSettings {
191
192
  * survives MCP changes until the chat is reset).
192
193
  */
193
194
  hotReloadMcp?: boolean;
195
+ /**
196
+ * Route opencode MCP server tools through the in-process `opencode_proxy`
197
+ * MCP server instead of bridging them directly into Claude CLI's
198
+ * `--mcp-config`. With both layers configured for the same MCP server,
199
+ * direct bridging causes each tool invocation to execute twice — once by
200
+ * Claude CLI's own MCP child process and once by opencode. Routing through
201
+ * the proxy keeps a single execution site (opencode) while preserving the
202
+ * tool-call/result surface in opencode's UI and its permission prompts.
203
+ *
204
+ * Defaults to `true`. Set to `false` to restore the prior direct-bridge
205
+ * behavior (Claude CLI executes MCP tools itself; opencode also re-executes
206
+ * — accept the duplication if you need Claude to invoke the tool without
207
+ * an opencode round-trip).
208
+ */
209
+ proxyOpencodeMcpTools?: boolean;
194
210
  }
195
211
  type PermissionMode = "acceptEdits" | "auto" | "bypassPermissions" | "default" | "dontAsk" | "plan";
196
212
  type ControlRequestBehavior = "allow" | "deny";
@@ -305,6 +321,16 @@ declare class ClaudeCodeLanguageModel implements LanguageModelV3 {
305
321
  private effectiveMcpConfig;
306
322
  /** Resolve ProxyToolDef[] for the configured proxyTools names. */
307
323
  private resolvedProxyTools;
324
+ /**
325
+ * Resolve ProxyToolDef[] for opencode's MCP-bridged tools so they go
326
+ * through the in-process proxy instead of being bridged into Claude CLI's
327
+ * `--mcp-config`. Direct bridging causes double execution because both
328
+ * Claude CLI's own MCP child and opencode hold their own connection to
329
+ * the same server; routing through the proxy keeps a single execution
330
+ * site (opencode). Returns null when the feature is disabled, the SDK
331
+ * client is unavailable, or no MCP servers are configured.
332
+ */
333
+ private resolvedProxyMcpTools;
308
334
  /**
309
335
  * Create a proxy MCP server for a single active Claude process/session.
310
336
  * The process lifecycle owns the server lifecycle via session-manager.
@@ -339,6 +365,18 @@ interface BridgedMcp {
339
365
  path: string;
340
366
  /** Stable hash of the merged opencode mcp block (pre-translation). */
341
367
  hash: string;
368
+ /**
369
+ * Names of opencode MCP servers that were bridged into Claude CLI's
370
+ * `--mcp-config`. Excludes any servers passed in `excludeServers`.
371
+ */
372
+ serverNames: string[];
373
+ /**
374
+ * Names of every enabled opencode MCP server after merge + runtime
375
+ * overlay, regardless of whether they ended up bridged or excluded.
376
+ * Callers (e.g. the proxy-tool builder) use this to decide which
377
+ * `<server>_<tool>` IDs in opencode's tool catalog are MCP-origin.
378
+ */
379
+ allEnabledServerNames: string[];
342
380
  }
343
381
  /**
344
382
  * Per-server runtime status from opencode's `client.mcp.status()`. Used as
@@ -362,7 +400,7 @@ type RuntimeMcpStatus = Record<string, string>;
362
400
  * return its path + a stable hash. Returns null when no enabled MCP servers
363
401
  * remain after the merge + overlay.
364
402
  */
365
- declare function bridgeOpencodeMcp(cwd: string, runtimeStatus?: RuntimeMcpStatus): BridgedMcp | null;
403
+ declare function bridgeOpencodeMcp(cwd: string, runtimeStatus?: RuntimeMcpStatus, excludeServers?: ReadonlySet<string>): BridgedMcp | null;
366
404
 
367
405
  declare const defaultModels: Record<string, OpenCodeModel>;
368
406
 
package/dist/index.js CHANGED
@@ -637,7 +637,7 @@ function mergeMcp(target, source) {
637
637
  }
638
638
  return out;
639
639
  }
640
- function bridgeOpencodeMcp(cwd, runtimeStatus) {
640
+ function bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers) {
641
641
  const worktree = detectWorktree(cwd);
642
642
  let merged = {};
643
643
  merged = mergeMcp(merged, extractMcpBlock(loadGlobalConfig()));
@@ -676,15 +676,37 @@ function bridgeOpencodeMcp(cwd, runtimeStatus) {
676
676
  merged[name] = { ...base, enabled: status === "connected" };
677
677
  }
678
678
  }
679
+ const allEnabledServerNames = [];
680
+ for (const [name, spec] of Object.entries(merged)) {
681
+ if (!spec || typeof spec !== "object") continue;
682
+ const enabled = spec.enabled;
683
+ if (enabled === false) continue;
684
+ allEnabledServerNames.push(name);
685
+ }
679
686
  const servers = {};
687
+ const bridgedServerNames = [];
680
688
  for (const [name, spec] of Object.entries(merged)) {
681
689
  if (!spec || typeof spec !== "object") continue;
690
+ if (excludeServers?.has(name)) continue;
682
691
  const translated = translateServer(name, spec);
683
- if (translated) servers[name] = translated;
692
+ if (translated) {
693
+ servers[name] = translated;
694
+ bridgedServerNames.push(name);
695
+ }
696
+ }
697
+ const mergedBody = JSON.stringify({ mcpServers: merged }, null, 2);
698
+ const hash = crypto.createHash("sha256").update(mergedBody).digest("hex").slice(0, 12);
699
+ if (Object.keys(servers).length === 0) {
700
+ const allEnabledServersExcluded = excludeServers && allEnabledServerNames.length > 0 && allEnabledServerNames.every((name) => excludeServers.has(name));
701
+ if (!allEnabledServersExcluded) return null;
702
+ return {
703
+ path: "",
704
+ hash,
705
+ serverNames: [],
706
+ allEnabledServerNames
707
+ };
684
708
  }
685
- if (Object.keys(servers).length === 0) return null;
686
709
  const body = JSON.stringify({ mcpServers: servers }, null, 2);
687
- const hash = crypto.createHash("sha256").update(body).digest("hex").slice(0, 12);
688
710
  const outPath = path2.join(
689
711
  pluginTmpDir(),
690
712
  `mcp-${hash}.json`
@@ -702,9 +724,15 @@ function bridgeOpencodeMcp(cwd, runtimeStatus) {
702
724
  log.info("bridged opencode MCP config", {
703
725
  target: outPath,
704
726
  hash,
705
- servers: Object.keys(servers)
727
+ servers: bridgedServerNames,
728
+ excluded: excludeServers ? Array.from(excludeServers) : []
706
729
  });
707
- return { path: outPath, hash };
730
+ return {
731
+ path: outPath,
732
+ hash,
733
+ serverNames: bridgedServerNames,
734
+ allEnabledServerNames
735
+ };
708
736
  }
709
737
 
710
738
  // src/runtime-status.ts
@@ -736,6 +764,35 @@ async function getRuntimeMcpStatus() {
736
764
  return void 0;
737
765
  }
738
766
  }
767
+ async function fetchOpencodeToolList(provider, model, directory) {
768
+ const client = opencodeClient;
769
+ if (!client?.tool?.list) return void 0;
770
+ try {
771
+ const res = await client.tool.list({
772
+ query: { provider, model, ...directory ? { directory } : {} }
773
+ });
774
+ const data = res.data;
775
+ if (!Array.isArray(data)) return void 0;
776
+ const out = [];
777
+ for (const entry of data) {
778
+ if (!entry || typeof entry !== "object") continue;
779
+ const e = entry;
780
+ const id = typeof e.id === "string" ? e.id : null;
781
+ const description = typeof e.description === "string" ? e.description : "";
782
+ const parameters = e.parameters && typeof e.parameters === "object" ? e.parameters : {};
783
+ if (!id) continue;
784
+ out.push({ id, description, parameters });
785
+ }
786
+ return out;
787
+ } catch (err) {
788
+ log.warn("failed to fetch opencode tool list", {
789
+ provider,
790
+ model,
791
+ error: err instanceof Error ? err.message : String(err)
792
+ });
793
+ return void 0;
794
+ }
795
+ }
739
796
 
740
797
  // src/session-manager.ts
741
798
  import { spawn } from "child_process";
@@ -1264,6 +1321,7 @@ function writeJson(res, body) {
1264
1321
  import { EventEmitter as EventEmitter3 } from "events";
1265
1322
  var pendingBySession = /* @__PURE__ */ new Map();
1266
1323
  var emitter = new EventEmitter3();
1324
+ var PENDING_PROXY_CALL_TIMEOUT_MS = 10 * 60 * 1e3;
1267
1325
  function eventName(sessionKey2) {
1268
1326
  return `pending:${sessionKey2}`;
1269
1327
  }
@@ -1275,16 +1333,50 @@ function onPendingProxyCall(sessionKey2, handler) {
1275
1333
  function queuePendingProxyCall(sessionKey2, call) {
1276
1334
  const existing = pendingBySession.get(sessionKey2);
1277
1335
  if (existing) {
1336
+ if (Date.now() - existing.createdAt < PENDING_PROXY_CALL_TIMEOUT_MS) {
1337
+ call.reject(
1338
+ new Error(`Another proxy tool call is already pending for ${sessionKey2}`)
1339
+ );
1340
+ log.warn("rejected overlapping proxy call", {
1341
+ sessionKey: sessionKey2,
1342
+ existingToolCallId: existing.toolCallId,
1343
+ existingToolName: existing.toolName,
1344
+ toolCallId: call.id,
1345
+ toolName: call.toolName
1346
+ });
1347
+ return existing;
1348
+ }
1349
+ clearTimeout(existing.timer);
1278
1350
  existing.reject(
1279
- new Error(`Another proxy tool call is already pending for ${sessionKey2}`)
1351
+ new Error(
1352
+ `Stale proxy tool call expired after ${PENDING_PROXY_CALL_TIMEOUT_MS}ms for ${sessionKey2}`
1353
+ )
1280
1354
  );
1281
1355
  pendingBySession.delete(sessionKey2);
1282
1356
  }
1357
+ const timer = setTimeout(() => {
1358
+ const current = pendingBySession.get(sessionKey2);
1359
+ if (!current || current.toolCallId !== call.id) return;
1360
+ pendingBySession.delete(sessionKey2);
1361
+ current.reject(
1362
+ new Error(
1363
+ `Proxy tool call '${call.toolName}' timed out after ${PENDING_PROXY_CALL_TIMEOUT_MS}ms waiting for opencode to resolve the call`
1364
+ )
1365
+ );
1366
+ log.warn("timed out pending proxy call", {
1367
+ sessionKey: sessionKey2,
1368
+ toolCallId: call.id,
1369
+ toolName: call.toolName,
1370
+ timeoutMs: PENDING_PROXY_CALL_TIMEOUT_MS
1371
+ });
1372
+ }, PENDING_PROXY_CALL_TIMEOUT_MS);
1283
1373
  const pending = {
1284
1374
  sessionKey: sessionKey2,
1285
1375
  toolCallId: call.id,
1286
1376
  toolName: call.toolName,
1287
1377
  input: call.input,
1378
+ createdAt: Date.now(),
1379
+ timer,
1288
1380
  resolve: call.resolve,
1289
1381
  reject: call.reject
1290
1382
  };
@@ -1304,6 +1396,7 @@ function resolvePendingProxyCall(sessionKey2, result) {
1304
1396
  const pending = pendingBySession.get(sessionKey2);
1305
1397
  if (!pending) return false;
1306
1398
  pendingBySession.delete(sessionKey2);
1399
+ clearTimeout(pending.timer);
1307
1400
  pending.resolve(result);
1308
1401
  log.info("resolved pending proxy call", {
1309
1402
  sessionKey: sessionKey2,
@@ -1432,18 +1525,20 @@ var ClaudeCodeLanguageModel = class {
1432
1525
  * provided it overlays opencode's UI-toggled state on top of disk config
1433
1526
  * so `/mcps` toggles propagate without a config file write.
1434
1527
  */
1435
- effectiveMcpConfig(cwd, proxyConfigPath, runtimeStatus) {
1528
+ effectiveMcpConfig(cwd, proxyConfigPath, runtimeStatus, excludeServers) {
1436
1529
  const paths = Array.isArray(this.config.mcpConfig) ? this.config.mcpConfig.slice() : this.config.mcpConfig ? [this.config.mcpConfig] : [];
1437
1530
  let bridgedHash = null;
1531
+ let allEnabledServerNames = [];
1438
1532
  if (this.config.bridgeOpencodeMcp !== false) {
1439
- const bridged = bridgeOpencodeMcp(cwd, runtimeStatus);
1533
+ const bridged = bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers);
1440
1534
  if (bridged) {
1441
- paths.push(bridged.path);
1535
+ if (bridged.path) paths.push(bridged.path);
1442
1536
  bridgedHash = bridged.hash;
1537
+ allEnabledServerNames = bridged.allEnabledServerNames;
1443
1538
  }
1444
1539
  }
1445
1540
  if (proxyConfigPath) paths.push(proxyConfigPath);
1446
- return { paths, bridgedHash };
1541
+ return { paths, bridgedHash, allEnabledServerNames };
1447
1542
  }
1448
1543
  /** Resolve ProxyToolDef[] for the configured proxyTools names. */
1449
1544
  resolvedProxyTools() {
@@ -1459,6 +1554,45 @@ var ClaudeCodeLanguageModel = class {
1459
1554
  }
1460
1555
  return picked.length > 0 ? picked : null;
1461
1556
  }
1557
+ /**
1558
+ * Resolve ProxyToolDef[] for opencode's MCP-bridged tools so they go
1559
+ * through the in-process proxy instead of being bridged into Claude CLI's
1560
+ * `--mcp-config`. Direct bridging causes double execution because both
1561
+ * Claude CLI's own MCP child and opencode hold their own connection to
1562
+ * the same server; routing through the proxy keeps a single execution
1563
+ * site (opencode). Returns null when the feature is disabled, the SDK
1564
+ * client is unavailable, or no MCP servers are configured.
1565
+ */
1566
+ async resolvedProxyMcpTools(allEnabledServerNames) {
1567
+ if (this.config.proxyOpencodeMcpTools === false) return null;
1568
+ if (this.config.bridgeOpencodeMcp === false) return null;
1569
+ if (allEnabledServerNames.length === 0) return null;
1570
+ const items = await fetchOpencodeToolList(
1571
+ this.config.provider,
1572
+ this.modelId,
1573
+ this.config.cwd
1574
+ );
1575
+ if (!items || items.length === 0) return null;
1576
+ const serversByLengthDesc = [...allEnabledServerNames].sort(
1577
+ (a, b) => b.length - a.length
1578
+ );
1579
+ const out = [];
1580
+ const seen = /* @__PURE__ */ new Set();
1581
+ for (const item of items) {
1582
+ const matchedServer = serversByLengthDesc.find(
1583
+ (name) => item.id === name || item.id.startsWith(`${name}_`)
1584
+ );
1585
+ if (!matchedServer) continue;
1586
+ if (seen.has(item.id)) continue;
1587
+ seen.add(item.id);
1588
+ out.push({
1589
+ name: item.id,
1590
+ description: item.description ?? "",
1591
+ inputSchema: item.parameters && typeof item.parameters === "object" ? item.parameters : { type: "object", properties: {} }
1592
+ });
1593
+ }
1594
+ return out.length > 0 ? out : null;
1595
+ }
1462
1596
  /**
1463
1597
  * Create a proxy MCP server for a single active Claude process/session.
1464
1598
  * The process lifecycle owns the server lifecycle via session-manager.
@@ -1740,7 +1874,7 @@ var ClaudeCodeLanguageModel = class {
1740
1874
  const scope = this.requestScope(options);
1741
1875
  const affinity = this.sessionAffinity(options);
1742
1876
  const sk = sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`);
1743
- if (scope === "tools" && this.resolvedProxyTools()) {
1877
+ if (scope === "tools" && (this.resolvedProxyTools() || this.config.proxyOpencodeMcpTools !== false && this.config.bridgeOpencodeMcp !== false)) {
1744
1878
  return this.doGenerateViaStream(options);
1745
1879
  }
1746
1880
  if (scope === "no-tools") {
@@ -2137,8 +2271,18 @@ ${plan}
2137
2271
  }
2138
2272
  }
2139
2273
  const setup = async () => {
2140
- if (!proxyServer && resolvedProxy) {
2141
- proxyServer = await self.ensureProxyServer(resolvedProxy, sk);
2274
+ const discovery = self.effectiveMcpConfig(
2275
+ cwd,
2276
+ void 0,
2277
+ runtimeStatus
2278
+ );
2279
+ const proxyMcpTools = await self.resolvedProxyMcpTools(
2280
+ discovery.allEnabledServerNames
2281
+ );
2282
+ const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
2283
+ const combinedProxyTools = resolvedProxy || proxyMcpTools ? [...resolvedProxy ?? [], ...proxyMcpTools ?? []] : null;
2284
+ if (!proxyServer && combinedProxyTools) {
2285
+ proxyServer = await self.ensureProxyServer(combinedProxyTools, sk);
2142
2286
  }
2143
2287
  const proxyDisallowed = resolvedProxy ? disallowedToolFlags(resolvedProxy) : [];
2144
2288
  const extraDisallowed = [];
@@ -2147,7 +2291,8 @@ ${plan}
2147
2291
  const mcp = self.effectiveMcpConfig(
2148
2292
  cwd,
2149
2293
  proxyServer?.configPath(),
2150
- runtimeStatus
2294
+ runtimeStatus,
2295
+ excludeServers
2151
2296
  );
2152
2297
  const systemPromptFile = activeProcess ? void 0 : buildAppendedSystemPrompt(cwd);
2153
2298
  const cliArgs = buildCliArgs({
@@ -3225,7 +3370,8 @@ function createClaudeCode(settings = {}) {
3225
3370
  controlRequestDenyMessage: settings.controlRequestDenyMessage,
3226
3371
  proxyTools,
3227
3372
  webSearch: settings.webSearch,
3228
- hotReloadMcp: settings.hotReloadMcp ?? true
3373
+ hotReloadMcp: settings.hotReloadMcp ?? true,
3374
+ proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true
3229
3375
  });
3230
3376
  };
3231
3377
  const provider = function(modelId) {