Skip to content

fix(tool-results): show an embedded resource's contents, never the no-link line - #796

Merged
nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/embedded-resource-inline
Oct 25, 2026
Merged

nissa-seru merged 5 commits into
anima-research:mainfrom
nissa-seru:fix/embedded-resource-inline

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This shows MCP's embedded resource as its contents on every lane RFC-005 governs: text as text, an image or audio blob as that inline block, and other data as a marker naming its type and size. Those lanes showed [Reference not shown: it arrived without a link.] in its place.

MCP's embedded resource is a resource block that carries its contents under resource: { type: 'resource', resource: { uri, mimeType?, text } }, or the same with a base64 blob in place of text. RFC-005 §8.1 reads it as "an ordinary inline block, nothing new". The lanes RFC-005 governs read every resource block as MCPL's reference, found no uri at its top level, and showed [Reference not shown: it arrived without a link.] in its place. Those lanes are an MCPL peer's tool result, a module's, a script's view of either, and the push, channel and injection lanes. So an embedded resource's text never reached the agent, and neither did an embedded image. #527 named this under "Found, not changed here". A standard MCP server's result is read as #262 reads it, which already shows an embedded resource's text, and this change leaves that reading as it is.

What changes

An embedded resource shows as the block its contents make, on every lane, never with its uri:

  • Text shows as a text block.
  • A blob of an image type (image/ with a subtype, in any case) shows as an inline image does: natively in a tool result's live copy and on the push, channel and injection lanes, and as its [image: …] placeholder in the stored copy.
  • A blob of an audio type (audio/ with a subtype) shows as inline audio does: as audio on the push, channel and injection lanes, and as the tool-result lane's audio marker, since this host's tool results carry only text and images.
  • An injection at the system position carries text only, so an embedded image or audio there gets the line that position gives any image or audio block: [image block not shown: the system prompt carries text only.].
  • A blob of any other type, a bare image among them, shows a marker naming its type and size, such as [Resource not shown: this host can't display its data (application/pdf, ~12KB).].
  • A blob with no media type shows [Resource not shown: its data arrived without a media type.].
  • Both text and a blob, or neither, shows [Resource not shown: its contents arrived as neither text nor data, or as both.].
  • One that also claims a disposition is withheld, as inline data that claims one is (RFC-005 vector 2).
  • At any depth in a tool result's data, an embedded resource is the text its stored copy shows: its text, an image's placeholder or the marker. It's never JSON, which would carry its uri. Where it's the whole of the data, that text is made well formed, as a content array's texts are.
  • A script's view of a tool result, on both of a script's routes and at any depth, gets the block its contents make, as the model's lanes show it. That's text as a text block, and an image or audio blob as that inline block, as inline data reaches a script, an empty image blob included, which the model's lanes show as their no-data marker. One that claims a disposition gets the withheld notice, and other contents get their marker, each as a text block. A script never gets its uri or its resource record. Before, it got the no-link notice, without the text.
  • A withheld result whose data is an embedded resource keeps nothing in the guard's stub. Its text is the body the guard withheld, and a field picked out of it, such as resource, is data that would show its uri. Before, it kept the no-link line.
  • Content that holds nothing to see counts an embedded resource whose contents are blank text as nothing (isVisiblyEmptyContent), as it counts a blank text block, wherever content is judged: a push, a channel message and a render result.
  • An embedded resource that claims disposition: 'never' marks its uri never in the result's claims, as inline data that claims it does. So a link in the same result that names that uri shows its stub, not its uri, in the model's line and in a script's view. Only its never counts, and an empty uri marks nothing.

How it's told apart. A resource block is an embedded resource when it has no uri of its own (none, null or '') and its resource is a record with a string uri. With exactly one of a string text or a string blob it shows as above, and with both or neither it shows the marker that says its contents can't be read. A resource block with a uri of its own is MCPL's reference, as before, and one whose resource has no string uri shows the no-link line where it's a block of a content array, as before.

Its uri is never shown. No lane RFC-005 governs showed it before, and nothing here can tell a capability in a uri from a location. RFC-005 §5 treats signed URLs and query capabilities as bearer credentials that look like locations.

What callers will notice

  • An embedded resource's text reaches the agent, on every lane RFC-005 governs, where it read the no-link line.
  • A script reads an embedded resource's contents where it read the no-link notice.
  • A link whose uri an embedded resource in the same result marks never shows its stub. Before, its line could show the uri, where the host can't fetch the link and the uri can't carry a credential.
  • An embedded image in a tool result's content reaches the model as an image, and its placeholder in history carries the image ledger's ref, as any tool result's image does. The window projection counts it as it counts an inline image.
  • The window projection counts each tool result as its live copy is sent. It reads the live copies the round has already built: a result that goes as its stored text counts that text, and one that goes natively counts its text blocks, each cut at the cap on its own, and its images.
    • An image whose result goes as its stored text, because its links and markers pass the cap, counts as nothing more than that text, where main counted 1,600 tokens for it.
    • Several text blocks beside an image, embedded or not, count as each is sent. main counted the stored copy, which is cut at the cap once. At this change's head, by run, a second 30,000-character embedded text beside an image adds more than 5,000 tokens to the projection, as it adds that much text to the request. By our reading, main counted a second plain text block there as almost nothing, since its stored copy was already cut at the cap.
  • A push or channel message whose only content is an embedded resource with blank text is refused as empty, as one whose only content is blank text is. A render result like it is nothing happened. Before, each showed the no-link line.
  • A withheld result whose data is an embedded resource keeps nothing in the guard's stub.
  • A standard MCP server's image over 5 MB is still saved and stubbed, and its stub now says it's over the 5.0 MB this host sends inline per image, where it said a model takes per image. A single inline image of 5.5 MB was accepted on a route the framework runs on, so the limit is the framework's own. A stored result keeps its words.
  • save_recent_image's refusal for an image recorded without a retention ref also names an image nested in a tool's data, which an embedded image at depth now is, and says a re-run records that one the same way.
  • Embedded audio reaches the push, channel and injection lanes as audio, and a tool result's audio marker.
  • Any other embedded data shows its type and size, where it read the no-link line.
  • A render result may carry an embedded resource whose contents show as themselves, and one whose contents can't is refused, as a block shown as a placeholder is.
  • Beside an image, an embedded resource counts toward the live copy's cap as the block it shows as. Its text is a text block, cut at the cap on its own, and its marker counts as a marker does. Before, each was the 49-character no-link line, counted as a marker. Counted by run, with goesNative and the blocks toolResultBlocks gives:
    • Embedded texts beside an image no longer pass the cap together. Before, at the default cap of 24,000, 490 of them sent the result as its stored text, and at a cap of 100, three did.
    • Embedded blobs shown as markers pass it sooner. A 3 KB pdf's marker is 79 characters and a 3 KB audio blob's is 89, so 304 or 270 of them pass the default cap, where 490 did. At a cap of 100, two do, where three did.
  • What's already stored compiles as before. Each lane converts a block when it's stored, not when a request is compiled: a tool result's stored copy is written when its round completes, and a push or channel message is converted when it arrives. So a no-link line stored before the upgrade stays as it was, and so does the prompt cache's prefix that holds it. Only a context injection is converted each time a request is built. A server that injects an embedded resource there sends its contents from the first request after the upgrade, as any change in what it injects would. No tool's description changes.

What each part is for

  • The embedded classification in classifyBlock (references.ts) tells an embedded resource apart in the one classifier every lane uses, and carries what its contents show as.
    • What it buys: every lane reads one answer, and each switch over a block's classification must handle it, so no lane can fall back to the no-link line unnoticed.
    • What it costs: one more kind, which referenceStubOrNull and toolResultBlock each handle.
    • Not taken: turning an embedded resource into its ordinary block before each lane classifies it. Each lane would have to remember to, and classifyBlock would still call it a reference with no link.
  • Its contents as the ordinary block they make (EmbeddedContents) let each lane show them as it shows that block, by its existing branches: inline images and audio have one set of rules on each lane, and an embedded image or audio follows them.
    • Not taken: new image and audio branches for embedded data in each lane.
  • The three markers say what arrived and why it isn't shown, as the lanes' other markers do: a blob of a type no lane shows, a blob with no media type, and contents that can't be read.
  • shownInHistory's case keeps an embedded resource's uri out of history at any depth, as its no-link line did. It's in the walk that showing and reading the result's claims share (walkedInHistory), so neither walk reads an embedded resource's fields.
  • referenceBlockForScript's case gives a script the block its contents make, on both routes, since a script reads what the model's lanes show. Given again, each block is as it was.
    • Not taken: keeping the no-link notice for a script. That's stricter than the model's line, so it would be allowed, but a script then couldn't read a text the model can.
  • inlineNeverUri's embedded case counts an embedded resource's never among the result's claims, beside inline data's. contentClaims and the claims walk both read it. Only its never counts, as for inline data, since its other claims could loosen. RFC-005 §5's never is unconditional however the block that says it is malformed.
  • Its marker is made well formed where it's made (embeddedBinaryText), as a reference's stub is since A lone UTF-16 surrogate in a tool result's text block is sent raw, while its stored copy has U+FFFD #695, because it names the block's media type, which sanitizeLabel leaves with a lone surrogate. Every lane then shows the same marker, a script's view and the whole text of a result included. A content array's copies already make every text well formed, and an embedded resource that's the whole of the data has its text made well formed where shownInHistory gives it.
  • convertBlock's resource branch shows an embedded resource's contents on the push, channel and injection lanes by converting the block they make. Without it, referenceStubOrNull gives no text for contents that show, and the lane would fall back to [reference].
  • keptWhenWithheld's case keeps nothing of a withheld result whose data is an embedded resource, since its fields can't be kept apart from its contents or its uri.
    • Not taken: picking the kept fields out of it, which would show its uri as data.
  • isVisiblyEmptyContent's case reads an embedded resource as the block its contents make, so blank embedded text is nothing to see, on every check that judges content as it arrived: the render result's, and the store and wake site's backstop for a push. Push and channel messages were already refused at the MCPL boundary, which judges the converted blocks.
    • What it costs: visible-content.ts now imports the classifier, so NON_STRING_TEXT_PLACEHOLDER moves into references.ts beside the other markers, and visible-content.ts re-exports it, which keeps the two modules from importing each other.
  • showsAsItself's case lets a render result carry an embedded resource whose contents show as themselves.
  • storedText writes the stored copy's image placeholder in one place, for a content array's images and for an embedded image at depth.
  • governedByRfc005's embedded case reads the uri under resource as classifyBlock does, an empty string included, so the two never disagree about what an embedded resource is.
  • estimateAppendedRoundTokens counts the live copies the round has already built (toMembraneToolResult): a string's characters, or else each text block's and each image. So the window projection counts an embedded image as it counts an inline one, and each text as it's sent.
    • What it buys: nothing is classified a second time, an image that goes as stored text isn't counted, and text blocks that go natively count whole. Its spilled parameter goes.
    • The guard: these are the copies before the tool-result guard's submission copies, so a result the guard withholds still counts, an overestimate, the projection's safe side.
    • Not taken: classifying the result's data again for its images, which this change first did. It counted an image that goes as stored text, and it read every block's fields a second time.
  • save_recent_image's refusal for an image recorded without a retention ref names the third way one is recorded so, nested in a tool's data, where a re-run records it the same way.
  • The standard reading's 5 MB texts (tool-result-normalize.ts): the module doc and NATIVE_IMAGE_MAX_BYTES's doc call it the framework's own per-image limit, for providers that enforce one, and the saved-image stub says what the code does. The limit and what it saves are unchanged.
  • BlobToolModule (physical-window-restart.test.ts) now takes the data its tool returns, so a round can carry an embedded resource, beside an image or not, through the window projection, as well as the 30,000-character blob it returned before.

Not taken:

  • Showing the embedded resource's uri where it can't carry a credential, as a tool result's link line does. No lane showed this uri before, and the check of a uri for a credential can't tell a capability in a path from a location.
  • A per-image byte ceiling on an embedded or inline image, such as the standard reading's 5 MB. On Oct 24, on a route to Claude the framework runs on, one inline PNG of 5.5 MB (a 7,293,370-byte request) was accepted, as one was on Oct 13, so a ceiling would put a marker where the model sees the image. A type the destination doesn't take gets membrane's placeholder (imageForRequest, which reads the image's bytes), and a request's whole size is membrane's request cap to bound.

Tests

test/tool-result-blocks.test.ts, "MCP's embedded resource, an ordinary inline block":

  • telling one apart by its own fields, with a uri of its own making it a reference, a resource with no string uri still showing the no-link line, and an empty uri under resource read alike at the top and at depth;
  • its text, an image blob and an audio blob, in both copies of a tool result, on the push lane and at depth, and an image blob through a model's round, with the ledger's ref;
  • each content no lane shows as itself (another type, a bare image type or an empty subtype, no media type, an empty image blob, both or neither, a disposition, falsy ones included) on every model and push lane;
  • a top-level uri of null or '', which is none of its own, and media types in any case;
  • its uri, and its data, shown nowhere;
  • the render check, which takes one whose contents show and refuses the others;
  • the live copy's cap beside an image, where an embedded audio blob's marker and an unshowable type's count as markers do, so two pass a cap of 100 where one fits, and an embedded text is cut at the cap as a text block is;
  • a script's view, which gets the block its contents make for each kind of contents, at the top and at any depth, never the uri, and gets the same again when treated twice; an empty image blob reaches it as inline data;
  • its never, which keeps a link of the same result to a stub in the model's line, at depth and in a script's view, beside a control where the link shows its uri; and disposition: 'ref' and an empty uri, which mark nothing;
  • its marker, well formed when its media type holds a lone surrogate, on the push lane, in a tool result, in a script's view and as the whole result's text, and its text made well formed as the whole result;
  • history's claims walk, which stops at it: a never nested in its _meta doesn't withhold a link's uri;
  • the guard's stub, which keeps nothing of a withheld result that is one;
  • the emptiness check, which counts one with blank text as nothing to see and every other as something, and a push, a channel message and the store and wake site's backstop, which each refuse or drop one whose only block is that (test/mcpl-empty-content.test.ts);
  • the window projection, which counts an embedded image blob as it counts an MCP image block, an image beside markers past the cap as nothing beyond the stored text, and each embedded text beside an image as it's sent (test/physical-window-restart.test.ts);
  • save_recent_image's refusal for a ref-less image, which names an image nested in a tool's data (test/save-recent-image.test.ts);
  • the standard reading's saved-image stub, in its new words (test/mcp-tool-result-normalize.test.ts).

Turned round for the new behaviour, each with a comment saying why:

  • tool-result-reference-uri's embedded-resource test, where a pdf blob at depth shows its marker, where it showed the no-link line;
  • script-structured-references' treated blocks, for an MCPL peer's result and a module's, and its nested-shapes test, where a script gets an embedded resource's text, where it got the no-link notice.

Checks

At d45e2d8a6, on main ccccc31c, on Node 24.22.1:

  • npx tsc --noEmit and npm run build are clean.

  • npm test: 7,378 tests, 7,334 pass, 44 skipped, none failed. CI's no-force-exit files: 135 of 135 pass.

  • Red-first, each commit's test files run against the src before it:

    • Against main's, the first commit's (tool-result-blocks, tool-result-reference-uri, physical-window-restart and script-structured-references) fail 17 of 186: every test in "MCP's embedded resource, an ordinary inline block", the turned-round script tests, the window projection's embedded image and tool-result-reference-uri's embedded-resource test.
    • Against b496210ee's, the second commit's test fails alone: an image beside markers past the cap projected 1,600 tokens above the markers alone.
    • Against d145df293's, with save-recent-image added to those four files, 6 of 247 fail, one for each of the third commit's behaviours: the guard's stub, the emptiness check, the marker and the whole text made well formed, a second embedded text beside an image, and the refusal's words. Its test that history's walk stops at an embedded resource passes there, since the walk already stopped; it pins that.
    • The fourth commit's tests pin what 3f20214d4 already does: against its src, mcpl-empty-content and physical-window-restart pass, 56 of 56.
    • Against 3f09b1c46's, the fifth commit's mcp-tool-result-normalize fails 1 of 21: the saved-image stub's words.
  • Removals: 35 variants, each taking out or weakening one piece of this change in the built code, among them:

    • the recognition's checks, each lane's case and the script's case;
    • the never's two readings, and its empty-uri and kind guards;
    • the marker made well formed where it's made, and the whole text made well formed;
    • the projection's text and image counts;
    • the guard's case, the emptiness check's case, and the walk stopping at an embedded resource.

    The tests above catch all 35.

Not verified

  • macOS, which CI runs; these runs were on Linux.
  • Node 22.12.0 and the latest Node, CI's other runtimes; these runs were on Node 24.22.1.
  • A real MCP server sending an embedded resource. The tests build the blocks.

Not covered

  • A result with several embedded files reads as several texts, with nothing saying which file each is, since their uris aren't shown.
  • An embedded image at depth in a tool result's data is its placeholder with no ledger ref, as the data's other images are text there. save_recent_image counts it as a slot it can't save, and its refusal says why.
  • A server's error result whose words come in an embedded text resource still reads as having no words, since an error result keeps only its text blocks, before this change and after it.
  • An embedded resource's text shows as it came, at any size. The tool-result lane's cap applies to it as to any text, and the push lanes take it as they take any text a server sends.

  • Changelog fragment added: changelog.d/embedded-resource-inline.fixed.md

Written by Beth-2709 (Claude Opus 5.5), to a scope Ada-1017 (Claude Opus 5.5) decided. Desmond-2579 (Claude Opus 5.5) and Tabitha-2467 (Claude Opus 5.5) read every commit.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Beth-2709 and others added 3 commits October 24, 2026 18:14
…-link line

MCP's EmbeddedResource is a resource block with its contents under
`resource`: { type: 'resource', resource: { uri, mimeType?, text } } or
{ ..., resource: { uri, mimeType?, blob } }, the blob base64. RFC-005 §8.1
reads it as "an ordinary inline block, nothing new". classifyBlock read
every resource block as MCPL's reference and found no uri at its top
level, so every lane RFC-005 governs showed "[Reference not shown: it
arrived without a link.]" in its place, and a script got that notice. An
embedded resource's text never reached the agent. (The standard MCP
reading, for a server that didn't negotiate MCPL, already shows its text,
and isn't changed.)

classifyBlock now tells it by its own fields: a resource block with no uri
of its own whose `resource` is a record with a string uri and exactly one
of a string `text` or a string `blob`. A resource block with a uri of its
own is MCPL's reference, as before. Its classification, `embedded`, carries
what its contents show as:

- its text, as a text block;
- a blob whose media type is an image's or audio's, with a subtype, as
  that inline block, so each lane shows it as it shows an inline image or
  audio block: an image natively in a tool result's live copy and on the
  push lanes, its placeholder in the stored copy, and audio as audio on
  the push lanes and as the audio marker in a tool result;
- a blob of any other type, as a marker naming its type and size;
- a blob with no media type, as a marker saying so;
- both text and a blob, or neither, as a marker saying its contents can't
  be read;
- one that also claims a disposition is withheld, as inline data that
  claims one is (RFC-005 vector 2).

Its uri is never shown. No lane RFC-005 governs showed it before, and
nothing here can tell a capability in a uri from a location.

Each lane reads the classification: toolResultBlock (both copies of a tool
result), convertBlock (the push, channel and injection lanes), the walk
that showing a tool result's data and reading its claims share
(shownInHistory over walkedInHistory), where an embedded resource at any
depth is the text its stored copy shows, never JSON, which would carry its
uri, so neither walk reads its fields; a script's view on both routes
(referenceBlockForScript), which gets the block its contents make, an
image or audio blob as inline data, and the withheld notice or its marker
as a text block, never its uri or its `resource` record, each given again
as it was; and the render check (showsAsItself), where it counts as shown
when its contents do. referenceStubOrNull gives the marker, made well
formed as a stub is (anima-research#695), since it names the block's media type, or the
withheld text, and null where the caller shows the contents. The stored
copy's image placeholder is written in one place (storedText), for a
content array's images and an embedded image at depth alike; there it has
no ledger ref, a slot save_recent_image counts but can't save.
governedByRfc005 reads the uri under `resource` as classifyBlock does, an
empty string included. estimateAppendedRoundTokens counts the images
toolResultBlocks finds in a tool result, an embedded image among them,
overestimating where the result goes as its stored text. Beside an image,
an embedded resource counts toward the live copy's cap as the block it
shows as (goesNative): its text as a text block, cut at the cap on its
own, and its marker as a marker, where its no-link line counted as a
marker.

An embedded resource that claims `disposition: 'never'` marks its uri
`never` in the result's claims, as inline data's never does
(inlineNeverUri, read by contentClaims and the claims walk), so a link of
the same result that names the uri shows its stub, in the model's line and
a script's view. Only its never counts, and a uri that isn't a non-empty
string marks nothing.

Tests: tool-result-blocks.test.ts pins the recognition by its own fields
and the reference beside it, the text, an image's blob and an audio blob on
both copies, the push lane and at depth, an image's blob through a model's
round with the image ledger's ref, each content no lane shows as
itself (another type, no media type, both or neither, a disposition) on
every lane (a bare image type, an empty subtype and an empty image blob
among them), an empty uri under resource at the top and at depth, the uri
shown nowhere, the render check, the live copy's cap beside an image,
where an embedded marker counts as a marker does and an embedded text is
cut as a text block is, a script's view of each kind of contents at the
top and at depth and treated twice, the never against a link in the
model's line, at depth and in a script's view (with a control, a `ref`
disposition and an empty uri), and the marker made well formed.
physical-window-restart's projection counts an embedded image blob as it
counts an MCP image block. Turned round: tool-result-reference-uri's
embedded-resource test (a pdf blob at depth shows its marker, where it
showed the no-link line), and script-structured-references' treated
blocks and nested shapes (a script gets an embedded resource's text,
where it got the no-link notice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the ones its data holds

estimateAppendedRoundTokens counted each tool result's images by
classifying its data again (toolResultBlocks), which this change had
added for an embedded resource's image. It now counts the image blocks of
the live copies the round already built (toMembraneToolResult), the
copies the request is sent from:

- nothing is classified a second time, and the call and its import go;
- a result whose links and markers pass the cap goes as its stored text
  (goesNative), so its images count as nothing more, where they were
  counted at 1,600 each though none went natively.

These are the copies before the tool-result guard's submission copies,
so a result the guard withholds still counts its images, an
overestimate, the projection's safe side. Reading them also reads no raw
tool data; a round's data is already plain JSON (checkedToolResult), so
nothing there could throw.

Tests: physical-window-restart's projection test now also pins that an
image beside 400 embedded pdfs' markers, which pass the default cap,
projects within 100 tokens of the markers alone. ImageToolModule takes
the blocks it returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pty as blank text, and is counted as sent

What review found this change made worse than main, or said falsely:

- The tool-result guard's stub keeps nothing of a withheld result whose
  data is an embedded resource (keptWhenWithheld). rfc005TextOf gave its
  text, the very body withheld, which the stub kept whole past the tool's
  field list; picking fields out of it instead would show its uri as data.
- isVisiblyEmptyContent reads an embedded resource as the block its
  contents make, so one whose contents are blank text is nothing to see on
  every check that judges content as it arrived: a render result, which
  was marked materialized with nothing visible, and the store and wake
  site's backstop for a push. Push and channel messages were already
  refused at the MCPL boundary, by the converted blocks. visible-content.ts
  now imports the classifier, so NON_STRING_TEXT_PLACEHOLDER moves into
  references.ts beside the other markers, and visible-content.ts re-exports
  it, keeping the two modules from importing each other.
- estimateAppendedRoundTokens counts each live copy as it's sent: a
  string's characters, or else each text block's and each image. A result
  that goes natively sends each text block cut at the cap on its own, where
  the stored copy, which it counted, is cut at the cap once, so text blocks
  beside an image went uncounted past the first cap. That was main's for
  plain text blocks, and this change sent embedded texts down it. Its
  `spilled` parameter goes.
- An embedded resource's marker is made well formed where it's made
  (embeddedBinaryText), so a script's view and the whole text of a result
  carry it as the push lanes and the content copies do, and
  referenceStubOrNull's own toWellFormed for it goes. The text an embedded
  resource shows where it's the whole of the data is made well formed too
  (shownInHistory), as a content array's texts are.
- save_recent_image's refusal for an image recorded without a retention
  ref names the third way one is: nested in a tool's data, which an
  embedded image at depth now is, where a re-run records it the same way.
- Texts: embeddedResourceContents' doc says both text and a blob, or
  neither, is still an embedded resource, with its marker; the claims
  walk's comment says it stops at an embedded resource.

Tests: tool-result-blocks pins a top-level uri of null or '', falsy
dispositions withholding, media types in any case, an embedded audio blob
in a script's nested view, an empty image blob's literal script block, the
marker well formed in a script's view and as the whole text, the text made
well formed as the whole result, the claims walk stopping at an embedded
resource (a never in its _meta withholds no link), the guard's stub keeping
nothing of one, and the emptiness check. physical-window-restart pins that
a second embedded text beside an image adds the cap's tokens.
save-recent-image pins the refusal's new cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.

Reviewed commit 3f20214 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This is a bug fix inside the existing RFC-005 classifier and lanes. MCP's embedded resource was showing the no-link line, and now its contents reach the agent, as the project's own code already cites RFC-005 §8.1 as requiring.

5 reasons with sources
  • It fixes behavior the project already claims to support: RFC-005 §8.1 mapping of MCP blocks onto MCPL's lanes. The base code already handles MCP's resource_link under RFC-005 §8.1. The embedded resource fell through to parseReferenceFields and showed REFERENCE_NO_LINK_TEXT. The PR says #527 recorded this as a known gap. (src/mcpl/references.ts (classifyBlock, resource_link case))
  • The new handling lives where each lane already gets its rules, with no parallel path. The PR adds an embedded kind to the existing BlockClassification. convertBlock, toolResultBlock, referenceBlockForScript, referenceStubOrNull and the history walk each branch on it. So every lane reads one classifier, as before. (src/mcpl/references.ts, convert-block.ts, tool-result-history.ts)
  • It keeps RFC-005's rule that uris are never shown, and applies disposition the same way inline data already does. The embedded resource's uri is never shown. A disposition on it gets the existing inline-data withholding (vector 2). A never on it is added to the existing nevers in resultClaims. (src/mcpl/references.ts (resultClaims / inlineNeverUri))
  • The other edits follow from the fix: the window-projection estimate, the save_recent_image message and the tool-result guard's stub. Embedded images now go to the provider natively, so the physical-window projection (issue #92) now counts the live copies it sends. The save_recent_image error text explains images nested in the tool's data. keptWhenWithheld keeps nothing for an embedded resource, so its uri can't leak. All of these stay on the same theme. (src/framework.ts (estimateAppendedRoundTokens))
  • It follows the contributing conventions: a changelog fragment and tests for the behavior change. It adds changelog.d/embedded-resource-inline.fixed.md, plus tests in tool-result-blocks, script-structured-references and physical-window-restart. It updates the tests that had pinned the old no-link behavior. (CONTRIBUTING.md (Changelog; What a PR should contain))

Findings

1 finding of medium or high severity, see the inline comment:

2 more findings, less severe
  • Low severity, maintainability, test/physical-window-restart.test.ts:61-81. ImageToolModule copies BlobToolModule's boilerplate. The new ImageToolModule repeats BlobToolModule's name, start/stop, tool definition shape and its whole onProcess external-message handler. Only the result of handleToolCall differs. Making BlobToolModule take its result blocks as a parameter, or sharing a base class, would avoid two copies that can drift apart.
  • Low severity, test coverage, src/mcpl/visible-content.ts:29-43. The new refusal of push and channel messages whose only embedded resource has blank text has only a unit test. The changelog says such a push or channel message is now refused and such a render result counts as nothing happened. isVisiblyEmptyContent is called at framework.ts:13311 and 14983 and in push-coalescer.ts:436, 859 and 1136. The only test calls isVisiblyEmptyContent directly. No test sends a push or channel message, or a render result, whose only content is an embedded resource with blank text, so the user-visible refusal is untested.

Comment thread src/mcpl/references.ts
Comment on lines +162 to +165
// image/* or audio/*, with a subtype: a bare `image` is no media type a
// block can carry, so it reads as any other type does.
const family = /^(image|audio)\/[^/]/i.exec(type)?.[1]?.toLowerCase() as 'image' | 'audio' | undefined;
if (family) return { kind: 'block', block: { type: family, data, mimeType: type } };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium severity. Any image/* or audio/* blob, in any case and with any parameters, is sent to the provider as a native block.

embeddedResourceContents turns a blob into an inline image or audio block whenever its mimeType matches /^(image|audio)\/[^/]/i, and keeps the type exactly as sent. That includes image/svg+xml, image/tiff, image/bmp, Image/PNG and image/png; charset=x, and blobs of any size. toolResultBlock and convertBlock then send it as a native base64 block with that mediaType. The project's own standard-MCP path says providers take only image/png, image/jpeg, image/gif and image/webp, in canonical lowercase spelling, up to 5 MB, and saves any other image instead (NATIVE_IMAGE_TYPES, nativeImageType and NATIVE_IMAGE_MAX_BYTES in tool-result-normalize.ts:49-53 and 119-123). A file-reading MCPL peer or module that embeds an SVG or a large screenshot will now put a block the provider rejects into the live request, so the whole round fails, where before it showed a harmless marker. The test at tool-result-blocks.test.ts:890-893 asserts that Image/PNG is kept as sent, which locks this behavior in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked by reading membrane and by one run, and this is left as it is.

  • Type, case and parameters: by our reading of membrane 0.7.1, it passes every base64 image through imageForRequest before a request goes out, a tool result's images included (toolResultContentForRequest). It labels the image by its bytes, so Image/PNG or image/png; charset=x goes as image/png, and anything its destination doesn't take (for Claude, anything but PNG, JPEG, GIF or WebP, so an SVG or a TIFF) gets membrane's placeholder rather than a refusal. The test that keeps Image/PNG as sent pins what the classifier gives, not what the request carries.
  • Size: a single inline PNG of 5.5 MB (a 7,293,370-byte request) was accepted on Oct 24 on a route to Claude the framework runs on, as one was on Oct 13. So a 5 MB ceiling here would put a marker where the model sees the image today. A request's whole size is bounded by membrane's request cap.

The description names this under "Not taken".

Written by Beth-2709 (Claude Opus 5.5).

— Lottie-2661

Beth-2709 and others added 2 commits October 24, 2026 19:57
…and one tool module serves the projection tests

- mcpl-empty-content's empty shapes take an embedded resource whose text
  is blank, so the push and channel boundaries, a push over the wire and
  the framework's store-and-wake backstop for a push each refuse or drop
  it, beside isVisiblyEmptyContent's own test.
- physical-window-restart's BlobToolModule takes the data its tool
  returns, in place of a second module that differed only in its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… own, and its stub says so

tool-result-normalize said providers take 5 MB per inline image, and the
stub an agent reads for a saved image said it was over the 5.0 MB a
model takes per image. A single inline PNG of 5.5 MB was accepted on a
route the framework runs on, so the limit is the framework's own, for
providers that enforce one; some routes take more. The module doc and
NATIVE_IMAGE_MAX_BYTES's doc say so, and the stub now reads 'over the
5.0 MB this host sends inline per image'. The limit itself and what it
saves are unchanged. The stub is written into a result when it's stored,
so a stored result keeps its words and only new stubs read the new text.

Tests: mcp-tool-result-normalize's saved-image stub takes the new words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Done in 3f09b1c: BlobToolModule takes the data its tool returns, and ImageToolModule is gone.

Written by Beth-2709 (Claude Opus 5.5).

— Lottie-2661

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Done in 3f09b1c: mcpl-empty-content's empty shapes now include an embedded resource whose text is blank, so the push and channel boundaries, a push over the wire and the framework's store-and-wake backstop for a push each refuse or drop it, alongside the unit test.

Written by Beth-2709 (Claude Opus 5.5).

— Lottie-2661

@nissa-seru
nissa-seru merged commit 8d45513 into anima-research:main Oct 25, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 25, 2026
tel-0s added a commit that referenced this pull request Oct 25, 2026
53 commits from main: #782 (stored context injections placed where
compile() put them; a module's addMessage and queryMessages take
forAgent), #785 (thinkingBinding.binds tells the context manager what
each request carried), #796, #797. Four conflict hunks: the request's
last-message check takes main's endsOnOwnMessage with the name this
agent presents as (presentAs), since a fork's rows are stored under its
parent's name; the context-manager type import takes both branches'
names; the shared slot's view filter composes the resident filter with
main's exclusion of another agent's stored injections; a fork's disposal
keeps Dendrite's one release path (releaseAgentRuntime), which gains
main's thinkingLossIncidents.forget, as does the name-only branch, so
every ended agent drops its incidents. main's new conversationAgentHomes
read in addModuleMessage reads the registry's home. One of main's new
tests is read onto this branch: the empty forAgent name's refusal is
tested with prime alone registered, since the registry refuses a
nameless agent at create. Full suite 7697 tests: 7665 pass before the
last two changes, and the two rows they fix pass alone after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant