@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
JavaScript
"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");
}));
});