@baruchiro/paperless-mcp 0.4.4 → 0.5.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
@@ -59,6 +59,15 @@ Add these to your MCP config file:
59
59
  - `your-api-token` with the token you just generated
60
60
  - `https://your-public-domain.com` with your public Paperless-NGX URL (optional, falls back to PAPERLESS_URL)
61
61
 
62
+ ### Environment Variables
63
+
64
+ | Variable | Required | Default | Description |
65
+ |---|---|---|---|
66
+ | `PAPERLESS_URL` | Yes | — | Base URL of your Paperless-NGX instance |
67
+ | `PAPERLESS_API_KEY` | Yes | — | API token from your Paperless-NGX profile |
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`. |
70
+
62
71
  That's it! Now you can ask Claude to help you manage your Paperless-NGX documents.
63
72
 
64
73
  ### Example Usage
@@ -215,9 +224,20 @@ bulk_edit_documents({
215
224
  bulk_edit_documents({
216
225
  documents: [12, 13],
217
226
  method: "modify_custom_fields",
218
- add_custom_fields: {
219
- "2": "שנה"
220
- }
227
+ add_custom_fields: [
228
+ { field: 2, value: "year" }
229
+ ],
230
+ remove_custom_fields: []
231
+ })
232
+
233
+ // Set an empty custom field value, e.g. a date field used as a pending marker
234
+ bulk_edit_documents({
235
+ documents: [14],
236
+ method: "modify_custom_fields",
237
+ add_custom_fields: [
238
+ { field: 9, value: "" }
239
+ ],
240
+ remove_custom_fields: []
221
241
  })
222
242
  ```
223
243
 
@@ -483,6 +503,22 @@ npm run start -- <baseUrl> <token> --http --port 3000
483
503
  - Each request is handled statelessly, following the [StreamableHTTPServerTransport](https://github.com/modelcontextprotocol/typescript-sdk) pattern.
484
504
  - GET and DELETE requests to `/mcp` will return 405 Method Not Allowed.
485
505
 
506
+ #### Per-request API token (HTTP/Docker mode)
507
+
508
+ In HTTP mode, clients can supply their own Paperless-NGX API token via the standard `Authorization` header instead of (or in addition to) the server-configured `PAPERLESS_API_KEY`. The client-supplied token takes precedence.
509
+
510
+ ```
511
+ Authorization: Bearer <paperless-ngx-api-token>
512
+ ```
513
+
514
+ | Scenario | Token used |
515
+ |---|---|
516
+ | Client sends `Authorization: Bearer <tok>` | `<tok>` (client-supplied, takes precedence) |
517
+ | No header, `PAPERLESS_API_KEY` env var set | `PAPERLESS_API_KEY` from env |
518
+ | No header, no env var | `401 Unauthorized` |
519
+
520
+ This allows a single server instance to serve multiple users, each authenticating with their own Paperless-NGX token. The same behaviour applies to both `/mcp` and `/sse` endpoints.
521
+
486
522
  <details>
487
523
  <summary>Docker Deployment</summary>
488
524
 
@@ -3,6 +3,7 @@ import { BulkEditDocumentsResult, BulkEditParameters, Correspondent, CustomField
3
3
  export declare class PaperlessAPI {
4
4
  private readonly baseUrl;
5
5
  private readonly token;
6
+ private readonly apiVersion;
6
7
  constructor(baseUrl: string, token: string);
7
8
  request<T = any>(path: string, options?: RequestInit): Promise<T>;
8
9
  bulkEditDocuments(documents: number[], method: string, parameters?: BulkEditParameters): Promise<BulkEditDocumentsResult>;
@@ -22,13 +22,14 @@ 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
26
  }
26
27
  request(path_1) {
27
28
  return __awaiter(this, arguments, void 0, function* (path, options = {}) {
28
- var _a, _b;
29
+ var _a, _b, _c;
29
30
  const url = `${this.baseUrl}/api${path}`;
30
31
  const isJson = !options.body || typeof options.body === "string";
31
- const mergedHeaders = Object.assign(Object.assign({ Authorization: `Token ${this.token}`, Accept: "application/json; version=5", "Accept-Language": "en-US,en;q=0.9" }, (isJson ? { "Content-Type": "application/json" } : {})), (0, utils_1.headersToObject)(options.headers));
32
+ const mergedHeaders = Object.assign(Object.assign({ Authorization: `Token ${this.token}`, Accept: `application/json; version=${this.apiVersion}`, "Accept-Language": "en-US,en;q=0.9" }, (isJson ? { "Content-Type": "application/json" } : {})), (0, utils_1.headersToObject)(options.headers));
32
33
  try {
33
34
  const response = yield (0, axios_1.default)({
34
35
  url,
@@ -54,13 +55,17 @@ class PaperlessAPI {
54
55
  return body;
55
56
  }
56
57
  catch (error) {
58
+ if (axios_1.default.isAxiosError(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 406) {
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+).`);
61
+ }
57
62
  console.error({
58
63
  error: "Error executing request",
59
64
  message: error instanceof Error ? error.message : String(error),
60
65
  url,
61
66
  options,
62
- responseData: (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.data,
63
- status: (_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.status,
67
+ responseData: axios_1.default.isAxiosError(error) ? (_b = error.response) === null || _b === void 0 ? void 0 : _b.data : undefined,
68
+ status: axios_1.default.isAxiosError(error) ? (_c = error.response) === null || _c === void 0 ? void 0 : _c.status : undefined,
64
69
  });
65
70
  throw error;
66
71
  }
@@ -81,6 +86,7 @@ class PaperlessAPI {
81
86
  }
82
87
  postDocument(document_1, filename_1) {
83
88
  return __awaiter(this, arguments, void 0, function* (document, filename, metadata = {}) {
89
+ var _a;
84
90
  const formData = new form_data_1.default();
85
91
  formData.append("document", document, { filename });
86
92
  // Add optional metadata fields
@@ -103,13 +109,22 @@ class PaperlessAPI {
103
109
  if (metadata.custom_fields) {
104
110
  metadata.custom_fields.forEach((field) => formData.append("custom_fields", String(field)));
105
111
  }
106
- const response = yield axios_1.default.post(`${this.baseUrl}/api/documents/post_document/`, formData, {
107
- headers: Object.assign({ Authorization: `Token ${this.token}` }, formData.getHeaders()),
108
- });
109
- if (response.status < 200 || response.status >= 300) {
110
- throw new Error(`HTTP error! status: ${response.status}`);
112
+ try {
113
+ const response = yield axios_1.default.post(`${this.baseUrl}/api/documents/post_document/`, formData, {
114
+ headers: Object.assign({ Authorization: `Token ${this.token}`, Accept: `application/json; version=${this.apiVersion}` }, formData.getHeaders()),
115
+ });
116
+ if (response.status < 200 || response.status >= 300) {
117
+ throw new Error(`HTTP error! status: ${response.status}`);
118
+ }
119
+ return response.data;
120
+ }
121
+ catch (error) {
122
+ if (axios_1.default.isAxiosError(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 406) {
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+).`);
125
+ }
126
+ throw error;
111
127
  }
112
- return response.data;
113
128
  });
114
129
  }
115
130
  getDocuments() {
@@ -176,7 +191,7 @@ class PaperlessAPI {
176
191
  updateTag(id, data) {
177
192
  return __awaiter(this, void 0, void 0, function* () {
178
193
  return this.request(`/tags/${id}/`, {
179
- method: "PUT",
194
+ method: "PATCH",
180
195
  body: JSON.stringify(data),
181
196
  });
182
197
  });
@@ -213,7 +228,7 @@ class PaperlessAPI {
213
228
  updateCorrespondent(id, data) {
214
229
  return __awaiter(this, void 0, void 0, function* () {
215
230
  return this.request(`/correspondents/${id}/`, {
216
- method: "PUT",
231
+ method: "PATCH",
217
232
  body: JSON.stringify(data),
218
233
  });
219
234
  });
@@ -242,7 +257,7 @@ class PaperlessAPI {
242
257
  updateDocumentType(id, data) {
243
258
  return __awaiter(this, void 0, void 0, function* () {
244
259
  return this.request(`/document_types/${id}/`, {
245
- method: "PUT",
260
+ method: "PATCH",
246
261
  body: JSON.stringify(data),
247
262
  });
248
263
  });
@@ -276,7 +291,7 @@ class PaperlessAPI {
276
291
  updateCustomField(id, data) {
277
292
  return __awaiter(this, void 0, void 0, function* () {
278
293
  return this.request(`/custom_fields/${id}/`, {
279
- method: "PUT",
294
+ method: "PATCH",
280
295
  body: JSON.stringify(data),
281
296
  });
282
297
  });
@@ -24,14 +24,13 @@ exports.convertDocsWithNames = convertDocsWithNames;
24
24
  function convertDocsWithNames(input, api) {
25
25
  return __awaiter(this, void 0, void 0, function* () {
26
26
  if ("results" in input) {
27
- const enhancedResults = yield enhanceDocumentsArray(input.results || [], api);
27
+ const { all, results } = input, paginationMeta = __rest(input, ["all", "results"]);
28
+ const enhancedResults = yield enhanceDocumentsArray(results || [], api);
28
29
  return {
29
30
  content: [
30
31
  {
31
32
  type: "text",
32
- text: (enhancedResults === null || enhancedResults === void 0 ? void 0 : enhancedResults.length)
33
- ? JSON.stringify(Object.assign(Object.assign({}, input), { results: enhancedResults }))
34
- : "No documents found",
33
+ text: JSON.stringify(Object.assign(Object.assign({}, paginationMeta), { results: enhancedResults })),
35
34
  },
36
35
  ],
37
36
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ const strict_1 = __importDefault(require("node:assert/strict"));
16
+ const node_test_1 = require("node:test");
17
+ const documentEnhancer_1 = require("./documentEnhancer");
18
+ const paperlessApi_1 = require("../test/mocks/paperlessApi");
19
+ const LARGE_DOCUMENT_COUNT = 709;
20
+ const MAX_RESPONSE_SIZE_BYTES = 2000;
21
+ function getTextContent(result) {
22
+ var _a;
23
+ const item = (_a = result.content) === null || _a === void 0 ? void 0 : _a[0];
24
+ if (!item || item.type !== "text") {
25
+ throw new Error("Expected text content");
26
+ }
27
+ return item.text;
28
+ }
29
+ (0, node_test_1.test)("convertDocsWithNames omits `all` and keeps paginated JSON shape", () => __awaiter(void 0, void 0, void 0, function* () {
30
+ const docsResponse = {
31
+ count: 2,
32
+ next: null,
33
+ previous: null,
34
+ all: [1, 2],
35
+ results: [(0, paperlessApi_1.createDocument)(), (0, paperlessApi_1.createDocument)({ id: 2, title: "Document 2" })],
36
+ };
37
+ const result = yield (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, (0, paperlessApi_1.createPaperlessApiMock)());
38
+ const parsed = JSON.parse(getTextContent(result));
39
+ strict_1.default.ok(!("all" in parsed));
40
+ strict_1.default.deepEqual(parsed.results.map((doc) => doc.id), [1, 2]);
41
+ strict_1.default.ok(!("content" in parsed.results[0]));
42
+ }));
43
+ (0, node_test_1.test)("convertDocsWithNames keeps responses small when source has large `all` arrays", () => __awaiter(void 0, void 0, void 0, function* () {
44
+ const docsResponse = {
45
+ count: LARGE_DOCUMENT_COUNT,
46
+ next: "http://localhost:8000/api/documents/?page=2",
47
+ previous: null,
48
+ all: Array.from({ length: LARGE_DOCUMENT_COUNT }, (_, index) => index + 1),
49
+ results: [
50
+ (0, paperlessApi_1.createDocument)({
51
+ id: 123,
52
+ title: "Large all payload case",
53
+ content: "x".repeat(2700),
54
+ }),
55
+ ],
56
+ };
57
+ const result = yield (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, (0, paperlessApi_1.createPaperlessApiMock)());
58
+ const responseText = getTextContent(result);
59
+ strict_1.default.ok(responseText.length < MAX_RESPONSE_SIZE_BYTES);
60
+ const parsed = JSON.parse(responseText);
61
+ strict_1.default.ok(!("all" in parsed));
62
+ strict_1.default.ok(!("content" in parsed.results[0]));
63
+ }));
64
+ (0, node_test_1.test)("convertDocsWithNames returns paginated JSON for empty multi-document results", () => __awaiter(void 0, void 0, void 0, function* () {
65
+ const docsResponse = {
66
+ count: 0,
67
+ next: null,
68
+ previous: null,
69
+ all: [],
70
+ results: [],
71
+ };
72
+ const result = yield (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, (0, paperlessApi_1.createPaperlessApiMock)());
73
+ const parsed = JSON.parse(getTextContent(result));
74
+ strict_1.default.deepEqual(parsed, {
75
+ count: 0,
76
+ next: null,
77
+ previous: null,
78
+ results: [],
79
+ });
80
+ }));
@@ -30,13 +30,14 @@ export interface CustomField {
30
30
  extra_data?: Record<string, unknown> | null;
31
31
  document_count: number;
32
32
  }
33
+ export type CustomFieldValue = string | number | boolean | number[] | null;
33
34
  export interface CustomFieldInstance {
34
35
  field: number;
35
- value: string | number | boolean | object | null;
36
+ value: CustomFieldValue;
36
37
  }
37
38
  export interface CustomFieldInstanceRequest {
38
39
  field: number;
39
- value: string | number | boolean | object | null;
40
+ value: CustomFieldValue;
40
41
  }
41
42
  export interface PaginationResponse<T> {
42
43
  count: number;
@@ -128,8 +129,7 @@ export interface BulkEditDocumentsResult {
128
129
  result: string;
129
130
  }
130
131
  export interface BulkEditParameters {
131
- assign_custom_fields?: number[];
132
- assign_custom_fields_values?: CustomFieldInstanceRequest[];
132
+ add_custom_fields?: Record<string, CustomFieldInstanceRequest["value"]>;
133
133
  remove_custom_fields?: number[];
134
134
  add_tags?: number[];
135
135
  remove_tags?: number[];
package/build/index.js CHANGED
@@ -13,18 +13,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
14
14
  };
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
- const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
17
16
  const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
18
17
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
19
18
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
20
19
  const express_1 = __importDefault(require("express"));
21
20
  const node_util_1 = require("node:util");
22
- const PaperlessAPI_1 = require("./api/PaperlessAPI");
23
- const correspondents_1 = require("./tools/correspondents");
24
- const customFields_1 = require("./tools/customFields");
25
- const documents_1 = require("./tools/documents");
26
- const documentTypes_1 = require("./tools/documentTypes");
27
- const tags_1 = require("./tools/tags");
21
+ const server_1 = require("./server");
28
22
  const { version } = require("../package.json");
29
23
  const { values: { baseUrl, token, http: useHttp, port, publicUrl }, } = (0, node_util_1.parseArgs)({
30
24
  options: {
@@ -40,46 +34,39 @@ const resolvedBaseUrl = baseUrl || process.env.PAPERLESS_URL;
40
34
  const resolvedToken = token || process.env.PAPERLESS_API_KEY;
41
35
  const resolvedPublicUrl = publicUrl || process.env.PAPERLESS_PUBLIC_URL || resolvedBaseUrl;
42
36
  const resolvedPort = port ? parseInt(port, 10) : 3000;
43
- if (!resolvedBaseUrl || !resolvedToken) {
37
+ if (!resolvedBaseUrl) {
44
38
  console.error("Usage: paperless-mcp --baseUrl <url> --token <token> [--http] [--port <port>] [--publicUrl <url>]");
45
39
  console.error("Or set PAPERLESS_URL and PAPERLESS_API_KEY environment variables.");
46
40
  process.exit(1);
47
41
  }
42
+ if (!useHttp && !resolvedToken) {
43
+ console.error("Usage: paperless-mcp --baseUrl <url> --token <token> [--http] [--port <port>] [--publicUrl <url>]");
44
+ console.error("Or set PAPERLESS_URL and PAPERLESS_API_KEY environment variables.");
45
+ process.exit(1);
46
+ }
47
+ function buildServer(requestToken) {
48
+ return (0, server_1.createMcpServer)({
49
+ baseUrl: resolvedBaseUrl,
50
+ token: requestToken,
51
+ version,
52
+ publicUrl: resolvedPublicUrl,
53
+ });
54
+ }
48
55
  function main() {
49
56
  return __awaiter(this, void 0, void 0, function* () {
50
- // Initialize API client and server once
51
- const api = new PaperlessAPI_1.PaperlessAPI(resolvedBaseUrl, resolvedToken);
52
- const server = new mcp_js_1.McpServer({ name: "paperless-ngx", version }, {
53
- instructions: `
54
- Paperless-NGX MCP Server Instructions
55
-
56
- ⚠️ CRITICAL: Always differentiate between operations on specific documents vs operations on the entire system:
57
-
58
- - REMOVE operations (e.g., remove_tag in bulk_edit_documents): Affect only the specified documents, items remain in the system
59
- - DELETE operations (e.g., delete_tag, delete_correspondent): Permanently delete items from the entire system, affecting ALL documents that use them
60
-
61
- When a user asks to "remove" something, prefer operations that affect specific documents. Only use DELETE operations when explicitly asked to delete from the system.
62
-
63
- To view documents in your Paperless-NGX web interface, construct URLs using this pattern:
64
- ${resolvedPublicUrl}/documents/{document_id}/
65
-
66
- Example: If your base URL is "http://localhost:8000", the web interface URL would be "http://localhost:8000/documents/123/" for document ID 123.
67
-
68
- The document tools return JSON data with document IDs that you can use to construct these URLs.
69
- `,
70
- });
71
- (0, documents_1.registerDocumentTools)(server, api);
72
- (0, tags_1.registerTagTools)(server, api);
73
- (0, correspondents_1.registerCorrespondentTools)(server, api);
74
- (0, documentTypes_1.registerDocumentTypeTools)(server, api);
75
- (0, customFields_1.registerCustomFieldTools)(server, api);
76
57
  if (useHttp) {
77
58
  const app = (0, express_1.default)();
78
59
  app.use(express_1.default.json());
79
60
  // Store transports for each session
80
61
  const sseTransports = {};
81
62
  app.post("/mcp", (req, res) => __awaiter(this, void 0, void 0, function* () {
63
+ const requestToken = (0, server_1.getBearerToken)(req, resolvedToken);
64
+ if (!requestToken) {
65
+ (0, server_1.sendUnauthorized)(res);
66
+ return;
67
+ }
82
68
  try {
69
+ const server = buildServer(requestToken);
83
70
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
84
71
  sessionIdGenerator: undefined,
85
72
  });
@@ -125,7 +112,13 @@ The document tools return JSON data with document IDs that you can use to constr
125
112
  }));
126
113
  app.get("/sse", (req, res) => __awaiter(this, void 0, void 0, function* () {
127
114
  console.log("SSE request received");
115
+ const requestToken = (0, server_1.getBearerToken)(req, resolvedToken);
116
+ if (!requestToken) {
117
+ (0, server_1.sendUnauthorized)(res);
118
+ return;
119
+ }
128
120
  try {
121
+ const server = buildServer(requestToken);
129
122
  const transport = new sse_js_1.SSEServerTransport("/messages", res);
130
123
  sseTransports[transport.sessionId] = transport;
131
124
  res.on("close", () => {
@@ -164,6 +157,7 @@ The document tools return JSON data with document IDs that you can use to constr
164
157
  // await new Promise((resolve) => setTimeout(resolve, 1000000));
165
158
  }
166
159
  else {
160
+ const server = buildServer(resolvedToken);
167
161
  const transport = new stdio_js_1.StdioServerTransport();
168
162
  yield server.connect(transport);
169
163
  }
@@ -0,0 +1,11 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type express from "express";
3
+ export interface CreateMcpServerOptions {
4
+ baseUrl: string;
5
+ token: string;
6
+ version: string;
7
+ publicUrl: string;
8
+ }
9
+ export declare function createMcpServer({ baseUrl, token, version, publicUrl, }: CreateMcpServerOptions): McpServer;
10
+ export declare function getBearerToken(req: express.Request, fallbackToken?: string): string | undefined;
11
+ export declare function sendUnauthorized(res: express.Response): void;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createMcpServer = createMcpServer;
4
+ exports.getBearerToken = getBearerToken;
5
+ exports.sendUnauthorized = sendUnauthorized;
6
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
7
+ const PaperlessAPI_1 = require("./api/PaperlessAPI");
8
+ const correspondents_1 = require("./tools/correspondents");
9
+ const customFields_1 = require("./tools/customFields");
10
+ const documents_1 = require("./tools/documents");
11
+ const documentTypes_1 = require("./tools/documentTypes");
12
+ const tags_1 = require("./tools/tags");
13
+ function createMcpServer({ baseUrl, token, version, publicUrl, }) {
14
+ const api = new PaperlessAPI_1.PaperlessAPI(baseUrl, token);
15
+ const server = new mcp_js_1.McpServer({ name: "paperless-ngx", version }, { instructions: buildInstructions(publicUrl) });
16
+ (0, documents_1.registerDocumentTools)(server, api);
17
+ (0, tags_1.registerTagTools)(server, api);
18
+ (0, correspondents_1.registerCorrespondentTools)(server, api);
19
+ (0, documentTypes_1.registerDocumentTypeTools)(server, api);
20
+ (0, customFields_1.registerCustomFieldTools)(server, api);
21
+ return server;
22
+ }
23
+ function getBearerToken(req, fallbackToken) {
24
+ const authHeader = req.headers["authorization"];
25
+ if (authHeader && authHeader.startsWith("Bearer ")) {
26
+ return authHeader.slice(7);
27
+ }
28
+ return fallbackToken || undefined;
29
+ }
30
+ function sendUnauthorized(res) {
31
+ res
32
+ .status(401)
33
+ .set("WWW-Authenticate", 'Bearer realm="paperless-mcp"')
34
+ .end();
35
+ }
36
+ function buildInstructions(publicUrl) {
37
+ return `
38
+ Paperless-NGX MCP Server Instructions
39
+
40
+ ⚠️ CRITICAL: Always differentiate between operations on specific documents vs operations on the entire system:
41
+
42
+ - REMOVE operations (e.g., remove_tag in bulk_edit_documents): Affect only the specified documents, items remain in the system
43
+ - DELETE operations (e.g., delete_tag, delete_correspondent): Permanently delete items from the entire system, affecting ALL documents that use them
44
+
45
+ When a user asks to "remove" something, prefer operations that affect specific documents. Only use DELETE operations when explicitly asked to delete from the system.
46
+
47
+ To view documents in your Paperless-NGX web interface, construct URLs using this pattern:
48
+ ${publicUrl}/documents/{document_id}/
49
+
50
+ Example: If your base URL is "http://localhost:8000", the web interface URL would be "http://localhost:8000/documents/123/" for document ID 123.
51
+
52
+ The document tools return JSON data with document IDs that you can use to construct these URLs.
53
+ `;
54
+ }
@@ -0,0 +1,4 @@
1
+ import { PaperlessAPI } from "../../api/PaperlessAPI";
2
+ import { Document } from "../../api/types";
3
+ export declare function createPaperlessApiMock(): PaperlessAPI;
4
+ export declare function createDocument(overrides?: Partial<Document>): Document;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createPaperlessApiMock = createPaperlessApiMock;
13
+ exports.createDocument = createDocument;
14
+ function emptyPaginationResponse(results = []) {
15
+ return {
16
+ count: results.length,
17
+ next: null,
18
+ previous: null,
19
+ all: [],
20
+ results,
21
+ };
22
+ }
23
+ function createPaperlessApiMock() {
24
+ return {
25
+ getCorrespondents: () => __awaiter(this, void 0, void 0, function* () { return emptyPaginationResponse(); }),
26
+ getDocumentTypes: () => __awaiter(this, void 0, void 0, function* () { return emptyPaginationResponse(); }),
27
+ getTags: () => __awaiter(this, void 0, void 0, function* () { return emptyPaginationResponse(); }),
28
+ getCustomFields: () => __awaiter(this, void 0, void 0, function* () { return emptyPaginationResponse(); }),
29
+ };
30
+ }
31
+ function createDocument(overrides = {}) {
32
+ return Object.assign({ id: 1, correspondent: null, document_type: null, storage_path: null, title: "Document 1", content: "OCR content", tags: [], created: "2026-01-01T00:00:00.000Z", created_date: "2026-01-01", modified: "2026-01-01T00:00:00.000Z", added: "2026-01-01T00:00:00.000Z", deleted_at: null, archive_serial_number: null, original_file_name: "doc1.pdf", archived_file_name: "2026/doc1.pdf", owner: null, user_can_change: true, is_shared_by_requester: false, notes: [], custom_fields: [], page_count: 1, mime_type: "application/pdf" }, overrides);
33
+ }
@@ -1,3 +1,36 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
2
2
  import { PaperlessAPI } from "../api/PaperlessAPI";
3
+ export type BulkCustomFieldValue = string | number | boolean | number[] | null;
4
+ export type BulkCustomFieldUpdate = {
5
+ field: number;
6
+ value: BulkCustomFieldValue;
7
+ };
8
+ export type BulkCustomFieldParameters = {
9
+ add_custom_fields?: Record<string, BulkCustomFieldValue>;
10
+ remove_custom_fields?: number[];
11
+ };
12
+ /**
13
+ * Builds Paperless-NGX bulk edit parameters from base parameters plus optional
14
+ * custom field updates.
15
+ *
16
+ * Paperless-NGX expects custom field bulk updates as an `add_custom_fields`
17
+ * record keyed by custom field id. `addCustomFields` is accepted as an array for
18
+ * the MCP tool schema and transformed into that id-to-value record while
19
+ * preserving supported value types, including `number[]` document links and
20
+ * `null` resets. Passing an empty `addCustomFields` array intentionally produces
21
+ * an empty `add_custom_fields` record.
22
+ *
23
+ * When `includeCustomFieldDefaults` is true, the function also initializes
24
+ * `add_custom_fields` and `remove_custom_fields` with empty defaults using
25
+ * nullish coalescing (`??=`). This keeps the `modify_custom_fields` method's
26
+ * payload shape acceptable to Paperless even when no field values are supplied.
27
+ *
28
+ * @param parameters - Base bulk edit parameters to include in the result.
29
+ * @param addCustomFields - Optional custom field updates to map by field id.
30
+ * @param includeCustomFieldDefaults - Whether to include empty custom field
31
+ * defaults required by `modify_custom_fields`.
32
+ * @returns The merged API parameters with custom field updates transformed into
33
+ * Paperless-NGX's `add_custom_fields` record shape.
34
+ */
35
+ export declare function buildBulkEditParameters<T extends Record<string, unknown>>(parameters: T, addCustomFields?: BulkCustomFieldUpdate[], includeCustomFieldDefaults?: boolean): T & BulkCustomFieldParameters;
3
36
  export declare function registerDocumentTools(server: McpServer, api: PaperlessAPI): void;
@@ -20,6 +20,7 @@ var __rest = (this && this.__rest) || function (s, e) {
20
20
  return t;
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.buildBulkEditParameters = buildBulkEditParameters;
23
24
  exports.registerDocumentTools = registerDocumentTools;
24
25
  const zod_1 = require("zod");
25
26
  const documentEnhancer_1 = require("../api/documentEnhancer");
@@ -27,6 +28,44 @@ const empty_1 = require("./utils/empty");
27
28
  const middlewares_1 = require("./utils/middlewares");
28
29
  const monetary_1 = require("./utils/monetary");
29
30
  const descriptions_1 = require("./utils/descriptions");
31
+ /**
32
+ * Builds Paperless-NGX bulk edit parameters from base parameters plus optional
33
+ * custom field updates.
34
+ *
35
+ * Paperless-NGX expects custom field bulk updates as an `add_custom_fields`
36
+ * record keyed by custom field id. `addCustomFields` is accepted as an array for
37
+ * the MCP tool schema and transformed into that id-to-value record while
38
+ * preserving supported value types, including `number[]` document links and
39
+ * `null` resets. Passing an empty `addCustomFields` array intentionally produces
40
+ * an empty `add_custom_fields` record.
41
+ *
42
+ * When `includeCustomFieldDefaults` is true, the function also initializes
43
+ * `add_custom_fields` and `remove_custom_fields` with empty defaults using
44
+ * nullish coalescing (`??=`). This keeps the `modify_custom_fields` method's
45
+ * payload shape acceptable to Paperless even when no field values are supplied.
46
+ *
47
+ * @param parameters - Base bulk edit parameters to include in the result.
48
+ * @param addCustomFields - Optional custom field updates to map by field id.
49
+ * @param includeCustomFieldDefaults - Whether to include empty custom field
50
+ * defaults required by `modify_custom_fields`.
51
+ * @returns The merged API parameters with custom field updates transformed into
52
+ * Paperless-NGX's `add_custom_fields` record shape.
53
+ */
54
+ function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false) {
55
+ var _a, _b;
56
+ const apiParameters = Object.assign({}, parameters);
57
+ if (addCustomFields) {
58
+ apiParameters.add_custom_fields = Object.fromEntries(addCustomFields.map((customField) => [
59
+ String(customField.field),
60
+ customField.value,
61
+ ]));
62
+ }
63
+ if (includeCustomFieldDefaults) {
64
+ (_a = apiParameters.add_custom_fields) !== null && _a !== void 0 ? _a : (apiParameters.add_custom_fields = {});
65
+ (_b = apiParameters.remove_custom_fields) !== null && _b !== void 0 ? _b : (apiParameters.remove_custom_fields = []);
66
+ }
67
+ return apiParameters;
68
+ }
30
69
  function registerDocumentTools(server, api) {
31
70
  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.", {
32
71
  documents: zod_1.z.array(zod_1.z.number()),
@@ -104,13 +143,9 @@ function registerDocumentTools(server, api) {
104
143
  }
105
144
  const { documents, method, add_custom_fields, confirm } = args, parameters = __rest(args, ["documents", "method", "add_custom_fields", "confirm"]);
106
145
  (0, monetary_1.validateCustomFields)(add_custom_fields);
107
- // Transform add_custom_fields into the two separate API parameters
108
- const apiParameters = Object.assign({}, parameters);
109
- if (add_custom_fields && add_custom_fields.length > 0) {
110
- apiParameters.assign_custom_fields = add_custom_fields.map((cf) => cf.field);
111
- apiParameters.assign_custom_fields_values = add_custom_fields;
112
- }
113
- const response = yield api.bulkEditDocuments(documents, method, apiParameters);
146
+ const response = yield api.bulkEditDocuments(documents, method, method === "delete"
147
+ ? {}
148
+ : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields"));
114
149
  return {
115
150
  content: [
116
151
  {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const strict_1 = __importDefault(require("node:assert/strict"));
7
+ const node_test_1 = require("node:test");
8
+ const documents_1 = require("./documents");
9
+ (0, node_test_1.test)("buildBulkEditParameters sends Paperless bulk custom fields as id:value map", () => {
10
+ const parameters = (0, documents_1.buildBulkEditParameters)({ remove_custom_fields: [] }, [
11
+ { field: 9, value: "" },
12
+ { field: 10, value: "2026-05-14" },
13
+ ]);
14
+ strict_1.default.deepEqual(parameters, {
15
+ remove_custom_fields: [],
16
+ add_custom_fields: {
17
+ "9": "",
18
+ "10": "2026-05-14",
19
+ },
20
+ });
21
+ strict_1.default.ok(!("assign_custom_fields" in parameters));
22
+ strict_1.default.ok(!("assign_custom_fields_values" in parameters));
23
+ });
24
+ (0, node_test_1.test)("buildBulkEditParameters preserves null custom field values", () => {
25
+ const parameters = (0, documents_1.buildBulkEditParameters)({}, [{ field: 9, value: null }]);
26
+ strict_1.default.deepEqual(parameters, {
27
+ add_custom_fields: {
28
+ "9": null,
29
+ },
30
+ });
31
+ });
32
+ (0, node_test_1.test)("buildBulkEditParameters includes Paperless-required empty custom field keys", () => {
33
+ const parameters = (0, documents_1.buildBulkEditParameters)({}, undefined, true);
34
+ strict_1.default.deepEqual(parameters, {
35
+ add_custom_fields: {},
36
+ remove_custom_fields: [],
37
+ });
38
+ });
39
+ (0, node_test_1.test)("buildBulkEditParameters preserves an empty custom fields array", () => {
40
+ const parameters = (0, documents_1.buildBulkEditParameters)({}, []);
41
+ strict_1.default.deepEqual(parameters, {
42
+ add_custom_fields: {},
43
+ });
44
+ strict_1.default.ok(!("remove_custom_fields" in parameters));
45
+ });
46
+ (0, node_test_1.test)("buildBulkEditParameters preserves an empty custom fields array with defaults", () => {
47
+ const parameters = (0, documents_1.buildBulkEditParameters)({}, [], true);
48
+ strict_1.default.deepEqual(parameters, {
49
+ add_custom_fields: {},
50
+ remove_custom_fields: [],
51
+ });
52
+ });
53
+ (0, node_test_1.test)("buildBulkEditParameters combines base parameters with custom fields", () => {
54
+ const parameters = (0, documents_1.buildBulkEditParameters)({ add_tags: [3], remove_tags: [1, 2] }, [{ field: 9, value: "pending" }]);
55
+ strict_1.default.deepEqual(parameters, {
56
+ add_tags: [3],
57
+ remove_tags: [1, 2],
58
+ add_custom_fields: {
59
+ "9": "pending",
60
+ },
61
+ });
62
+ });
63
+ (0, node_test_1.test)("buildBulkEditParameters preserves supported custom field value types", () => {
64
+ const parameters = (0, documents_1.buildBulkEditParameters)({}, [
65
+ { field: 1, value: 42 },
66
+ { field: 2, value: true },
67
+ { field: 3, value: "" },
68
+ { field: 4, value: null },
69
+ { field: 5, value: [123, 456] },
70
+ ]);
71
+ strict_1.default.deepEqual(parameters.add_custom_fields, {
72
+ "1": 42,
73
+ "2": true,
74
+ "3": "",
75
+ "4": null,
76
+ "5": [123, 456],
77
+ });
78
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baruchiro/paperless-mcp",
3
- "version": "0.4.4",
3
+ "version": "0.5.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