diff --git a/.gitignore b/.gitignore index a5df1575..df5bf5d1 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Claude Code internal config (developer tooling, not part of OSS repo) .claude +.environments/collateral-test/ diff --git a/src/api/handlers.ts b/src/api/handlers.ts index f37015f9..02442a31 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -306,6 +306,81 @@ export async function handleResourceProxy( }); } +/** + * Handle POST /v1/resources/read — MCP resources/read proxy. + * + * Body: { server, uri } + * Returns: MCP ReadResourceResult — { contents: [{ uri, mimeType?, text?, blob? }] }. + * Binary payloads are returned as base64-encoded `blob` strings per spec. + */ +export async function handleReadResource( + request: Request, + runtime: Runtime, + options?: { workspaceId?: string }, +): Promise { + const body = await parseJsonBody(request); + if (body instanceof Response) return body; + + const { server, uri } = body as { server?: string; uri?: string }; + if (!server || typeof server !== "string") { + return apiError(400, "bad_request", "'server' is required"); + } + if (!uri || typeof uri !== "string") { + return apiError(400, "bad_request", "'uri' is required"); + } + + const workspaceId = options?.workspaceId; + if (!workspaceId) { + return apiError(400, "bad_request", "Workspace ID required"); + } + + // Workspace scoping — reject servers not in the active workspace. + const wsRegistry = await runtime.ensureWorkspaceRegistry(workspaceId); + if (!wsRegistry.hasSource(server)) { + return apiError( + 403, + "workspace_access_denied", + `Server "${server}" is not available in this workspace`, + { server }, + ); + } + + const resource = await runtime.readAppResource(server, uri, workspaceId); + if (resource === null) { + return apiError(404, "resource_not_found", `Resource "${uri}" not found`, { + server, + uri, + }); + } + + const entry: Record = { uri }; + if (resource.mimeType) entry.mimeType = resource.mimeType; + if (resource.blob) { + entry.blob = bytesToBase64(resource.blob); + } else { + entry.text = resource.text ?? ""; + } + + return json({ contents: [entry] }); +} + +/** + * Base64-encode a Uint8Array. Prefers Bun/Node's native Buffer (single C++ + * call, significantly faster on large binaries than the chunked btoa path). + * Falls back to a stack-safe btoa loop for runtimes without Buffer. + */ +export function bytesToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + const CHUNK = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + /** Handle POST /v1/tools/call — direct tool invocation. */ export async function handleToolCall( request: Request, diff --git a/src/api/routes/resources.ts b/src/api/routes/resources.ts index fb4191b5..646974d3 100644 --- a/src/api/routes/resources.ts +++ b/src/api/routes/resources.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { handleResourceProxy } from "../handlers.ts"; +import { handleReadResource, handleResourceProxy } from "../handlers.ts"; import { requireAuth } from "../middleware/auth.ts"; import { errorLog } from "../middleware/error-log.ts"; import { requireWorkspace } from "../middleware/workspace.ts"; @@ -10,12 +10,15 @@ export function resourceRoutes(ctx: AppContext) { .use("*", requireAuth(ctx.authOptions)) .use("*", requireWorkspace(ctx.workspaceStore)) .use("*", errorLog(ctx)) + .post("/v1/resources/read", (c) => + handleReadResource(c.req.raw, ctx.runtime, { workspaceId: c.var.workspaceId }), + ) .get("/v1/apps/:name/resources/*", (c) => { const name = decodeURIComponent(c.req.param("name")); // Extract the full resource path after /resources/ const url = new URL(c.req.url); const prefix = `/v1/apps/${c.req.param("name")}/resources/`; - const resourcePath = url.pathname.slice(prefix.length); + const resourcePath = decodeURIComponent(url.pathname.slice(prefix.length)); return handleResourceProxy(name, resourcePath, ctx.runtime, c.var.workspaceId); }); } diff --git a/src/conversation/types.ts b/src/conversation/types.ts index 6e8f92cf..b0dbbca3 100644 --- a/src/conversation/types.ts +++ b/src/conversation/types.ts @@ -138,6 +138,12 @@ export type StoredMessage = LanguageModelV3Message & { ok: boolean; ms: number; resourceUri?: string; + resourceLinks?: Array<{ + uri: string; + name?: string; + mimeType?: string; + description?: string; + }>; }>; inputTokens?: number; outputTokens?: number; diff --git a/src/engine/content-helpers.ts b/src/engine/content-helpers.ts index f797924d..3c32f736 100644 --- a/src/engine/content-helpers.ts +++ b/src/engine/content-helpers.ts @@ -1,5 +1,37 @@ import type { ContentBlock, TextContent } from "./types.ts"; +/** A resource_link content block surfaced from an MCP tool result. */ +export interface ResourceLinkInfo { + uri: string; + name?: string; + mimeType?: string; + description?: string; +} + +/** + * Collect `resource_link` content blocks from a ContentBlock array. + * + * Per the MCP spec (2025-11-25), tools may return `resource_link` blocks that + * point to resources fetched separately via `resources/read`. We surface the + * bare metadata so UIs can render viewers without pulling the full payload + * through the agent loop. + */ +export function extractResourceLinks(blocks: ContentBlock[]): ResourceLinkInfo[] { + const links: ResourceLinkInfo[] = []; + for (const block of blocks) { + if ((block as { type?: string }).type !== "resource_link") continue; + const b = block as Record; + const uri = typeof b.uri === "string" ? b.uri : undefined; + if (!uri) continue; + const link: ResourceLinkInfo = { uri }; + if (typeof b.name === "string") link.name = b.name; + if (typeof b.mimeType === "string") link.mimeType = b.mimeType; + if (typeof b.description === "string") link.description = b.description; + links.push(link); + } + return links; +} + /** Wrap a plain string in a single TextContent block. */ export function textContent(text: string): ContentBlock[] { return [{ type: "text" as const, text }]; diff --git a/src/engine/engine.ts b/src/engine/engine.ts index 5c4189c4..162d2c83 100644 --- a/src/engine/engine.ts +++ b/src/engine/engine.ts @@ -9,7 +9,12 @@ import type { import { MAX_ITERATIONS, MAX_TOOL_RESULT_CHARS } from "../limits.ts"; import { callModel, type StreamResult } from "../model/stream.ts"; import { validateToolInput } from "../tools/validate-input.ts"; -import { estimateContentSize, extractTextForModel, textContent } from "./content-helpers.ts"; +import { + estimateContentSize, + extractResourceLinks, + extractTextForModel, + textContent, +} from "./content-helpers.ts"; import { withRetry } from "./retry.ts"; import { ActiveTaskTracker, getImmediateResponse, type McpTask, pollTask } from "./tasks.ts"; import type { @@ -436,6 +441,11 @@ export class AgentEngine { // text output is always needed for conversation history reconstruction. const outputText = extractTextForModel(finalResult.content); + // Per-call resource_link blocks (MCP 2025-11-25). Distinct from the + // static `resourceUri` tool annotation used for inline UI binding — + // resource_link points at a file/resource the client should fetch. + const resourceLinks = extractResourceLinks(finalResult.content); + this.events.emit({ type: "tool.done", data: { @@ -447,10 +457,11 @@ export class AgentEngine { resourceUri, output: outputText, result: resourceUri ? finalResult : undefined, + ...(resourceLinks.length > 0 ? { resourceLinks } : {}), }, }); - return { toolCall, result: finalResult, ms, resourceUri }; + return { toolCall, result: finalResult, ms, resourceUri, resourceLinks }; }), ); @@ -460,7 +471,13 @@ export class AgentEngine { // The full result is still available to the inline UI via tool.done event. const toolResultParts: LanguageModelV3ToolResultPart[] = []; - for (const { toolCall, result, ms, resourceUri: uri } of toolResults) { + for (const { + toolCall, + result, + ms, + resourceUri: uri, + resourceLinks: links, + } of toolResults) { let llmText = extractTextForModel(result.content); if (uri && llmText.length > MAX_TOOL_RESULT_CHARS) { @@ -490,6 +507,7 @@ export class AgentEngine { ok: !result.isError, ms, ...(uri ? { resourceUri: uri } : {}), + ...(links && links.length > 0 ? { resourceLinks: links } : {}), }); toolResultParts.push({ diff --git a/src/engine/types.ts b/src/engine/types.ts index db3fd9a3..a3eac7d7 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -178,4 +178,15 @@ export interface ToolCallRecord { ok: boolean; ms: number; resourceUri?: string; + /** + * MCP `resource_link` content blocks surfaced by the tool result. + * Distinct from `resourceUri`: this is a per-call, spec-defined pointer + * to resources the client should fetch via `resources/read`. + */ + resourceLinks?: Array<{ + uri: string; + name?: string; + mimeType?: string; + description?: string; + }>; } diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index d181b25d..6efd274b 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1356,8 +1356,10 @@ export class Runtime { const source = registry.getSources().find((s) => s.name === appName); if (!source || !isResourceReader(source)) return null; - // Try the exact URI first (ui://path as registered by the server), - // then namespaced fallback (ui://appName/path) for backwards compat + if (resourcePath.includes("://")) { + return source.readResource(resourcePath); + } + const exactUri = `ui://${resourcePath}`; const namespacedUri = `ui://${appName}/${resourcePath}`; diff --git a/test/unit/api/read-resource-handler.test.ts b/test/unit/api/read-resource-handler.test.ts new file mode 100644 index 00000000..46786c54 --- /dev/null +++ b/test/unit/api/read-resource-handler.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; +import { bytesToBase64, handleReadResource } from "../../../src/api/handlers.ts"; +import type { Runtime } from "../../../src/runtime/runtime.ts"; +import type { ResourceData } from "../../../src/tools/types.ts"; + +interface StubOptions { + sources?: string[]; + resource?: ResourceData | null; + captureCall?: (args: { server: string; uri: string; workspaceId: string }) => void; +} + +function makeStubRuntime(opts: StubOptions = {}): Runtime { + const sources = new Set(opts.sources ?? ["calendar"]); + const registry = { + hasSource: (name: string) => sources.has(name), + }; + return { + ensureWorkspaceRegistry: async () => registry, + readAppResource: async (server: string, uri: string, workspaceId: string) => { + opts.captureCall?.({ server, uri, workspaceId }); + return opts.resource === undefined ? null : opts.resource; + }, + } as unknown as Runtime; +} + +function req(body: unknown): Request { + return new Request("http://x/v1/resources/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("handleReadResource", () => { + it("rejects missing workspaceId with 400", async () => { + const runtime = makeStubRuntime(); + const res = await handleReadResource( + req({ server: "calendar", uri: "ui://calendar/main" }), + runtime, + {}, + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("bad_request"); + }); + + it("rejects missing server with 400", async () => { + const runtime = makeStubRuntime(); + const res = await handleReadResource(req({ uri: "ui://x/y" }), runtime, { workspaceId: "w" }); + expect(res.status).toBe(400); + }); + + it("rejects missing uri with 400", async () => { + const runtime = makeStubRuntime(); + const res = await handleReadResource(req({ server: "calendar" }), runtime, { + workspaceId: "w", + }); + expect(res.status).toBe(400); + }); + + it("returns 403 for servers outside the workspace", async () => { + const runtime = makeStubRuntime({ sources: ["other"] }); + const res = await handleReadResource( + req({ server: "calendar", uri: "ui://calendar/main" }), + runtime, + { workspaceId: "w1" }, + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe("workspace_access_denied"); + }); + + it("returns 404 when the resource is missing", async () => { + const runtime = makeStubRuntime({ resource: null }); + const res = await handleReadResource( + req({ server: "calendar", uri: "ui://calendar/missing" }), + runtime, + { workspaceId: "w1" }, + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("resource_not_found"); + }); + + it("returns ReadResourceResult shape for text resources", async () => { + const runtime = makeStubRuntime({ + resource: { text: "hello world", mimeType: "text/plain" }, + }); + const res = await handleReadResource( + req({ server: "calendar", uri: "ui://calendar/greeting" }), + runtime, + { workspaceId: "w1" }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.contents).toHaveLength(1); + expect(body.contents[0]).toEqual({ + uri: "ui://calendar/greeting", + mimeType: "text/plain", + text: "hello world", + }); + }); + + it("base64-encodes binary resources into the blob field", async () => { + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0xff, 0xfe]); + const runtime = makeStubRuntime({ + resource: { blob: bytes, mimeType: "application/pdf" }, + }); + const res = await handleReadResource( + req({ server: "calendar", uri: "collateral://exports/e.pdf" }), + runtime, + { workspaceId: "w1" }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.contents[0].uri).toBe("collateral://exports/e.pdf"); + expect(body.contents[0].mimeType).toBe("application/pdf"); + expect(body.contents[0].text).toBeUndefined(); + expect(body.contents[0].blob).toBe(bytesToBase64(bytes)); + }); + + it("passes the uri through to readAppResource as-is (any scheme)", async () => { + const calls: Array<{ server: string; uri: string; workspaceId: string }> = []; + const runtime = makeStubRuntime({ + resource: { text: "{}", mimeType: "application/json" }, + captureCall: (c) => calls.push(c), + }); + await handleReadResource( + req({ server: "calendar", uri: "custom://whatever/123" }), + runtime, + { workspaceId: "w1" }, + ); + expect(calls).toEqual([{ server: "calendar", uri: "custom://whatever/123", workspaceId: "w1" }]); + }); + + it("returns 400 for invalid JSON body", async () => { + const runtime = makeStubRuntime(); + const res = await handleReadResource( + new Request("http://x/v1/resources/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }), + runtime, + { workspaceId: "w1" }, + ); + expect(res.status).toBe(400); + }); +}); + +describe("bytesToBase64", () => { + it("matches btoa for small inputs", () => { + const bytes = new Uint8Array([0x68, 0x69]); // "hi" + expect(bytesToBase64(bytes)).toBe(btoa("hi")); + }); + + it("handles empty buffers", () => { + expect(bytesToBase64(new Uint8Array())).toBe(""); + }); + + it("round-trips arbitrary binary content", () => { + const bytes = new Uint8Array(70_000); + for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256; + const encoded = bytesToBase64(bytes); + const decoded = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + expect(decoded).toEqual(bytes); + }); +}); diff --git a/test/unit/bridge-resources-read.test.ts b/test/unit/bridge-resources-read.test.ts new file mode 100644 index 00000000..68c5de70 --- /dev/null +++ b/test/unit/bridge-resources-read.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +// --------------------------------------------------------------------------- +// Minimal DOM mocks — same approach as bridge-extensions.test.ts, but scoped +// to the resources/read message flow. +// --------------------------------------------------------------------------- + +type Listener = (event: unknown) => void; + +function makeFakeIframe() { + const posted: unknown[] = []; + const loadListeners: Listener[] = []; + + const iframe = { + contentWindow: { + postMessage(data: unknown, _origin: string) { + posted.push(data); + }, + }, + addEventListener(event: string, fn: Listener) { + if (event === "load") loadListeners.push(fn); + }, + removeEventListener(event: string, fn: Listener) { + if (event === "load") { + const idx = loadListeners.indexOf(fn); + if (idx >= 0) loadListeners.splice(idx, 1); + } + }, + } as unknown as HTMLIFrameElement; + + return { iframe, posted, loadListeners }; +} + +const windowListeners = new Map>(); + +beforeEach(() => { + windowListeners.clear(); + + if (typeof globalThis.window === "undefined") { + (globalThis as Record).window = globalThis; + } + (globalThis as unknown as { open: unknown }).open = mock(() => null); + + globalThis.window.addEventListener = ((type: string, fn: Listener) => { + if (!windowListeners.has(type)) windowListeners.set(type, new Set()); + windowListeners.get(type)!.add(fn); + }) as unknown as typeof window.addEventListener; + + globalThis.window.removeEventListener = ((type: string, fn: Listener) => { + windowListeners.get(type)?.delete(fn); + }) as unknown as typeof window.removeEventListener; + + globalThis.window.dispatchEvent = (() => true) as unknown as typeof window.dispatchEvent; + + if (typeof globalThis.document === "undefined") { + (globalThis as Record).document = { + documentElement: { classList: { contains: () => false } }, + }; + } + if (!globalThis.window?.location) { + (globalThis.window as Record).location = { + origin: "http://localhost:27246", + href: "http://localhost:27246/", + }; + } +}); + +afterEach(() => { + mock.restore(); +}); + +function simulatePostMessage(iframe: HTMLIFrameElement, data: unknown) { + const listeners = windowListeners.get("message"); + if (!listeners) return; + const event = { data, source: iframe.contentWindow } as MessageEvent; + for (const fn of listeners) fn(event); +} + +const readResourceMock = mock(async (_server: string, _uri: string) => ({ + contents: [{ uri: "ui://x/y", text: "ok" }], +})); +const callToolMock = mock(async () => ({ + content: [], + isError: false, +})); + +mock.module("../../web/src/api/client", () => ({ + callTool: callToolMock, + readResource: readResourceMock, +})); + +const { createBridge } = await import("../../web/src/bridge/bridge.ts"); + +describe("Bridge — resources/read", () => { + it("forwards the uri to the readResource client and posts the result verbatim", async () => { + readResourceMock.mockImplementationOnce(async () => ({ + contents: [ + { + uri: "collateral://exports/e.pdf", + mimeType: "application/pdf", + blob: "JVBERi0=", + }, + ], + })); + + const { iframe, posted } = makeFakeIframe(); + const handle = createBridge(iframe, "collateral"); + + simulatePostMessage(iframe, { + jsonrpc: "2.0", + id: "rr-1", + method: "resources/read", + params: { uri: "collateral://exports/e.pdf" }, + }); + + await new Promise((r) => setTimeout(r, 10)); + + expect(readResourceMock).toHaveBeenCalledWith("collateral", "collateral://exports/e.pdf"); + + const response = posted.find( + (m: unknown) => (m as Record).id === "rr-1", + ) as Record | undefined; + expect(response).toBeDefined(); + expect(response!.jsonrpc).toBe("2.0"); + const result = response!.result as { contents: unknown[] }; + expect(result.contents).toHaveLength(1); + expect(result.contents[0]).toEqual({ + uri: "collateral://exports/e.pdf", + mimeType: "application/pdf", + blob: "JVBERi0=", + }); + + handle.destroy(); + }); + + it("scopes the read to appName by default (ignores params.server for non-internal apps)", async () => { + const { iframe } = makeFakeIframe(); + const handle = createBridge(iframe, "crm"); + + simulatePostMessage(iframe, { + jsonrpc: "2.0", + id: "rr-2", + method: "resources/read", + params: { uri: "ui://crm/main", server: "attacker" }, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const calls = readResourceMock.mock.calls; + const last = calls[calls.length - 1]; + expect(last?.[0]).toBe("crm"); + + handle.destroy(); + }); + + it("honors params.server for internal bundles (e.g., home)", async () => { + const { iframe } = makeFakeIframe(); + const handle = createBridge(iframe, "home"); + + simulatePostMessage(iframe, { + jsonrpc: "2.0", + id: "rr-3", + method: "resources/read", + params: { uri: "ui://crm/main", server: "crm" }, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const calls = readResourceMock.mock.calls; + const last = calls[calls.length - 1]; + expect(last?.[0]).toBe("crm"); + + handle.destroy(); + }); + + it("returns a JSON-RPC error response with code -32000 on failure", async () => { + readResourceMock.mockImplementationOnce(async () => { + throw new Error("resource_not_found"); + }); + + const { iframe, posted } = makeFakeIframe(); + const handle = createBridge(iframe, "crm"); + + simulatePostMessage(iframe, { + jsonrpc: "2.0", + id: "rr-4", + method: "resources/read", + params: { uri: "ui://crm/missing" }, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const response = posted.find( + (m: unknown) => (m as Record).id === "rr-4", + ) as Record | undefined; + expect(response).toBeDefined(); + const error = response!.error as { code: number; message: string }; + expect(error.code).toBe(-32000); + expect(error.message).toBe("resource_not_found"); + + handle.destroy(); + }); +}); diff --git a/test/unit/engine.test.ts b/test/unit/engine.test.ts index 74654928..f0659378 100644 --- a/test/unit/engine.test.ts +++ b/test/unit/engine.test.ts @@ -170,6 +170,132 @@ describe("AgentEngine", () => { expect(toolDone!.data["result"]).toEqual({ content: textContent("rendered"), isError: false }); }); + it("surfaces resource_link blocks on tool.done and in the result record", async () => { + let callCount = 0; + const model = createMockModel(() => { + callCount++; + if (callCount === 1) { + return { + content: [ + { + type: "tool-call", + toolCallId: "call_export", + toolName: "collateral__export_pdf", + input: JSON.stringify({}), + }, + ], + inputTokens: 10, + outputTokens: 5, + }; + } + return { + content: [{ type: "text", text: "Done." }], + inputTokens: 20, + outputTokens: 5, + }; + }); + + const toolSchemas: ToolSchema[] = [ + { name: "collateral__export_pdf", description: "Export PDF", inputSchema: {} }, + ]; + + const tools = { + schemas: toolSchemas, + handler: (): ToolResult => ({ + content: [ + { type: "text", text: "Exported 10-page PDF (1.1MB)." }, + { + type: "resource_link", + uri: "collateral://exports/exp_abc123.pdf", + name: "Document export", + mimeType: "application/pdf", + }, + ] as ToolResult["content"], + isError: false, + }), + }; + + const events: EngineEvent[] = []; + const sink: EventSink = { emit: (e) => events.push(e) }; + + const engine = new AgentEngine( + model, + new StaticToolRouter(tools.schemas, tools.handler), + sink, + ); + + const result = await engine.run( + defaultConfig, + "", + [{ role: "user", content: [{ type: "text", text: "Export" }] }], + tools.schemas, + ); + + const toolDone = events.find((e) => e.type === "tool.done"); + expect(toolDone).toBeDefined(); + expect(toolDone!.data["resourceLinks"]).toEqual([ + { + uri: "collateral://exports/exp_abc123.pdf", + name: "Document export", + mimeType: "application/pdf", + }, + ]); + // resourceUri is separate (no UI annotation) — stays undefined. + expect(toolDone!.data["resourceUri"]).toBeUndefined(); + + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]!.resourceLinks).toEqual([ + { + uri: "collateral://exports/exp_abc123.pdf", + name: "Document export", + mimeType: "application/pdf", + }, + ]); + }); + + it("omits resourceLinks from tool.done when the tool returns none", async () => { + let callCount = 0; + const model = createMockModel(() => { + callCount++; + if (callCount === 1) { + return { + content: [ + { + type: "tool-call", + toolCallId: "call_plain", + toolName: "test__plain", + input: JSON.stringify({}), + }, + ], + inputTokens: 10, + outputTokens: 5, + }; + } + return { content: [{ type: "text", text: "ok" }], inputTokens: 5, outputTokens: 5 }; + }); + + const toolSchemas: ToolSchema[] = [ + { name: "test__plain", description: "No link", inputSchema: {} }, + ]; + + const events: EngineEvent[] = []; + const sink: EventSink = { emit: (e) => events.push(e) }; + + const engine = new AgentEngine( + model, + new StaticToolRouter(toolSchemas, () => ({ content: textContent("plain"), isError: false })), + sink, + ); + + await engine.run(defaultConfig, "", [ + { role: "user", content: [{ type: "text", text: "go" }] }, + ], toolSchemas); + + const toolDone = events.find((e) => e.type === "tool.done"); + expect(toolDone).toBeDefined(); + expect(toolDone!.data["resourceLinks"]).toBeUndefined(); + }); + it("stops at max_iterations", async () => { const model = createMockModel(() => ({ content: [ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f74d5807..511436cc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -134,7 +134,11 @@ export class ApiClientError extends Error { // Resources & Tools // --------------------------------------------------------------------------- -/** Fetch a ui:// resource from an app. Returns raw HTML string. */ +/** + * Fetch an app's ui:// resource as HTML/text. Used by the iframe mounting + * path (SlotRenderer, InlineAppView) to load app views into sandboxed frames. + * For binary artifacts (PDFs, images, etc.), use {@link readResource}. + */ export async function getResources(appName: string, path: string): Promise { const res = await fetchWithRefresh( `${API_BASE}/v1/apps/${encodeURIComponent(appName)}/resources/${path}`, @@ -171,6 +175,29 @@ export async function callTool( }); } +/** + * MCP ReadResourceResult entry. Exactly one of `text` or `blob` is populated; + * `blob` is a base64-encoded string per spec. + */ +export interface ReadResourceContent { + uri: string; + mimeType?: string; + text?: string; + blob?: string; +} + +export interface ReadResourceResult { + contents: ReadResourceContent[]; +} + +/** Read an MCP resource via POST /v1/resources/read. */ +export async function readResource(server: string, uri: string): Promise { + return request("/v1/resources/read", { + method: "POST", + body: JSON.stringify({ server, uri }), + }); +} + // --------------------------------------------------------------------------- // Chat // --------------------------------------------------------------------------- diff --git a/web/src/bridge/bridge.ts b/web/src/bridge/bridge.ts index ec5d837d..6e5ae57b 100644 --- a/web/src/bridge/bridge.ts +++ b/web/src/bridge/bridge.ts @@ -5,7 +5,7 @@ // Routes iframe messages to platform APIs and forwards events back to iframes. // // Spec-compliant methods: -// tools/call, ui/initialize, ui/notifications/initialized, +// tools/call, resources/read, ui/initialize, ui/notifications/initialized, // ui/notifications/tool-result, ui/notifications/tool-input, // ui/notifications/host-context-changed, ui/notifications/size-changed, // ui/open-link, ui/message, ui/update-model-context @@ -16,7 +16,7 @@ // synapse/request-file // --------------------------------------------------------------------------- -import { callTool } from "../api/client"; +import { callTool, readResource } from "../api/client"; import { getHostThemeMode, getThemeTokens } from "./theme"; import type { BridgeCallbacks, @@ -49,6 +49,14 @@ interface WidgetStateEntry { const appStateStore = new Map(); const widgetStateStore = new Map(); +/** + * Internal bundle names allowed to cross-call other sources by setting + * `params.server` on tools/call or resources/read. External iframe apps + * are strictly scoped to their own server. Defined once at module scope so + * both message-type cases share the same trust list. + */ +const INTERNAL_APPS = new Set(["nb", "settings", "home", "usage"]); + /** Get the latest app state pushed via ui/update-model-context. */ export function getAppState(appName: string): AppStateEntry | undefined { return appStateStore.get(appName); @@ -193,9 +201,10 @@ export function createBridge( case "tools/call": { const { id, params } = msg; - // Security: tool calls are scoped to appName by default. - // Internal bundles can specify params.server to call other sources. - const INTERNAL_APPS = new Set(["nb", "settings", "home", "usage"]); + // Security: tool/resource calls are scoped to appName by default. + // Internal bundles can specify params.server to cross-call other + // sources. INTERNAL_APPS is defined once at module scope so both + // tools/call and resources/read share the same trust list. const server = INTERNAL_APPS.has(appName) && params.server ? params.server : appName; callTool(server, params.name, params.arguments) .then((result) => { @@ -236,6 +245,32 @@ export function createBridge( break; } + // ----------------------------------------------------------------- + // Spec: resources/read — standard MCP resource reads + // Returns ReadResourceResult: { contents: [{ uri, mimeType?, text?, blob? }] } + // ----------------------------------------------------------------- + case "resources/read": { + const { id, params } = msg; + // Same trust list as tools/call. The URI itself is passed through + // verbatim to the server — SSRF safety lives in the bundle, not + // the host, because only URIs the bundle advertises via + // resources/list will resolve anyway. + const server = INTERNAL_APPS.has(appName) && params.server ? params.server : appName; + readResource(server, params.uri) + .then((result) => { + postToIframe({ jsonrpc: "2.0", id, result }); + }) + .catch((err: unknown) => { + const errorMsg = err instanceof Error ? err.message : "Resource read failed"; + postToIframe({ + jsonrpc: "2.0", + id, + error: { code: -32000, message: errorMsg }, + }); + }); + break; + } + // ----------------------------------------------------------------- // Spec: ui/message — { role, content: [{ type, text, _meta? }] } // ----------------------------------------------------------------- diff --git a/web/src/bridge/types.ts b/web/src/bridge/types.ts index 7355114f..05c79bf3 100644 --- a/web/src/bridge/types.ts +++ b/web/src/bridge/types.ts @@ -164,6 +164,7 @@ export interface UiKeydownMessage { /** Union of all App -> Host messages. */ export type AppToHostMessage = | ToolsCallMessage + | ResourcesReadMessage | UiMessageMessage | UiOpenLinkMessage | UiSizeChangedMessage @@ -202,6 +203,42 @@ export interface UiInitializeMessage { }; } +/** Spec: App requests the host to read an MCP resource. */ +export interface ResourcesReadMessage { + jsonrpc: "2.0"; + method: "resources/read"; + id: string; + params: { + uri: string; + /** Internal-only: route the read to a different server (allowed for internal bundles). */ + server?: string; + }; +} + +/** Spec: Result of a resources/read request — returns ReadResourceResult per MCP spec. */ +export interface UiResourceResultResponse { + jsonrpc: "2.0"; + id: string; + result: { + contents: Array<{ + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }>; + }; +} + +/** Error response for a failed resources/read request. */ +export interface UiResourceResultError { + jsonrpc: "2.0"; + id: string; + error: { + code: number; + message: string; + }; +} + /** Spec: Result of a tools/call request — returns CallToolResult per MCP spec. */ export interface UiToolResultResponse { jsonrpc: "2.0"; @@ -307,6 +344,8 @@ export type HostToAppMessage = | UiToolResultResponse | UiToolResultError | UiToolResultMessage + | UiResourceResultResponse + | UiResourceResultError | UiDataChangedMessage | UiStateLoadedMessage | ExtAppsInitializeResponse diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 8e21cefb..c2f04a94 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -6,6 +6,7 @@ import { participantColor } from "../lib/participant-colors"; import { FileAttachment } from "./FileAttachment"; import { FlashCardGroup } from "./FlashCardGroup"; import { InlineAppView } from "./InlineAppView"; +import { ResourceLinkView } from "./ResourceLinkView"; import type { DisplayDetail } from "./ToolCallIndicator"; function formatTokens(count: number): string { @@ -316,6 +317,13 @@ export function MessageList({ const blockWidgets = block.toolCalls.filter( (tc) => tc.resourceUri && tc.status === "done" && tc.appName, ); + const resourceLinkCalls = block.toolCalls.filter( + (tc) => + tc.status === "done" && + tc.appName && + tc.resourceLinks && + tc.resourceLinks.length > 0, + ); return ( // biome-ignore lint/suspicious/noArrayIndexKey: blocks are append-only and don't reorder
@@ -334,6 +342,18 @@ export function MessageList({ /> ); })} + {resourceLinkCalls.flatMap((tc) => + tc.resourceLinks!.map((link) => ( + + )), + )}
); } diff --git a/web/src/components/ResourceLinkView.tsx b/web/src/components/ResourceLinkView.tsx new file mode 100644 index 00000000..5bc76e8e --- /dev/null +++ b/web/src/components/ResourceLinkView.tsx @@ -0,0 +1,219 @@ +import { Download, FileText, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ApiClientError, readResource, type ReadResourceContent } from "../api/client"; + +export interface ResourceLinkViewProps { + /** URI from the resource_link content block (e.g., `collateral://exports/exp_abc.pdf`). */ + uri: string; + /** Server/app that owns the resource — forwarded to POST /v1/resources/read. */ + appName: string; + /** Optional display name from the resource_link block. */ + name?: string; + /** Declared MIME type from the resource_link block. Falls back to the resource's own mimeType. */ + mimeType?: string; + /** Optional description surfaced to the user. */ + description?: string; +} + +function formatBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Decode a base64 string to a Uint8Array in chunks (stack-safe for large blobs). */ +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function defaultFilename(uri: string, name?: string): string { + if (name) return name; + const tail = uri.split("/").pop(); + return tail && tail.length > 0 ? tail : "download"; +} + +export function ResourceLinkView({ + uri, + appName, + name, + mimeType, + description, +}: ResourceLinkViewProps) { + const [content, setContent] = useState(null); + const [objectUrl, setObjectUrl] = useState(null); + const [byteSize, setByteSize] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + let createdUrl: string | null = null; + + setLoading(true); + setError(null); + setContent(null); + setObjectUrl(null); + setByteSize(null); + + (async () => { + try { + const result = await readResource(appName, uri); + if (cancelled) return; + const first = result.contents[0]; + if (!first) throw new Error("No content returned"); + setContent(first); + if (first.blob !== undefined) { + const bytes = base64ToBytes(first.blob); + const blob = new Blob([bytes.buffer as ArrayBuffer], { + type: first.mimeType ?? mimeType ?? "application/octet-stream", + }); + createdUrl = URL.createObjectURL(blob); + setObjectUrl(createdUrl); + setByteSize(bytes.byteLength); + } + } catch (err) { + if (cancelled) return; + const msg = + err instanceof ApiClientError + ? err.message + : err instanceof Error + ? err.message + : "Failed to load resource"; + setError(msg); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + if (createdUrl) URL.revokeObjectURL(createdUrl); + }; + }, [uri, appName, mimeType]); + + const displayName = name ?? uri; + const resolvedMime = content?.mimeType ?? mimeType ?? "application/octet-stream"; + + if (loading) { + return ( +
+ + Loading {displayName}... +
+ ); + } + + if (error || !content) { + return ( +
+ Failed to load {displayName}: {error ?? "unknown error"} +
+ ); + } + + const header = ( +
+
+ + {displayName} + {byteSize !== null && ( + + {formatBytes(byteSize)} + + )} +
+ {objectUrl && ( + + + + )} +
+ ); + + if (resolvedMime === "application/pdf" && objectUrl) { + return ( +
+ {header} + {/* sandbox="allow-scripts" lets the browser's PDF viewer run its + internal scripts in a null origin, so a malicious PDF can't reach + this document's cookies, storage, or same-origin resources. We + deliberately omit allow-same-origin. */} +