@baruchiro/paperless-mcp 0.5.0 → 1.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 CHANGED
@@ -455,6 +455,52 @@ The server will show clear error messages if:
455
455
  - The requested operation fails
456
456
  - The provided parameters are invalid
457
457
 
458
+ ## Testing
459
+
460
+ ### Unit tests
461
+
462
+ Run the unit test suite (no external dependencies required):
463
+
464
+ ```bash
465
+ npm test
466
+ ```
467
+
468
+ ### E2E tests
469
+
470
+ The E2E suite boots an empty Paperless-ngx instance, runs the compiled MCP server, and drives a deterministic serial scenario through `tools/call` requests — creating a tag, correspondent, and document type, uploading a PDF, then exercising list / get / search / download / thumbnail / bulk-edit on the same document. No LLM and no Paperless REST client outside MCP.
471
+
472
+ **Prerequisites:** Docker, Docker Compose, and `jq`.
473
+
474
+ ```bash
475
+ # 1. Build the MCP server
476
+ npm run build
477
+
478
+ # 2. Start Paperless-ngx
479
+ docker compose -f docker-compose.e2e.yml up -d
480
+
481
+ # 3. Wait for Paperless to be ready, then get a token
482
+ TOKEN=$(curl -s -X POST http://localhost:8000/api/token/ \
483
+ -H 'Content-Type: application/json' \
484
+ -d '{"username":"admin","password":"admin123"}' | jq -r '.token')
485
+
486
+ # 4. Start the MCP server
487
+ node build/index.js --http --port 3001 \
488
+ --baseUrl http://localhost:8000 --token "$TOKEN" &
489
+ MCP_PID=$!
490
+
491
+ # 5. Run the E2E tests
492
+ MCP_URL=http://localhost:3001/mcp \
493
+ PAPERLESS_URL=http://localhost:8000 \
494
+ PAPERLESS_TOKEN="$TOKEN" \
495
+ npm run test:e2e
496
+
497
+ # 6. Cleanup
498
+ kill "$MCP_PID"
499
+ docker compose -f docker-compose.e2e.yml down -v
500
+ ```
501
+
502
+ E2E tests also run automatically in CI on every pull request and push to `main`, covering both the `build/index.js` CLI and the published Docker image.
503
+
458
504
  ## Development
459
505
 
460
506
  Want to contribute or modify the server? Here's what you need to know:
@@ -0,0 +1,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ListResourcesResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types";
3
+ import { PaperlessAPI } from "../api/PaperlessAPI";
4
+ type TemplateVariables = Record<string, string | string[]>;
5
+ export declare function registerDocumentResources(server: McpServer, api: PaperlessAPI): void;
6
+ export declare function listDocumentResources(api: PaperlessAPI): Promise<ListResourcesResult>;
7
+ export declare function readDocumentResource(api: PaperlessAPI, uri: URL, variables: TemplateVariables): Promise<ReadResourceResult>;
8
+ export {};
@@ -0,0 +1,154 @@
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.registerDocumentResources = registerDocumentResources;
13
+ exports.listDocumentResources = listDocumentResources;
14
+ exports.readDocumentResource = readDocumentResource;
15
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
16
+ const resourceUri_1 = require("../tools/utils/resourceUri");
17
+ function registerDocumentResources(server, api) {
18
+ 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); }),
20
+ }), (uri, variables) => __awaiter(this, void 0, void 0, function* () { return readDocumentResource(api, uri, variables); }));
21
+ server.resource("paperless-document-original-download", new mcp_js_1.ResourceTemplate("paperless://documents/{id}/download{?original}", {
22
+ list: undefined,
23
+ }), (uri, variables) => __awaiter(this, void 0, void 0, function* () {
24
+ return readDocumentDownloadResource(api, uri, parseDocumentId(readVariable(variables, "id")), isTrueQueryValue(readVariable(variables, "original")));
25
+ }));
26
+ }
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
+ function readDocumentResource(api, uri, variables) {
44
+ return __awaiter(this, void 0, void 0, function* () {
45
+ assertPaperlessDocumentsUri(uri);
46
+ const id = parseDocumentId(readVariable(variables, "id"));
47
+ const resource = readVariable(variables, "resource").split("?")[0];
48
+ switch (resource) {
49
+ case "download":
50
+ return readDocumentDownloadResource(api, uri, id, isTrueQueryValue(uri.searchParams.get("original") || undefined));
51
+ case "thumbnail":
52
+ case "thumb":
53
+ return readDocumentThumbnailResource(api, uri, id);
54
+ default:
55
+ throw new Error(`Unsupported Paperless document resource: ${resource}`);
56
+ }
57
+ });
58
+ }
59
+ function readDocumentDownloadResource(api, uri, id, original) {
60
+ return __awaiter(this, void 0, void 0, function* () {
61
+ const response = yield api.downloadDocument(id, original);
62
+ return {
63
+ contents: [
64
+ responseToResourceContents(uri.href, response, "application/octet-stream"),
65
+ ],
66
+ };
67
+ });
68
+ }
69
+ function readDocumentThumbnailResource(api, uri, id) {
70
+ return __awaiter(this, void 0, void 0, function* () {
71
+ const response = yield api.getThumbnail(id);
72
+ return {
73
+ contents: [responseToResourceContents(uri.href, response, "image/webp")],
74
+ };
75
+ });
76
+ }
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
+ function responseToResourceContents(uri, response, fallbackMimeType) {
95
+ const mimeType = getHeader(response, "content-type") || fallbackMimeType;
96
+ const data = Buffer.from(response.data);
97
+ if (isTextMimeType(mimeType)) {
98
+ return {
99
+ uri,
100
+ mimeType,
101
+ text: data.toString("utf8"),
102
+ };
103
+ }
104
+ return {
105
+ uri,
106
+ mimeType,
107
+ blob: data.toString("base64"),
108
+ };
109
+ }
110
+ function assertPaperlessDocumentsUri(uri) {
111
+ if (uri.protocol !== "paperless:" || uri.hostname !== "documents") {
112
+ throw new Error(`Unsupported Paperless resource URI: ${uri.href}`);
113
+ }
114
+ }
115
+ function parseDocumentId(idValue) {
116
+ const id = Number(idValue);
117
+ if (!Number.isInteger(id) || id <= 0) {
118
+ throw new Error(`Invalid Paperless document id in resource URI: ${idValue}`);
119
+ }
120
+ return id;
121
+ }
122
+ function readVariable(variables, name) {
123
+ const value = variables[name];
124
+ const stringValue = Array.isArray(value) ? value[0] : value;
125
+ if (!stringValue) {
126
+ throw new Error(`Missing ${name} in Paperless resource URI`);
127
+ }
128
+ return stringValue;
129
+ }
130
+ function isTrueQueryValue(value) {
131
+ return value === "true" || value === "1";
132
+ }
133
+ function getHeader(response, headerName) {
134
+ const headers = response.headers;
135
+ if (typeof headers.get === "function") {
136
+ const value = headers.get(headerName);
137
+ if (typeof value === "string") {
138
+ return value;
139
+ }
140
+ }
141
+ const value = headers[headerName] || headers[headerName.toLowerCase()];
142
+ if (Array.isArray(value)) {
143
+ return String(value[0]);
144
+ }
145
+ return typeof value === "string" ? value : undefined;
146
+ }
147
+ function isTextMimeType(mimeType) {
148
+ const normalizedMimeType = mimeType.split(";")[0].trim().toLowerCase();
149
+ return (normalizedMimeType.startsWith("text/") ||
150
+ normalizedMimeType === "application/json" ||
151
+ normalizedMimeType === "application/xml" ||
152
+ normalizedMimeType.endsWith("+json") ||
153
+ normalizedMimeType.endsWith("+xml"));
154
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
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 index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
18
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
19
+ const paperlessApi_1 = require("../test/mocks/paperlessApi");
20
+ const documents_1 = require("./documents");
21
+ class TestTransport {
22
+ start() {
23
+ return __awaiter(this, void 0, void 0, function* () { });
24
+ }
25
+ send(message) {
26
+ return __awaiter(this, void 0, void 0, function* () {
27
+ queueMicrotask(() => { var _a, _b; return (_b = (_a = this.peer) === null || _a === void 0 ? void 0 : _a.onmessage) === null || _b === void 0 ? void 0 : _b.call(_a, message); });
28
+ });
29
+ }
30
+ close() {
31
+ return __awaiter(this, void 0, void 0, function* () {
32
+ var _a;
33
+ (_a = this.onclose) === null || _a === void 0 ? void 0 : _a.call(this);
34
+ });
35
+ }
36
+ }
37
+ function createTransportPair() {
38
+ const clientTransport = new TestTransport();
39
+ const serverTransport = new TestTransport();
40
+ clientTransport.peer = serverTransport;
41
+ serverTransport.peer = clientTransport;
42
+ return { clientTransport, serverTransport };
43
+ }
44
+ function emptyPaginationResponse(results = [], all = []) {
45
+ return {
46
+ count: results.length,
47
+ next: null,
48
+ previous: null,
49
+ all,
50
+ results,
51
+ };
52
+ }
53
+ function createBinaryResponse(body, contentType) {
54
+ return {
55
+ data: Buffer.from(body),
56
+ status: 200,
57
+ statusText: "OK",
58
+ headers: {
59
+ "content-type": contentType,
60
+ },
61
+ config: {},
62
+ };
63
+ }
64
+ function withResourceClient(api, run) {
65
+ return __awaiter(this, void 0, void 0, function* () {
66
+ const server = new mcp_js_1.McpServer({ name: "paperless-test", version: "1.0.0" });
67
+ (0, documents_1.registerDocumentResources)(server, api);
68
+ const client = new index_js_1.Client({ name: "paperless-test-client", version: "1.0.0" });
69
+ const { clientTransport, serverTransport } = createTransportPair();
70
+ yield server.connect(serverTransport);
71
+ yield client.connect(clientTransport);
72
+ try {
73
+ yield run(client);
74
+ }
75
+ finally {
76
+ yield client.close();
77
+ yield server.close();
78
+ }
79
+ });
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.
86
+ const api = {
87
+ getDocuments: () => __awaiter(void 0, void 0, void 0, function* () {
88
+ return emptyPaginationResponse([
89
+ (0, paperlessApi_1.createDocument)({
90
+ id: 1,
91
+ title: "Invoice",
92
+ mime_type: "application/pdf",
93
+ }),
94
+ (0, paperlessApi_1.createDocument)({
95
+ id: 2,
96
+ title: "Receipt",
97
+ mime_type: "image/png",
98
+ }),
99
+ ], [1, 2, 3]);
100
+ }),
101
+ };
102
+ yield withResourceClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
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
+ }
117
+ }));
118
+ }));
119
+ (0, node_test_1.test)("resources/read returns text contents for text responses", () => __awaiter(void 0, void 0, void 0, function* () {
120
+ const api = {
121
+ getDocuments: () => __awaiter(void 0, void 0, void 0, function* () { return emptyPaginationResponse(); }),
122
+ downloadDocument: () => __awaiter(void 0, void 0, void 0, function* () { return createBinaryResponse("plain text", "text/plain; charset=utf-8"); }),
123
+ };
124
+ yield withResourceClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () {
125
+ const result = yield client.readResource({
126
+ uri: "paperless://documents/4/download",
127
+ });
128
+ strict_1.default.deepEqual(result.contents, [
129
+ {
130
+ uri: "paperless://documents/4/download",
131
+ mimeType: "text/plain; charset=utf-8",
132
+ text: "plain text",
133
+ },
134
+ ]);
135
+ }));
136
+ }));
package/build/server.js CHANGED
@@ -5,15 +5,17 @@ exports.getBearerToken = getBearerToken;
5
5
  exports.sendUnauthorized = sendUnauthorized;
6
6
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
7
7
  const PaperlessAPI_1 = require("./api/PaperlessAPI");
8
+ const documents_1 = require("./resources/documents");
8
9
  const correspondents_1 = require("./tools/correspondents");
9
10
  const customFields_1 = require("./tools/customFields");
10
- const documents_1 = require("./tools/documents");
11
+ const documents_2 = require("./tools/documents");
11
12
  const documentTypes_1 = require("./tools/documentTypes");
12
13
  const tags_1 = require("./tools/tags");
13
14
  function createMcpServer({ baseUrl, token, version, publicUrl, }) {
14
15
  const api = new PaperlessAPI_1.PaperlessAPI(baseUrl, token);
15
16
  const server = new mcp_js_1.McpServer({ name: "paperless-ngx", version }, { instructions: buildInstructions(publicUrl) });
16
- (0, documents_1.registerDocumentTools)(server, api);
17
+ (0, documents_2.registerDocumentTools)(server, api);
18
+ (0, documents_1.registerDocumentResources)(server, api);
17
19
  (0, tags_1.registerTagTools)(server, api);
18
20
  (0, correspondents_1.registerCorrespondentTools)(server, api);
19
21
  (0, documentTypes_1.registerDocumentTypeTools)(server, api);
@@ -32,5 +32,5 @@ export type BulkCustomFieldParameters = {
32
32
  * @returns The merged API parameters with custom field updates transformed into
33
33
  * Paperless-NGX's `add_custom_fields` record shape.
34
34
  */
35
- export declare function buildBulkEditParameters<T extends Record<string, unknown>>(parameters: T, addCustomFields?: BulkCustomFieldUpdate[], includeCustomFieldDefaults?: boolean): T & BulkCustomFieldParameters;
35
+ export declare function buildBulkEditParameters<T extends Record<string, unknown>>(parameters: T, addCustomFields?: BulkCustomFieldUpdate[], includeCustomFieldDefaults?: boolean, includeTagDefaults?: boolean): T & BulkCustomFieldParameters;
36
36
  export declare function registerDocumentTools(server: McpServer, api: PaperlessAPI): void;
@@ -28,6 +28,7 @@ const empty_1 = require("./utils/empty");
28
28
  const middlewares_1 = require("./utils/middlewares");
29
29
  const monetary_1 = require("./utils/monetary");
30
30
  const descriptions_1 = require("./utils/descriptions");
31
+ const resourceUri_1 = require("./utils/resourceUri");
31
32
  /**
32
33
  * Builds Paperless-NGX bulk edit parameters from base parameters plus optional
33
34
  * custom field updates.
@@ -51,8 +52,9 @@ const descriptions_1 = require("./utils/descriptions");
51
52
  * @returns The merged API parameters with custom field updates transformed into
52
53
  * Paperless-NGX's `add_custom_fields` record shape.
53
54
  */
54
- function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false) {
55
- var _a, _b;
55
+ function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false, includeTagDefaults = false) {
56
+ var _a, _b, _c, _d;
57
+ var _e, _f;
56
58
  const apiParameters = Object.assign({}, parameters);
57
59
  if (addCustomFields) {
58
60
  apiParameters.add_custom_fields = Object.fromEntries(addCustomFields.map((customField) => [
@@ -64,6 +66,10 @@ function buildBulkEditParameters(parameters, addCustomFields, includeCustomField
64
66
  (_a = apiParameters.add_custom_fields) !== null && _a !== void 0 ? _a : (apiParameters.add_custom_fields = {});
65
67
  (_b = apiParameters.remove_custom_fields) !== null && _b !== void 0 ? _b : (apiParameters.remove_custom_fields = []);
66
68
  }
69
+ if (includeTagDefaults) {
70
+ (_c = (_e = apiParameters).add_tags) !== null && _c !== void 0 ? _c : (_e.add_tags = []);
71
+ (_d = (_f = apiParameters).remove_tags) !== null && _d !== void 0 ? _d : (_f.remove_tags = []);
72
+ }
67
73
  return apiParameters;
68
74
  }
69
75
  function registerDocumentTools(server, api) {
@@ -145,7 +151,7 @@ function registerDocumentTools(server, api) {
145
151
  (0, monetary_1.validateCustomFields)(add_custom_fields);
146
152
  const response = yield api.bulkEditDocuments(documents, method, method === "delete"
147
153
  ? {}
148
- : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields"));
154
+ : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields", method === "modify_tags"));
149
155
  return {
150
156
  content: [
151
157
  {
@@ -266,43 +272,45 @@ function registerDocumentTools(server, api) {
266
272
  const docsResponse = yield api.searchDocuments(args.query);
267
273
  return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
268
274
  })));
269
- server.tool("download_document", "Download a document file by ID. Returns the document as a base64-encoded resource.", {
270
- id: zod_1.z.number(),
275
+ server.tool("download_document", "Download a document file by ID. Returns a paperless:// resource URI; read the resource to fetch the file content.", {
276
+ id: zod_1.z.number().int().positive(),
271
277
  original: zod_1.z.boolean().optional(),
272
278
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
273
- var _a, _b;
274
279
  if (!api)
275
280
  throw new Error("Please configure API connection first");
276
- const response = yield api.downloadDocument(args.id, args.original);
277
- const filename = ((_b = (_a = (typeof response.headers.get === "function"
278
- ? response.headers.get("content-disposition")
279
- : response.headers["content-disposition"])) === null || _a === void 0 ? void 0 : _a.split("filename=")[1]) === null || _b === void 0 ? void 0 : _b.replace(/"/g, "")) || `document-${args.id}`;
281
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(args.id, {
282
+ original: args.original,
283
+ });
280
284
  return {
281
285
  content: [
282
286
  {
283
287
  type: "resource",
284
288
  resource: {
285
- uri: filename,
286
- blob: Buffer.from(response.data).toString("base64"),
287
- mimeType: "application/pdf",
289
+ uri,
290
+ // MCP SDK 1.11 embedded resources require text or blob. Keep the
291
+ // existing resource-shaped tool result while making resources/read
292
+ // the canonical place for the large binary payload.
293
+ text: "",
294
+ mimeType: "application/octet-stream",
288
295
  },
289
296
  },
290
297
  ],
291
298
  };
292
299
  })));
293
- server.tool("get_document_thumbnail", "Get a document thumbnail (image preview) by ID. Returns the thumbnail as a base64-encoded WebP image resource.", {
294
- id: zod_1.z.number(),
300
+ server.tool("get_document_thumbnail", "Get a document thumbnail (image preview) by ID. Returns a paperless:// resource URI; read the resource to fetch the image content.", {
301
+ id: zod_1.z.number().int().positive(),
295
302
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
296
303
  if (!api)
297
304
  throw new Error("Please configure API connection first");
298
- const response = yield api.getThumbnail(args.id);
299
305
  return {
300
306
  content: [
301
307
  {
302
308
  type: "resource",
303
309
  resource: {
304
- uri: `document-${args.id}-thumb.webp`,
305
- blob: Buffer.from(response.data).toString("base64"),
310
+ uri: (0, resourceUri_1.buildThumbnailResourceUri)(args.id),
311
+ // See download_document above: the binary thumbnail is fetched
312
+ // lazily through resources/read instead of embedded here.
313
+ text: "",
306
314
  mimeType: "image/webp",
307
315
  },
308
316
  },
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resource URI builders for document/thumbnail downloads.
3
+ *
4
+ * URIs mirror document resources under a custom `paperless://` scheme, so the
5
+ * same identifiers can back MCP resources (`resources/list` / `resources/read`)
6
+ * without a second naming scheme.
7
+ *
8
+ * The scheme also keeps the URI well-formed regardless of filename
9
+ * content, which Python MCP clients (pydantic-validated) require.
10
+ */
11
+ /**
12
+ * Optional flags for document download resource URIs.
13
+ */
14
+ export interface DocumentResourceUriOptions {
15
+ original?: boolean;
16
+ }
17
+ /**
18
+ * Builds a resource URI for a downloaded document.
19
+ *
20
+ * The MCP resource URI is intentionally canonical and filename-free; filenames
21
+ * belong in resource metadata, while the URI identifies the fetchable content.
22
+ */
23
+ export declare function buildDocumentResourceUri(id: number, optionsOrFilename?: DocumentResourceUriOptions | string): string;
24
+ /**
25
+ * Builds a resource URI for a document thumbnail.
26
+ *
27
+ * Mirrors `GET /api/documents/{id}/thumb/`.
28
+ */
29
+ export declare function buildThumbnailResourceUri(id: number): string;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ /**
3
+ * Resource URI builders for document/thumbnail downloads.
4
+ *
5
+ * URIs mirror document resources under a custom `paperless://` scheme, so the
6
+ * same identifiers can back MCP resources (`resources/list` / `resources/read`)
7
+ * without a second naming scheme.
8
+ *
9
+ * The scheme also keeps the URI well-formed regardless of filename
10
+ * content, which Python MCP clients (pydantic-validated) require.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.buildDocumentResourceUri = buildDocumentResourceUri;
14
+ exports.buildThumbnailResourceUri = buildThumbnailResourceUri;
15
+ /**
16
+ * Builds a resource URI for a downloaded document.
17
+ *
18
+ * The MCP resource URI is intentionally canonical and filename-free; filenames
19
+ * belong in resource metadata, while the URI identifies the fetchable content.
20
+ */
21
+ function buildDocumentResourceUri(id, optionsOrFilename) {
22
+ const options = typeof optionsOrFilename === "string" ? {} : optionsOrFilename || {};
23
+ const params = new URLSearchParams();
24
+ if (options.original) {
25
+ params.set("original", "true");
26
+ }
27
+ const query = params.toString();
28
+ return `paperless://documents/${id}/download${query ? `?${query}` : ""}`;
29
+ }
30
+ /**
31
+ * Builds a resource URI for a document thumbnail.
32
+ *
33
+ * Mirrors `GET /api/documents/{id}/thumb/`.
34
+ */
35
+ function buildThumbnailResourceUri(id) {
36
+ return `paperless://documents/${id}/thumb`;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
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 resourceUri_1 = require("./resourceUri");
9
+ (0, node_test_1.test)("buildDocumentResourceUri mirrors the REST download path", () => {
10
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "doc.pdf");
11
+ strict_1.default.match(uri, /^paperless:\/\/documents\/1\/download(\?|$)/);
12
+ });
13
+ (0, node_test_1.test)("buildDocumentResourceUri is canonical without filenames", () => {
14
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "invoice 2026.pdf");
15
+ strict_1.default.equal(uri, "paperless://documents/1/download");
16
+ });
17
+ (0, node_test_1.test)("buildDocumentResourceUri ignores reserved chars in legacy filename input", () => {
18
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(42, "weird ?#&=+name.pdf");
19
+ const url = new URL(uri);
20
+ strict_1.default.equal(url.pathname, "/42/download");
21
+ strict_1.default.equal(url.search, "");
22
+ });
23
+ (0, node_test_1.test)("buildDocumentResourceUri ignores path separators in legacy filename input", () => {
24
+ // A filename containing a slash must not become an extra path segment.
25
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(7, "sub/dir/file.pdf");
26
+ const url = new URL(uri);
27
+ strict_1.default.equal(url.pathname, "/7/download");
28
+ strict_1.default.equal(url.search, "");
29
+ });
30
+ (0, node_test_1.test)("buildDocumentResourceUri keeps unicode filenames out of the URI", () => {
31
+ const original = "Rechnüng — März.pdf";
32
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, original);
33
+ const url = new URL(uri);
34
+ strict_1.default.equal(url.pathname, "/1/download");
35
+ strict_1.default.equal(url.search, "");
36
+ });
37
+ (0, node_test_1.test)("buildDocumentResourceUri preserves original download intent", () => {
38
+ strict_1.default.equal((0, resourceUri_1.buildDocumentResourceUri)(1, { original: true }), "paperless://documents/1/download?original=true");
39
+ });
40
+ (0, node_test_1.test)("buildDocumentResourceUri produces a valid URL", () => {
41
+ const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "Some File.pdf");
42
+ strict_1.default.doesNotThrow(() => new URL(uri));
43
+ });
44
+ (0, node_test_1.test)("buildThumbnailResourceUri mirrors the REST thumb path", () => {
45
+ strict_1.default.equal((0, resourceUri_1.buildThumbnailResourceUri)(1), "paperless://documents/1/thumb");
46
+ strict_1.default.equal((0, resourceUri_1.buildThumbnailResourceUri)(123), "paperless://documents/123/thumb");
47
+ });
48
+ (0, node_test_1.test)("buildThumbnailResourceUri produces a valid URL", () => {
49
+ strict_1.default.doesNotThrow(() => new URL((0, resourceUri_1.buildThumbnailResourceUri)(99)));
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baruchiro/paperless-mcp",
3
- "version": "0.5.0",
3
+ "version": "1.0.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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --require ts-node/register --test src/**/*.test.ts",
11
+ "test:e2e": "node --require ts-node/register --test e2e/e2e.test.ts",
11
12
  "start": "ts-node src/index.ts",
12
13
  "build": "tsc",
13
14
  "dxt-pack": "dxt pack",
package/paperless-mcp.dxt CHANGED
Binary file