UNPKG

@baruchiro/paperless-mcp

Version:

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.

483 lines (482 loc) 23.4 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const strict_1 = __importDefault(require("node:assert/strict")); const node_fs_1 = require("node:fs"); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); const node_test_1 = require("node:test"); const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js"); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const documents_1 = require("./documents"); const documentQuery_1 = require("./utils/documentQuery"); function getQueryParams(queryString) { return new URLSearchParams(queryString.replace(/^\?/, "")); } function getDocumentQueryParamsFromOpenApi() { const openApiPath = (0, node_path_1.join)(process.cwd(), "Paperless_ngx_REST_API.yaml"); const text = (0, node_fs_1.readFileSync)(openApiPath, "utf8"); const start = text.indexOf(" /api/documents/:"); const end = text.indexOf(" /api/documents/{id}/:"); strict_1.default.ok(start >= 0, "OpenAPI docs marker '/api/documents/' not found"); strict_1.default.ok(end > start, "OpenAPI docs marker '/api/documents/{id}/' not found or out of order"); const section = text.slice(start, end); return Array.from(section.matchAll(/^\s*-?\s*name:\s+(.+)$/gm), (match) => match[1]).sort(); } // ALLOWED_UPLOAD_PATHS is read from the environment at module load, so the // allowlist can only be exercised by re-importing the module with the env set. function validateFilePathWithAllowlist(allowedPaths) { const modulePath = require.resolve("./documents"); const previous = process.env.PAPERLESS_MCP_UPLOAD_PATHS; process.env.PAPERLESS_MCP_UPLOAD_PATHS = allowedPaths; delete require.cache[modulePath]; try { return require("./documents").validateFilePath; } finally { delete require.cache[modulePath]; if (previous === undefined) { delete process.env.PAPERLESS_MCP_UPLOAD_PATHS; } else { process.env.PAPERLESS_MCP_UPLOAD_PATHS = previous; } } } (0, node_test_1.test)("buildBulkEditParameters sends Paperless bulk custom fields as id:value map", () => { const parameters = (0, documents_1.buildBulkEditParameters)({ remove_custom_fields: [] }, [ { field: 9, value: "" }, { field: 10, value: "2026-05-14" }, ]); strict_1.default.deepEqual(parameters, { remove_custom_fields: [], add_custom_fields: { "9": "", "10": "2026-05-14", }, }); strict_1.default.ok(!("assign_custom_fields" in parameters)); strict_1.default.ok(!("assign_custom_fields_values" in parameters)); }); (0, node_test_1.test)("buildBulkEditParameters preserves null custom field values", () => { const parameters = (0, documents_1.buildBulkEditParameters)({}, [{ field: 9, value: null }]); strict_1.default.deepEqual(parameters, { add_custom_fields: { "9": null, }, }); }); (0, node_test_1.test)("buildBulkEditParameters includes Paperless-required empty custom field keys", () => { const parameters = (0, documents_1.buildBulkEditParameters)({}, undefined, true); strict_1.default.deepEqual(parameters, { add_custom_fields: {}, remove_custom_fields: [], }); }); (0, node_test_1.test)("buildBulkEditParameters preserves an empty custom fields array", () => { const parameters = (0, documents_1.buildBulkEditParameters)({}, []); strict_1.default.deepEqual(parameters, { add_custom_fields: {}, }); strict_1.default.ok(!("remove_custom_fields" in parameters)); }); (0, node_test_1.test)("buildBulkEditParameters preserves an empty custom fields array with defaults", () => { const parameters = (0, documents_1.buildBulkEditParameters)({}, [], true); strict_1.default.deepEqual(parameters, { add_custom_fields: {}, remove_custom_fields: [], }); }); (0, node_test_1.test)("buildBulkEditParameters combines base parameters with custom fields", () => { const parameters = (0, documents_1.buildBulkEditParameters)({ add_tags: [3], remove_tags: [1, 2] }, [{ field: 9, value: "pending" }]); strict_1.default.deepEqual(parameters, { add_tags: [3], remove_tags: [1, 2], add_custom_fields: { "9": "pending", }, }); }); (0, node_test_1.test)("buildBulkEditParameters preserves supported custom field value types", () => { const parameters = (0, documents_1.buildBulkEditParameters)({}, [ { field: 1, value: 42 }, { field: 2, value: true }, { field: 3, value: "" }, { field: 4, value: null }, { field: 5, value: [123, 456] }, ]); strict_1.default.deepEqual(parameters.add_custom_fields, { "1": 42, "2": true, "3": "", "4": null, "5": [123, 456], }); }); (0, node_test_1.test)("paperless filter allowlist stays in sync with the document OpenAPI section", () => { const documentedParams = getDocumentQueryParamsFromOpenApi(); const allowedParams = [...documentQuery_1.DOCUMENT_QUERY_PAPERLESS_FILTER_KEYS].sort(); strict_1.default.deepEqual(allowedParams, documentedParams); }); (0, node_test_1.test)("serializes full-text query_documents arguments", () => { const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ query: "invoice 2024", search: "jan", more_like_id: 42, })); strict_1.default.equal(query.get("query"), "invoice 2024"); strict_1.default.equal(query.get("search"), "jan"); strict_1.default.equal(query.get("more_like_id"), "42"); }); (0, node_test_1.test)("serializes first-class document filters using Paperless parameter names", () => { const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ page: 2, page_size: 50, ordering: "-created", correspondent: 3, document_type: 4, tag: 5, storage_path: 6, created__date__gte: "2024-01-01", created__date__lte: "2024-12-31", archive_serial_number: 99, archive_serial_number__isnull: false, custom_fields__icontains: "invoice", })); strict_1.default.equal(query.get("page"), "2"); strict_1.default.equal(query.get("page_size"), "50"); strict_1.default.equal(query.get("ordering"), "-created"); strict_1.default.equal(query.get("correspondent__id"), "3"); strict_1.default.equal(query.get("document_type__id"), "4"); strict_1.default.equal(query.get("tags__id"), "5"); strict_1.default.equal(query.get("storage_path__id"), "6"); strict_1.default.equal(query.get("created__date__gte"), "2024-01-01"); strict_1.default.equal(query.get("created__date__lte"), "2024-12-31"); strict_1.default.equal(query.get("archive_serial_number"), "99"); strict_1.default.equal(query.get("archive_serial_number__isnull"), "false"); strict_1.default.equal(query.get("custom_fields__icontains"), "invoice"); }); (0, node_test_1.test)("serializes paperless_filters arrays as comma-separated values", () => { const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ paperless_filters: { fields: ["title", "tags"], id__in: [1, 2, 3], }, })); strict_1.default.equal(query.get("fields"), "title,tags"); strict_1.default.equal(query.get("id__in"), "1,2,3"); }); (0, node_test_1.test)("serializes raw list custom_field_query strings without JSON encoding", () => { const rawCustomFieldQuery = '[7, "icontains", "value"]'; const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ custom_field_query: rawCustomFieldQuery, })); strict_1.default.equal(query.get("custom_field_query"), rawCustomFieldQuery); }); (0, node_test_1.test)("serializes leaf custom_field_query values as JSON", () => { const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ custom_field_query: ["Invoice Number", "exact", "12345"], })); strict_1.default.equal(query.get("custom_field_query"), JSON.stringify(["Invoice Number", "exact", "12345"])); }); (0, node_test_1.test)("serializes numeric custom_field_query field IDs as JSON", () => { const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ custom_field_query: [7, "exact", "12345"], })); strict_1.default.equal(query.get("custom_field_query"), JSON.stringify([7, "exact", "12345"])); }); (0, node_test_1.test)("serializes grouped custom_field_query values as JSON", () => { const groupedQuery = [ "OR", [ ["Invoice Number", "isnull", true], ["Invoice Number", "exact", ""], ], ]; const query = getQueryParams((0, documentQuery_1.buildDocumentQueryString)({ custom_field_query: groupedQuery, })); strict_1.default.equal(query.get("custom_field_query"), JSON.stringify(groupedQuery)); }); (0, node_test_1.test)("rejects unsupported paperless_filters keys", () => { strict_1.default.throws(() => (0, documentQuery_1.buildDocumentQueryString)({ paperless_filters: { not_a_real_filter: "value", }, }), /Unsupported paperless_filters key/); }); (0, node_test_1.test)("rejects duplicate first-class and paperless_filters definitions", () => { strict_1.default.throws(() => (0, documentQuery_1.buildDocumentQueryString)({ correspondent: 7, paperless_filters: { correspondent__id: 7, }, }), /Duplicate filter 'correspondent__id'/); }); (0, node_test_1.test)("rejects invalid custom_field_query shapes", () => { strict_1.default.equal(documentQuery_1.customFieldQuerySchema.safeParse(["field", "exact"]).success, false); strict_1.default.equal(documentQuery_1.customFieldQuerySchema.safeParse(["AND", []]).success, false); strict_1.default.equal(documentQuery_1.customFieldQuerySchema.safeParse(["AND", [["field"]]]).success, false); strict_1.default.equal(documentQuery_1.customFieldQuerySchema.safeParse(["AND", "iexact", "foo"]).success, false); }); (0, node_test_1.describe)("validateFilePath", () => { let testDir; let testFile; let emptyFile; let symlinkPath; (0, node_test_1.before)(() => { testDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "paperless-mcp-test-")); testFile = (0, node_path_1.join)(testDir, "test.pdf"); (0, node_fs_1.writeFileSync)(testFile, "%PDF-1.4\ntest content"); emptyFile = (0, node_path_1.join)(testDir, "empty.pdf"); (0, node_fs_1.writeFileSync)(emptyFile, ""); symlinkPath = (0, node_path_1.join)(testDir, "link.pdf"); try { (0, node_fs_1.symlinkSync)(testFile, symlinkPath); } catch (_a) { // Skip if unsupported. } }); (0, node_test_1.after)(() => { (0, node_fs_1.rmSync)(testDir, { recursive: true, force: true }); }); (0, node_test_1.test)("accepts a valid absolute file path", () => __awaiter(void 0, void 0, void 0, function* () { yield strict_1.default.doesNotReject(() => (0, documents_1.validateFilePath)(testFile)); })); (0, node_test_1.test)("rejects relative paths", () => __awaiter(void 0, void 0, void 0, function* () { yield strict_1.default.rejects(() => (0, documents_1.validateFilePath)("relative/path.pdf"), { message: "file_path must be an absolute path", }); })); (0, node_test_1.test)("rejects non-existent files", () => __awaiter(void 0, void 0, void 0, function* () { yield strict_1.default.rejects(() => (0, documents_1.validateFilePath)((0, node_path_1.join)(testDir, "missing.pdf")), { message: "File not found" }); })); (0, node_test_1.test)("rejects directories", () => __awaiter(void 0, void 0, void 0, function* () { yield strict_1.default.rejects(() => (0, documents_1.validateFilePath)(testDir), { message: "Path must point to a regular file", }); })); (0, node_test_1.test)("rejects empty files", () => __awaiter(void 0, void 0, void 0, function* () { yield strict_1.default.rejects(() => (0, documents_1.validateFilePath)(emptyFile), { message: "File is empty", }); })); (0, node_test_1.test)("resolves symlinks to the real file", () => __awaiter(void 0, void 0, void 0, function* () { try { yield strict_1.default.doesNotReject(() => (0, documents_1.validateFilePath)(symlinkPath)); } catch (_a) { // Skip on systems without symlink support. } })); (0, node_test_1.test)("rejects files exceeding the maximum size", () => __awaiter(void 0, void 0, void 0, function* () { const largeFile = (0, node_path_1.join)(testDir, "large.pdf"); (0, node_fs_1.writeFileSync)(largeFile, "%PDF-1.4\n"); (0, node_fs_1.truncateSync)(largeFile, 101 * 1024 * 1024); yield strict_1.default.rejects(() => (0, documents_1.validateFilePath)(largeFile), { message: /exceeds maximum allowed size/, }); })); (0, node_test_1.test)("accepts files within allowed upload paths", () => __awaiter(void 0, void 0, void 0, function* () { const validate = validateFilePathWithAllowlist((0, node_fs_1.realpathSync)(testDir)); yield strict_1.default.doesNotReject(() => validate(testFile)); })); (0, node_test_1.test)("rejects files outside allowed upload paths", () => __awaiter(void 0, void 0, void 0, function* () { const validate = validateFilePathWithAllowlist("/some/other/path"); yield strict_1.default.rejects(() => validate(testFile), { message: /outside allowed upload directories/, }); })); }); class TestTransport { start() { return __awaiter(this, void 0, void 0, function* () { }); } send(message) { return __awaiter(this, void 0, void 0, function* () { 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); }); }); } close() { return __awaiter(this, void 0, void 0, function* () { var _a; (_a = this.onclose) === null || _a === void 0 ? void 0 : _a.call(this); }); } } function createTransportPair() { const clientTransport = new TestTransport(); const serverTransport = new TestTransport(); clientTransport.peer = serverTransport; serverTransport.peer = clientTransport; return { clientTransport, serverTransport }; } function parseToolText(result) { var _a; const item = (_a = result.content) === null || _a === void 0 ? void 0 : _a[0]; if (!item || item.type !== "text") { throw new Error("Expected text tool response"); } return JSON.parse(item.text); } function withDocumentClient(api, run) { return __awaiter(this, void 0, void 0, function* () { const server = new mcp_js_1.McpServer({ name: "paperless-doc-test", version: "1.0.0" }); (0, documents_1.registerDocumentTools)(server, api); const client = new index_js_1.Client({ name: "paperless-doc-test-client", version: "1.0.0", }); const { clientTransport, serverTransport } = createTransportPair(); yield server.connect(serverTransport); yield client.connect(clientTransport); try { yield run(client); } finally { yield client.close(); yield server.close(); } }); } function createDocumentApi(fields) { const calls = { updateDocument: [], bulkEditDocuments: [], getCustomField: [], }; const fieldMap = new Map(fields.map((field) => [field.id, field])); const api = { getCustomField: (id) => __awaiter(this, void 0, void 0, function* () { calls.getCustomField.push(id); const field = fieldMap.get(id); if (!field) throw new Error(`custom field ${id} not found`); return field; }), updateDocument: (id, data) => __awaiter(this, void 0, void 0, function* () { var _a; calls.updateDocument.push([id, data]); return { id, custom_fields: (_a = data.custom_fields) !== null && _a !== void 0 ? _a : [] }; }), bulkEditDocuments: (documents, method, parameters) => __awaiter(this, void 0, void 0, function* () { calls.bulkEditDocuments.push([documents, method, parameters]); return { result: "OK" }; }), getCorrespondents: () => __awaiter(this, void 0, void 0, function* () { return ({ results: [] }); }), getDocumentTypes: () => __awaiter(this, void 0, void 0, function* () { return ({ results: [] }); }), getTags: () => __awaiter(this, void 0, void 0, function* () { return ({ results: [] }); }), getCustomFields: () => __awaiter(this, void 0, void 0, function* () { return ({ results: [] }); }), }; return { api, calls }; } const LEGACY_SELECT_FIELD = { id: 2, name: "Retention period", data_type: "select", extra_data: { select_options: ["1 year", "7 years", "2 years"], default_currency: null }, document_count: 10, }; const OBJECT_SELECT_FIELD = { id: 3, name: "Priority", data_type: "select", extra_data: { select_options: [ { id: "abc123", label: "Low" }, { id: "def456", label: "High" }, ], }, document_count: 5, }; (0, node_test_1.describe)("select custom field value resolution in document handlers", () => { (0, node_test_1.test)("update_document translates a select label to its zero-based index", () => __awaiter(void 0, void 0, void 0, function* () { const { api, calls } = createDocumentApi([LEGACY_SELECT_FIELD]); yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () { var _a; const result = (yield client.callTool({ name: "update_document", arguments: { id: 42, custom_fields: [{ field: 2, value: "1 year" }] }, })); strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error); })); strict_1.default.equal(calls.updateDocument.length, 1); const [, data] = calls.updateDocument[0]; strict_1.default.deepEqual(data.custom_fields, [{ field: 2, value: 0 }]); })); (0, node_test_1.test)("update_document translates a select label to its option index (Paperless 2.17+)", () => __awaiter(void 0, void 0, void 0, function* () { const { api, calls } = createDocumentApi([OBJECT_SELECT_FIELD]); yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () { var _a; const result = (yield client.callTool({ name: "update_document", arguments: { id: 7, custom_fields: [{ field: 3, value: "High" }] }, })); strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error); })); const [, data] = calls.updateDocument[0]; strict_1.default.deepEqual(data.custom_fields, [{ field: 3, value: 1 }]); })); (0, node_test_1.test)("bulk_edit_documents translates a select label in add_custom_fields", () => __awaiter(void 0, void 0, void 0, function* () { const { api, calls } = createDocumentApi([LEGACY_SELECT_FIELD]); yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () { var _a; const result = (yield client.callTool({ name: "bulk_edit_documents", arguments: { documents: [1, 2], method: "modify_custom_fields", add_custom_fields: [{ field: 2, value: "7 years" }], }, })); strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error); })); strict_1.default.equal(calls.bulkEditDocuments.length, 1); const [, , parameters] = calls.bulkEditDocuments[0]; strict_1.default.deepEqual(parameters.add_custom_fields, { "2": 1 }); })); (0, node_test_1.test)("bulk_edit_documents sends the option id for 2.17+ select fields (stored form)", () => __awaiter(void 0, void 0, void 0, function* () { const { api, calls } = createDocumentApi([OBJECT_SELECT_FIELD]); yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () { var _a; const result = (yield client.callTool({ name: "bulk_edit_documents", arguments: { documents: [1], method: "modify_custom_fields", add_custom_fields: [{ field: 3, value: "High" }], }, })); strict_1.default.ok(!result.isError, (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error); })); const [, , parameters] = calls.bulkEditDocuments[0]; strict_1.default.deepEqual(parameters.add_custom_fields, { "3": "def456" }); })); (0, node_test_1.test)("update_document rejects an unknown select option with a helpful error", () => __awaiter(void 0, void 0, void 0, function* () { const { api, calls } = createDocumentApi([LEGACY_SELECT_FIELD]); yield withDocumentClient(api, (client) => __awaiter(void 0, void 0, void 0, function* () { var _a, _b; const result = (yield client.callTool({ name: "update_document", arguments: { id: 42, custom_fields: [{ field: 2, value: "forever" }] }, })); strict_1.default.ok(result.isError, "expected an error for an unknown select option"); const message = (_b = (_a = parseToolText(result)) === null || _a === void 0 ? void 0 : _a.error) !== null && _b !== void 0 ? _b : ""; strict_1.default.match(message, /forever/); strict_1.default.match(message, /1 year/); })); strict_1.default.equal(calls.updateDocument.length, 0, "no document update should be sent when the option is invalid"); })); });