@baruchiro/paperless-mcp 0.5.1 → 2.0.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 (37) hide show
  1. package/README.md +334 -11
  2. package/build/api/PaperlessAPI.d.ts +29 -2
  3. package/build/api/PaperlessAPI.js +97 -8
  4. package/build/api/types.d.ts +47 -0
  5. package/build/index.js +28 -5
  6. package/build/resources/documents.d.ts +7 -0
  7. package/build/resources/documents.js +119 -0
  8. package/build/resources/documents.test.d.ts +1 -0
  9. package/build/resources/documents.test.js +124 -0
  10. package/build/server.d.ts +14 -1
  11. package/build/server.js +21 -5
  12. package/build/server.test.d.ts +1 -0
  13. package/build/server.test.js +54 -0
  14. package/build/tools/documents.d.ts +3 -1
  15. package/build/tools/documents.js +170 -67
  16. package/build/tools/documents.test.js +404 -0
  17. package/build/tools/mail.d.ts +3 -0
  18. package/build/tools/mail.js +187 -0
  19. package/build/tools/mail.test.d.ts +1 -0
  20. package/build/tools/mail.test.js +212 -0
  21. package/build/tools/notes.d.ts +3 -0
  22. package/build/tools/notes.js +55 -0
  23. package/build/tools/notes.test.d.ts +1 -0
  24. package/build/tools/notes.test.js +177 -0
  25. package/build/tools/utils/descriptions.d.ts +1 -1
  26. package/build/tools/utils/descriptions.js +1 -1
  27. package/build/tools/utils/documentQuery.d.ts +71 -0
  28. package/build/tools/utils/documentQuery.js +270 -0
  29. package/build/tools/utils/resourceUri.d.ts +12 -7
  30. package/build/tools/utils/resourceUri.js +13 -8
  31. package/build/tools/utils/resourceUri.test.js +12 -12
  32. package/build/tools/utils/selectFields.d.ts +11 -0
  33. package/build/tools/utils/selectFields.js +91 -0
  34. package/build/tools/utils/selectFields.test.d.ts +1 -0
  35. package/build/tools/utils/selectFields.test.js +164 -0
  36. package/package.json +2 -1
  37. package/paperless-mcp.dxt +0 -0
@@ -20,15 +20,59 @@ var __rest = (this && this.__rest) || function (s, e) {
20
20
  return t;
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.validateFilePath = validateFilePath;
23
24
  exports.buildBulkEditParameters = buildBulkEditParameters;
24
25
  exports.registerDocumentTools = registerDocumentTools;
25
26
  const zod_1 = require("zod");
27
+ const promises_1 = require("fs/promises");
28
+ const path_1 = require("path");
26
29
  const documentEnhancer_1 = require("../api/documentEnhancer");
27
30
  const empty_1 = require("./utils/empty");
31
+ const documentQuery_1 = require("./utils/documentQuery");
28
32
  const middlewares_1 = require("./utils/middlewares");
29
33
  const monetary_1 = require("./utils/monetary");
34
+ const selectFields_1 = require("./utils/selectFields");
30
35
  const descriptions_1 = require("./utils/descriptions");
31
36
  const resourceUri_1 = require("./utils/resourceUri");
37
+ const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
38
+ const ALLOWED_UPLOAD_PATHS = process.env.PAPERLESS_MCP_UPLOAD_PATHS
39
+ ? process.env.PAPERLESS_MCP_UPLOAD_PATHS.split(":")
40
+ : [];
41
+ /** Validates that a file path is safe to read for document upload. */
42
+ function validateFilePath(filePath) {
43
+ return __awaiter(this, void 0, void 0, function* () {
44
+ if (!(0, path_1.isAbsolute)(filePath)) {
45
+ throw new Error("file_path must be an absolute path");
46
+ }
47
+ // Resolve symlinks to get canonical path for allowlist checks
48
+ let realPath;
49
+ try {
50
+ realPath = yield (0, promises_1.realpath)(filePath);
51
+ }
52
+ catch (_a) {
53
+ throw new Error("File not found");
54
+ }
55
+ if (ALLOWED_UPLOAD_PATHS.length > 0) {
56
+ const isAllowed = ALLOWED_UPLOAD_PATHS.some((allowedPath) => {
57
+ return realPath.startsWith(allowedPath + "/") || realPath === allowedPath;
58
+ });
59
+ if (!isAllowed) {
60
+ throw new Error("file_path is outside allowed upload directories. " +
61
+ "Configure PAPERLESS_MCP_UPLOAD_PATHS environment variable to specify allowed paths.");
62
+ }
63
+ }
64
+ const stats = yield (0, promises_1.stat)(realPath);
65
+ if (!stats.isFile()) {
66
+ throw new Error("Path must point to a regular file");
67
+ }
68
+ if (stats.size > MAX_FILE_SIZE_BYTES) {
69
+ throw new Error(`File size (${Math.round(stats.size / 1024 / 1024)}MB) exceeds maximum allowed size (${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB)`);
70
+ }
71
+ if (stats.size === 0) {
72
+ throw new Error("File is empty");
73
+ }
74
+ });
75
+ }
32
76
  /**
33
77
  * Builds Paperless-NGX bulk edit parameters from base parameters plus optional
34
78
  * custom field updates.
@@ -52,8 +96,9 @@ const resourceUri_1 = require("./utils/resourceUri");
52
96
  * @returns The merged API parameters with custom field updates transformed into
53
97
  * Paperless-NGX's `add_custom_fields` record shape.
54
98
  */
55
- function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false) {
56
- var _a, _b;
99
+ function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false, includeTagDefaults = false) {
100
+ var _a, _b, _c, _d;
101
+ var _e, _f;
57
102
  const apiParameters = Object.assign({}, parameters);
58
103
  if (addCustomFields) {
59
104
  apiParameters.add_custom_fields = Object.fromEntries(addCustomFields.map((customField) => [
@@ -65,8 +110,18 @@ function buildBulkEditParameters(parameters, addCustomFields, includeCustomField
65
110
  (_a = apiParameters.add_custom_fields) !== null && _a !== void 0 ? _a : (apiParameters.add_custom_fields = {});
66
111
  (_b = apiParameters.remove_custom_fields) !== null && _b !== void 0 ? _b : (apiParameters.remove_custom_fields = []);
67
112
  }
113
+ if (includeTagDefaults) {
114
+ (_c = (_e = apiParameters).add_tags) !== null && _c !== void 0 ? _c : (_e.add_tags = []);
115
+ (_d = (_f = apiParameters).remove_tags) !== null && _d !== void 0 ? _d : (_f.remove_tags = []);
116
+ }
68
117
  return apiParameters;
69
118
  }
119
+ function executeDocumentQuery(api, args) {
120
+ return __awaiter(this, void 0, void 0, function* () {
121
+ const docsResponse = yield api.getDocuments((0, documentQuery_1.buildDocumentQueryString)(args));
122
+ return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
123
+ });
124
+ }
70
125
  function registerDocumentTools(server, api) {
71
126
  server.tool("bulk_edit_documents", "Perform bulk operations on multiple documents. Note: 'remove_tag' removes a tag from specific documents (tag remains in system), while 'delete_tag' permanently deletes a tag from the entire system. ⚠️ WARNING: 'delete' method permanently deletes documents and requires confirmation.", {
72
127
  documents: zod_1.z.array(zod_1.z.number()),
@@ -144,9 +199,10 @@ function registerDocumentTools(server, api) {
144
199
  }
145
200
  const { documents, method, add_custom_fields, confirm } = args, parameters = __rest(args, ["documents", "method", "add_custom_fields", "confirm"]);
146
201
  (0, monetary_1.validateCustomFields)(add_custom_fields);
202
+ const resolvedCustomFields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, add_custom_fields, "stored");
147
203
  const response = yield api.bulkEditDocuments(documents, method, method === "delete"
148
204
  ? {}
149
- : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields"));
205
+ : buildBulkEditParameters(parameters, resolvedCustomFields, method === "modify_custom_fields", method === "modify_tags"));
150
206
  return {
151
207
  content: [
152
208
  {
@@ -156,9 +212,10 @@ function registerDocumentTools(server, api) {
156
212
  ],
157
213
  };
158
214
  })));
159
- server.tool("post_document", "Upload a new document to Paperless-NGX with optional metadata like title, correspondent, document type, tags, and custom fields.", {
160
- file: zod_1.z.string(),
161
- filename: zod_1.z.string(),
215
+ const postDocumentBaseSchema = zod_1.z.object({
216
+ file: zod_1.z.string().optional().describe("Base64-encoded file content. Either 'file' or 'file_path' must be provided."),
217
+ file_path: zod_1.z.string().optional().describe("Absolute path to a file on the server's filesystem. Either 'file' or 'file_path' must be provided. The filename is derived from the path unless 'filename' is also specified. For security, configure PAPERLESS_MCP_UPLOAD_PATHS to restrict allowed directories."),
218
+ filename: zod_1.z.string().optional().describe("Filename for the uploaded document. Required when using 'file', optional when using 'file_path' (defaults to the basename of the path)."),
162
219
  title: zod_1.z.string().optional(),
163
220
  created: zod_1.z.string().optional(),
164
221
  correspondent: zod_1.z.number().optional(),
@@ -167,16 +224,90 @@ function registerDocumentTools(server, api) {
167
224
  tags: zod_1.z.array(zod_1.z.number()).optional(),
168
225
  archive_serial_number: zod_1.z.number().optional(),
169
226
  custom_fields: zod_1.z.array(zod_1.z.number()).optional(),
170
- }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
227
+ });
228
+ const postDocumentSchema = postDocumentBaseSchema.superRefine((data, ctx) => {
229
+ const hasFile = data.file !== undefined;
230
+ const hasFilePath = data.file_path !== undefined;
231
+ if (!hasFile && !hasFilePath) {
232
+ ctx.addIssue({
233
+ code: zod_1.z.ZodIssueCode.custom,
234
+ message: "Either 'file' (base64) or 'file_path' must be provided.",
235
+ path: ["file"],
236
+ });
237
+ ctx.addIssue({
238
+ code: zod_1.z.ZodIssueCode.custom,
239
+ message: "Either 'file' (base64) or 'file_path' must be provided.",
240
+ path: ["file_path"],
241
+ });
242
+ }
243
+ if (hasFile && hasFilePath) {
244
+ ctx.addIssue({
245
+ code: zod_1.z.ZodIssueCode.custom,
246
+ message: "Only one of 'file' or 'file_path' should be provided, not both.",
247
+ path: ["file"],
248
+ });
249
+ ctx.addIssue({
250
+ code: zod_1.z.ZodIssueCode.custom,
251
+ message: "Only one of 'file' or 'file_path' should be provided, not both.",
252
+ path: ["file_path"],
253
+ });
254
+ }
255
+ if (hasFile && !data.filename) {
256
+ ctx.addIssue({
257
+ code: zod_1.z.ZodIssueCode.custom,
258
+ message: "'filename' is required when using 'file' (base64 mode).",
259
+ path: ["filename"],
260
+ });
261
+ }
262
+ if (hasFilePath && data.file_path && !(0, path_1.isAbsolute)(data.file_path)) {
263
+ ctx.addIssue({
264
+ code: zod_1.z.ZodIssueCode.custom,
265
+ message: "file_path must be an absolute path",
266
+ path: ["file_path"],
267
+ });
268
+ }
269
+ });
270
+ server.tool("post_document", "Upload a new document to Paperless-NGX with optional metadata like title, correspondent, document type, tags, and custom fields. Provide either 'file' (base64-encoded content) or 'file_path' (absolute path to a file on the server's filesystem). Using file_path avoids base64 encoding overhead for large files. SECURITY: When using file_path, set PAPERLESS_MCP_UPLOAD_PATHS environment variable to restrict uploads to specific directories (colon-separated paths).", postDocumentBaseSchema.shape, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
171
271
  if (!api)
172
272
  throw new Error("Please configure API connection first");
173
- // Validate base64 input
174
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
175
- if (!base64Regex.test(args.file)) {
176
- throw new Error("Invalid base64-encoded file data. Please provide a valid base64 string.");
273
+ const validationResult = postDocumentSchema.safeParse(args);
274
+ if (!validationResult.success) {
275
+ throw new Error(validationResult.error.errors.map(e => e.message).join("; "));
276
+ }
277
+ let document;
278
+ let filename;
279
+ if (args.file_path) {
280
+ yield validateFilePath(args.file_path);
281
+ try {
282
+ document = yield (0, promises_1.readFile)(args.file_path);
283
+ }
284
+ catch (err) {
285
+ throw new Error("Failed to read file");
286
+ }
287
+ filename = args.filename || (0, path_1.basename)(args.file_path);
288
+ if (!filename) {
289
+ throw new Error("Could not derive filename from file_path");
290
+ }
291
+ }
292
+ else if (args.file) {
293
+ const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
294
+ if (!base64Regex.test(args.file)) {
295
+ throw new Error("Invalid base64-encoded file data. Please provide a valid base64 string.");
296
+ }
297
+ document = Buffer.from(args.file, "base64");
298
+ if (document.length > MAX_FILE_SIZE_BYTES) {
299
+ throw new Error(`File size (${Math.round(document.length / 1024 / 1024)}MB) exceeds maximum allowed size (${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB)`);
300
+ }
301
+ if (document.length === 0) {
302
+ throw new Error("File is empty");
303
+ }
304
+ filename = args.filename;
305
+ }
306
+ else {
307
+ // This should never happen due to schema validation, but TypeScript needs it
308
+ throw new Error("Either 'file' (base64) or 'file_path' must be provided.");
177
309
  }
178
- const { file, filename } = args, metadata = __rest(args, ["file", "filename"]);
179
- const document = Buffer.from(file, "base64");
310
+ const { file, file_path, filename: _fn } = args, metadata = __rest(args, ["file", "file_path", "filename"]);
180
311
  const response = yield api.postDocument(document, filename, metadata);
181
312
  let result;
182
313
  if (typeof response === "string" && /^\d+$/.test(response)) {
@@ -194,43 +325,15 @@ function registerDocumentTools(server, api) {
194
325
  ],
195
326
  };
196
327
  })));
197
- server.tool("list_documents", "List and filter documents by fields such as title, correspondent, document type, tag, storage path, creation date, and more. IMPORTANT: For queries like 'the last 3 contributions' or when searching by tag, correspondent, document type, or storage path, you should FIRST use the relevant tool (e.g., 'list_tags', 'list_correspondents', 'list_document_types', 'list_storage_paths') to find the correct ID, and then use that ID as a filter here. Only use the 'search' argument for free-text search when no specific field applies. Using the correct ID filter will yield much more accurate results. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", {
198
- page: zod_1.z.number().optional(),
199
- page_size: zod_1.z.number().optional(),
200
- search: zod_1.z.string().optional(),
201
- correspondent: zod_1.z.number().optional(),
202
- document_type: zod_1.z.number().optional(),
203
- tag: zod_1.z.number().optional(),
204
- storage_path: zod_1.z.number().optional(),
205
- created__date__gte: zod_1.z.string().optional(),
206
- created__date__lte: zod_1.z.string().optional(),
207
- ordering: zod_1.z.string().optional(),
208
- }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
328
+ server.tool("list_documents", "List and filter documents with pagination and common Paperless filters such as title search, correspondent, document type, tag, storage path, creation date, archive serial number, and simple custom field filters. Use 'query_documents' for full-text query, structured custom field conditions, or advanced documented /api/documents/ query parameters. IMPORTANT: For queries like 'the last 3 contributions' or when searching by tag, correspondent, document type, or storage path, first use the relevant lookup tool to find the correct ID. Note: Document content is excluded from results by default. Use 'get_document_content' when you need the document text.", documentQuery_1.LIST_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
209
329
  if (!api)
210
330
  throw new Error("Please configure API connection first");
211
- const query = new URLSearchParams();
212
- if (args.page)
213
- query.set("page", args.page.toString());
214
- if (args.page_size)
215
- query.set("page_size", args.page_size.toString());
216
- if (args.search)
217
- query.set("search", args.search);
218
- if (args.correspondent)
219
- query.set("correspondent__id", args.correspondent.toString());
220
- if (args.document_type)
221
- query.set("document_type__id", args.document_type.toString());
222
- if (args.tag)
223
- query.set("tags__id", args.tag.toString());
224
- if (args.storage_path)
225
- query.set("storage_path__id", args.storage_path.toString());
226
- if (args.created__date__gte)
227
- query.set("created__date__gte", args.created__date__gte);
228
- if (args.created__date__lte)
229
- query.set("created__date__lte", args.created__date__lte);
230
- if (args.ordering)
231
- query.set("ordering", args.ordering);
232
- const docsResponse = yield api.getDocuments(query.toString() ? `?${query.toString()}` : "");
233
- return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
331
+ return executeDocumentQuery(api, args);
332
+ })));
333
+ server.tool("query_documents", "Query documents using the full-text query engine plus structured Paperless filters. Use this for complex filtering, custom field conditions, or any documented /api/documents/ query parameters that are not exposed as first-class arguments. Prefer the dedicated top-level arguments where available. custom_field_query supports [field_name_or_id, operator, value] leaves or ['AND'|'OR', [clause1, clause2]] groups. Note: Document content is excluded from results by default. Use 'get_document_content' when you need the document text.", documentQuery_1.QUERY_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
334
+ if (!api)
335
+ throw new Error("Please configure API connection first");
336
+ return executeDocumentQuery(api, args);
234
337
  })));
235
338
  server.tool("get_document", "Get a specific document by ID with full details including correspondent, document type, tags, and custom fields. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", {
236
339
  id: zod_1.z.number(),
@@ -259,51 +362,50 @@ function registerDocumentTools(server, api) {
259
362
  ],
260
363
  };
261
364
  })));
262
- server.tool("search_documents", "Full text search for documents. This tool is for searching document content, title, and metadata using a full text query. For general document listing or filtering by fields, use 'list_documents' instead. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", {
263
- query: zod_1.z.string(),
264
- }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
365
+ server.tool("search_documents", "Deprecated compatibility wrapper for full-text document search. Use 'query_documents' with the 'query' argument for new integrations. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", documentQuery_1.SEARCH_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
265
366
  if (!api)
266
367
  throw new Error("Please configure API connection first");
267
- const docsResponse = yield api.searchDocuments(args.query);
268
- return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
368
+ return executeDocumentQuery(api, args);
269
369
  })));
270
- server.tool("download_document", "Download a document file by ID. Returns the document as a base64-encoded resource.", {
271
- id: zod_1.z.number(),
370
+ server.tool("download_document", "Download a document file by ID. Returns a paperless:// resource URI; read the resource to fetch the file content.", {
371
+ id: zod_1.z.number().int().positive(),
272
372
  original: zod_1.z.boolean().optional(),
273
373
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
274
- var _a, _b;
275
374
  if (!api)
276
375
  throw new Error("Please configure API connection first");
277
- const response = yield api.downloadDocument(args.id, args.original);
278
- const filename = ((_b = (_a = (typeof response.headers.get === "function"
279
- ? response.headers.get("content-disposition")
280
- : response.headers["content-disposition"])) === null || _a === void 0 ? void 0 : _a.split("filename=")[1]) === null || _b === void 0 ? void 0 : _b.replace(/"/g, "")) || `document-${args.id}`;
376
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(args.id, {
377
+ original: args.original,
378
+ });
281
379
  return {
282
380
  content: [
283
381
  {
284
382
  type: "resource",
285
383
  resource: {
286
- uri: (0, resourceUri_1.buildDocumentResourceUri)(args.id, filename),
287
- blob: Buffer.from(response.data).toString("base64"),
288
- mimeType: "application/pdf",
384
+ uri,
385
+ // MCP SDK 1.11 embedded resources require text or blob. Keep the
386
+ // existing resource-shaped tool result while making resources/read
387
+ // the canonical place for the large binary payload.
388
+ text: "",
389
+ mimeType: "application/octet-stream",
289
390
  },
290
391
  },
291
392
  ],
292
393
  };
293
394
  })));
294
- server.tool("get_document_thumbnail", "Get a document thumbnail (image preview) by ID. Returns the thumbnail as a base64-encoded WebP image resource.", {
295
- id: zod_1.z.number(),
395
+ server.tool("get_document_thumbnail", "Get a document thumbnail (image preview) by ID. Returns a paperless:// resource URI; read the resource to fetch the image content.", {
396
+ id: zod_1.z.number().int().positive(),
296
397
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
297
398
  if (!api)
298
399
  throw new Error("Please configure API connection first");
299
- const response = yield api.getThumbnail(args.id);
300
400
  return {
301
401
  content: [
302
402
  {
303
403
  type: "resource",
304
404
  resource: {
305
405
  uri: (0, resourceUri_1.buildThumbnailResourceUri)(args.id),
306
- blob: Buffer.from(response.data).toString("base64"),
406
+ // See download_document above: the binary thumbnail is fetched
407
+ // lazily through resources/read instead of embedded here.
408
+ text: "",
307
409
  mimeType: "image/webp",
308
410
  },
309
411
  },
@@ -373,6 +475,7 @@ function registerDocumentTools(server, api) {
373
475
  throw new Error("Please configure API connection first");
374
476
  const { id } = args, updateData = __rest(args, ["id"]);
375
477
  (0, monetary_1.validateCustomFields)(updateData.custom_fields);
478
+ updateData.custom_fields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, updateData.custom_fields, "index");
376
479
  const response = yield api.updateDocument(id, updateData);
377
480
  return (0, documentEnhancer_1.convertDocsWithNames)(response, api);
378
481
  })));