Skip to content

fix: disable stale bridge tool IDs from other pool slots#56

Merged
KochC merged 1 commit into
devfrom
fix/tool-bridge-slot-leakage
Jul 5, 2026
Merged

fix: disable stale bridge tool IDs from other pool slots#56
KochC merged 1 commit into
devfrom
fix/tool-bridge-slot-leakage

Conversation

@KochC

@KochC KochC commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #55. Fixes a bug where tool/function calling (#52/v1.7.0) can return the wrong tool call — a stale one from a previously-reused bridge pool slot — instead of the current turn's own tool.

The bug

OpenCode's server API has no endpoint to deregister an MCP server once added, so registerToolBridge()'s pool of px_tools_N slots keeps each slot connected under its previous tool schema until that slot is next reused. getDisabledTools() also only snapshots OpenCode's built-in tool IDs once, via client.tool.ids(), before any bridge tools ever exist — so it can never know about px_tools_N_* IDs either, past or present.

The result: runAgentTurn() only ever set the current turn's own bridge tool IDs to true on the shared tools map. A previous turn's still-connected tool was never explicitly set to false — it was simply absent from the map, which left it implicitly enabled rather than disabled. Once the pool has cycled through more than one slot, a later turn's session can see (and the model can call) an unrelated, stale tool left over from an earlier turn's slot.

Confirmed against a live OpenCode server (not just mocks, per the caveat in #52's original PR description): two sequential tool-calling requests with different declared tools (get_weather, then calculate) — the second request's response contained the first request's tool call name instead of its own.

The fix

  • getToolBridgeState(): added a knownToolIDs set tracking every bridge tool ID ever registered across the pool's lifetime, from any slot.
  • registerToolBridge(): records each turn's tool IDs into it. Exported for direct unit testing.
  • New exported buildToolsMap(baseTools, bridge): explicitly disables every known bridge tool ID first, then re-enables only the current turn's own IDs. runAgentTurn() now uses this instead of a map that only ever gained true entries and never explicitly cleared stale ones.

Testing

  • npm test140 passed (138 existing + 2 new)
  • npm run lint — clean
  • Manually verified against a live OpenCode server end-to-end (this wasn't possible when feat: add tool/function calling support #52 was originally written, per its own notes):
    • Request 1: tools: [get_weather] → correctly got back finish_reason: "tool_calls", function.name: "get_weather"
    • Request 2 (immediately after, different tool): tools: [calculate] → correctly got back function.name: "calculate" (not the stale get_weather)
    • GET /mcp confirmed both px_tools_0 and px_tools_1 ended up connected independently, as expected

Related

A bridge slot reused by a later request stays connected under its
previous tool schema, since OpenCode's server API has no endpoint to
deregister an MCP server once added. getDisabledTools() also only
snapshots OpenCode's built-in tool IDs once, before any bridge tools
ever exist, so it can never know to disable them either.

Without explicitly disabling every previously-registered bridge tool
ID per turn, a stale, still-connected tool from an earlier turn's slot
remains implicitly enabled and can be called by the model instead of
(or alongside) the current turn's own tool - confirmed against a real
live OpenCode server: a second tool-calling request reusing a
different pool slot got back the *first* request's tool call
(get_weather) instead of its own (calculate).

- getToolBridgeState(): track every bridge tool ID ever registered
  across the pool's lifetime in a new knownToolIDs set.
- registerToolBridge(): record each turn's tool IDs into it. Exported
  for direct unit testing.
- buildToolsMap() (new, exported): explicitly disables every known
  bridge tool ID, then re-enables only the current turn's own IDs.
  runAgentTurn() now uses this instead of only ever setting (never
  clearing) tool IDs on the shared map.
- index.test.js: unit tests covering slot isolation (a stale tool ID
  from an earlier turn must be explicitly disabled, not just absent)
  and the no-bridge passthrough case.

Testing:
- npm test - 140 passed (138 existing + 2 new)
- npm run lint - clean
- Manually verified against a live OpenCode server (not just mocks):
  two sequential tool-calling requests with different tools
  (get_weather, then calculate) each correctly returned their own
  tool_calls, with GET /mcp confirming both bridge slots ended up
  connected independently.
@KochC KochC requested a review from Copilot July 5, 2026 11:38
@KochC KochC merged commit a5ba31c into dev Jul 5, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a tool-calling bug where OpenCode can surface (and allow calling) stale MCP bridge tool IDs from previously-used bridge pool slots by explicitly disabling all previously-known bridge tool IDs each turn and only re-enabling the current turn’s bridge tool IDs.

Changes:

  • Track every bridge tool ID ever registered (knownToolIDs) and use it to explicitly disable stale bridge tools per turn.
  • Introduce buildToolsMap(baseTools, bridge) and update runAgentTurn() to use it instead of only ever adding true entries.
  • Add regression tests covering cross-slot stale tool disablement and the no-bridge case.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
index.js Tracks known bridge tool IDs and builds a per-turn tools map that disables stale bridge tools across pool slots.
index.test.js Adds regression coverage for bridge-slot isolation and buildToolsMap() behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread index.js
Comment on lines +516 to +520
// so a slot reused for a later request stays connected under its old tool
// schema until it's next reused - see buildToolsMap() below for why this
// must be tracked and explicitly disabled per-turn, not just left out of the
// map.
knownToolIDs: new Set(),
Comment thread index.test.js
Comment on lines +2255 to +2257
it("disables a previously-registered bridge tool ID from a different pool slot", async () => {
const { client } = createMcpMockClient()

Comment thread index.test.js
Comment on lines +2291 to +2292
it("returns a plain copy of baseTools when there is no bridge for this turn", () => {
const baseTools = { bash: false, read: false }
Comment thread index.js
Comment on lines 691 to +695
})

const toolIDs = bridgeTools.map((tool) => `${slotName}_${tool.name}`)
const bridgeState = getToolBridgeState()
for (const id of toolIDs) bridgeState.knownToolIDs.add(id)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants