Skip to content

Commit 4b3f625

Browse files
authored
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.
1 parent 3870056 commit 4b3f625

5 files changed

Lines changed: 557 additions & 19 deletions

File tree

examples/pdf-server/README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,13 @@ When roots are ignored the server logs:
188188

189189
## Tools
190190

191-
| Tool | Visibility | Purpose |
192-
| ---------------- | ---------- | ----------------------------------------------------- |
193-
| `list_pdfs` | Model | List available local files and origins |
194-
| `display_pdf` | Model + UI | Display interactive viewer |
195-
| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms |
196-
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
197-
| `save_pdf` | App only | Save annotated PDF back to local file |
191+
| Tool | Visibility | Purpose |
192+
| ---------------- | ---------- | ------------------------------------------------------------------- |
193+
| `list_pdfs` | Model | List available local files and origins |
194+
| `display_pdf` | Model + UI | Display interactive viewer |
195+
| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms, save to file |
196+
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
197+
| `save_pdf` | App only | Save annotated PDF back to local file |
198198

199199
¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment).
200200

@@ -250,6 +250,16 @@ After the model calls `display_pdf`, it receives the `viewUUID` and a descriptio
250250
>
251251
> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_
252252
253+
### Saving
254+
255+
> **User:** Save the annotated version as `/docs/contract-signed.pdf`.
256+
>
257+
> _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._
258+
259+
> **User:** Save my changes back to the file.
260+
>
261+
> _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`._
262+
253263
## Testing
254264

255265
### E2E Tests (Playwright)

examples/pdf-server/server.test.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
cliLocalFiles,
1818
isWritablePath,
1919
writeFlags,
20+
viewSourcePaths,
2021
CACHE_INACTIVITY_TIMEOUT_MS,
2122
CACHE_MAX_LIFETIME_MS,
2223
CACHE_MAX_PDF_SIZE_BYTES,
@@ -1055,6 +1056,294 @@ describe("interact tool", () => {
10551056
});
10561057
});
10571058

1059+
describe("save_as", () => {
1060+
// Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting
1061+
// (it blocks until the view replies), poll → submit → await. The poll()
1062+
// call also registers the uuid in viewsPolled, satisfying
1063+
// ensureViewerIsPolling — without it interact would hang ~8s and fail.
1064+
1065+
let tmpDir: string;
1066+
let savedDirs: Set<string>;
1067+
1068+
beforeEach(() => {
1069+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-"));
1070+
savedDirs = new Set(allowedLocalDirs);
1071+
allowedLocalDirs.add(tmpDir); // make tmpDir a directory root → writable
1072+
});
1073+
1074+
afterEach(() => {
1075+
allowedLocalDirs.clear();
1076+
for (const x of savedDirs) allowedLocalDirs.add(x);
1077+
viewSourcePaths.clear();
1078+
fs.rmSync(tmpDir, { recursive: true, force: true });
1079+
});
1080+
1081+
it("no path, no source tracked → tells model to provide a path", async () => {
1082+
// Fresh UUID never seen by display_pdf → viewSourcePaths has no entry.
1083+
// Same condition as a remote (https://) PDF or a stale viewUUID.
1084+
const { server, client } = await connect();
1085+
const r = await client.callTool({
1086+
name: "interact",
1087+
arguments: { viewUUID: "saveas-nosource", action: "save_as" },
1088+
});
1089+
expect(r.isError).toBe(true);
1090+
expect(firstText(r)).toContain("no local source file");
1091+
expect(firstText(r)).toContain("Provide an explicit `path`");
1092+
await client.close();
1093+
await server.close();
1094+
});
1095+
1096+
it("no path, source tracked, overwrite omitted → asks for confirmation", async () => {
1097+
const { server, client } = await connect();
1098+
const source = path.join(tmpDir, "original.pdf");
1099+
fs.writeFileSync(source, "%PDF-1.4\noriginal");
1100+
viewSourcePaths.set("saveas-noconfirm", source);
1101+
1102+
const r = await client.callTool({
1103+
name: "interact",
1104+
arguments: { viewUUID: "saveas-noconfirm", action: "save_as" },
1105+
});
1106+
expect(r.isError).toBe(true);
1107+
expect(firstText(r)).toContain("overwrites the original");
1108+
expect(firstText(r)).toContain(source);
1109+
expect(firstText(r)).toContain("overwrite: true");
1110+
// Nothing enqueued, file untouched
1111+
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\noriginal");
1112+
await client.close();
1113+
await server.close();
1114+
});
1115+
1116+
it("no path, source not writable → same gate as save button", async () => {
1117+
const { server, client } = await connect();
1118+
// Source outside any directory root → isWritablePath false → save button
1119+
// would be hidden in the viewer. save_as should refuse for the same reason.
1120+
const outside = path.join(os.tmpdir(), "saveas-outside.pdf");
1121+
fs.writeFileSync(outside, "x");
1122+
viewSourcePaths.set("saveas-buttongate", outside);
1123+
1124+
try {
1125+
const r = await client.callTool({
1126+
name: "interact",
1127+
arguments: {
1128+
viewUUID: "saveas-buttongate",
1129+
action: "save_as",
1130+
overwrite: true,
1131+
},
1132+
});
1133+
expect(r.isError).toBe(true);
1134+
expect(firstText(r)).toContain("not writable");
1135+
expect(firstText(r)).toContain("save button is hidden");
1136+
} finally {
1137+
fs.rmSync(outside, { force: true });
1138+
}
1139+
await client.close();
1140+
await server.close();
1141+
});
1142+
1143+
it("no path, overwrite: true → roundtrip overwrites the original", async () => {
1144+
const { server, client } = await connect();
1145+
const uuid = "saveas-original";
1146+
const source = path.join(tmpDir, "report.pdf");
1147+
fs.writeFileSync(source, "%PDF-1.4\noriginal contents");
1148+
viewSourcePaths.set(uuid, source);
1149+
1150+
const interactPromise = client.callTool({
1151+
name: "interact",
1152+
arguments: { viewUUID: uuid, action: "save_as", overwrite: true },
1153+
});
1154+
1155+
const cmds = await poll(client, uuid);
1156+
expect(cmds).toHaveLength(1);
1157+
expect(cmds[0].type).toBe("save_as");
1158+
await client.callTool({
1159+
name: "submit_save_data",
1160+
arguments: {
1161+
requestId: cmds[0].requestId as string,
1162+
data: Buffer.from("%PDF-1.4\nannotated").toString("base64"),
1163+
},
1164+
});
1165+
1166+
const r = await interactPromise;
1167+
expect(r.isError).toBeFalsy();
1168+
expect(firstText(r)).toContain(source);
1169+
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\nannotated");
1170+
1171+
await client.close();
1172+
await server.close();
1173+
});
1174+
1175+
it("rejects non-absolute path", async () => {
1176+
const { server, client } = await connect();
1177+
const r = await client.callTool({
1178+
name: "interact",
1179+
arguments: {
1180+
viewUUID: "saveas-rel",
1181+
action: "save_as",
1182+
path: "relative.pdf",
1183+
},
1184+
});
1185+
expect(r.isError).toBe(true);
1186+
expect(firstText(r)).toContain("absolute");
1187+
await client.close();
1188+
await server.close();
1189+
});
1190+
1191+
it("rejects non-writable path", async () => {
1192+
const { server, client } = await connect();
1193+
// Path outside any directory root → not writable. Validation is sync,
1194+
// so nothing is enqueued and the queue stays empty.
1195+
const r = await client.callTool({
1196+
name: "interact",
1197+
arguments: {
1198+
viewUUID: "saveas-nowrite",
1199+
action: "save_as",
1200+
path: "/somewhere/else/out.pdf",
1201+
},
1202+
});
1203+
expect(r.isError).toBe(true);
1204+
expect(firstText(r)).toContain("not under a mounted directory root");
1205+
await client.close();
1206+
await server.close();
1207+
});
1208+
1209+
it("rejects existing file when overwrite is false (default)", async () => {
1210+
const { server, client } = await connect();
1211+
const target = path.join(tmpDir, "exists.pdf");
1212+
fs.writeFileSync(target, "old contents");
1213+
1214+
const r = await client.callTool({
1215+
name: "interact",
1216+
arguments: {
1217+
viewUUID: "saveas-exists",
1218+
action: "save_as",
1219+
path: target,
1220+
},
1221+
});
1222+
expect(r.isError).toBe(true);
1223+
expect(firstText(r)).toContain("already exists");
1224+
expect(firstText(r)).toContain("overwrite: true");
1225+
// Existence check is sync — nothing enqueued, file untouched.
1226+
expect(fs.readFileSync(target, "utf8")).toBe("old contents");
1227+
await client.close();
1228+
await server.close();
1229+
});
1230+
1231+
it("full roundtrip: enqueue → poll → submit → file written", async () => {
1232+
const { server, client } = await connect();
1233+
const uuid = "saveas-roundtrip";
1234+
const target = path.join(tmpDir, "out.pdf");
1235+
const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF";
1236+
1237+
// interact blocks in waitForSaveData until submit_save_data resolves it
1238+
const interactPromise = client.callTool({
1239+
name: "interact",
1240+
arguments: { viewUUID: uuid, action: "save_as", path: target },
1241+
});
1242+
1243+
// Viewer polls → receives the save_as command with a requestId
1244+
const cmds = await poll(client, uuid);
1245+
expect(cmds).toHaveLength(1);
1246+
expect(cmds[0].type).toBe("save_as");
1247+
const requestId = cmds[0].requestId as string;
1248+
expect(typeof requestId).toBe("string");
1249+
1250+
// Viewer submits bytes
1251+
const submit = await client.callTool({
1252+
name: "submit_save_data",
1253+
arguments: {
1254+
requestId,
1255+
data: Buffer.from(pdfBytes).toString("base64"),
1256+
},
1257+
});
1258+
expect(submit.isError).toBeFalsy();
1259+
1260+
// interact now unblocks with success
1261+
const r = await interactPromise;
1262+
expect(r.isError).toBeFalsy();
1263+
expect(firstText(r)).toContain("Saved");
1264+
expect(firstText(r)).toContain(target);
1265+
expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes);
1266+
1267+
await client.close();
1268+
await server.close();
1269+
});
1270+
1271+
it("overwrite: true replaces an existing file", async () => {
1272+
const { server, client } = await connect();
1273+
const uuid = "saveas-overwrite";
1274+
const target = path.join(tmpDir, "replace.pdf");
1275+
fs.writeFileSync(target, "old contents");
1276+
1277+
const interactPromise = client.callTool({
1278+
name: "interact",
1279+
arguments: {
1280+
viewUUID: uuid,
1281+
action: "save_as",
1282+
path: target,
1283+
overwrite: true,
1284+
},
1285+
});
1286+
1287+
const cmds = await poll(client, uuid);
1288+
const requestId = cmds[0].requestId as string;
1289+
await client.callTool({
1290+
name: "submit_save_data",
1291+
arguments: {
1292+
requestId,
1293+
data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
1294+
},
1295+
});
1296+
1297+
const r = await interactPromise;
1298+
expect(r.isError).toBeFalsy();
1299+
expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew");
1300+
1301+
await client.close();
1302+
await server.close();
1303+
});
1304+
1305+
it("propagates viewer-reported errors to the model", async () => {
1306+
const { server, client } = await connect();
1307+
const uuid = "saveas-viewerr";
1308+
const target = path.join(tmpDir, "wontwrite.pdf");
1309+
1310+
const interactPromise = client.callTool({
1311+
name: "interact",
1312+
arguments: { viewUUID: uuid, action: "save_as", path: target },
1313+
});
1314+
1315+
const cmds = await poll(client, uuid);
1316+
// Viewer hit an error building bytes → reports it instead of timing out
1317+
await client.callTool({
1318+
name: "submit_save_data",
1319+
arguments: {
1320+
requestId: cmds[0].requestId as string,
1321+
error: "pdf-lib choked on a comb field",
1322+
},
1323+
});
1324+
1325+
const r = await interactPromise;
1326+
expect(r.isError).toBe(true);
1327+
expect(firstText(r)).toContain("pdf-lib choked on a comb field");
1328+
expect(fs.existsSync(target)).toBe(false);
1329+
1330+
await client.close();
1331+
await server.close();
1332+
});
1333+
1334+
it("submit_save_data with unknown requestId returns isError", async () => {
1335+
const { server, client } = await connect();
1336+
const r = await client.callTool({
1337+
name: "submit_save_data",
1338+
arguments: { requestId: "never-created", data: "AAAA" },
1339+
});
1340+
expect(r.isError).toBe(true);
1341+
expect(firstText(r)).toContain("No pending request");
1342+
await client.close();
1343+
await server.close();
1344+
});
1345+
});
1346+
10581347
describe("viewer liveness", () => {
10591348
// get_screenshot/get_text fail fast when the iframe never polled, instead
10601349
// of waiting 45s for a viewer that isn't there. Reproduces the case where

0 commit comments

Comments
 (0)