Skip to content

fix(mcp-host): alias tool names that exceed the provider name limit - #1445

Merged
zach88 merged 26 commits into
devfrom
fix/mcp-host-tool-wire-names
Nov 12, 2026
Merged

zach88 merged 26 commits into
devfrom
fix/mcp-host-tool-wire-names

Conversation

@zach88

@zach88 zach88 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What

mcp-host exposes every MCP tool to the LLM as <server>__<tool>. That name can exceed what a provider accepts as a function name: 64 characters on OpenAI, Azure, OpenAI-compatible endpoints and Bedrock; 128 on Claude and Vertex (Vertex also rejects a leading digit). Today a single over-long tool makes every LLM call of that context fail with a 400.

This PR projects tool names at the LLM edge, per request:

  • New pure module mcp-host/src/llm/toolWireNames.ts builds a per-request map from canonical name to wire name.
  • Names that already fit the adapter's envelope are sent unchanged, so the request is byte-identical to today's.
  • Names that don't fit get a deterministic alias of at most 64 characters on every envelope:
    • <server>__<tool head>_<12 hex> when the server is a single lowercase label (/^[a-z][a-z0-9-]*$/) of at most 50 characters (subject to the 51-character cut below);
    • otherwise (longer server, dots/underscores/leading digit, or no __) a prefix-less form: the whole name sanitized, runs of _ collapsed, then _<12 hex>, so the alias never carries a __ that could be read as another server.
    • The head (server + tool) is cut to 51 characters before the hash (minus the collision suffix, if any), so a long server eats into the visible tool part: with a 42-character server only 7 characters of the tool survive, and from 49 characters (47 with a suffix) none do. With a suffix, a 49-character server also loses the __ and a 50-character one is cut to 49 characters, so in practice the prefix form only holds up to 48 characters. Many sibling tools can share the same visible head and differ only in the hash. Routing is not affected: the lookup back is exact. The alias is therefore not meant to be readable on its own: the canonical server and tool are annotated in the tool description, and that annotation is what tells the model what it is calling.
    • A collision appends _<k in base36>, so the suffix depends on the set of names in the request, not only on the name.
  • Tool calls in the response are mapped back to the canonical name by exact lookup. Unknown names pass through unchanged, so the loop still answers "Tool not found" for invented names.
  • History stays canonical: nothing is persisted, migrated or rewritten at rest. History names are projected on the way out like tools[].

Closes #1388

Why

Remote MCP servers with long tool names (Notion's hosted server is the real case, 77 tools captured; Atlassian's 26 tools are captured too) combined with long registry server names push canonical names into the 65 to 128 range. On a 64-envelope provider the context is unusable, not just the one tool.

How

  • Every tool-calling edge is projected: OpenAIProvider.completeSingleTurnWithTools (covers both the Chat Completions and Responses protocols, Azure and compatibles, by wrapping encode/decode without touching the protocols), ClaudeProvider (both tool methods, including the cache-aware one), googleGenerative, bedrockConverse, plus the token counters AnthropicTokenCounter.count and OpenAITokenCounter (tiktoken), so their counts match the names and annotations actually sent.
  • Text-only methods are not projected: completeSingleTurn of ClaudeProvider, OpenAIProvider, Bedrock and Gemini, and ClaudeProvider.completeSingleTurnAndCache, send no tools[] (the Claude and OpenAI ones also reduce each message to role and content). Today no caller reaches them with tool_calls in the history (summarization flattens the history to markdown first). If one ever does, its history names go out canonical.
  • Envelope per adapter: Claude and its token counter use the 128 envelope, Vertex its own (128, no leading digit), gemini/openrouter a conservative fixed envelope, everything else 64. In the OpenAI family the envelope is a field of the endpoint profile (toolNameEnvelope), decided by profile id.
  • Collisions are resolved deterministically: order of tools in the request never changes the result, ties are broken by code unit, and a non-colliding name gets the same wire name in every request.
  • Not touched: the Codex and Grok subscription proxies keep their own aliasing (pinned by a test that they still receive canonical names).
  • Golden of server names (control-api/test/mcpHost.serverNames.golden.test.ts → mcp-host/src/llm/__tests__/fixtures/serverNames.json) is emitted by the real generateRegistryName and fails when stale (UPDATE_WIRE_GOLDENS=1 regenerates it). Its catalog slot is intentionally empty: the catalog name producer is not in dev yet.

Testing

  • Full mcp-host suite green on head 959314dee: 606 files / 8970 tests (1 skipped, 1 todo), typecheck clean.
  • toolWireNames.property.test.ts (fast-check): envelope literals, control aliases, map properties (idempotence, order independence, stable wire name across requests, alias only claims its own server segment), forced collisions with an injected hash, description annotation bounds.
  • wireToolNames.providers.test.ts: every direct provider sends only names inside its envelope and resolves an aliased call back to the canonical name, history projection, return mapping incl. unknown and orphan names, 63-character real server names.
  • wireToolNames.identity.test.ts: byte identity of the request against a golden produced on the merge-base when all names fit, for every direct provider and the Claude cache-aware method.
  • wireToolNames.edges.test.ts: Claude cache-aware method incl. history projection, token counters count the names sent, Responses continuation across an aliased turn, proxies untouched, and lone-surrogate canonical names on the makeProvider path (see Risks).
  • wireToolNames.endToEnd.test.ts: an alias returned by the model reaches the MCP server with the canonical name, (Notion, and Vertex with an over-128 tool), and deferred tools / approval see the right tool.
  • Regression check: the per-provider envelope test (22/22 provider ids) and the token-counter tests fail with assertion errors on the current merge-base 61f32ae04.

Risks / Follow-ups

  • Merge order: this must land in dev before feat: install remote OAuth MCP connectors from the Marketplace #1284, which extends the same golden test with catalog server names from its real producer and adds the "longest catalog name = 55" check.
  • gemini / openrouter envelope not observed live: no credentials were available to probe their real limit, so they use a fixed conservative envelope. Worth confirming when a key is available.
  • Responses limit not read from a source: the openai id (Responses) stays on the 64 envelope, the strictest in the family.
  • Accepted cost: the Responses continuation digest is computed over wire names. If alias assignment changes between turns (e.g. a colliding tool appears), the digest mismatches and that turn falls back to a plain replay, losing the encrypted reasoning for that turn only. Tool resolution is not affected.
  • Accepted cost (lone surrogates, after fix(mcp-host): repair lone UTF-16 surrogates before LLM dispatch; report a provider 400 as a rejected request #1434): makeProvider now repairs lone surrogates to U+FFFD before this projection runs. A canonical tool name with a lone surrogate therefore resolves back to its repaired form (the loop answers "Tool not found"), and two names that differ only in the surrogate share one wire name. Without this PR that tool already failed with a 400. No known MCP server emits such names; wireToolNames.edges.test.ts pins the behaviour.
  • The heuristic estimators (FallbackTokenCounter, the context manager heuristic, Anthropic's heuristic fallback) keep measuring canonical names and descriptions without the alias annotation; the drift is bounded and only affects estimates.
  • Known limitation (bridge): when dynamic tools are on but the catalog is under the bridge threshold (every tool listed), a model that calls clerum__tool_call({ name: "<alias>" }) instead of calling the listed tool directly gets "Tool not available": the bridge target name is checked against canonical names and is not mapped back. The error is recoverable and the discovery text already tells the model not to bridge a listed tool. Left for a follow-up.
  • Follow-up (alias shape): MAX_PREFIX_SERVER = 50 has no effect today because the 51-character head cut wins first; the shape above is the real one. Keeping a distinguishing slice of the tool for long registry server names is left for a follow-up.

zach88 and others added 2 commits November 11, 2026 20:49
…aliases

mcp-host prefixes every tool with `<server>__`, so the server name decides how
much room a tool name has on the provider wire. The golden is emitted by the
real generateRegistryName and the test fails when it goes stale
(UPDATE_WIRE_GOLDENS=1 regenerates it).

`catalog` stays empty on purpose: the catalog-slot name producer is not in this
tree yet and will extend this same test when it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the LLM edge

`<server>__<tool>` names can exceed what a provider accepts for a function
name (64 on OpenAI, Azure, compatibles and Bedrock; 128 on Claude and Vertex;
Vertex also rejects a leading digit). Today such a name makes every call of
the context fail with a 400.

Each direct adapter now projects the request through a per-request map
(src/llm/toolWireNames.ts): names that fit the adapter's envelope are sent
unchanged (byte-identical request), the rest get a readable alias
`<server>__<tool head>_<12 hex>` (or a prefix-less form) with the canonical
server/tool annotated in the description, and tool calls in the response are
mapped back to the canonical name by exact lookup. History stays canonical,
so nothing is persisted or migrated. Envelope per adapter: Claude and its
token counter W_c, Vertex W_v, gemini/openrouter W_g, everything else W
(an endpoint-profile field in the OpenAI family). The codex/grok proxies keep
their own aliasing and are not touched.

Closes #1388

Class-check: `name: (t|tool|tc)\.name` in mcp-host/src plus the openaiFamily
protocol sites -> 25 sites -> 2 producers (manager.ts getAllTools,
stepRouter.ts) reach 6 network edges, all projected: OpenAIProvider
(chat + Responses, azure, compatibles), ClaudeProvider x2, googleGenerative,
bedrockConverse, AnthropicTokenCounter.count. Excluded: codexSubscription.ts
and grokSubscription.ts (the proxy aliases; pinned by T-W6). The rest never
reach an LLM or build canonical tools upstream of an edge. Local estimators
(tiktoken, FallbackTokenCounter, countSync, fitGfsImages) keep measuring
canonical names; accepted, bounded drift.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.providers.test.ts:95
(T-W2, 22/22 ids) and wireToolNames.edges.test.ts:84 (T-W8 b) fail on e611ebf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zach88
zach88 requested a review from jozer-rami as a code owner November 12, 2026 04:01
Comment thread mcp-host/src/llm/toolWireNames.ts Fixed
zach88 and others added 3 commits November 12, 2026 07:36
… account

The public-boundary guard rejects any added PEM private-key marker, with no
fixture exemption. The Vertex driver only checks that private_key is present
and the harness fakes the SDK, so a plain placeholder is enough.

Class-check: `BEGIN .*PRIVATE KEY` in lines added by this branch -> 1 site
(wireToolNamesHarness.ts) -> fixed.
Repro-test: scripts/tests/test-minikube-t2-public-boundary.sh rejects 98779cb
and passes on this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssword heuristics

CodeQL's js/insufficient-password-hash read the `1password...` test server
names flowing into the alias hash as passwords. The hash only derives a
deterministic alias suffix and never sees secrets. `1keychain` keeps the
leading digit and the same length (9), so the Vertex/W_g leading-digit case
and the 47/50-character server boundary are unchanged.

The control alias literal is recomputed independently (sha256 over UTF-16LE,
which reproduces the previous 4adbcea87d1d for the old name). The identity
golden is regenerated with UPDATE_WIRE_GOLDENS=1 on the merge-base e611ebf;
it differs from the previous one only by the rename.

Class-check: `password|passwd|secret` in lines added by this branch -> 10 sites,
all the `1password` test server name in 3 test files + identity golden -> all
renamed; no other credential-shaped names reach createHash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zach88 and others added 13 commits November 12, 2026 09:45
… path

The cache-aware Claude method is the one the port adapter picks whenever
system prompt parts are present, yet nothing failed if it converted the
canonical history instead of the projected one. Send a finished tool turn
naming a 129-character tool and a dotted one, and assert every tool_use name
in the body fits W_c and matches a declared tool.

Class-check: `grep -rn "toWireRequest(" src` (non-test) -> 6 projection sites
(claude.ts:212 and :295, googleGenerative.ts:136, openai.ts:131,
bedrockConverse.ts:138, anthropicTokenCounter.ts:71) -> only claude.ts:295
lacked a history assertion; the per-provider history test walks the other
four provider paths through completeSingleTurnWithTools, and the token
counter test compares counted vs sent messages. Fixed the one gap.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.edges.test.ts:52 (fails with mutant: convertToClaudeMessages(messages) at claude.ts:300 -> AssertionError, history name does not match W_c)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every model answer in the provider contract named a Notion tool that fits
the 128-character envelopes, so Vertex (and Claude's non-cache method) never
had to map a returned alias back: replacing fromWireResponse with identity
left the suite green and the alias would reach dispatch as an unknown tool.

- Provider contract: every direct provider answers with the alias of a
  129-character tool, which no envelope accepts, and must return the
  canonical name.
- End to end: Vertex behind the real LlmPortAdapter and McpManager answers
  with that alias; the remote MCP server must receive the canonical tool.

Class-check: `grep -rn "fromWireResponse(" src` (non-test) -> 5 sites; each
mutated to identity against src/llm/__tests__: openai.ts:150 (27 red),
claude.ts:362 (1 red), bedrockConverse.ts:173 (2 red) were caught;
googleGenerative.ts:174 and claude.ts:256 survived. Both are now covered.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.providers.test.ts:114 (fails with mutant: fromWireResponse -> identity at googleGenerative.ts:174 and at claude.ts:256, AssertionError on the returned name)
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.endToEnd.test.ts:142 (fails with mutant: fromWireResponse -> identity at googleGenerative.ts:174, AssertionError on the returned name)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The only projection test named the history tool in both the assistant
tool_call and the tool message, so dropping either source from the set
stayed green: the total toWire fallback still produced a valid alias.
Add one case per source, alone, with a real tool in tools[] whose name
equals the history name's base alias. Leaving the source out makes the
history name go out under that tool's name; with it, the history name gets
a distinct wire name that resolves back to its canonical.

Class-check: `grep -n "set.push" mcp-host/src/llm/toolWireNames.ts` -> 3
sources (tools[].name, assistant tool_calls[].name, tool message name) ->
tools[] is pinned by every provider test; the two history sources now have
one isolating case each.
Repro-test: mcp-host/src/llm/__tests__/toolWireNames.property.test.ts:588 (fails with mutant: drop the tool_calls[].name push in toWireRequest -> AssertionError, history wire name equals the tool's name)
Repro-test: mcp-host/src/llm/__tests__/toolWireNames.property.test.ts:602 (fails with mutant: drop the tool message name push in toWireRequest -> AssertionError, history wire name equals the tool's name)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No real slot is exactly 50 characters, so turning the prefix bound from
<= into < stayed green. Cut the longest real slot to 49, 50 and 51
characters and assert the alias keeps the server prefix through 50 and
drops it (no `__` at all) at 51, under W and W_g.

Class-check: numeric bounds in mcp-host/src/llm/toolWireNames.ts
(`grep -n "const [A-Z_]* = [0-9]" `) -> 3 (MAX_PREFIX_SERVER=50,
HEAD_BUDGET=51, ANNOTATION_FIELD_CODE_POINTS=200) -> only MAX_PREFIX_SERVER
lacked a boundary case; HEAD_BUDGET is pinned by the literal control aliases
and the 200 cut by the annotation tests at 199/200 code points.
Repro-test: mcp-host/src/llm/__tests__/toolWireNames.property.test.ts:348 (fails with mutant: server.length < MAX_PREFIX_SERVER at toolWireNames.ts:75 -> AssertionError at length 50)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ounter

OpenAITokenCounter encoded the canonical request, while the wire request
carries the alias and a "(MCP server: ...; tool: ...)" annotation in front
of every aliased description: roughly 25-30 tokens short per aliased tool.
Project the request with toWireRequest before counting, as the Anthropic
counter already does, and feed the projected fields to both the tiktoken
path and the byte-length fallback.

Envelope: the factory builds this counter only for descriptors with
tokenizer 'openai' (openai, azure), and both endpoint profiles declare W,
so W is the exact envelope of every real use, not an approximation. The
test builds both providers through the real factory and compares the count
of the canonical tools with the count of the declarations their adapters
actually sent.

Class-check: local estimators that receive canonical tools
(`grep -rln "heuristicCountTools\|countWithEncoder\|countTokens" src/core`)
-> 4: OpenAITokenCounter (fixed), AnthropicTokenCounter (already projects;
its heuristic offline/countSync fallback does not), FallbackTokenCounter
(19 compat/own-SDK providers plus the two proxied ones, which send canonical
names) and the contextManager heuristic. Not fixed: they are biased upper
bounds, not tokenizers, and the right envelope is not knowable inside the
counter without new per-provider plumbing (the proxied providers must not
be projected at all). Reported as a follow-up.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.edges.test.ts:121 (fails at 313b876: expected 175 to be 251)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ap.toWire

toWire was documented as total: a name outside the set fell back to
identity or its base alias. That base alias can already be the wire name of
another canonical of the set (a real name equal to it is reserved first),
so the caller would send the outside name under another tool's name and
fromWire would resolve it to that other tool. Throw ToolWireNameError
instead; the map only knows the names it was built with.

No caller depends on the fallback: the three production calls in
toWireRequest pass names collected into the set under the same
`typeof name === 'string'` guards, and every test call passes a member of
the map's own set (grep below).

Class-check: `grep -rn "\.toWire(" mcp-host/src` -> 3 production sites
(toolWireNames.ts toWireRequest: tools, tool_calls, tool message name) and
25 pre-existing test calls -> all pass set members, none changed. The fallback branch in
toWire was the only "total" claim in the module (`grep -n "Total" `).
Repro-test: mcp-host/src/llm/__tests__/toolWireNames.property.test.ts:438 (fails at 0965185: expected function to throw an error, but it didn't)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- toolWireNames.ts module doc: the alias is not a pure function of the
  canonical name; on a collision the suffix depends on the request's other
  names. Keep why that is fine: the map is local to one request and history
  is stored canonical.
- PREFIX_SERVER: it does not mirror the MCP manager's server names (those
  are DNS subdomains and may carry dots). Describe what it accepts and why:
  a single lowercase label that already passes every envelope and keeps the
  alias's first `__` at the server boundary.
- bedrockConverse.ts: the 64-character limit comes from AWS's Converse API
  reference, not from the SDK's types.

Class-check: comments attributing an envelope or the alias rule
(`grep -rn -B4 "toolNameEnvelope" src --exclude-dir=__tests__`,
`grep -rn "pure function\|Same shape" src/llm`) -> 6 envelope sites
(claude.ts, googleGenerative.ts, bedrockConverse.ts, endpointProfiles.ts x3)
plus the two module comments -> corrected the three reported; the others
either carry no source claim or state one that holds (gemini/openrouter
"no published limit").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names

The wire-name tests were labelled T-W1 ... T-W9 (with b variants), IDs of a
test matrix that does not live in the repository. Drop them from describe
names, section rules and file headers, keeping the description that already
followed each ID. The (c)/(d)/(e) letters of the "tool calls coming back"
cases were sub-items of that same matrix, split across two files, so they
go too; letter lists complete within one describe (annotation, Claude token
counter) stay.

Class-check: `grep -rn 'T-W[0-9]' mcp-host/src` -> 21 lines in 5 files ->
all replaced; `git grep 'T-W[0-9]'` outside mcp-host/src -> 0. Not touched:
the P1 ... P8 property labels in toolWireNames.property.test.ts are the same
kind of external ID, left out of this finding's scope and reported as a
follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nter

The openai/azure tiktoken counter test only sent `[{role:'user'}]`, so a
counter that projected the tools but counted the canonical history stayed
green. The test now sends a finished tool turn through the real adapter:
two aliased calls (a >128-character name built from varied words, and
QUERY), with tool messages that carry the canonical `name`, which is the
shape the tool-use loop and the task executor produce
(toolUseLoopMessages.ts:211, toolUseLoopSingleTool.ts:87,
taskExecutor.ts:879,885). It compares `countSync(history, tools)` with the
count of what the adapter sent.

Comparison chosen: rebuilding ChatMessages from the captured body would be
per-family (chat `messages` vs Responses `input` items, string arguments,
null vs '' content) and would encode adapter layout the counter does not
model. Aliasing changes only names, so `sentHistory` is the canonical
history with the call aliases read off the captured body substituted, in
order; the `name` of its tool messages is that substitution (the alias of
the call each one answers), because neither family sends it. The test
asserts exactly that: in chat the body's tool messages carry no string
`name`, and the Responses body has no tool messages. It also asserts that
every call name changed, fits W and matches a declared alias, and, as an
explicit precondition, that the sent names cost a different number of
tokens than the canonical ones, measured through message content (never
projected), so a vanished margin turns red instead of silently losing
power. The `'x'.repeat` name is not used here: it tokenizes like its alias.

Class-check: `toWireRequest(` in token counters (src/core/tokenizer) -> 2
sites (anthropicTokenCounter.ts:71, openaiTokenCounter.ts:133) -> the
anthropic one is already covered with an aliased tool turn by "Claude token
counter (offline=false) (b)" (params.messages == sent.messages); this commit
covers the openai one.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.edges.test.ts:208 (fails with mutant: countSync projects only the tools, `toWireRequest([], canonicalTools, COUNTED_ENVELOPE)` counting canonicalMessages -> 459 vs 437; fails with mutant: tool messages counted with their canonical `name` -> 448 vs 437; both on openai and azure. A benign `+3` per tool in countWithEncoder stays green)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…files

`COUNTED_ENVELOPE = 'W'` in the tiktoken counter repeats the envelope the
openai and azure profiles declare, and the test could not tell `W` from
`W_g`. A separate case now declares `1srv__ok` (fits W, not W_g: digit
first) next to the twin `asrv__ok`, which fits every envelope.

Comparing two counts that both go through the counter's own projection
cannot discriminate the envelope (a W_g counter aliases the name the same
way on both sides; checked). Instead the case compares the counted cost of
the `1srv__ok` declaration with the cost of its twin and requires them to be
within 4 tokens; an alias adds its annotation, 22 tokens here. The twin
comparison does not depend on how the counter prices a declaration, so a
benign formula change (per-tool overhead, a different serialization) does
not go red. The case also asserts the adapter sent both declarations
unchanged, so a profile switched to W_g goes red as well.

Class-check: hardcoded envelopes outside the provider profiles
(`ToolNameEnvelopeKey = '` / `toolNameEnvelope` in src, excluding
toolWireNames.ts) -> 1 duplicate (openaiTokenCounter.ts:92); the anthropic
counter receives the envelope from ClaudeProvider (claude.ts:63, 'W_c' only
as an unset-option default) -> only the openai one needs pinning. Passing the
envelope through the constructor is out of scope.
Repro-test: mcp-host/src/llm/__tests__/wireToolNames.edges.test.ts:230 (fails with mutant: `COUNTED_ENVELOPE = 'W_g'` -> cost gap 22, expected < 5, on openai and azure; the openai profile switched to W_g fails at :222, sent declarations aliased. A benign `+3` per tool in countWithEncoder stays green)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #1434 (lone UTF-16 surrogate repair at makeProvider, HTTP 400
classified as UpstreamRejected) and the repo-hygiene CI check (#1448).
Only textual conflict: the mcp-host version bump; kept 0.1.648.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om dev

dev (#1434) now wraps every provider from makeProvider with
withWellFormedRequestText and adds ES2024.String to the tsconfig lib.
Three comments of the wire-name projection became inaccurate:

- the annotation helper said toWellFormed is unavailable under ES2022;
- the UTF-16LE hash note implied injectivity over lone surrogates on the
  production path, where names arrive already repaired to U+FFFD;
- the Claude counter claimed to count exactly the sent names, which no
  longer holds for a name with lone surrogates (the counter is unwrapped).

Comment-only; no behaviour change. Clean merge, no textual conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since dev's makeProvider wraps providers with withWellFormedRequestText,
lone surrogates in tool names are repaired to U+FFFD before the wire-name
projection runs. A canonical with a lone surrogate therefore resolves back
to its repaired form, and two names that differ only in the surrogate share
one wire name. Both are accepted (no known MCP server emits such names);
the tests pin the behaviour so a change to either layer revisits it.

Both tests fail on 5ace877 (before the merge, without the wrapper).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Mircala

Mircala commented Nov 12, 2026

Copy link
Copy Markdown

Evenfire Reviewer — Round 1

Reviewed head: 8e7931cddfa1b6eaa9dca2a17d410061c5bdb0d4

Decision: Approve with follow-ups

The projection does what it claims. A canonical <server>__<tool> past the provider envelope no longer fails the whole turn, the returned call resolves back to the canonical name, and history stays canonical. I ran the five new test files on this head: 263 passed. The one thing worth tracking is not a correctness break.

Must fix

No must-fix items found.

Verified

  • Every direct tool-calling edge projects and maps back: OpenAI (both protocols), both Claude tool methods, Vertex, and Bedrock. The Codex and Grok proxies stay canonical, and a test pins that.
  • Names that already fit are reserved before any alias is assigned, so a canonical equal to another tool's base alias is never shadowed. toWire throws for a name outside the request set instead of lending it another tool's wire name.
  • The Responses continuation digest is computed over the same projected names it is checked against, so an aliased turn still replays. Claude sorts by canonical name before projecting, so the tools cache slot keeps its order.

Validation gaps

  • Previous-head regression proof: not verified in this environment.
  • The gemini and openrouter envelope is a fixed conservative guess; no credential was available to probe the live limit.

PR description

Accurate. One optional clarification: on the captured Notion catalog the 7 surviving tool characters are the shared prefix notion-, so the visible head is byte-identical across tools, not merely similar.

Pre-existing

None identified during the scoped review.

Next action

R1-M1 — Medium, follow-up, mcp-host/src/llm/toolWireNames.ts:49. Of the 77 captured Notion tools, 54 exceed 64 characters and all 54 get the wire head mcp-evenfire-notion-remote-v1-0-0-b234d024__notion-; only the 12-hex suffix differs. The annotation carries the full canonical name and is distinct for each, so the turn works, but the model has to copy a hash to call the right tool. Worth keeping a disambiguating slice of the tool name, or shortening the registry server name, before treating this alias as the long-term shape.

Full review archive: pr-reviews/pr-1445/r1-26-11-12.md

— Review performed by Evenfire Reviewer

@alfredolopez80

Copy link
Copy Markdown
Contributor

Blind review — PR 1445 (head 8e7931cdd, merge-base 61f32ae04)

No blocking defect found. All findings below are Low and introduced by this PR unless stated otherwise.

Findings

BR-L1 — The clerum__tool_call bridge rejects the alias the model is shown

  • mcp-host/src/llm/toolWireNames.ts:249-254 sends an over-envelope tool under its alias. mcp-host/src/core/orchestration/toolUseLoopToolBatch.ts:131 checks the bridge target name against deferrableCatalogNames, which holds only canonical names. fromWireResponse (toolWireNames.ts:279-292) rewrites only tool_calls[].name, never the bridge's arguments.name.
  • Scenario: dynamic tools are enabled, the catalog is under the threshold (the bridge latch is off and every MCP tool is listed), a tool's canonical name is over 64 characters, and the provider is OpenAI-family or Bedrock. The model calls clerum__tool_call({ name: "<alias from tools[]>" }) and gets Tool not available: "<alias>" is not in the current tool catalog. The tool never runs.
  • At the merge-base the listed name was the canonical name, so the same call resolved. The error is recoverable and the discovery text discourages bridging a listed tool, so this is Low.

BR-L2 — The documented alias shape does not hold for servers of 47–50 characters, and MAX_PREFIX_SERVER has no effect

  • toolWireNames.ts:50-51,83,97: HEAD_BUDGET = 51 cuts the head before the cap is consulted, so MAX_PREFIX_SERVER = 50 never changes the output. Mutating it to 51 or 63 leaves every test green: the mutation survives because it changes nothing, not because a test misses it.
  • Probe on the head (ToolWireNameMap, envelope W), with and without a forced collision suffix:
    • Server of 49–50 characters, base alias: no tool characters are kept (<S49>___<hash>, <S50>__<hash>).
    • Server of 47–48 characters with a suffix: no tool characters are kept (<S47>___<hash>_0, <S48>__<hash>_0).
    • Server of 49 characters with a suffix: the __ is lost (<S49>_<hash>_0).
    • Server of 50 characters with a suffix: the server is also cut to 49 characters (<S[0:49]>_<hash>_0).
  • Routing stays correct, because the lookup is exact and no alias claims a foreign server. The body still describes <server>__<tool head>_<12 hex> for every lowercase server of 50 characters or fewer, and no test pins these shapes.
  • Related readability cost on the 64-character envelopes: any server of 49 characters or more leaves zero tool characters, and the captured Notion catalog gives 54 tools the same visible head. Sibling tools differ only in the 12-hex hash, and only the description annotation tells them apart.

BR-L3 — The new logger call bypasses the logger's error redaction

  • mcp-host/src/core/tokenizer/anthropicTokenCounter.ts:98-101 logs error: (err as Error)?.message ?? String(err) as a plain string.
  • mcp-host/src/logger.ts:54-66 deliberately reduces Error values to name, code and status, because provider error text can carry payloads. AGENTS.md:369-370 asks for errors to be passed as { err }.
  • The text logged is the same as the old console.warn, so this adds no new exposure. But this PR wrote the logger.warn call, and the repository's logging rule requires it to keep the redaction.

BR-L4 — PR body and comment accuracy

  • The body says the Atlassian tools/list capture is still pending. mcp-host/src/llm/__tests__/fixtures/remoteMcpToolLists.ts:104-131 contains a 26-tool Atlassian capture, and two test files import it.
  • The regression check names merge-base 4fd7b2878; the current merge-base is 61f32ae04. We re-ran the check on 61f32ae04 and it still holds: the 22 provider-envelope tests and the Anthropic counter test fail there with assertion errors. "606 files / 8964 tests" does not say which head it was measured on.
  • The list of text-only methods omits ClaudeProvider.completeSingleTurn (claude.ts:160) and OpenAIProvider.completeSingleTurn (openai.ts:101). Both reduce messages to role and content, so the omission is harmless.
  • toolWireNames.ts:230-231 says only auto and none are sent. required is also a valid mode (core/types.ts:301), and remoteLlmHook.ts:199-203 sets it.
  • "T1 fixtures" (remoteMcpToolLists.ts:2) and the test title "P1/P2/P3/P3b/P5" (toolWireNames.property.test.ts:204) use labels that are defined nowhere in the repository.

BR-L5 — Test gaps

  • The envelope pass-through in ClaudeProvider.createTokenCounter is not pinned: removing it survives, because the counter defaults to W_c today.
  • A Responses continuation where the alias changes between turns (the accepted plain-replay cost) has no test. wireToolNames.edges.test.ts:235 covers only replay with the same alias.
  • The 58 pass-through cases in wireToolNames.providers.test.ts:151-186 assert only that names are unchanged. They pass on the merge-base and survive disabling the projection, so they need a witness that the same request did alias its over-long tool.
  • wireToolAlias (toolWireNames.ts:102) and the options.hash parameter have no production caller.

Recommended mini-plan

  1. BR-L1: in the decode path, while the request's ToolWireNameMap is still in scope, also resolve arguments.name of clerum__tool_call calls through fromWire. Add a provider-level test: an aliased tool called through the bridge reaches the canonical tool. Mutation check: removing the new mapping makes the test fail.
  2. BR-L2: make the shape explicit. Either derive the prefix cap from the budget, so the prefix form always keeps at least a few tool characters (for example MAX_PREFIX_SERVER = HEAD_BUDGET - 2 - <min tool chars> - <max suffix length>), or remove the cap and document the real shape. Pin the 47–50-character cases, with and without a suffix, in toolWireNames.property.test.ts. Readability for long registry server names (keeping a distinguishing slice of the tool name) can go to a follow-up issue.
  3. BR-L3: log { provider: 'claude', reason, err } and let the logger serializer redact it.
  4. BR-L4: correct the body (Atlassian capture, merge-base SHA and the head the counts were measured on, the full list of text-only methods), the tool_choice comment, and the undefined labels.
  5. BR-L5: add the ClaudeProvider counter-envelope test, an alias-change continuation test, and a positive aliasing witness to the pass-through matrix. Then either delete wireToolAlias and the hash option or document them as test seams.
  6. Re-run the five new test files and the src/llm and src/core/tokenizer suites on the new head.

zach88 and others added 7 commits November 12, 2026 15:02
The countTokens fallback logged `error: err.message`, a plain string, so the
structured logger's Error redaction (which reduces an Error to name, code and
status because provider error text can echo request payloads) never ran and
the raw provider message reached the log. Pass the Error itself as `err`.
redactUnknown recognises an Error under any key, so the field keeps only the
bounded classification. No test or consumer read the old `error` field
(`grep -rn "countTokens failed" mcp-host/src` -> only the log call).

Class-check: logger.{warn,error,info,debug} field built from raw error text
(`err.message`, `String(err)`, `errorText(err)`) in mcp-host/src, excluding
tests -> 8 sites: anthropicTokenCounter.ts:99, guardrailHookResolver.ts:180,
workflow/fonts.ts:138,187,193,486,849,949 -> fix anthropicTokenCounter.ts, the
only one this PR adds (`git diff $(git merge-base HEAD origin/dev)..HEAD`);
the other 7 predate the PR and are left untouched. The routes.ts hits of the
same grep are HTTP response bodies, not logger fields.
Repro-test: mcp-host/src/core/tokenizer/__tests__/anthropicTokenCounter.test.ts:47 (fails at 8e7931c: expected '{"provider":"claude","reason":"rate_l…' not to contain 'sk-ant-payload-secret')

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…labels

toWireRequest said only the `auto` and `none` tool_choice modes are sent;
`required` is a third mode (ToolCompletionRequest.tool_choice) that
remoteLlmHook forwards. None of the three names a tool, so the projection
still does not have to rewrite tool_choice; the comment now lists all three.

The property tests and the captured fixture carried labels (`P1`..`P8`,
`P3b`, `P3c`, `P6b`, "T1 fixtures") that point at documents outside the
repo. They are replaced by descriptions of what each test or assertion
checks. Comments and test names only: no logic changes, same 1407 cases in
src/llm + src/core/tokenizer.

Class-check: `git grep -nP '\b[PT][0-9]+[a-z]?\b|\b[PT][0-9]+(\.[0-9]+)+\b'`
over the files this PR touches in mcp-host/src/llm and src/core/tokenizer ->
22 lines: 17 in toolWireNames.property.test.ts, 1 in remoteMcpToolLists.ts
(all added by this PR, all replaced) and 4 in claude.ts (`T2.2`, `P1-006`),
which predate the PR and are left untouched -> 0 PR-added labels remain.
`tool_choice` mode lists: `grep -rn "auto.*none" mcp-host/src/llm/toolWireNames.ts`
-> 1 site, fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ounter

createTokenCounter passes `this.toolNameEnvelope()` to AnthropicTokenCounter,
but the counter defaults to `W_c`, the same envelope ClaudeProvider returns,
so dropping the pass-through left the suite green. No production subclass of
ClaudeProvider overrides toolNameEnvelope (the Vertex and Bedrock drivers
that do are separate classes and count with their own counters), so the
test uses a subclass that narrows the envelope to `W` and checks that the
counted tool names equal the names the same provider sends.

Class-check: `grep -rn "toolNameEnvelope" mcp-host/src` (non-test) -> the
only counter fed by a provider envelope is AnthropicTokenCounter via
ClaudeProvider.createTokenCounter (1 site); the tiktoken counters of openai
and azure get the profile envelope from the factory and are already pinned
per profile.
Mutation: replacing the pass-through with `{ ...options }` fails only
mcp-host/src/llm/__tests__/wireToolNames.edges.test.ts:125 (AssertionError:
expected [ Array(1) ] to deeply equal [ Array(1) ]); 1407 other tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cases

The three "tool calls coming back" cases that expect a name to come back
unchanged (an exact canonical, the canonical of an aliased tool, an unknown
name) asserted only that, so they also passed with the projection turned
off. Each now also asserts that, in the same request whose response it
checks, the long tool went out under an alias. The unknown-name case runs
over every direct provider, and `longA` fits the 128-character envelopes of
Claude and Vertex, so that case adds the 129-character tool to the request
as its witness; the other two run only on `W` providers, where `longA` is
aliased.

Class-check: `grep -n "toBe(shortB)\|toBe(longA)\|toBe('inventado_xyz')"
mcp-host/src/llm/__tests__/wireToolNames.providers.test.ts` -> 4 sites: the
3 pass-through cases (witness added) and the orphan-alias case, which
already asserts `alias !== longA` first.
Mutation: making toWireRequest return its inputs unchanged fails all 58
cases of the three tests (18 + 18 W providers, 22 direct providers), e.g.
wireToolNames.providers.test.ts:166 "expected
'mcp-evenfire-notion-remote-v1-0-0-b23…' not to be
'mcp-evenfire-notion-remote-v1-0-0-b23…'"; before this commit they passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est seams

`wireToolAlias` and the `options.hash` parameter of ToolWireNameMap have no
production caller: the property tests use the first as the expected wire
name of a name whose base alias is free, and the second to force alias
collisions with a weak hash. Both stay; a one-line comment on each says so,
so neither reads as dead code or as a production knob. Comments only.

Class-check: `grep -rn "wireToolAlias\|new ToolWireNameMap" mcp-host/src`
outside __tests__ -> wireToolAlias: 0 callers; ToolWireNameMap: 1 production
construction (toWireRequest), which passes no options. The other exports
with no caller outside the module (TOOL_NAME_ENVELOPES, ToolWireNameError,
ToolWireNameMap) are used by the module's own production path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… error

The redaction test threw a hand-built Error with `name: 'RateLimitError'`
and asserted that name in the log. The SDK (0.32.1) builds a 429 with
`APIError.generate`, which returns a RateLimitError instance whose `name`
is still 'Error', so the test pinned a field production never emits. It
now throws the error `Anthropic.APIError.generate(429, ...)` produces, with
the secret inside the provider body, and asserts `status: 429` and the
`name` the SDK actually sets.

Class-check: `git diff $(git merge-base HEAD origin/dev)..HEAD -- mcp-host/src
| grep "Object.assign(new Error"` -> 1 PR-added site (this test), fixed. The
other hand-built 429 in the same file ('falls back to heuristic × 1.5 on
error', line 40) predates the PR and asserts only the fallback count.
Repro-test: mcp-host/src/core/tokenizer/__tests__/anthropicTokenCounter.test.ts:48 (fails at 8e7931c: expected '{"provider":"claude","reason":"rate_l…' not to contain 'sk-ant-payload-secret')

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… list

The witness added in BR-R3-L5c, `expect(sentNames(id, body)[i]).not.toBe(X)`,
held vacuously when the request carried no tools: wireToolDecls returns
[], the element is undefined, and undefined is not X. A shared helper now
also asserts the number of tools sent and that the witnessed name fits the
provider's envelope before checking it differs from the canonical.

Class-check: `grep -n "sentNames(id, body)\[" mcp-host/src/llm/__tests__/wireToolNames.providers.test.ts`
-> 3 sites (the three pass-through cases) -> all moved to the helper. The
orphan-alias case reads `first.sent[0].name`, which throws on an empty list
instead of passing, so it is not part of the class.
Mutation: with toWireRequest returning `tools: []`, the three tests fail in
all 58 cases ("expected [] to have a length of 2 but got +0"); the previous
version of the file passed all 58. With the projection turned off (inputs
returned unchanged) both versions fail all 58.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The structured logger reduces an Error to name/code/status and keeps `name`
only when it fits a bounded identifier shape. Every redaction test fed a
plain Error, and the real SDK 429 now used by the token-counter test also
has `name === 'Error'`, so replacing the name with a constant 'Error'
survived the whole suite. Two cases go through the public logger's write
path: a real TypeError must log `name: 'TypeError'` without its message, and
a name outside the shape must fall back to 'Error' without leaking.

Class-check: `grep -rn "name: 'Error'\|err: {"` over the test files that use
the logger or redactUnknown -> 2 redactor assertions before this commit
(logger.test.ts:28, plain Error; anthropicTokenCounter.test.ts:77, SDK error
whose name is 'Error') -> both left as they are; they cover code/status
and the counter path, and the new cases cover the name branch.
Repro-test: mcp-host/src/logger.test.ts:53 and :59 pass at 8e7931c (the
branch already worked), so the regression check is by mutation: logger.ts:60
`name: 'Error'` fails :53 ("expected { err: { name: 'Error' }, …(3) } to
match object { err: { name: 'TypeError' }, …(1) }"); dropping the fallback
(always `error.name`) fails :59 ("expected '{"err":{"name":"Type Error
carrying s…' not to contain 'secret-name-value'"). Both restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@alfredolopez80 alfredolopez80 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved on head 8e7931c. No blocking defects; the Low findings and the recommended mini-plan are in the blind-review comment above.

@zach88
zach88 merged commit 6751294 into dev Nov 12, 2026
64 checks passed
@zach88
zach88 deleted the fix/mcp-host-tool-wire-names branch November 12, 2026 23:29
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.

4 participants