diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 2bee370a1..f4c6e2e58 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -153,6 +153,7 @@ bun examples/pdf-server/main.ts --stdio ./papers/ - `--debug` — Enable verbose server-side logging. - `--enable-interact` — Enable the `interact` tool in HTTP mode (see [Deployment](#deployment)). Not needed for stdio. +- `--split-app-tools` — Register the view's actions as separate app tools (`navigate`, `search`, `add_annotations`, ...) instead of the default single `interact` app tool. Hosts that ask the user to approve each app tool separately show one approval card per tool, so the default keeps that to one approval; split tools can still be useful for hosts that pick tools by name. - `--writeable-uploads-root` — Allow saving annotated PDFs back to files under client roots named `uploads` (Claude Desktop mounts attachments there; writes are refused by default). ## Deployment diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index bb7b57f42..bfbb62b2d 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -97,6 +97,7 @@ function parseArgs(): { useClientRoots: boolean; enableInteract: boolean; debug: boolean; + splitAppTools: boolean; } { const args = process.argv.slice(2); const urls: string[] = []; @@ -104,6 +105,7 @@ function parseArgs(): { let useClientRoots = false; let enableInteract = false; let debug = false; + let splitAppTools = false; for (const arg of args) { if (arg === "--stdio") { @@ -118,6 +120,11 @@ function parseArgs(): { enableInteract = true; } else if (arg === "--debug") { debug = true; + } else if (arg === "--split-app-tools") { + // Register the view's actions as separate app tools instead of the + // default single `interact` app tool. Hosts that ask the user to + // approve each app tool separately then show one card per tool. + splitAppTools = true; } else if (arg === "--writeable-uploads-root") { // Claude Desktop mounts attachments under a dir root named "uploads"; // by default we refuse to write there. This flag opts back in. @@ -144,11 +151,13 @@ function parseArgs(): { useClientRoots, enableInteract, debug, + splitAppTools, }; } async function main() { - const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs(); + const { urls, stdio, useClientRoots, enableInteract, debug, splitAppTools } = + parseArgs(); // Register local files in whitelist for (const url of urls) { @@ -175,7 +184,12 @@ async function main() { if (stdio) { // stdio → client is local (e.g. Claude Desktop), roots are safe await startStdioServer(() => - createServer({ enableInteract: true, useClientRoots: true, debug }), + createServer({ + enableInteract: true, + useClientRoots: true, + debug, + splitAppTools, + }), ); } else { // HTTP → client is remote, only honour roots with explicit opt-in @@ -186,7 +200,7 @@ async function main() { ); } await startStreamableHTTPServer(() => - createServer({ useClientRoots, enableInteract, debug }), + createServer({ useClientRoots, enableInteract, debug, splitAppTools }), ); } } diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index 0b2a5eb4d..b72aa8582 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -1815,3 +1815,50 @@ describe("interact tool", () => { }, 15_000); }); }); + +// The served view is the built bundle, which not every CI job builds +// (npm-publish's test job runs the unit suite without building examples). +const viewIsBuilt = fs.existsSync( + path.join(import.meta.dir, "dist", "mcp-app.html"), +); + +describe.skipIf(!viewIsBuilt)("splitAppTools view config injection", () => { + async function readViewHtml(options?: { + splitAppTools?: boolean; + }): Promise { + const server = createServer(options); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + try { + const res = await client.readResource({ + uri: "ui://pdf-viewer/mcp-app.html", + }); + const first = res.contents[0] as { text?: string }; + return first.text ?? ""; + } finally { + await client.close(); + await server.close(); + } + } + + it("serves the view unmodified by default", async () => { + const html = await readViewHtml(); + expect(html).toContain(" when enabled", async () => { + const html = await readViewHtml({ splitAppTools: true }); + const idx = html.indexOf( + "globalThis.PDF_APP_CONFIG = { splitAppTools: true }", + ); + expect(idx).toBeGreaterThan(-1); + // Injected right after , before any module script runs. + expect(idx).toBeLessThan(html.indexOf("`, + ); + if (injected === html) { + console.error( + "[pdf-server] --split-app-tools: no tag found in the view HTML; " + + "falling back to the single interact app tool", + ); + } + cachedSplitAppHtml = injected; + } + servedHtml = cachedSplitAppHtml; + } return { contents: [ { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, - text: html, + text: servedHtml, _meta: { ui: { permissions: { clipboardWrite: {} }, diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index e8cd540c4..61349d5d4 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -5183,12 +5183,20 @@ app.onteardown = async () => { app.onhostcontextchanged = handleHostContextChanged; // ============================================================================= -// App-registered tools — 1:1 with the server's `interact` commands. +// App-registered tools. // -// Each tool constructs the matching PdfCommand and dispatches through -// processCommands(), so the command-handling logic lives in exactly one -// place. The server-side `interact` tool remains for hosts that don't -// support app-registered tools. +// By default the view registers a single `interact` app tool that mirrors +// the server-side `interact` (an `action` plus flat parameters, with an +// optional `commands` batch). Hosts that ask the user to approve each app +// tool separately then show one approval instead of thirteen. Start the +// server with --split-app-tools to register each action as its own +// narrowly-scoped tool instead (one tool per action, each with its own +// schema). +// +// Either way, each action constructs the matching PdfCommand and +// dispatches through processCommands(), so the command-handling logic +// lives in exactly one place. The server-side `interact` tool remains for +// hosts that don't support app-registered tools. // ============================================================================= /** Shared zod shapes mirroring server.ts interact schema. */ @@ -5217,140 +5225,150 @@ async function runCommand( return { content: [{ type: "text" as const, text }] }; } -app.registerTool( - "get-document-info", - { +/** One action of the app-side tool surface. */ +type AppToolDef = { + name: string; + title: string; + description: string; + /** Validates the action's own parameters (required fields per action). */ + inputSchema?: z.ZodObject; + handler: (args: Record) => Promise; +}; + +/** Keeps each handler typed by its own schema while storing defs uniformly. */ +function defineAppTool(def: { + name: string; + title: string; + description: string; + inputSchema?: z.ZodObject; + handler: (args: z.output>) => Promise; +}): AppToolDef { + return def as unknown as AppToolDef; +} + +const APP_TOOL_DEFS: AppToolDef[] = [ + defineAppTool({ + name: "get_document_info", title: "Get Document Info", description: "Get information about the current PDF document including title, current page, total pages, and zoom level", - }, - async () => { - if (!pdfDocument) { + handler: async () => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const info = { + title: pdfTitle || "Untitled", + url: pdfUrl, + currentPage, + totalPages, + scale, + displayMode: currentDisplayMode, + }; return { - content: [{ type: "text" as const, text: "Error: No document loaded" }], - isError: true, + content: [ + { type: "text" as const, text: JSON.stringify(info, null, 2) }, + ], + structuredContent: info, }; - } - const info = { - title: pdfTitle || "Untitled", - url: pdfUrl, - currentPage, - totalPages, - scale, - displayMode: currentDisplayMode, - }; - return { - content: [{ type: "text" as const, text: JSON.stringify(info, null, 2) }], - structuredContent: info, - }; - }, -); - -app.registerTool( - "navigate", - { + }, + }), + defineAppTool({ + name: "navigate", title: "Navigate", description: "Jump to a specific page in the document", inputSchema: z.object({ page: z.number().int().min(1).describe("Page number (1-indexed)"), }), - }, - async ({ page }) => { - if (pdfDocument && (page < 1 || page > totalPages)) { - return { - content: [ - { - type: "text" as const, - text: `Error: Page ${page} out of range (1-${totalPages})`, - }, - ], - isError: true, - }; - } - return runCommand( - { type: "navigate", page }, - `Navigated to page ${page}/${totalPages}`, - ); - }, -); - -app.registerTool( - "search", - { + handler: async ({ page }) => { + if (pdfDocument && (page < 1 || page > totalPages)) { + return { + content: [ + { + type: "text" as const, + text: `Error: Page ${page} out of range (1-${totalPages})`, + }, + ], + isError: true, + }; + } + return runCommand( + { type: "navigate", page }, + `Navigated to page ${page}/${totalPages}`, + ); + }, + }), + defineAppTool({ + name: "search", title: "Search", description: "Search for text and highlight matches in the viewer UI. Opens the search bar and jumps to the first match.", inputSchema: z.object({ query: z.string().describe("Text to search for"), }), - }, - async ({ query }) => - runCommand( - { type: "search", query }, - () => `Searched for "${query}": ${allMatches.length} match(es)`, - ), -); - -app.registerTool( - "find", - { + handler: async ({ query }) => + runCommand( + { type: "search", query }, + () => `Searched for "${query}": ${allMatches.length} match(es)`, + ), + }), + defineAppTool({ + name: "find", title: "Find", description: "Silent search — locate matches without opening the search UI. Use before search_navigate.", inputSchema: z.object({ query: z.string().describe("Text to search for"), }), - }, - async ({ query }) => - runCommand( - { type: "find", query }, - () => `Found ${allMatches.length} match(es) for "${query}"`, - ), -); - -app.registerTool( - "search_navigate", - { + handler: async ({ query }) => + runCommand( + { type: "find", query }, + () => `Found ${allMatches.length} match(es) for "${query}"`, + ), + }), + defineAppTool({ + name: "search_navigate", title: "Search Navigate", description: "Jump to the Nth search match (0-indexed). Call search or find first.", inputSchema: z.object({ matchIndex: z.number().int().min(0).describe("Match index (0-indexed)"), }), - }, - async ({ matchIndex }) => { - if (allMatches.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "Error: No search results. Call search or find first.", - }, - ], - isError: true, - }; - } - if (matchIndex >= allMatches.length) { - return { - content: [ - { - type: "text" as const, - text: `Error: matchIndex ${matchIndex} out of range (0-${allMatches.length - 1})`, - }, - ], - isError: true, - }; - } - return runCommand( - { type: "search_navigate", matchIndex }, - `Jumped to match ${matchIndex + 1}/${allMatches.length} on page ${allMatches[matchIndex].pageNum}`, - ); - }, -); - -app.registerTool( - "zoom", - { + handler: async ({ matchIndex }) => { + if (allMatches.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "Error: No search results. Call search or find first.", + }, + ], + isError: true, + }; + } + if (matchIndex >= allMatches.length) { + return { + content: [ + { + type: "text" as const, + text: `Error: matchIndex ${matchIndex} out of range (0-${allMatches.length - 1})`, + }, + ], + isError: true, + }; + } + return runCommand( + { type: "search_navigate", matchIndex }, + `Jumped to match ${matchIndex + 1}/${allMatches.length} on page ${allMatches[matchIndex].pageNum}`, + ); + }, + }), + defineAppTool({ + name: "zoom", title: "Zoom", description: "Set the zoom scale for the document", inputSchema: z.object({ @@ -5360,17 +5378,14 @@ app.registerTool( .max(3.0) .describe("Zoom scale, 1.0 = 100% (range: 0.5-3.0)"), }), - }, - async ({ scale }) => - runCommand( - { type: "zoom", scale }, - `Zoom set to ${Math.round(scale * 100)}%`, - ), -); - -app.registerTool( - "add_annotations", - { + handler: async ({ scale }) => + runCommand( + { type: "zoom", scale }, + `Zoom set to ${Math.round(scale * 100)}%`, + ), + }), + defineAppTool({ + name: "add_annotations", title: "Add Annotations", description: "Add one or more annotations (highlight, note, rectangle, circle, line, stamp, image, freetext). Each needs id, type, page, and type-specific geometry.", @@ -5382,20 +5397,17 @@ app.registerTool( "Annotation objects. Each needs: id, type, page, plus type-specific fields (x, y, width, height, rects, color, content, etc.)", ), }), - }, - async ({ annotations }) => - runCommand( - { - type: "add_annotations", - annotations: annotations as PdfAnnotationDef[], - }, - `Added ${annotations.length} annotation(s)`, - ), -); - -app.registerTool( - "update_annotations", - { + handler: async ({ annotations }) => + runCommand( + { + type: "add_annotations", + annotations: annotations as PdfAnnotationDef[], + }, + `Added ${annotations.length} annotation(s)`, + ), + }), + defineAppTool({ + name: "update_annotations", title: "Update Annotations", description: "Patch existing annotations by id. Only id and type are required; other fields are merged.", @@ -5405,39 +5417,33 @@ app.registerTool( .min(1) .describe("Partial annotation objects. Each needs: id, type."), }), - }, - async ({ annotations }) => - runCommand( - { - type: "update_annotations", - annotations: annotations as Extract< - PdfCommand, - { type: "update_annotations" } - >["annotations"], - }, - `Updated ${annotations.length} annotation(s)`, - ), -); - -app.registerTool( - "remove_annotations", - { + handler: async ({ annotations }) => + runCommand( + { + type: "update_annotations", + annotations: annotations as Extract< + PdfCommand, + { type: "update_annotations" } + >["annotations"], + }, + `Updated ${annotations.length} annotation(s)`, + ), + }), + defineAppTool({ + name: "remove_annotations", title: "Remove Annotations", description: "Delete annotations by id", inputSchema: z.object({ ids: z.array(z.string()).min(1).describe("Annotation IDs to remove"), }), - }, - async ({ ids }) => - runCommand( - { type: "remove_annotations", ids }, - `Removed ${ids.length} annotation(s)`, - ), -); - -app.registerTool( - "highlight_text", - { + handler: async ({ ids }) => + runCommand( + { type: "remove_annotations", ids }, + `Removed ${ids.length} annotation(s)`, + ), + }), + defineAppTool({ + name: "highlight_text", title: "Highlight Text", description: "Auto-locate text and add a highlight annotation. Searches the document (or a specific page) and highlights the first match.", @@ -5452,19 +5458,16 @@ app.registerTool( color: z.string().optional().describe("Highlight color (CSS color)"), content: z.string().optional().describe("Tooltip/note content"), }), - }, - async ({ query, page, color, content }) => { - const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - return runCommand( - { type: "highlight_text", id, query, page, color, content }, - `Highlighted "${query}"${page ? ` on page ${page}` : ""} (id: ${id})`, - ); - }, -); - -app.registerTool( - "fill_form", - { + handler: async ({ query, page, color, content }) => { + const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return runCommand( + { type: "highlight_text", id, query, page, color, content }, + `Highlighted "${query}"${page ? ` on page ${page}` : ""} (id: ${id})`, + ); + }, + }), + defineAppTool({ + name: "fill_form", title: "Fill Form", description: "Fill PDF form fields by name", inputSchema: z.object({ @@ -5475,17 +5478,14 @@ app.registerTool( "Form fields: { name, value } where value is string or boolean", ), }), - }, - async ({ fields }) => - runCommand( - { type: "fill_form", fields }, - `Filled ${fields.length} field(s): ${fields.map((f) => f.name).join(", ")}`, - ), -); - -app.registerTool( - "get_text", - { + handler: async ({ fields }) => + runCommand( + { type: "fill_form", fields }, + `Filled ${fields.length} field(s): ${fields.map((f) => f.name).join(", ")}`, + ), + }), + defineAppTool({ + name: "get_text", title: "Get Text", description: "Extract text from one or more pages. Returns one text block per page.", @@ -5503,76 +5503,314 @@ app.registerTool( "Page ranges. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", ), }), - }, - async ({ page, intervals }) => { - if (!pdfDocument) { + handler: async ({ page, intervals }) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const resolved = + intervals ?? (page ? [{ start: page, end: page }] : [{}]); + const data = await collectPageData(resolved, true, false); + const parts = data + .filter((e) => e.text != null) + .map((e) => ({ + type: "text" as const, + text: `--- Page ${e.page} ---\n${e.text}`, + })); return { - content: [{ type: "text" as const, text: "Error: No document loaded" }], - isError: true, + content: + parts.length > 0 + ? parts + : [{ type: "text" as const, text: "No text content returned" }], + structuredContent: { pages: data }, }; - } - const resolved = intervals ?? (page ? [{ start: page, end: page }] : [{}]); - const data = await collectPageData(resolved, true, false); - const parts = data - .filter((e) => e.text != null) - .map((e) => ({ - type: "text" as const, - text: `--- Page ${e.page} ---\n${e.text}`, - })); - return { - content: - parts.length > 0 - ? parts - : [{ type: "text" as const, text: "No text content returned" }], - structuredContent: { pages: data }, - }; - }, -); - -app.registerTool( - "get_screenshot", - { + }, + }), + defineAppTool({ + name: "get_screenshot", title: "Get Screenshot", description: "Render a page to a JPEG image for visual analysis", inputSchema: z.object({ page: z.number().int().min(1).describe("Page number to render"), }), - }, - async ({ page }) => { - if (!pdfDocument) { - return { - content: [{ type: "text" as const, text: "Error: No document loaded" }], - isError: true, - }; - } - const data = await collectPageData( - [{ start: page, end: page }], - false, - true, - ); - const entry = data[0]; - if (entry?.image) { + handler: async ({ page }) => { + if (!pdfDocument) { + return { + content: [ + { type: "text" as const, text: "Error: No document loaded" }, + ], + isError: true, + }; + } + const data = await collectPageData( + [{ start: page, end: page }], + false, + true, + ); + const entry = data[0]; + if (entry?.image) { + return { + content: [ + { + type: "image" as const, + data: entry.image, + mimeType: "image/jpeg", + }, + ], + }; + } return { content: [ { - type: "image" as const, - data: entry.image, - mimeType: "image/jpeg", + type: "text" as const, + text: `Error: screenshot failed for page ${page}`, }, ], + isError: true, }; - } + }, + }), +]; + +/** Run one action of the app-side `interact` tool. */ +async function runAppInteractCommand( + cmd: Record, +): Promise { + const def = APP_TOOL_DEFS.find((d) => d.name === cmd.action); + if (!def) { return { content: [ { type: "text" as const, - text: `Error: screenshot failed for page ${page}`, + text: `Unknown action: ${String(cmd.action)}`, }, ], isError: true, }; - }, -); + } + let args: Record = cmd; + if (def.inputSchema) { + const parsed = def.inputSchema.safeParse(cmd); + if (!parsed.success) { + const issues = parsed.error.issues + .map((i) => `${i.path.join(".") || "(input)"}: ${i.message}`) + .join("; "); + return { + content: [{ type: "text" as const, text: `${def.name}: ${issues}` }], + isError: true, + }; + } + args = parsed.data as Record; + } + return def.handler(args); +} + +/** + * Load-time config injected into the served HTML by the server + * (see --split-app-tools in main.ts). Must be read before connect(), + * because the tool list is declared during the handshake. + */ +const PDF_APP_CONFIG = + (globalThis as { PDF_APP_CONFIG?: { splitAppTools?: boolean } }) + .PDF_APP_CONFIG ?? {}; + +if (PDF_APP_CONFIG.splitAppTools) { + // One narrowly-scoped tool per action. + for (const def of APP_TOOL_DEFS) { + app.registerTool( + def.name, + { + title: def.title, + description: def.description, + ...(def.inputSchema ? { inputSchema: def.inputSchema } : {}), + }, + (args) => def.handler((args ?? {}) as Record), + ); + } +} else { + // Default: a single `interact` app tool, mirroring the server-side + // `interact` — hosts that approve each app tool separately then ask once. + app.registerTool( + "interact", + { + title: "Interact with PDF", + description: `Interact with this PDF viewer: navigate, search, zoom, annotate, extract text/screenshots, fill forms. + +**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially; content[i] is the result of commands[i]. If a command fails, the batch stops there and that command's slot contains text starting with \`ERROR\` — content.length tells you how far it got. + +**ANNOTATION** — add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed). + +**COORDINATE SYSTEM**: PDF points (1pt = 1/72in), origin at page TOP-LEFT corner. X increases rightward, Y increases downward. +- US Letter = 612×792pt. Margins: top≈y=50, bottom≈y=742, left≈x=72, right≈x=540, center≈(306, 396). +- Rectangle/circle/stamp x,y is the TOP-LEFT corner. +- For highlights/underlines, each rect's y is the TOP of the highlighted region. + +Annotation types: +• highlight: rects:[{x,y,width,height}], color?, content? • underline: rects:[{x,y,w,h}], color? +• strikethrough: rects:[{x,y,w,h}], color? • note: x, y, content, color? +• rectangle: x, y, width, height, color?, fillColor?, rotation? • circle: x, y, width, height, color?, fillColor? +• line: x1, y1, x2, y2, color? • freetext: x, y, content, fontSize?, color? +• stamp: x, y, label (any text, e.g. APPROVED, DRAFT, CONFIDENTIAL), color?, rotation? +• image: imageUrl (required), x?, y?, width?, height?, mimeType?, rotation?, aspect? + +TIP: For text annotations, prefer highlight_text (auto-finds text) over manual rects. +• highlight_text: auto-find and highlight text (query, page?, color?, content?) +• update_annotations: partial update (id+type required) • remove_annotations: remove by ids + +**NAVIGATION**: navigate (page), search (query), find (query, silent), search_navigate (matchIndex), zoom (scale 0.5–3.0) + +**TEXT/SCREENSHOTS/INFO**: +• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages. +• get_screenshot: capture a single page as a JPEG image. Requires \`page\`. +• get_document_info: title, current page, total pages, zoom, display mode. + +**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`, + inputSchema: z.object({ + action: z + .enum([ + "get_document_info", + "navigate", + "search", + "find", + "search_navigate", + "zoom", + "add_annotations", + "update_annotations", + "remove_annotations", + "highlight_text", + "fill_form", + "get_text", + "get_screenshot", + ]) + .optional() + .describe( + "Action to perform (for a single command). Use \`commands\` for batching.", + ), + page: z + .number() + .min(1) + .optional() + .describe( + "Page number (for navigate, highlight_text, get_screenshot, get_text)", + ), + query: z + .string() + .optional() + .describe("Search text (for search / find / highlight_text)"), + matchIndex: z + .number() + .min(0) + .optional() + .describe("Match index (for search_navigate)"), + scale: z + .number() + .min(0.5) + .max(3.0) + .optional() + .describe("Zoom scale, 1.0 = 100% (for zoom)"), + annotations: z + .array(z.record(z.string(), z.any())) + .optional() + .describe( + "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.", + ), + ids: z + .array(z.string()) + .optional() + .describe("Annotation IDs (for remove_annotations)"), + color: z + .string() + .optional() + .describe("Color override (for highlight_text)"), + content: z + .string() + .optional() + .describe("Tooltip/note content (for highlight_text)"), + fields: z + .array(FormFieldSchema) + .optional() + .describe( + "Form fields to fill (for fill_form): { name, value } where value is string or boolean", + ), + intervals: z + .array(PageIntervalSchema) + .optional() + .describe( + "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", + ), + commands: z + .array(z.record(z.string(), z.any())) + .optional() + .describe( + "Array of {action, ...params} commands to execute sequentially. More efficient than separate calls.", + ), + }), + }, + async ({ action, commands, ...rest }) => { + if (commands && commands.length > 0) { + // Mirror the server-side interact's batch contract: content[i] is + // the result of commands[i], a failed step's slot is text starting + // with "ERROR", and isError is only set for single-command calls — + // some host SDKs collapse isError results to content[0].text, + // which would drop images from earlier successful steps. + const content: CallToolResult["content"] = []; + let failed = false; + for (let i = 0; i < commands.length; i++) { + const res = await runAppInteractCommand(commands[i]); + if (res.isError) { + const errText = res.content + .map((c) => (c.type === "text" ? c.text : null)) + .filter((t) => t != null) + .join(" — ") + .replace(/^error:\s*/i, ""); + content.push({ + type: "text" as const, + text: + commands.length > 1 + ? `ERROR at step ${i + 1}/${commands.length} (${String(commands[i].action)}): ${errText}` + : `ERROR: ${errText}`, + }); + failed = true; + break; + } + // Squash multi-part successes (e.g. get_text returning multiple + // text blocks) into one slot so 1:1 indexing holds. Preserve a + // lone image as-is — that's the screenshot the model wants. + if (res.content.length === 1) { + content.push(res.content[0]); + } else { + const texts = res.content + .map((c) => (c.type === "text" ? c.text : null)) + .filter((t) => t != null); + content.push({ type: "text" as const, text: texts.join("\n") }); + } + } + return { + content, + ...(failed && commands.length === 1 ? { isError: true } : {}), + }; + } + if (!action) { + return { + content: [ + { + type: "text" as const, + text: "Error: provide `action` (or a `commands` array)", + }, + ], + isError: true, + }; + } + // Single-action calls return the handler's result verbatim — + // structuredContent, image content and isError included. + return runAppInteractCommand({ action, ...rest }); + }, + ); +} // Connect to host app diff --git a/tests/e2e/pdf-app-helpers.ts b/tests/e2e/pdf-app-helpers.ts new file mode 100644 index 000000000..2a095a246 --- /dev/null +++ b/tests/e2e/pdf-app-helpers.ts @@ -0,0 +1,147 @@ +/** + * Shared helpers for driving the pdf-server MCP App from Playwright. + * + * 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 specs + * play the host's role directly: they post the JSON-RPC request from the + * sandbox frame (the app iframe's parent) and read the response there. + */ + +import { test, expect, type Page, type Frame } from "@playwright/test"; + +/** Wait for the MCP App to load inside nested iframes. */ +export 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) */ +export function getAppFrame(page: Page) { + return page.frameLocator("iframe").first().frameLocator("iframe").first(); +} + +/** Load the PDF server and call display_pdf with the default PDF. */ +export async function loadPdfServer(page: Page) { + // The reload loop below can legitimately take longer than the default + // test timeout right after the examples server (re)builds. + test.setTimeout(240_000); + 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. */ +export 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). */ +export 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()!; +} + +/** Post one JSON-RPC message into the app iframe from the sandbox frame. */ +export function postToApp( + sandboxFrame: Frame, + message: unknown, +): Promise { + return sandboxFrame.evaluate((msg) => { + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + iframe.contentWindow!.postMessage(msg, "*"); + }, message); +} + +/** + * Send one JSON-RPC request into the app iframe from the sandbox frame and + * await the matching response. + */ +export 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 }, + ); +} + +export type AppToolCallResult = { + content: Array<{ type: string; text?: string }>; + structuredContent?: Record; + isError?: boolean; +}; + +type ToolCallResponse = { + result?: AppToolCallResult; + error?: { message: string }; +}; + +/** + * Call the view's `interact` app tool (the default app-tool surface) and + * return the CallToolResult. + */ +export async function callAppInteract( + page: Page, + args: Record, + id: number, +): Promise { + const sandboxFrame = getSandboxFrame(page); + const resp = (await sendJsonRpcToApp(sandboxFrame, { + id, + method: "tools/call", + params: { name: "interact", arguments: args }, + })) as ToolCallResponse; + expect(resp.error).toBeUndefined(); + expect(resp.result).toBeDefined(); + return resp.result!; +} diff --git a/tests/e2e/pdf-app-interact.spec.ts b/tests/e2e/pdf-app-interact.spec.ts new file mode 100644 index 000000000..48c01b912 --- /dev/null +++ b/tests/e2e/pdf-app-interact.spec.ts @@ -0,0 +1,132 @@ +/** + * PDF Server — single `interact` app tool (default app-tool surface). + * + * By default the view exposes one `interact` app tool (action + flat + * params, optional `commands` batch) instead of one tool per action — on + * hosts that ask the user to approve each app tool separately that is one + * approval instead of thirteen. The per-action tools stay available behind + * the server's --split-app-tools flag (not exercised here: the e2e harness + * starts the server without it). + */ + +import { test, expect } from "@playwright/test"; +import { + loadPdfServer, + waitForPdfCanvas, + getSandboxFrame, + sendJsonRpcToApp, + callAppInteract, +} from "./pdf-app-helpers"; + +test.describe("PDF app-side interact tool", () => { + test("the view lists a single interact tool, not per-action tools", async ({ + page, + }) => { + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const sandboxFrame = getSandboxFrame(page); + const resp = (await sendJsonRpcToApp(sandboxFrame, { + id: 9001, + method: "tools/list", + params: {}, + })) as { result?: { tools: Array<{ name: string }> } }; + + const names = (resp.result?.tools ?? []).map((t) => t.name); + expect(names).toContain("interact"); + expect(names).not.toContain("navigate"); + expect(names).not.toContain("add_annotations"); + }); + + test("interact dispatches a single action and reflects it in state", async ({ + page, + }) => { + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const nav = await callAppInteract( + page, + { action: "navigate", page: 2 }, + 9002, + ); + expect(nav.isError).toBeFalsy(); + expect(nav.content[0]?.text).toContain("Navigated to page 2"); + + const info = await callAppInteract( + page, + { action: "get_document_info" }, + 9003, + ); + expect(info.isError).toBeFalsy(); + expect(info.structuredContent?.currentPage).toBe(2); + }); + + test("interact validates per-action required params", async ({ page }) => { + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const res = await callAppInteract(page, { action: "navigate" }, 9004); + expect(res.isError).toBe(true); + expect(res.content[0]?.text).toContain("page"); + + // An unknown top-level `action` is already rejected by the tool's own + // schema (the SDK answers with a JSON-RPC error before the handler + // runs), so the dispatcher's fallback is only reachable through the + // untyped `commands` batch. + const unknown = await callAppInteract( + page, + { commands: [{ action: "warp" }] }, + 9005, + ); + expect(unknown.isError).toBe(true); + expect(unknown.content[0]?.text).toContain("Unknown action"); + }); + + test("interact runs a commands batch sequentially with 1:1 slots", async ({ + page, + }) => { + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const res = await callAppInteract( + page, + { + commands: [ + { action: "zoom", scale: 1.5 }, + { action: "get_document_info" }, + ], + }, + 9006, + ); + expect(res.isError).toBeFalsy(); + expect(res.content).toHaveLength(2); + expect(res.content[0]?.text).toContain("Zoom set to 150%"); + expect(res.content[1]?.text).toContain('"scale": 1.5'); + }); + + test("a mid-batch failure fills its slot with ERROR and stops, without isError", async ({ + page, + }) => { + await loadPdfServer(page); + await waitForPdfCanvas(page); + + const res = await callAppInteract( + page, + { + commands: [ + { action: "zoom", scale: 1.5 }, + { action: "navigate", page: 999 }, + { action: "get_document_info" }, + ], + }, + 9007, + ); + // Mirrors the server-side interact: no isError on a multi-step batch + // (some host SDKs collapse isError results to content[0].text), the + // failing step's slot starts with ERROR, and the batch stops there. + expect(res.isError).toBeFalsy(); + expect(res.content).toHaveLength(2); + expect(res.content[0]?.text).toContain("Zoom set to 150%"); + expect(res.content[1]?.text).toMatch(/^ERROR at step 2\/3 \(navigate\)/); + }); +}); diff --git a/tests/e2e/pdf-page-restore.spec.ts b/tests/e2e/pdf-page-restore.spec.ts index b8f76dfbe..111c29c24 100644 --- a/tests/e2e/pdf-page-restore.spec.ts +++ b/tests/e2e/pdf-page-restore.spec.ts @@ -16,55 +16,15 @@ * 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()!; -} +import { test, expect, type Page } from "@playwright/test"; +import { + loadPdfServer, + waitForPdfCanvas, + getAppFrame, + getSandboxFrame, + postToApp, + callAppInteract, +} from "./pdf-app-helpers"; /** Read the display_pdf CallToolResult JSON from the host's result panel. */ async function readDisplayPdfResult( @@ -79,57 +39,6 @@ async function readDisplayPdfResult( 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, @@ -149,12 +58,14 @@ test.describe("PDF Server - saved page restore", () => { 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(); + // Navigate to page 3 via the view's own interact tool — goToPage + // persists the page to localStorage under the viewUUID key. + const nav = await callAppInteract( + page, + { action: "navigate", page: 3 }, + 9301, + ); + expect(nav.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 @@ -183,10 +94,12 @@ test.describe("PDF Server - saved page restore", () => { 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(); + const nav = await callAppInteract( + page, + { action: "navigate", page: 4 }, + 9302, + ); + expect(nav.isError).toBeFalsy(); await expect(appFrame.locator("#page-input")).toHaveValue("4"); // Some hosts drop _meta from replayed tool results; the server also