diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index cd265f868..2bee370a1 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -188,13 +188,13 @@ 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 | -| `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 to file | +| `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). @@ -250,6 +250,16 @@ After the model calls `display_pdf`, it receives the `viewUUID` and a descriptio > > _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_ +### Saving + +> **User:** Save the annotated version as `/docs/contract-signed.pdf`. +> +> _Model calls `interact` with action `save_as`, path `/docs/contract-signed.pdf`. Fails if the file exists; pass `overwrite: true` to replace. The target path must be under a mounted directory root._ + +> **User:** Save my changes back to the file. +> +> _Model calls `interact` with action `save_as`, `overwrite: true` (no `path`). Overwrites the original — same writability gate as the viewer's save button. Only works for local files; remote PDFs need an explicit `path`._ + ## Testing ### E2E Tests (Playwright) diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts index 52075069d..8080df8b4 100644 --- a/examples/pdf-server/server.test.ts +++ b/examples/pdf-server/server.test.ts @@ -17,6 +17,7 @@ import { cliLocalFiles, isWritablePath, writeFlags, + viewSourcePaths, CACHE_INACTIVITY_TIMEOUT_MS, CACHE_MAX_LIFETIME_MS, CACHE_MAX_PDF_SIZE_BYTES, @@ -1055,6 +1056,294 @@ describe("interact tool", () => { }); }); + describe("save_as", () => { + // Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting + // (it blocks until the view replies), poll → submit → await. The poll() + // call also registers the uuid in viewsPolled, satisfying + // ensureViewerIsPolling — without it interact would hang ~8s and fail. + + let tmpDir: string; + let savedDirs: Set; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-")); + savedDirs = new Set(allowedLocalDirs); + allowedLocalDirs.add(tmpDir); // make tmpDir a directory root → writable + }); + + afterEach(() => { + allowedLocalDirs.clear(); + for (const x of savedDirs) allowedLocalDirs.add(x); + viewSourcePaths.clear(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("no path, no source tracked → tells model to provide a path", async () => { + // Fresh UUID never seen by display_pdf → viewSourcePaths has no entry. + // Same condition as a remote (https://) PDF or a stale viewUUID. + const { server, client } = await connect(); + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "saveas-nosource", action: "save_as" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("no local source file"); + expect(firstText(r)).toContain("Provide an explicit `path`"); + await client.close(); + await server.close(); + }); + + it("no path, source tracked, overwrite omitted → asks for confirmation", async () => { + const { server, client } = await connect(); + const source = path.join(tmpDir, "original.pdf"); + fs.writeFileSync(source, "%PDF-1.4\noriginal"); + viewSourcePaths.set("saveas-noconfirm", source); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "saveas-noconfirm", action: "save_as" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("overwrites the original"); + expect(firstText(r)).toContain(source); + expect(firstText(r)).toContain("overwrite: true"); + // Nothing enqueued, file untouched + expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\noriginal"); + await client.close(); + await server.close(); + }); + + it("no path, source not writable → same gate as save button", async () => { + const { server, client } = await connect(); + // Source outside any directory root → isWritablePath false → save button + // would be hidden in the viewer. save_as should refuse for the same reason. + const outside = path.join(os.tmpdir(), "saveas-outside.pdf"); + fs.writeFileSync(outside, "x"); + viewSourcePaths.set("saveas-buttongate", outside); + + try { + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "saveas-buttongate", + action: "save_as", + overwrite: true, + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("not writable"); + expect(firstText(r)).toContain("save button is hidden"); + } finally { + fs.rmSync(outside, { force: true }); + } + await client.close(); + await server.close(); + }); + + it("no path, overwrite: true → roundtrip overwrites the original", async () => { + const { server, client } = await connect(); + const uuid = "saveas-original"; + const source = path.join(tmpDir, "report.pdf"); + fs.writeFileSync(source, "%PDF-1.4\noriginal contents"); + viewSourcePaths.set(uuid, source); + + const interactPromise = client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "save_as", overwrite: true }, + }); + + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("save_as"); + await client.callTool({ + name: "submit_save_data", + arguments: { + requestId: cmds[0].requestId as string, + data: Buffer.from("%PDF-1.4\nannotated").toString("base64"), + }, + }); + + const r = await interactPromise; + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain(source); + expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\nannotated"); + + await client.close(); + await server.close(); + }); + + it("rejects non-absolute path", async () => { + const { server, client } = await connect(); + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "saveas-rel", + action: "save_as", + path: "relative.pdf", + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("absolute"); + await client.close(); + await server.close(); + }); + + it("rejects non-writable path", async () => { + const { server, client } = await connect(); + // Path outside any directory root → not writable. Validation is sync, + // so nothing is enqueued and the queue stays empty. + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "saveas-nowrite", + action: "save_as", + path: "/somewhere/else/out.pdf", + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("not under a mounted directory root"); + await client.close(); + await server.close(); + }); + + it("rejects existing file when overwrite is false (default)", async () => { + const { server, client } = await connect(); + const target = path.join(tmpDir, "exists.pdf"); + fs.writeFileSync(target, "old contents"); + + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "saveas-exists", + action: "save_as", + path: target, + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("already exists"); + expect(firstText(r)).toContain("overwrite: true"); + // Existence check is sync — nothing enqueued, file untouched. + expect(fs.readFileSync(target, "utf8")).toBe("old contents"); + await client.close(); + await server.close(); + }); + + it("full roundtrip: enqueue → poll → submit → file written", async () => { + const { server, client } = await connect(); + const uuid = "saveas-roundtrip"; + const target = path.join(tmpDir, "out.pdf"); + const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF"; + + // interact blocks in waitForSaveData until submit_save_data resolves it + const interactPromise = client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "save_as", path: target }, + }); + + // Viewer polls → receives the save_as command with a requestId + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("save_as"); + const requestId = cmds[0].requestId as string; + expect(typeof requestId).toBe("string"); + + // Viewer submits bytes + const submit = await client.callTool({ + name: "submit_save_data", + arguments: { + requestId, + data: Buffer.from(pdfBytes).toString("base64"), + }, + }); + expect(submit.isError).toBeFalsy(); + + // interact now unblocks with success + const r = await interactPromise; + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain("Saved"); + expect(firstText(r)).toContain(target); + expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes); + + await client.close(); + await server.close(); + }); + + it("overwrite: true replaces an existing file", async () => { + const { server, client } = await connect(); + const uuid = "saveas-overwrite"; + const target = path.join(tmpDir, "replace.pdf"); + fs.writeFileSync(target, "old contents"); + + const interactPromise = client.callTool({ + name: "interact", + arguments: { + viewUUID: uuid, + action: "save_as", + path: target, + overwrite: true, + }, + }); + + const cmds = await poll(client, uuid); + const requestId = cmds[0].requestId as string; + await client.callTool({ + name: "submit_save_data", + arguments: { + requestId, + data: Buffer.from("%PDF-1.4\nnew").toString("base64"), + }, + }); + + const r = await interactPromise; + expect(r.isError).toBeFalsy(); + expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew"); + + await client.close(); + await server.close(); + }); + + it("propagates viewer-reported errors to the model", async () => { + const { server, client } = await connect(); + const uuid = "saveas-viewerr"; + const target = path.join(tmpDir, "wontwrite.pdf"); + + const interactPromise = client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "save_as", path: target }, + }); + + const cmds = await poll(client, uuid); + // Viewer hit an error building bytes → reports it instead of timing out + await client.callTool({ + name: "submit_save_data", + arguments: { + requestId: cmds[0].requestId as string, + error: "pdf-lib choked on a comb field", + }, + }); + + const r = await interactPromise; + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("pdf-lib choked on a comb field"); + expect(fs.existsSync(target)).toBe(false); + + await client.close(); + await server.close(); + }); + + it("submit_save_data with unknown requestId returns isError", async () => { + const { server, client } = await connect(); + const r = await client.callTool({ + name: "submit_save_data", + 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 b216d49c6..5bd79a421 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -243,9 +243,9 @@ function waitForPageData( /** * Wait for the viewer's first poll_pdf_commands call. * - * Called before waitForPageData() so a viewer that never mounted fails in ~8s - * with a specific message instead of a generic 45s "Timeout waiting for page - * data" that gives no hint why. + * Called before waitForPageData() / waitForSaveData() so a viewer that never + * mounted fails in ~8s with a specific message instead of a generic 45s + * "Timeout waiting for ..." that gives no hint why. * * Intentionally does NOT touch pollWaiters: piggybacking on that single-slot * Map races with poll_pdf_commands' batch-wait branch (which never cancels the @@ -267,6 +267,41 @@ async function ensureViewerIsPolling(uuid: string): Promise { } } +// ============================================================================= +// Pending save_as Requests (request-response bridge via client) +// ============================================================================= +// +// Same shape as get_pages: model's interact call blocks while the viewer +// builds annotated bytes and posts them back. Reuses GET_PAGES_TIMEOUT_MS +// (45s) — generous because pdf-lib reflow on a large doc can take seconds. + +const pendingSaveRequests = new Map void>(); + +/** + * Wait for the viewer to build annotated PDF bytes and submit them as base64. + * Rejects on timeout, abort, or when the viewer reports an error. + */ +function waitForSaveData( + requestId: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const settle = (v: string | Error) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + pendingSaveRequests.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); + pendingSaveRequests.set(requestId, settle); + }); +} + interface QueueEntry { commands: PdfCommand[]; /** Timestamp of the most recent enqueue or dequeue */ @@ -287,6 +322,15 @@ const pollWaiters = new Map void>(); */ const viewsPolled = new Set(); +/** + * Resolved local file path per viewer UUID, for save_as without an explicit + * target. Only set for local files (remote PDFs have nothing to overwrite). + * Populated during display_pdf, cleared by the heartbeat sweep. + * + * Exported for tests. + */ +export const viewSourcePaths = new Map(); + /** Valid form field names per viewer UUID (populated during display_pdf) */ const viewFieldNames = new Map>(); @@ -333,6 +377,7 @@ function pruneStaleQueues(): void { viewFieldNames.delete(uuid); viewFieldInfo.delete(uuid); viewsPolled.delete(uuid); + viewSourcePaths.delete(uuid); stopFileWatch(uuid); } } @@ -1209,7 +1254,8 @@ export function createServer(options: CreateServerOptions = {}): McpServer { "read_pdf_bytes", { title: "Read PDF Bytes", - description: "Read a range of bytes from a PDF (max 512KB per request)", + description: + "Read a range of bytes from a PDF (max 512KB per request). The model should NOT call this tool directly.", inputSchema: { url: z.string().describe("PDF URL or local file path"), offset: z.number().min(0).default(0).describe("Byte offset"), @@ -1392,6 +1438,7 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before : decodeURIComponent(normalized); const resolved = path.resolve(localPath); debugResolved = resolved; + if (!disableInteract) viewSourcePaths.set(uuid, resolved); if (isWritablePath(resolved)) { try { await fs.promises.access(resolved, fs.constants.W_OK); @@ -1475,7 +1522,7 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before ? `Displaying PDF: ${normalized}` : `PDF opened. viewUUID: ${uuid} -→ To annotate, sign, stamp, fill forms, navigate, or extract: call \`interact\` with this viewUUID. +→ To annotate, sign, stamp, fill forms, navigate, extract, or save to a file: call \`interact\` with this viewUUID. → DO NOT call display_pdf again — that spawns a separate viewer with a different viewUUID; your interact calls would target the new empty one, not the one the user is looking at. URL: ${normalized}`, @@ -1600,6 +1647,7 @@ URL: ${normalized}`, "fill_form", "get_text", "get_screenshot", + "save_as", ]) .describe("Action to perform"), page: z @@ -1654,6 +1702,16 @@ URL: ${normalized}`, .describe( "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", ), + path: z + .string() + .optional() + .describe( + "Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).", + ), + overwrite: z + .boolean() + .optional() + .describe("Overwrite if file exists (for save_as). Default false."), }); type InteractCommand = z.infer; @@ -1793,6 +1851,8 @@ URL: ${normalized}`, content, fields, intervals, + path: savePath, + overwrite, } = cmd; let description: string; @@ -2084,6 +2144,97 @@ URL: ${normalized}`, isError: true, }; } + case "save_as": { + const saveErr = (text: string) => ({ + content: [{ type: "text" as const, text }], + isError: true as const, + }); + + let resolved: string; + if (savePath) { + // Explicit target. Same path normalisation as save_pdf — but NOT + // validateUrl(), which fails on non-existent files. + const filePath = isFileUrl(savePath) + ? fileUrlToPath(savePath) + : isLocalPath(savePath) + ? decodeURIComponent(savePath) + : null; + if (!filePath) + return saveErr( + "save_as: path must be an absolute local path or file:// URL", + ); + resolved = path.resolve(filePath); + if (!isWritablePath(resolved)) + return saveErr( + `save_as refused: ${resolved} is not under a mounted ` + + `directory root. Only paths under directory roots ` + + `(or files passed as CLI args) are writable.`, + ); + if (!overwrite && fs.existsSync(resolved)) + return saveErr( + `File already exists: ${resolved}. ` + + `Set overwrite: true to replace it, or choose a different path.`, + ); + } else { + // No target → overwrite the original. Same gate as the viewer's + // save button: isWritablePath + OS-level W_OK (so we don't try + // on read-only mounts). Remote PDFs have no source path stored. + const source = viewSourcePaths.get(uuid); + if (!source) + return saveErr( + "save_as: no `path` given and this viewer has no local source " + + "file to overwrite (it's a remote URL, or the viewUUID is " + + "stale/unknown). Provide an explicit `path`.", + ); + if (!overwrite) + return saveErr( + `save_as: omitting \`path\` overwrites the original ` + + `(${source}). Set overwrite: true to confirm.`, + ); + if (!isWritablePath(source)) + return saveErr( + `save_as refused: ${source} is not writable (the viewer's ` + + `save button is hidden for the same reason).`, + ); + try { + await fs.promises.access(source, fs.constants.W_OK); + } catch { + return saveErr( + `save_as refused: ${source} is not writable at the OS level ` + + `(read-only mount or insufficient permissions).`, + ); + } + resolved = source; + } + + const requestId = randomUUID(); + enqueueCommand(uuid, { type: "save_as", requestId }); + let data: string; + try { + await ensureViewerIsPolling(uuid); + data = await waitForSaveData(requestId, signal); + } catch (err) { + return saveErr( + `save_as failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { + const bytes = Buffer.from(data, "base64"); + await fs.promises.writeFile(resolved, bytes); + return { + content: [ + { + type: "text", + text: `Saved annotated PDF to ${resolved} (${bytes.length} bytes)`, + }, + ], + }; + } catch (err) { + return saveErr( + `save_as: failed to write ${resolved}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } default: return { content: [{ type: "text", text: `Unknown action: ${action}` }], @@ -2142,7 +2293,9 @@ Example — add a signature image and a stamp, then screenshot to verify: • 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 PNG image. Requires \`page\`. -**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`, +**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\`).`, inputSchema: { viewUUID: z .string() @@ -2164,6 +2317,7 @@ Example — add a signature image and a stamp, then screenshot to verify: "fill_form", "get_text", "get_screenshot", + "save_as", ]) .optional() .describe( @@ -2221,6 +2375,16 @@ Example — add a signature image and a stamp, then screenshot to verify: .describe( "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", ), + path: z + .string() + .optional() + .describe( + "Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).", + ), + overwrite: z + .boolean() + .optional() + .describe("Overwrite if file exists (for save_as). Default false."), // Batch mode commands: z .array(InteractCommandSchema) @@ -2244,6 +2408,8 @@ Example — add a signature image and a stamp, then screenshot to verify: content, fields, intervals, + path: savePath, + overwrite, commands, }, extra, @@ -2265,6 +2431,8 @@ Example — add a signature image and a stamp, then screenshot to verify: content, fields, intervals, + path: savePath, + overwrite, }, ] : []; @@ -2324,7 +2492,7 @@ Example — add a signature image and a stamp, then screenshot to verify: { title: "Submit Page Data", description: - "Submit rendered page data for a get_pages request (used by viewer)", + "Submit rendered page data for a get_pages request (used by viewer). The model should NOT call this tool directly.", inputSchema: { requestId: z .string() @@ -2360,13 +2528,53 @@ Example — add a signature image and a stamp, then screenshot to verify: }, ); + // Tool: submit_save_data (app-only) - Viewer submits annotated PDF bytes + registerAppTool( + server, + "submit_save_data", + { + title: "Submit Save Data", + description: + "Submit annotated PDF bytes for a save_as request (used by viewer). The model should NOT call this tool directly.", + inputSchema: { + requestId: z + .string() + .describe("The request ID from the save_as command"), + data: z.string().optional().describe("Base64-encoded PDF bytes"), + error: z + .string() + .optional() + .describe("Error message if the viewer failed to build bytes"), + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ requestId, data, error }): Promise => { + const settle = pendingSaveRequests.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); + } + return { content: [{ type: "text", text: "Submitted" }] }; + }, + ); + // Tool: poll_pdf_commands (app-only) - Poll for pending commands registerAppTool( server, "poll_pdf_commands", { title: "Poll PDF Commands", - description: "Poll for pending commands for a PDF viewer", + description: + "Poll for pending commands for a PDF viewer. The model should NOT call this tool directly.", inputSchema: { viewUUID: z.string().describe("The viewUUID of the PDF viewer"), }, @@ -2412,7 +2620,8 @@ Example — add a signature image and a stamp, then screenshot to verify: "save_pdf", { title: "Save PDF", - description: "Save annotated PDF bytes back to a local file", + description: + "Save annotated PDF bytes back to a local file. The model should NOT call this tool directly — use interact with action: save_as instead.", inputSchema: { url: z.string().describe("Original PDF URL or local file path"), data: z.string().describe("Base64-encoded PDF bytes"), diff --git a/examples/pdf-server/src/commands.ts b/examples/pdf-server/src/commands.ts index fa657ca80..c469ebd6c 100644 --- a/examples/pdf-server/src/commands.ts +++ b/examples/pdf-server/src/commands.ts @@ -65,4 +65,5 @@ export type PdfCommand = getText: boolean; getScreenshots: boolean; } + | { type: "save_as"; 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 b20d74b12..0a0eb4cc8 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -4298,6 +4298,30 @@ async function processCommands(commands: PdfCommand[]): Promise { .catch(() => {}); } break; + case "save_as": + // Same await-before-next-poll discipline as get_pages — submit must + // be SENT before we re-poll, or it queues behind the 30s long-poll. + try { + const pdfBytes = await getAnnotatedPdfBytes(); + const base64 = uint8ArrayToBase64(pdfBytes); + await app.callServerTool({ + name: "submit_save_data", + arguments: { requestId: cmd.requestId, data: base64 }, + }); + log.info(`save_as: submitted ${pdfBytes.length} bytes`); + } catch (err) { + log.error("save_as: failed to build bytes — submitting error:", err); + await app + .callServerTool({ + name: "submit_save_data", + arguments: { + requestId: cmd.requestId, + error: err instanceof Error ? err.message : String(err), + }, + }) + .catch(() => {}); + } + break; case "file_changed": { // Skip our own save_pdf echo: either save is still in flight, or the // event's mtime matches what save_pdf just returned. @@ -4337,10 +4361,15 @@ async function processCommands(commands: PdfCommand[]): Promise { } // Persist after processing batch — but only if anything mutated. - // get_pages / file_changed are read-only; writing localStorage and - // recomputing the diff for them is wasted work. + // get_pages / save_as / 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 !== "file_changed") + commands.some( + (c) => + c.type !== "get_pages" && + c.type !== "save_as" && + c.type !== "file_changed", + ) ) { persistAnnotations(); }