diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 2bee370a1..ef2eb7690 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -188,16 +188,29 @@ When roots are ignored the server logs: ## Tools -| Tool | Visibility | Purpose | -| ---------------- | ---------- | ------------------------------------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms, save to file | -| `read_pdf_bytes` | App only | Stream PDF data in chunks | -| `save_pdf` | App only | Save annotated PDF back to local file | +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ------------------------------------------------------------------ | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms, save/export | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| `save_pdf` | App only | Save annotated PDF back to local file | ¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment). +### App tools + +The viewer also registers tools on the view itself (via `app.registerTool`), for hosts that list and call view tools directly. These mirror the `interact` actions (navigate, search, zoom, annotations, form fill, `get_text`, `get_screenshot`, …) and include `get_current_pdf`. + +### Exporting the current document + +`get_current_pdf` is the viewer's "get output file" tool: it returns the current document — with annotations and form fills applied — as an embedded PDF resource in the tool result, so the model can retrieve the file whenever it needs it without a save to disk (as opposed to `ui/download-file`, which saves to the user's disk without the model seeing the bytes). + +It is available on both surfaces: + +- **App tool** (view-registered): the view builds the bytes and returns them directly in the `tools/call` response. +- **`interact` action** (server-mediated, for stdio hosts such as Claude Desktop): the server enqueues a `get_current_pdf` command, the viewer builds the bytes and posts them back through the app-only `submit_current_pdf` tool, and the blocking `interact` call returns the same text + embedded resource. + ## Example Prompts After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features: diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index bb7b57f42..26b467d45 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -34,7 +34,10 @@ export async function startStreamableHTTPServer( ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); - const app = createMcpExpressApp({ host: "0.0.0.0" }); + // Raise the JSON body limit (express default: 100kb): the viewer submits + // whole-document base64 through submit_current_pdf / submit_save_data and + // page screenshots through submit_page_data. + const app = createMcpExpressApp({ host: "0.0.0.0", jsonLimit: "64mb" }); app.use(cors()); app.all("/mcp", async (req: Request, res: Response) => { diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index 0b2a5eb4d..3e0e52c30 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -1675,6 +1675,102 @@ describe("interact tool", () => { }); }); + describe("get_current_pdf action", () => { + it("full roundtrip: enqueue → poll → submit → embedded resource returned", async () => { + const { server, client } = await connect(); + const uuid = "getpdf-roundtrip"; + const pdfBytes = "%PDF-1.4\nfake-current-contents\n%%EOF"; + + // interact blocks in waitForCurrentPdf until submit_current_pdf resolves it + const interactPromise = client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "get_current_pdf" }, + }); + + // Viewer polls → receives the get_current_pdf command with a requestId + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("get_current_pdf"); + const requestId = cmds[0].requestId as string; + expect(typeof requestId).toBe("string"); + + // Viewer submits bytes + metadata + const submit = await client.callTool({ + name: "submit_current_pdf", + arguments: { + requestId, + data: Buffer.from(pdfBytes).toString("base64"), + fileName: "report - edited.pdf", + hasEdits: true, + }, + }); + expect(submit.isError).toBeFalsy(); + + // interact unblocks with a text summary + the embedded resource + const r = await interactPromise; + expect(r.isError).toBeFalsy(); + const content = r.content as Array<{ + type: string; + text?: string; + resource?: { uri: string; mimeType: string; blob: string }; + }>; + expect(content[0].type).toBe("text"); + expect(content[0].text).toContain("report - edited.pdf"); + expect(content[0].text).toContain(`${pdfBytes.length} bytes`); + expect(content[0].text).toContain("includes in-viewer edits"); + const resource = content.find((c) => c.type === "resource"); + expect(resource).toBeTruthy(); + expect(resource!.resource!.mimeType).toBe("application/pdf"); + expect(resource!.resource!.uri).toBe( + `file:///${encodeURIComponent("report - edited.pdf")}`, + ); + expect(Buffer.from(resource!.resource!.blob, "base64").toString()).toBe( + pdfBytes, + ); + + await client.close(); + await server.close(); + }); + + it("viewer-reported error propagates as isError", async () => { + const { server, client } = await connect(); + const uuid = "getpdf-error"; + + const interactPromise = client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "get_current_pdf" }, + }); + + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + const requestId = cmds[0].requestId as string; + + await client.callTool({ + name: "submit_current_pdf", + arguments: { requestId, error: "no document loaded" }, + }); + + const r = await interactPromise; + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("no document loaded"); + + await client.close(); + await server.close(); + }); + + it("submit_current_pdf with unknown requestId returns isError", async () => { + const { server, client } = await connect(); + const r = await client.callTool({ + name: "submit_current_pdf", + arguments: { requestId: "never-created", data: "AAAA" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("No pending request"); + await client.close(); + await server.close(); + }); + }); + describe("viewer liveness", () => { // get_screenshot/get_text fail fast when the iframe never polled, instead // of waiting 45s for a viewer that isn't there. Reproduces the case where diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 629ace85f..2d303af23 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -338,6 +338,43 @@ function waitForViewerState( }); } +/** What the viewer submits for a get_current_pdf request. */ +interface CurrentPdfPayload { + data: string; // base64 PDF bytes + fileName: string; + hasEdits: boolean; +} + +const pendingCurrentPdfRequests = new Map< + string, + (v: CurrentPdfPayload | Error) => void +>(); + +/** + * Wait for the viewer to build the current PDF bytes and submit them. + * Same timeout/abort semantics as waitForSaveData. + */ +function waitForCurrentPdf( + requestId: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const settle = (v: CurrentPdfPayload | Error) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + pendingCurrentPdfRequests.delete(requestId); + v instanceof Error ? reject(v) : resolve(v); + }; + const onAbort = () => settle(new Error("interact request cancelled")); + const timer = setTimeout( + () => settle(new Error("Timeout waiting for PDF bytes from viewer")), + GET_PAGES_TIMEOUT_MS, + ); + signal?.addEventListener("abort", onAbort); + pendingCurrentPdfRequests.set(requestId, settle); + }); +} + interface QueueEntry { commands: PdfCommand[]; /** Timestamp of the most recent enqueue or dequeue */ @@ -1473,6 +1510,7 @@ Returns a viewUUID in structuredContent. Pass it to \`interact\`: - navigate, search, find, search_navigate, zoom - get_text, get_screenshot, get_viewer_state (extract content / read selection & current page) - save_as (write annotated PDF to disk) +- get_current_pdf (return the current PDF, edits applied, as an embedded resource) Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL. Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`, @@ -1760,6 +1798,7 @@ URL: ${normalized}`, "get_screenshot", "get_viewer_state", "save_as", + "get_current_pdf", ]) .describe("Action to perform"), page: z @@ -1829,7 +1868,11 @@ URL: ${normalized}`, type InteractCommand = z.infer; type ContentPart = | { type: "text"; text: string } - | { type: "image"; data: string; mimeType: string }; + | { type: "image"; data: string; mimeType: string } + | { + type: "resource"; + resource: { uri: string; mimeType: string; blob: string }; + }; /** * Resolve an image annotation: fetch imageUrl → imageData if needed, @@ -2347,6 +2390,42 @@ URL: ${normalized}`, ); } } + case "get_current_pdf": { + const requestId = randomUUID(); + enqueueCommand(uuid, { type: "get_current_pdf", requestId }); + let payload: CurrentPdfPayload; + try { + await ensureViewerIsPolling(uuid); + payload = await waitForCurrentPdf(requestId, signal); + } catch (err) { + return { + content: [ + { + type: "text", + text: `get_current_pdf failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + const byteLength = Buffer.from(payload.data, "base64").length; + return { + content: [ + { + type: "text", + text: `${payload.fileName} (application/pdf, ${byteLength} bytes${payload.hasEdits ? ", includes in-viewer edits" : ""})`, + }, + { + type: "resource", + resource: { + uri: `file:///${encodeURIComponent(payload.fileName)}`, + mimeType: "application/pdf", + blob: payload.data, + }, + }, + ], + }; + } case "get_viewer_state": { const requestId = randomUUID(); enqueueCommand(uuid, { type: "get_viewer_state", requestId }); @@ -2428,7 +2507,9 @@ Example — add a signature image and a stamp, then screenshot to verify: **FORMS** — fill_form: fill fields with \`fields\` array of {name, value}. -**SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).`, +**SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`). + +**EXPORT** — get_current_pdf: return the current document (annotations + form values applied) as an embedded PDF resource. No file is written. The result embeds the whole document as base64, so prefer a single call once editing is done.`, inputSchema: z.object({ viewUUID: z .string() @@ -2452,6 +2533,7 @@ Example — add a signature image and a stamp, then screenshot to verify: "get_screenshot", "get_viewer_state", "save_as", + "get_current_pdf", ]) .optional() .describe( @@ -2633,10 +2715,23 @@ Example — add a signature image and a stamp, then screenshot to verify: if (result.content.length === 1) { allContent.push(result.content[0]); } else { - const texts = result.content - .map((c) => (c.type === "text" ? c.text : null)) - .filter((t) => t != null); - allContent.push({ type: "text", text: texts.join("\n") }); + const resourcePart = result.content.find( + (c) => c.type === "resource", + ); + if (resourcePart && commandList.length === 1) { + // Single command: no 1:1 slot contract — keep the summary + // text and the embedded resource (get_current_pdf) as-is. + allContent.push(...(result.content as ContentPart[])); + } else if (resourcePart) { + // Batch: preserve the embedded resource as the slot's value — + // the accompanying text block is only a summary. + allContent.push(resourcePart as ContentPart); + } else { + const texts = result.content + .map((c) => (c.type === "text" ? c.text : null)) + .filter((t) => t != null); + allContent.push({ type: "text", text: texts.join("\n") }); + } } } @@ -2734,6 +2829,63 @@ Example — add a signature image and a stamp, then screenshot to verify: }, ); + // Tool: submit_current_pdf (app-only) - Viewer submits current PDF bytes + registerAppTool( + server, + "submit_current_pdf", + { + title: "Submit Current PDF", + description: + "Submit current PDF bytes for a get_current_pdf request (used by viewer). The model should NOT call this tool directly.", + inputSchema: z.object({ + requestId: z + .string() + .describe("The request ID from the get_current_pdf command"), + data: z.string().optional().describe("Base64-encoded PDF bytes"), + fileName: z + .string() + .optional() + .describe("Suggested file name for the document"), + hasEdits: z + .boolean() + .optional() + .describe("Whether the bytes include in-viewer edits"), + error: z + .string() + .optional() + .describe("Error message if the viewer failed to build bytes"), + }), + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ + requestId, + data, + fileName, + hasEdits, + error, + }): Promise => { + const settle = pendingCurrentPdfRequests.get(requestId); + if (!settle) { + return { + content: [ + { type: "text", text: `No pending request for ${requestId}` }, + ], + isError: true, + }; + } + if (error || !data) { + settle(new Error(error || "Viewer returned no data")); + } else { + settle({ + data, + fileName: fileName || "document.pdf", + hasEdits: hasEdits ?? false, + }); + } + return { content: [{ type: "text", text: "Submitted" }] }; + }, + ); + // Tool: submit_viewer_state (app-only) - Viewer reports its live state registerAppTool( server, diff --git a/examples/pdf-server/src/commands.ts b/examples/pdf-server/src/commands.ts index 6d27edf9a..e27b73ada 100644 --- a/examples/pdf-server/src/commands.ts +++ b/examples/pdf-server/src/commands.ts @@ -66,5 +66,6 @@ export type PdfCommand = getScreenshots: boolean; } | { type: "save_as"; requestId: string } + | { type: "get_current_pdf"; requestId: string } | { type: "get_viewer_state"; requestId: string } | { type: "file_changed"; mtimeMs: number }; diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 45e0c7458..e3dea80ce 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3220,6 +3220,38 @@ async function savePdf(): Promise { } } +/** + * Whether exporting now would bake in any changes vs. the loaded bytes — + * the same diff getAnnotatedPdfBytes applies (added/removed annotations, + * changed or cleared form values). annotationMap/formFieldValues alone + * don't work here: both are seeded with baseline state from the PDF. + */ +function hasInViewerEdits(): boolean { + const baselineIds = new Set(pdfBaselineAnnotations.map((a) => a.id)); + for (const tracked of annotationMap.values()) { + if (!baselineIds.has(tracked.def.id)) return true; + } + for (const a of pdfBaselineAnnotations) { + if (!annotationMap.has(a.id)) return true; + } + for (const id of restoredRemovedIds) { + if (!annotationMap.has(id)) return true; + } + for (const [name, value] of formFieldValues) { + if (pdfBaselineFormValues.get(name) !== value) return true; + } + for (const name of pdfBaselineFormValues.keys()) { + if (!formFieldValues.has(name)) return true; + } + return false; +} + +/** Suggested file name for exporting the current document. */ +function currentPdfFileName(hasEdits: boolean): string { + const baseName = (pdfTitle || "document").replace(/\.pdf$/i, ""); + return hasEdits ? `${baseName} - edited.pdf` : `${baseName}.pdf`; +} + async function downloadAnnotatedPdf(): Promise { if (!pdfDocument) return; downloadBtn.disabled = true; @@ -3228,9 +3260,7 @@ async function downloadAnnotatedPdf(): Promise { try { const pdfBytes = await getAnnotatedPdfBytes(); - const hasEdits = annotationMap.size > 0 || formFieldValues.size > 0; - const baseName = (pdfTitle || "document").replace(/\.pdf$/i, ""); - const fileName = hasEdits ? `${baseName} - edited.pdf` : `${baseName}.pdf`; + const fileName = currentPdfFileName(hasInViewerEdits()); const base64 = uint8ArrayToBase64(pdfBytes); @@ -4976,6 +5006,37 @@ async function processCommands(commands: PdfCommand[]): Promise { .catch(() => {}); } break; + case "get_current_pdf": + // Same await-before-next-poll discipline as get_pages/save_as. + try { + const pdfBytes = await getAnnotatedPdfBytes(); + const hasEdits = hasInViewerEdits(); + await app.callServerTool({ + name: "submit_current_pdf", + arguments: { + requestId: cmd.requestId, + data: uint8ArrayToBase64(pdfBytes), + fileName: currentPdfFileName(hasEdits), + hasEdits, + }, + }); + log.info(`get_current_pdf: submitted ${pdfBytes.length} bytes`); + } catch (err) { + log.error( + "get_current_pdf: failed to build bytes — submitting error:", + err, + ); + await app + .callServerTool({ + name: "submit_current_pdf", + arguments: { + requestId: cmd.requestId, + error: err instanceof Error ? err.message : String(err), + }, + }) + .catch(() => {}); + } + break; case "get_viewer_state": // Same await-before-next-poll discipline as get_pages/save_as. try { @@ -5032,13 +5093,14 @@ async function processCommands(commands: PdfCommand[]): Promise { } // Persist after processing batch — but only if anything mutated. - // get_pages / save_as / file_changed are read-only; writing localStorage - // and recomputing the diff for them is wasted work. + // get_pages / save_as / get_current_pdf / file_changed are read-only; + // writing localStorage and recomputing the diff for them is wasted work. if ( commands.some( (c) => c.type !== "get_pages" && c.type !== "save_as" && + c.type !== "get_current_pdf" && c.type !== "file_changed", ) ) { @@ -5565,6 +5627,54 @@ app.registerTool( }, ); +app.registerTool( + "get_current_pdf", + { + title: "Get Current PDF", + description: + "Get the current PDF document with all annotations and form fills applied, returned as an embedded PDF resource. Use this to retrieve the edited document, e.g. to read it or hand it to another tool. The result embeds the whole document as base64, so prefer a single call once editing is done.", + }, + async () => { + if (!pdfDocument) { + return { + content: [{ type: "text" as const, text: "Error: No document loaded" }], + isError: true, + }; + } + try { + const pdfBytes = await getAnnotatedPdfBytes(); + const hasEdits = hasInViewerEdits(); + const fileName = currentPdfFileName(hasEdits); + return { + content: [ + { + type: "text" as const, + text: `${fileName} (application/pdf, ${pdfBytes.length} bytes${hasEdits ? ", includes in-viewer edits" : ""})`, + }, + { + type: "resource" as const, + resource: { + uri: `file:///${encodeURIComponent(fileName)}`, + mimeType: "application/pdf", + blob: uint8ArrayToBase64(pdfBytes), + }, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: "text" as const, + text: `Error: failed to export PDF: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + // Connect to host app .connect() diff --git a/tests/e2e/pdf-get-current-pdf.spec.ts b/tests/e2e/pdf-get-current-pdf.spec.ts new file mode 100644 index 000000000..a1a6615b1 --- /dev/null +++ b/tests/e2e/pdf-get-current-pdf.spec.ts @@ -0,0 +1,276 @@ +/** + * PDF Server — get_current_pdf (app tool + interact action) + * + * App-tool path: the viewer registers tools on the view itself + * (App.registerTool). A host calls them by sending `tools/call` JSON-RPC + * requests to the app iframe over its PostMessageTransport, which accepts + * messages whose source is window.parent. The basic-host test UI only drives + * server tools, so the first test plays the host's role directly: it posts + * the JSON-RPC request from the sandbox frame (the app iframe's parent) and + * reads the response there. + * + * Interact path: the second test drives the server-mediated bridge through + * the basic-host UI (interact enqueues a command, the viewer polls, builds + * the bytes and posts them back via submit_current_pdf). + */ + +import { test, expect, type Page, type Frame } from "@playwright/test"; + +/** Wait for the MCP App to load inside nested iframes. */ +async function waitForAppLoad(page: Page) { + const outerFrame = page.frameLocator("iframe").first(); + await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 }); +} + +/** Get the app frame locator (nested: sandbox > app) */ +function getAppFrame(page: Page) { + return page.frameLocator("iframe").first().frameLocator("iframe").first(); +} + +/** Load the PDF server and call display_pdf with the default PDF. */ +async function loadPdfServer(page: Page) { + await page.goto("/?theme=hide"); + // The host fetches the server list once per page load, and pdf-server + // rebuilds on examples:start, so it can come up after the first load — + // reload until it appears instead of waiting on a stale list. + await expect(async () => { + await page.reload(); + const serverSelect = page.locator("select").first(); + await expect(serverSelect).toBeEnabled({ timeout: 30000 }); + expect(await serverSelect.locator("option").allTextContents()).toContain( + "PDF Server", + ); + }).toPass({ timeout: 90000, intervals: [2000] }); + await page.locator("select").first().selectOption({ label: "PDF Server" }); + await page.click('button:has-text("Call Tool")'); + await waitForAppLoad(page); +} + +/** Wait for the PDF canvas to render. */ +async function waitForPdfCanvas(page: Page) { + const appFrame = getAppFrame(page); + await expect(appFrame.locator("canvas").first()).toBeVisible({ + timeout: 30000, + }); + await page.waitForTimeout(2000); +} + +/** Resolve the sandbox frame (the app iframe's parent). */ +function getSandboxFrame(page: Page): Frame { + const appFrame = page + .frames() + .find((f) => f.parentFrame() && f.parentFrame()!.parentFrame()); + if (!appFrame) throw new Error("App frame not found"); + return appFrame.parentFrame()!; +} + +/** + * Send one JSON-RPC request into the app iframe from the sandbox frame and + * await the matching response. + */ +function sendJsonRpcToApp( + sandboxFrame: Frame, + request: { id: number; method: string; params?: unknown }, + timeoutMs = 120_000, +): Promise { + return sandboxFrame.evaluate( + async ({ id, method, params, timeoutMs }) => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + return await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${method} timed out`)), + timeoutMs, + ); + const onMsg = (e: MessageEvent) => { + // Only accept a response (not an echoed request) to our id, from + // the app iframe itself. + if ( + e.source === iframe.contentWindow && + e.data?.jsonrpc === "2.0" && + e.data.id === id && + ("result" in e.data || "error" in e.data) + ) { + clearTimeout(timer); + window.removeEventListener("message", onMsg); + resolve(e.data); + } + }; + window.addEventListener("message", onMsg); + iframe.contentWindow!.postMessage( + { jsonrpc: "2.0", id, method, params }, + "*", + ); + }); + }, + { ...request, timeoutMs }, + ); +} + +type ToolCallResponse = { + result: { + isError?: boolean; + content: { + type: string; + text?: string; + resource?: { uri: string; mimeType: string; blob: string }; + }[]; + }; +}; + +test.describe("PDF Server - get_current_pdf", () => { + test("is listed and returns the document as an embedded PDF resource", async ({ + page, + }) => { + // Waiting out pdf-server's rebuild plus serializing a multi-MB PDF + // through postMessage can push past the default 30s on slow runners. + test.setTimeout(180_000); + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const sandboxFrame = getSandboxFrame(page); + + const listResponse = (await sendJsonRpcToApp(sandboxFrame, { + id: 9101, + method: "tools/list", + params: {}, + })) as { result: { tools: { name: string }[] } }; + expect(listResponse.result.tools.map((t) => t.name)).toContain( + "get_current_pdf", + ); + + // Pristine document: a real PDF comes back, not marked as edited. + const pristine = (await sendJsonRpcToApp(sandboxFrame, { + id: 9102, + method: "tools/call", + params: { name: "get_current_pdf", arguments: {} }, + })) as ToolCallResponse; + + expect(pristine.result.isError).toBeFalsy(); + const pristineText = pristine.result.content.find((c) => c.type === "text"); + expect(pristineText?.text).toMatch(/\.pdf \(application\/pdf, \d+ bytes/); + expect(pristineText?.text).not.toContain("includes in-viewer edits"); + + const pristineRes = pristine.result.content.find( + (c) => c.type === "resource", + ); + expect(pristineRes).toBeTruthy(); + expect(pristineRes!.resource!.mimeType).toBe("application/pdf"); + expect(pristineRes!.resource!.uri).toMatch(/^file:\/\/\/.+\.pdf$/); + // The blob must be a real PDF: base64 of "%PDF" is "JVBERi" + expect(pristineRes!.resource!.blob.slice(0, 6)).toBe("JVBERi"); + expect(pristineRes!.resource!.blob.length).toBeGreaterThan(10000); + + // Annotate, then export again: the result reflects the edit. + const annotate = (await sendJsonRpcToApp(sandboxFrame, { + id: 9103, + method: "tools/call", + params: { + name: "add_annotations", + arguments: { + annotations: [ + { + id: "e2e_note", + type: "note", + page: 1, + x: 100, + y: 500, + content: "e2e test note", + }, + ], + }, + }, + })) as ToolCallResponse; + expect(annotate.result.isError).toBeFalsy(); + + const edited = (await sendJsonRpcToApp(sandboxFrame, { + id: 9104, + method: "tools/call", + params: { name: "get_current_pdf", arguments: {} }, + })) as ToolCallResponse; + + expect(edited.result.isError).toBeFalsy(); + const editedText = edited.result.content.find((c) => c.type === "text"); + expect(editedText?.text).toContain("includes in-viewer edits"); + + const editedRes = edited.result.content.find((c) => c.type === "resource"); + expect(editedRes!.resource!.uri).toContain("edited"); + expect(editedRes!.resource!.blob.slice(0, 6)).toBe("JVBERi"); + // The annotation is baked into the bytes, so the export differs. + expect(editedRes!.resource!.blob).not.toBe(pristineRes!.resource!.blob); + }); +}); + +/** + * Extract the viewUUID from the display_pdf result panel. + */ +async function extractViewUUID(page: Page): Promise { + const resultPanel = page.locator('text="📤 Tool Result"').first(); + await expect(resultPanel).toBeVisible({ timeout: 30000 }); + await resultPanel.click(); + const resultContent = page.locator("pre").last(); + await expect(resultContent).toBeVisible({ timeout: 5000 }); + const resultText = (await resultContent.textContent()) ?? ""; + const match = resultText.match(/viewUUID["\s:]+([a-f0-9-]{36})/); + if (!match) { + throw new Error( + `Could not extract viewUUID from result: ${resultText.slice(0, 200)}`, + ); + } + return match[1]; +} + +/** Call the interact tool with the given input JSON via the basic-host UI. */ +async function callInteract(page: Page, input: Record) { + const toolSelect = page.locator("select").nth(1); + await toolSelect.selectOption("interact"); + const inputTextarea = page.locator("textarea"); + await inputTextarea.fill(JSON.stringify(input)); + await page.click('button:has-text("Call Tool")'); +} + +/** Read the most recent tool result JSON once `expectedCount` panels exist. */ +async function readLastToolResult( + page: Page, + expectedCount: number, +): Promise { + const panels = page.locator('text="📤 Tool Result"'); + await expect(panels).toHaveCount(expectedCount, { timeout: 60000 }); + await panels.last().click(); + const pre = page.locator("pre").last(); + await expect(pre).toBeVisible({ timeout: 5000 }); + return (await pre.textContent()) ?? ""; +} + +test.describe("PDF Server - get_current_pdf via interact", () => { + test("server-mediated export returns the document as an embedded PDF resource", async ({ + page, + }) => { + test.setTimeout(180_000); + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const viewUUID = await extractViewUUID(page); + await callInteract(page, { viewUUID, action: "get_current_pdf" }); + + const raw = await readLastToolResult(page, 2); + const parsed = JSON.parse(raw) as { + isError?: boolean; + content: { + type: string; + text?: string; + resource?: { uri: string; mimeType: string; blob: string }; + }[]; + }; + + expect(parsed.isError).toBeFalsy(); + const textBlock = parsed.content.find((c) => c.type === "text"); + expect(textBlock?.text).toMatch(/\.pdf \(application\/pdf, \d+ bytes/); + + const resourceBlock = parsed.content.find((c) => c.type === "resource"); + expect(resourceBlock).toBeTruthy(); + expect(resourceBlock!.resource!.mimeType).toBe("application/pdf"); + expect(resourceBlock!.resource!.uri).toMatch(/^file:\/\/\/.+\.pdf$/); + expect(resourceBlock!.resource!.blob.slice(0, 6)).toBe("JVBERi"); + expect(resourceBlock!.resource!.blob.length).toBeGreaterThan(10000); + }); +});