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:
parent
38700569e7
commit
4b3f6252a4
5 changed files with 557 additions and 19 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -65,4 +65,5 @@ export type PdfCommand =
|
|||
getText: boolean;
|
||||
getScreenshots: boolean;
|
||||
}
|
||||
| { type: "save_as"; requestId: string }
|
||||
| { type: "file_changed"; mtimeMs: number };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue