Skip to content

Wire MCP resources/read through host + chat renderer for resource_link#36

Merged
mgoldsborough merged 7 commits intomainfrom
feat/resource-link-support
Apr 17, 2026
Merged

Wire MCP resources/read through host + chat renderer for resource_link#36
mgoldsborough merged 7 commits intomainfrom
feat/resource-link-support

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

Summary

Completes the host-side half of the MCP resource_link story. Tools that return large artifacts (PDFs, images) can now produce tiny resource_link content blocks pointing at a URI, and the host fetches the bytes via standard MCP resources/read in three places that matter:

  1. New HTTP endpoint POST /v1/resources/read — symmetric with /v1/tools/call. Bundles expose resources, host proxies via the same middleware stack.
  2. Bridge plumbing — iframe apps can call synapse.readResource(uri) (the SDK counterpart is synapse#2). The bridge now has a case "resources/read": next to case "tools/call": that forwards to the new HTTP endpoint.
  3. Chat renderer — tool results with resource_link blocks now render inline in the chat stream via a new ResourceLinkView component that dispatches on mimeType (PDF → iframe, image/* → <img>, text/* → <pre>, else → download link).

Net: a tool returning a 50-page PDF via resource_link produces a ~450-byte tool result and the bytes are fetched out-of-band. No more 1MB tool-result cap failures on artifact-returning tools.

Why

Tools returning large binary artifacts used to inline base64 bytes in the CallToolResult.content. That hits the engine's 1MB maxToolResultSize cap (killed a multi-hour Collateral session; see issue context). Inlining also pollutes the model's context with useless data it can't read.

The MCP spec already offers resource_link content blocks for exactly this. A tool returns a URI; the client calls resources/read to get the bytes. This PR implements the client half for NimbleBrain — host + iframe + chat all speak the resource channel properly.

Architecture

iframe → synapse.readResource(uri)
  → postMessage "resources/read" → web bridge (parent frame)
    → fetch POST /v1/resources/read { server, uri }
      → handleReadResource → runtime.readAppResource(server, uri, wsId)
        → source.readResource(uri) → MCP JSON-RPC → bundle

Design principle: symmetry with tools/call. resources/read is a peer MCP method, not a special case. Same middleware, same proxy pattern, same shape — just a different spec method.

What changed

Server

File Change
src/api/handlers.ts New handleReadResource. Returns MCP ReadResourceResult JSON; base64-encodes blob for binary, passes text through. 403 cross-workspace, 404 null, 400 bad body. Exports bytesToBase64.
src/api/routes/resources.ts POST /v1/resources/read mounted alongside the existing GET route, sharing auth + workspace middleware with /v1/tools/call.
src/runtime/runtime.ts readAppResource now passes URIs with any scheme through unchanged (previously hardcoded ui:// prefix). Keeps ui:// fallback for legacy callers.
src/engine/content-helpers.ts New extractResourceLinks(content) helper + ResourceLinkInfo type.
src/engine/engine.ts Scans finalResult.content for resource_link blocks after tool execution, attaches them to the tool.done event under resourceLinks.
src/engine/types.ts Extends ToolCallRecord with optional resourceLinks.

Web bridge

File Change
web/src/bridge/bridge.ts New case "resources/read": handler. Mirrors case "tools/call": — same security posture (server scoped to appName unless internal), error envelope on failure.
web/src/bridge/types.ts ResourcesReadMessage, UiResourceResultResponse, UiResourceResultError wired into the host/app message unions.
web/src/api/client.ts New readResource(server, uri) client function + ReadResourceResult / ReadResourceContent types. Mirrors callTool.

Chat renderer

File Change
web/src/components/ResourceLinkView.tsx New component. Fetches via readResource(appName, uri), decodes base64, creates object URL, dispatches on mimeType (PDF iframe / image / text <pre> / download link). Object URL lifecycle managed (create on blob change, revoke on unmount).
web/src/components/MessageList.tsx Renders a <ResourceLinkView> per resource_link block on each tool call. Existing InlineAppView path untouched (different concern — ui:// HTML app binding).
web/src/hooks/useChat.ts ToolCallDisplay carries resourceLinks, populated from tool.done and from loaded conversation metadata.
web/src/types.ts ResourceLinkInfo, extended ToolDoneEvent + ToolCallRecord.

Test plan

  • test/unit/api/read-resource-handler.test.ts — 12 tests covering shape, binary/text dispatch, base64 encoding, workspace scoping, 400/403/404 paths, URI passthrough for any scheme (ui://, collateral://, https://).
  • test/unit/bridge-resources-read.test.ts — 4 tests: URI forwarding, appName scoping for non-internal apps, internal bundle override behavior, error envelope on failure.
  • test/unit/engine.test.ts — 2 new tests: resource_link blocks propagate onto tool.done.resourceLinks; absence doesn't create a spurious empty array.
  • bun run verify2179/2179 tests pass (1764 unit + 61 web + 338 integration + 16 smoke), lint clean, typecheck clean, build clean.

Dependency

Runtime-wise, this PR stands alone — the new endpoint works today against any MCP server that exposes resources. The iframe path specifically uses synapse.readResource which is added in synapse#2. For iframe apps to actually call into this, synapse#2 needs to land and be released first. For the chat renderer (in-app React component), no synapse dep — it calls /v1/resources/read directly.

Compatibility

  • Additive — no existing API changed. The GET /v1/apps/:name/resources/* route stays for ui:// HTML app serving. The POST /v1/resources/read is new.
  • runtime.readAppResource scheme generalization — was ui://-only, now passes any scheme through. Backward-compatible because the ui:// branch remains as fallback when no scheme is present in the input.
  • No config changes. No env vars. No migrations.

Risk

Low. The new endpoint is auth+workspace-gated via the same middleware that protects every other /v1 route. Binary content is base64-encoded per spec. The bridge forwarding is a direct pass-through — we aren't interpreting resource_link URIs, just carrying them.

The main user-visible change is that tool.done events now carry resourceLinks — any consumer (CLI, logs, conversation store) that deserializes these events will see the new field. All in-tree consumers updated.

Scans tool results for MCP-spec resource_link content blocks and
surfaces them on the tool.done event and ToolCallRecord. Distinct
from the existing resourceUri tool annotation (which binds static
inline UI apps) — resource_link is a per-call pointer the client
fetches via resources/read.

The 1MB maxToolResultSize cap is unaffected: resource_link blocks
are ~200 bytes each.
- getResources now inspects Content-Type and returns a discriminated
  {text|blob} response so binary payloads (PDFs, images) aren't
  corrupted by UTF-8 decoding. Existing HTML callers (InlineAppView,
  SlotRenderer) take the text branch.
- New ResourceLinkView component fetches the resource via the
  platform's /v1/apps/{app}/resources/{path} route and renders by
  mime type: PDF in an iframe, images inline, text in a <pre>,
  fallback download link. Revokes object URLs on unmount.
- MessageList renders one ResourceLinkView per resource_link block
  per tool call, alongside any existing InlineAppView.
- parseResourceResponse / isTextMimeType extracted and unit-tested.
Resource paths from the web are now URL-encoded full URIs so the server
can resolve MCP resources with custom schemes (e.g. collateral://).
readAppResource passes through any URI that already has a scheme and
keeps the ui:// construction as a fallback for legacy callers.
Expose MCP resources/read as a symmetric peer of tools/call. The handler
proxies through runtime.readAppResource, enforces workspace scoping, and
returns a spec-compliant ReadResourceResult with base64-encoded blobs
for binary content.
Add readResource() to the API client (POST /v1/resources/read) and a
matching bridge case alongside tools/call. Iframes calling
synapse.readResource(uri) now forward to the platform using the same
JSON-RPC envelope, workspace scoping, and error conventions as tool
calls.
ResourceLinkView now fetches through readResource() and dispatches on the
returned MIME type (PDF iframe, image tag, text pre, or download link),
building blob URLs from base64 and revoking them on unmount. The
URL-encoded URI-as-path hack is gone.

getResources() reverts to a text-only HTML fetch now that the binary
artifact path no longer flows through it — its two remaining callers
(InlineAppView, SlotRenderer) only ever wanted HTML.
… base64

- src/conversation/types.ts: StoredMessage.metadata.toolCalls gains
  resourceLinks so the reload path is typed end-to-end. Drops the
  'tc as Record<string, unknown>' cast in useChat.ts. Web-side mirror
  type in useChat.ts updated the same way.
- web/src/bridge/bridge.ts: INTERNAL_APPS hoisted to a module-level
  const. Was duplicated across tools/call and resources/read cases —
  a silent trust-boundary-drift hazard. Added a comment noting that
  URI SSRF safety lives in the bundle (only URIs advertised via
  resources/list resolve).
- web/src/components/ResourceLinkView.tsx: PDF iframe now carries
  sandbox="allow-scripts" (without allow-same-origin). The browser's
  native PDF viewer still runs its internal scripts, but in a null
  origin, so a malicious PDF can't reach cookies/storage/same-origin
  resources. Defense-in-depth for the blob-URL case.
- src/api/handlers.ts: bytesToBase64 prefers Buffer.from(bytes)
  .toString('base64') when available (single C++ call, significantly
  faster on large binaries). Chunked btoa stays as a fallback.
@mgoldsborough
Copy link
Copy Markdown
Contributor Author

Addressed QA review in 6cbbc62.

Warning 1 (stale persistence type) — fixed. StoredMessage.metadata.toolCalls in src/conversation/types.ts now declares resourceLinks?: Array<{ uri; name?; mimeType?; description? }>. The web-side mirror in useChat.ts gets the same extension. The tc as Record<string, unknown> cast on the reload path is gone — everything is typed end-to-end.

Suggestion 1 (duplicate INTERNAL_APPS) — fixed. Hoisted to a module-level const at the top of web/src/bridge/bridge.ts so both tools/call and resources/read share one source of truth. No more drift hazard if the trust list grows.

Suggestion 2 (PDF iframe sandbox) — fixed with a correction. QA proposed sandbox=\"allow-same-origin\", but that would actually block scripts (Chrome's PDF viewer needs them) without isolating the iframe. The correct defense-in-depth is sandbox=\"allow-scripts\" without allow-same-origin — PDF JS runs in a null origin, can't reach cookies / storage / same-origin resources. That's what landed on ResourceLinkView.tsx. Added a comment explaining the choice.

Suggestion 3 (Bun native Buffer) — fixed. bytesToBase64 now prefers Buffer.from(bytes).toString(\"base64\") when available (Bun + Node), falling back to the chunked btoa path for portability. On Bun this is a single native call — noticeable on large PDFs where this handler is the hot path.

Suggestion 4 (SSRF comment) — fixed. One-line comment in the bridge's resources/read case clarifying that URI passthrough is safe because only URIs advertised via the bundle's resources/list will resolve — SSRF safety lives in the bundle, not the host.

Verification after changes:

  • bunx tsc --noEmit — clean
  • bun run verify — 1764 unit + 61 web + 338 integration + 16 smoke = 2179/2179 pass
  • cd web && bun run build — clean

Ready for re-review.

@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label Apr 17, 2026
@mgoldsborough mgoldsborough merged commit 42946f7 into main Apr 17, 2026
4 checks passed
@mgoldsborough mgoldsborough deleted the feat/resource-link-support branch April 17, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa-reviewed QA review completed with no critical issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant