Wire MCP resources/read through host + chat renderer for resource_link#36
Wire MCP resources/read through host + chat renderer for resource_link#36mgoldsborough merged 7 commits intomainfrom
Conversation
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.
|
Addressed QA review in 6cbbc62. Warning 1 (stale persistence type) — fixed. Suggestion 1 (duplicate INTERNAL_APPS) — fixed. Hoisted to a module-level Suggestion 2 (PDF iframe sandbox) — fixed with a correction. QA proposed Suggestion 3 (Bun native Buffer) — fixed. Suggestion 4 (SSRF comment) — fixed. One-line comment in the bridge's Verification after changes:
Ready for re-review. |
Summary
Completes the host-side half of the MCP
resource_linkstory. Tools that return large artifacts (PDFs, images) can now produce tinyresource_linkcontent blocks pointing at a URI, and the host fetches the bytes via standard MCPresources/readin three places that matter:POST /v1/resources/read— symmetric with/v1/tools/call. Bundles expose resources, host proxies via the same middleware stack.synapse.readResource(uri)(the SDK counterpart is synapse#2). The bridge now has acase "resources/read":next tocase "tools/call":that forwards to the new HTTP endpoint.resource_linkblocks now render inline in the chat stream via a newResourceLinkViewcomponent that dispatches on mimeType (PDF → iframe, image/* →<img>, text/* →<pre>, else → download link).Net: a tool returning a 50-page PDF via
resource_linkproduces 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 1MBmaxToolResultSizecap (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_linkcontent blocks for exactly this. A tool returns a URI; the client callsresources/readto get the bytes. This PR implements the client half for NimbleBrain — host + iframe + chat all speak the resource channel properly.Architecture
Design principle: symmetry with
tools/call.resources/readis a peer MCP method, not a special case. Same middleware, same proxy pattern, same shape — just a different spec method.What changed
Server
src/api/handlers.tshandleReadResource. Returns MCPReadResourceResultJSON; base64-encodesblobfor binary, passes text through. 403 cross-workspace, 404 null, 400 bad body. ExportsbytesToBase64.src/api/routes/resources.tsPOST /v1/resources/readmounted alongside the existing GET route, sharing auth + workspace middleware with/v1/tools/call.src/runtime/runtime.tsreadAppResourcenow passes URIs with any scheme through unchanged (previously hardcodedui://prefix). Keepsui://fallback for legacy callers.src/engine/content-helpers.tsextractResourceLinks(content)helper +ResourceLinkInfotype.src/engine/engine.tsfinalResult.contentforresource_linkblocks after tool execution, attaches them to thetool.doneevent underresourceLinks.src/engine/types.tsToolCallRecordwith optionalresourceLinks.Web bridge
web/src/bridge/bridge.tscase "resources/read":handler. Mirrorscase "tools/call":— same security posture (server scoped to appName unless internal), error envelope on failure.web/src/bridge/types.tsResourcesReadMessage,UiResourceResultResponse,UiResourceResultErrorwired into the host/app message unions.web/src/api/client.tsreadResource(server, uri)client function +ReadResourceResult/ReadResourceContenttypes. MirrorscallTool.Chat renderer
web/src/components/ResourceLinkView.tsxreadResource(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<ResourceLinkView>perresource_linkblock on each tool call. ExistingInlineAppViewpath untouched (different concern —ui://HTML app binding).web/src/hooks/useChat.tsToolCallDisplaycarriesresourceLinks, populated fromtool.doneand from loaded conversation metadata.web/src/types.tsResourceLinkInfo, extendedToolDoneEvent+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_linkblocks propagate ontotool.done.resourceLinks; absence doesn't create a spurious empty array.bun run verify— 2179/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.readResourcewhich 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/readdirectly.Compatibility
/v1/apps/:name/resources/*route stays forui://HTML app serving. The POST/v1/resources/readis new.runtime.readAppResourcescheme generalization — wasui://-only, now passes any scheme through. Backward-compatible because theui://branch remains as fallback when no scheme is present in the input.Risk
Low. The new endpoint is auth+workspace-gated via the same middleware that protects every other
/v1route. Binary content is base64-encoded per spec. The bridge forwarding is a direct pass-through — we aren't interpretingresource_linkURIs, just carrying them.The main user-visible change is that
tool.doneevents now carryresourceLinks— any consumer (CLI, logs, conversation store) that deserializes these events will see the new field. All in-tree consumers updated.