Skip to content

fix(anthropic): normalize forced tool_choice name to mcp__ on the OAuth wire - #48192

Open
ly-wang19 wants to merge 1 commit into
NousResearch:mainfrom
ly-wang19:fix/anthropic-oauth-tool-choice-mcp-prefix
Open

fix(anthropic): normalize forced tool_choice name to mcp__ on the OAuth wire#48192
ly-wang19 wants to merge 1 commit into
NousResearch:mainfrom
ly-wang19:fix/anthropic-oauth-tool-choice-mcp-prefix

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #25255 / b70a4e753 ("normalize MCP-server tool names to mcp__ on OAuth wire").

Under is_oauth=True, build_anthropic_kwargs rewrites every tool name to the double-underscore mcp__ wire form so it clears Anthropic's subscription/OAuth billing classifier — which treats a single-underscore mcp_ name as a third-party-app fingerprint and rejects the request with HTTP 400. That rename is applied to tools[] and to replayed tool_use history:

# under `if is_oauth:`
for tool in anthropic_tools:
    tool["name"] = _to_oauth_wire_name(tool["name"])   # read_file -> mcp__read_file
# ... and tool_use history blocks likewise

But the forced tool_choice mapping is a sibling site that was left on the raw caller name:

elif isinstance(tool_choice, str):
    kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}   # NOT normalized

So any OAuth request that forces a specific tool breaks two ways:

  1. HTTP 400 (name mismatch). tools[] becomes mcp__read_file but tool_choice.name stays read_file. The Messages API requires tool_choice.name to match a name present in tools[], so the request is rejected.
  2. Classifier-fingerprint leak. With an MCP-server tool (tool_choice="mcp_linear_get_issue"), the single-underscore mcp_ name reaches the OAuth wire via tool_choice — the exact invariant this transform exists to enforce ("ZERO single-underscore mcp_ names on the wire").

Reachable through auxiliary_client.py / the Anthropic transport, which forward a caller-supplied tool_choice (incl. the OpenAI {"type":"function","function":{"name":…}} form, normalized to a bare string) together with is_oauth.

Fix

Hoist _to_oauth_wire_name from a closure to module scope and apply it to the forced name when is_oauth:

forced_name = _to_oauth_wire_name(tool_choice) if is_oauth else tool_choice
kwargs["tool_choice"] = {"type": "tool", "name": forced_name}

The non-OAuth path is untouched.

Tests

Added to TestAnthropicOAuthOutgoingPrefix in tests/agent/test_anthropic_mcp_prefix_strip.py:

  • test_oauth_tool_choice_matches_renamed_bare_toolread_filetool_choice.name == "mcp__read_file" and equals the tools[] entry;
  • test_oauth_tool_choice_promotes_single_underscore_mcpmcp_linear_get_issuemcp__linear_get_issue, no single-underscore on the wire;
  • test_non_oauth_tool_choice_untouched — non-OAuth keeps the bare name.

The two OAuth cases fail without the fix; all 12 existing prefix tests still pass.

@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 duplicate This issue or pull request already exists labels Jun 18, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Duplicate of #23361 — that open PR makes the identical fix at the same site in agent/anthropic_adapter.py: under is_oauth, the forced elif isinstance(tool_choice, str): branch is normalized via _to_oauth_wire_name(tool_choice) if is_oauth else tool_choice, with matching tests for read_file -> mcp__read_file and mcp_linear_get_issue -> mcp__linear_get_issue. Same code path, same approach, same invariant ("no single-underscore mcp_ on the OAuth wire"). #23361 is the earlier open canonical (created 2026-05-10). Consolidating there.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approved

Normalizes forced tool_choice name to mcp__ prefix on the OAuth wire in the Anthropic adapter.

Looks Good

  • Clean, well-scoped fix
  • Consistent naming convention

Reviewed by Hermes Agent

@ly-wang19

Copy link
Copy Markdown
Contributor Author

Thanks @alt-glitch — you're right that #23361 (by @aldoeliacim, opened earlier) makes the same core change: lifting _to_oauth_wire_name to module scope and applying it to the forced tool_choice name under OAuth. I arrived at the same fix independently since it's the natural one for this site.

The one thing this PR adds on top is a dedicated regression test for the forced-tool_choice path specifically (tests/agent/test_anthropic_mcp_prefix_strip.py), which #23361 doesn't cover.

I'm happy to defer to #23361 and close this if maintainers prefer to land that one — or, if it's easier, I can port my regression test over to #23361 so the fix lands with coverage. Whichever keeps it moving; just let me know.

@Mohit-Ak

Copy link
Copy Markdown

Confirming this gap is real and complementary to #47738 (system-prompt relocation), not a duplicate.

With is_oauth=True, build_anthropic_kwargs rewrites tools[] names to the mcp__ wire form, but a forced tool_choice passed as a concrete name string is emitted raw:

elif isinstance(tool_choice, str):
    kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}   # raw — no mcp__ normalization

On the OAuth wire that name no longer matches the encoded entry in tools[], so Anthropic 400s (tool_choice not among tools). Normalizing it through the same _to_oauth_wire_name transform (gated on is_oauth) is the correct fix.

Verified the rest of the OAuth path on a live Max subscription while testing #47738 (opus-4-8 ran tools, billed to plan limits). This tool_choice edge is the remaining piece for forced-tool calls on the subscription route. 👍

@teknium1

Copy link
Copy Markdown
Contributor

Verified a real current-main OAuth wire-shape defect. agent/anthropic_adapter.py:2581 renames OAuth tools[] entries, but agent/anthropic_adapter.py:2614-2616 emits a concrete forced tool_choice name unchanged. agent/auxiliary_client.py:1271-1285 can supply exactly that concrete name while forwarding is_oauth.

The proposed change reuses the existing idempotent encoder, retains the non-OAuth path unchanged, and the added tests assert the relevant relationship between tool_choice.name and the rewritten tools[] entry. The patch is narrowly scoped and matches the existing OAuth normalization introduced in b70a4e753.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 14, 2026
@alt-glitch alt-glitch removed the duplicate This issue or pull request already exists label Jul 14, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Re-triage: removing the duplicate label. The cited anchor #23361 is closed (unmerged), so this is not a duplicate of it — this is the only open PR making the forced-tool_choice OAuth-wire normalization fix. Related to #23361 (closed predecessor, same core change) and #47738 (system-prompt relocation, complementary per @Mohit-Ak).

@alt-glitch alt-glitch added area/billing Account usage, credit usage, billing (cross-cutting) and removed area/auth Authentication, OAuth, credential pools sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 14, 2026

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This was generated by AI during triage.

Summary

Three PRs address the Anthropic OAuth tool-name billing failure or its remaining forced-tool edge. #33570 proposed stripping prefixes plus broader payload restrictions, #47723 implemented the current mcp__ wire normalization and registry-backed round trip, and #48192 applies that same normalization to forced tool_choice names.

Related pull requests

  • #33570 [closed] related — (+608/-44) — superseded: This broader alternative stripped mcp_, filtered tools, capped schemas and system prompts, and rewrote history, but merged #47723 fixed the reported classifier trigger with consistent mcp__ normalization; it remains relevant as the superseded investigation, while its environment-based behavioral setting also conflicts with repository configuration policy.
  • #47723 [merged] related — (+144/-108) — merged reference implementation: It fixes the root cause by ensuring neither native nor MCP-server tools retain a single-underscore mcp_ prefix on the OAuth wire, and reverses mcp__ names through registry lookup on response.
  • #48192 related — (+75/-10) — merge: It closes the sibling-site gap left by #47723 by applying the same idempotent mcp__ encoder to forced tool_choice, preventing both a tools[] name mismatch and a single-underscore classifier leak while preserving non-OAuth behavior. This matches the visible keep_open review on #48192, which verified the defect on current main and the reachable caller path; the earlier same-core predecessor #23361 is closed unmerged.

Suggested consolidation

Merge #48192 as the narrow, tested follow-up to merged #47723. Keep #33570 closed as superseded by #47723; no listed open PR should be closed as a duplicate, because #48192 fixes the distinct forced-tool_choice site that #47723 did not cover.

Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 68 kB of PR diffs, 9 kB of issue/PR text, 5 kB of discussion (8 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@ly-wang19
ly-wang19 force-pushed the fix/anthropic-oauth-tool-choice-mcp-prefix branch from 724bcf5 to 3e6de3e Compare July 31, 2026 15:53
@ly-wang19

Copy link
Copy Markdown
Contributor Author

Heads up on the red check: the only failing job is Python tests / slice 4/8, and the failure is unrelated to this PR.

FAILED tests/hermes_cli/test_update_eol_churn.py::test_churn_across_more_files_than_fit_in_one_argv
assert len(_dirty(repo)) == len(files)   # AssertionError: assert 733 == 1200

That assertion is in the test's setup (before _normalize_managed_eol — the code under test — is even called). It counts how many of 1200 checked-out CRLF files git diff --name-only reports as dirty against an LF index, which is sensitive to git's racy-clean stat-cache timing on the runner (733 isn't an argv limit, it's a timing artifact). This PR only touches agent/anthropic_adapter.py and its own test — there is no code path to the EOL-normalization helper, so the failure reproduces independently of this change. The 7 other test slices and all lint/attribution/supply-chain checks pass. Happy to open a separate PR to de-flake that setup assertion if useful.

…th wire

build_anthropic_kwargs rewrites every tool name to the double-underscore mcp__
form under is_oauth — for tools[] and replayed tool_use history — to clear
Anthropic's OAuth billing classifier (a single-underscore mcp_ name is treated
as a third-party-app fingerprint and rejected with HTTP 400; NousResearchGH-25255). The
forced tool_choice mapping was the one sibling site left on the raw caller
name, so any OAuth request that forces a specific tool:

  - sends tool_choice.name="read_file" while tools[] holds "mcp__read_file" — a
    mismatch the Messages API rejects with HTTP 400; and
  - with an MCP-server tool (mcp_linear_get_issue), leaks the single-underscore
    mcp_ name onto the wire, the exact fingerprint the transform removes.

Hoist _to_oauth_wire_name to module scope and apply it to the forced name when
is_oauth. The non-OAuth path is unchanged. Adds 3 tests; the two OAuth cases
fail without the fix.
@ly-wang19
ly-wang19 force-pushed the fix/anthropic-oauth-tool-choice-mcp-prefix branch from 3e6de3e to f01a1d6 Compare August 12, 2026 03:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing Account usage, credit usage, billing (cross-cutting) 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 sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants