@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 +46 -0
- package/build/resources/documents.d.ts +8 -0
- package/build/resources/documents.js +154 -0
- package/build/resources/documents.test.d.ts +1 -0
- package/build/resources/documents.test.js +136 -0
- package/build/server.js +4 -2
- package/build/tools/documents.d.ts +1 -1
- package/build/tools/documents.js +24 -17
- package/build/tools/utils/resourceUri.d.ts +12 -7
- package/build/tools/utils/resourceUri.js +13 -8
- package/build/tools/utils/resourceUri.test.js +12 -12
- package/package.json +2 -1
- package/paperless-mcp.dxt +0 -0
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
|
|
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,
|
|
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;
|
package/build/tools/documents.js
CHANGED
|
@@ -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
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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,
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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,
|
|
23
|
-
|
|
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
|
|
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
|
|
15
|
+
strict_1.default.equal(uri, "paperless://documents/1/download");
|
|
16
16
|
});
|
|
17
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
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.
|
|
21
|
+
strict_1.default.equal(url.search, "");
|
|
25
22
|
});
|
|
26
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
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.
|
|
28
|
+
strict_1.default.equal(url.search, "");
|
|
32
29
|
});
|
|
33
|
-
(0, node_test_1.test)("buildDocumentResourceUri
|
|
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.
|
|
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.
|
|
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
|