# Directory Structure ``` ├── .cursor │ └── rules │ ├── http-transport.mdc │ └── typescript-mcp-migration.mdc ├── .dockerignore ├── .github │ ├── FUNDING.yml │ └── workflows │ ├── docker-build.yml │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ ├── api │ │ └── PaperlessAPI.ts │ ├── index.ts │ └── tools │ ├── correspondents.ts │ ├── documents.ts │ ├── documentTypes.ts │ └── tags.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` node_modules build ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Environment variables .env .env.local .env.*.local # Build output dist/ build/ coverage/ # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # OS .DS_Store Thumbs.db # Editor directories and files .idea/ .vscode/ *.suo *.ntvs* *.njsproj *.sln *.sw? ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown [](https://mseep.ai/app/nloui-paperless-mcp) # Paperless-NGX MCP Server [](https://smithery.ai/server/@nloui/paperless-mcp) An MCP (Model Context Protocol) server for interacting with a Paperless-NGX API server. This server provides tools for managing documents, tags, correspondents, and document types in your Paperless-NGX instance. ## Quick Start ### Installing via Smithery To install Paperless NGX MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@nloui/paperless-mcp): ```bash npx -y @smithery/cli install @nloui/paperless-mcp --client claude ``` ### Manual Installation 1. Install the MCP server: ```bash npm install -g paperless-mcp ``` 2. Add it to your Claude's MCP configuration: For VSCode extension, edit `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`: ```json { "mcpServers": { "paperless": { "command": "npx", "args": ["paperless-mcp", "http://your-paperless-instance:8000", "your-api-token"] } } } ``` For Claude desktop app, edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "paperless": { "command": "npx", "args": ["paperless-mcp", "http://your-paperless-instance:8000", "your-api-token"] } } } ``` 3. Get your API token: 1. Log into your Paperless-NGX instance 2. Click your username in the top right 3. Select "My Profile" 4. Click the circular arrow button to generate a new token 4. Replace the placeholders in your MCP config: - `http://your-paperless-instance:8000` with your Paperless-NGX URL - `your-api-token` with the token you just generated That's it! Now you can ask Claude to help you manage your Paperless-NGX documents. ## Example Usage Here are some things you can ask Claude to do: - "Show me all documents tagged as 'Invoice'" - "Search for documents containing 'tax return'" - "Create a new tag called 'Receipts' with color #FF0000" - "Download document #123" - "List all correspondents" - "Create a new document type called 'Bank Statement'" ## Available Tools ### Document Operations #### list_documents Get a paginated list of all documents. Parameters: - page (optional): Page number - page_size (optional): Number of documents per page ```typescript list_documents({ page: 1, page_size: 25 }) ``` #### get_document Get a specific document by ID. Parameters: - id: Document ID ```typescript get_document({ id: 123 }) ``` #### search_documents Full-text search across documents. Parameters: - query: Search query string ```typescript search_documents({ query: "invoice 2024" }) ``` #### download_document Download a document file by ID. Parameters: - id: Document ID - original (optional): If true, downloads original file instead of archived version ```typescript download_document({ id: 123, original: false }) ``` #### bulk_edit_documents Perform bulk operations on multiple documents. Parameters: - documents: Array of document IDs - method: One of: - set_correspondent: Set correspondent for documents - set_document_type: Set document type for documents - set_storage_path: Set storage path for documents - add_tag: Add a tag to documents - remove_tag: Remove a tag from documents - modify_tags: Add and/or remove multiple tags - delete: Delete documents - reprocess: Reprocess documents - set_permissions: Set document permissions - merge: Merge multiple documents - split: Split a document into multiple documents - rotate: Rotate document pages - delete_pages: Delete specific pages from a document - Additional parameters based on method: - correspondent: ID for set_correspondent - document_type: ID for set_document_type - storage_path: ID for set_storage_path - tag: ID for add_tag/remove_tag - add_tags: Array of tag IDs for modify_tags - remove_tags: Array of tag IDs for modify_tags - permissions: Object for set_permissions with owner, permissions, merge flag - metadata_document_id: ID for merge to specify metadata source - delete_originals: Boolean for merge/split - pages: String for split "[1,2-3,4,5-7]" or delete_pages "[2,3,4]" - degrees: Number for rotate (90, 180, or 270) Examples: ```typescript // Add a tag to multiple documents bulk_edit_documents({ documents: [1, 2, 3], method: "add_tag", tag: 5 }) // Set correspondent and document type bulk_edit_documents({ documents: [4, 5], method: "set_correspondent", correspondent: 2 }) // Merge documents bulk_edit_documents({ documents: [6, 7, 8], method: "merge", metadata_document_id: 6, delete_originals: true }) // Split document into parts bulk_edit_documents({ documents: [9], method: "split", pages: "[1-2,3-4,5]" }) // Modify multiple tags at once bulk_edit_documents({ documents: [10, 11], method: "modify_tags", add_tags: [1, 2], remove_tags: [3, 4] }) ``` #### post_document Upload a new document to Paperless-NGX. Parameters: - file: Base64 encoded file content - filename: Name of the file - title (optional): Title for the document - created (optional): DateTime when the document was created (e.g. "2024-01-19" or "2024-01-19 06:15:00+02:00") - correspondent (optional): ID of a correspondent - document_type (optional): ID of a document type - storage_path (optional): ID of a storage path - tags (optional): Array of tag IDs - archive_serial_number (optional): Archive serial number - custom_fields (optional): Array of custom field IDs ```typescript post_document({ file: "base64_encoded_content", filename: "invoice.pdf", title: "January Invoice", created: "2024-01-19", correspondent: 1, document_type: 2, tags: [1, 3], archive_serial_number: "2024-001" }) ``` ### Tag Operations #### list_tags Get all tags. ```typescript list_tags() ``` #### create_tag Create a new tag. Parameters: - name: Tag name - color (optional): Hex color code (e.g. "#ff0000") - match (optional): Text pattern to match - matching_algorithm (optional): One of "any", "all", "exact", "regular expression", "fuzzy" ```typescript create_tag({ name: "Invoice", color: "#ff0000", match: "invoice", matching_algorithm: "fuzzy" }) ``` ### Correspondent Operations #### list_correspondents Get all correspondents. ```typescript list_correspondents() ``` #### create_correspondent Create a new correspondent. Parameters: - name: Correspondent name - match (optional): Text pattern to match - matching_algorithm (optional): One of "any", "all", "exact", "regular expression", "fuzzy" ```typescript create_correspondent({ name: "ACME Corp", match: "ACME", matching_algorithm: "fuzzy" }) ``` ### Document Type Operations #### list_document_types Get all document types. ```typescript list_document_types() ``` #### create_document_type Create a new document type. Parameters: - name: Document type name - match (optional): Text pattern to match - matching_algorithm (optional): One of "any", "all", "exact", "regular expression", "fuzzy" ```typescript create_document_type({ name: "Invoice", match: "invoice total amount due", matching_algorithm: "any" }) ``` ## Error Handling The server will show clear error messages if: - The Paperless-NGX URL or API token is incorrect - The Paperless-NGX server is unreachable - The requested operation fails - The provided parameters are invalid ## Development Want to contribute or modify the server? Here's what you need to know: 1. Clone the repository 2. Install dependencies: ```bash npm install ``` 3. Make your changes to server.js 4. Test locally: ```bash node server.js http://localhost:8000 your-test-token ``` The server is built with: - [litemcp](https://github.com/wong2/litemcp): A TypeScript framework for building MCP servers - [zod](https://github.com/colinhacks/zod): TypeScript-first schema validation ## API Documentation This MCP server implements endpoints from the Paperless-NGX REST API. For more details about the underlying API, see the [official documentation](https://docs.paperless-ngx.com/api/). ## Running the MCP Server The MCP server can be run in two modes: ### 1. stdio (default) This is the default mode. The server communicates over stdio, suitable for CLI and direct integrations. ``` npm run start -- <baseUrl> <token> ``` ### 2. HTTP (Streamable HTTP Transport) To run the server as an HTTP service, use the `--http` flag. You can also specify the port with `--port` (default: 3000). This mode requires [Express](https://expressjs.com/) to be installed (it is included as a dependency). ``` npm run start -- <baseUrl> <token> --http --port 3000 ``` - The MCP API will be available at `POST /mcp` on the specified port. - Each request is handled statelessly, following the [StreamableHTTPServerTransport](https://github.com/modelcontextprotocol/typescript-sdk) pattern. - GET and DELETE requests to `/mcp` will return 405 Method Not Allowed. ``` -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- ```yaml name: Docker Build (PR) on: pull_request: branches: - '**' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: false ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Builder stage FROM node:20-slim AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # Production stage FROM node:20-slim AS production WORKDIR /app COPY --from=builder /app/build . COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package-lock.json ./package-lock.json EXPOSE 3000 ENTRYPOINT [ "node", "index.js", "--http", "--port", "3000" ] ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - baseUrl - token properties: baseUrl: type: string description: The base URL of your Paperless-NGX instance. token: type: string description: The API token for accessing your Paperless-NGX instance. commandFunction: # A function that produces the CLI command to start the MCP on stdio. |- (config) => ({command: 'node', args: ['src/index.js', config.baseUrl, config.token]}) ``` -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- ```yaml # These are supported funding model platforms github: [nloui] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ``` -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- ```yaml name: Docker Publish on: push: branches: - master - main jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.actor }}/${{ github.repository }} flavor: | latest=true - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@nloui/paperless-mcp", "version": "1.0.0", "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.", "main": "src/index.js", "bin": { "paperless-mcp": "src/index.js" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "ts-node src/index.ts", "build": "tsc", "inspect": "npm run build && npx -y @modelcontextprotocol/inspector node build/index.js" }, "keywords": [ "mcp", "paperless-ngx", "document-management", "ai", "claude", "model-context-protocol", "paperless" ], "author": "Nick Loui", "license": "ISC", "repository": { "type": "git", "url": "git+https://github.com/nloui/paperless-mcp.git" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.11.1", "express": "^5.1.0", "typescript": "^5.8.3", "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^22.15.17", "ts-node": "^10.9.2" } } ``` -------------------------------------------------------------------------------- /src/tools/correspondents.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; import { z } from "zod"; export function registerCorrespondentTools(server: McpServer, api) { server.tool( "list_correspondents", "Retrieve all available correspondents (people, companies, organizations that send/receive documents). Returns names and automatic matching patterns for document assignment.", { }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.getCorrespondents(); }); server.tool( "create_correspondent", "Create a new correspondent (person, company, or organization) for tracking document senders and receivers. Can include automatic matching patterns for smart assignment to incoming documents.", { name: z.string().describe("Name of the correspondent (person, company, or organization that sends/receives documents). Examples: 'Bank of America', 'John Smith', 'Electric Company'."), match: z.string().optional().describe("Text pattern to automatically assign this correspondent to matching documents. Use names, email addresses, or keywords that appear in documents from this correspondent."), matching_algorithm: z .enum(["any", "all", "exact", "regular expression", "fuzzy"]) .optional().describe("How to match text patterns: 'any'=any word matches, 'all'=all words must match, 'exact'=exact phrase match, 'regular expression'=use regex patterns, 'fuzzy'=approximate matching with typos. Default is 'any'."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.createCorrespondent(args); } ); server.tool( "bulk_edit_correspondents", "Perform bulk operations on multiple correspondents: set permissions to control who can assign them to documents, or permanently delete multiple correspondents. Use with caution as deletion affects all associated documents.", { correspondent_ids: z.array(z.number()).describe("Array of correspondent IDs to perform bulk operations on. Use list_correspondents to get valid correspondent IDs."), operation: z.enum(["set_permissions", "delete"]).describe("Bulk operation: 'set_permissions' to control who can assign these correspondents to documents, 'delete' to permanently remove correspondents from the system. Warning: Deleting correspondents will remove them from all associated documents."), owner: z.number().optional().describe("User ID to set as owner when operation is 'set_permissions'. The owner has full control over these correspondents."), permissions: z .object({ view: z.object({ users: z.array(z.number()).optional().describe("User IDs who can see and assign these correspondents to documents"), groups: z.array(z.number()).optional().describe("Group IDs who can see and assign these correspondents to documents"), }).describe("Users and groups with permission to view and use these correspondents"), change: z.object({ users: z.array(z.number()).optional().describe("User IDs who can modify correspondent details (name, matching rules)"), groups: z.array(z.number()).optional().describe("Group IDs who can modify correspondent details"), }).describe("Users and groups with permission to edit these correspondent settings"), }) .optional().describe("Permission settings when operation is 'set_permissions'. Defines who can view/assign and modify these correspondents."), merge: z.boolean().optional().describe("Whether to merge with existing permissions (true) or replace them entirely (false). Default is false."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.bulkEditObjects( args.correspondent_ids, "correspondents", args.operation, args.operation === "set_permissions" ? { owner: args.owner, permissions: args.permissions, merge: args.merge, } : {} ); } ); } ``` -------------------------------------------------------------------------------- /src/tools/documentTypes.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; export function registerDocumentTypeTools(server, api) { server.tool( "list_document_types", "Retrieve all available document types for categorizing documents by purpose or format (Invoice, Receipt, Contract, etc.). Returns names and automatic matching rules.", { // No parameters - returns all available document types }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.getDocumentTypes(); }); server.tool( "create_document_type", "Create a new document type for categorizing documents by their purpose or format (e.g., Invoice, Receipt, Contract). Can include automatic matching rules for smart classification.", { name: z.string().describe("Name of the document type for categorizing documents by their purpose or format. Examples: 'Invoice', 'Receipt', 'Contract', 'Letter', 'Bank Statement', 'Tax Document'."), match: z.string().optional().describe("Text pattern to automatically assign this document type to matching documents. Use keywords that commonly appear in this type of document (e.g., 'invoice', 'receipt', 'contract terms')."), matching_algorithm: z .enum(["any", "all", "exact", "regular expression", "fuzzy"]) .optional().describe("How to match text patterns: 'any'=any word matches, 'all'=all words must match, 'exact'=exact phrase match, 'regular expression'=use regex patterns, 'fuzzy'=approximate matching with typos. Default is 'any'."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.createDocumentType(args); } ); server.tool( "bulk_edit_document_types", "Perform bulk operations on multiple document types: set permissions to control who can assign them to documents, or permanently delete multiple types. Use with caution as deletion affects all associated documents.", { document_type_ids: z.array(z.number()).describe("Array of document type IDs to perform bulk operations on. Use list_document_types to get valid document type IDs."), operation: z.enum(["set_permissions", "delete"]).describe("Bulk operation: 'set_permissions' to control who can assign these document types to documents, 'delete' to permanently remove document types from the system. Warning: Deleting document types will remove the classification from all associated documents."), owner: z.number().optional().describe("User ID to set as owner when operation is 'set_permissions'. The owner has full control over these document types."), permissions: z .object({ view: z.object({ users: z.array(z.number()).optional().describe("User IDs who can see and assign these document types to documents"), groups: z.array(z.number()).optional().describe("Group IDs who can see and assign these document types to documents"), }).describe("Users and groups with permission to view and use these document types for categorization"), change: z.object({ users: z.array(z.number()).optional().describe("User IDs who can modify document type details (name, matching rules)"), groups: z.array(z.number()).optional().describe("Group IDs who can modify document type details"), }).describe("Users and groups with permission to edit these document type settings"), }) .optional().describe("Permission settings when operation is 'set_permissions'. Defines who can view/assign and modify these document types."), merge: z.boolean().optional().describe("Whether to merge with existing permissions (true) or replace them entirely (false). Default is false."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.bulkEditObjects( args.document_type_ids, "document_types", args.operation, args.operation === "set_permissions" ? { owner: args.owner, permissions: args.permissions, merge: args.merge, } : {} ); } ); } ``` -------------------------------------------------------------------------------- /src/api/PaperlessAPI.ts: -------------------------------------------------------------------------------- ```typescript export class PaperlessAPI { constructor( private readonly baseUrl: string, private readonly token: string ) { this.baseUrl = baseUrl; this.token = token; } async request(path: string, options: RequestInit = {}) { const url = `${this.baseUrl}/api${path}`; const headers = { Authorization: `Token ${this.token}`, Accept: "application/json; version=5", "Content-Type": "application/json", "Accept-Language": "en-US,en;q=0.9", }; const response = await fetch(url, { ...options, headers: { ...headers, ...options.headers, }, }); if (!response.ok) { console.error({ error: "Error executing request", url, options, status: response.status, response: await response.json(), }); throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } // Document operations async bulkEditDocuments(documents, method, parameters = {}) { return this.request("/documents/bulk_edit/", { method: "POST", body: JSON.stringify({ documents, method, parameters, }), }); } async postDocument( file: File, metadata: Record<string, string | string[]> = {} ) { const formData = new FormData(); formData.append("document", file); // Add optional metadata fields if (metadata.title) formData.append("title", metadata.title); if (metadata.created) formData.append("created", metadata.created); if (metadata.correspondent) formData.append("correspondent", metadata.correspondent); if (metadata.document_type) formData.append("document_type", metadata.document_type); if (metadata.storage_path) formData.append("storage_path", metadata.storage_path); if (metadata.tags) { (metadata.tags as string[]).forEach((tag) => formData.append("tags", tag) ); } if (metadata.archive_serial_number) { formData.append("archive_serial_number", metadata.archive_serial_number); } if (metadata.custom_fields) { (metadata.custom_fields as string[]).forEach((field) => formData.append("custom_fields", field) ); } const response = await fetch( `${this.baseUrl}/api/documents/post_document/`, { method: "POST", headers: { Authorization: `Token ${this.token}`, }, body: formData, } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); } async getDocuments(query = "") { return this.request(`/documents/${query}`); } async getDocument(id) { return this.request(`/documents/${id}/`); } async searchDocuments(query) { return this.request(`/documents/?query=${encodeURIComponent(query)}`); } async downloadDocument(id, asOriginal = false) { const query = asOriginal ? "?original=true" : ""; const response = await fetch( `${this.baseUrl}/api/documents/${id}/download/${query}`, { headers: { Authorization: `Token ${this.token}`, }, } ); return response; } // Tag operations async getTags() { return this.request("/tags/"); } async createTag(data) { return this.request("/tags/", { method: "POST", body: JSON.stringify(data), }); } async updateTag(id, data) { return this.request(`/tags/${id}/`, { method: "PUT", body: JSON.stringify(data), }); } async deleteTag(id) { return this.request(`/tags/${id}/`, { method: "DELETE", }); } // Correspondent operations async getCorrespondents() { return this.request("/correspondents/"); } async createCorrespondent(data) { return this.request("/correspondents/", { method: "POST", body: JSON.stringify(data), }); } // Document type operations async getDocumentTypes() { return this.request("/document_types/"); } async createDocumentType(data) { return this.request("/document_types/", { method: "POST", body: JSON.stringify(data), }); } // Bulk object operations async bulkEditObjects(objects, objectType, operation, parameters = {}) { return this.request("/bulk_edit_objects/", { method: "POST", body: JSON.stringify({ objects, object_type: objectType, operation, ...parameters, }), }); } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import express from "express"; import { PaperlessAPI } from "./api/PaperlessAPI"; import { registerCorrespondentTools } from "./tools/correspondents"; import { registerDocumentTools } from "./tools/documents"; import { registerDocumentTypeTools } from "./tools/documentTypes"; import { registerTagTools } from "./tools/tags"; // Simple CLI argument parsing const args = process.argv.slice(2); const useHttp = args.includes("--http"); let port = 3000; const portIndex = args.indexOf("--port"); if (portIndex !== -1 && args[portIndex + 1]) { const parsed = parseInt(args[portIndex + 1], 10); if (!isNaN(parsed)) port = parsed; } async function main() { let baseUrl: string | undefined; let token: string | undefined; if (useHttp) { baseUrl = process.env.PAPERLESS_URL; token = process.env.API_KEY; if (!baseUrl || !token) { console.error( "When using --http, PAPERLESS_URL and API_KEY environment variables must be set." ); process.exit(1); } } else { baseUrl = args[0]; token = args[1]; if (!baseUrl || !token) { console.error( "Usage: paperless-mcp <baseUrl> <token> [--http] [--port <port>]" ); console.error( "Example: paperless-mcp http://localhost:8000 your-api-token --http --port 3000" ); console.error( "When using --http, PAPERLESS_URL and API_KEY environment variables must be set." ); process.exit(1); } } // Initialize API client and server once const api = new PaperlessAPI(baseUrl, token); const server = new McpServer({ name: "paperless-ngx", version: "1.0.0" }); registerDocumentTools(server, api); registerTagTools(server, api); registerCorrespondentTools(server, api); registerDocumentTypeTools(server, api); if (useHttp) { const app = express(); app.use(express.json()); // Store transports for each session const sseTransports: Record<string, SSEServerTransport> = {}; app.post("/mcp", async (req, res) => { try { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); res.on("close", () => { transport.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (error) { console.error("Error handling MCP request:", error); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error", }, id: null, }); } } }); app.get("/mcp", async (req, res) => { res.writeHead(405).end( JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed.", }, id: null, }) ); }); app.delete("/mcp", async (req, res) => { res.writeHead(405).end( JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed.", }, id: null, }) ); }); app.get("/sse", async (req, res) => { console.log("SSE request received"); try { const transport = new SSEServerTransport("/messages", res); sseTransports[transport.sessionId] = transport; res.on("close", () => { delete sseTransports[transport.sessionId]; transport.close(); }); await server.connect(transport); } catch (error) { console.error("Error handling SSE request:", error); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error", }, id: null, }); } } }); app.post("/messages", async (req, res) => { const sessionId = req.query.sessionId as string; const transport = sseTransports[sessionId]; if (transport) { await transport.handlePostMessage(req, res, req.body); } else { res.status(400).send("No transport found for sessionId"); } }); app.listen(port, () => { console.log( `MCP Stateless Streamable HTTP Server listening on port ${port}` ); }); } else { const transport = new StdioServerTransport(); await server.connect(transport); } } main().catch((e) => console.error(e.message)); ``` -------------------------------------------------------------------------------- /src/tools/tags.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; export function registerTagTools(server, api) { server.tool( "list_tags", "Retrieve all available tags for labeling and organizing documents. Returns tag names, colors, and matching rules for automatic assignment.", { // No parameters - returns all available tags }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.getTags(); }); server.tool( "create_tag", "Create a new tag for labeling and organizing documents. Tags can have colors for visual identification and automatic matching rules for smart assignment.", { name: z.string().describe("Tag name for labeling and organizing documents (e.g., 'important', 'taxes', 'receipts'). Must be unique and descriptive."), color: z .string() .regex(/^#[0-9A-Fa-f]{6}$/) .optional().describe("Hex color code for visual identification (e.g., '#FF0000' for red, '#00FF00' for green). If not provided, Paperless assigns a random color."), match: z.string().optional().describe("Text pattern to automatically assign this tag to matching documents. Use keywords, phrases, or regular expressions depending on matching_algorithm."), matching_algorithm: z.number().int().min(0).max(4).optional().describe("How to match text patterns: 0=any word, 1=all words, 2=exact phrase, 3=regular expression, 4=fuzzy matching. Default is 0 (any word)."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.createTag(args); } ); server.tool( "update_tag", "Modify an existing tag's name, color, or automatic matching rules. Useful for refining tag organization and improving automatic document classification.", { id: z.number().describe("ID of the tag to update. Use list_tags to find existing tag IDs."), name: z.string().describe("New tag name. Must be unique among all tags."), color: z .string() .regex(/^#[0-9A-Fa-f]{6}$/) .optional().describe("New hex color code for visual identification (e.g., '#FF0000' for red). Leave empty to keep current color."), match: z.string().optional().describe("Text pattern for automatic tag assignment. Empty string removes auto-matching. Use keywords, phrases, or regex depending on matching_algorithm."), matching_algorithm: z.number().int().min(0).max(4).optional().describe("Algorithm for pattern matching: 0=any word, 1=all words, 2=exact phrase, 3=regular expression, 4=fuzzy matching."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.updateTag(args.id, args); } ); server.tool( "delete_tag", "Permanently delete a tag from the system. This removes the tag from all documents that currently use it. Use with caution as this action cannot be undone.", { id: z.number().describe("ID of the tag to permanently delete. This will remove the tag from all documents that currently use it. Use list_tags to find tag IDs."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.deleteTag(args.id); } ); server.tool( "bulk_edit_tags", "Perform bulk operations on multiple tags: set permissions to control access or permanently delete multiple tags at once. Efficient for managing large tag collections.", { tag_ids: z.array(z.number()).describe("Array of tag IDs to perform bulk operations on. Use list_tags to get valid tag IDs."), operation: z.enum(["set_permissions", "delete"]).describe("Bulk operation: 'set_permissions' to control who can use these tags, 'delete' to permanently remove all specified tags from the system."), owner: z.number().optional().describe("User ID to set as owner when operation is 'set_permissions'. Owner has full control over the tags."), permissions: z .object({ view: z.object({ users: z.array(z.number()).optional().describe("User IDs who can see and use these tags"), groups: z.array(z.number()).optional().describe("Group IDs who can see and use these tags"), }).describe("Users and groups with view/use permissions for these tags"), change: z.object({ users: z.array(z.number()).optional().describe("User IDs who can modify these tags (name, color, matching rules)"), groups: z.array(z.number()).optional().describe("Group IDs who can modify these tags"), }).describe("Users and groups with edit permissions for these tags"), }) .optional().describe("Permission settings when operation is 'set_permissions'. Defines who can view/use and modify these tags."), merge: z.boolean().optional().describe("Whether to merge with existing permissions (true) or replace them entirely (false). Default is false."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.bulkEditObjects( args.tag_ids, "tags", args.operation, args.operation === "set_permissions" ? { owner: args.owner, permissions: args.permissions, merge: args.merge, } : {} ); } ); } ``` -------------------------------------------------------------------------------- /src/tools/documents.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; export function registerDocumentTools(server, api) { server.tool( "bulk_edit_documents", "Perform bulk operations on multiple documents simultaneously: set correspondent/type/tags, delete, reprocess, merge, split, rotate, or manage permissions. Efficient for managing large document collections.", { documents: z.array(z.number()).describe("Array of document IDs to perform bulk operations on. Get document IDs from list_documents or search_documents first."), method: z.enum([ "set_correspondent", "set_document_type", "set_storage_path", "add_tag", "remove_tag", "modify_tags", "delete", "reprocess", "set_permissions", "merge", "split", "rotate", "delete_pages", ]).describe("The bulk operation to perform: set_correspondent (assign sender/receiver), set_document_type (categorize documents), set_storage_path (organize file location), add_tag/remove_tag/modify_tags (manage labels), delete (permanently remove), reprocess (re-run OCR/indexing), set_permissions (control access), merge (combine documents), split (separate into multiple), rotate (adjust orientation), delete_pages (remove specific pages)"), correspondent: z.number().optional().describe("ID of correspondent to assign when method is 'set_correspondent'. Use list_correspondents to get valid IDs."), document_type: z.number().optional().describe("ID of document type to assign when method is 'set_document_type'. Use list_document_types to get valid IDs."), storage_path: z.number().optional().describe("ID of storage path to assign when method is 'set_storage_path'. Storage paths organize documents in folder hierarchies."), tag: z.number().optional().describe("Single tag ID to add or remove when method is 'add_tag' or 'remove_tag'. Use list_tags to get valid IDs."), add_tags: z.array(z.number()).optional().describe("Array of tag IDs to add when method is 'modify_tags'. Use list_tags to get valid IDs."), remove_tags: z.array(z.number()).optional().describe("Array of tag IDs to remove when method is 'modify_tags'. Use list_tags to get valid IDs."), permissions: z .object({ owner: z.number().nullable().optional().describe("User ID to set as document owner, or null to remove ownership"), set_permissions: z .object({ view: z.object({ users: z.array(z.number()).describe("User IDs granted view permission"), groups: z.array(z.number()).describe("Group IDs granted view permission"), }).describe("Users and groups who can view these documents"), change: z.object({ users: z.array(z.number()).describe("User IDs granted edit permission"), groups: z.array(z.number()).describe("Group IDs granted edit permission"), }).describe("Users and groups who can edit these documents"), }) .optional().describe("Specific permission settings for users and groups"), merge: z.boolean().optional().describe("Whether to merge with existing permissions (true) or replace them (false)"), }) .optional().describe("Permission settings when method is 'set_permissions'. Controls who can view and edit the documents."), metadata_document_id: z.number().optional().describe("Source document ID when merging documents. The metadata from this document will be preserved."), delete_originals: z.boolean().optional().describe("Whether to delete original documents after merge/split operations. Use with caution."), pages: z.string().optional().describe("Page specification for delete_pages method. Format: '1,3,5-7' to delete pages 1, 3, and 5 through 7."), degrees: z.number().optional().describe("Rotation angle in degrees when method is 'rotate'. Use 90, 180, or 270 for standard rotations."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); const { documents, method, ...parameters } = args; return api.bulkEditDocuments(documents, method, parameters); } ); server.tool( "post_document", "Upload a new document to Paperless-NGX with metadata. Supports PDF, images (PNG/JPG/TIFF), and text files. Automatically processes for OCR and indexing.", { file: z.string().describe("Base64 encoded file content. Convert your file to base64 before uploading. Supports PDF, images (PNG, JPG, TIFF), and text files."), filename: z.string().describe("Original filename with extension (e.g., 'invoice.pdf', 'receipt.png'). This helps Paperless determine file type and initial document title."), title: z.string().optional().describe("Custom document title. If not provided, Paperless will extract title from filename or document content."), created: z.string().optional().describe("Document creation date in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss). If not provided, uses current date."), correspondent: z.number().optional().describe("ID of the correspondent (sender/receiver) for this document. Use list_correspondents to find or create_correspondent to add new ones."), document_type: z.number().optional().describe("ID of document type for categorization (e.g., Invoice, Receipt, Letter). Use list_document_types to find or create_document_type to add new ones."), storage_path: z.number().optional().describe("ID of storage path to organize document location in folder hierarchy. Leave empty for default storage."), tags: z.array(z.number()).optional().describe("Array of tag IDs to label this document. Use list_tags to find existing tags or create_tag to add new ones."), archive_serial_number: z.string().optional().describe("Custom archive number for document organization and reference. Useful for maintaining external filing systems."), custom_fields: z.array(z.number()).optional().describe("Array of custom field IDs to associate with this document. Custom fields store additional metadata."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); const binaryData = Buffer.from(args.file, "base64"); const blob = new Blob([binaryData]); const file = new File([blob], args.filename); const { file: _, filename: __, ...metadata } = args; return api.postDocument(file, metadata); } ); server.tool( "list_documents", "Retrieve paginated list of all documents in the system with basic metadata. Use for browsing document collections or getting document IDs for other operations.", { page: z.number().optional().describe("Page number for pagination (starts at 1). Use this to browse through large document collections."), page_size: z.number().optional().describe("Number of documents per page (default 25, max 100). Larger page sizes return more documents but may be slower."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); const query = new URLSearchParams(); if (args.page) query.set("page", args.page); if (args.page_size) query.set("page_size", args.page_size); return api.getDocuments(query.toString() ? `?${query.toString()}` : ""); } ); server.tool( "get_document", "Get complete details for a specific document including full metadata, content preview, tags, correspondent, and document type information.", { id: z.number().describe("Unique document ID. Get this from list_documents or search_documents results. Returns full document metadata, content preview, and associated tags/correspondent/type."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.getDocument(args.id); } ); server.tool( "search_documents", "Search through documents using full-text search across content, titles, tags, and metadata. Supports advanced query syntax and filtering.", { query: z.string().describe("Search query to find documents. Supports full-text search through document content, titles, tags, and metadata. Use keywords, phrases in quotes, or advanced syntax like 'tag:important' or 'correspondent:john' for targeted searches."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); return api.searchDocuments(args.query); } ); server.tool( "download_document", "Download a document file as base64-encoded data. Choose between original uploaded file or processed/archived version with OCR improvements.", { id: z.number().describe("Document ID to download. Get this from list_documents, search_documents, or get_document results."), original: z.boolean().optional().describe("Whether to download the original uploaded file (true) or the processed/archived version (false, default). Original files preserve exact formatting but may not include OCR improvements."), }, async (args, extra) => { if (!api) throw new Error("Please configure API connection first"); const response = await api.downloadDocument(args.id, args.original); return { blob: Buffer.from(await response.arrayBuffer()).toString("base64"), filename: response.headers .get("content-disposition") ?.split("filename=")[1] ?.replace(/"/g, "") || `document-${args.id}`, }; } ); } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "lib": [ "es2016", "ES2015" ], // "jsx": "preserve", /* Specify what JSX code is generated. */ // "libReplacement": true, /* Enable lib replacement. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "CommonJS", "moduleResolution": "node", // "rootDir": "./", /* Specify the root folder within your source files. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ // "resolveJsonModule": true, /* Enable importing .json files. */ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ "outDir": "build", // "removeComments": true, /* Disable emitting comments. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ // "newLine": "crlf", /* Set the newline character for emitting files. */ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ /* Interop Constraints */ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } } ```