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.
…y's JSON
A tool result whose content array held one block the tool-result lane
didn't recognize went to history, and to the model, as the whole array's
JSON. That put inline audio's base64, an image's base64 when it had no
media type, and an unknown block's uri (a signed URL's signature
included) into the agent's context, and turned a native image beside it
into base64 text. The push lane showed each of those blocks as a marker.
One per-block function now feeds both copies (toolResultBlocks in
tool-result-history.ts): the stored text and the live copy
(tryNativeToolResultContent) classify each block alike, so the stored
copy's shown ranges stay the live copy's. Each block is itself (text, or
an image with data and a media type), a reference's stub, inline data
withheld for a contradiction, or a marker saying what it was and why it
isn't shown: the push lane's markers, and for inline audio, which this
host's tool results can't carry, "[Audio not shown: this host's tool
results carry only text and images (<type>, <size>).]". An image whose
data or media type is an empty string reads as its marker, as the push
lane reads it.
Which arrays are content: a protocol result's content is, whatever its
blocks' types. ToolResult.dataIsContent says so; the framework sets it
on an MCPL tool's result, from the model's dispatch and from an
in-process call. checkedToolResult keeps it as a flag, read by name, so
it survives the copy the admin puppet stores its pair from, and the
puppet stores what a model's call stores. Readers take it for its truth,
as a result's other flags. Any other array (a module's, a script's, a
direct caller's) is content only when every element is a block of one of
MCP's five types (text, image, audio, resource, resource_link). Any other
element makes it data, written as JSON as before. The miss this leaves:
a list of records that all carry MCP's type names, such as
[{ type: 'image', url }], reads as content.
MCP's resource_link is a reference (RFC-005 §8.1): classifyBlock maps its
uri, name, mimeType and size (as sizeBytes) onto the reference record,
with no disposition, and marks the testimony a link's (fromLink). No uri
reaches context that was kept out of it before:
- A link the host can fetch shows as a stub on every lane, convertBlock's
included, and at any depth in data (governedByRfc005); showsAsItself
lets a render result carry one.
- A tool result's link the host can't fetch, whose JSON showed its uri
before, shows its name, uri and type, "[resource link "<name>": <uri>,
<type>; not fetched]", as #262 shows a standard server's link: the uri
is the agent's only handle on it, and showing it is §5's MAY for a
reference the host classifies as non-capability-bearing. The tool-result
lane asks for this (StubOptions.unfetchedLinkUri) at both its sites.
- Unless the uri, as it would be shown, may carry a credential
(uriMayCarryCredential): userinfo, or a query or fragment parameter
with a value, named on CREDENTIAL_PARAMETERS, one list with an example
of each family (AWS's, Google Cloud Storage's, Azure's, CloudFront's and
B2's signed URLs, Discord's CDN, OAuth's tokens and secrets, generic
names), compared by letters and digits alone, without case, a fragment
read as a query is, '?' and ';' separating parameters too, a uri parsed
as absolute before it's tried as relative, and a uri that doesn't parse
even as a relative reference counted as one; or a reference withheld it
with disposition never. Such a link gets the stub that says why it can't
be fetched, with no uri. Plain links with a query, such as watch?v=,
and anchors named like a credential, such as #token, which carry no
value, still show.
- On every other lane a link the host can't fetch gets that stub too,
and so does a resource reference on any lane: neither's uri showed
before, and a credential the list misses, such as a token in a path,
stays out of context there.
A link is registered with its server but never fetched eagerly
(autoFetchReferences) or on a script's demand: a tool's links are what
it offers to follow. A uri a link names is fetched only when a block that
isn't a link names it too. A link's testimony never replaces a record's
in the registry, since it claims no digest, expiry or disposition, so a
later link can't lift a reference's expiry or digest check.
A stub's promise is the fetch's own check. #518's pre-dial check of the
server and expiry becomes dialServer (servers in order, the origin) and
dialCheck (dialServer, then the expiry) in references.ts, pure functions
of a record and the addresses its servers are dialed at. The fetcher
runs them on its own framework's configs, through its resolver, so the
origin it checks is the url of the config whose token it sends, as
#518 checked it against the binding the token came from; #518's eager
check joins a fetch under way with dialServer alone. A stub runs
dialCheck on the reference registry's addresses (unfetchableReason),
since the code that makes a stub can't reach its framework: the
framework records each server's address there as it configures it
(setMcplServerConfig, from serverAddressOf) and takes it back when it
removes the server. The registry keeps each framework's entry per id,
the latest in force, and a removal takes back only its own; stop()
takes back all of a framework's entries, so a stopped one is neither
read nor kept reachable, and start() records them again. With one
framework, or frameworks that agree on each id they share, a stub
promises what the fetch does; where two configure one id differently,
only a stub's words can be wrong, never where a token goes. So a
resource stub now says why a fetch would be refused (no address,
unknown server, off its server's origin, a scheme the fetcher refuses,
plaintext from a secure origin, unbound, expired) where it offered
fetch_reference. A third-party origin and a refused scheme read in a
stub without the uri's parts; a server's id is bounded and stripped of
controls, in a stub and in fetch_reference's refusal alike, so a stub
stays within REFERENCE_STUB_MAX_CHARS. The plaintext refusal runs before
the origins are compared, which had made it unreachable, for an http uri
on the secure server's host on the same port or each on its scheme's
default.
sanitizeLabel strips U+2028 and U+2029 too, so no label starts a line
the image ledger would read as a slot. The script-demand loop registers
each reference rather than finding it by uri, so a re-issued
reference's claims replace an earlier link's or reference's before the
fetch reads them.
The empty-link rule is made whole, as #506's merge note leaves open:
inline data beside uri '' is plain inline data on every lane. A link
that isn't empty still withholds it.
Tests: tool-result-blocks.test.ts pins each shape on both copies, the
content rule with and without dataIsContent, resource_link on each lane
and in each fetch path, a tool result's unfetchable link shown where its
uri can't carry a credential and stubbed where it may (a uri from each
family of the list), the push lane's link never showing its uri, the
render check, a link never replacing a reference's claims, each refusal
a stub gives and the fetcher's own check agreeing with it, a refused
scheme unnamed in a stub, two frameworks that
configure one id at two urls (neither fetch dials the other's origin,
each carries its own token to its own server, and a removal takes back
only its own address), a framework's stop and start, plaintext from a secure
origin, an unparseable server url, a link a reference withheld with
never, a credential name or a scheme split by a control character, a link line with
line separators reading as no slot, a script's demand fetch reading a
reference's claims over a link's, the empty link beside data on both lanes,
and dataIsContent through checkedToolResult, a model's round, and an
MCPL call from the model, in process and through the puppet. Three tests
that pinned the old behaviour are turned round: inline audio beside text
(tool-result-history), a resource_link's uri in data
(tool-result-reference-uri), and the empty link beside audio
(mcpl-agent-words). mcpl-reference-fields, mcpl-reference-binding and
save-recent-image configure their servers as the framework does, and
save-recent-image's stub bound check takes each refusal at its longest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.