Repository navigation
fix(agent): reject duplicate client tool names - #162
Conversation
Calls are dispatched by tool name, and findToolByName returns the first match. With two client tools sharing a name, the first one in the list received the call, including a call approved while only the other tool was registered. Throw from the ModelResult constructor instead. createMCPTools already rejects duplicates within one MCP server's tools; this covers the combined list passed to callModel. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
I'll fix CI failures and address comments from users with write access that start with 'Devin'.
|
There was a problem hiding this comment.
Verdict: Approve. The diagnosis is right: client tool calls are dispatched by name (findToolByName and the this.options.tools?.find(... t.function.name ...) lookups in model-result.ts), so a duplicate name silently routes a call, including an approved one, to whichever tool is listed first. Checking once in the ModelResult constructor covers every public entry point (callModel constructs ModelResult; executeToolLoop is not exported), and the tool list is not mutated after construction. Throwing before any state load keeps a paused conversation in awaiting_approval, which the resume test confirms.
Out of scope, no change needed here: approvals are still bound to callId + name, not to a tool instance, so resuming with a different tool that reuses the approved name (and no duplicate) still runs the new tool with the approved arguments. If that matters later, approvals could record a tool identity in saved state.
Estimated impact: low. Callers who pass duplicate client tool names now get a synchronous error instead of ambiguous dispatch; there is no other behavior change and the fix is easy to revert.
Risk: implementation low, premise low, impact low (fail-fast validation on a configuration the model cannot disambiguate anyway). CI is green.
1 suggestion, 0 blockers, 0 nits.
Summary
Risk: when two client tools in
toolsshare a name, a call is dispatched to whichever one is listed first (findToolByName). That includes calls approved throughapproveToolCalls: a call approved while only tool A was registered runs on tool B with A's arguments if the resume passes[B, A]. In practice B is usually an unprefixed MCP tool from a lower-trust server whose name collides with a local tool.createMCPToolsalready rejects duplicates within one server, but nothing checked the combined list passed tocallModel.Fix: the
ModelResultconstructor (reached fromcallModel, deferred.resolve()withrun, and direct use) now callsassertUniqueClientToolNames(options.tools)and throws:Server tools have no client name and are skipped. Because the throw happens before any state load, a paused conversation stays
awaiting_approvaland can be resumed with a corrected list.Behavior change: callers who currently pass duplicate names (which the model cannot tell apart anyway, and some providers reject with HTTP 400) now get an error. Changeset is
patch.Tests:
tests/unit/duplicate-tool-names.test.tsnew ModelResultandcallModel[approved], resume with[other, approved]+approveToolCalls: before the fixother.executeran with the approved arguments; after the fix it throws andothernever runspnpm run lint,pnpm run typecheck, and the unit project (110 files, 1267 tests) pass locally.