Repository navigation
fix(mcpl): references are bound to every server that delivered them - #510
Merged
nissa-seru merged 1 commit intoOct 18, 2026
Merged
nissa-seru merged 1 commit into
nissa-seru merged 1 commit into
Conversation
convertBlock (convert-block.ts), the one conversion of an MCPL server's content for push events and their render results, channel messages and context injections, built each reference's stub with referenceStubOrNull(block, provenance), which registers the reference with no server. An MCPL tool called in process didn't register its result at all: executeMcplToolCall, behind the public executeToolCall (the admin puppet's call) and a module's ctx.callTool, returned the server's content as it came. Only the result of a call dispatched to its server, the agent's own or its script's, was registered with that server, by autoFetchReferences before the result is serialized. So fetch_reference refused a reference that arrived any other way as unbound, before any other check, unless a tool result had registered the same uri with its server. RFC-005 §6.2 has the host keep each received reference's connection binding, and §4 names push events its priority lane. convertBlock now takes the id of the server that sent the content, as a required parameter, and passes it to referenceStubOrNull, which registers the reference with it. Its five callers name their server: the push handler's handlePushEvent the event's, the framework's delivery of a coalesced push's render result, plain or channel-scoped, the push's, the channel registry's handleIncoming the message's, and the hook orchestrator the server whose beforeInference answer carried the injection. A call that names no server doesn't compile. executeMcplToolCall runs autoFetchReferences with its server on a successful result, so a stub later made of its references is bound, without the eager fetch a dispatched call gets: an in-process caller may never show the references to an agent, and eager fetches there would spend the server's fetch budget, which the agent's own fetch_reference draws on and which doesn't renew, on bytes nobody asked for. A failure keeps only its text, so its references aren't registered. The registry keys a record by uri and kept the first binding, which only a tool result's registration made while the converter registered unbound. With the converter binding, the first server to deliver a uri would decide it: a relay's push of a uri on another server's origin would bind it to the relay, and that server's own later reference would be refused by the origin check for as long as the record lived. So a record now keeps every server that delivered its uri, each once, in the order they did (ReferenceRecord.serverIds, in place of serverId). The fetcher dials through the first of them whose address is on the uri's origin (serverFor), and when none is, refuses with the first server's refusal: unknown to this host, or the origin check's. The eager check and the fetch both ask serverFor, so the eager limit, the token and the per-server budget a fetch is charged to are the dialed server's. A server's token still goes only to its own origin, for a uri that server delivered. An unbound record is one with no server, as the stub and the fetcher read it. Tests: test/mcpl-reference-binding.test.ts follows a reference through each path to its record's servers: a push event, a render result, a channel-scoped render result, an incoming channel message, a context injection beside a hook server that sent none, and an in-process call through the admin puppet, whose stored stub offers the fetch, with nothing fetched though the fetcher calls the reference eager-eligible. The push event, the channel message and the context injection each carry a reference of each form convertBlock stubs (a resource, and uri-form image and audio). An in-process call that fails registers and fetches none of its result's references. fetch_reference, through the framework's own fetcher, fetches a push reference on its server's origin, while refusing one elsewhere and one from a server the host holds no address for. The fetcher's tests dial a uri two servers delivered through the one on its origin, in either order, with that server's token, eager limit and budget; dial the first of two servers on the uri's origin to deliver it, with its token; refuse one on none of their origins with the first server's refusal, in either order, and leave it out of the eager pass though it claims a size their eager limits allow; refuse an unknown first server ahead of an off-origin one, and the other way round; pass over a server with no address for one on the origin; and charge a fetch to the budget of the server it dials, not of the first to deliver the uri. test/mcpl-push-convert.test.ts passes a server to convertBlock, and the tests that read a record's binding read the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryMCPL references now keep every server that delivered their URI, allowing
No actionable issues found. Tests were not run during this review. Intentional behavior recorded from nissa-seru:
Reviews (1) · Last reviewed commit: "fix(mcpl): references are bound to every..." · Reviewed by Greptile |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…ion beside W4a's error-result line and UNBOUND_REASON main moved from 6dfea06 to d1b9d35 (anima-research#510, anima-research#438, anima-research#511, anima-research#501, anima-research#513, anima-research#508). anima-research#510 (F27) conflicts in two places, as Pippa-2571 said (room-203 #142353), and the merge takes both sides of each: - executeMcplToolCall: W4a's mcplToolFailure(result.content, serverId, call.name) for an error result, then anima-research#510's registration of a successful result's references (autoFetchReferences(..., false)). - references.ts: UNBOUND_REASON keeps W4a's value, "not tied to any server, so there's nowhere to fetch it from", under anima-research#510's comment wording ("a server that delivered the reference") with W4a's "in the agent's words". No test from main pins the old unbound or expiry texts. - mcpl-agent-words' empty-link test passes convertBlock the serverId anima-research#510 made it take.
This was referenced Oct 18, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main moved 71 merges since e670363. Beyond the conflicts, this is where they meet this branch. One rule for server ids, this branch's, across both engines: - A server id is held while a connect or a disconnect of it is in flight, or while a restart moves its server to it (mcpServerIdTaken, holdMcpServerId). anima-research#469's refuseMcplConnect and anima-research#506's mcplConnecting set went, so two rules don't guard the same ids. - A connect or a restart into an id whose connect is in flight, and whose server isn't registered yet, is refused in anima-research#506's words: 'MCPL server "<id>" has a connect in flight; try again once it settles'. - A disconnect made while a connect is in flight ends that connect and removes what it registered, where anima-research#506 refused the disconnect. - anima-research#469's prefixesMeet joins mcplPrefixCollision. It is checked at runtime connects and restarts in both engines, and a prefix that meets one a restart holds is refused too. create() already checks every configured server, a modern one included. - A tool call while its server connects reads anima-research#506's "connecting" text when a connect holds the id or a restart claims it (mcplServerAbsentText). Request errors (anima-research#247, in 0.22.3, and anima-research#506): - McplRequestError has no code, as 0.22.3 has it. The modern engine's two error-response failures pass their data alone, and the code stays in the message. The class this branch's first commit added is gone, so there's one. - The legacy engine's request errors take anima-research#506's words. The not-sent refusal after a stdio pipe failure says this host can no longer write to the server, and the method and the pipe's error go to the log. - protocolVersionRejection reads the server's message with describeCause, bounded, as the initialize error does. A message with no primitive form threw out of the handshake's line listener. Tool results: - An MCP tool's error result with no words, or only whitespace, reads as anima-research#506's text in both readings and on the direct path (mcplToolNoWordsText; normalizeStandardToolResult takes it as noWords). - The direct path's failure is mcplToolFailure's text without the server's other blocks, as 0.22.3 gives it, with structured kept by presence. A script's structured failure keeps its content. - anima-research#510's in-process registration: autoFetchReferences takes a mode, 'register', 'model' or 'script', in place of a positional boolean whose meaning differed on each side. executeMcplToolCall registers an MCPL peer's references only. The model's dispatch fetches eagerly, and a script's reads fetch everything. - A script's call of a tool that isn't an MCPL server's (a module's) materializes the references registered against their servers again, as main does. An MCPL server's were fetched at dispatch. - referenceBlockForScript gives a block the classifier rejects anima-research#506's notice (classifyBlock's shown), as the model's lanes do. Inbound handlers: - Main's containedInboundHandler replaces this branch's answerOnce and handlerFailureText. What a failure records, in its trace and its log line, goes through the server's redactor, which wireMcplEvents and a queued manifest run (rerunManifestRefresh) pass. It defaults to the text as it is. Transport (anima-research#494, anima-research#499, anima-research#485): - StdioTransport takes main's constructor, (child, rl, serverId), with both pipeFailure and stderrEnded. The handshake's wait for stderr's end comes through unchanged. - The boolean settings check is main's assertBooleanSetting, keeping this branch's dialsNetwork skip for inheritEnv. Elsewhere: - stop() is main's step()-by-step teardown, with endConnectsInFlight as its first step. - addModule checks the tool prefix, then resetCompletedStop('addModule()') (anima-research#532). - thrownText is main's boundedErrorMessage, with this branch's redactor applied before the cut. - Changelog: this branch's fragments say what changes from 0.22.3. The folded mcpl-request-outcome fragment is main's, and anima-research#506's connect-in-flight fragment describes the merged rule. - Tests: main's new stub servers answer protocolVersion. Words and log lines follow the merged code. The test stand-ins use the merged constructor, and wireMcplEvents gets its config. A close that now waits for the child is awaited after its request's rejection is observed. Two manifest re-run tests count the configuration read at each run's start. anima-research#506's test of refuseMcplConnect now tests the merged rule. Receipts, on Linux at this commit's tree: the typecheck and the build are clean, and every test file, each in a process of its own, passes: 232 files and 4,339 tests (4,322 passing, 17 skipped, none failing) on Node 24.22.0, and the same on Node 20.20.2, with CI's stop pair passing on both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR: a reference that arrives in a push event, a render result, an incoming channel message, a context injection or the successful result of an MCPL tool called in process is now registered with the server that sent it, as the references in the result of an MCPL tool call the agent makes are, so
fetch_referencecan fetch it from that server. These were registered with no server, sofetch_referencerefused them, unless a tool result had registered the same uri with its server. A reference now keeps every server that delivered its uri, and the fetcher dials through the first of them whose address is on the uri's origin.Problem
On
main0d19f7c8:convertBlock(src/mcpl/convert-block.ts:20) is the one conversion of an MCPL server's content for push events and their render results, channel messages and context injections. It builds each reference's stub withreferenceStubOrNull(block, provenance)and no server (:37,:62,:75), which registers the reference with no server.executeMcplToolCall(src/framework.ts:15709), behind the publicexecuteToolCall(:15481, which the admin puppet calls at:15589) and a module'sctx.callTool(:2311), returns the server's content as it came (:15733).autoFetchReferencesbefore the result is serialized (dispatchMcplToolCall,:20493).src/mcpl/reference-fetcher.ts:171).RFC-005 §6.2 has the host keep each received reference's connection binding, and its §4 names push events as the priority case.
A probe through the push path shows it.
PushHandler.handlePushEventtakes events from two servers, with three references in all, andfetch_referenceis then called on each stub through the framework's dispatch. Onmain, each stub ends "unbound: no server is recorded to fetch it from",fetch_referenceanswers each with "reference unbound: no server is recorded to fetch it from", and nothing is dialed.Binding these as they arrive raises one more thing. The registry keys a record by uri and keeps the first server it's given (
src/mcpl/references.ts:262). Onmain, only a tool result's registration names a server, so a relay's earlier push of the same uri stayed unbound until the uri's own server bound it. With every lane binding, the first server to deliver a uri would decide it. A relay's push of a uri on another server's origin would bind it to the relay, and that server's own later reference would be refused by the origin check for as long as the record lived.Changes
convertBlocktakes the id of the server that sent the content and passes it toreferenceStubOrNull, which registers the reference with it. The parameter is required, so a call that names no server doesn't compile. Its callers name their server:PushHandler.handlePushEvent, the event's;ChannelRegistry.handleIncoming, the message's;HookOrchestrator, through its injection conversion, the server whosebeforeInferenceanswer carried the injection.executeToolCallor a module'sctx.callTool, registers its result's references with its server:executeMcplToolCallrunsautoFetchReferenceswith its server on a successful result, without the eager fetch, so nothing is fetched until the agent asks. A failure keeps only its text, so its references aren't registered.ReferenceRecord.serverIds, in place ofserverId. The fetcher dials through the first of them whose address is on the uri's origin. When none is, it refuses with the first server's refusal: unknown to this host, or the origin check's. The eager check and the fetch both ask one private function for that server, so the eager limit, the token and the per-server budget a fetch is charged to are all the dialed server's. A server's token still goes only to its own origin, for a uri that server delivered. While the reference is unfetched, its claims, such as its digest, size and expiry, are still the latest any server gave, as before. A reference with no server is unbound, as the stub and the fetcher read it.What callers will notice
fetch_reference, where its stub said it was unbound, and so does a stub made later of an in-process call's successful result, such as the admin puppet's stored one.fetch_referencefetches one on its server's origin with one request. One on another origin is refused by the origin check ("third-party origin (… != dialed …); not fetched by this host"), and one from a server the host holds no address for is refused as "unknown server: …", with nothing dialed for either, as a tool result's would be.convertBlock, the reference registry andReferenceRecordaren't exported from the package's entry point.ChannelRegistryis (src/index.ts:85), and none of its 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:b66c8c7e: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), run without--test-force-exit, pass 154 of 154 on each runtime. CI runs the whole suite.test/mcpl-reference-binding.test.ts: each path, followed to the record's servers:test/helpers/coalescing-fixture.ts), bound to that server. The push event and the channel message each carry a reference of each formconvertBlockstubs (a resource, and uri-form image and audio), and the render results one resource each;HookOrchestrator.beforeInference, with one of each form, bound to the server whose answer carried it, with a server listed before it whose answer carried none;fetch_reference, through the framework's dispatch and its own fetcher, which resolves each server from its MCPL config: it fetches a push event's reference on its server's origin with one request, and refuses one on another origin and one from a server with no address ("unknown server"), with nothing dialed.test/mcpl-reference-fetcher.test.ts:test/mcpl-push-convert.test.tspasses a server toconvertBlock. The test of a stub-time registration with no server gaining the dispatch's server, andtest/tool-result-history.test.ts, read the record's list of servers.main's source. So that they readmain's behaviour rather than failing on the list's name,main's registry there gave each record a getter showing its one server, if any, as that list. 10 of their 83 tests fail there: seven of the eight binding tests, and three of the new fetcher tests (two servers, with the dialed one's token, eager limit and budget; a server with no address passed over; and the budget charged to the dialed server). The binding test of a failed in-process call passes there, sincemainregisters nothing for one either. Three new fetcher tests pass there as controls, sincemain's first server gives the same answer in their cases: of two servers on the uri's origin the first to deliver it is dialed, a uri on none of its servers' origins gets the first server's refusal in either order, and an unknown first server's refusal comes ahead of an off-origin one's. The two changed tests that read the list pass there too, sincemaingives a record with no server the first server it's given.tscthere reports one type error, forconvertBlock's third argument, whichmain's doesn't take, and emits anyway.main: the probe above, throughPushHandler.handlePushEventand the framework'sfetch_referencedispatch.convertBlock's registration with its server, at its three stub sites at once and at each alone (image, audio and resource);Not verified
Out of scope
Other open pull requests
This change merges cleanly with each open pull request that merges cleanly with
mainbut one. #506 meets it in two places:UNBOUND_REASON's doc comment, where #506 also rewords the reason itself, and the line inexecuteMcplToolCallthat answers a server's error, where #506 passes the server and the tool's name tomcplToolFailureand this change registers a success's references just after. A merge of the two takes both sides of each. Another meeting isn't textual: #262 and #337 changeautoFetchReferences' third parameter fromeagertoforScript, and, by our reading, under theirs the eager pass runs on every call. This change's in-process call passesfalse, to register without fetching. With either of them merged as well, this change'sfalsewould, by our reading, no longer keep the in-process call from their eager pass.Outside this repository, mcpl's RFC-005 §11 says the Framework's push lane registers a reference with no server. After this change it registers one with the event's server.
What each part is for
convertBlockis what the change is for: every lane that brings an MCPL server's content in passes through it, so naming the server there binds every reference those lanes carry. Required rather than optional, because an optional one is how the lanes came to register with no server: a caller that leaves it out still compiles.fetch_referencedraws on and which doesn't renew, on bytes nobody asked for, and write files underrefs/that nothing in the framework removes. All they'd buy is onefetch_referencecall, which the agent can make when it wants the bytes. Only a success registers, since a failure keeps only its text and no stub is made of its references.Review
Reviewed internally: a review of an earlier form of this change, whose findings it answers. Nigel-2654 (Claude Opus 5.5) read and approved the change. Ada-1017 made the design calls.
changelog.d/converter-references-bound.fixed.md— Pippa-2571 (Claude Opus 5.5), contributing through nissa-seru
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.