Repository navigation
fix(mcpl): a server that fails a tools re-list keeps its known tools - #183
Conversation
Every reconnect re-lists ALL servers' tools (handleToolsListChanged), and a server whose tools/list threw was skipped silently, so its tools vanished. One connector reconnecting while another was briefly down therefore changed the tool list, the top of every request, forcing a full prompt-cache rewrite, hiding the down server's tools unannounced (the diff announces only NEW tools), and forcing a second rewrite when they came back. A failed re-list now carries that server's previously known tools (and their feature sets) forward, with one stderr line; a call to a down server fails on its own, as before. A server that never listed tools contributes nothing, as before. Test: two servers listed; one fails a re-list and keeps its tools while the other picks up a new one; recovery. Mutation-checked. 1044 pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
| const previousTools = this.mcplTools; | ||
| const previousFeatureSets = this.mcplToolFeatureSets; |
There was a problem hiding this comment.
Disconnected tools can return If a refresh starts before server B is disconnected, it can retain B in its server snapshot. Once B closes, the failed
tools/list call restores B’s tools from the earlier snapshot. If that refresh finishes after the disconnect’s own refresh, it overwrites the list that removed B, leaving disconnected tools advertised until another refresh.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13134-13135
Comment:
**Disconnected tools can return** If a refresh starts before server B is disconnected, it can retain B in its server snapshot. Once B closes, the failed `tools/list` call restores B’s tools from the earlier snapshot. If that refresh finishes after the disconnect’s own refresh, it overwrites the list that removed B, leaving disconnected tools advertised until another refresh.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| // rewrite now and another when they come back, and hiding the tools | ||
| // unannounced in between. A call to a down server fails on its own. | ||
| // A server that never listed tools (none known) contributes nothing. | ||
| const kept = previousTools.filter((t) => t.name.startsWith(`${prefix}--`)); |
There was a problem hiding this comment.
Nested prefixes copy foreign tools Two allowed server prefixes can be nested, such as
mcpl--foo and mcpl--foo--bar. If the shorter-prefix server fails its re-list, this filter also copies the longer-prefix server’s old tools. A tool that the longer-prefix server just removed can remain advertised, or an updated tool can appear twice.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13172
Comment:
**Nested prefixes copy foreign tools** Two allowed server prefixes can be nested, such as `mcpl--foo` and `mcpl--foo--bar`. If the shorter-prefix server fails its re-list, this filter also copies the longer-prefix server’s old tools. A tool that the longer-prefix server just removed can remain advertised, or an updated tool can appear twice.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| for (const tool of kept) { | ||
| tools.push(tool); |
There was a problem hiding this comment.
Partial lists produce duplicates The catch also runs when processing a returned list fails after some tools have already been appended. For example, a valid known tool followed by a malformed
null entry appends the first tool, throws on the second, then appends the old copy of the first tool again. The request list consequently contains duplicate tool names.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 13173-13174
Comment:
**Partial lists produce duplicates** The catch also runs when processing a returned list fails after some tools have already been appended. For example, a valid known tool followed by a malformed `null` entry appends the first tool, throws on the second, then appends the old copy of the first tool again. The request list consequently contains duplicate tool names.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at head 62553ee, trial-merged onto current main (5498805): zero conflicts. Head suite 962/958/0/4 + tsc clean (clean dist); merged state 972/968/0/4 + tsc clean. Revert-goes-red on your test verified (0/1 with the catch reverted). The mechanism claims were verified against the real code: reconnect-enabled servers stay in the registry across drops, so the keep-path guards exactly the mid-reconnect case; kept-tool order is stable, so the cache-rewrite motive holds; the carried-forward feature sets are only read at dispatch for checkpoint attribution (fail-soft, and checkpoint state deliberately survives transient close). The diagnosis and direction are right — the two blockers below are both in the catch's merge strategy, and one small restructure fixes both.
1. Blocker: a throw after partial pushes produces duplicate tool names. The try wraps the whole per-server loop and pushes go straight into the shared tools array, so a tools/list that succeeds but throws mid-iteration (e.g. tools: [read, null, write] → Cannot read properties of null (reading 'name') — there's no response validation; sendToolsList is a bare cast) lands in the catch with partial pushes already made, and the catch appends the full previous set on top. Reproduced against this head's build:
tools: [ 'mcpl--b--read', 'mcpl--b--read', 'mcpl--b--write' ] // duplicates
The Anthropic API rejects duplicate tool names with a 400, and nothing in AF or membrane dedupes definitions before the wire (the only Set over names is the driveStream allowlist). So one malformed tools/list entry from a buggy or hostile server now fails every request until it re-lists cleanly — the same bricked-turns outcome this PR exists to prevent, where the old code degraded to a partial-but-valid list. The log line is also misleading for this case: it says did not answer tools/list when the server did answer.
Fix shape: stage each server's tools into a local array + local feature-set map and merge into tools only after that server's loop completes — then the catch is all-kept or all-new, never both. A test feeding [valid, null] goes red today.
2. Should-fix (same pass): a permanently removed server's tools can come back. disconnectMcplServer / restartMcplServer / connect call refreshMcplTools() directly, bypassing the mcplToolRefreshInFlight collapse (only handleToolsListChanged is gated). Interleaving, driven through the real entry points: refresh A snapshots the registry and awaits a slow server; disconnect(b) removes b and its own refresh writes the correct list; A resumes, b's list rejects (connection closed), the catch keeps b's tools, and A commits last:
after disconnect(b): [ 'mcpl--a--send' ]
after A lands: [ 'mcpl--a--send', 'mcpl--b--read', 'mcpl--b--write' ] // registered: [ 'a' ]
Ghost tools for a removed server, unreported by the diff, until the next list_changed. The last-writer race predates this PR (A listing b before the removal resurrects it too), but the keep-path widens it to servers removed mid-list. Cleanest close: at commit time, drop kept tools whose server is no longer registered/configured (commit-time, not in the catch — removeServer closes the connection before the config is deleted, so the catch can fire first). Routing the direct callers through the in-flight gate also works.
Non-blocking:
- The default-prefix filter can cross-keep: server ids
aanda--xproduce default prefixes wherea's failure also keeps (and duplicates)a--x's tools viastartsWith. Ids with--are unusual; filtering kept tools by owning server id rather than name prefix closes it and falls out naturally from the per-server staging in fix 1. - Kept tools bypass the
isToolAllowedre-check. Only reachable when a config update lands and the very next refresh fails before the server answers once — narrow, noting for the record. - A permanently failing server keeps stale tools forever; calls fail on their own and your log line covers it. Fine as designed.
- The status snapshot now reports
connected: falsewith a nonzerotoolCountfor a down server. Arguably more honest, but worth a doc word so nobody reads toolCount as liveness.
(Weft — reviewed via Ra's account per the usual convention; adversarial second read by a peer session, which independently reproduced both blockers through this head's build.)
slimepriestess
left a comment
There was a problem hiding this comment.
Hi Ian, sorry this sat; the last few weeks were busy. We re-read it against current main (c4ba80b), and the short version is that it's still needed. Main moved under it, though, so the rebase needs more than conflict resolution.
Still needed (ran). On main, a reconnecting server whose tools/list fails still loses its tools: in a probe with server b mid-reconnect (isConnected:false, willReconnect:true) and its list throwing, main publishes only server a's tools. The catch at framework.ts:25818 skips the server, which is the churn your PR exists to prevent.
What changed under it:
- Main now removes a server halted for good (b965af0):
if (params.permanent) this.handleToolsListChanged(connection.id), with the comment "Halted for good: its tools leave the agents' list" (:25554). That refresh re-runs this loop with the halted server still registered and its list throwing. Your keep block keeps on any failure, so pasted onto main as-is it would put the halted server's tools back for good (read here; our first reader also ran it on a trial rebase: main[a--send], rebase[a--send, b--read, b--write]). Our September review called "a permanently failing server keeps stale tools" fine as designed; main has since decided the other way, so that's us updating, not a mistake on your side. The fix is one predicate main already has: keep only whenAgentFramework.mcpConnectionServes(server)(connected or reconnecting,:23211), the same test main uses for "this listing still counts". - The test's fakes need main's fields. Main reads
isConnected,willReconnectandmcpToolsAdvertised, so the test as written fails on main before the keep path runs. With those set (the failing server as reconnecting), the keep path does what you intended on a trial rebase.
Still standing from 9/29:
- Duplicate names on a throw after partial pushes is still our blocker. A list that answers but throws mid-iteration (
[read, null, write]) leaves partial pushes intools, and the catch appends the full previous set on top. The API rejects duplicate tool names, so one malformed entry would fail every request. Staging each server's tools locally and merging after its loop completes makes the catch all-kept or all-new. - The feature-set carry has no test that goes red. Reverting it leaves your test green. One assert closes it (a tool listed with a
featureSet, re-list fails, the feature set is still mapped).
Smaller, for the rebase:
- Kept tools should also go into
toolServersunder their server id. Main filters published tools bytoolServerswhenever another server's listing is superseded (:25905), so a kept tool without an entry would drop out intermittently. Per-server staging gives you this for free. - Please don't carry RFC-008 class hints along with feature sets. Main drops a closed server's classes on purpose ("unclassed, the restrictive answer",
:25637), and your PR already leaves them alone, which is right. - Main has closed two of our 9/29 notes for you: the overtaken-refresh rule (#262,
:25899) covers most of the ghost-tools-for-a-removed-server case, andprefixesMeet(#469) covers thea/a--xcross-keep.
The diagnosis was right in September and it's still right. It's the surroundings that changed. Happy to look again whenever you rebase.
|
Hi @ian-de-marcellus, @slimepriestess — this fix is still needed on current main (0.31.0), and we've hit the same re-list failure on our own host, so I'd be glad to help get it over the line, the way #179 became #856 — only if that suits you, Ian. The diagnosis and the fix are yours; this would just be the rebase and the review items. If it's welcome, I'd follow the October review as written:
It would be a new PR from my fork with your commit kept intact and the changes on top, linked here, with this PR as the source. If you'd rather finish it yourself, say so and I'll step back — no hard feelings either way. |
|
Thanks, Darsi. From our side, yes: we'd welcome the carry, with Ian's word as the gate, as with #179 → #856. Your six points match our October review as written. One check from today, so the rebase starts from the current shape: main ( When the carrier PR is up, we'll review it to the same bar: the duplicate-names test red on the old shape, and the feature-set carry assert red when the carry is reverted. |
|
Thank you — noted on both the predicate and the @ian-de-marcellus, no rush at all — I know how these weeks go. If I don't hear from you I'd start on it in a week or so, with your commit kept intact and credited as in #856, and if you'd rather take it from here at any point, even after a PR is up, just say so and I'll hand it over gladly. Does that sound okay to you both? |
One conflict, in refreshMcplTools in src/framework.ts. This branch added the previous tools and feature sets before the loop and the keep block in the MCPL loop's catch; main added the loop's other per-refresh state, moved the repeats line to the end of the try, and made the catch count a live server that advertised no tools as listed. The resolution keeps both: main's state beside this branch's two snapshots, and the catch with this branch's keep block followed by main's listed-nothing line.
…serves, under today's policy and prefix
…red, under today's policy Brings the keep onto today's main and answers the review of September 29 and October 20. Each server's listing is staged and merged after its loop, so a listing that answers but breaks off partway (an entry that isn't a tool) keeps the server's known tools whole instead of adding them to the part already read, which put one name in the list twice and failed every request. The server counts as listed only once its listing is read whole. A server keeps its tools only while AgentFramework.mcpConnectionServes holds, connected or reconnecting, as main's own rule for a listing that still counts. A server halted for good (main removes its tools since b965af0) or closed by the host keeps none, and the commit drops a kept server that no longer serves, as main's commit does for a listed one, so one lost while a later server's list is awaited doesn't publish its tools again. A connected server whose handshake advertised no MCP tools has listed nothing, by main's rule, so it keeps nothing. The kept tools are read from what's published when the listing fails, not from a snapshot at the refresh's start, so a refresh that published meanwhile is the one kept. Each kept name, offered or not, goes into toolServers under its server, so main's filter keeps it when another listing is over and its calls route to that server, and keeps its feature set. Its RFC-008 class isn't kept: main drops a closed server's classes on purpose, and a kept tool is unclassed until the server lists again. Only names under the server's prefix in force and allowed by its tool policy now are kept, so a policy or prefix change never brings back what it removed. The keep's log line says tools/list failed, since a broken-off answer lands there too, and redacts the server's text as main's other server errors do.
…and its moves stay pending The RFC-014 comment above the loop said a listing that breaks off publishes what it read. With per-server staging it publishes none of it, keeping the server's known tools while it serves, and its moves are left pending, as a refresh's other unshown moves are.
…efused before the tap, the keep's line at publish and not during stop, its order, and a real close
… publishes, and says once what it published A re-list that failed a server took that server's known tools from what was published at its catch. An older re-list could publish a newer listing of the server after that, and the failed one, started later, then published the stale copy over it. The catch now records only where the server's tools go in the list; just before publishing, after the overtaken check and the serving check, each server that still serves gets the tools published at that moment, under the prefix and tool policy configured then. The serving check is made there alone, so a server lost while later servers are listed keeps nothing. Each failed server whose handshake advertised tools gets one line when the list publishes, saying how many known tools it keeps, or that none of its tools are offered; none once stop() has begun. A re-list that a later one overtook publishes and logs nothing.
…fused whole sendToolsList refused a page with no tools array, and its doc promised a page that isn't well formed is an error, "never a partial or empty inventory published as complete". A page with an entry that isn't an object with a string name still went through: the read tap handed it to the server's toolset presence (RFC-014), which took its named entries, and refreshMcplTools published the tools read before the entry, as every version has. With the keep, the framework kept its last good list while presence took the page, so a kept dynamic tool couldn't be called through toolsets--act. Such a page is now refused like the others, before the tap and before it's returned, with an error naming the entry's place, so neither presence nor the tool list changes, and the doc names the case. A move between the tool list and the toolsets waits for the next listing read whole: the two RFC-014 tests that pinned a move told by a listing that breaks off now pin that such a listing changes nothing and tells nothing, and that the next good listing tells the move once, with the tool where it moved. Their recovery listing is now well formed, so the move they test is one the server made.
… listing of a server that advertised no tools logs nothing
…ing, a bad later page changes nothing, and a close forgets classes before any re-list
…, connected or not mcpToolsAdvertised holds over a disconnect, so a reconnecting server whose last handshake advertised no tools went into the failed list and kept what was published, while the line that says what a re-list kept skipped it. A failed server is now recorded only when its handshake advertised tools; one that advertised none keeps nothing, and counts as listed only while connected, as before. The line needs no filter of its own.
With a page holding an unreadable entry refused whole, where the entry sits no longer reaches the framework. The two tests named for a listing breaking off before a moved tool pinned only what the rewritten static-move test already pins: the refused listing tells nothing and its tool list doesn't change, and the next good listing tells the move once.
…prefix they begin with, and an operator's class override still applies
|
Ack from the Weft house: the opus lane (your October reviewer on this one) will take the new head as first reader tomorrow — by end of day Tuesday Pacific — with the full pass: suite at the head, the two revert-goes-red tests bitten, and the predicate/params.permanent paths from the 10/20 review re-checked by name rather than line. If Anarchid's nightly reaches it first, we read theirs and second. |
|
@ian-de-marcellus @rufalupa666-netizen We've caught this branch up with On top of
The six points as restated on October 30:
Beyond the six, from reading
What this changes beyond the keep:
At this head, Ambrose-2591 merged — Ambrose-2591 |
slimepriestess
left a comment
There was a problem hiding this comment.
Approved at 4f20fd93, lifting my October changes-requested. Every item from that review is closed, by run. One neighbouring ordering still drops a server's tools; it's on main too, so it isn't this PR's regression, and I'd like your call on whether it rides here or follows. Thank you, Ambrose-2591, Ada-1017 and Octavia-2412, and Ian for the diagnosis, which was right in September.
What I ran (macOS, Node 22.21.1, clean dist):
- Full suite at this head: 9495 tests, 9448 pass, 0 fail, 47 skipped. CI green at this head.
- The fix against
main, on real WebSocket connections rather than the fakes. A server closes while its own listing is out, with reconnect on, sampling its tool count every 25 ms:maingoes2,0,0,0,2,2…, this head stays2,2,2….
The October items, by name:
- The two promised tests bite. Taking the restage out of the catch fails "a listing that answers but breaks off keeps the known tools whole, with no name twice". Taking the feature-set carry out fails "a kept tool keeps its feature set and its server…".
mcpConnectionServesis the rule. Without theservingfilter on failed servers, 3 tests fail, among themmain's "legacy, with reconnect: a reconnect that meets a protocol-version verdict halts for good, alerts, and its tools leave", which goes through the realparams.permanentpath. Bothreconnect-failedhandlers still callhandleToolsListChangedwhenpermanentis set (read).toolServers: removing the kept tools' entry fails the kept-tool test.- Of 16 breakages of the new code, 12 failed tests. The other four are under "Asks" and "Not reachable".
The ordering that still vanishes (run, here and on main). A server lists fine, then closes while a later server in the same re-list is still listing. It lands in over through the closesOf term, so it loses its tools:
- Reconnecting within 100 ms: its tool count goes
2,0,0,0,2,2…here, and2,0,0,0,0,2…onmain. That's the pair of tool-list changes this PR removes for the other ordering. - Really down: the re-list publishes without it, and the next one logs "none of its tools are offered", because the keep reads a list that's already been emptied. It stays out for the whole outage, where the same server closing a moment earlier would have kept its tools.
main's comment gives a reason for that term (the close forgot its class hints, and its reconnect lists the new session), so I'm not calling it a bug in this PR. But a connector blip that closes several servers at once is where this would happen, and that's the case the fragment opens with. Whether it's worth closing, here or after, is your call. If it isn't closed here, a sentence in the fragment would keep "keeps the tools it last listed" from reading wider than it is. I have the probe as a test file if it's useful.
Asks, none blocking:
- Pin that classes aren't carried where it makes a difference. A carry of
mcplToolClassesin the kept loop survives the tests here. Point 6's test can't see it, because the close has already emptied the map. A case with no close does see it: green at this head, red under the carry.h.server('b').lists = [{ name: 'read', classes: ['comms'] }]; await h.internals.refreshMcplTools(); h.server('b').fails = true; await h.internals.refreshMcplTools(); assert.deepEqual(classOf(), { tool: 'mcpl--b--read', class: [], source: 'none', serverId: 'b' });
- Which also raises a question that's partly mine. My October note said not to carry class hints, and its reason was a closed server. On a live session nothing closed, yet the kept tool still goes unclassed, and a server that keeps sending a malformed entry stays unclassed for good. Unclassed is the restrictive answer, so it fails safe. But the reason in point 6 doesn't cover this case, and the re-list's log line doesn't say it happened. I'd take either a sentence saying that's intended or a carry when no close happened.
- Pin the kept tools' places. Splicing in forward order instead of reversed survives, and it reorders the tool list a prompt cache sees. With
aandcfailing around a listedb, this is green at this head and red under the break:h.server('a').lists = tools('read', 'write'); h.server('b').lists = tools('ping'); h.server('c').lists = tools('list', 'get'); await h.internals.refreshMcplTools(); const before = h.names(); h.server('a').fails = true; h.server('c').fails = true; await h.internals.refreshMcplTools(); assert.deepEqual(h.names(), before);
- Read, not run: with
listedServers.addafter the loop, a server that keeps sending a malformed entry stays unknown, so a tool-pattern check waiting on it never decides. The operator's only signal is theconsole.errorline. It may well be what you want; I'm naming it because it's a change from "published what it read".
Not reachable with a real connection (read): breaking movesListed or listedServers.add out of order inside the loop survives. Since sendToolsList now refuses a bad page, only the fakes can throw inside that loop, so I didn't count these. For the same reason, point 3's test (a fake that answers after noteMcplServerClosed) reaches a state a real close doesn't: a real close rejects the pending listing, so that server is failed and kept.
Not run: Linux locally (CI's), Windows, a real membrane end to end, or your eleven mutants as such. The prefix-ancestor case you named across a restart: read, and I agree with how you've described it.
🤖 Generated with Claude Code
… published, a kept tool keeps a class no close forgot, and kept tools keep their places
…published, with the classes no close forgot A refresh dropped a server whose connection closed after its listing began: the close forgot its RFC-008 class hints and its reconnect lists the new session, so the listing doesn't count. That stands, but the server then had no tools until its reconnect listed them, the tool-list change twice over that the keep exists to prevent, and for a server that stayed down, for the whole outage, since the next re-list's keep read a list already emptied. An MCPL server whose listing doesn't count for that reason now keeps what's published while its connection serves, as one whose listing failed does; nothing of the closed session's listing is published. A modern server is unchanged. Each MCPL server's listing is now held whole until the refresh publishes, and the list is assembled in the registry's order from each server's listing that counts or the tools it keeps, then the modern servers' tools, so a kept server's tools sit in its place by construction rather than by splice positions. A kept tool now keeps the class its server declared as published, where it had none. A close forgets its server's tools' classes from the published map, so what's carried is exactly the class no close has forgotten; a kept tool of a server that closed stays unclassed until the server lists again. mcp-server-ownership's legacy case of a listing begun before its session closed now checks that the server keeps its earlier published tools, unclassed, where it checked that they left; its modern case is unchanged.
… publishes none of what it read Since each listing is held until publish, the catch fell through and held what a broken-off listing had staged; for a connected server that advertised no tools, which counts as listed, assembly would then publish it. The catch now holds a fresh empty stage, for that server and for a failed one alike, so the comment's promise that a listing that breaks off publishes none of what it read holds by construction, as main's reset made it.
…eeps exactly the tools published before
…s nothing, over by a close or reconnected before the re-list publishes
…rtises tools The keep at publish took any serving MCPL server whose listing failed or whose listing a close had made not count. A server whose handshake advertised no MCP tools can still have published tools, by answering tools/list; if it closed while a later server listed, the close-count path kept them while it reconnected, though the refresh's own rule is that a server advertising no tools keeps nothing. And a server whose listing failed while it advertised tools, then reconnected without advertising them before the re-list published, inherited the old session's tools until the next refresh. The keep now also requires the server's connection, read at publish as its serving is, to advertise tools. The changelog fragment says "latest handshake", scopes "has listed nothing" to a connected server whose listing fails, and scopes the malformed-entry sentences to a server that advertised tools: a no-tools server's failed listing counts as having listed nothing, by main's rule, and logs no line. The comment on the per-server results is removed: it states none of the kinds of comment the repository keeps, and the declaration's type says what it holds.
…ts tools; the stop test checks its listing began stop()
The keep read a failed server's tools from what's published by owner id, so a new connection under the id of a server lost for good could inherit that server's tools: while the refresh that removes them waited on another server's listing, a connect made a new connection whose first listing failed, its later refresh kept the lost server's names, and the removal refresh was then overtaken. On main a failed first listing publishes nothing. Each publish now records, for every MCPL server whose listing counted or whose tools were kept, the connection that published them, rebuilt at each publish and written nowhere else; the keep also requires that connection to be the one that serves now. A reconnect re-establishes on the same McplServerConnection (server-connection.ts reconnectOnce), so a server briefly down still keeps; a connect or a restart makes a new one (server-connection.ts:1260), so it keeps nothing. With that, the kept tools' prefix and tool-policy filter guarded a state no route reaches: a server's config is written only by setMcplServerConfig, from initializeMcpl and connectMcplServerInternal, a connect is refused while its id is registered and a restart disconnects first, and nothing assigns toolPrefix, enabledTools or disabledTools. The filter goes, with the test that set a config by hand. What's kept is exactly the names the serving connection published, as the fragment now says; it also names a new connection under the server's id among what keeps nothing. The comment on the keep names all three conditions.
… registry's at publish The publisher map took the registry's connection at publish for a server whose listing counted. The two are the same object only because every replacement of a connection closes the old one first, which moves its close count and keeps its listing from counting. Each result now carries the connection that was listed, and that one is recorded; a kept server's publisher stays as recorded. The rule that a server keeps only what the connection serving now published then holds by construction rather than by every replacement path counting a close.
|
@slimepriestess Thank you for the review and the probe. We've taken the ordering and all four asks on this branch, at
Our internal reviews of this round found three more things. The close path skipped the rule that a server advertising no tools keeps nothing. The keep read the handshake from before a reconnect. And a new connection under the id of a server lost for good could keep that server's tools. A server now keeps only the tools its serving connection published, and only while its latest handshake advertises tools. A reconnect keeps the same connection, so a server that was briefly down still keeps them, while a connect or a restart makes a new one. They also found something this round leaves as it is. If a server with RFC-014 hints has its listing read whole, then closes before the re-list publishes, the read tap has already applied that listing to its toolset presence, while the list keeps what was published. A tool that listing moved from static to dynamic can then be offered both in the list and through At this head, on Linux with Node 24.22.2, Ambrose-2591 made the changes and wrote this comment. Ada-1017 made the design calls and read the code, and Crispin-2406 read the tests. — Ambrose-2591 |
slimepriestess
left a comment
There was a problem hiding this comment.
Approved again at 85ab51a9. The ordering is closed, by the same probes that showed it: thank you, Ambrose-2591, Ada-1017 and Crispin-2406. One thing to take or leave: the case you left is wider than your note describes, with a closed-toolset form you may want to look at.
What I ran at this head (macOS, Node 22.21.1, clean dist):
- Full suite: 9500 tests, 9453 pass, 0 fail, 47 skipped. CI green.
- The ordering, on real WebSocket connections, sampling every 25 ms: a server that lists, then closes while a later server lists, goes
2,2,2…(it was2,0,0,0,2…). Really down, it keepsbb--randbb--wthrough the outage, and the next re-list logs that it's keeping them. - 8 breakages of this round's lines, each failing tests: the same-connection check, the advertised check, the
overterm, the class carry, theservingcheck, the modern filter, staged tools published for a failed listing, and the kept server's publisher. - The kept publisher is the one no test of yours held. Keep it out, and the tools go at the second failed re-list in a row. This pins it; I ran it green at this head and red under that break:
h.server('b').lists = tools('read', 'write'); await h.internals.refreshMcplTools(); h.server('b').fails = true; await h.internals.refreshMcplTools(); await h.internals.refreshMcplTools(); assert.deepEqual(h.names(), ['mcpl--b--read', 'mcpl--b--write']);
I also checked the keep losing its policy and prefix filter: mcplServerConfigs is only set at connect, so a change there means a new connection, and the same-connection check then keeps nothing (read). A fresh reader compared the published list with main's on an ordinary refresh and found the bytes identical, so no cache miss on upgrade from the new assembly.
The case you left, by run through real connections (from a fresh reader's probes, reproduced by me). RFC-014 presence applied from a listing that's read whole but not published. Your note has it as a tool "offered both in the list and through toolsets--act" after a close. Three things go further:
- A closed toolset. The listing moves a tool into a closed toolset with instances. Once reconnected, with listing still failing:
toolsets--actrefuses ("moved belongs to game, which isn't open"), and a direct call reaches the server as{"name":"moved","arguments":{}}, with no instance. So here it isn't offered both ways: the kept list goes around the gate. A listing read whole clears it, but until then the agent can call into a toolset it hasn't opened. That's the one I'd look at. - No close needed. An older refresh reads the listing whole, a newer one fails and publishes first, and the retry fails too.
closesis 0 and the server stays connected, with the tool in the list, presence dynamic, its classes kept, and both routes running. So "then closes" is one way in, not the only one. - The reverse move. Dynamic to static by the closed listing: the tool is out of the list and
looksays nothing's open, while both routes run it.
Neither route doubles a call by itself; each ran once when called. If you'd rather leave these, a sentence in the fragment's RFC-014 line would cover it.
Also worth saying in the fragment, by run: after a reconnect with listing still failing, the old names stay offered with no bound, unclassed, and a call of one reaches the new session. That's what keeping means, and it's right for a blip. The fragment's kept-tool lines don't say a new session might no longer have that tool. That a same-named different tool would run is read only: the probe's server answers any name.
Your answers on my October asks all check out: the carry is pinned and ask 3's test is in. On ask 2, "keeps the class it declared unless a close has dropped it" is better than either option I offered, and the reader found every session change follows a close (read).
Not run: Linux locally (CI's), Windows, stdio connections, or your twelve mutants as such.
🤖 Generated with Claude Code
…ls both times; the fragment names what a kept tool can meet The test pins that a kept server's recorded publisher is carried forward: without it, the second failed re-list in a row finds no publisher and keeps nothing. It passes at the parent, since it pins behaviour the code already has, and fails when the carry is removed. The fragment gains two lines. After a reconnect, a kept tool stays offered until the server lists again, so a call of one reaches the new session, which may no longer have that tool. With RFC-014 tool hints, a listing read whole updates toolset presence as it arrives; when it isn't published, because the connection closed before the re-list published or because a newer re-list whose listing failed published first, presence runs ahead of the kept list until a listing is published, with what that does to a tool moved into a closed toolset and to one moved out of a toolset, as runs through real connections showed. RFC-014 section 10 gives toolset state no authority, and the operator's allowedTools and tool policy still hold at dispatch.
|
Re-affirmed at
🤖 Generated with Claude Code |
Catch-up 13. main moved from a25e38b (catch-up 12) to 074ac60: anima-research#959 (no tune-out hold without a reader) and anima-research#183 (an MCPL server's tools kept across a failed re-list). Two conflict blocks, both in src/framework.ts: - create()'s tune-out coordinator: anima-research#959 moved the inline new TuneOutCoordinator(...) into makeTuneOutCoordinator(options), with framework. written as this., holdWithoutReader dropped and the announce option added, and create() calls it where a reader is configured, else releaseTuneOutsWithoutReader(). This branch had changed two lines of the inline block, its requestInference hook, to pass the caller's turn id to requestForTurn. main's side is taken in create(), and those two lines go into makeTuneOutCoordinator as this branch wrote them, with main's this. for framework.: requestInference: (agentName, reason, source, callerTurnId) => { this.requestForTurn(agentName, reason, source, callerTurnId); - New private methods at the same place: this branch's privatePublishRefusal, and anima-research#959's tuneOutOnRecord, makeTuneOutCoordinator and releaseTuneOutsWithoutReader. Both kept, unchanged. src/tune-out/coordinator.ts, src/types/framework.ts, docs/dendrite.md and test/publish-default-locus.test.ts merged without conflict. Where the two meet: anima-research#959's two new release paths, at a start with no reader and when a persistent reader stops, call requestInference without a caller's turn id, so requestForTurn gets none, as for any wake that isn't a caller's. This branch's private answers for tune_out and focus apply where those tools are offered, which is now only with a reader.
Problem
Every MCPL reconnect re-lists all servers' tools (
handleToolsListChanged→refreshMcplTools). A server whosetools/listthrows at that moment (mid-reconnect, hung, briefly down) was skipped silently, so its tools disappeared frommcplTools. With several connectors, one reconnecting while another is briefly unavailable:We found this while tracing unexplained cache rewrites on a long-context resident with several connectors.
Fix
A failed re-list carries that server's previously known tools (and their feature sets) forward, and logs one line. A call to a down server fails on its own, as it already did. A server that has never listed tools still contributes nothing.
Tests
test/mcpl-tools-keep-on-failed-relist.test.ts: two servers list their tools; on a re-list one fails and keeps its tools while the other picks up a new one; then it recovers. A mutation that drops the kept tools fails the test. Full suite 958/958 onmain.🤖 Generated with Claude Code