@baruchiro/paperless-mcp 1.0.0 → 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.
@@ -1,8 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import type { ListResourcesResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types";
2
+ import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types";
3
3
  import { PaperlessAPI } from "../api/PaperlessAPI";
4
4
  type TemplateVariables = Record<string, string | string[]>;
5
5
  export declare function registerDocumentResources(server: McpServer, api: PaperlessAPI): void;
6
- export declare function listDocumentResources(api: PaperlessAPI): Promise<ListResourcesResult>;
7
6
  export declare function readDocumentResource(api: PaperlessAPI, uri: URL, variables: TemplateVariables): Promise<ReadResourceResult>;
8
7
  export {};
@@ -10,13 +10,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.registerDocumentResources = registerDocumentResources;
13
- exports.listDocumentResources = listDocumentResources;
14
13
  exports.readDocumentResource = readDocumentResource;
15
14
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
16
- const resourceUri_1 = require("../tools/utils/resourceUri");
17
15
  function registerDocumentResources(server, api) {
18
16
  server.resource("paperless-document-resource", new mcp_js_1.ResourceTemplate("paperless://documents/{id}/{resource}", {
19
- list: () => __awaiter(this, void 0, void 0, function* () { return listDocumentResources(api); }),
17
+ list: undefined,
20
18
  }), (uri, variables) => __awaiter(this, void 0, void 0, function* () { return readDocumentResource(api, uri, variables); }));
21
19
  server.resource("paperless-document-original-download", new mcp_js_1.ResourceTemplate("paperless://documents/{id}/download{?original}", {
22
20
  list: undefined,
@@ -24,22 +22,6 @@ function registerDocumentResources(server, api) {
24
22
  return readDocumentDownloadResource(api, uri, parseDocumentId(readVariable(variables, "id")), isTrueQueryValue(readVariable(variables, "original")));
25
23
  }));
26
24
  }
27
- function listDocumentResources(api) {
28
- return __awaiter(this, void 0, void 0, function* () {
29
- // Return only the first page of documents. Paperless libraries can
30
- // contain tens of thousands of documents; expanding the full `all`
31
- // ID array would produce an unbounded `resources/list` payload.
32
- // Clients that need to enumerate more documents can use the
33
- // `list_documents` tool (which paginates) and read
34
- // `paperless://documents/{id}/download` directly — the resource
35
- // template handles `resources/read` for any document ID.
36
- const documentsResponse = yield api.getDocuments();
37
- const documents = documentsResponse.results || [];
38
- return {
39
- resources: documents.flatMap((document) => buildResourcesForDocument(document.id, document)),
40
- };
41
- });
42
- }
43
25
  function readDocumentResource(api, uri, variables) {
44
26
  return __awaiter(this, void 0, void 0, function* () {
45
27
  assertPaperlessDocumentsUri(uri);
@@ -74,23 +56,6 @@ function readDocumentThumbnailResource(api, uri, id) {
74
56
  };
75
57
  });
76
58
  }
77
- function buildResourcesForDocument(id, document) {
78
- const label = (document === null || document === void 0 ? void 0 : document.title) || (document === null || document === void 0 ? void 0 : document.original_file_name) || `Document ${id}`;
79
- return [
80
- {
81
- uri: (0, resourceUri_1.buildDocumentResourceUri)(id),
82
- name: `${label} download`,
83
- description: `Full file content for Paperless document ${id}`,
84
- mimeType: (document === null || document === void 0 ? void 0 : document.mime_type) || "application/octet-stream",
85
- },
86
- {
87
- uri: (0, resourceUri_1.buildThumbnailResourceUri)(id),
88
- name: `${label} thumbnail`,
89
- description: `Thumbnail image for Paperless document ${id}`,
90
- mimeType: "image/webp",
91
- },
92
- ];
93
- }
94
59
  function responseToResourceContents(uri, response, fallbackMimeType) {
95
60
  const mimeType = getHeader(response, "content-type") || fallbackMimeType;
96
61
  const data = Buffer.from(response.data);
@@ -78,11 +78,11 @@ function withResourceClient(api, run) {
78
78
  }
79
79
  });
80
80
  }
81
- (0, node_test_1.test)("resources/list exposes download and thumbnail resources for the first page only", () => __awaiter(void 0, void 0, void 0, function* () {
82
- // `all` lists IDs across every page (potentially tens of thousands of
83
- // documents) `resources/list` must not expand that or it will produce
84
- // an unbounded payload. Only the documents on the fetched page should
85
- // appear; the rest are still reachable via the resource template.
81
+ (0, node_test_1.test)("resources/list does not enumerate documents at startup", () => __awaiter(void 0, void 0, void 0, function* () {
82
+ // Documents are dynamic DMS data and must not be pre-registered as MCP
83
+ // resources: enumerating them floods `resources/list` and the LLM context
84
+ // window (issue #112). The list must stay empty even when documents exist;
85
+ // documents are reached on demand via tools + the `resources/read` template.
86
86
  const api = {
87
87
  getDocuments: () => __awaiter(void 0, void 0, void 0, function* () {
88
88
  return emptyPaginationResponse([
@@ -101,19 +101,7 @@ function withResourceClient(api, run) {
101
101
  };
102
102
  yield withResourceClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
103
103
  const result = yield client.listResources();
104
- strict_1.default.deepEqual(result.resources.map((resource) => resource.uri), [
105
- "paperless://documents/1/download",
106
- "paperless://documents/1/thumb",
107
- "paperless://documents/2/download",
108
- "paperless://documents/2/thumb",
109
- ]);
110
- strict_1.default.equal(result.resources[0].name, "Invoice download");
111
- strict_1.default.equal(result.resources[0].mimeType, "application/pdf");
112
- strict_1.default.equal(result.resources[3].name, "Receipt thumbnail");
113
- // Document 3 lives in `all` but not on this page — it must not leak in.
114
- for (const resource of result.resources) {
115
- strict_1.default.ok(!resource.uri.includes("/3/"), `unexpected page-3 resource in list: ${resource.uri}`);
116
- }
104
+ strict_1.default.deepEqual(result.resources, []);
117
105
  }));
118
106
  }));
119
107
  (0, node_test_1.test)("resources/read returns text contents for text responses", () => __awaiter(void 0, void 0, void 0, function* () {
package/build/server.d.ts CHANGED
@@ -7,5 +7,18 @@ export interface CreateMcpServerOptions {
7
7
  publicUrl: string;
8
8
  }
9
9
  export declare function createMcpServer({ baseUrl, token, version, publicUrl, }: CreateMcpServerOptions): McpServer;
10
- export declare function getBearerToken(req: express.Request, fallbackToken?: string): string | undefined;
10
+ export interface ResolveTokenOptions {
11
+ /**
12
+ * Server-configured token used as a fallback for unauthenticated requests.
13
+ * Only consulted when `allowAnonymous` is true.
14
+ */
15
+ fallbackToken?: string;
16
+ /**
17
+ * When true, requests without a `Bearer` header fall back to `fallbackToken`
18
+ * (the legacy behaviour). When false (the default in HTTP mode), a request
19
+ * without a `Bearer` header is rejected and never uses the server token.
20
+ */
21
+ allowAnonymous: boolean;
22
+ }
23
+ export declare function getBearerToken(req: express.Request, options: ResolveTokenOptions): string | undefined;
11
24
  export declare function sendUnauthorized(res: express.Response): void;
package/build/server.js CHANGED
@@ -10,30 +10,44 @@ const correspondents_1 = require("./tools/correspondents");
10
10
  const customFields_1 = require("./tools/customFields");
11
11
  const documents_2 = require("./tools/documents");
12
12
  const documentTypes_1 = require("./tools/documentTypes");
13
+ const mail_1 = require("./tools/mail");
14
+ const notes_1 = require("./tools/notes");
13
15
  const tags_1 = require("./tools/tags");
14
16
  function createMcpServer({ baseUrl, token, version, publicUrl, }) {
15
17
  const api = new PaperlessAPI_1.PaperlessAPI(baseUrl, token);
16
18
  const server = new mcp_js_1.McpServer({ name: "paperless-ngx", version }, { instructions: buildInstructions(publicUrl) });
17
19
  (0, documents_2.registerDocumentTools)(server, api);
18
20
  (0, documents_1.registerDocumentResources)(server, api);
21
+ (0, notes_1.registerNoteTools)(server, api);
19
22
  (0, tags_1.registerTagTools)(server, api);
20
23
  (0, correspondents_1.registerCorrespondentTools)(server, api);
21
24
  (0, documentTypes_1.registerDocumentTypeTools)(server, api);
22
25
  (0, customFields_1.registerCustomFieldTools)(server, api);
26
+ (0, mail_1.registerMailTools)(server, api);
23
27
  return server;
24
28
  }
25
- function getBearerToken(req, fallbackToken) {
29
+ function getBearerToken(req, options) {
26
30
  const authHeader = req.headers["authorization"];
27
31
  if (authHeader && authHeader.startsWith("Bearer ")) {
28
32
  return authHeader.slice(7);
29
33
  }
30
- return fallbackToken || undefined;
34
+ if (options.allowAnonymous) {
35
+ return options.fallbackToken || undefined;
36
+ }
37
+ return undefined;
31
38
  }
32
39
  function sendUnauthorized(res) {
40
+ // Log operator-facing guidance server-side; keep the wire response minimal so
41
+ // we don't echo configuration hints back to unauthenticated callers.
42
+ console.error("[paperless-mcp] Rejected request with no 'Authorization: Bearer <paperless-ngx-api-token>' header. " +
43
+ "As of v2.0.0, HTTP mode requires a per-request Bearer token and no longer falls back to the " +
44
+ "server-configured token for unauthenticated requests. Have clients send their Paperless-NGX API " +
45
+ "token as a Bearer token, or restart the server with --no-auth to use the server token for " +
46
+ "unauthenticated requests (trusted/local networks only).");
33
47
  res
34
48
  .status(401)
35
49
  .set("WWW-Authenticate", 'Bearer realm="paperless-mcp"')
36
- .end();
50
+ .json({ error: "unauthorized" });
37
51
  }
38
52
  function buildInstructions(publicUrl) {
39
53
  return `
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
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 node_test_1 = require("node:test");
7
+ const strict_1 = __importDefault(require("node:assert/strict"));
8
+ const server_1 = require("./server");
9
+ function reqWith(authorization) {
10
+ return {
11
+ headers: authorization ? { authorization } : {},
12
+ };
13
+ }
14
+ (0, node_test_1.test)("client Bearer token takes precedence over the server token", () => {
15
+ const token = (0, server_1.getBearerToken)(reqWith("Bearer client-token"), {
16
+ fallbackToken: "server-token",
17
+ allowAnonymous: true,
18
+ });
19
+ strict_1.default.equal(token, "client-token");
20
+ });
21
+ (0, node_test_1.test)("client Bearer token is used even when anonymous access is disabled", () => {
22
+ const token = (0, server_1.getBearerToken)(reqWith("Bearer client-token"), {
23
+ fallbackToken: "server-token",
24
+ allowAnonymous: false,
25
+ });
26
+ strict_1.default.equal(token, "client-token");
27
+ });
28
+ (0, node_test_1.test)("falls back to the server token only when anonymous access is allowed", () => {
29
+ const token = (0, server_1.getBearerToken)(reqWith(), {
30
+ fallbackToken: "server-token",
31
+ allowAnonymous: true,
32
+ });
33
+ strict_1.default.equal(token, "server-token");
34
+ });
35
+ (0, node_test_1.test)("no header without anonymous access yields no token (request is rejected)", () => {
36
+ const token = (0, server_1.getBearerToken)(reqWith(), {
37
+ fallbackToken: "server-token",
38
+ allowAnonymous: false,
39
+ });
40
+ strict_1.default.equal(token, undefined);
41
+ });
42
+ (0, node_test_1.test)("non-Bearer Authorization scheme never leaks the server token by default", () => {
43
+ const token = (0, server_1.getBearerToken)(reqWith("Token server-token"), {
44
+ fallbackToken: "server-token",
45
+ allowAnonymous: false,
46
+ });
47
+ strict_1.default.equal(token, undefined);
48
+ });
49
+ (0, node_test_1.test)("anonymous access with no configured server token yields no token", () => {
50
+ const token = (0, server_1.getBearerToken)(reqWith(), {
51
+ allowAnonymous: true,
52
+ });
53
+ strict_1.default.equal(token, undefined);
54
+ });
@@ -9,6 +9,8 @@ export type BulkCustomFieldParameters = {
9
9
  add_custom_fields?: Record<string, BulkCustomFieldValue>;
10
10
  remove_custom_fields?: number[];
11
11
  };
12
+ /** Validates that a file path is safe to read for document upload. */
13
+ export declare function validateFilePath(filePath: string): Promise<void>;
12
14
  /**
13
15
  * Builds Paperless-NGX bulk edit parameters from base parameters plus optional
14
16
  * custom field updates.
@@ -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.
@@ -72,6 +116,12 @@ function buildBulkEditParameters(parameters, addCustomFields, includeCustomField
72
116
  }
73
117
  return apiParameters;
74
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
+ }
75
125
  function registerDocumentTools(server, api) {
76
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.", {
77
127
  documents: zod_1.z.array(zod_1.z.number()),
@@ -149,9 +199,10 @@ function registerDocumentTools(server, api) {
149
199
  }
150
200
  const { documents, method, add_custom_fields, confirm } = args, parameters = __rest(args, ["documents", "method", "add_custom_fields", "confirm"]);
151
201
  (0, monetary_1.validateCustomFields)(add_custom_fields);
202
+ const resolvedCustomFields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, add_custom_fields, "stored");
152
203
  const response = yield api.bulkEditDocuments(documents, method, method === "delete"
153
204
  ? {}
154
- : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields", method === "modify_tags"));
205
+ : buildBulkEditParameters(parameters, resolvedCustomFields, method === "modify_custom_fields", method === "modify_tags"));
155
206
  return {
156
207
  content: [
157
208
  {
@@ -161,9 +212,10 @@ function registerDocumentTools(server, api) {
161
212
  ],
162
213
  };
163
214
  })));
164
- server.tool("post_document", "Upload a new document to Paperless-NGX with optional metadata like title, correspondent, document type, tags, and custom fields.", {
165
- file: zod_1.z.string(),
166
- 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)."),
167
219
  title: zod_1.z.string().optional(),
168
220
  created: zod_1.z.string().optional(),
169
221
  correspondent: zod_1.z.number().optional(),
@@ -172,16 +224,90 @@ function registerDocumentTools(server, api) {
172
224
  tags: zod_1.z.array(zod_1.z.number()).optional(),
173
225
  archive_serial_number: zod_1.z.number().optional(),
174
226
  custom_fields: zod_1.z.array(zod_1.z.number()).optional(),
175
- }, (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* () {
176
271
  if (!api)
177
272
  throw new Error("Please configure API connection first");
178
- // Validate base64 input
179
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
180
- if (!base64Regex.test(args.file)) {
181
- 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
+ }
182
291
  }
183
- const { file, filename } = args, metadata = __rest(args, ["file", "filename"]);
184
- const document = Buffer.from(file, "base64");
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.");
309
+ }
310
+ const { file, file_path, filename: _fn } = args, metadata = __rest(args, ["file", "file_path", "filename"]);
185
311
  const response = yield api.postDocument(document, filename, metadata);
186
312
  let result;
187
313
  if (typeof response === "string" && /^\d+$/.test(response)) {
@@ -199,43 +325,15 @@ function registerDocumentTools(server, api) {
199
325
  ],
200
326
  };
201
327
  })));
202
- 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.", {
203
- page: zod_1.z.number().optional(),
204
- page_size: zod_1.z.number().optional(),
205
- search: zod_1.z.string().optional(),
206
- correspondent: zod_1.z.number().optional(),
207
- document_type: zod_1.z.number().optional(),
208
- tag: zod_1.z.number().optional(),
209
- storage_path: zod_1.z.number().optional(),
210
- created__date__gte: zod_1.z.string().optional(),
211
- created__date__lte: zod_1.z.string().optional(),
212
- ordering: zod_1.z.string().optional(),
213
- }, (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* () {
214
329
  if (!api)
215
330
  throw new Error("Please configure API connection first");
216
- const query = new URLSearchParams();
217
- if (args.page)
218
- query.set("page", args.page.toString());
219
- if (args.page_size)
220
- query.set("page_size", args.page_size.toString());
221
- if (args.search)
222
- query.set("search", args.search);
223
- if (args.correspondent)
224
- query.set("correspondent__id", args.correspondent.toString());
225
- if (args.document_type)
226
- query.set("document_type__id", args.document_type.toString());
227
- if (args.tag)
228
- query.set("tags__id", args.tag.toString());
229
- if (args.storage_path)
230
- query.set("storage_path__id", args.storage_path.toString());
231
- if (args.created__date__gte)
232
- query.set("created__date__gte", args.created__date__gte);
233
- if (args.created__date__lte)
234
- query.set("created__date__lte", args.created__date__lte);
235
- if (args.ordering)
236
- query.set("ordering", args.ordering);
237
- const docsResponse = yield api.getDocuments(query.toString() ? `?${query.toString()}` : "");
238
- 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);
239
337
  })));
240
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.", {
241
339
  id: zod_1.z.number(),
@@ -264,13 +362,10 @@ function registerDocumentTools(server, api) {
264
362
  ],
265
363
  };
266
364
  })));
267
- 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.", {
268
- query: zod_1.z.string(),
269
- }, (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* () {
270
366
  if (!api)
271
367
  throw new Error("Please configure API connection first");
272
- const docsResponse = yield api.searchDocuments(args.query);
273
- return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
368
+ return executeDocumentQuery(api, args);
274
369
  })));
275
370
  server.tool("download_document", "Download a document file by ID. Returns a paperless:// resource URI; read the resource to fetch the file content.", {
276
371
  id: zod_1.z.number().int().positive(),
@@ -380,6 +475,7 @@ function registerDocumentTools(server, api) {
380
475
  throw new Error("Please configure API connection first");
381
476
  const { id } = args, updateData = __rest(args, ["id"]);
382
477
  (0, monetary_1.validateCustomFields)(updateData.custom_fields);
478
+ updateData.custom_fields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, updateData.custom_fields, "index");
383
479
  const response = yield api.updateDocument(id, updateData);
384
480
  return (0, documentEnhancer_1.convertDocsWithNames)(response, api);
385
481
  })));