Skip to content

Commit 2b85e59

Browse files
committed
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.
1 parent 0266171 commit 2b85e59

5 files changed

Lines changed: 447 additions & 12 deletions

File tree

examples/pdf-server/README.md

Lines changed: 13 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,12 @@ 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 (same write rules as the in-viewer save button)._
258+
253259
## Testing
254260

255261
### E2E Tests (Playwright)

examples/pdf-server/server.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,4 +1054,210 @@ describe("interact tool", () => {
10541054
await server.close();
10551055
});
10561056
});
1057+
1058+
describe("save_as", () => {
1059+
// save_as is the FIRST interact action with a full request/reply unit
1060+
// test (get_text/get_screenshot use the same plumbing but are e2e-only).
1061+
// The roundtrip tests need: writable scope, kick off interact WITHOUT
1062+
// awaiting (it blocks until the view replies), poll → submit → await.
1063+
1064+
let tmpDir: string;
1065+
let savedDirs: Set<string>;
1066+
1067+
beforeEach(() => {
1068+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-"));
1069+
savedDirs = new Set(allowedLocalDirs);
1070+
allowedLocalDirs.add(tmpDir); // make tmpDir a directory root → writable
1071+
});
1072+
1073+
afterEach(() => {
1074+
allowedLocalDirs.clear();
1075+
for (const x of savedDirs) allowedLocalDirs.add(x);
1076+
fs.rmSync(tmpDir, { recursive: true, force: true });
1077+
});
1078+
1079+
it("rejects without `path`", async () => {
1080+
const { server, client } = await connect();
1081+
const r = await client.callTool({
1082+
name: "interact",
1083+
arguments: { viewUUID: "saveas-nopath", action: "save_as" },
1084+
});
1085+
expect(r.isError).toBe(true);
1086+
expect(firstText(r)).toContain("save_as");
1087+
expect(firstText(r)).toContain("path");
1088+
await client.close();
1089+
await server.close();
1090+
});
1091+
1092+
it("rejects non-absolute path", async () => {
1093+
const { server, client } = await connect();
1094+
const r = await client.callTool({
1095+
name: "interact",
1096+
arguments: {
1097+
viewUUID: "saveas-rel",
1098+
action: "save_as",
1099+
path: "relative.pdf",
1100+
},
1101+
});
1102+
expect(r.isError).toBe(true);
1103+
expect(firstText(r)).toContain("absolute");
1104+
await client.close();
1105+
await server.close();
1106+
});
1107+
1108+
it("rejects non-writable path", async () => {
1109+
const { server, client } = await connect();
1110+
// Path outside any directory root → not writable. Validation is sync,
1111+
// so nothing is enqueued and the queue stays empty.
1112+
const r = await client.callTool({
1113+
name: "interact",
1114+
arguments: {
1115+
viewUUID: "saveas-nowrite",
1116+
action: "save_as",
1117+
path: "/somewhere/else/out.pdf",
1118+
},
1119+
});
1120+
expect(r.isError).toBe(true);
1121+
expect(firstText(r)).toContain("not under a mounted directory root");
1122+
await client.close();
1123+
await server.close();
1124+
});
1125+
1126+
it("rejects existing file when overwrite is false (default)", async () => {
1127+
const { server, client } = await connect();
1128+
const target = path.join(tmpDir, "exists.pdf");
1129+
fs.writeFileSync(target, "old contents");
1130+
1131+
const r = await client.callTool({
1132+
name: "interact",
1133+
arguments: {
1134+
viewUUID: "saveas-exists",
1135+
action: "save_as",
1136+
path: target,
1137+
},
1138+
});
1139+
expect(r.isError).toBe(true);
1140+
expect(firstText(r)).toContain("already exists");
1141+
expect(firstText(r)).toContain("overwrite: true");
1142+
// Existence check is sync — nothing enqueued, file untouched.
1143+
expect(fs.readFileSync(target, "utf8")).toBe("old contents");
1144+
await client.close();
1145+
await server.close();
1146+
});
1147+
1148+
it("full roundtrip: enqueue → poll → submit → file written", async () => {
1149+
const { server, client } = await connect();
1150+
const uuid = "saveas-roundtrip";
1151+
const target = path.join(tmpDir, "out.pdf");
1152+
const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF";
1153+
1154+
// interact blocks in waitForSaveData until submit_save_data resolves it
1155+
const interactPromise = client.callTool({
1156+
name: "interact",
1157+
arguments: { viewUUID: uuid, action: "save_as", path: target },
1158+
});
1159+
1160+
// Viewer polls → receives the save_as command with a requestId
1161+
const cmds = await poll(client, uuid);
1162+
expect(cmds).toHaveLength(1);
1163+
expect(cmds[0].type).toBe("save_as");
1164+
const requestId = cmds[0].requestId as string;
1165+
expect(typeof requestId).toBe("string");
1166+
1167+
// Viewer submits bytes
1168+
const submit = await client.callTool({
1169+
name: "submit_save_data",
1170+
arguments: {
1171+
requestId,
1172+
data: Buffer.from(pdfBytes).toString("base64"),
1173+
},
1174+
});
1175+
expect(submit.isError).toBeFalsy();
1176+
1177+
// interact now unblocks with success
1178+
const r = await interactPromise;
1179+
expect(r.isError).toBeFalsy();
1180+
expect(firstText(r)).toContain("Saved");
1181+
expect(firstText(r)).toContain(target);
1182+
expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes);
1183+
1184+
await client.close();
1185+
await server.close();
1186+
});
1187+
1188+
it("overwrite: true replaces an existing file", async () => {
1189+
const { server, client } = await connect();
1190+
const uuid = "saveas-overwrite";
1191+
const target = path.join(tmpDir, "replace.pdf");
1192+
fs.writeFileSync(target, "old contents");
1193+
1194+
const interactPromise = client.callTool({
1195+
name: "interact",
1196+
arguments: {
1197+
viewUUID: uuid,
1198+
action: "save_as",
1199+
path: target,
1200+
overwrite: true,
1201+
},
1202+
});
1203+
1204+
const cmds = await poll(client, uuid);
1205+
const requestId = cmds[0].requestId as string;
1206+
await client.callTool({
1207+
name: "submit_save_data",
1208+
arguments: {
1209+
requestId,
1210+
data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
1211+
},
1212+
});
1213+
1214+
const r = await interactPromise;
1215+
expect(r.isError).toBeFalsy();
1216+
expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew");
1217+
1218+
await client.close();
1219+
await server.close();
1220+
});
1221+
1222+
it("propagates viewer-reported errors to the model", async () => {
1223+
const { server, client } = await connect();
1224+
const uuid = "saveas-viewerr";
1225+
const target = path.join(tmpDir, "wontwrite.pdf");
1226+
1227+
const interactPromise = client.callTool({
1228+
name: "interact",
1229+
arguments: { viewUUID: uuid, action: "save_as", path: target },
1230+
});
1231+
1232+
const cmds = await poll(client, uuid);
1233+
// Viewer hit an error building bytes → reports it instead of timing out
1234+
await client.callTool({
1235+
name: "submit_save_data",
1236+
arguments: {
1237+
requestId: cmds[0].requestId as string,
1238+
error: "pdf-lib choked on a comb field",
1239+
},
1240+
});
1241+
1242+
const r = await interactPromise;
1243+
expect(r.isError).toBe(true);
1244+
expect(firstText(r)).toContain("pdf-lib choked on a comb field");
1245+
expect(fs.existsSync(target)).toBe(false);
1246+
1247+
await client.close();
1248+
await server.close();
1249+
});
1250+
1251+
it("submit_save_data with unknown requestId returns isError", async () => {
1252+
const { server, client } = await connect();
1253+
const r = await client.callTool({
1254+
name: "submit_save_data",
1255+
arguments: { requestId: "never-created", data: "AAAA" },
1256+
});
1257+
expect(r.isError).toBe(true);
1258+
expect(firstText(r)).toContain("No pending request");
1259+
await client.close();
1260+
await server.close();
1261+
});
1262+
});
10571263
});

0 commit comments

Comments
 (0)