From 3f7b16f40c8dd43340a39ac2375d335c8020e7f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:04:23 +0000 Subject: [PATCH 1/2] Fix pdf-server saved-page restore: dead pageCount guard since #329 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer saves the current page to localStorage and is meant to restore it when a host re-renders the same tool call, but the restore bounds-checked the saved page against structuredContent.pageCount — a field display_pdf stopped sending in #329 (the server no longer pre-parses the PDF). The comparison was always false, so the viewer always fell back to initialPage: annotations survived a re-render, the page never did. - Restore the saved page unconditionally and clamp to totalPages once the document loads (same clamp reloadPdf already has). - Resolve viewUUID from _meta with a structuredContent fallback: the server sends it in both, and some hosts drop _meta on replayed tool results — without the fallback the page had no storage key at all. - Drop the pageCount field from the parsed-result type (never sent). The e2e test plays the host: it captures the display_pdf result from the result panel, navigates via the view's tool, re-posts the result as a ui/notifications/tool-result notification, and asserts the viewer comes back on the saved page — with and without _meta. --- examples/pdf-server/src/mcp-app.ts | 38 +++--- tests/e2e/pdf-page-restore.spec.ts | 207 +++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/pdf-page-restore.spec.ts diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 45e0c7458..7a304dda1 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -1535,9 +1535,7 @@ function setupResizeHandle( e.preventDefault(); const def = tracked.def as - | RectangleAnnotation - | CircleAnnotation - | ImageAnnotation; + RectangleAnnotation | CircleAnnotation | ImageAnnotation; const beforeDef = { ...def }; const startX = e.clientX; const startY = e.clientY; @@ -1644,9 +1642,7 @@ function setupRotateHandle( e.preventDefault(); const def = tracked.def as - | StampAnnotation - | RectangleAnnotation - | ImageAnnotation; + StampAnnotation | RectangleAnnotation | ImageAnnotation; const beforeDef = { ...def }; const el = tracked.elements[0]; const rect = el.getBoundingClientRect(); @@ -3069,10 +3065,7 @@ function setFieldInStorage(name: string, value: string | boolean): void { for (const id of ids) { storage.setValue(id, { value: storageValue }); const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as - | HTMLInputElement - | HTMLSelectElement - | HTMLTextAreaElement - | null; + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; if (!el) continue; if (el instanceof HTMLInputElement && el.type === "checkbox") { el.checked = !!value; @@ -4287,14 +4280,14 @@ canvasContainerEl.addEventListener("touchcancel", () => { function parseToolResult(result: CallToolResult): { url: string; title?: string; - pageCount: number; + viewUUID?: string; initialPage: number; totalBytes: number; } | null { return result.structuredContent as { url: string; title?: string; - pageCount: number; + viewUUID?: string; initialPage: number; totalBytes: number; } | null; @@ -4682,18 +4675,25 @@ app.ontoolresult = async (result: CallToolResult) => { pdfUrl = parsed.url; pdfTitle = parsed.title; - // Note: pageCount may not be accurate until document loads - totalPages = parsed.pageCount || 1; - viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined; + // Placeholder until the document loads — the tool result carries no page + // count since the server stopped pre-parsing the PDF. + totalPages = 1; + // Prefer _meta, but fall back to structuredContent: the server sends + // viewUUID in both, and some hosts drop _meta from replayed tool results. + viewUUID = result._meta?.viewUUID + ? String(result._meta.viewUUID) + : parsed.viewUUID + ? String(parsed.viewUUID) + : undefined; interactEnabled = result._meta?.interactEnabled === true; fileWritable = result._meta?.writable === true; // Debug bubble: server only emits _debug when --debug is set. if (result._meta?._debug !== undefined) showDebugBubble(result._meta._debug); - // Restore saved page or use initial page + // Restore saved page or use initial page. The saved page can't be + // bounds-checked yet; it's clamped once the document loads below. const savedPage = loadSavedPage(); - currentPage = - savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage; + currentPage = savedPage ?? parsed.initialPage; log.info("URL:", pdfUrl, "Starting at page:", currentPage); @@ -4704,6 +4704,8 @@ app.ontoolresult = async (result: CallToolResult) => { const { document, totalBytes } = await loadPdfProgressively(pdfUrl); pdfDocument = document; totalPages = document.numPages; + // A restored page may point past the end of a changed document. + currentPage = Math.max(1, Math.min(currentPage, totalPages)); log.info("PDF loaded, pages:", totalPages, "bytes:", totalBytes); diff --git a/tests/e2e/pdf-page-restore.spec.ts b/tests/e2e/pdf-page-restore.spec.ts new file mode 100644 index 000000000..b8f76dfbe --- /dev/null +++ b/tests/e2e/pdf-page-restore.spec.ts @@ -0,0 +1,207 @@ +/** + * PDF Server — saved-page restore on tool-result re-delivery + * + * The viewer persists the current page to localStorage (keyed by viewUUID) + * and is meant to restore it when a host re-renders the same tool call — + * which hosts do by re-sending `ui/notifications/tool-result`. Regression + * test for the restore being discarded: the old code bounds-checked the + * saved page against structuredContent.pageCount, a field the server + * stopped sending, so the comparison was always false and the viewer + * always fell back to initialPage. + * + * The basic-host test UI doesn't replay tool results, so the test plays the + * host: it captures the display_pdf result from the host's result panel and + * re-posts it into the app iframe as a `ui/notifications/tool-result` + * notification (the transport accepts messages from window.parent, i.e. the + * sandbox frame). + */ + +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()!; +} + +/** Read the display_pdf CallToolResult JSON from the host's result panel. */ +async function readDisplayPdfResult( + page: Page, +): Promise> { + const resultPanel = page.locator('text="📤 Tool Result"').first(); + await expect(resultPanel).toBeVisible({ timeout: 30000 }); + await resultPanel.click(); + const pre = page.locator("pre").last(); + await expect(pre).toBeVisible({ timeout: 5000 }); + const raw = (await pre.textContent()) ?? ""; + return JSON.parse(raw) as Record; +} + +/** Post one JSON-RPC message into the app iframe from the sandbox frame. */ +function postToApp(sandboxFrame: Frame, message: unknown): Promise { + return sandboxFrame.evaluate((msg) => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + iframe.contentWindow!.postMessage(msg, "*"); + }, message); +} + +/** Call a view-registered tool and await its response. */ +function callAppTool( + sandboxFrame: Frame, + id: number, + name: string, + args: Record, +): Promise { + return sandboxFrame.evaluate( + async ({ id, name, args }) => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + return await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`tools/call ${name} timed out`)), + 30000, + ); + const onMsg = (e: MessageEvent) => { + 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: "tools/call", + params: { name, arguments: args }, + }, + "*", + ); + }); + }, + { id, name, args }, + ); +} + +test.describe("PDF Server - saved page restore", () => { + test("re-delivered tool result restores the last viewed page", async ({ + page, + }) => { + test.setTimeout(180_000); + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const appFrame = getAppFrame(page); + await expect(appFrame.locator("#page-input")).toHaveValue("1"); + + // Capture the exact CallToolResult the host delivered. + const toolResult = await readDisplayPdfResult(page); + expect( + (toolResult.structuredContent as { viewUUID?: string })?.viewUUID, + ).toBeTruthy(); + + const sandboxFrame = getSandboxFrame(page); + + // Navigate to page 3 via the view's own tool — goToPage persists the + // page to localStorage under the viewUUID key. + const nav = (await callAppTool(sandboxFrame, 9301, "navigate", { + page: 3, + })) as { result: { isError?: boolean } }; + expect(nav.result.isError).toBeFalsy(); + await expect(appFrame.locator("#page-input")).toHaveValue("3"); + + // Replay the host's tool result — this is what a real host does when it + // re-renders the same tool call (conversation reopened, page refreshed). + await postToApp(sandboxFrame, { + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: toolResult, + }); + + // The viewer reloads the document and must come back on page 3, not 1. + await expect(appFrame.locator("#page-input")).toHaveValue("3", { + timeout: 60000, + }); + await expect(appFrame.locator("canvas").first()).toBeVisible(); + }); + + test("restore works when _meta is missing (viewUUID from structuredContent)", async ({ + page, + }) => { + test.setTimeout(180_000); + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const appFrame = getAppFrame(page); + const toolResult = await readDisplayPdfResult(page); + const sandboxFrame = getSandboxFrame(page); + + const nav = (await callAppTool(sandboxFrame, 9302, "navigate", { + page: 4, + })) as { result: { isError?: boolean } }; + expect(nav.result.isError).toBeFalsy(); + await expect(appFrame.locator("#page-input")).toHaveValue("4"); + + // Some hosts drop _meta from replayed tool results; the server also + // sends viewUUID in structuredContent, which the viewer must fall + // back to for the storage key. + const withoutMeta = { ...toolResult }; + delete withoutMeta._meta; + await postToApp(sandboxFrame, { + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: withoutMeta, + }); + + await expect(appFrame.locator("#page-input")).toHaveValue("4", { + timeout: 60000, + }); + }); +}); From e880dfe1c97d7d48ed4bdd4e1404a14b8c9678a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Oct 2026 01:05:13 +0000 Subject: [PATCH 2/2] style: auto-fix prettier formatting --- examples/pdf-server/src/mcp-app.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 7a304dda1..e8cd540c4 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -1535,7 +1535,9 @@ function setupResizeHandle( e.preventDefault(); const def = tracked.def as - RectangleAnnotation | CircleAnnotation | ImageAnnotation; + | RectangleAnnotation + | CircleAnnotation + | ImageAnnotation; const beforeDef = { ...def }; const startX = e.clientX; const startY = e.clientY; @@ -1642,7 +1644,9 @@ function setupRotateHandle( e.preventDefault(); const def = tracked.def as - StampAnnotation | RectangleAnnotation | ImageAnnotation; + | StampAnnotation + | RectangleAnnotation + | ImageAnnotation; const beforeDef = { ...def }; const el = tracked.elements[0]; const rect = el.getBoundingClientRect(); @@ -3065,7 +3069,10 @@ function setFieldInStorage(name: string, value: string | boolean): void { for (const id of ids) { storage.setValue(id, { value: storageValue }); const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | null; if (!el) continue; if (el instanceof HTMLInputElement && el.type === "checkbox") { el.checked = !!value;