diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 45e0c7458..e8cd540c4 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -4287,14 +4287,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 +4682,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 +4711,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, + }); + }); +});