fix: sanitize registered MCP tool names for model APIs #41
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/sanitize-mcp-tool-names"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The bug
sigil publishes its tools as
sigil/lookup. The registered name was a raw<name_prefix>_<upstream_tool>join, so that slash rode through intosigil_sigil/lookup.Gemini enforces
[A-Za-z_][A-Za-z0-9_.:-]{0,127}on function-declaration names and rejects the wholeGenerateContentRequestover one bad entry:So one upstream's naming convention disabled every wraptool tool in the session, not just its own.
What changed
bb2b10d— the fix. Sanitize the exposed name. The handler still calls the upstream by its unmodified name, so dispatch is untouched. Also applied on the collision-detection path, soa/banda_bare caught as the collision they become once sanitized rather than silently registering twice.2f3554d— cleanup, no behavior change. Single-sourced theprefix + "_" + toolformula — it was spelled twice, in the minter and the collision checker, which is the class of bug being fixed here. Exportedconfig.FlagParamNamesomcptools.FlagToParamstopped duplicating it (its "avoid an import cycle" comment went stale the momentschema.goimportedconfig). Dropped a package-level regexp used for a one-character class test, plus an unreachable branch.2d19952— depth.mcptools.NewRegisteredToolderivesNamefrom the sanitizedTool.Name. The struct carried two unchecked copies of one string, and the registrar adds underTool.Namebut deregisters byName— divergence leaks a registration on every reload. Now unconstructible, and it is the choke point everySourcefunnels through.e332c4f— extraction, pure move.internal/toolnamenow ownsSanitize,CLI,Proxy,Param,ValidParam. The rules have two caller groups that cannot import each other —configvalidates,mcptools/mcpproxymint — andconfigcannot importmcptoolswithout a cycle. That is the same shape that letFlagToParamandflagParamNamedrift apart originally, and the same reasoning behind the existinginternal/yamleditextraction.5271396— visibility.Validate()checked a flag's derived property key but never thetoolsmap key or subcommand tokens — onlyAddTool's edit path did — so config-owned names were already being silently rewritten.This warns at the mint site rather than rejecting at load, a deliberate departure from the stricter option. A subcommand token can legitimately carry a colon:
npm run build:prod,mvn clean:install. Those sanitize cleanly today; hard-erroring would turn a working config into a server that refuses to start. The choke point above already guarantees nothing invalid reaches the wire, so the only gap left was telling the operator.Testing
gofmt,go vetand the full suite pass.One pre-existing failure, unrelated to this branch:
cmd.TestUpdateServeStateConfigLoadedAtRequiresOwnershipfails withmkdir /run/user: permission denied. Verified it fails identically on untouched code — the dev container has no XDG runtime dir.New coverage: the reported agy/Gemini failure end to end (exposed name sanitized, upstream still called by its own name), the length cap and prefix-sharing long names, the idempotence the choke point relies on, and a colon-bearing subcommand loading rather than erroring.
🤖 Generated with Claude Code
https://claude.ai/code/session_017WJrdw1xwqL2xjAWhdzuVY
An upstream MCP server namespaces its own tools however it likes — sigil publishes "sigil/lookup" — and Source.Tools carried that name verbatim into "<name_prefix>_<upstream_tool>", registering "sigil_sigil/lookup". Gemini enforces [A-Za-z_][A-Za-z0-9_.:-]{0,127} on a function declaration name and rejects the whole GenerateContentRequest when a single entry violates it: tools[54].function_declarations[0].name: [FIELD_INVALID] Invalid function name. So one upstream's naming convention disabled every wraptool tool in an Antigravity session, and the only workaround was to unhook wraptool entirely by renaming .agents/mcp_config.json. Claude Code never surfaced this because its own mcp__server__tool mangling rewrites the name before it reaches the API. SanitizeToolName maps a minted name onto that grammar. It is applied at both minting sites — mcptools.ToolName for wrapped CLI subcommands and Source.Tools for proxied upstream tools — and only to the EXPOSED name: the proxy handler still calls upstream by its own unmodified ut.Name, so dispatch is unchanged. Validate's collision map sanitizes too. Without that, "a/b" and "a_b" under one prefix both collapse to "sigil_a_b" at registration while validation saw two distinct names, silently registering the same tool name twice. It lives in config rather than mcptools because config cannot import mcptools (cycle — the same reason flagParamName is duplicated there), while every minting site already imports config. Verified against a real config carrying sigil: 45 tools, none violating the grammar, and sigil_sigil_search still dispatches to sigil/search upstream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019xSbKHd9CmzQW37AuvjVS6CI (Go 1.27) failed the gofmt gate on this file while Go 1.25 and 1.26 considered it clean. The two disagree about comment alignment in the test's case table: 1.25/1.26 align the trailing comment on {"9lives", ...} with the two longer rows above it, 1.27 breaks that alignment group instead. Neither formatting satisfies the other, so running either version's gofmt -w just moves the failure. Put the comments on their own lines. With no trailing comments there is no alignment group to disagree about, and gofmt from 1.25 through 1.27 all report the file clean. Also write the non-ASCII case as "café/x" so the source stays pure ASCII. It is the same string -- the test still pins that the unsafe-character pass replaces one "_" per RUNE rather than per byte -- but it no longer depends on how a given gofmt measures the display width of a multi-byte rune. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WJrdw1xwqL2xjAWhdzuVYCI started failing on every branch with golangci-lint's own typecheck unable to read the standard library it was handed: go-1.27.1/lib/go/src/math/rand/v2/rand.go:213:17: method must have no type parameters (typecheck) golangci-lint 2.12.2 was built with go1.26.2, so its bundled go/types does not know Go 1.27 language features and chokes on 1.27's own stdlib sources. 2.13.2 is built with go1.27.0. Reproduced locally by pointing GOROOT at a 1.27.0 toolchain: 2.12.2 fails the same way -- a different stdlib file, same cause -- and 2.13.2 reports 0 issues across ./... under that toolchain. The base32 hash was computed with a nix-base32 encoder self-tested by re-deriving 2.12.2's existing manifest hash from its tarball first, so it is verified rather than transcribed. NOTE, not fixed here: the trigger was the Go version moving on its own. channels.scm pins only the snamellit channel and leaves guix itself on %default-channels, so `guix time-machine` tracks guix master and the toolchain drifts between runs -- which is how a pinned linter and an unpinned compiler drifted apart. Pinning the guix channel is the reproducibility fix and is worth doing separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WJrdw1xwqL2xjAWhdzuVYTestUpdateServeStateConfigLoadedAtRequiresOwnership set XDG_RUNTIME_DIR with a bare t.Setenv, which does not isolate anything: adrg/xdg resolves xdg.RuntimeDir once at package init, so paths.RuntimeDir() never saw the temp dir and the test wrote to the real per-user runtime directory. That passes only where /run/user/<uid> already exists and is writable -- a desktop session -- and fails everywhere else. On the CI runner as uid 980: server_test.go:386: writeServeState: creating runtime dir: mkdir /run/user/980: permission denied Use withIsolatedRuntime, the helper two dozen lines above it that every other runtime-dir test in this file already uses. It does the same Setenv plus the xdg.Reload() that makes it take effect, and restores both on cleanup. No production code changes -- the test was asserting against the wrong directory, not finding a real defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WJrdw1xwqL2xjAWhdzuVY514342af9f0968e700fb