pdf-server: add save_as interact action (#580)

* pdf-server: add save_as interact action

Adds a model-callable save_as action to the interact tool that writes the
viewer's current annotated state (annotations + form values) to a new file.

Reuses the get_pages request/reply pattern: model's interact call blocks
while the viewer builds bytes via getAnnotatedPdfBytes() and posts them
back through a new submit_save_data app-only tool.

Validation runs synchronously before bothering the viewer:
- path must be absolute or file://
- isWritablePath gate (same as save_pdf)
- existence check; fails with a descriptive error unless overwrite: true

The viewer can report build errors back via submit_save_data({error}),
so the model gets a real message instead of the 45s timeout.

* pdf-server: warn model off app-only tools in descriptions

Hosts that don't filter visibility:["app"] tools from the model's tool
list will still surface these. The save_pdf one in particular is a
near-miss for the new save_as interact action — point the model at the
right one.

* pdf-server: save_as without path overwrites original

When path is omitted, save_as falls back to the displayed PDF's local
path (tracked per-viewUUID by display_pdf). Same writability gate as
the viewer's save button: isWritablePath + OS-level W_OK probe, so it
refuses on read-only mounts before the viewer roundtrip.

Requires explicit overwrite: true since the original always exists —
the model can't accidentally clobber it.

Remote PDFs have no source path stored; the error tells the model to
pass an explicit path.
This commit is contained in:
Olivier Chafik 2026-04-01 22:45:00 +01:00 • committed by GitHub
commit 4b3f6252a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 557 additions and 19 deletions

View file

@ -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)

View file

@ -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<string>;
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

View file

@ -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<void> {
}
}
// =============================================================================
// 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<string, (v: string | Error) => 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<string> {
return new Promise<string>((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<string, () => void>();
*/
const viewsPolled = new Set<string>();
/**
* 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<string, string>();
/** Valid form field names per viewer UUID (populated during display_pdf) */
const viewFieldNames = new Map<string, Set<string>>();
@ -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<typeof InteractCommandSchema>;
@ -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<CallToolResult> => {
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"),

View file

@ -65,4 +65,5 @@ export type PdfCommand =
getText: boolean;
getScreenshots: boolean;
}
| { type: "save_as"; requestId: string }
| { type: "file_changed"; mtimeMs: number };

View file

@ -4298,6 +4298,30 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
.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<void> {
}
// 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();
}