fix(agent): two MCP servers exposing the same tool name advertise duplicate functions
Summary. If two MCP servers both expose a tool with the same name, Jan tells the model that name twice. The provider either rejects the request outright or silently keeps one of the two definitions, and the model has no way to know which server a call was meant for.
Configure two MCP servers that both expose, say, search, and connect them to Jan Agent. You get one search in the UI and two on the wire, with different descriptions and different arguments, and a 50/50 chance the call goes somewhere you did not intend.
What the model sees
An fs server and a zed server that both expose search. Jan sends:
[
{"type": "function", "function": {"name": "search", "description": "Search the filesystem"}},
{"type": "function", "function": {"name": "search", "description": "Search the open editor buffer"}}
]Two functions, one name. Jan's tool APIs key tools by name, so:
- A strict provider rejects the whole request with a duplicate-name error, and the turn fails.
- A lenient provider keeps one definition - usually the last - so a
searchcall can land on the server whose description the model never read. The call returns something plausible and wrong.
The model also sees two identical-looking entries it cannot choose between, so even a provider that accepts the request may misroute it.
Why it happens
assemble_tool_array (src-tauri/src/core/agent/upstream.rs:692) flattens every connected server's tool listing into the advertised array and adds each tool as it comes. Nothing checks whether that name is already present.
The routing table that maps a tool name back to its server, tool_to_server, is a HashMap<String, String> (upstream.rs:709-711). One name, one target - so even the map already behaves as if the collision did not exist, and last server in order wins.
Neither of these is new; the collision predates the prefix-cache work.
Where it stands after #8974
#8974 made the collision resolve the same way every time: the array is byte-stable and tool_to_server now always resolves to the last server by name, instead of following HashMap iteration order, which is randomized per process. That removed the worst symptom - the same tool call routing to a different server after each app restart.
The duplicate entries on the wire are untouched. Fixing those is this issue.
Options
Not decided here. All three are reasonable; they differ in how much they break.
| Option | Behaviour | Cost |
|---|---|---|
Namespace per server, e.g. fs.search / zed.search |
Both tools stay usable, names are unique | What most MCP clients do, but it is a breaking change for saved prompts, permission lists, and anything matching on tool names |
| Reject the duplicate and warn, naming both servers | Config error is visible and fixable by the user | One server's tool is unavailable until the user renames something |
| First server wins, in the array and the map | Consistent, cheap, no new naming scheme | A tool disappears with no error; the user has to notice |
Whichever is chosen, the fix should assert that the advertised array has unique function.name values.
What a fix must not break
- The array must stay byte-stable across runs (#8959), or the prefix cache regresses.
- A fixed order must not depend on
HashMapiteration (#8959). - It must not shrink the array silently mid-session - a hiccuping server reuses its last known listing rather than vanishing (#8959).
Pointers
upstream.rs:1757- testa_tool_name_exposed_by_two_servers_routes_the_same_way_every_runassertsadvertised_names(&array) == ["search", "search"], and its doc comment records the collision as pre-existing.upstream.rs:692-assemble_tool_array, where the flatten and the missing dedupe live.upstream.rs:709-711-tool_to_server, the one-name-one-target map.
Part of #8956. Found while working on #8959 / #8974.
Source: janhq/jan