@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.
- package/README.md +288 -11
- package/build/api/PaperlessAPI.d.ts +29 -2
- package/build/api/PaperlessAPI.js +97 -8
- package/build/api/types.d.ts +47 -0
- package/build/index.js +28 -5
- package/build/resources/documents.d.ts +1 -2
- package/build/resources/documents.js +1 -36
- package/build/resources/documents.test.js +6 -18
- package/build/server.d.ts +14 -1
- package/build/server.js +17 -3
- package/build/server.test.d.ts +1 -0
- package/build/server.test.js +54 -0
- package/build/tools/documents.d.ts +2 -0
- package/build/tools/documents.js +147 -51
- package/build/tools/documents.test.js +404 -0
- package/build/tools/mail.d.ts +3 -0
- package/build/tools/mail.js +187 -0
- package/build/tools/mail.test.d.ts +1 -0
- package/build/tools/mail.test.js +212 -0
- package/build/tools/notes.d.ts +3 -0
- package/build/tools/notes.js +55 -0
- package/build/tools/notes.test.d.ts +1 -0
- package/build/tools/notes.test.js +177 -0
- package/build/tools/utils/descriptions.d.ts +1 -1
- package/build/tools/utils/descriptions.js +1 -1
- package/build/tools/utils/documentQuery.d.ts +71 -0
- package/build/tools/utils/documentQuery.js +270 -0
- package/build/tools/utils/selectFields.d.ts +11 -0
- package/build/tools/utils/selectFields.js +91 -0
- package/build/tools/utils/selectFields.test.d.ts +1 -0
- package/build/tools/utils/selectFields.test.js +164 -0
- package/package.json +1 -1
- package/paperless-mcp.dxt +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import type {
|
|
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:
|
|
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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
.
|
|
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.
|
package/build/tools/documents.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
165
|
-
file: zod_1.z.string(),
|
|
166
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
})));
|