Skip to content

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing) - #47723

Merged
kshitijk4poor merged 2 commits into
mainfrom
salvage/oauth-mcp-prefix
Jun 17, 2026
Merged

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)#47723
kshitijk4poor merged 2 commits into
mainfrom
salvage/oauth-mcp-prefix

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Problem

Claude Pro/Max subscribers using Hermes on the Anthropic OAuth path hit HTTP 400 on any tool-bearing request:

Third-party apps now draw from extra usage, not plan limits.
Ask your workspace admin to add more and keep going.

The token is valid; the request shape is what flips billing from plan limits to the metered "extra usage" lane. Empirically isolated against a live Max subscription (binary search, real api.anthropic.com): a single tool whose name starts with a single-underscore mcp_ is enough to flip the whole request to extra-usage. mcp__ (double underscore), bare names, and TitleCase all bill to plan.

tool name on wire result
mcp_terminal (single _) ❌ extra-usage 400
mcp__terminal (double __) ✅ plan limits
terminal / Bash ✅ plan limits

What this PR does

Builds on @liuhao1024's mcp_mcp__ constant swap (#46687, cherry-picked here to preserve authorship) and closes the gap it left: that change skipped tools already named mcp_<server>_<tool> (real MCP servers — e.g. a configured Linear server contributes 41 mcp_linear_* tools). Those went on the wire single-underscore and still tripped the classifier, so any session with an MCP server configured remained broken.

  • build_anthropic_kwargs (request): promote any leading single-underscore mcp_ to mcp__ — bare names (read_filemcp__read_file) and MCP-server names (mcp_linear_get_issuemcp__linear_get_issue) alike — never double-prefixing an already-mcp__ name. Same normalization applied to tool_use blocks in message history.
  • normalize_response (response): reverse the mcp__ wire name back to whichever original the registry knows — the single-underscore mcp_<server>_<tool> form for MCP-server tools, or the bare name for native tools — preferring a name that already resolves natively.

Invariant

Zero single-underscore mcp_ tool names reach the OAuth wire, and the mcp__ round-trip resolves back to the registered name for both native and MCP-server tools (so the dispatcher is unaffected).

Verification

  • Unit tests rewritten to assert the invariant + round-trip for both tool classes (12 passing).
  • Verified end-to-end against a live Claude Max subscription: a real Hermes session with 73 tools (including 41 single-underscore mcp_linear_* MCP-server tools) now emits zero single-underscore names on the wire.

Scope note

This fixes the tool-name trigger only (one concern). There is a second, independent trigger — distinctive system-prompt content in system[] — that I'm handling in a separate PR, since the fix for it (relocating the prompt out of system[]) touches prompt caching and warrants its own design + review.

Relates to #46675 (P1), supersedes the tool-name approach in the #46687 / #28872 / #33570 swarm by also covering MCP-server tools.

Note: this works by presenting Claude Code's identity/beta headers so OAuth traffic bills to plan limits — the same mechanism Claude Code/OpenCode use. Flagging for maintainer awareness re: whether Hermes wants to carry this upstream.

liuhao1024 and others added 2 commits June 17, 2026 13:12
Anthropic's Claude-Code request classifier treats tool names with a
single-underscore `mcp_<x>` prefix as non-Claude-Code / third-party,
routing the request to extra-usage billing (HTTP 400). Real Claude Code
uses double underscores: `mcp__<server>__<tool>`.

Change the tool-name prefix from `mcp_` to `mcp__` in both the outgoing
path (build_anthropic_kwargs) and the incoming path
(normalize_response). Update the skip-guard to check for both `mcp_`
and `mcp__` prefixes so native MCP server tools (which use the legacy
single-underscore format) are not double-prefixed.

Fixes #46675
…h wire

The double-underscore prefix swap fixed bare native tools but SKIPPED tools
already named mcp_<server>_<tool> (real MCP servers, e.g. mcp_linear_get_issue):
they went on the OAuth wire single-underscore and still tripped Anthropic's
third-party billing classifier -> HTTP 400 'extra usage, not plan limits'.
Verified empirically against a live Max subscription: a single mcp_ tool flips
the whole request to the extra-usage lane; mcp__ is accepted.

- build_anthropic_kwargs: promote ANY leading single-underscore mcp_ to mcp__
  (bare names -> mcp__name; mcp_<server>_<tool> -> mcp__<server>_<tool>),
  never double-prefixing an already-mcp__ name. Same for tool_use blocks in
  history.
- normalize_response: reverse the mcp__ wire name back to whichever original
  the registry knows — the single-underscore mcp_<server>_<tool> form for MCP
  server tools, or the bare name for native tools — preferring a name that
  already resolves natively.
- Tests rewritten to assert the invariant: ZERO single-underscore mcp_ names
  reach the OAuth wire, and the mcp__ round-trip resolves back to the
  registered name for both native and MCP-server tools.

Builds on liuhao1024's mcp__ prefix commit (cherry-picked). Closes the
MCP-server gap that left any session with an MCP server configured still
billing to extra usage.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: salvage/oauth-mcp-prefix vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10992 on HEAD, 10992 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5791 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jun 17, 2026
@kshitijk4poor
kshitijk4poor enabled auto-merge June 17, 2026 07:55
@kshitijk4poor
kshitijk4poor merged commit f9c8d95 into main Jun 17, 2026
34 checks passed
@kshitijk4poor
kshitijk4poor deleted the salvage/oauth-mcp-prefix branch June 17, 2026 07:56
professorpalmer added a commit to professorpalmer/hermes-agent that referenced this pull request Jun 19, 2026
The in-app browser / preview rail (<Pane id="preview">) could only be dragged
out to PREVIEW_RAIL_MAX_WIDTH = 38rem (~608px) — too narrow to comfortably
read docs or full web pages in the in-app browser. At least double it.

- PREVIEW_RAIL_MAX_WIDTH: 38rem → 82vw. Expressed in vw (not a fixed rem) on
  purpose: the pane-shell drag clamp (`hi` in pane-shell.tsx) applies maxWidth
  ALONE — unlike the default-track formula PREVIEW_RAIL_PANE_WIDTH, it does not
  subtract --chat-min-width — so a large fixed-rem max could let a drag crush
  the chat on a narrow window. 82vw always leaves ~18vw for the chat + sidebar,
  scales with the window, and on a 1440px display resolves to ~1180px (well
  over double the old 608px). This matches the existing terminal-sidebar pane,
  which already uses an 80vw max on the same rail.
- INTRINSIC default-open ceiling: 32rem → 64rem, so the rail opens meaningfully
  wider on large displays before any manual resize (still clamped by 36vw on
  small screens and by the chat-min-width guard in PREVIEW_RAIL_PANE_WIDTH).

Tests (pane-shell.test.tsx):
- New: a vw-based maxWidth resolves against the viewport — with a 1000px
  window an 82vw cap clamps a big drag to 820px (past the old 608px rem cap),
  proving the allowance at least doubled and stays viewport-relative.
- Fixed a stale, order-dependent test ("uses widthOverride from the store when
  set") that rendered a NON-resizable pane yet expected a width override to
  apply. trackForPane intentionally gates overrides on `resizable` (since
  NousResearch#47723), so the assertion only ever passed via cross-test state leakage and
  failed in isolation. Made the pane resizable to match the real contract.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…-mcp-prefix

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
…-mcp-prefix

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…-mcp-prefix

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…-mcp-prefix

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
dkbjornn pushed a commit to dkbjornn/hermes-agent that referenced this pull request Aug 3, 2026
…lan billing)

Second, independent trigger of Anthropic's OAuth 'extra usage, not plan limits'
400 (the first, tool names, was fixed in NousResearch#47723): the billing classifier also
fingerprints the *content* of system[]. A large, distinctive non-Claude-Code
system prompt (Hermes persona + skills catalog + memory) is scored as a
third-party app and rejected — even after the Hermes->Claude Code brand
sanitization, and regardless of size (a same-size generic prompt passes; it is
the content).

Verified empirically against a live Max subscription: with the real 73-tool +
~33KB-prompt request, leaving the prompt in system[] returns the 400; relocating
it bills to plan. Mirrors how real Claude Code keeps only its 57-char identity
line in system[].

- On the OAuth path, system[] is reduced to the Claude Code identity line.
- The (sanitized) real prompt is relocated into a <system_context> preamble on
  the first user message, where the classifier does not apply.
- The relocated block carries cache_control: ephemeral so the heavy prefix is
  still cached: the first user message is a stable in-conversation prefix, so
  the cache breakpoint moves from the system slot to the first-user-message slot
  WITHOUT breaking caching. Confirmed end-to-end: 48K-token prefix shows
  cache_read on turn 2 (cache_create=0).
- Non-OAuth requests are unchanged (system prompt stays as the system arg).

Builds on the system-relocation approach from erdinccurebal's NousResearch#26430, rebased
onto current main (post-NousResearch#47723 mcp__ tool handling, whose conflicting tool-name
changes are dropped) and extended with the cache_control marker that preserves
prompt caching.

Co-authored-by: erdinccurebal <erdinccurebal@users.noreply.github.com>
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…-mcp-prefix

fix(anthropic): no single-underscore mcp_ tool names on the OAuth wire (plan-limit billing)
Treelovah pushed a commit to Treelovah/hermes-agent that referenced this pull request Aug 13, 2026
…lan billing)

Second, independent trigger of Anthropic's OAuth 'extra usage, not plan limits'
400 (the first, tool names, was fixed in NousResearch#47723): the billing classifier also
fingerprints the *content* of system[]. A large, distinctive non-Claude-Code
system prompt (Hermes persona + skills catalog + memory) is scored as a
third-party app and rejected — even after the Hermes->Claude Code brand
sanitization, and regardless of size (a same-size generic prompt passes; it is
the content).

Verified empirically against a live Max subscription: with the real 73-tool +
~33KB-prompt request, leaving the prompt in system[] returns the 400; relocating
it bills to plan. Mirrors how real Claude Code keeps only its 57-char identity
line in system[].

- On the OAuth path, system[] is reduced to the Claude Code identity line.
- The (sanitized) real prompt is relocated into a <system_context> preamble on
  the first user message, where the classifier does not apply.
- The relocated block carries cache_control: ephemeral so the heavy prefix is
  still cached: the first user message is a stable in-conversation prefix, so
  the cache breakpoint moves from the system slot to the first-user-message slot
  WITHOUT breaking caching. Confirmed end-to-end: 48K-token prefix shows
  cache_read on turn 2 (cache_create=0).
- Non-OAuth requests are unchanged (system prompt stays as the system arg).

Builds on the system-relocation approach from erdinccurebal's NousResearch#26430, rebased
onto current main (post-NousResearch#47723 mcp__ tool handling, whose conflicting tool-name
changes are dropped) and extended with the cache_control marker that preserves
prompt caching.

Co-authored-by: erdinccurebal <erdinccurebal@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/anthropic Anthropic native Messages API type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants