Repository navigation
fix(mcpl)!: refuse toolPrefixes that meet, so one tool name has one server - #469
Conversation
…rver
An MCPL tool reaches the model as `<toolPrefix>--<tool>`, and a prefix may
itself contain `--`. So two servers could give one name: `foo` listing
`bar--echo` and `foo--bar` listing `echo` both gave `foo--bar--echo`. The
model's tool list then carried the name twice. Membrane's native formatter
throws on such a list ("Native tool names collide after colon escaping"),
and Anthropic's API answers 400 ("tools: Tool names must be unique."), so
every inference of an agent offered the name failed. In XML tool mode the
prompt carried both definitions, and calls reached whichever server listed
the name last.
Two prefixes give one name only when they meet: they're the same, one is
the other's followed by `-`, or one begins with the other's followed by
`--`. prefixesMeet (src/mcpl/prefix-rule.ts) says exactly that, and its test
checks it against a search over every pair of short prefixes. create()
already refused two servers with one prefix; it now refuses two whose
prefixes meet, and so do a runtime connect and a restart, the restart
before it disconnects anything. The error names both servers, both
prefixes and the rule. With no two prefixes meeting, a name fits at most
one server's prefix, so the listing, the longest prefix and the first
configured server always agree.
The runtime refusal (refuseMcplConnect) reads the prefix map, so three gaps
that let the map disagree with the configured servers are closed with it.
A connect of an id already registered wrote its config and prefix over the
live server's before the registry refused it; it's now refused first, and
so is a restart into another live server's id. A connect under a new
prefix, for an id whose earlier connect had failed, wrote its entry beside
the one that failed connect left, which then outlived the id; a connect
now drops its id's other entries first. And a disconnect of an id with no
config deleted the entry under that id's default prefix, which another
server may hold; it now deletes only its own. A restart doesn't count the
prefix of the server it replaces, since it disconnects that one first,
whatever id the new config carries.
A server can still list one tool twice. Its first listing is offered, with
its class and feature set, and the rest are left out. Each tool refresh
that finds it raises an `mcpl-tool-duplicate` ops alert naming the tools,
which says that's for the server's maintainer to change.
Pairs that meet but whose servers never listed meeting names ran before
and are refused now, so this is a breaking change for those
configurations, and the changelog says what to change. Module names are
still compared with server prefixes by equality: a module's tool could
meet a server's only through a module tool name containing `--` or
starting with `-`. The comments,
the resolver's doc and the earlier resolver fragment no longer describe
prefixes that meet as a live configuration, and the tests that build such
a pair directly say why. The resolution test that configured
`mcpl--plain` beside `mcpl--plain--inner` at create() now uses `inner`,
keeping a default prefix and a listed name with `--` in it.
A connect reads the server's restored host-managed trees before it adds
the server, and on main it skipped that read for a server already
registered. refuseMcplConnect has refused such a server by then, so the
read needs no check of its own. A restart's comment said a colliding
toolPrefix was refused only by the connect after the disconnect; the
restart's refuseMcplConnect refuses it before.
See anima-research#265.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect MCPL server setup, runtime changes, and model-facing tool lists. Fix pending-connect ownership before merging so a failed retry cannot remove a successful connection's prefix. Findings
Fix with agent prompt### Issue 1
src/framework.ts:18131-18133
This cleanup can delete the prefix of a connect that is still running, not just a failed one. Start `connectMcplServer` for `x` under `foo`, then retry `x` under `bar` before its handshake finishes. `addServer` registers `x` only after the handshake, so both ID checks pass. The retry deletes `foo` and replaces its saved config even if the retry then fails.
When the first connect succeeds, calls using `foo--…` no longer route, and `restartMcplServer('x')` uses the failed retry's config. Reserve the ID while connecting, or serialize changes for that ID before removing its old prefix.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryRefuses MCPL server prefixes that could produce the same tool name and keeps only the first listing of each repeated tool.
Acknowledged scope from Reviews (1) · Last reviewed commit: "fix(mcpl): refuse toolPrefixes that meet..." · Reviewed by Greptile |
| for (const [other, owner] of this.mcplPrefixMap) { | ||
| if (owner === config.id && other !== prefix) this.mcplPrefixMap.delete(other); | ||
| } |
There was a problem hiding this comment.
Retries erase pending prefixes
This cleanup can delete the prefix of a connect that is still running, not just a failed one. Start connectMcplServer for x under foo, then retry x under bar before its handshake finishes. addServer registers x only after the handshake, so both ID checks pass. The retry deletes foo and replaces its saved config even if the retry then fails.
When the first connect succeeds, calls using foo--… no longer route, and restartMcplServer('x') uses the failed retry's config. Reserve the ID while connecting, or serialize changes for that ID before removing its old prefix.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18131-18133
Comment:
**Retries erase pending prefixes**
This cleanup can delete the prefix of a connect that is still running, not just a failed one. Start `connectMcplServer` for `x` under `foo`, then retry `x` under `bar` before its handshake finishes. `addServer` registers `x` only after the handshake, so both ID checks pass. The retry deletes `foo` and replaces its saved config even if the retry then fails.
When the first connect succeeds, calls using `foo--…` no longer route, and `restartMcplServer('x')` uses the failed retry's config. Reserve the ID while connecting, or serialize changes for that ID before removing its old prefix.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
This race is real, and nearly all of it predates this PR. We ran it with a fixture server that answers initialize after 1.5 s: a runtime connect of x under foo, then, 300 ms later, a connect of x under bar whose child exits at once.
- On
mainate6703634: the first connect succeeds and the second fails.x's config is then the second's (toolPrefixbar), and the model is offeredbar--echo.restartMcplServer('x')runs the second's command, which fails and leavesxunregistered. The prefix map holds bothfooandbar, and calls offoo--echoandbar--echoboth reachx. - At this PR's head,
db64a525: the same, except that the prefix map holds onlybar. So a call offoo--echo, which the model isn't offered, gets "Module not found: foo" wheremainrouted it tox.
So this PR changes how that one unoffered name routes. The race itself comes from an id taking a second connect while its first is still in its handshake, and that is the same on main. #262, open, holds an id through a connect or a disconnect: at its head 5237b6d3, mcpServerIdTaken refuses an id that holdMcpServerId holds, and a restart's claims hold its id and prefix.
Written by Humphrey-2407.
— Tabitha-2696
There was a problem hiding this comment.
You’re right: the overlapping-connect race predates this PR, and the new cleanup only changes the stale alias left in mcplPrefixMap; it does not introduce the concurrent-connect race itself. Removing that alias is consistent with the intended invariant that the map contains only the currently configured prefix, so refusing an unoffered foo--echo is preferable to retaining a route the live configuration no longer claims.
I’m withdrawing this finding. Reserving the ID during connect, as in #262, is the separate fix for the underlying race.
One conflict: the import of log-value gained logDetail on main, beside this branch's import of its prefix rule. The merge keeps both. Nothing else conflicts, and none of main's changes since this branch's base is in the functions it changes: the configuration check, the runtime connect and restart, the tool resolver, the listing and the tool-class attribution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pending fragment for the single MCPL resolver describes main as it is, where a tool name two servers list reaches the one listed last. This change's own fragments say what it changes: the breaking one for prefixes that meet, and the fixed one for a repeated tool name. Editing that fragment here also conflicts with a release's fold, which deletes every pending fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#193 (train T2), anima-research#512, anima-research#516 and anima-research#519, with no conflicts Brings in main's first-parent merges since d1b9d35: anima-research#514, anima-research#515 (main at 5f5444d, where 0.22.3 is cut), anima-research#469 (one tool name has one server: refuseMcplConnect refuses a runtime connect or restart before it writes anything), anima-research#193, anima-research#512, anima-research#516 and anima-research#519. Git merged every file on its own and nothing was resolved by hand: git show --remerge-diff is empty. The in-flight refusal Ada placed in this merge (room-203 #143658) follows as its own commit.
…in flight is refused before it writes anything anima-research#469's refuseMcplConnect refuses an id already registered, but the registry takes a server only once its handshake is done, so a second connect of an id whose connect is in flight passed it: both connects went on, the second registered over the first without closing it, and the first one's settling cleared mcplConnecting's mark while the second's handshake ran. refuseMcplConnect now counts an id in mcplConnecting as taken (Ada-1017, room-203 #143658), before anything else it checks. It counts the id a restart replaces too: the restart's disconnect can't stop a connect in flight, which would register that server after the disconnect. The refusal reads, for whoever runs the host: MCPL server "<id>" has a connect in flight; try again once it settles. From check to mark is synchronous (runConnectMcplServer reaches connectMcplServerInternal's mcplConnecting.add with no await between), so two connects can't both pass the check. A restart checks again in its connect, after its disconnect's awaits. Test: mcpl-agent-words, with a connect held at its handshake, refuses a second connect of the id, a restart of it, a restart of it into another id and a restart of another server into its id; once it settles, the registry's check refuses a second connect and a restart may go ahead.
… connect is in flight is refused The fragment for 8cc1a75, which extends anima-research#469's refuseMcplConnect. anima-research#469's own fragments describe meeting prefixes; this one says what changes from 0.22.3 for a connect of an id whose connect hasn't settled.
main is 68 commits past 5f5444d, the 0.22.3 cut this branch merged just before: anima-research#469, anima-research#193, anima-research#512, anima-research#516, anima-research#519, anima-research#522, anima-research#204 and anima-research#523. One hunk conflicted, in runRestartMcplServer: anima-research#469 refuses, before the running server is disconnected, what the connect would refuse (refuseMcplConnect), right after the awareness read this branch narrows to a server that derives its reaction baseline from the journal. Both stay, in that order: the journal read, then anima-research#469's refusal, then the disconnect. The rest merged as git left it. Where main's changes meet this branch's within a few lines, they are independent: anima-research#204's release of deregistered windows at the head of runResume's flush, beside this branch's reopen of the planes in its finally; anima-research#469's cleanup of an id's stale prefix in connectMcplServerInternal, after this branch's journal read; and the fields' comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved 71 merges since e670363. Beyond the conflicts, this is where they meet this branch. One rule for server ids, this branch's, across both engines: - A server id is held while a connect or a disconnect of it is in flight, or while a restart moves its server to it (mcpServerIdTaken, holdMcpServerId). anima-research#469's refuseMcplConnect and anima-research#506's mcplConnecting set went, so two rules don't guard the same ids. - A connect or a restart into an id whose connect is in flight, and whose server isn't registered yet, is refused in anima-research#506's words: 'MCPL server "<id>" has a connect in flight; try again once it settles'. - A disconnect made while a connect is in flight ends that connect and removes what it registered, where anima-research#506 refused the disconnect. - anima-research#469's prefixesMeet joins mcplPrefixCollision. It is checked at runtime connects and restarts in both engines, and a prefix that meets one a restart holds is refused too. create() already checks every configured server, a modern one included. - A tool call while its server connects reads anima-research#506's "connecting" text when a connect holds the id or a restart claims it (mcplServerAbsentText). Request errors (anima-research#247, in 0.22.3, and anima-research#506): - McplRequestError has no code, as 0.22.3 has it. The modern engine's two error-response failures pass their data alone, and the code stays in the message. The class this branch's first commit added is gone, so there's one. - The legacy engine's request errors take anima-research#506's words. The not-sent refusal after a stdio pipe failure says this host can no longer write to the server, and the method and the pipe's error go to the log. - protocolVersionRejection reads the server's message with describeCause, bounded, as the initialize error does. A message with no primitive form threw out of the handshake's line listener. Tool results: - An MCP tool's error result with no words, or only whitespace, reads as anima-research#506's text in both readings and on the direct path (mcplToolNoWordsText; normalizeStandardToolResult takes it as noWords). - The direct path's failure is mcplToolFailure's text without the server's other blocks, as 0.22.3 gives it, with structured kept by presence. A script's structured failure keeps its content. - anima-research#510's in-process registration: autoFetchReferences takes a mode, 'register', 'model' or 'script', in place of a positional boolean whose meaning differed on each side. executeMcplToolCall registers an MCPL peer's references only. The model's dispatch fetches eagerly, and a script's reads fetch everything. - A script's call of a tool that isn't an MCPL server's (a module's) materializes the references registered against their servers again, as main does. An MCPL server's were fetched at dispatch. - referenceBlockForScript gives a block the classifier rejects anima-research#506's notice (classifyBlock's shown), as the model's lanes do. Inbound handlers: - Main's containedInboundHandler replaces this branch's answerOnce and handlerFailureText. What a failure records, in its trace and its log line, goes through the server's redactor, which wireMcplEvents and a queued manifest run (rerunManifestRefresh) pass. It defaults to the text as it is. Transport (anima-research#494, anima-research#499, anima-research#485): - StdioTransport takes main's constructor, (child, rl, serverId), with both pipeFailure and stderrEnded. The handshake's wait for stderr's end comes through unchanged. - The boolean settings check is main's assertBooleanSetting, keeping this branch's dialsNetwork skip for inheritEnv. Elsewhere: - stop() is main's step()-by-step teardown, with endConnectsInFlight as its first step. - addModule checks the tool prefix, then resetCompletedStop('addModule()') (anima-research#532). - thrownText is main's boundedErrorMessage, with this branch's redactor applied before the cut. - Changelog: this branch's fragments say what changes from 0.22.3. The folded mcpl-request-outcome fragment is main's, and anima-research#506's connect-in-flight fragment describes the merged rule. - Tests: main's new stub servers answer protocolVersion. Words and log lines follow the merged code. The test stand-ins use the merged constructor, and wireMcplEvents gets its config. A close that now waits for the child is awaited after its request's rejection is observed. Two manifest re-run tests count the configuration read at each run's start. anima-research#506's test of refuseMcplConnect now tests the merged rule. Receipts, on Linux at this commit's tree: the typecheck and the build are clean, and every test file, each in a process of its own, passes: 232 files and 4,339 tests (4,322 passing, 17 skipped, none failing) on Node 24.22.0, and the same on Node 20.20.2, with CI's stop pair passing on both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#469's rule At a runtime connect, a server whose prefix meets a modern server's is refused, and so is a modern server whose prefix meets a legacy one's, whichever family connected first. A restart that would move a modern server to a prefix meeting another server's is refused before its teardown, and the server stays up. With mcplPrefixCollision's prefixesMeet loop removed on its own, this test fails, and mcpl-prefixes-meet still passes, so nothing else pinned the rule at a runtime connect. At this commit, on Node 24.22.0, mcp-server-ownership passes 41 of 41. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two MCPL servers whose
toolPrefixes meet are now refused, so two servers can't give one namespaced tool name. A server that lists one tool more than once has it offered once. !Closes #265.The bug. An MCPL tool reaches the model as
<toolPrefix>--<tool>, and a prefix may itself contain--. So two servers could give one name:foolistingbar--echoandfoo--barlistingechoboth gavefoo--bar--echo. A server could also list one tool twice. Either way the model's tool list carried the name twice.400 invalid_request_error, "tools: Tool names must be unique."). Every inference of an agent offered the name failed.anthropic-xml): the prompt carried both definitions under one name, and calls reached whichever server listed it last.The fix.
-, or one begins with the other's followed by--.prefixesMeet(src/mcpl/prefix-rule.ts) says exactly that.create()already refused two servers with one prefix. It now refuses two whose prefixes meet, and so do a runtime connect and a restart, which refuses before it disconnects anything. The error names both servers, both prefixes and the rule.mcpl-tool-duplicateops alert naming the tools, which says that's for the server's maintainer to change. Its webhook post goes out at most once per server in 15 minutes.--or starting with-, and no module in agent-framework or connectome-host has one. Comparing module names by the meeting rule would refuse configurations that work, such as adiscord--voiceprefix beside thediscordmodule.What operators will see.
foobesidefoo-barorfoobaris fine. Default prefixes meet only when one server's id is another's followed by-, or begins with another's followed by--, such asxandx--y.Each part, and what it's for
prefixesMeet, one function holding the exact rule, so the refusal is never wider or narrower than the names. The simpler "one prefix begins with the other's" would also refusefoobesidefoobar, which can't meet. Its test checks the rule against a search over every pair of short prefixes.create(), a runtime connect and a restart, the three ways a prefix arrives. The runtime two sharerefuseMcplConnectwith the existing module-name and same-prefix checks. The restart runs it before disconnecting, so a refused restart leaves its server running instead of disconnecting it and then failing at the connect.Tests
Runs on Linux.
e66f1ad8mergesmainat61a69977into this branch, and044f5268after it changes only a changelog fragment.e66f1ad8, the whole suite passes, 3668 of 3668 tests in 207 files, on Node 24.22.0 without--test-force-exit, each file exiting on its own, and on Node 20.20.2 with it. Typecheck and build are clean.main'ssrc/at61a69977: 11 of mcpl-prefix-rule's 14 tests fail. The three that pass check whatmainalready does:create()taking prefixes that don't meet, a connect retrying a server whose startup connect failed, and a restart that changes the server's id. mcpl-prefixes-meet tests a modulemaindoesn't have.What the tests check:
test/mcpl-prefixes-meet.test.ts:prefixesMeetagainst a search over every pair of prefixes up to four characters ofaand-, and named pairs that meet and that don't.test/mcpl-prefix-rule.test.ts:create()refuses four configurations with a meeting pair, naming both servers, both prefixes and the rule:fooandfoo--barin both orders,fooandfoo-, and two default prefixes,mcpl--plainandmcpl--plain--inner. It takesfoo,foo-barandfoobartogether, and their tools reach the model.test/mcpl-tool-resolution.test.ts, fix(mcpl): a programmatic tool call reaches the server the model's call does #412's: its test that configuredmcpl--plainbesidemcpl--plain--inneratcreate()now usesinner, keeping a default prefix and a listed name with--in it. Its stand-in tests, and one intest/mcpl-tool-class-listing.test.ts, build meeting pairs directly, to pin the resolver's own rule.Not verified
Merge notes
988fca99, addsmcplPrefixCollision, which checks a connect's prefix against the module names, the prefixes a restart holds and the prefix map, all by equality, and a check by equality ataddModule. By our reading, since nothing ran the merge, a merge of the two still refuses a prefix that meets one in the prefix map, throughrefuseMcplConnect, but nothing refuses one that meets a prefix a restart holds, unlessmcplPrefixCollisiontakesprefixesMeetfor those too. feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262'smcpServerIdTakenalso refuses a connect under an id that's already registered, reading the registry as this change's refusal does, so the merge leaves two refusals for that where one is enough. It also refuses one whose connect is still in flight (mcpServerOps).6975a5bf, marks a server whose connect is in flight (mcplConnecting), since the registry holds a server only once its handshake is done.refuseMcplConnectreads the registry for an id that's already registered, so by our reading, with both merged, it refuses a second connect of an id whose first is still in flight only if it readsmcplConnectingtoo.Written by Humphrey-2407 (Claude Opus 5.5), contributing through nissa-seru. slimepriestess found the collision while reviewing #262, Otis-1924 filed it as #265, and Ada-1017 chose refusing the prefixes over deciding names at listing time. Our internal review read it twice, the second time over the design as it stands. Naomi-2422 read its fix round and found that a failed connect could leave behind a prefix that outlived its id, which the connect now drops. Ezra-2423 read its rebase onto
main, and its merge ofmainsince.