@baruchiro/paperless-mcp 2.0.0 → 2.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.
package/README.md CHANGED
@@ -66,7 +66,7 @@ Add these to your MCP config file:
66
66
  | `PAPERLESS_URL` | Yes | — | Base URL of your Paperless-NGX instance |
67
67
  | `PAPERLESS_API_KEY` | Yes | — | API token from your Paperless-NGX profile |
68
68
  | `PAPERLESS_PUBLIC_URL` | No | `PAPERLESS_URL` | Public-facing URL for document links |
69
- | `PAPERLESS_API_VERSION` | No | `5` | Paperless-ngx REST API version. Use `10` for Paperless-ngx v3+. If you see HTTP 406 errors, set this to `10`. |
69
+ | `PAPERLESS_API_VERSION` | No | `9` | Paperless-ngx REST API version. `9` works on Paperless-ngx v2.x (recent) and v3.x. Paperless-ngx v3.0.0 dropped support for versions below `9`, so older defaults now return HTTP 406. If you see HTTP 406 errors, set this to a version your server supports. |
70
70
  | `PAPERLESS_MCP_UPLOAD_PATHS` | No | — | Colon-separated list of allowed directories for `file_path` uploads. **Recommended for security.** Example: `/var/uploads:/tmp/scans` |
71
71
 
72
72
  That's it! Now you can ask Claude to help you manage your Paperless-NGX documents.
@@ -252,7 +252,9 @@ Parameters:
252
252
  - tag: ID for add_tag/remove_tag
253
253
  - add_tags: Array of tag IDs for modify_tags
254
254
  - remove_tags: Array of tag IDs for modify_tags
255
- - permissions: Object for set_permissions with owner, permissions, merge flag
255
+ - set_permissions: Object for set_permissions with view/change users and groups (`{"view": {"users": [], "groups": []}, "change": {...}}`). Omitted actions/lists are left untouched
256
+ - owner: User ID (or null to remove) for set_permissions. Unless merge is true, omitting owner clears the current owner
257
+ - merge: Boolean for set_permissions — true adds to existing permissions and keeps the owner; false (default) replaces the listed users/groups
256
258
  - metadata_document_id: ID for merge to specify metadata source
257
259
  - delete_originals: Boolean for merge/split
258
260
  - pages: String for split "[1,2-3,4,5-7]" or delete_pages "[2,3,4]"
@@ -22,7 +22,7 @@ class PaperlessAPI {
22
22
  this.token = token;
23
23
  this.baseUrl = baseUrl;
24
24
  this.token = token;
25
- this.apiVersion = process.env.PAPERLESS_API_VERSION || "5";
25
+ this.apiVersion = process.env.PAPERLESS_API_VERSION || "9";
26
26
  }
27
27
  request(path_1) {
28
28
  return __awaiter(this, arguments, void 0, function* (path, options = {}) {
@@ -57,7 +57,7 @@ class PaperlessAPI {
57
57
  catch (error) {
58
58
  if (axios_1.default.isAxiosError(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 406) {
59
59
  throw new Error(`HTTP 406: Paperless-ngx rejected API version ${this.apiVersion}. ` +
60
- `Set the PAPERLESS_API_VERSION environment variable to match your server's API version (e.g., "10" for Paperless-ngx v3+).`);
60
+ `Set the PAPERLESS_API_VERSION environment variable to a version your server supports (e.g., "9" or "10" for Paperless-ngx v3+, or a lower value for older servers).`);
61
61
  }
62
62
  console.error({
63
63
  error: "Error executing request",
@@ -121,7 +121,7 @@ class PaperlessAPI {
121
121
  catch (error) {
122
122
  if (axios_1.default.isAxiosError(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 406) {
123
123
  throw new Error(`HTTP 406: Paperless-ngx rejected API version ${this.apiVersion}. ` +
124
- `Set the PAPERLESS_API_VERSION environment variable to match your server's API version (e.g., "10" for Paperless-ngx v3+).`);
124
+ `Set the PAPERLESS_API_VERSION environment variable to a version your server supports (e.g., "9" or "10" for Paperless-ngx v3+, or a lower value for older servers).`);
125
125
  }
126
126
  throw error;
127
127
  }
@@ -188,18 +188,16 @@ export interface BulkEditParameters {
188
188
  document_type?: number;
189
189
  storage_path?: number;
190
190
  tag?: number;
191
- permissions?: {
192
- owner?: number | null;
193
- set_permissions?: {
194
- view: {
195
- users: number[];
196
- groups: number[];
197
- };
198
- change: {
199
- users: number[];
200
- groups: number[];
201
- };
191
+ set_permissions?: {
192
+ view?: {
193
+ users?: number[];
194
+ groups?: number[];
195
+ };
196
+ change?: {
197
+ users?: number[];
198
+ groups?: number[];
202
199
  };
203
- merge?: boolean;
204
200
  };
201
+ owner?: number | null;
202
+ merge?: boolean;
205
203
  }
@@ -164,25 +164,37 @@ function registerDocumentTools(server, api) {
164
164
  .array(zod_1.z.number())
165
165
  .optional()
166
166
  .transform(empty_1.arrayNotEmpty),
167
- permissions: zod_1.z
167
+ set_permissions: zod_1.z
168
168
  .object({
169
- owner: zod_1.z.number().nullable().optional(),
170
- set_permissions: zod_1.z
169
+ view: zod_1.z
171
170
  .object({
172
- view: zod_1.z.object({
173
- users: zod_1.z.array(zod_1.z.number()),
174
- groups: zod_1.z.array(zod_1.z.number()),
175
- }),
176
- change: zod_1.z.object({
177
- users: zod_1.z.array(zod_1.z.number()),
178
- groups: zod_1.z.array(zod_1.z.number()),
179
- }),
171
+ users: zod_1.z.array(zod_1.z.number()).optional(),
172
+ groups: zod_1.z.array(zod_1.z.number()).optional(),
180
173
  })
174
+ .strict()
175
+ .optional(),
176
+ change: zod_1.z
177
+ .object({
178
+ users: zod_1.z.array(zod_1.z.number()).optional(),
179
+ groups: zod_1.z.array(zod_1.z.number()).optional(),
180
+ })
181
+ .strict()
181
182
  .optional(),
182
- merge: zod_1.z.boolean().optional(),
183
183
  })
184
+ // strict: a misspelled action ("read") must not silently collapse
185
+ // into {} and clear permissions/ownership.
186
+ .strict()
187
+ .optional()
188
+ .describe("For set_permissions: view/change permissions to apply. Omitted actions (view/change) and omitted users/groups lists are left untouched; an empty list [] removes all (unless merge is true). Omit entirely for owner-only changes."),
189
+ owner: zod_1.z
190
+ .number()
191
+ .nullable()
192
+ .optional()
193
+ .describe("For set_permissions: new owner user ID, or null to remove the owner. Unless merge is true, omitting owner also clears the current owner."),
194
+ merge: zod_1.z
195
+ .boolean()
184
196
  .optional()
185
- .transform(empty_1.objectNotEmpty),
197
+ .describe("For set_permissions: true adds to existing permissions and keeps the current owner; false (default) replaces the listed users/groups"),
186
198
  metadata_document_id: zod_1.z.number().optional(),
187
199
  delete_originals: zod_1.z.boolean().optional(),
188
200
  pages: zod_1.z.string().optional(),
@@ -192,12 +204,23 @@ function registerDocumentTools(server, api) {
192
204
  .optional()
193
205
  .describe("Must be true when method is 'delete' to confirm destructive operation"),
194
206
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
207
+ var _a;
195
208
  if (!api)
196
209
  throw new Error("Please configure API connection first");
197
210
  if (args.method === "delete" && !args.confirm) {
198
211
  throw new Error("Confirmation required for destructive operation. Set confirm: true to proceed.");
199
212
  }
213
+ if (args.method === "set_permissions" &&
214
+ !args.set_permissions &&
215
+ args.owner === undefined) {
216
+ throw new Error("Method 'set_permissions' requires set_permissions and/or owner.");
217
+ }
200
218
  const { documents, method, add_custom_fields, confirm } = args, parameters = __rest(args, ["documents", "method", "add_custom_fields", "confirm"]);
219
+ if (method === "set_permissions") {
220
+ // Paperless rejects (Paperless <= 3.0.5: crashes with a 500 on) a missing
221
+ // set_permissions key even for owner-only changes.
222
+ (_a = parameters.set_permissions) !== null && _a !== void 0 ? _a : (parameters.set_permissions = {});
223
+ }
201
224
  (0, monetary_1.validateCustomFields)(add_custom_fields);
202
225
  const resolvedCustomFields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, add_custom_fields, "stored");
203
226
  const response = yield api.bulkEditDocuments(documents, method, method === "delete"
@@ -378,6 +401,9 @@ function registerDocumentTools(server, api) {
378
401
  });
379
402
  return {
380
403
  content: [
404
+ // Legacy clients surface only content[].text and drop resource blocks
405
+ // entirely, so the URI is repeated here to stay reachable (issue #134).
406
+ { type: "text", text: uri },
381
407
  {
382
408
  type: "resource",
383
409
  resource: {
@@ -397,12 +423,16 @@ function registerDocumentTools(server, api) {
397
423
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
398
424
  if (!api)
399
425
  throw new Error("Please configure API connection first");
426
+ const uri = (0, resourceUri_1.buildThumbnailResourceUri)(args.id);
400
427
  return {
401
428
  content: [
429
+ // See download_document above: the URI is repeated as text for legacy
430
+ // clients that drop resource blocks.
431
+ { type: "text", text: uri },
402
432
  {
403
433
  type: "resource",
404
434
  resource: {
405
- uri: (0, resourceUri_1.buildThumbnailResourceUri)(args.id),
435
+ uri,
406
436
  // See download_document above: the binary thumbnail is fetched
407
437
  // lazily through resources/read instead of embedded here.
408
438
  text: "",
@@ -412,7 +442,7 @@ function registerDocumentTools(server, api) {
412
442
  ],
413
443
  };
414
444
  })));
415
- server.tool("update_document", "Update a specific document with new values. This tool allows you to modify any document field including title, correspondent, document type, storage path, tags, custom fields, and more. Only the fields you specify will be updated.", {
445
+ server.tool("update_document", "Update a specific document with new values (title, correspondent, document type, storage path, tags, custom fields, and more). Top-level fields you omit are left unchanged. IMPORTANT: custom_fields is the exception — see its parameter description; it replaces the document's entire custom-field set.", {
416
446
  id: zod_1.z.number().describe("The ID of the document to update"),
417
447
  title: zod_1.z
418
448
  .string()
@@ -469,13 +499,13 @@ function registerDocumentTools(server, api) {
469
499
  .describe(descriptions_1.CUSTOM_FIELD_VALUE_DESCRIPTION),
470
500
  }))
471
501
  .optional()
472
- .describe("Array of custom field values to assign"),
502
+ .describe("Custom field values for the document. ⚠️ REPLACES the document's entire custom-field set — any field not included here will be CLEARED. To update or add a single field without losing the others, first call get_document to read the existing custom_fields, then pass the full merged array. To add/set fields additively without fetching, use bulk_edit_documents with method 'modify_custom_fields' instead."),
473
503
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
474
504
  if (!api)
475
505
  throw new Error("Please configure API connection first");
476
506
  const { id } = args, updateData = __rest(args, ["id"]);
477
507
  (0, monetary_1.validateCustomFields)(updateData.custom_fields);
478
- updateData.custom_fields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, updateData.custom_fields, "index");
508
+ updateData.custom_fields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, updateData.custom_fields, "stored");
479
509
  const response = yield api.updateDocument(id, updateData);
480
510
  return (0, documentEnhancer_1.convertDocsWithNames)(response, api);
481
511
  })));
@@ -21,6 +21,7 @@ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
21
21
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
22
22
  const documents_1 = require("./documents");
23
23
  const documentQuery_1 = require("./utils/documentQuery");
24
+ const resourceUri_1 = require("./utils/resourceUri");
24
25
  function getQueryParams(queryString) {
25
26
  return new URLSearchParams(queryString.replace(/^\?/, ""));
26
27
  }
@@ -416,7 +417,7 @@ const OBJECT_SELECT_FIELD = {
416
417
  const [, data] = calls.updateDocument[0];
417
418
  strict_1.default.deepEqual(data.custom_fields, [{ field: 2, value: 0 }]);
418
419
  }));
419
- (0, node_test_1.test)("update_document translates a select label to its option index (Paperless 2.17+)", () => __awaiter(void 0, void 0, void 0, function* () {
420
+ (0, node_test_1.test)("update_document sends the option id for 2.17+ select fields (stored form)", () => __awaiter(void 0, void 0, void 0, function* () {
420
421
  const { api, calls } = createDocumentApi([OBJECT_SELECT_FIELD]);
421
422
  yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
422
423
  var _a;
@@ -427,7 +428,7 @@ const OBJECT_SELECT_FIELD = {
427
428
  strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error);
428
429
  }));
429
430
  const [, data] = calls.updateDocument[0];
430
- strict_1.default.deepEqual(data.custom_fields, [{ field: 3, value: 1 }]);
431
+ strict_1.default.deepEqual(data.custom_fields, [{ field: 3, value: "def456" }]);
431
432
  }));
432
433
  (0, node_test_1.test)("bulk_edit_documents translates a select label in add_custom_fields", () => __awaiter(void 0, void 0, void 0, function* () {
433
434
  const { api, calls } = createDocumentApi([LEGACY_SELECT_FIELD]);
@@ -480,3 +481,161 @@ const OBJECT_SELECT_FIELD = {
480
481
  strict_1.default.equal(calls.updateDocument.length, 0, "no document update should be sent when the option is invalid");
481
482
  }));
482
483
  });
484
+ (0, node_test_1.describe)("document resource reference tools", () => {
485
+ // Expected URIs come from the builders rather than literals: their exact
486
+ // format is already pinned by utils/resourceUri.test.ts, and what matters
487
+ // here is that the handler surfaces that URI in both content blocks.
488
+ const cases = [
489
+ {
490
+ tool: "download_document",
491
+ args: { id: 4 },
492
+ uri: (0, resourceUri_1.buildDocumentResourceUri)(4),
493
+ mimeType: "application/octet-stream",
494
+ },
495
+ {
496
+ tool: "download_document",
497
+ args: { id: 4, original: true },
498
+ uri: (0, resourceUri_1.buildDocumentResourceUri)(4, { original: true }),
499
+ mimeType: "application/octet-stream",
500
+ },
501
+ {
502
+ tool: "get_document_thumbnail",
503
+ args: { id: 123 },
504
+ uri: (0, resourceUri_1.buildThumbnailResourceUri)(123),
505
+ mimeType: "image/webp",
506
+ },
507
+ ];
508
+ for (const { tool, args, uri, mimeType } of cases) {
509
+ (0, node_test_1.test)(`${tool} ${JSON.stringify(args)} returns the URI as text beside the resource`, () => __awaiter(void 0, void 0, void 0, function* () {
510
+ const { api } = createDocumentApi([]);
511
+ let result;
512
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
513
+ result = (yield client.callTool({
514
+ name: tool,
515
+ arguments: args,
516
+ }));
517
+ }));
518
+ strict_1.default.ok(result && !result.isError, `${tool} failed`);
519
+ const [text, embedded] = result.content;
520
+ // Legacy clients read only content[].text (issue #134).
521
+ strict_1.default.equal(text.type, "text");
522
+ strict_1.default.equal(text.text, uri);
523
+ strict_1.default.equal(embedded.type, "resource");
524
+ const { resource } = embedded;
525
+ strict_1.default.equal(resource.uri, uri, "both blocks must reference the same URI");
526
+ strict_1.default.equal(resource.mimeType, mimeType);
527
+ }));
528
+ }
529
+ });
530
+ (0, node_test_1.describe)("bulk_edit_documents set_permissions", () => {
531
+ (0, node_test_1.test)("sends set_permissions, owner and merge at the top level of parameters", () => __awaiter(void 0, void 0, void 0, function* () {
532
+ const { api, calls } = createDocumentApi([]);
533
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
534
+ var _a;
535
+ const result = (yield client.callTool({
536
+ name: "bulk_edit_documents",
537
+ arguments: {
538
+ documents: [4103],
539
+ method: "set_permissions",
540
+ set_permissions: {
541
+ view: { users: [], groups: [10] },
542
+ change: { users: [], groups: [10] },
543
+ },
544
+ owner: 3,
545
+ merge: true,
546
+ },
547
+ }));
548
+ strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error);
549
+ }));
550
+ strict_1.default.equal(calls.bulkEditDocuments.length, 1);
551
+ const [documents, method, parameters] = calls.bulkEditDocuments[0];
552
+ strict_1.default.deepEqual(documents, [4103]);
553
+ strict_1.default.equal(method, "set_permissions");
554
+ // Paperless reads parameters["set_permissions"] directly; nesting it under
555
+ // another key makes the server crash with a KeyError (HTTP 500).
556
+ strict_1.default.deepEqual(parameters, {
557
+ set_permissions: {
558
+ view: { users: [], groups: [10] },
559
+ change: { users: [], groups: [10] },
560
+ },
561
+ owner: 3,
562
+ merge: true,
563
+ });
564
+ }));
565
+ (0, node_test_1.test)("forwards partial permissions without filling in omitted actions or lists", () => __awaiter(void 0, void 0, void 0, function* () {
566
+ const { api, calls } = createDocumentApi([]);
567
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
568
+ var _a;
569
+ const result = (yield client.callTool({
570
+ name: "bulk_edit_documents",
571
+ arguments: {
572
+ documents: [1],
573
+ method: "set_permissions",
574
+ set_permissions: { view: { groups: [10] } },
575
+ },
576
+ }));
577
+ strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error);
578
+ }));
579
+ // Paperless only touches the actions/lists that are present, and clears
580
+ // the owner when it is omitted without merge — so nothing may be added.
581
+ const [, , parameters] = calls.bulkEditDocuments[0];
582
+ strict_1.default.deepEqual(parameters, { set_permissions: { view: { groups: [10] } } });
583
+ }));
584
+ (0, node_test_1.test)("owner-only changes send an empty set_permissions object", () => __awaiter(void 0, void 0, void 0, function* () {
585
+ const { api, calls } = createDocumentApi([]);
586
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
587
+ var _a;
588
+ for (const owner of [3, null]) {
589
+ const result = (yield client.callTool({
590
+ name: "bulk_edit_documents",
591
+ arguments: { documents: [1], method: "set_permissions", owner },
592
+ }));
593
+ strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error);
594
+ }
595
+ }));
596
+ strict_1.default.deepEqual(calls.bulkEditDocuments.map(([, , parameters]) => parameters), [
597
+ { set_permissions: {}, owner: 3 },
598
+ { set_permissions: {}, owner: null },
599
+ ]);
600
+ }));
601
+ (0, node_test_1.test)("rejects method set_permissions with neither set_permissions nor owner", () => __awaiter(void 0, void 0, void 0, function* () {
602
+ const { api, calls } = createDocumentApi([]);
603
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
604
+ var _a, _b;
605
+ const result = (yield client.callTool({
606
+ name: "bulk_edit_documents",
607
+ arguments: { documents: [1], method: "set_permissions", merge: true },
608
+ }));
609
+ strict_1.default.ok(result.isError, "expected an error when both are missing");
610
+ strict_1.default.match((_b = (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error) !== null && _b !== void 0 ? _b : "", /set_permissions and\/or owner/);
611
+ }));
612
+ strict_1.default.equal(calls.bulkEditDocuments.length, 0);
613
+ }));
614
+ (0, node_test_1.test)("rejects malformed set_permissions input before calling Paperless", () => __awaiter(void 0, void 0, void 0, function* () {
615
+ const { api, calls } = createDocumentApi([]);
616
+ yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
617
+ for (const set_permissions of [
618
+ { view: { groups: ["ai-agents"] } },
619
+ { read: { groups: [10] } },
620
+ { view: [10] },
621
+ ]) {
622
+ // Depending on the installed MCP SDK version, an input-schema (zod)
623
+ // violation either rejects with a protocol error (-32602) or resolves
624
+ // with a CallToolResult carrying isError: true.
625
+ let rejected = false;
626
+ let result;
627
+ try {
628
+ result = (yield client.callTool({
629
+ name: "bulk_edit_documents",
630
+ arguments: { documents: [1], method: "set_permissions", set_permissions },
631
+ }));
632
+ }
633
+ catch (_a) {
634
+ rejected = true;
635
+ }
636
+ strict_1.default.ok(rejected || (result === null || result === void 0 ? void 0 : result.isError), `expected ${JSON.stringify(set_permissions)} to be rejected`);
637
+ }
638
+ }));
639
+ strict_1.default.equal(calls.bulkEditDocuments.length, 0);
640
+ }));
641
+ });
@@ -1,9 +1,11 @@
1
1
  import { PaperlessAPI } from "../../api/PaperlessAPI";
2
2
  import { CustomField, CustomFieldInstanceRequest, CustomFieldValue } from "../../api/types";
3
3
  /**
4
- * Encoding Paperless expects for a select value: `update_document` takes the
5
- * option index; `bulk_edit` writes `value_select` directly so it needs the
6
- * stored form (option id on 2.17+, index on pre-2.17 string options).
4
+ * Encoding Paperless expects for a select value. On the supported API versions
5
+ * (v9+) both `update_document` and `bulk_edit` take the option's stored form:
6
+ * the option id on 2.17+ fields, or the index on pre-2.17 string-option fields.
7
+ * The bare `index` form is retained for older API versions whose document
8
+ * endpoint accepted the option index directly.
7
9
  */
8
10
  export type SelectValueEncoding = "index" | "stored";
9
11
  /** Translates a select value (label, option id, or index) to the `encoding` Paperless expects; throws on no match. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baruchiro/paperless-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Model Context Protocol (MCP) server for interacting with Paperless-NGX document management system. Enables AI assistants to manage documents, tags, correspondents, and document types through the Paperless-NGX API.",
5
5
  "main": "build/index.js",
6
6
  "bin": {
package/paperless-mcp.dxt CHANGED
Binary file