@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 +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 +26 -18
- package/build/tools/utils/resourceUri.d.ts +29 -0
- package/build/tools/utils/resourceUri.js +37 -0
- package/build/tools/utils/resourceUri.test.d.ts +1 -0
- package/build/tools/utils/resourceUri.test.js +50 -0
- 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
|
@@ -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
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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:
|
|
305
|
-
|
|
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.
|
|
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
|