@baruchiro/paperless-mcp 0.5.1 → 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;
@@ -52,8 +52,9 @@ const resourceUri_1 = require("./utils/resourceUri");
52
52
  * @returns The merged API parameters with custom field updates transformed into
53
53
  * Paperless-NGX's `add_custom_fields` record shape.
54
54
  */
55
- function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false) {
56
- var _a, _b;
55
+ function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false, includeTagDefaults = false) {
56
+ var _a, _b, _c, _d;
57
+ var _e, _f;
57
58
  const apiParameters = Object.assign({}, parameters);
58
59
  if (addCustomFields) {
59
60
  apiParameters.add_custom_fields = Object.fromEntries(addCustomFields.map((customField) => [
@@ -65,6 +66,10 @@ function buildBulkEditParameters(parameters, addCustomFields, includeCustomField
65
66
  (_a = apiParameters.add_custom_fields) !== null && _a !== void 0 ? _a : (apiParameters.add_custom_fields = {});
66
67
  (_b = apiParameters.remove_custom_fields) !== null && _b !== void 0 ? _b : (apiParameters.remove_custom_fields = []);
67
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
+ }
68
73
  return apiParameters;
69
74
  }
70
75
  function registerDocumentTools(server, api) {
@@ -146,7 +151,7 @@ function registerDocumentTools(server, api) {
146
151
  (0, monetary_1.validateCustomFields)(add_custom_fields);
147
152
  const response = yield api.bulkEditDocuments(documents, method, method === "delete"
148
153
  ? {}
149
- : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields"));
154
+ : buildBulkEditParameters(parameters, add_custom_fields, method === "modify_custom_fields", method === "modify_tags"));
150
155
  return {
151
156
  content: [
152
157
  {
@@ -267,43 +272,45 @@ function registerDocumentTools(server, api) {
267
272
  const docsResponse = yield api.searchDocuments(args.query);
268
273
  return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api);
269
274
  })));
270
- server.tool("download_document", "Download a document file by ID. Returns the document as a base64-encoded resource.", {
271
- 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(),
272
277
  original: zod_1.z.boolean().optional(),
273
278
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
274
- var _a, _b;
275
279
  if (!api)
276
280
  throw new Error("Please configure API connection first");
277
- const response = yield api.downloadDocument(args.id, args.original);
278
- const filename = ((_b = (_a = (typeof response.headers.get === "function"
279
- ? response.headers.get("content-disposition")
280
- : 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
+ });
281
284
  return {
282
285
  content: [
283
286
  {
284
287
  type: "resource",
285
288
  resource: {
286
- uri: (0, resourceUri_1.buildDocumentResourceUri)(args.id, filename),
287
- blob: Buffer.from(response.data).toString("base64"),
288
- 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",
289
295
  },
290
296
  },
291
297
  ],
292
298
  };
293
299
  })));
294
- server.tool("get_document_thumbnail", "Get a document thumbnail (image preview) by ID. Returns the thumbnail as a base64-encoded WebP image resource.", {
295
- 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(),
296
302
  }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () {
297
303
  if (!api)
298
304
  throw new Error("Please configure API connection first");
299
- const response = yield api.getThumbnail(args.id);
300
305
  return {
301
306
  content: [
302
307
  {
303
308
  type: "resource",
304
309
  resource: {
305
310
  uri: (0, resourceUri_1.buildThumbnailResourceUri)(args.id),
306
- blob: Buffer.from(response.data).toString("base64"),
311
+ // See download_document above: the binary thumbnail is fetched
312
+ // lazily through resources/read instead of embedded here.
313
+ text: "",
307
314
  mimeType: "image/webp",
308
315
  },
309
316
  },
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * Resource URI builders for document/thumbnail downloads.
3
3
  *
4
- * URIs mirror the Paperless REST API paths under a custom `paperless://`
5
- * scheme, so the same identifiers can later back proper MCP resources
6
- * (`resources/list` / `resources/read`) without a second naming scheme.
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
7
  *
8
8
  * The scheme also keeps the URI well-formed regardless of filename
9
9
  * content, which Python MCP clients (pydantic-validated) require.
10
10
  */
11
+ /**
12
+ * Optional flags for document download resource URIs.
13
+ */
14
+ export interface DocumentResourceUriOptions {
15
+ original?: boolean;
16
+ }
11
17
  /**
12
18
  * Builds a resource URI for a downloaded document.
13
19
  *
14
- * Mirrors `GET /api/documents/{id}/download/`. The original filename is
15
- * preserved as a `filename` query parameter (URL-encoded) so clients
16
- * that need the human-readable name can still recover it.
20
+ * The MCP resource URI is intentionally canonical and filename-free; filenames
21
+ * belong in resource metadata, while the URI identifies the fetchable content.
17
22
  */
18
- export declare function buildDocumentResourceUri(id: number, filename: string): string;
23
+ export declare function buildDocumentResourceUri(id: number, optionsOrFilename?: DocumentResourceUriOptions | string): string;
19
24
  /**
20
25
  * Builds a resource URI for a document thumbnail.
21
26
  *
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * Resource URI builders for document/thumbnail downloads.
4
4
  *
5
- * URIs mirror the Paperless REST API paths under a custom `paperless://`
6
- * scheme, so the same identifiers can later back proper MCP resources
7
- * (`resources/list` / `resources/read`) without a second naming scheme.
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
8
  *
9
9
  * The scheme also keeps the URI well-formed regardless of filename
10
10
  * content, which Python MCP clients (pydantic-validated) require.
@@ -15,12 +15,17 @@ exports.buildThumbnailResourceUri = buildThumbnailResourceUri;
15
15
  /**
16
16
  * Builds a resource URI for a downloaded document.
17
17
  *
18
- * Mirrors `GET /api/documents/{id}/download/`. The original filename is
19
- * preserved as a `filename` query parameter (URL-encoded) so clients
20
- * that need the human-readable name can still recover it.
18
+ * The MCP resource URI is intentionally canonical and filename-free; filenames
19
+ * belong in resource metadata, while the URI identifies the fetchable content.
21
20
  */
22
- function buildDocumentResourceUri(id, filename) {
23
- return `paperless://documents/${id}/download?filename=${encodeURIComponent(filename)}`;
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}` : ""}`;
24
29
  }
25
30
  /**
26
31
  * Builds a resource URI for a document thumbnail.
@@ -10,32 +10,32 @@ const resourceUri_1 = require("./resourceUri");
10
10
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "doc.pdf");
11
11
  strict_1.default.match(uri, /^paperless:\/\/documents\/1\/download(\?|$)/);
12
12
  });
13
- (0, node_test_1.test)("buildDocumentResourceUri puts the filename in a query parameter", () => {
13
+ (0, node_test_1.test)("buildDocumentResourceUri is canonical without filenames", () => {
14
14
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "invoice 2026.pdf");
15
- strict_1.default.equal(uri, "paperless://documents/1/download?filename=invoice%202026.pdf");
15
+ strict_1.default.equal(uri, "paperless://documents/1/download");
16
16
  });
17
- (0, node_test_1.test)("buildDocumentResourceUri encodes RFC-3986 reserved chars in the filename", () => {
17
+ (0, node_test_1.test)("buildDocumentResourceUri ignores reserved chars in legacy filename input", () => {
18
18
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(42, "weird ?#&=+name.pdf");
19
19
  const url = new URL(uri);
20
- // The path stays canonical — only the literal `?` that separates
21
- // path from query is allowed; nothing from the filename should
22
- // bleed into the path or break query parsing.
23
20
  strict_1.default.equal(url.pathname, "/42/download");
24
- strict_1.default.equal(url.searchParams.get("filename"), "weird ?#&=+name.pdf");
21
+ strict_1.default.equal(url.search, "");
25
22
  });
26
- (0, node_test_1.test)("buildDocumentResourceUri encodes path separators inside filename", () => {
23
+ (0, node_test_1.test)("buildDocumentResourceUri ignores path separators in legacy filename input", () => {
27
24
  // A filename containing a slash must not become an extra path segment.
28
25
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(7, "sub/dir/file.pdf");
29
26
  const url = new URL(uri);
30
27
  strict_1.default.equal(url.pathname, "/7/download");
31
- strict_1.default.equal(url.searchParams.get("filename"), "sub/dir/file.pdf");
28
+ strict_1.default.equal(url.search, "");
32
29
  });
33
- (0, node_test_1.test)("buildDocumentResourceUri preserves unicode filenames", () => {
30
+ (0, node_test_1.test)("buildDocumentResourceUri keeps unicode filenames out of the URI", () => {
34
31
  const original = "Rechnüng — März.pdf";
35
32
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, original);
36
- // Roundtrip via URL parsing should recover the original name.
37
33
  const url = new URL(uri);
38
- strict_1.default.equal(url.searchParams.get("filename"), original);
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
39
  });
40
40
  (0, node_test_1.test)("buildDocumentResourceUri produces a valid URL", () => {
41
41
  const uri = (0, resourceUri_1.buildDocumentResourceUri)(1, "Some File.pdf");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baruchiro/paperless-mcp",
3
- "version": "0.5.1",
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