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

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"),