Skip to content

fix(mcp): expose Anthropic-safe alias for MCP tool names#1134

Merged
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
johnkattenhorn:fix/mcp-anthropic-safe-tool-names
May 21, 2026
Merged

fix(mcp): expose Anthropic-safe alias for MCP tool names#1134
Aaronontheweb merged 3 commits into
netclaw-dev:devfrom
johnkattenhorn:fix/mcp-anthropic-safe-tool-names

Conversation

@johnkattenhorn

Copy link
Copy Markdown
Contributor

Closes #1133.

Summary

Anthropic's API enforces tool-name regex ^[a-zA-Z0-9_-]{1,128}$, which rejects the / that McpToolAdapter uses to namespace MCP tools as {server}/{tool}. Once the LLM tool list contains an MCP tool, every turn fails:

tools.NN.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'

This patch adds an Anthropic-safe alias on McpToolAdapter ({server}__{tool}, double-underscore boundary) and surfaces that to the LLM via SanitizedAIFunction.Name. The canonical Name stays {server}/{tool} so skill text, registry keys, and existing operator workflows that reference server/tool keep working. ToolRegistry.GetByName / GetRegistrationByToolName accept either form so tool_use responses (which now arrive with the sanitized name) dispatch correctly.

Why __ (double underscore) and not single

MCP tool names already use single underscores (e.g. find_completed_tasks, get_gmail_messages_content_batch). A single-underscore boundary would make the server/tool split ambiguous when consumers want to parse the name back into its parts. Double underscore is unambiguous, fits the regex, and keeps a visual server prefix.

Verification

Pre-patch on 0.20.0: weekly-report skill dies at LLM call with the BadRequest above as soon as Todoist tools are loaded (reproduced repeatedly).

Post-patch: LLM receives todoist__find-tasks; request succeeds; tool dispatches via GetByName matching on the sanitized alias.

Test plan

  • Existing McpToolAdapter / ToolRegistry unit tests still pass
  • Sessions integration test that exercises an MCP tool round-trip succeeds with the alias path
  • Manual: weekly-report skill end-to-end with at least one MCP tool loaded (live Anthropic API)

🤖 Generated with Claude Code

johnkattenhorn and others added 3 commits May 21, 2026 08:25
…claw-dev#1133

Anthropic's API enforces tool-name regex ^[a-zA-Z0-9_-]{1,128}$, which
rejects the '/' that McpToolAdapter uses to namespace MCP tools as
{server}/{tool}. Once the LLM tool list contains an MCP tool, every
turn fails with:

  tools.NN.custom.name: String should match pattern '^[a-zA-Z0-9_-]{1,128}$'

Adds an Anthropic-safe alias on McpToolAdapter (`{server}__{tool}`,
double underscore boundary) and surfaces THAT to the LLM via
SanitizedAIFunction.Name. The canonical Name stays `{server}/{tool}`
so skill text, registry keys, and existing operator workflows that
reference `server/tool` keep working. ToolRegistry.GetByName /
GetRegistrationByToolName accept either form so tool_use responses
(which now arrive with the sanitized name) dispatch correctly.

Verified locally: pre-patch, every weekly-report run died at LLM call
with the BadRequest above as soon as a Todoist tool was loaded;
post-patch, the LLM receives `todoist__find-tasks` and the request
succeeds.
Update three tests whose assertions were pinned to the old slashed
LLM-facing tool names, and add positive coverage for the new alias:

- McpToolAdapterTests.ToAITool_ReturnsSanitizedWrapper: expect
  memorizer__store from the AIFunction wrapper while pinning the
  canonical adapter.Name to memorizer/store.
- McpToolAudienceGrantsTests.FilterExposedTools_RemovesToolsBlockedByGrants:
  expect sanitized name in the filtered AIFunction output.
- LlmSessionIntegrationTests.Discovered_tools_are_retained_then_expire_after_lease_window:
  ReceivedToolNames now records the sanitized alias.

New tests:
- SanitizedName_uses_double_underscore_separator pins the alias format.
- SanitizedName_matches_Anthropic_tool_name_regex (theory) pins the
  documented ^[a-zA-Z0-9_-]{1,64}$ contract so future regressions fail
  loudly.
- ToolRegistry.GetByName / GetRegistrationByToolName now have explicit
  bidirectional-lookup coverage (canonical and sanitized both resolve
  to the same registration).
@Aaronontheweb Aaronontheweb added mcp Model context protocol server / client issues. bug Something isn't working labels May 21, 2026

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

/// <c>{server}__{tool}</c>. Surfaced to the LLM in tool definitions so the
/// Anthropic API does not reject the request for invalid tool-name chars.
/// </summary>
public string SanitizedName { get; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yeah this is documented in their tooling guidance here https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools

@Aaronontheweb Aaronontheweb merged commit 94820ec into netclaw-dev:dev May 21, 2026
14 checks passed
Aaronontheweb added a commit that referenced this pull request May 22, 2026
* chore: release 0.20.1

* fix(mcp): look up approval grants by canonical tool name

PR #1134 introduced an Anthropic-safe sanitized alias (`server__tool`)
for MCP tool names. The LLM now emits tool_use with the sanitized form,
but ToolAccessPolicy builds the approval context — and
LlmSessionActor.PersistApprovalCandidatesAsync records the grant —
under the canonical `server/tool`. DispatchingToolExecutor.AuthorizeCoreAsync
was still calling _approvalService.CheckApprovalAsync with the
sanitized form (toolCall.Name), so every post-approval retry missed
the recorded grant and re-threw ToolApprovalRequiredException.

In production this surfaced as "I encountered an error executing a tool"
loops on Notion writes: user approves the session, sees the canonical
name in the Slack prompt, click registers — but the next attempt fails
the gate immediately on the same session.

Switch the lookup to tool.Name (canonical, already resolved via the
registry's two-form GetByName) so the storage and lookup keys agree.

Regression test:
Mcp_session_approval_recorded_under_canonical_name_authorizes_sanitized_alias_retry
mirrors the production flow — LLM-side sanitized alias on the
FunctionCallContent, canonical-name RecordApprovalAsync, and the retry
that must pass the gate. Also covers the canonical-name dispatch path
so the registry's both-form acceptance stays exercised.

* fix(mcp): drop duplicated comment block above approval lookup

Self-inflicted duplication from a revert + re-apply during testing.
Same comment appeared twice above the CheckApprovalAsync call.
Aaronontheweb added a commit to Aaronontheweb/netclaw that referenced this pull request May 22, 2026
PR netclaw-dev#1134 created two name forms for MCP tools (canonical `server/tool`
for everything internal, sanitized `server__tool` for the LLM wire)
but stored both as bare strings. The follow-on bug (netclaw-dev#1147) showed
how easy it is to grab the wrong one — the approval lookup keyed on
sanitized while the grant store keyed on canonical, no compiler signal.

Make the LLM-facing form a type so misuse is a build error, not a
runtime regression:

- New `LlmFacingToolName` value object in Netclaw.Tools.Abstractions.
  `FromCanonical(string)` sanitizes `/` -> `__` and asserts the result
  satisfies Anthropic's tool-name regex; throws for anything that
  can't be expressed (spaces, dots, colons, >128 chars). No implicit
  string conversion — per the constitution rule on value objects.
- `INetclawTool.LlmFacingName` becomes a required member. Renames
  the documented intent of `Name` to "canonical, operator-facing"
  (the prior doc lied for MCP tools).
- NetclawToolGenerator emits a static readonly `_generatedLlmFacingName`
  via `FromCanonical`, so first-party tools with malformed names fail
  at type-init (i.e. at registry warm-up on daemon start) rather than
  at the Anthropic API boundary on the user's first call.
- McpToolAdapter swaps its `SanitizedName` string for `LlmFacingName`.
  `SanitizedAIFunction` is constructed from `LlmFacingName.Value`.
- ToolRegistry's inner `AIToolAdapter` and the two hand-written test
  fakes (FakeNetclawTool, RecordingContextTool) implement the new
  member; the registry's two-form `FindRegistration` now matches
  against `LlmFacingName.Value`.

No behavioral changes — every call site that previously read
`SanitizedName` now reads `LlmFacingName.Value` and gets the same
string. This is foundation for the follow-up cleanup that pushes the
canonical-vs-LLM-facing distinction out to the actual boundary
(LlmSessionActor ingress/egress) instead of having every consumer
guess which form to use.

586 Tools/Approval/Mcp tests pass; slopwatch clean; headers verified.
Aaronontheweb added a commit that referenced this pull request May 22, 2026
…rced (#1153)

* refactor(mcp): introduce LlmFacingToolName value object

PR #1134 created two name forms for MCP tools (canonical `server/tool`
for everything internal, sanitized `server__tool` for the LLM wire)
but stored both as bare strings. The follow-on bug (#1147) showed
how easy it is to grab the wrong one — the approval lookup keyed on
sanitized while the grant store keyed on canonical, no compiler signal.

Make the LLM-facing form a type so misuse is a build error, not a
runtime regression:

- New `LlmFacingToolName` value object in Netclaw.Tools.Abstractions.
  `FromCanonical(string)` sanitizes `/` -> `__` and asserts the result
  satisfies Anthropic's tool-name regex; throws for anything that
  can't be expressed (spaces, dots, colons, >128 chars). No implicit
  string conversion — per the constitution rule on value objects.
- `INetclawTool.LlmFacingName` becomes a required member. Renames
  the documented intent of `Name` to "canonical, operator-facing"
  (the prior doc lied for MCP tools).
- NetclawToolGenerator emits a static readonly `_generatedLlmFacingName`
  via `FromCanonical`, so first-party tools with malformed names fail
  at type-init (i.e. at registry warm-up on daemon start) rather than
  at the Anthropic API boundary on the user's first call.
- McpToolAdapter swaps its `SanitizedName` string for `LlmFacingName`.
  `SanitizedAIFunction` is constructed from `LlmFacingName.Value`.
- ToolRegistry's inner `AIToolAdapter` and the two hand-written test
  fakes (FakeNetclawTool, RecordingContextTool) implement the new
  member; the registry's two-form `FindRegistration` now matches
  against `LlmFacingName.Value`.

No behavioral changes — every call site that previously read
`SanitizedName` now reads `LlmFacingName.Value` and gets the same
string. This is foundation for the follow-up cleanup that pushes the
canonical-vs-LLM-facing distinction out to the actual boundary
(LlmSessionActor ingress/egress) instead of having every consumer
guess which form to use.

586 Tools/Approval/Mcp tests pass; slopwatch clean; headers verified.

* refactor(mcp): canonicalize tool names at the LLM-actor boundary

Adds the inbound/outbound seam that pushes the canonical-vs-LLM-facing
distinction out to the actor boundary, so internal consumers
(audit log, ToolCallOutput events, duplicate-tool fingerprint,
persisted assistant message, memory ledger) stop guessing which form
to use.

Inbound (LlmSessionActor.HandleToolCallResponse): rewrites every
FunctionCallContent in the incoming AiChatMessage and the toolCalls
list from the LLM-facing alias (`server__tool`) to the canonical
name (`server/tool`) before any consumer reads `tc.Name`. The
persisted SerializableChatMessage that flows into history therefore
carries the canonical form. Tools that don't resolve (unknown name)
pass through unchanged and the executor rejects them downstream as
it did before.

Outbound (SessionMessageAssembler.Assemble -> ChatMessageConverter.
ToAiMessages): an optional `toolNameResolver` Func is threaded into
the converter so it maps the persisted canonical name back to the
LLM-facing alias when reconstructing FunctionCallContent for the
wire. Without this hop Anthropic returns 400 on the `/` character.
The session actor passes `_toolRegistry.ToLlmFacingName`; tests and
internal re-drive paths leave the resolver null and get the persisted
name back unchanged.

Two new ToolRegistry helpers — `ToCanonicalName` and
`ToLlmFacingName` — wrap the registry's existing two-form lookup.
First-party tool names round-trip as identity in both directions
(name is already LLM-safe by construction).

Tests:
- ToolRegistry: round-trip and idempotence for MCP and first-party.
- ChatMessageConverter: resolver applied / not applied paths.
- Full Actors test suite (1956 tests) still green.

Behavioral consequence: for MCP tools the audit log, ToolCallOutput
event stream, duplicate-tool nudge, and memory ledger now record
`notion/notion-create-pages` instead of `notion__notion-create-pages`
— matching what the operator sees in the Slack approval prompt and
the persisted approval grant. No change for first-party tools.

* refactor(mcp): align LLM-facing surfaces with the wire form

search_tools / load_tool / discovered-tool activation all surfaced
the canonical name (server/tool) while the LLM emits and receives
the sanitized alias (server__tool) in tool definitions and tool_use
callbacks. The model saw the same tool under two distinct strings
within a single turn — confusing for weaker models, and a cross-
reference hazard between the system-prompt index, the search_tools
suggestion list, the load_tool result, and the tool definitions
Anthropic actually returns.

- SearchToolsTool: emit `tool.LlmFacingName` in both the exact-match
  list and the "did you mean" suggestion list.
- LoadToolTool: return `tool.LlmFacingName.Value` as the confirmation
  string the model echoes back.
- LlmSessionActor.TryActivateDiscoveredTool: resolve via the
  registry's two-form lookup (no change in behavior — the registry
  already accepted either form) and then cache + log under the
  canonical name so per-tool lease accounting and audit lines are
  unambiguous regardless of which form the LLM sent.

Operator-facing surfaces (audit, ToolCallOutput, Slack approval
prompt) are still canonical via the inbound seam from the previous
commit. The LLM and the operator now have separate consistent
identifiers, one form per audience.

Test expectations updated for SearchToolsToolTests and
McpToolAudienceGrantsTests where they pinned the prior (canonical)
LLM-facing emission. 1956 Actors tests pass; slopwatch clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working mcp Model context protocol server / client issues.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP tool names with '/' separator rejected by Anthropic API (regex ^[a-zA-Z0-9_-]{1,128}$)

2 participants