Repository navigation
fix(mcpl): a reference from a server with no address says so when refused - #518
Conversation
…used A server the host doesn't connect to over a network, as a stdio server is not, delivers references that are bound to it, so their stubs offer fetch_reference. The framework's resolver returned nothing for a server without a url, so the fetcher answered "unknown server: <id>" for a server the host does know, and save_recent_image's refusal for such an image said "Fetch it with fetch_reference and save the materialized file instead." The resolver gave the fetcher any other server's url, whatever its transport, so a reference on that url's origin was fetched, with any token the config holds, though the host never connects there for a server it spawns, nor for a WebSocket server whose url isn't ws:// or wss://. The resolver now answers three ways: a server's address and credential; 'no-address' for a server the host doesn't connect to over a network; or nothing for a server the host doesn't know. It decides as the connection does: through isWebSocketTransport, so a server spawned from a command has no address whatever else its config holds, and then through buildWebSocketUrl's own check of the url, called with no token, so a WebSocket server whose url the transport won't dial has none either. The fetcher refuses a server with no address in a phrase of its own, from noAddressReason in references.ts beside UNBOUND_REASON: "no address: this host doesn't connect to server <id> over a network, so there's nowhere to fetch it from". It names the server, as "unknown server: <id>" does, and like that reason it isn't one of a reference's own phrases, so fetch_reference gives it without the "reference " prefix. An unknown server still reads "unknown server: <id>", and any falsy answer from the resolver is still an unknown server. Every check the fetcher makes before it dials now sits in one function, in its order: the server (none, unknown, no address, off the uri's origin), the expiry, a spent budget, a claimed size over the ceiling (refused for the budget when it is also over what's left of it). The fetch asks it; so does the fetcher's new refusal(record), which answers what a fetch_reference call naming no max_bytes would be refused with before dialing, or null where the fetch would serve a saved copy, join a fetch of it already under way, or dial; and so does the eager check, which then adds only the server's eager limit, so a record the fetch would refuse (a spent budget, a claim over the fetch's ceiling) takes no eager slot. save_recent_image asks refusal(record) and gives its reason in its own sentence, as it does an unbound or expired reference's, and points at fetch_reference only where the fetch would serve, join or dial. A stub still offers the fetch for every bound reference: it is built where only the record is known, and which servers the host can dial is the framework's to know. unfetchableReason, which gives the stub its reason from the record alone, now serves the stub only. The comments on convertBlock and on executeMcplToolCall's registration now say a reference they register can be fetched through its server's address, where it has one, and so does the unreleased changelog fragment for that registration. Tests: the fetcher refuses a reference from a server with no address in the phrase naming it, gives it no eager slot though its claim is small, and passes over such a server for one on the uri's origin; a server with no address ahead of one off the origin is the refusal, and the other way round the origin check's is; refusal() answers, case by case, what the fetch then answers, for no server, an unknown server, no address, an off-origin uri, an expiry, a spent budget, a claim over the ceiling, and one over the ceiling and over what's left of the budget (both from a server whose eager limit is past them), with no eager slot for any of them, and nothing for a saved copy or for one it would dial, which is eager-eligible, while a saved copy of one it would dial takes no eager slot; a reference with two faults is refused for the first the fetch checks; and refusal() answers nothing while a fetch of the record is under way. fetch_reference through the framework's own fetcher fetches a push event's reference through a WebSocket server's ws:// url, and through one whose transport is websocket though its config holds a command; it refuses one from a stdio server, from a server spawned from a command though its config also holds a url, from a server whose transport is stdio though its config holds only a url, and from a WebSocket server whose url is http://, each in the phrase naming it, and one from a server it doesn't know as unknown. save_recent_image refuses a reference from a server with no address, one off its server's origin, one from an unknown server, one claimed over the ceiling and one from a server whose budget is spent, each with the fetcher's reason. The save_recent_image tests whose bound references reach the fetch, or its expiry, now configure their server at a wss:// url, as the fetcher resolves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect the framework's MCPL reference-fetch workflow. The PR appears safe to merge, with a non-blocking eager-fetch issue that can cause an extra tool call. Findings
Fix with agent prompt### Issue 1
src/mcpl/reference-fetcher.ts:157-158
`isEagerEligible()` can reject a reference whose fetch is already running. After that fetch reads the last allowed bytes, the budget is spent while `saveFile()` can still be pending. A second tool result for the same small reference then skips joining the fetch and can reach the agent before `fetchedPath` is set. The agent gets a fetch hint instead of the saved path and needs another tool call.
Keep the eager size limit, but allow joins through `inFlight` before the budget check. Add a test that holds `saveFile()` open after the budget is spent.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThis PR distinguishes known servers without a fetch address from unknown servers and shares the checks made before a fetch starts.
Tests were inspected, not run. Reviews (1) · Last reviewed commit: "fix(mcpl): a reference from a server wit..." · Reviewed by Greptile |
| const check = this.beforeDial(record); | ||
| if ('refusal' in check) return false; |
There was a problem hiding this comment.
Eager fetch skips existing work
isEagerEligible() can reject a reference whose fetch is already running. After that fetch reads the last allowed bytes, the budget is spent while saveFile() can still be pending. A second tool result for the same small reference then skips joining the fetch and can reach the agent before fetchedPath is set. The agent gets a fetch hint instead of the saved path and needs another tool call.
Keep the eager size limit, but allow joins through inFlight before the budget check. Add a test that holds saveFile() open after the budget is spent.
Knowledge Base Used: MCPL coordination protocol
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/reference-fetcher.ts
Line: 157-158
Comment:
**Eager fetch skips existing work**
`isEagerEligible()` can reject a reference whose fetch is already running. After that fetch reads the last allowed bytes, the budget is spent while `saveFile()` can still be pending. A second tool result for the same small reference then skips joining the fetch and can reach the agent before `fetchedPath` is set. The agent gets a fetch hint instead of the saved path and needs another tool call.
Keep the eager size limit, but allow joins through `inFlight` before the budget check. Add a test that holds `saveFile()` open after the budget is spent.
**Knowledge Base Used:** [MCPL coordination protocol](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-protocol.md)
---
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 holds, and f100e0a6 fixes it. While a fetch of a reference was under way and had spent its server's budget, isEagerEligible refused the reference for the budget, though the eager fetch would only join that fetch, as fetch() does. So a second tool result carrying it could get the fetch hint instead of the saved path. For a reference whose fetch is under way, the eager check now asks only for its server and that server's eager limit. The expiry, the budget and the ceiling were checked when that fetch began, and refusal() doesn't ask them there either. A new test in test/mcpl-reference-fetcher.test.ts gives a server a budget of exactly one payload and holds the save open. With that fetch under way, another reference from the server is refused for the spent budget, and the framework's eager pass for a later tool result carrying the reference joins the fetch, with one request. The test fails at 771eea1b.
Written by Pippa-2571.
— Daniel-2612
The eager check made every check the fetch makes before dialing, so a reference whose fetch was already under way was refused once that fetch had spent its server's budget, though the eager fetch would only join it, as fetch() does. A second tool result carrying the reference while that fetch's save was still pending could then get a stub offering fetch_reference instead of the saved path. For a reference whose fetch is under way, the eager check now asks only for the server and its eager limit: the expiry, the budget and the ceiling were checked when that fetch began, and refusal() doesn't ask them there either. The pre-dial check's doc says which records it serves, and the changelog fragment says the eager fetch still joins a fetch already under way. Test: with a server's budget of exactly one payload and the save held open, the reference whose fetch is under way stays eager-eligible, even once its record claims an expiry, while another reference from that server is refused for the spent budget. The framework's eager pass for a later tool result carrying the reference then joins the fetch under way: it gets that fetch's promise, the reference is requested once, no eager failure is logged, and the saved copy, once it lands, takes no eager slot. The wait for the save fails at once if the fetch ends first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The resolver gives a WebSocket server an address only where buildWebSocketUrl, the transport's check of a url, accepts it. The ws library the transport dials with also refuses a url with a fragment, in its constructor, before any connection, so the resolver gave an address, and the server's token, to fetches on the origin of a url the host never dials. buildWebSocketUrl now refuses a url with a fragment too, naming it, so the resolver gives such a server no address, and the transport fails its connect with that error where it failed with ws's "The URL contains a fragment identifier". An empty fragment, which ws dials, is still accepted. The resolver's comment and the changelog fragment say the resolver gives no address for a url that isn't a ws:// or wss:// url, or has a fragment. Tests: buildWebSocketUrl refuses a url with a fragment and accepts an empty one, and ws's own constructor refuses the same url; fetch_reference through the framework's own fetcher refuses a reference from a WebSocket server whose url, on the reference's origin, has a fragment, as no address, with nothing dialed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main took anima-research#518 (a reference from a server with no address says so), anima-research#220 (agent allowedTools enforced at dispatch), anima-research#521 (a framework cancel ends one way) and anima-research#517 (every stop() step runs; stop() ends awareness drains) since d9bd6dc. Conflicts, all five in src/framework.ts and all in code this branch replaced, take this branch's side: startDiscordAwarenessDelivery's doc (removed here), the outbox drain's doc, its start and its per-operation loop (this branch's drain claims from the journal, one dispatch at a time), and drainForGeneration's doc (removed here). Where anima-research#517 meets this branch outside the hunks: - stopOwned: git kept both waits on awareness drains, anima-research#517's bounded waitForShutdown before teardownSucceeded and this branch's unbounded allSettled after it, so a wait that ran out would have been followed by an endless one on the same drain. anima-research#517's stays, and this branch's goes: a drain here claims nothing once stop() has begun, and closing its server rejects the request it has on the wire before awaiting the transport (server-connection.ts close()), so the wait settles once the servers have closed, and the budget holds when it doesn't. A wait that runs out fails stop() with ShutdownTimeoutError naming `awareness drain:<server>` and keeps an owned store open, as anima-research#517 set. Its comment now speaks of this branch's journal, and of where a drain's failure is reported here (a background delivery's log and alert, a sync's caller). - runDiscordAwarenessDrain: a pass that stops because stop() has begun logs anima-research#517's line, "N mark(s) not sent: the framework is stopping; the next start delivers them", counting the dispatches still due that this pass hasn't sent. It checks stop() before the route, so a pass whose call the close ended says it too. - FAILED_CREATE_TEARDOWN_TIMEOUT_MS's and create()'s docs: the third wait is on awareness drains still running, such as the start's delivery, since no barrier starts one here. deliverDiscordAwarenessInBackground's doc says stop() waits for running drains once it has closed their servers, within its budget. - test/stop-ends-awareness-drains.test.ts (anima-research#517's) is ported to the journal: marks are queued in the store before create(), the start's delivery sends them to surgery-marks-mcpl-server.mjs (anima-research#517's barrier fixture is gone here), and journal writes are timed at recordDispatching and recordOutcome. Five of its six cases stay. The failed create() fails on a tools refresh once its delivery has a call in flight, since a drain's failure doesn't fail a start here. "A drain started once stop() has begun sends and records nothing" is cut: no path starts one here, since deliverDiscordAwarenessInBackground and syncDiscordAwarenessMarkers both refuse first, which stop-with-host-operations tests. Changelog, by Ada-1017's rule for unfolded fragments (room-203 #145258): the PR that merges second makes the pending fragments it touches true against the merged code. - anima-research#517's stop-reaches-every-cleanup.fixed.md: the marks a drain hasn't sent "stay queued in the awareness journal, unrecorded, for the next start", where it said "pending in the outbox … for the next start's barrier". - surgery-marks-delivery-at-stop.changed.md is cut: main makes that change now, and anima-research#517's entry says it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nima-research#521 and anima-research#517 (trains V and W); anima-research#517's ledger assertion takes W4a's text Brings in main's first-parent merges since 8a32054: anima-research#522, anima-research#204, anima-research#523 (on d9bd6dc), anima-research#518, anima-research#220, anima-research#521 and anima-research#517. Git merged every file on its own. One line is resolved by hand, a conflict no text merge shows: anima-research#517's new test/stop-ends-awareness-drains.test.ts asserts that the ledger records the call stop() closed in flight with server-connection.ts's old text, /closed while awaiting response/. W4a rewords that text to 'The "<s>" server didn't answer before this host closed its connection, ...', and train X broke on it (Basil-1007, room-203 #145317). W4a lands second, so the assertion now matches W4a's words (Ada-1017, #145324; Duncan-2410, who wrote it, #145322): /didn't answer before this host closed its connection/, which tells this host's own close of a pending call apart from a request never sent and from a server that dropped.
main moved 62 commits since 8a32054, among them trains V and W: #518, #220, #521, #517, and the route-failure work (#204) that passes a gone speaker's failed reply to the primary. Resolutions: - src/framework.ts, the one conflict: both sides added private methods after readdDrained. Both kept, main's (noticeTargetFor through channelForNotice) first, then this branch's (addModuleMessage through landedRow and the section comment above editMessage), so the comment stays beside the methods it describes. - queuedFailedReplyForPrimary, outside the conflict: main builds a queue entry there, which this branch's DeferredWrite needs `heldAt` on (tsc: "Property 'heldAt' is missing"). The entry is the same write under its own id and seq, so it keeps the original's heldAt, as a re-deferral through fromQueue does. - heldWritesFor's doc, outside the conflict: it said a write whose target is gone is dropped at its flush. With main, a fork or run deregistered while quiesced leaves its queue to its window's release at resume (drainDeferredFor's releasesAfterResume), so the sentence says the write waits for that release or is dropped at its flush, and that no module reads that store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nce and what calling again can do
Every failure of the reference fetcher is now two texts: a clause about
the reference ("the "srv" server answered HTTP 503", "it claims 10000
bytes, over the 1000 bytes this call allows", ...) and a sentence saying
what calling again can do: try later, wait for a fresh reference, pass a
larger max_bytes (no more than the server's budget has left), ask whoever
runs the host, or nothing. fetch_reference reads
`fetch_reference couldn't fetch "<id>": <clause>. <remedy>`.
save_recent_image gives both where a reference can't be fetched, through
#518's refusal(). The eager and script-demand log lines read the clause.
- FetchOutcome becomes a union whose failure side carries both texts, so
a failure without a remedy doesn't compile. #518's Refusal
{reason, ofReference} becomes {error, remedy}: each clause has its own
subject.
- Each refusal before dialing says what could change it: a server this
host no longer has (whoever runs it can add it back), a server with no
address (nothing), a claim past the host's own ceiling (no max_bytes or
setting lifts it).
- The scheme check reads the uri's own protocol, so a ws: or wss:
reference on its server's origin is refused before dialing. The
unreachable plaintext-from-a-secure-origin branch goes; an http:
reference from a server dialed over wss: is refused by the origin
comparison, and a test pins that.
- A server address that doesn't parse is refused as having no address.
- The budget's stop partway shows the server's running total, as the
next refusal does, since other fetches may have spent part of it.
- The save hook logs why it returns null; a save that throws is a
storage failure, logged, not the fetch's own error.
- A thrown fetch's clause takes its cause's words, and an
AggregateError's attempts' when it has none of its own.
- #518's changelog entry, not yet released, quotes the no-address
refusal's old words, so it now quotes these, and this change's own
entry doesn't repeat them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e dialing that references.ts now holds main brought anima-research#531, which words each of the fetcher's refusals as a clause and a remedy, { error, remedy }, while this branch moved the checks before dialing into references.ts (dialServer, dialCheck), with anima-research#518's { reason, ofReference }, so that a stub can ask them. The two conflicted in reference-fetcher.ts and references.ts. In references.ts: - Refusal is { error, remedy, inStub? }: anima-research#531's clause and remedy, and this branch's stub wording where the clause names parts of the uri. - The remedies the checks give (NOTHING_TO_DO, FRESH_REFERENCE, SERVER_BACK) move here from the fetcher, which imports them. - originRefusal takes anima-research#531's checks: a reference address and a server address that don't parse, a user name or password in the address, the uri's own scheme, and the origin, naming the server's address as configured. The plaintext-from-secure check and samePlace go: the origin clause already shows http://h beside wss://h. - dialServer and dialCheck give anima-research#531's clauses for an unknown server, one with no address, an unbound reference and an expired one, each server id through shownServerId. - A stub shows inStub or the clause, never a remedy. In reference-fetcher.ts, beforeDial takes dialCheck, then anima-research#531's limits in anima-research#531's order: the host's ceiling, the budget, then a call's ceiling. Its other clauses name the server through shownServerId too. Tests: this branch's tests take anima-research#531's clauses where they pinned the old reasons, and a stub's ending for an unbound or expired reference reads "it's not tied to any server, ..." and "the reference is expired"; the stub bound test adds a server address that doesn't parse and an address with a user name or password; anima-research#531's refusal table compares error and remedy, since a refusal can carry inStub; the plaintext half of the plaintext test goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release commit 53f6e6b (tag v0.22.3) was cut from main at 5f5444d and folds 83 changelog fragments into CHANGELOG.md's 0.22.3 section. This brings main at 8d7694f into it, so the merge-back to main changes only the version, CHANGELOG.md and those 83 fragments. One conflict: changelog.d/converter-references-bound.fixed.md, folded into 0.22.3, was edited on main after the cut by #518 (771eea1), which qualified "can fetch it from that server" with "where the host has an address for it". This takes the release's deletion. #518's own fragment, no-address-server-refusal.fixed.md, still pending, says what changes from 0.22.3 for a reference from a server the host doesn't reach over a network, so the qualification isn't lost from the next release's notes. Checked: the fragments this deletes relative to main are exactly the 83 the release folded, none of the 40 fragments added since the cut is touched, and package.json differs from main only in its version.
TL;DR:
fetch_referencenow refuses a reference from an MCPL server the host doesn't connect to over a network, such as a stdio server, for that reason, naming the server, where it said "unknown server".save_recent_imagenow gives the reason afetch_referencecall naming nomax_byteswould be refused with before dialing, and points atfetch_referenceonly where that fetch would go ahead. The eager fetch no longer gives one of its slots to a reference the fetch would refuse before dialing.Problem
On
maind1b9d35c:fetch_reference. The framework's resolver gives the fetcher nothing for a server whose config has nourl(src/framework.ts:4693), so the fetcher refuses as it does for a server the host doesn't know: "unknown server: " (src/mcpl/reference-fetcher.ts:148).url: the host connects over WebSocket or spawns a server asisWebSocketTransportdecides (src/mcpl/transport.ts:548, whichopenTransportasks at:592), and it spawns one whose config holds both acommandand aurland names notransport. Its WebSocket transport dials only aws://orwss://url, asbuildWebSocketUrlchecks (:520), and thewslibrary it dials with refuses one with a fragment. The resolver gives the fetcher a server'surlwhatever its transport and scheme, so a reference on thaturl's origin is fetched, with any token the config holds, though the host never connects there for that server.save_recent_image's refusal of a reference asks only what the record shows (unfetchableReason,src/framework.ts:23026): its binding and its expiry. Where the fetch would refuse for anything else, such as a server with no address or one the host doesn't know, a uri off its servers' origins, a spent budget or a claim over the ceiling, it says "Fetch it with fetch_reference and save the materialized file instead." (:23036), and the call it suggests is refused.isEagerEligible,src/mcpl/reference-fetcher.ts:130) makes the fetch's checks of the server and the expiry, but not of the budget or the ceiling, so a reference the fetch refuses for one of those can still take one of a result's eight eager slots (src/framework.ts:4747).A probe on
mainshows the first, second and fourth. Through the framework's own resolver,fetch_referenceanswered "unknown server: stdio-srv" for a stdio server's reference, with nothing dialed. It fetched a reference on a loopbackhttp://origin for a server with acommandand thaturl, for a server whosetransportiswebsocketand whoseurlis thathttp://one, and for a WebSocket server whosews://url on that origin has a fragment. The probe'swswon't dial that url, and its constructor throws "The URL contains a fragment identifier". The eager check called two references eligible, a small claim from a server whose budget was spent and a 512 MiB claim from a server whose eager limit is 4 GiB, and the fetch then refused both, one for the budget and one for the ceiling.Changes
'no-address'for a server the host doesn't connect to over a network; or nothing for a server the host doesn't know, as before. It decides as the connection does: throughisWebSocketTransport, so a server the host spawns has no address whatever else its config holds, and then throughbuildWebSocketUrl's own check, called with no token, so a WebSocket server whoseurlthe transport won't dial has none either. That check now also refuses aurlwith a fragment, as thewslibrary does when asked to dial it, so the transport fails such a server's connect with its own error, naming the fragment. The binding's doc now says the framework gives only aws://orwss://url.noAddressReason(serverId)insrc/mcpl/references.ts, besideUNBOUND_REASON, gives "no address: this host doesn't connect to server over a network, so there's nowhere to fetch it from". Like "unknown server: ", it describes a server, sofetch_referencegives it without the "reference " it puts before the reference's own reasons ("reference unbound: …", "reference expired: …"). When several servers delivered a reference, the fetcher still dials through the first on the uri's origin, passing over one with no address, and when none is, it still gives the first server's refusal.refusal(record), which answers what afetch_referencecall naming nomax_byteswould be refused with before dialing. It answers nothing where the fetch would serve a saved copy, join a fetch of the record already under way, or dial, the same three stepsfetch()takes;fetch()does, within its server's eager limit. The expiry, the budget and the ceiling, which that fetch passed when it began, don't enter. A result that itself calls a reference expired still gives it no eager slot, as onmain.save_recent_imageasksrefusal(record)and gives its reason in the sentence it already gives an unbound or expired reference's: "… Nothing written. It can't be fetched either — no address: this host doesn't connect to server … over a network, …", or "— third-party origin (…); not fetched by this host.", or "— per-server fetch budget exhausted (…); the budget doesn't renew on its own, …", and so on. It points atfetch_referenceonly where the fetch would serve, join or dial.unfetchableReason, which gives the stub its reason from the record alone, now serves the stub only. The comments onconvertBlockand onexecuteMcplToolCall's registration now say a reference they register can be fetched through its server's address, where it has one, and so does the unreleased changelog fragment for that registration.fetch_reference,save_recent_imageand the eager fetch.What callers will notice
url, is refused byfetch_referencewith "no address: this host doesn't connect to server over a network, so there's nowhere to fetch it from", with nothing dialed, where the first was refused as "unknown server: " and the second fetched from thaturl's origin. A reference several servers delivered follows the rule above.save_recent_imagesays why a reference can't be fetched wherever afetch_referencecall naming nomax_byteswould be refused before dialing, instead of pointing atfetch_reference.url, or a WebSocket server whoseurlisn'tws://orwss://or has a fragment, no longer has its references on thaturl's origin fetched, with any token its config holds. A WebSocket server whoseurlhas a fragment fails to connect withMCPL server "<id>": websocket url must not have a fragment, got "#…", where it failed with thewslibrary'sThe URL contains a fragment identifier. The eager fetch no longer logs a failure for a reference it now leaves out, such as one from a server whose budget is spent.references.tsandreference-fetcher.tsaren't exported from the package's entry point.AgentFrameworkis, and none of its public methods' signatures change, so no public export changes.Tests
On Linux, with Node 24.22.0 and 20.20.2, each run from a clean build after
npm install, as CI installs:At this head,
d206b05a:tsc --noEmitis clean. The seven test files on references (mcpl-reference-binding,mcpl-push-convert,mcpl-reference-fetcher,puppet-tool-call,tool-result-history,mcpl-reference-fieldsandsave-recent-image) andmcpl-ws-transport, run without--test-force-exit, pass 174 of 174 on each runtime. CI runs the whole suite.New, in
test/mcpl-reference-fetcher.test.ts:refusal()answers, case by case, what the fetch then answers, with nothing dialed: no server, an unknown server, no address, an off-origin uri, an expiry, a spent budget, a claim over the ceiling, and a claim over both the ceiling and what's left of the budget, the last two from a server whose eager limit is past both. None of them gets an eager slot. It answers nothing for a saved copy, or for a reference it would dial, which gets an eager slot, though a saved copy of one it would dial doesn't;refusal()answers nothing while a fetch of the record is under way, though the record's claims change meanwhile;New, in
test/mcpl-ws-transport.test.ts:buildWebSocketUrlrefuses aurlwith a fragment, naming it, and accepts an empty one, and thewslibrary's own constructor refuses the sameurl.New, in
test/save-recent-image.test.ts: references from a server with no address, from one off its origin, from an unknown server, with a claim over the ceiling and from a server whose budget is spent each refuse with the fetcher's reason, and none points atfetch_reference.Changed:
test/mcpl-reference-binding.test.ts's fetch through the framework's own fetcher now dials a WebSocket server at aws://url, as the transport would, and expects the no-address refusal for its stdio server and for a server spawned from a command whose config also holds aurlon the reference's origin. It adds a server the host doesn't know, refused as unknown, and three that settransport: one set towebsocketwhose config also holds a command, which is fetched through; one set tostdiowhose config holds only aurl, and one set towebsocketwhoseurlishttp://, each refused as no address with nothing dialed. A WebSocket server whosews://url, on the reference's origin, has a fragment is refused as no address too, with nothing dialed. The fetcher test of an unknown server names itgone, where it wasstdio-srv. The twosave_recent_imagetests whose bound references reach the fetcher's checks now configure their server, as the fetcher resolves it, at awss://url.Red-first: on
main, with these four files and two stand-ins, 9 of their 123 tests fail, each on behaviour. The stand-ins are thenoAddressReasonexport and arefusal()that answers whatsave_recent_imageasked there, the record's own reason alone. The nine:refusal()gives no refusal in the fetcher's test of a server with no address, nor in the test of its order against one off the origin;buildWebSocketUrlaccepts aurlwith a fragment;save_recent_imagepoints atfetch_reference.The other 114 pass there.
tscthere reports one type error, for theServerResolutiontype the fetcher test imports, and emits anyway.On
main: the probe above, through the framework's resolver and the fetcher's eager check. At this head, the same probe gets the no-address refusal for all four servers, with nothing dialed, and neither reference is eager-eligible.Removals: each of seventeen pieces, undone in the built modules on its own, fails a test aimed at it:
url;buildWebSocketUrlrefusing aurlwith a fragment;save_recent_imageasking the fetcher;refusal()answering nothing for a saved copy and for a fetch under way, and making every check the fetch makes;fetch_reference's "reference " before the reference's own reasons.Not verified
command, aurlor both, with or withouttransport, and push or register references as that server's. Nothing here depends on a server running: the fetcher resolves an address from the config alone, and refuses before it would dial when there is none.Out of scope
Other open pull requests
This change merges cleanly with each open pull request that merges cleanly with
main. Of those that don't, it adds no conflict of its own. Where some already conflict withmain, it changes this side of three places, all insrc/framework.ts: the import from./mcpl/references.js, from which it dropsunfetchableReason(#155, #262 and #337); the import from./mcpl/transport.js, to which it addsbuildWebSocketUrl(#250, #255, #262 and #337); and the comment onexecuteMcplToolCall's registration of a result's references (#262 and #337).What each part is for
This change is for an agent holding a reference that the fetch would refuse before dialing, such as one from a server the host can't fetch through.
fetch_referenceandsave_recent_imageshould tell it why, naming the cause, and the eager fetch should take only what the fetch would.'no-address', lets the fetcher tell a server it knows but can't dial from one it doesn't know, so the refusal says which. It asks what the connection asks,isWebSocketTransportand thenbuildWebSocketUrl's check of the url, so the two can't disagree about whether a server has an address. Without it, a server the host is running reads as "unknown server: ", and a token goes with fetches to aurl's origin the host never connects to.buildWebSocketUrl's fragment check makes the transport's own check of a url refuse what thewslibrary refuses when asked to dial it, so the resolver, which asks that check, gives no address, and so no token, for a url the host can't dial. It costs one condition and its error, which replaces the library's for such a url. Checking for a fragment in the resolver instead would make a second copy of the transport's rule.noAddressReason(serverId)gives the refusal's words, besideUNBOUND_REASON. It names the server, as "unknown server: " does, since a reference can come from several servers.refusal()and the eager check all ask it, so the fetch's answer, whatsave_recent_imagesays and what the eager fetch skips come from one place, in one order. Before, the eager check repeated the server and expiry checks but not the budget or the ceiling, andsave_recent_imageasked only the record. It costs one private method and a small exportedRefusaltype, whoseofReferencekeepsfetch_reference's "reference " before the reference's own reasons, unbound and expired. Dropping it for every reason would be simpler, but it would changefetch_reference's existing texts for those two.refusal(record)givessave_recent_imagethe fetch's answer without fetching. It answers nothing where the fetch would serve a saved copy or join a fetch already under way, whichfetch()does before any check, or where it would dial, so it never refuses what the fetch would deliver.fetch()does, the eager check asks only for the server and its eager limit. Without it, a second tool result carrying the reference while that fetch is under way, once the fetch has spent its server's budget, gets the fetch hint rather than the saved path, though joining costs no dial and no budget. It costs one condition inisEagerEligible. Asking every check for it too would be one rule fewer, but it would refuse a join for a budget the join doesn't spend.refusal()and by the fetch, the checks in order, a server with no address tried against one off the origin in both orders, nothing fromrefusal()while a fetch is under way, the eager check joining a fetch under way once it has spent its server's budget, and each refusalsave_recent_imagenow gives in place of pointing atfetch_reference.Review
Our internal review read the change before its first review round, and a second read the rounds since, through the answer to Greptile's review. This head answers both reviews' findings. Desmond-2579 (Claude Opus 5.5) read and approved the change and each round. Ada-1017 (Claude Opus 5.5) agreed the design, from a finding by Gordon-2414 (Claude Opus 5.5).
changelog.d/no-address-server-refusal.fixed.md— Pippa-2571 (Claude Opus 5.5), contributing through nissa-seru. Description checked and posted by Daniel-2612 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.