ADR 0035 — Tool names: normalise the class, assert at mount
- Status: Accepted — refines the tool pipeline of ADR 0007.
- Date: 2026-08-05
Context
toToolName normalised only the hyphen, so every other character rode into
the advertised name: a contract with prefix: 'admin/analytics' derived
overview_admin/analytic.
Nothing stopped it. @modelcontextprotocol/sdk@1.29 does validate — SEP-986,
validateAndWarnToolName — but it only warns and registers the tool anyway,
against the MCP rule [A-Za-z0-9._-]{1,128}. ai@7 has no name rule at all, and
stitchkit assigned the name without looking at it. So the first enforcing check
was the provider’s, at the first model call — and the provider rejects the whole
request, so one malformed entry takes every tool of that mount down, not just its
own.
stitchkit deliberately validates more strictly than MCP: [a-zA-Z0-9_-] and 64
characters, i.e. no dot and half the length. That is OpenAI’s function-name rule,
the tightest of the surfaces a contract can be advertised on — a name that passes
here is deliverable everywhere. A consumer targeting only MCP or only Anthropic
(both 128) may therefore have to set a shorter explicit toolName.
defineContract already enforces contract hygiene at declaration time (empty
desc, misplaced toolName, duplicate toolName) — but only the placement and
uniqueness of an explicit name, never the string, and never a derived one.
Separately, singularize compared SINGULAR_EXCEPTIONS against the whole
normalised name, so the list only ever matched an unprefixed service:
bot-status derived get_bot_statu, chat-analytics derived
get_chat_analytic.
This repo’s own fixtures shipped illegal names (do_thing_/test, list_/items,
create_/item) and nobody noticed, because the assertions were toBeTruthy().
Decision
- Normalise, per half, touching nothing that already worked. The service half
has always collapsed
-to_, so it keeps doing that and covers the rest of the class ([^a-zA-Z0-9_]→_). The method half was never normalised, and a hyphenated key shipped a legal name (get-user_note) that may be pinned in a client config — so it keeps its hyphen and normalises only what no provider accepts ([^a-zA-Z0-9_-]→_). It needs normalising at all because it is aRecordkey and a runtime-built contract bypasses the type. - No run-collapsing and no trimming. They would rename names that are legal
today (
get__internal,list_a__b,get_foo_) for cosmetics alone. Only illegal characters are touched, so the legal-name surface is untouched apart from point 4. - Assert at mount, and throw. The repo already splits these two concerns:
representability defects get the
onIncompatibleSchemapolicy ('throw' | 'warn' | 'skip') because an endpoint can be valid on HTTP while its schema is not expressible as JSON Schema; identity defects — duplicate tool name, extend conflict, thedefineContractchecks — always throw. A name outside the provider charset is an identity defect. Of the three policy values onlyskipwould even be coherent:'warn'would register the illegal name anyway and poison the whole tool list, which is strictly worse than not mounting it. singularizeapplies to the last_segment, so the exception list works behind a prefix.- A prefix with no usable characters is rejected on its own terms.
'///','_',''and a fully non-ASCII prefix normalise to separators, and the result (get____,get__,get_) passes the charset check while being meaningless and identical for every such service — so it is caught by an explicit “no usable characters” check, not by the regex. An explicittoolNamerescues such a prefix: the prefix never enters the name. This is the one place a provider-legal name (get__) now throws. - The read-only listers opt out (
collectTools({ assertNames: false })) —listToolNamesandsummarizeTransports.listToolNamesis the documented way to find an offending name before an upgrade; if it threw, the diagnostic would die on the very case it exists for. - The CLI is exempt.
[a-zA-Z0-9_-]{1,64}is a provider rule; a CLI command is typed into a shell and no provider ever sees it. Holding it to the provider charset would refuse a command likeпоискthat worked yesterday, for no benefit. - Native tools assert too.
mountWait/mountDownload/mountUploadland in the sametools/listas the contract tools, so one undeliverable name there takes the contract tools down as well — the exact argument this ADR rests on.nativeTools?.(server)is consumer code and out of reach. - No new collision check. All three mounts already dedupe derived names
across every service in the mount, and
collectToolsis per-service and could not see a cross-service collision anyway. Only a regression test was added, to pin that the existing guards fire on newly-merged names.
Alternatives considered
- Widen the replace to
[-/](the reporting consumer’s proposal). Rejected — fixes one symbol and leaves the class: a dot, a space, unicode or a >64-character name still fail in production. - Normalise without validating. Rejected — an explicit
toolNamestill ships illegal, and an all-separator prefix ships asget_. - An
onIncompatibleSchema-style policy knob. Rejected per decision 3. Raw mount-time throws are a defect in the representability context, which is precisely the distinction drawn here.
Consequences
- Breaking, two classes. (a) An illegal name now throws at mount — nothing that
worked stops working (such a tool was rejected provider-side), but a build that
mounted yesterday can fail today. (b)
singularizeon the last segment renames names that are legal but wrong:get_bot_statu→get_bot_status,get_user_setting→get_user_settings,get_chat_analytic→get_chat_analytics,get_site_new→get_site_news. A host config or agent prompt pinned to an old name breaks; the migration recipe is alistToolNamesdiff, which is what that lister was built for. implementRemoteinherits the check — it is a second name producer over someone else’s contract, the likeliest source of an illegal prefix from outside the author’s control.validateMcpSchemasstill walks'MCP'only, so an AGENT-only or CLI-only endpoint with a bad name is invisible to the build probe and surfaces atmountAgent/createCliinstead. Stated rather than fixed — widening the probe’s transport set is a separate change.nativeTools?.(server)remains out of reach — it is consumer code registering straight on theMcpServer. The three built-in native tools are covered (decision 8).- A throw mid-mount leaves an
McpServerpartially registered when several services are mounted in one call. Pre-existing (the duplicate-name and extend-conflict throws behave identically); the new assertion widens the window.