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) 25.1 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 __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.validateFilePath = validateFilePath; exports.buildBulkEditParameters = buildBulkEditParameters; exports.registerDocumentTools = registerDocumentTools; const zod_1 = require("zod"); const promises_1 = require("fs/promises"); const path_1 = require("path"); const documentEnhancer_1 = require("../api/documentEnhancer"); const empty_1 = require("./utils/empty"); const documentQuery_1 = require("./utils/documentQuery"); const middlewares_1 = require("./utils/middlewares"); const monetary_1 = require("./utils/monetary"); const selectFields_1 = require("./utils/selectFields"); const descriptions_1 = require("./utils/descriptions"); const resourceUri_1 = require("./utils/resourceUri"); const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; const ALLOWED_UPLOAD_PATHS = process.env.PAPERLESS_MCP_UPLOAD_PATHS ? process.env.PAPERLESS_MCP_UPLOAD_PATHS.split(":") : []; /** Validates that a file path is safe to read for document upload. */ function validateFilePath(filePath) { return __awaiter(this, void 0, void 0, function* () { if (!(0, path_1.isAbsolute)(filePath)) { throw new Error("file_path must be an absolute path"); } // Resolve symlinks to get canonical path for allowlist checks let realPath; try { realPath = yield (0, promises_1.realpath)(filePath); } catch (_a) { throw new Error("File not found"); } if (ALLOWED_UPLOAD_PATHS.length > 0) { const isAllowed = ALLOWED_UPLOAD_PATHS.some((allowedPath) => { return realPath.startsWith(allowedPath + "/") || realPath === allowedPath; }); if (!isAllowed) { throw new Error("file_path is outside allowed upload directories. " + "Configure PAPERLESS_MCP_UPLOAD_PATHS environment variable to specify allowed paths."); } } const stats = yield (0, promises_1.stat)(realPath); if (!stats.isFile()) { throw new Error("Path must point to a regular file"); } if (stats.size > MAX_FILE_SIZE_BYTES) { throw new Error(`File size (${Math.round(stats.size / 1024 / 1024)}MB) exceeds maximum allowed size (${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB)`); } if (stats.size === 0) { throw new Error("File is empty"); } }); } /** * Builds Paperless-NGX bulk edit parameters from base parameters plus optional * custom field updates. * * Paperless-NGX expects custom field bulk updates as an `add_custom_fields` * record keyed by custom field id. `addCustomFields` is accepted as an array for * the MCP tool schema and transformed into that id-to-value record while * preserving supported value types, including `number[]` document links and * `null` resets. Passing an empty `addCustomFields` array intentionally produces * an empty `add_custom_fields` record. * * When `includeCustomFieldDefaults` is true, the function also initializes * `add_custom_fields` and `remove_custom_fields` with empty defaults using * nullish coalescing (`??=`). This keeps the `modify_custom_fields` method's * payload shape acceptable to Paperless even when no field values are supplied. * * @param parameters - Base bulk edit parameters to include in the result. * @param addCustomFields - Optional custom field updates to map by field id. * @param includeCustomFieldDefaults - Whether to include empty custom field * defaults required by `modify_custom_fields`. * @returns The merged API parameters with custom field updates transformed into * Paperless-NGX's `add_custom_fields` record shape. */ function buildBulkEditParameters(parameters, addCustomFields, includeCustomFieldDefaults = false, includeTagDefaults = false) { var _a, _b, _c, _d; var _e, _f; const apiParameters = Object.assign({}, parameters); if (addCustomFields) { apiParameters.add_custom_fields = Object.fromEntries(addCustomFields.map((customField) => [ String(customField.field), customField.value, ])); } if (includeCustomFieldDefaults) { (_a = apiParameters.add_custom_fields) !== null && _a !== void 0 ? _a : (apiParameters.add_custom_fields = {}); (_b = apiParameters.remove_custom_fields) !== null && _b !== void 0 ? _b : (apiParameters.remove_custom_fields = []); } if (includeTagDefaults) { (_c = (_e = apiParameters).add_tags) !== null && _c !== void 0 ? _c : (_e.add_tags = []); (_d = (_f = apiParameters).remove_tags) !== null && _d !== void 0 ? _d : (_f.remove_tags = []); } return apiParameters; } function executeDocumentQuery(api, args) { return __awaiter(this, void 0, void 0, function* () { const docsResponse = yield api.getDocuments((0, documentQuery_1.buildDocumentQueryString)(args)); return (0, documentEnhancer_1.convertDocsWithNames)(docsResponse, api); }); } function registerDocumentTools(server, api) { server.tool("bulk_edit_documents", "Perform bulk operations on multiple documents. Note: 'remove_tag' removes a tag from specific documents (tag remains in system), while 'delete_tag' permanently deletes a tag from the entire system. ⚠️ WARNING: 'delete' method permanently deletes documents and requires confirmation.", { documents: zod_1.z.array(zod_1.z.number()), method: zod_1.z.enum([ "set_correspondent", "set_document_type", "set_storage_path", "add_tag", "remove_tag", "modify_tags", "modify_custom_fields", "delete", "reprocess", "set_permissions", "merge", "split", "rotate", "delete_pages", ]), correspondent: zod_1.z.number().optional(), document_type: zod_1.z.number().optional(), storage_path: zod_1.z.number().optional(), tag: zod_1.z.number().optional(), add_tags: zod_1.z.array(zod_1.z.number()).optional().transform(empty_1.arrayNotEmpty), remove_tags: zod_1.z.array(zod_1.z.number()).optional().transform(empty_1.arrayNotEmpty), add_custom_fields: zod_1.z .array(zod_1.z.object({ field: zod_1.z.number(), value: zod_1.z.union([ zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean(), zod_1.z.array(zod_1.z.number()), zod_1.z.null(), ]).describe(descriptions_1.CUSTOM_FIELD_VALUE_DESCRIPTION), })) .optional() .transform(empty_1.arrayNotEmpty), remove_custom_fields: zod_1.z .array(zod_1.z.number()) .optional() .transform(empty_1.arrayNotEmpty), permissions: zod_1.z .object({ owner: zod_1.z.number().nullable().optional(), set_permissions: zod_1.z .object({ view: zod_1.z.object({ users: zod_1.z.array(zod_1.z.number()), groups: zod_1.z.array(zod_1.z.number()), }), change: zod_1.z.object({ users: zod_1.z.array(zod_1.z.number()), groups: zod_1.z.array(zod_1.z.number()), }), }) .optional(), merge: zod_1.z.boolean().optional(), }) .optional() .transform(empty_1.objectNotEmpty), metadata_document_id: zod_1.z.number().optional(), delete_originals: zod_1.z.boolean().optional(), pages: zod_1.z.string().optional(), degrees: zod_1.z.number().optional(), confirm: zod_1.z .boolean() .optional() .describe("Must be true when method is 'delete' to confirm destructive operation"), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); if (args.method === "delete" && !args.confirm) { throw new Error("Confirmation required for destructive operation. Set confirm: true to proceed."); } const { documents, method, add_custom_fields, confirm } = args, parameters = __rest(args, ["documents", "method", "add_custom_fields", "confirm"]); (0, monetary_1.validateCustomFields)(add_custom_fields); const resolvedCustomFields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, add_custom_fields, "stored"); const response = yield api.bulkEditDocuments(documents, method, method === "delete" ? {} : buildBulkEditParameters(parameters, resolvedCustomFields, method === "modify_custom_fields", method === "modify_tags")); return { content: [ { type: "text", text: JSON.stringify({ result: response.result || response }), }, ], }; }))); const postDocumentBaseSchema = zod_1.z.object({ file: zod_1.z.string().optional().describe("Base64-encoded file content. Either 'file' or 'file_path' must be provided."), file_path: zod_1.z.string().optional().describe("Absolute path to a file on the server's filesystem. Either 'file' or 'file_path' must be provided. The filename is derived from the path unless 'filename' is also specified. For security, configure PAPERLESS_MCP_UPLOAD_PATHS to restrict allowed directories."), filename: zod_1.z.string().optional().describe("Filename for the uploaded document. Required when using 'file', optional when using 'file_path' (defaults to the basename of the path)."), title: zod_1.z.string().optional(), created: zod_1.z.string().optional(), correspondent: zod_1.z.number().optional(), document_type: zod_1.z.number().optional(), storage_path: zod_1.z.number().optional(), tags: zod_1.z.array(zod_1.z.number()).optional(), archive_serial_number: zod_1.z.number().optional(), custom_fields: zod_1.z.array(zod_1.z.number()).optional(), }); const postDocumentSchema = postDocumentBaseSchema.superRefine((data, ctx) => { const hasFile = data.file !== undefined; const hasFilePath = data.file_path !== undefined; if (!hasFile && !hasFilePath) { ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "Either 'file' (base64) or 'file_path' must be provided.", path: ["file"], }); ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "Either 'file' (base64) or 'file_path' must be provided.", path: ["file_path"], }); } if (hasFile && hasFilePath) { ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "Only one of 'file' or 'file_path' should be provided, not both.", path: ["file"], }); ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "Only one of 'file' or 'file_path' should be provided, not both.", path: ["file_path"], }); } if (hasFile && !data.filename) { ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "'filename' is required when using 'file' (base64 mode).", path: ["filename"], }); } if (hasFilePath && data.file_path && !(0, path_1.isAbsolute)(data.file_path)) { ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: "file_path must be an absolute path", path: ["file_path"], }); } }); server.tool("post_document", "Upload a new document to Paperless-NGX with optional metadata like title, correspondent, document type, tags, and custom fields. Provide either 'file' (base64-encoded content) or 'file_path' (absolute path to a file on the server's filesystem). Using file_path avoids base64 encoding overhead for large files. SECURITY: When using file_path, set PAPERLESS_MCP_UPLOAD_PATHS environment variable to restrict uploads to specific directories (colon-separated paths).", postDocumentBaseSchema.shape, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); const validationResult = postDocumentSchema.safeParse(args); if (!validationResult.success) { throw new Error(validationResult.error.errors.map(e => e.message).join("; ")); } let document; let filename; if (args.file_path) { yield validateFilePath(args.file_path); try { document = yield (0, promises_1.readFile)(args.file_path); } catch (err) { throw new Error("Failed to read file"); } filename = args.filename || (0, path_1.basename)(args.file_path); if (!filename) { throw new Error("Could not derive filename from file_path"); } } else if (args.file) { const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; if (!base64Regex.test(args.file)) { throw new Error("Invalid base64-encoded file data. Please provide a valid base64 string."); } document = Buffer.from(args.file, "base64"); if (document.length > MAX_FILE_SIZE_BYTES) { throw new Error(`File size (${Math.round(document.length / 1024 / 1024)}MB) exceeds maximum allowed size (${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB)`); } if (document.length === 0) { throw new Error("File is empty"); } filename = args.filename; } else { // This should never happen due to schema validation, but TypeScript needs it throw new Error("Either 'file' (base64) or 'file_path' must be provided."); } const { file, file_path, filename: _fn } = args, metadata = __rest(args, ["file", "file_path", "filename"]); const response = yield api.postDocument(document, filename, metadata); let result; if (typeof response === "string" && /^\d+$/.test(response)) { result = { id: Number(response) }; } else { result = { status: response }; } return { content: [ { type: "text", text: JSON.stringify(result), }, ], }; }))); server.tool("list_documents", "List and filter documents with pagination and common Paperless filters such as title search, correspondent, document type, tag, storage path, creation date, archive serial number, and simple custom field filters. Use 'query_documents' for full-text query, structured custom field conditions, or advanced documented /api/documents/ query parameters. IMPORTANT: For queries like 'the last 3 contributions' or when searching by tag, correspondent, document type, or storage path, first use the relevant lookup tool to find the correct ID. Note: Document content is excluded from results by default. Use 'get_document_content' when you need the document text.", documentQuery_1.LIST_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); return executeDocumentQuery(api, args); }))); server.tool("query_documents", "Query documents using the full-text query engine plus structured Paperless filters. Use this for complex filtering, custom field conditions, or any documented /api/documents/ query parameters that are not exposed as first-class arguments. Prefer the dedicated top-level arguments where available. custom_field_query supports [field_name_or_id, operator, value] leaves or ['AND'|'OR', [clause1, clause2]] groups. Note: Document content is excluded from results by default. Use 'get_document_content' when you need the document text.", documentQuery_1.QUERY_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); return executeDocumentQuery(api, args); }))); server.tool("get_document", "Get a specific document by ID with full details including correspondent, document type, tags, and custom fields. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", { id: zod_1.z.number(), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); const doc = yield api.getDocument(args.id); return (0, documentEnhancer_1.convertDocsWithNames)(doc, api); }))); server.tool("get_document_content", "Get the text content of a specific document by ID. Use this when you need to read or analyze the actual document text.", { id: zod_1.z.number(), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); const doc = yield api.getDocument(args.id); return { content: [ { type: "text", text: JSON.stringify({ id: doc.id, title: doc.title, content: doc.content, }), }, ], }; }))); server.tool("search_documents", "Deprecated compatibility wrapper for full-text document search. Use 'query_documents' with the 'query' argument for new integrations. Note: Document content is excluded from results by default. Use 'get_document_content' to retrieve content when needed.", documentQuery_1.SEARCH_DOCUMENTS_ARGS_SHAPE, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); return executeDocumentQuery(api, args); }))); server.tool("download_document", "Download a document file by ID. Returns a paperless:// resource URI; read the resource to fetch the file content.", { id: zod_1.z.number().int().positive(), original: zod_1.z.boolean().optional(), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); const uri = (0, resourceUri_1.buildDocumentResourceUri)(args.id, { original: args.original, }); return { content: [ { type: "resource", resource: { uri, // MCP SDK 1.11 embedded resources require text or blob. Keep the // existing resource-shaped tool result while making resources/read // the canonical place for the large binary payload. text: "", mimeType: "application/octet-stream", }, }, ], }; }))); 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.", { id: zod_1.z.number().int().positive(), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); return { content: [ { type: "resource", resource: { uri: (0, resourceUri_1.buildThumbnailResourceUri)(args.id), // See download_document above: the binary thumbnail is fetched // lazily through resources/read instead of embedded here. text: "", mimeType: "image/webp", }, }, ], }; }))); server.tool("update_document", "Update a specific document with new values. This tool allows you to modify any document field including title, correspondent, document type, storage path, tags, custom fields, and more. Only the fields you specify will be updated.", { id: zod_1.z.number().describe("The ID of the document to update"), title: zod_1.z .string() .max(128) .optional() .describe("The new title for the document (max 128 characters)"), correspondent: zod_1.z .number() .nullable() .optional() .describe("The ID of the correspondent to assign"), document_type: zod_1.z .number() .nullable() .optional() .describe("The ID of the document type to assign"), storage_path: zod_1.z .number() .nullable() .optional() .describe("The ID of the storage path to assign"), tags: zod_1.z .array(zod_1.z.number()) .optional() .describe("Array of tag IDs to assign to the document"), content: zod_1.z .string() .optional() .describe("The raw text content of the document (used for searching)"), created: zod_1.z .string() .optional() .describe("The creation date in YYYY-MM-DD format"), archive_serial_number: zod_1.z .number() .optional() .describe("The archive serial number (0-4294967295)"), owner: zod_1.z .number() .nullable() .optional() .describe("The ID of the user who owns the document"), custom_fields: zod_1.z .array(zod_1.z.object({ field: zod_1.z.number().describe("The custom field ID"), value: zod_1.z .union([ zod_1.z.string(), zod_1.z.number(), zod_1.z.boolean(), zod_1.z.array(zod_1.z.number()), zod_1.z.null(), ]) .describe(descriptions_1.CUSTOM_FIELD_VALUE_DESCRIPTION), })) .optional() .describe("Array of custom field values to assign"), }, (0, middlewares_1.withErrorHandling)((args, extra) => __awaiter(this, void 0, void 0, function* () { if (!api) throw new Error("Please configure API connection first"); const { id } = args, updateData = __rest(args, ["id"]); (0, monetary_1.validateCustomFields)(updateData.custom_fields); updateData.custom_fields = yield (0, selectFields_1.resolveSelectCustomFieldValues)(api, updateData.custom_fields, "index"); const response = yield api.updateDocument(id, updateData); return (0, documentEnhancer_1.convertDocsWithNames)(response, api); }))); }