#8975·jan

fix(agent): two MCP servers exposing the same tool name advertise duplicate functions

Author: thinhlpgCreated Sep 17, 2026Updated Sep 17, 2026

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:

json
[
  {"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 search call 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 HashMap iteration (#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 - test a_tool_name_exposed_by_two_servers_routes_the_same_way_every_run asserts advertised_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.