stayput-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/dist/index.js +1022 -0
  3. package/package.json +51 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # StayPut MCP server
2
+
3
+ Local PDF and photo tools for Claude, Cursor, VS Code and any other app that speaks the [Model Context Protocol](https://modelcontextprotocol.io). Ask your assistant to merge PDFs, pull out pages, or remove the GPS location from a photo, and it does the work on your own machine.
4
+
5
+ It runs the same code as [stayput.dev](https://stayput.dev), the browser version. **Your files are read from disk and written back to disk; nothing is uploaded, and the server makes no network requests.** It is free and open source (MIT), with no account and no API key.
6
+
7
+ ## Tools
8
+
9
+ | Tool | What it does |
10
+ | --- | --- |
11
+ | `merge_pdfs` | Combine PDFs into one, in the order given |
12
+ | `extract_pdf_pages` | Keep only some pages, reorder them ("3,1,2") or drop pages |
13
+ | `split_pdf` | Split into one file per page, or every N pages |
14
+ | `rotate_pdf` | Rotate all pages or chosen pages by 90, 180 or 270 degrees |
15
+ | `number_pdf_pages` | Stamp page numbers, e.g. "Page {n} of {total}" |
16
+ | `pdf_info` | Page count, page sizes and title |
17
+ | `images_to_pdf` | JPG and PNG images into one PDF, one per page, no re-compression |
18
+ | `inspect_image_metadata` | Show GPS location, camera, capture time and other hidden metadata in a photo |
19
+ | `strip_image_metadata` | Write a copy of a JPG, PNG or WebP with EXIF, GPS, XMP and comments removed, without re-encoding |
20
+
21
+ Every tool writes a **new** file and returns its path. Input files are never modified, and an existing file is never overwritten (you get `file-1.pdf` instead). Encrypted PDFs (bank statements, forms with printing restrictions) are decrypted locally with qpdf; for PDFs that need a password to open, the assistant passes it in the `password` argument.
22
+
23
+ ## Install
24
+
25
+ Requires Node.js 20 or newer.
26
+
27
+ **Claude Code**
28
+
29
+ ```sh
30
+ claude mcp add stayput -- npx -y stayput-mcp
31
+ ```
32
+
33
+ **Claude Desktop, Cursor, Windsurf and other JSON configs** (`claude_desktop_config.json`, `.cursor/mcp.json`, ...)
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "stayput": {
39
+ "command": "npx",
40
+ "args": ["-y", "stayput-mcp"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ **VS Code**
47
+
48
+ ```sh
49
+ code --add-mcp '{"name":"stayput","command":"npx","args":["-y","stayput-mcp"]}'
50
+ ```
51
+
52
+ Then ask things like:
53
+
54
+ - "Merge invoice-march.pdf and invoice-april.pdf in ~/Downloads"
55
+ - "Take pages 2 to 5 out of contract.pdf"
56
+ - "Does IMG_2041.jpg have a location in it? If so, make a copy without it"
57
+ - "Number the pages of thesis.pdf, starting on page 3"
58
+
59
+ Relative paths are resolved from the folder the server was started in, so absolute paths or `~/...` are the safest thing to give it.
60
+
61
+ ## Not covered here
62
+
63
+ HEIC, WebP and AVIF conversion, compression, PDF to image and PDF signing need a browser engine. They are on [stayput.dev](https://stayput.dev), which works the same way: in your browser, nothing uploaded.
64
+
65
+ ## Development
66
+
67
+ ```sh
68
+ cd mcp
69
+ npm install
70
+ npm test # builds dist/index.js and runs the end-to-end tests
71
+ ```
72
+
73
+ The tests use the site's fixtures; generate them first from the repository root with `python3 tests/fixtures/make-fixtures.py && node tests/fixtures/make-pdf.mjs` (needs Pillow and pillow-heif).
74
+
75
+ The server imports the PDF and metadata code from `../src/lib` and bundles it with esbuild (`build.mjs`). Two browser-only modules are swapped at build time: `src/unlock.ts` loads qpdf from `node_modules` instead of a `<script>` tag, and `src/no-canvas.ts` stands in for the canvas helpers that only PDF compression uses.
package/dist/index.js ADDED
@@ -0,0 +1,1022 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { z } from "zod";
9
+ import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
10
+ import { basename, dirname, extname, join, resolve } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ // src/unlock.ts
14
+ import { createRequire } from "node:module";
15
+
16
+ // ../src/lib/unlock.ts
17
+ function looksEncrypted(bytes) {
18
+ const needle = "/Encrypt";
19
+ const window_ = 65536;
20
+ const td2 = new TextDecoder("latin1");
21
+ const tail = td2.decode(bytes.subarray(Math.max(0, bytes.length - window_)));
22
+ if (tail.includes(needle)) return true;
23
+ if (bytes.length > window_) {
24
+ const head = td2.decode(bytes.subarray(0, window_));
25
+ if (head.includes(needle)) return true;
26
+ }
27
+ return false;
28
+ }
29
+ var PasswordRequiredError = class extends Error {
30
+ constructor(wrong) {
31
+ super(wrong ? "That password did not open the PDF." : "This PDF needs a password to open.");
32
+ this.wrong = wrong;
33
+ this.name = "PasswordRequiredError";
34
+ }
35
+ wrong;
36
+ };
37
+
38
+ // src/unlock.ts
39
+ var stderr = [];
40
+ var modulePromise;
41
+ function loadModule() {
42
+ modulePromise ??= (async () => {
43
+ const factory = createRequire(import.meta.url)("@neslinesli93/qpdf-wasm");
44
+ const original = console.error;
45
+ console.error = (...args) => void stderr.push(args.map(String).join(" "));
46
+ try {
47
+ return await factory({ noInitialRun: true });
48
+ } finally {
49
+ console.error = original;
50
+ }
51
+ })();
52
+ return modulePromise;
53
+ }
54
+ async function decrypt(bytes, password2) {
55
+ const qpdf = await loadModule();
56
+ qpdf.FS.writeFile("/in.pdf", bytes);
57
+ stderr = [];
58
+ let code;
59
+ try {
60
+ code = qpdf.callMain(["--decrypt", ...password2 ? [`--password=${password2}`] : [], "/in.pdf", "/out.pdf"]);
61
+ } catch (e) {
62
+ code = typeof e === "object" && e && "status" in e ? Number(e.status) : 1;
63
+ } finally {
64
+ try {
65
+ qpdf.FS.unlink("/in.pdf");
66
+ } catch {
67
+ }
68
+ }
69
+ const messages = stderr.join("\n");
70
+ if (code !== 0) {
71
+ if (/invalid password/i.test(messages)) throw new PasswordRequiredError(password2 !== "");
72
+ const detail = messages.trim().split("\n").pop()?.replace(/^this\.program:\s*(\/in\.pdf:\s*)?/, "");
73
+ throw new Error(`This PDF could not be unlocked${detail ? ` (${detail})` : ""}.`);
74
+ }
75
+ const out = new Uint8Array(qpdf.FS.readFile("/out.pdf"));
76
+ qpdf.FS.unlink("/out.pdf");
77
+ return out;
78
+ }
79
+ var currentPassword = "";
80
+ var queue = Promise.resolve();
81
+ function withPassword(password2, fn) {
82
+ const run = queue.then(async () => {
83
+ currentPassword = password2 ?? "";
84
+ try {
85
+ return await fn();
86
+ } finally {
87
+ currentPassword = "";
88
+ }
89
+ });
90
+ queue = run.catch(() => {
91
+ });
92
+ return run;
93
+ }
94
+ async function unlockPdf(bytes) {
95
+ if (!looksEncrypted(bytes)) return { bytes, unlocked: false };
96
+ try {
97
+ return { bytes: await decrypt(bytes, currentPassword), unlocked: true };
98
+ } catch (e) {
99
+ if (!(e instanceof PasswordRequiredError)) throw e;
100
+ throw new Error(e.wrong ? "That password did not open the PDF." : 'This PDF is password-protected. Pass its password in the "password" argument.');
101
+ }
102
+ }
103
+
104
+ // ../src/lib/pdf.ts
105
+ var pdfLibPromise;
106
+ function loadPdfLib() {
107
+ pdfLibPromise ??= import("pdf-lib");
108
+ return pdfLibPromise;
109
+ }
110
+ async function loadDocument(source) {
111
+ assertPdfHeader(source);
112
+ const { bytes } = await unlockPdf(source);
113
+ const { PDFDocument } = await loadPdfLib();
114
+ let doc;
115
+ try {
116
+ doc = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false });
117
+ } catch (e) {
118
+ throw new Error(`This file could not be read as a PDF. It may be damaged${e instanceof Error && e.message ? ` (${e.message.split("\n")[0]})` : ""}.`);
119
+ }
120
+ if (doc.isEncrypted) throw new Error("This PDF uses an encryption method that could not be removed in the browser.");
121
+ return doc;
122
+ }
123
+ function assertPdfHeader(bytes) {
124
+ if (bytes.length === 0) throw new Error("This file is empty.");
125
+ if (!isPdfHeader(bytes)) throw new Error("This file is not a PDF. It does not start with a PDF header.");
126
+ }
127
+ function isPdfHeader(bytes) {
128
+ const head = bytes.subarray(0, 1024);
129
+ for (let i = 0; i + 5 <= head.length; i++) {
130
+ if (head[i] === 37 && head[i + 1] === 80 && head[i + 2] === 68 && head[i + 3] === 70 && head[i + 4] === 45) return true;
131
+ }
132
+ return false;
133
+ }
134
+ function visibleBox(page) {
135
+ const m = page.getMediaBox();
136
+ const c = page.getCropBox();
137
+ const x0 = Math.max(m.x, c.x);
138
+ const y0 = Math.max(m.y, c.y);
139
+ const x1 = Math.min(m.x + m.width, c.x + c.width);
140
+ const y1 = Math.min(m.y + m.height, c.y + c.height);
141
+ if (x1 - x0 <= 0 || y1 - y0 <= 0) return { x: m.x, y: m.y, width: m.width, height: m.height };
142
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
143
+ }
144
+ async function mergePdfs(sources, onProgress) {
145
+ const { PDFDocument } = await loadPdfLib();
146
+ const out = await PDFDocument.create();
147
+ for (let i = 0; i < sources.length; i++) {
148
+ const src = await loadDocument(sources[i]);
149
+ const pages = await out.copyPages(src, src.getPageIndices());
150
+ for (const p of pages) out.addPage(p);
151
+ onProgress?.(i + 1);
152
+ }
153
+ return out.save({ useObjectStreams: true });
154
+ }
155
+ async function extractPages(source, indexes) {
156
+ const { PDFDocument } = await loadPdfLib();
157
+ const src = await loadDocument(source);
158
+ const out = await PDFDocument.create();
159
+ const pages = await out.copyPages(src, indexes);
160
+ for (const p of pages) out.addPage(p);
161
+ return out.save({ useObjectStreams: true });
162
+ }
163
+ async function rotatePages(source, rotations) {
164
+ const { degrees } = await loadPdfLib();
165
+ const doc = await loadDocument(source);
166
+ doc.getPages().forEach((page, i) => {
167
+ const r = rotations[i];
168
+ if (r !== void 0) page.setRotation(degrees((r % 360 + 360) % 360));
169
+ });
170
+ return doc.save({ useObjectStreams: true });
171
+ }
172
+ var PAGE_SIZES = {
173
+ a4: [595.28, 841.89],
174
+ letter: [612, 792]
175
+ };
176
+ async function imagesToPdf(images, opts, onProgress) {
177
+ const { PDFDocument } = await loadPdfLib();
178
+ const doc = await PDFDocument.create();
179
+ for (let i = 0; i < images.length; i++) {
180
+ const img = images[i];
181
+ const embedded = img.kind === "jpg" ? await doc.embedJpg(img.bytes) : await doc.embedPng(img.bytes);
182
+ const { width: iw, height: ih } = embedded;
183
+ if (opts.pageSize === "fit") {
184
+ const page = doc.addPage([iw + opts.margin * 2, ih + opts.margin * 2]);
185
+ page.drawImage(embedded, { x: opts.margin, y: opts.margin, width: iw, height: ih });
186
+ } else {
187
+ let [pw, ph] = PAGE_SIZES[opts.pageSize];
188
+ const landscape = opts.orientation === "landscape" || opts.orientation === "auto" && iw > ih;
189
+ if (landscape) [pw, ph] = [ph, pw];
190
+ const page = doc.addPage([pw, ph]);
191
+ const availW = pw - opts.margin * 2;
192
+ const availH = ph - opts.margin * 2;
193
+ const scale = Math.min(availW / iw, availH / ih);
194
+ const w = iw * scale;
195
+ const h = ih * scale;
196
+ page.drawImage(embedded, { x: (pw - w) / 2, y: (ph - h) / 2, width: w, height: h });
197
+ }
198
+ onProgress?.(i + 1);
199
+ }
200
+ return doc.save({ useObjectStreams: true });
201
+ }
202
+ function hexToRgb(hex) {
203
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
204
+ if (!m) return [0.2, 0.2, 0.2];
205
+ const n = parseInt(m[1], 16);
206
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
207
+ }
208
+ function displayedToUserSpace(page, fx, fy) {
209
+ const box = visibleBox(page);
210
+ const { width, height } = box;
211
+ const rot = (page.getRotation().angle % 360 + 360) % 360;
212
+ const [dw, dh] = rot === 90 || rot === 270 ? [height, width] : [width, height];
213
+ const dx = fx * dw;
214
+ const dy = fy * dh;
215
+ switch (rot) {
216
+ case 90:
217
+ return { x: box.x + width - dy, y: box.y + dx, rotate: 90, w: dw, h: dh };
218
+ case 180:
219
+ return { x: box.x + width - dx, y: box.y + height - dy, rotate: 180, w: dw, h: dh };
220
+ case 270:
221
+ return { x: box.x + dy, y: box.y + height - dx, rotate: -90, w: dw, h: dh };
222
+ default:
223
+ return { x: box.x + dx, y: box.y + dy, rotate: 0, w: dw, h: dh };
224
+ }
225
+ }
226
+ function displayedSize(page) {
227
+ const { width, height } = visibleBox(page);
228
+ const rot = (page.getRotation().angle % 360 + 360) % 360;
229
+ return rot === 90 || rot === 270 ? { w: height, h: width } : { w: width, h: height };
230
+ }
231
+ async function addPageNumbers(source, opts, onProgress) {
232
+ const lib = await loadPdfLib();
233
+ const { StandardFonts, rgb, degrees } = lib;
234
+ const doc = await loadDocument(source);
235
+ const fontName = opts.font === "times" ? StandardFonts.TimesRoman : opts.font === "courier" ? StandardFonts.Courier : StandardFonts.Helvetica;
236
+ const font = await doc.embedFont(fontName);
237
+ const pages = doc.getPages();
238
+ const numberedPages = pages.slice(Math.max(0, opts.firstPage));
239
+ const total = numberedPages.length;
240
+ const [r, g, b] = hexToRgb(opts.color);
241
+ const color = rgb(r, g, b);
242
+ const size2 = Math.max(4, opts.fontSize);
243
+ numberedPages.forEach((page, i) => {
244
+ const n = opts.start + i;
245
+ const text = opts.template.replace(/\{n\}/g, String(n)).replace(/\{total\}/g, String(opts.start + total - 1)).replace(/\{count\}/g, String(total));
246
+ const textWidth = font.widthOfTextAtSize(text, size2);
247
+ const { w: dw, h: dh } = displayedSize(page);
248
+ const vertical = opts.position.startsWith("top") ? dh - opts.margin - size2 * 0.75 : opts.margin;
249
+ const horizontal = opts.position.endsWith("left") ? opts.margin : opts.position.endsWith("right") ? dw - opts.margin - textWidth : (dw - textWidth) / 2;
250
+ const p = displayedToUserSpace(page, horizontal / dw, vertical / dh);
251
+ page.drawText(text, { x: p.x, y: p.y, size: size2, font, color, rotate: degrees(p.rotate) });
252
+ onProgress?.(i + 1, total);
253
+ });
254
+ return { bytes: await doc.save({ useObjectStreams: true }), numbered: total };
255
+ }
256
+
257
+ // ../src/lib/ranges.ts
258
+ function parsePageRange(input, pageCount) {
259
+ const out = [];
260
+ const text = input.trim();
261
+ if (!text) throw new Error("Enter at least one page or range, for example 1-3, 5.");
262
+ for (const rawPart of text.split(/[,;\s]+/)) {
263
+ const part = rawPart.trim();
264
+ if (!part) continue;
265
+ const m = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(part);
266
+ if (!m) throw new Error(`"${part}" is not a page number or range.`);
267
+ const a = Number(m[1]);
268
+ const b = m[2] !== void 0 ? Number(m[2]) : a;
269
+ if (a < 1 || b < 1 || a > pageCount || b > pageCount) {
270
+ throw new Error(`Page ${a > pageCount ? a : b} is out of range. This document has ${pageCount} page${pageCount === 1 ? "" : "s"}.`);
271
+ }
272
+ const step = a <= b ? 1 : -1;
273
+ for (let p = a; step > 0 ? p <= b : p >= b; p += step) out.push(p - 1);
274
+ }
275
+ if (out.length === 0) throw new Error("Enter at least one page.");
276
+ return out;
277
+ }
278
+ function chunkPages(pageCount, n) {
279
+ if (n < 1) throw new Error("Pages per file must be at least 1.");
280
+ const chunks = [];
281
+ for (let start = 0; start < pageCount; start += n) {
282
+ const chunk = [];
283
+ for (let p = start; p < Math.min(start + n, pageCount); p++) chunk.push(p);
284
+ chunks.push(chunk);
285
+ }
286
+ return chunks;
287
+ }
288
+ function describePages(indexes) {
289
+ if (indexes.length === 0) return "";
290
+ const parts = [];
291
+ let start = indexes[0];
292
+ let prev = start;
293
+ for (let i = 1; i <= indexes.length; i++) {
294
+ const cur = indexes[i];
295
+ if (cur !== void 0 && cur === prev + 1) {
296
+ prev = cur;
297
+ continue;
298
+ }
299
+ parts.push(start === prev ? `${start + 1}` : `${start + 1}-${prev + 1}`);
300
+ if (cur !== void 0) {
301
+ start = cur;
302
+ prev = cur;
303
+ }
304
+ }
305
+ return parts.join(",");
306
+ }
307
+
308
+ // ../src/lib/exif.ts
309
+ var td = new TextDecoder("latin1");
310
+ function ascii(b, start, len) {
311
+ return td.decode(b.subarray(start, start + len));
312
+ }
313
+ function parseTiff(tiff) {
314
+ const info = { hasGps: false };
315
+ if (tiff.length < 8) return info;
316
+ const le = tiff[0] === 73 && tiff[1] === 73;
317
+ const be = tiff[0] === 77 && tiff[1] === 77;
318
+ if (!le && !be) return info;
319
+ const dv = new DataView(tiff.buffer, tiff.byteOffset, tiff.byteLength);
320
+ const u16 = (o) => dv.getUint16(o, le);
321
+ const u32 = (o) => dv.getUint32(o, le);
322
+ if (u16(2) !== 42) return info;
323
+ const readAscii = (off, count) => {
324
+ if (count === 0) return void 0;
325
+ const start = count <= 4 ? off + 8 : u32(off + 8);
326
+ if (start + count > tiff.length) return void 0;
327
+ return ascii(tiff, start, count).replace(/\0+$/, "").trim() || void 0;
328
+ };
329
+ const visited = /* @__PURE__ */ new Set();
330
+ const readIfd = (offset, isExifSub) => {
331
+ if (offset < 8 || offset + 2 > tiff.length || visited.has(offset)) return;
332
+ visited.add(offset);
333
+ const n = u16(offset);
334
+ for (let i = 0; i < n; i++) {
335
+ const e = offset + 2 + i * 12;
336
+ if (e + 12 > tiff.length) return;
337
+ const tag = u16(e);
338
+ const type = u16(e + 2);
339
+ const count = u32(e + 4);
340
+ switch (tag) {
341
+ case 274:
342
+ if (!isExifSub && type === 3) {
343
+ info.orientation = u16(e + 8);
344
+ info.orientationValueOffset = e + 8;
345
+ }
346
+ break;
347
+ case 271:
348
+ info.make ??= readAscii(e, count);
349
+ break;
350
+ case 272:
351
+ info.model ??= readAscii(e, count);
352
+ break;
353
+ case 305:
354
+ info.software ??= readAscii(e, count);
355
+ break;
356
+ case 306:
357
+ if (!info.dateTime) info.dateTime = readAscii(e, count);
358
+ break;
359
+ case 36867:
360
+ info.dateTime = readAscii(e, count) ?? info.dateTime;
361
+ break;
362
+ case 34665:
363
+ if (type === 4) readIfd(u32(e + 8), true);
364
+ break;
365
+ case 34853: {
366
+ if (type !== 4) break;
367
+ const g = u32(e + 8);
368
+ if (g + 2 <= tiff.length) {
369
+ const gn = u16(g);
370
+ for (let j = 0; j < gn; j++) {
371
+ const ge = g + 2 + j * 12;
372
+ if (ge + 12 > tiff.length) break;
373
+ const gt = u16(ge);
374
+ if (gt === 2 || gt === 4) {
375
+ info.hasGps = true;
376
+ break;
377
+ }
378
+ }
379
+ }
380
+ break;
381
+ }
382
+ }
383
+ }
384
+ };
385
+ readIfd(u32(4), false);
386
+ return info;
387
+ }
388
+ function jpegSegments(b) {
389
+ if (b.length < 4 || b[0] !== 255 || b[1] !== 216) throw new Error("Not a JPEG file.");
390
+ const segments = [];
391
+ let p = 2;
392
+ while (p + 4 <= b.length) {
393
+ if (b[p] !== 255) throw new Error("Corrupt JPEG structure.");
394
+ const marker = b[p + 1];
395
+ if (marker === 255) {
396
+ p++;
397
+ continue;
398
+ }
399
+ if (marker === 216 || marker >= 208 && marker <= 215 || marker === 1) {
400
+ p += 2;
401
+ continue;
402
+ }
403
+ if (marker === 218) {
404
+ return { segments, scanStart: p };
405
+ }
406
+ const len = b[p + 2] << 8 | b[p + 3];
407
+ const seg = { marker, start: p, end: p + 2 + len, payloadStart: p + 4, payloadLength: len - 2 };
408
+ if (seg.end > b.length) throw new Error("Truncated JPEG file.");
409
+ segments.push(seg);
410
+ p = seg.end;
411
+ }
412
+ throw new Error("No image data found in this JPEG.");
413
+ }
414
+ function segKind(b, s) {
415
+ const head = ascii(b, s.payloadStart, Math.min(29, s.payloadLength));
416
+ switch (s.marker) {
417
+ case 225:
418
+ if (head.startsWith("Exif\0")) return "EXIF";
419
+ if (head.startsWith("http://ns.adobe.com/xap/1.0/") || head.startsWith("http://ns.adobe.com/xmp/extension/")) return "XMP";
420
+ return "APP1";
421
+ case 226:
422
+ if (head.startsWith("ICC_PROFILE\0")) return "ICC";
423
+ if (head.startsWith("MPF\0")) return "MPF";
424
+ return "APP2";
425
+ case 237:
426
+ return "IPTC";
427
+ case 254:
428
+ return "Comment";
429
+ case 224:
430
+ return "JFIF";
431
+ case 238:
432
+ return "Adobe";
433
+ default:
434
+ if (s.marker >= 227 && s.marker <= 239) return `APP${s.marker - 224}`;
435
+ return void 0;
436
+ }
437
+ }
438
+ function inspectJpeg(b) {
439
+ const { segments } = jpegSegments(b);
440
+ const kinds = /* @__PURE__ */ new Set();
441
+ let bytes = 0;
442
+ let hasIcc = false;
443
+ let tiff = { hasGps: false };
444
+ for (const s of segments) {
445
+ const kind = segKind(b, s);
446
+ if (!kind || kind === "JFIF" || kind === "Adobe") continue;
447
+ if (kind === "ICC") {
448
+ hasIcc = true;
449
+ continue;
450
+ }
451
+ kinds.add(kind);
452
+ bytes += s.end - s.start;
453
+ if (kind === "EXIF") tiff = parseTiff(b.subarray(s.payloadStart + 6, s.payloadStart + s.payloadLength));
454
+ }
455
+ return { kinds: [...kinds], bytes, hasIcc, ...tiffSummary(tiff) };
456
+ }
457
+ function tiffSummary(t) {
458
+ return { hasGps: t.hasGps, orientation: t.orientation, make: t.make, model: t.model, dateTime: t.dateTime, software: t.software };
459
+ }
460
+ function stripJpeg(b, opts = {}) {
461
+ const keepIcc = opts.keepIcc ?? true;
462
+ const summary = inspectJpeg(b);
463
+ const { segments, scanStart } = jpegSegments(b);
464
+ const parts = [b.subarray(0, 2)];
465
+ let removed = 0;
466
+ for (const s of segments) {
467
+ const kind = segKind(b, s);
468
+ const isMeta = kind !== void 0 && kind !== "JFIF" && kind !== "Adobe";
469
+ const drop = isMeta && (kind !== "ICC" || !keepIcc);
470
+ if (drop) {
471
+ removed += s.end - s.start;
472
+ continue;
473
+ }
474
+ parts.push(b.subarray(s.start, s.end));
475
+ }
476
+ parts.push(b.subarray(scanStart));
477
+ if (!keepIcc && summary.hasIcc) summary.kinds.push("ICC");
478
+ summary.bytes = removed;
479
+ return { bytes: concat(parts), summary };
480
+ }
481
+ var PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
482
+ var PNG_META = /* @__PURE__ */ new Set(["eXIf", "tEXt", "zTXt", "iTXt", "tIME"]);
483
+ function pngChunks(b) {
484
+ for (let i = 0; i < 8; i++) if (b[i] !== PNG_SIG[i]) throw new Error("Not a PNG file.");
485
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
486
+ const chunks = [];
487
+ let p = 8;
488
+ while (p + 8 <= b.length) {
489
+ const length = dv.getUint32(p);
490
+ const type = ascii(b, p + 4, 4);
491
+ const end = p + 12 + length;
492
+ if (end > b.length) throw new Error("Truncated PNG file.");
493
+ chunks.push({ type, start: p, end, dataStart: p + 8, length });
494
+ p = end;
495
+ if (type === "IEND") break;
496
+ }
497
+ return chunks;
498
+ }
499
+ function pngKind(type) {
500
+ return type === "eXIf" ? "EXIF" : type === "tIME" ? "Time" : "Text";
501
+ }
502
+ function inspectPng(b) {
503
+ const chunks = pngChunks(b);
504
+ const kinds = /* @__PURE__ */ new Set();
505
+ let bytes = 0;
506
+ let tiff = { hasGps: false };
507
+ let hasIcc = false;
508
+ for (const c of chunks) {
509
+ if (c.type === "iCCP") hasIcc = true;
510
+ if (!PNG_META.has(c.type)) continue;
511
+ const kind = pngKind(c.type);
512
+ if (c.type === "iTXt" && ascii(b, c.dataStart, Math.min(17, c.length)).startsWith("XML:com.adobe.xmp")) kinds.add("XMP");
513
+ else kinds.add(kind);
514
+ bytes += c.end - c.start;
515
+ if (c.type === "eXIf") tiff = parseTiff(b.subarray(c.dataStart, c.dataStart + c.length));
516
+ }
517
+ return { kinds: [...kinds], bytes, hasIcc, ...tiffSummary(tiff) };
518
+ }
519
+ function stripPng(b, opts = {}) {
520
+ const keepIcc = opts.keepIcc ?? true;
521
+ const summary = inspectPng(b);
522
+ const chunks = pngChunks(b);
523
+ const parts = [b.subarray(0, 8)];
524
+ let removed = 0;
525
+ for (const c of chunks) {
526
+ const drop = PNG_META.has(c.type) || c.type === "iCCP" && !keepIcc;
527
+ if (drop) {
528
+ removed += c.end - c.start;
529
+ continue;
530
+ }
531
+ parts.push(b.subarray(c.start, c.end));
532
+ }
533
+ if (!keepIcc && summary.hasIcc) summary.kinds.push("ICC");
534
+ summary.bytes = removed;
535
+ return { bytes: concat(parts), summary };
536
+ }
537
+ function webpChunks(b) {
538
+ if (b.length < 12 || ascii(b, 0, 4) !== "RIFF" || ascii(b, 8, 4) !== "WEBP") throw new Error("Not a WebP file.");
539
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
540
+ const chunks = [];
541
+ let p = 12;
542
+ while (p + 8 <= b.length) {
543
+ const fourcc = ascii(b, p, 4);
544
+ const length = dv.getUint32(p + 4, true);
545
+ const end = p + 8 + length + (length & 1);
546
+ if (p + 8 + length > b.length) throw new Error("Truncated WebP file.");
547
+ chunks.push({ fourcc, start: p, end: Math.min(end, b.length), dataStart: p + 8, length });
548
+ p = end;
549
+ }
550
+ return chunks;
551
+ }
552
+ function inspectWebp(b) {
553
+ const chunks = webpChunks(b);
554
+ const kinds = /* @__PURE__ */ new Set();
555
+ let bytes = 0;
556
+ let hasIcc = false;
557
+ let tiff = { hasGps: false };
558
+ for (const c of chunks) {
559
+ if (c.fourcc === "ICCP") hasIcc = true;
560
+ if (c.fourcc === "EXIF") {
561
+ kinds.add("EXIF");
562
+ bytes += c.end - c.start;
563
+ let t = b.subarray(c.dataStart, c.dataStart + c.length);
564
+ if (ascii(t, 0, 4) === "Exif") t = t.subarray(6);
565
+ tiff = parseTiff(t);
566
+ } else if (c.fourcc === "XMP ") {
567
+ kinds.add("XMP");
568
+ bytes += c.end - c.start;
569
+ }
570
+ }
571
+ return { kinds: [...kinds], bytes, hasIcc, ...tiffSummary(tiff) };
572
+ }
573
+ function stripWebp(b, opts = {}) {
574
+ const keepIcc = opts.keepIcc ?? true;
575
+ const summary = inspectWebp(b);
576
+ const chunks = webpChunks(b);
577
+ const body = [];
578
+ let removed = 0;
579
+ for (const c of chunks) {
580
+ const drop = c.fourcc === "EXIF" || c.fourcc === "XMP " || c.fourcc === "ICCP" && !keepIcc;
581
+ if (drop) {
582
+ removed += c.end - c.start;
583
+ continue;
584
+ }
585
+ if (c.fourcc === "VP8X") {
586
+ const chunk = b.slice(c.start, c.end);
587
+ let flags = chunk[8];
588
+ flags &= ~8 & ~4;
589
+ if (!keepIcc) flags &= ~32;
590
+ chunk[8] = flags;
591
+ body.push(chunk);
592
+ continue;
593
+ }
594
+ body.push(b.subarray(c.start, c.end));
595
+ }
596
+ const payload = concat(body);
597
+ const header = new Uint8Array(12);
598
+ header.set([82, 73, 70, 70], 0);
599
+ new DataView(header.buffer).setUint32(4, payload.length + 4, true);
600
+ header.set([87, 69, 66, 80], 8);
601
+ if (!keepIcc && summary.hasIcc) summary.kinds.push("ICC");
602
+ summary.bytes = removed;
603
+ return { bytes: concat([header, payload]), summary };
604
+ }
605
+ function heifExifTiff(b) {
606
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
607
+ const u32 = (o) => dv.getUint32(o);
608
+ const u16 = (o) => dv.getUint16(o);
609
+ const readN = (o, size2) => {
610
+ if (size2 === 0) return 0;
611
+ if (size2 === 4) return u32(o);
612
+ if (size2 === 8) return Number(dv.getBigUint64(o));
613
+ return u16(o);
614
+ };
615
+ const boxesIn = (start, end) => {
616
+ const out = [];
617
+ let p2 = start;
618
+ while (p2 + 8 <= end) {
619
+ let size2 = u32(p2);
620
+ const type = ascii(b, p2 + 4, 4);
621
+ let body = p2 + 8;
622
+ if (size2 === 1) {
623
+ size2 = Number(dv.getBigUint64(p2 + 8));
624
+ body = p2 + 16;
625
+ } else if (size2 === 0) size2 = end - p2;
626
+ if (size2 < 8 || p2 + size2 > end) break;
627
+ out.push({ type, start: p2, end: p2 + size2, body });
628
+ p2 += size2;
629
+ }
630
+ return out;
631
+ };
632
+ const meta = boxesIn(0, b.length).find((x) => x.type === "meta");
633
+ if (!meta) return void 0;
634
+ const children = boxesIn(meta.body + 4, meta.end);
635
+ const iinf = children.find((x) => x.type === "iinf");
636
+ const iloc = children.find((x) => x.type === "iloc");
637
+ const idat = children.find((x) => x.type === "idat");
638
+ if (!iinf || !iloc) return void 0;
639
+ const iinfVersion = b[iinf.body];
640
+ const countSize = iinfVersion === 0 ? 2 : 4;
641
+ const entriesStart = iinf.body + 4 + countSize;
642
+ let exifId;
643
+ for (const infe of boxesIn(entriesStart, iinf.end)) {
644
+ if (infe.type !== "infe") continue;
645
+ const v = b[infe.body];
646
+ if (v < 2) continue;
647
+ const id = v === 2 ? u16(infe.body + 4) : u32(infe.body + 4);
648
+ const itemType = ascii(b, infe.body + (v === 2 ? 8 : 10), 4);
649
+ if (itemType === "Exif") {
650
+ exifId = id;
651
+ break;
652
+ }
653
+ }
654
+ if (exifId === void 0) return void 0;
655
+ const version = b[iloc.body];
656
+ let p = iloc.body + 4;
657
+ const offsetSize = b[p] >> 4;
658
+ const lengthSize = b[p] & 15;
659
+ const baseOffsetSize = b[p + 1] >> 4;
660
+ const indexSize = version === 1 || version === 2 ? b[p + 1] & 15 : 0;
661
+ p += 2;
662
+ const itemCount = version < 2 ? u16(p) : u32(p);
663
+ p += version < 2 ? 2 : 4;
664
+ for (let i = 0; i < itemCount; i++) {
665
+ const itemId = version < 2 ? u16(p) : u32(p);
666
+ p += version < 2 ? 2 : 4;
667
+ let construction = 0;
668
+ if (version === 1 || version === 2) {
669
+ construction = u16(p) & 15;
670
+ p += 2;
671
+ }
672
+ p += 2;
673
+ const baseOffset = readN(p, baseOffsetSize);
674
+ p += baseOffsetSize;
675
+ const extentCount = u16(p);
676
+ p += 2;
677
+ const extents = [];
678
+ for (let e = 0; e < extentCount; e++) {
679
+ if (indexSize > 0) p += indexSize;
680
+ const off = readN(p, offsetSize);
681
+ p += offsetSize;
682
+ const len = readN(p, lengthSize);
683
+ p += lengthSize;
684
+ if (itemId === exifId) {
685
+ const abs2 = construction === 1 ? idat ? idat.body + baseOffset + off : -1 : baseOffset + off;
686
+ if (abs2 < 0 || abs2 + len > b.length) return void 0;
687
+ extents.push(b.subarray(abs2, abs2 + len));
688
+ }
689
+ }
690
+ if (itemId === exifId) {
691
+ const data = concat(extents);
692
+ if (data.length < 4) return void 0;
693
+ const tiffOffset = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0);
694
+ const tiff = data.subarray(4 + tiffOffset);
695
+ return tiff.length >= 8 ? tiff : void 0;
696
+ }
697
+ }
698
+ return void 0;
699
+ }
700
+ function sniffFormat(b) {
701
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255) return "jpeg";
702
+ if (b.length >= 8 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "png";
703
+ if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp";
704
+ if (b.length >= 12 && ascii(b, 4, 4) === "ftyp") return "heif";
705
+ return "unknown";
706
+ }
707
+ function inspect(b) {
708
+ switch (sniffFormat(b)) {
709
+ case "jpeg":
710
+ return inspectJpeg(b);
711
+ case "png":
712
+ return inspectPng(b);
713
+ case "webp":
714
+ return inspectWebp(b);
715
+ case "heif": {
716
+ const t = heifExifTiff(b);
717
+ const info = t ? parseTiff(t) : { hasGps: false };
718
+ return { kinds: t ? ["EXIF"] : [], bytes: t?.length ?? 0, hasIcc: false, ...tiffSummary(info) };
719
+ }
720
+ default:
721
+ throw new Error("Only JPEG, PNG and WebP files can be cleaned losslessly.");
722
+ }
723
+ }
724
+ function strip(b, opts = {}) {
725
+ switch (sniffFormat(b)) {
726
+ case "jpeg":
727
+ return stripJpeg(b, opts);
728
+ case "png":
729
+ return stripPng(b, opts);
730
+ case "webp":
731
+ return stripWebp(b, opts);
732
+ default:
733
+ throw new Error("Only JPEG, PNG and WebP files can be cleaned losslessly. Convert HEIC photos first.");
734
+ }
735
+ }
736
+ function concat(parts) {
737
+ const total = parts.reduce((n, p) => n + p.length, 0);
738
+ const out = new Uint8Array(total);
739
+ let o = 0;
740
+ for (const p of parts) {
741
+ out.set(p, o);
742
+ o += p.length;
743
+ }
744
+ return out;
745
+ }
746
+
747
+ // src/server.ts
748
+ var VERSION = "0.1.0";
749
+ var SITE = "https://stayput.dev";
750
+ function abs(p) {
751
+ const t = p.trim();
752
+ if (t === "~" || t.startsWith("~/")) return join(homedir(), t.slice(1));
753
+ return resolve(process.cwd(), t);
754
+ }
755
+ async function read(p) {
756
+ const path = abs(p);
757
+ try {
758
+ return new Uint8Array(await readFile(path));
759
+ } catch (e) {
760
+ const code = e.code;
761
+ throw new Error(code === "ENOENT" ? `No file at ${path}.` : code === "EISDIR" ? `${path} is a folder, not a file.` : `Could not read ${path} (${code ?? String(e)}).`);
762
+ }
763
+ }
764
+ var exists = (p) => stat(p).then(() => true, () => false);
765
+ async function freePath(path) {
766
+ const ext = extname(path);
767
+ const stem = path.slice(0, path.length - ext.length);
768
+ let candidate = path;
769
+ for (let i = 1; await exists(candidate); i++) candidate = `${stem}-${i}${ext}`;
770
+ return candidate;
771
+ }
772
+ async function outPath(given, input, suffix, ext) {
773
+ if (given) return freePath(abs(given));
774
+ const src = abs(input);
775
+ return freePath(join(dirname(src), `${basename(src, extname(src))}-${suffix}${ext}`));
776
+ }
777
+ async function save(path, bytes) {
778
+ await mkdir(dirname(path), { recursive: true });
779
+ await writeFile(path, bytes, { flag: "wx" });
780
+ }
781
+ var size = (n) => n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
782
+ var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
783
+ function ok(text, structured) {
784
+ return { content: [{ type: "text", text }], structuredContent: structured };
785
+ }
786
+ async function guard(password2, fn) {
787
+ try {
788
+ return await withPassword(password2, fn);
789
+ } catch (e) {
790
+ return { isError: true, content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }] };
791
+ }
792
+ }
793
+ async function pdfPages(bytes) {
794
+ return (await loadDocument(bytes)).getPageCount();
795
+ }
796
+ var password = z.string().optional().describe("Password for a PDF that asks for one when opened. Used only on this machine.");
797
+ var output = z.string().optional().describe("Where to write the result. Defaults to a new file beside the input. Existing files are never overwritten; a free name like file-1.pdf is used instead.");
798
+ function createServer() {
799
+ const server = new McpServer(
800
+ { name: "stayput", title: "StayPut", version: VERSION, websiteUrl: SITE },
801
+ {
802
+ instructions: 'Local PDF and photo tools. Paths are on this machine: absolute, ~/..., or relative to the working directory. Tools never modify or overwrite input files; they write a new file and return its path. Nothing is uploaded. Encrypted PDFs are decrypted locally; pass "password" when one is needed. For formats these tools do not cover (HEIC, WebP, AVIF conversion, compression, PDF to image), point the user to https://stayput.dev, which runs the same way in the browser.'
803
+ }
804
+ );
805
+ server.registerTool(
806
+ "pdf_info",
807
+ {
808
+ title: "PDF info",
809
+ description: "Read a PDF on this machine and report its page count, page sizes, title and whether it was encrypted. Use before splitting, extracting or rotating to learn how many pages there are.",
810
+ inputSchema: { path: z.string().describe("Path to the PDF."), password },
811
+ annotations: { readOnlyHint: true, openWorldHint: false }
812
+ },
813
+ ({ path, password: pw }) => guard(pw, async () => {
814
+ const bytes = await read(path);
815
+ const doc = await loadDocument(bytes);
816
+ const sizes = doc.getPages().map((p) => {
817
+ const { width, height } = p.getSize();
818
+ return `${Math.round(width)}x${Math.round(height)} pt`;
819
+ });
820
+ const distinct = [...new Set(sizes)];
821
+ const title = doc.getTitle();
822
+ const lines = [`${abs(path)}: ${plural(doc.getPageCount(), "page")}, ${size(bytes.length)}.`, `Page size: ${distinct.slice(0, 5).join(", ")}${distinct.length > 5 ? ", ..." : ""}.`];
823
+ if (title) lines.push(`Title: ${title}`);
824
+ return ok(lines.join("\n"), { path: abs(path), pages: doc.getPageCount(), bytes: bytes.length, pageSizes: distinct, title: title ?? null });
825
+ })
826
+ );
827
+ server.registerTool(
828
+ "merge_pdfs",
829
+ {
830
+ title: "Merge PDFs",
831
+ description: "Combine several PDFs on this machine into one, in the order given. The same file may appear more than once. Writes a new PDF and returns its path.",
832
+ inputSchema: { inputs: z.array(z.string()).min(1).describe("PDF paths, in the order their pages should appear."), output, password },
833
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
834
+ },
835
+ ({ inputs, output: out, password: pw }) => guard(pw, async () => {
836
+ const sources = await Promise.all(inputs.map(read));
837
+ const bytes = await mergePdfs(sources);
838
+ const dest = await outPath(out, inputs[0], "merged", ".pdf");
839
+ await save(dest, bytes);
840
+ const n = await pdfPages(bytes);
841
+ return ok(`Merged ${plural(inputs.length, "file")} into ${dest} (${plural(n, "page")}, ${size(bytes.length)}).`, { output: dest, pages: n, bytes: bytes.length });
842
+ })
843
+ );
844
+ server.registerTool(
845
+ "extract_pdf_pages",
846
+ {
847
+ title: "Extract, reorder or delete PDF pages",
848
+ description: 'Write a new PDF containing only the listed pages, in the listed order. Use it to pull out a range, reorder pages ("3,1,2") or drop pages (list the ones to keep). Pages are 1-based; ranges like "1-3, 7" and reversed ranges like "5-1" work.',
849
+ inputSchema: { input: z.string().describe("Path to the PDF."), pages: z.string().describe('Pages to keep, for example "1-3, 7" or "3,1,2".'), output, password },
850
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
851
+ },
852
+ ({ input, pages, output: out, password: pw }) => guard(pw, async () => {
853
+ const src = await read(input);
854
+ const indexes = parsePageRange(pages, await pdfPages(src));
855
+ const bytes = await extractPages(src, indexes);
856
+ const dest = await outPath(out, input, "pages", ".pdf");
857
+ await save(dest, bytes);
858
+ return ok(`Wrote ${dest} with ${describePages(indexes)} (${plural(indexes.length, "page")}, ${size(bytes.length)}).`, { output: dest, pages: indexes.length, bytes: bytes.length });
859
+ })
860
+ );
861
+ server.registerTool(
862
+ "split_pdf",
863
+ {
864
+ title: "Split PDF",
865
+ description: "Split a PDF into several files of N pages each (1 = one file per page), written into a folder. Returns the list of files.",
866
+ inputSchema: {
867
+ input: z.string().describe("Path to the PDF."),
868
+ pages_per_file: z.number().int().min(1).default(1).describe("Pages in each output file. Default 1."),
869
+ output_dir: z.string().optional().describe("Folder for the parts. Defaults to a new folder beside the input named <file>-split."),
870
+ password
871
+ },
872
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
873
+ },
874
+ ({ input, pages_per_file, output_dir, password: pw }) => guard(pw, async () => {
875
+ const src = await read(input);
876
+ const stem = basename(abs(input), extname(input));
877
+ const folder = output_dir ? abs(output_dir) : await freePath(join(dirname(abs(input)), `${stem}-split`));
878
+ const written = [];
879
+ for (const chunk of chunkPages(await pdfPages(src), pages_per_file)) {
880
+ const first = chunk[0] + 1;
881
+ const last = chunk[chunk.length - 1] + 1;
882
+ const dest = await freePath(join(folder, `${stem}-pages-${first === last ? first : `${first}-${last}`}.pdf`));
883
+ await save(dest, await extractPages(src, chunk));
884
+ written.push(dest);
885
+ }
886
+ return ok(`Split into ${plural(written.length, "file")} in ${folder}:
887
+ ${written.map((w) => `- ${basename(w)}`).join("\n")}`, { outputDir: folder, files: written });
888
+ })
889
+ );
890
+ server.registerTool(
891
+ "rotate_pdf",
892
+ {
893
+ title: "Rotate PDF pages",
894
+ description: "Rotate all pages, or only the listed ones, clockwise by 90, 180 or 270 degrees, and save the rotation permanently in a new PDF.",
895
+ inputSchema: {
896
+ input: z.string().describe("Path to the PDF."),
897
+ degrees: z.union([z.literal(90), z.literal(180), z.literal(270)]).describe("Clockwise rotation to add."),
898
+ pages: z.string().optional().describe('Pages to rotate, for example "1, 3-4". Default: every page.'),
899
+ output,
900
+ password
901
+ },
902
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
903
+ },
904
+ ({ input, degrees, pages, output: out, password: pw }) => guard(pw, async () => {
905
+ const src = await read(input);
906
+ const doc = await loadDocument(src);
907
+ const count = doc.getPageCount();
908
+ const chosen = new Set(pages ? parsePageRange(pages, count) : doc.getPageIndices());
909
+ const rotations = doc.getPages().map((p, i) => p.getRotation().angle + (chosen.has(i) ? degrees : 0));
910
+ const bytes = await rotatePages(src, rotations);
911
+ const dest = await outPath(out, input, "rotated", ".pdf");
912
+ await save(dest, bytes);
913
+ return ok(`Rotated ${plural(chosen.size, "page")} by ${degrees} degrees. Wrote ${dest} (${size(bytes.length)}).`, { output: dest, rotatedPages: chosen.size, bytes: bytes.length });
914
+ })
915
+ );
916
+ server.registerTool(
917
+ "number_pdf_pages",
918
+ {
919
+ title: "Add page numbers",
920
+ description: 'Stamp page numbers on a PDF. The template may use {n} for the page number and {total} for the last number, e.g. "Page {n} of {total}".',
921
+ inputSchema: {
922
+ input: z.string().describe("Path to the PDF."),
923
+ template: z.string().default("{n}").describe('Text to print. Default "{n}".'),
924
+ position: z.enum(["bottom-center", "bottom-left", "bottom-right", "top-center", "top-left", "top-right"]).default("bottom-center"),
925
+ start: z.number().int().default(1).describe("Number printed on the first numbered page."),
926
+ first_page: z.number().int().min(1).default(1).describe("First page (1-based) that gets a number; earlier pages, such as a cover, are left blank."),
927
+ font_size: z.number().min(4).max(72).default(11),
928
+ output,
929
+ password
930
+ },
931
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
932
+ },
933
+ ({ input, template, position, start, first_page, font_size, output: out, password: pw }) => guard(pw, async () => {
934
+ const src = await read(input);
935
+ const { bytes, numbered } = await addPageNumbers(src, { template, position, start, firstPage: first_page - 1, fontSize: font_size, margin: 28, font: "helvetica", color: "#333333" });
936
+ const dest = await outPath(out, input, "numbered", ".pdf");
937
+ await save(dest, bytes);
938
+ return ok(`Numbered ${plural(numbered, "page")}. Wrote ${dest} (${size(bytes.length)}).`, { output: dest, numberedPages: numbered, bytes: bytes.length });
939
+ })
940
+ );
941
+ server.registerTool(
942
+ "images_to_pdf",
943
+ {
944
+ title: "Images to PDF",
945
+ description: "Put JPG and PNG images into one PDF, one image per page, in the order given. Images are embedded as they are, without re-compression.",
946
+ inputSchema: {
947
+ inputs: z.array(z.string()).min(1).describe("JPG or PNG paths, in page order."),
948
+ page_size: z.enum(["fit", "a4", "letter"]).default("fit").describe('"fit" makes each page the size of its image; "a4" and "letter" centre the image on a standard page.'),
949
+ margin: z.number().min(0).max(144).default(0).describe("Margin in points (72 = 1 inch)."),
950
+ output
951
+ },
952
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
953
+ },
954
+ ({ inputs, page_size, margin, output: out }) => guard(void 0, async () => {
955
+ const images = await Promise.all(
956
+ inputs.map(async (p) => {
957
+ const bytes2 = await read(p);
958
+ const kind = sniffFormat(bytes2);
959
+ if (kind === "jpeg") return { kind: "jpg", bytes: bytes2 };
960
+ if (kind === "png") return { kind: "png", bytes: bytes2 };
961
+ throw new Error(`${abs(p)} is not a JPG or PNG. Convert it first, for example at ${SITE}/tools/image-to-pdf, which also takes HEIC and WebP and runs in the browser.`);
962
+ })
963
+ );
964
+ const bytes = await imagesToPdf(images, { pageSize: page_size, orientation: "auto", margin });
965
+ const dest = await outPath(out, inputs[0], "images", ".pdf");
966
+ await save(dest, bytes);
967
+ return ok(`Wrote ${dest} (${plural(images.length, "page")}, ${size(bytes.length)}).`, { output: dest, pages: images.length, bytes: bytes.length });
968
+ })
969
+ );
970
+ const describe = (s) => [
971
+ `Metadata found: ${s.kinds.length ? s.kinds.join(", ") : "none"} (${size(s.bytes)} removable).`,
972
+ `GPS location: ${s.hasGps ? "yes" : "no"}.`,
973
+ ...s.make || s.model ? [`Camera: ${[s.make, s.model].filter(Boolean).join(" ")}.`] : [],
974
+ ...s.dateTime ? [`Taken: ${s.dateTime}.`] : [],
975
+ ...s.software ? [`Software: ${s.software}.`] : []
976
+ ];
977
+ server.registerTool(
978
+ "inspect_image_metadata",
979
+ {
980
+ title: "Inspect photo metadata",
981
+ description: "Report the metadata hidden in a JPG, PNG or WebP photo: GPS location, camera make and model, capture time, editing software, and which blocks (EXIF, XMP, IPTC, comments) are present. HEIC files report EXIF only.",
982
+ inputSchema: { path: z.string().describe("Path to the image.") },
983
+ annotations: { readOnlyHint: true, openWorldHint: false }
984
+ },
985
+ ({ path }) => guard(void 0, async () => {
986
+ const bytes = await read(path);
987
+ if (sniffFormat(bytes) === "unknown") throw new Error(`${abs(path)} is not a JPG, PNG, WebP or HEIC image.`);
988
+ const s = inspect(bytes);
989
+ return ok([`${abs(path)}:`, ...describe(s)].join("\n"), { path: abs(path), ...s });
990
+ })
991
+ );
992
+ server.registerTool(
993
+ "strip_image_metadata",
994
+ {
995
+ title: "Remove photo metadata",
996
+ description: "Write a copy of a JPG, PNG or WebP photo with EXIF (including GPS location), XMP, IPTC and comments removed. Lossless: the image data is not re-encoded. The original file is left untouched.",
997
+ inputSchema: {
998
+ input: z.string().describe("Path to the image."),
999
+ keep_color_profile: z.boolean().default(true).describe("Keep the ICC colour profile so colours look the same. Default true."),
1000
+ output: z.string().optional().describe("Where to write the cleaned copy. Defaults to <name>-clean.<ext> beside the input. Existing files are never overwritten.")
1001
+ },
1002
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
1003
+ },
1004
+ ({ input, keep_color_profile, output: out }) => guard(void 0, async () => {
1005
+ const src = await read(input);
1006
+ const format = sniffFormat(src);
1007
+ if (format === "unknown" || format === "heif") throw new Error(`${abs(input)} is not a JPG, PNG or WebP image. For HEIC photos, convert to JPG first (${SITE}/tools/heic-to-jpg drops the metadata by default).`);
1008
+ const { bytes, summary } = strip(src, { keepIcc: keep_color_profile });
1009
+ const dest = await outPath(out, input, "clean", extname(input) || (format === "jpeg" ? ".jpg" : `.${format}`));
1010
+ await save(dest, bytes);
1011
+ return ok([`Wrote ${dest} (${size(bytes.length)}). The original had:`, ...describe(summary)].join("\n"), { output: dest, bytes: bytes.length, removed: summary });
1012
+ })
1013
+ );
1014
+ return server;
1015
+ }
1016
+
1017
+ // src/index.ts
1018
+ if (process.argv.includes("--version")) {
1019
+ console.log(VERSION);
1020
+ process.exit(0);
1021
+ }
1022
+ await createServer().connect(new StdioServerTransport());
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "stayput-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP server for PDF and photo tasks: merge, split, rotate, number, images to PDF, inspect and strip photo metadata. Files never leave your machine.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://stayput.dev/mcp",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/keenanlk/stayput.git",
11
+ "directory": "mcp"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "mcp-server",
16
+ "model-context-protocol",
17
+ "pdf",
18
+ "merge-pdf",
19
+ "exif",
20
+ "privacy",
21
+ "local"
22
+ ],
23
+ "mcpName": "io.github.keenanlk/stayput",
24
+ "bin": {
25
+ "stayput-mcp": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "scripts": {
35
+ "build": "node build.mjs",
36
+ "prepublishOnly": "node build.mjs",
37
+ "typecheck": "tsc --noEmit -p tsconfig.json",
38
+ "test": "npm run build && node --test test/*.test.mjs"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.31.0",
42
+ "@neslinesli93/qpdf-wasm": "^0.3.0",
43
+ "pdf-lib": "^1.17.1",
44
+ "zod": "^4.6.5"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.6.2",
48
+ "esbuild": "^0.28.2",
49
+ "typescript": "^7.0.2"
50
+ }
51
+ }