Skip to content

fix(mcpl): references are bound to every server that delivered them - #510

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/references-bound-to-their-servers
Oct 18, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/references-bound-to-their-servers

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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_reference can fetch it from that server. These were registered with no server, so fetch_reference refused 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 main 0d19f7c8:

  • 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 with referenceStubOrNull(block, provenance) and no server (:37, :62, :75), which registers the reference with no server.
  • An MCPL tool called in process doesn't register its result at all. executeMcplToolCall (src/framework.ts:15709), behind the public executeToolCall (:15481, which the admin puppet calls at :15589) and a module's ctx.callTool (:2311), returns the server's content as it came (:15733).
  • Only the result of a call dispatched to its server, the agent's own or its script's, is registered with that server, by autoFetchReferences before the result is serialized (dispatchMcplToolCall, :20493).
  • The fetcher refuses a reference with no server before any other check (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.handlePushEvent takes events from two servers, with three references in all, and fetch_reference is then called on each stub through the framework's dispatch. On main, each stub ends "unbound: no server is recorded to fetch it from", fetch_reference answers 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). On main, 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

  • convertBlock takes the id of the server that sent the content and passes it to referenceStubOrNull, 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;
    • the framework's delivery of a coalesced push's render result, plain or channel-scoped, the push's;
    • ChannelRegistry.handleIncoming, the message's;
    • HookOrchestrator, through its injection conversion, the server whose beforeInference answer carried the injection.
  • An MCPL tool called in process, through executeToolCall or a module's ctx.callTool, registers its result's references with its server: executeMcplToolCall runs autoFetchReferences with 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.
  • A reference 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. 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.
  • Changelog: a fragment says what changes for each lane.

What callers will notice

  • Agents: a reference in a push event, a render result, a channel message or a context injection now offers 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_reference fetches 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.
  • Exports: convertBlock, the reference registry and ReferenceRecord aren't exported from the package's entry point. ChannelRegistry is (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:

  • At this head, b66c8c7e: tsc --noEmit is clean. The seven test files on references (mcpl-reference-binding, mcpl-push-convert, mcpl-reference-fetcher, puppet-tool-call, tool-result-history, mcpl-reference-fields and save-recent-image), run without --test-force-exit, pass 154 of 154 on each runtime. CI runs the whole suite.
  • New, in test/mcpl-reference-binding.test.ts: each path, followed to the record's servers:
    • a push event, a render result, a channel-scoped render result and an incoming channel message, each from a loopback MCPL server (test/helpers/coalescing-fixture.ts), bound to that server. The push event and the channel message each carry a reference of each form convertBlock stubs (a resource, and uri-form image and audio), and the render results one resource each;
    • a context injection through 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;
    • an MCPL tool called through the admin puppet, whose stored result's reference is bound to its server, so its stored stub offers the fetch, with nothing fetched though the fetcher calls the reference eager-eligible; and one whose call fails, whose reference is neither registered nor fetched;
    • 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.
  • New, in test/mcpl-reference-fetcher.test.ts:
    • a uri that two servers deliver is fetched through the one on its origin, in either order, with that server's token, eager limit and budget, and each server is listed once;
    • of two servers on the uri's origin, the first to deliver it is dialed, with its token, in either order;
    • a uri on none of its servers' origins, with a claimed size both servers' eager limits allow, is left out of the eager pass and refused with the first server's refusal, in either order;
    • a server unknown to the host ahead of one off the origin is the refusal, and the other way round the origin check's is;
    • a server with no address is the refusal while it's the only one, and is passed over once a server on the origin delivers the uri;
    • a fetch through the server on the origin, of a uri a relay delivered first, is charged to that server's budget, so that server's next reference is refused for it.
  • Changed: test/mcpl-push-convert.test.ts passes a server to convertBlock. The test of a stub-time registration with no server gaining the dispatch's server, and test/tool-result-history.test.ts, read the record's list of servers.
  • Red-first: these four files ran against main's source. So that they read main'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, since main registers nothing for one either. Three new fetcher tests pass there as controls, since main'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, since main gives a record with no server the first server it's given. tsc there reports one type error, for convertBlock's third argument, which main's doesn't take, and emits anyway.
  • On main: the probe above, through PushHandler.handlePushEvent and the framework's fetch_reference dispatch.
  • Removals: each of 19 pieces, undone in the built modules on its own, fails a test aimed at it:
    • convertBlock's registration with its server, at its three stub sites at once and at each alone (image, audio and resource);
    • each caller naming its server: a push event, a plain render result, a channel-scoped render result and an incoming channel message, and the injection's server at each of its three steps;
    • an in-process call's registration;
    • seven pieces of the record's list: a later server added, each server listed once, the fetcher looking past the first server, the first server's refusal when none fits, an unknown server's refusal kept when it comes first, a server with no address passed over, and the eager check taking the server the fetch would dial.
  • Mutations: six more, each failing a test: taking the budget, the eager limit or the total a fetch is charged to from the first server to deliver the uri rather than the one dialed; dialing the last of the servers on the uri's origin rather than the first; an in-process call fetching eagerly; and a failed in-process call registering its references.

Not verified

  • Platforms: every run here is on Linux. CI runs Ubuntu and macOS.

Out of scope

  • The stub's offer for a reference the fetcher will still refuse: one on none of its servers' origins, or one from servers the host holds no address for. A record holds its servers' ids, not their addresses, so the stub can't foresee those refusals, and the fetcher's refusal names the cause. A tool result's references read the same way.
  • A call the agent makes still registers, and eagerly fetches, the references in a failure's content, though its failure keeps only its text, as before.

Other open pull requests

This change merges cleanly with each open pull request that merges cleanly with main but one. #506 meets it in two places: UNBOUND_REASON's doc comment, where #506 also rewords the reason itself, and the line in executeMcplToolCall that answers a server's error, where #506 passes the server and the tool's name to mcplToolFailure and 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 change autoFetchReferences' third parameter from eager to forScript, and, by our reading, under theirs the eager pass runs on every call. This change's in-process call passes false, to register without fetching. With either of them merged as well, this change's false would, 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

  • The server on convertBlock is 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.
  • The in-process call's registration brings the one path that registered nothing in line with the agent's own calls: a stub later made of its references, such as the admin puppet's stored result, offers the fetch. It leaves out the eager fetch a call the agent makes 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, and write files under refs/ that nothing in the framework removes. All they'd buy is one fetch_reference call, 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.
  • The list of servers, not the first. Once every lane binds, a relay delivering another server's uri first would decide whether that uri can ever be fetched. Keeping each server that delivered it, and dialing the first on the uri's origin, makes the answer independent of which spoke first. Keeping only servers on the uri's origin would need their addresses when the reference arrives, and the registry is process-wide with no resolver. The first server's refusal, when none fits, keeps one refusal and one order a reader can follow.
  • One function choosing the server for both the eager check and the fetch, so the two can't disagree about which server's limit, token or budget applies.
  • Each server once, so a server that sends the same uri again doesn't grow its record.

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 fragment added: 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-seru account.

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>
@greptile-apps

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

MCPL references now keep every server that delivered their URI, allowing fetch_reference to choose one on the URI's origin.

  • References from MCPL content keep the server that sent them.
  • Fetches use the first recorded server on a reference’s origin.

No actionable issues found. Tests were not run during this review.

Intentional behavior recorded from nissa-seru:

  • Stubs may still offer fetching for unknown or off-origin servers because records do not hold server addresses.
  • Agent-dispatched failures retain their existing eager behavior.
  • Unfetched claims remain latest-wins, and the first server on the matching origin remains preferred.

Reviews (1) · Last reviewed commit: "fix(mcpl): references are bound to every..." · Reviewed by Greptile

@nissa-seru
nissa-seru merged commit 7b21471 into anima-research:main Oct 18, 2026
7 checks passed
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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant