@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.
938 lines (744 loc) • 25.9 kB
Markdown
<!-- [](https://mseep.ai/app/nloui-paperless-mcp) -->
# Paperless-NGX MCP Server

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
[](https://cursor.com/install-mcp?name=paperless&config=eyJjb21tYW5kIjoibnB4IC15IEBiYXJ1Y2hpcm8vcGFwZXJsZXNzLW1jcEBsYXRlc3QiLCJlbnYiOnsiUEFQRVJMRVNTX1VSTCI6Imh0dHA6Ly95b3VyLXBhcGVybGVzcy1pbnN0YW5jZTo4MDAwIiwiUEFQRVJMRVNTX0FQSV9LRVkiOiJ5b3VyLWFwaS10b2tlbiJ9fQ%3D%3D)
### Installation
Add these to your MCP config file:
// STDIO mode (recommended for local or CLI use)
```json
"paperless": {
"command": "npx",
"args": [
"-y",
"@baruchiro/paperless-mcp@latest",
],
"env": {
"PAPERLESS_URL": "http://your-paperless-instance:8000",
"PAPERLESS_API_KEY": "your-api-token",
"PAPERLESS_PUBLIC_URL": "https://your-public-domain.com"
}
}
```
// HTTP mode (recommended for Docker or remote use)
```json
"paperless": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"ghcr.io/baruchiro/paperless-mcp:latest",
],
"env": {
"PAPERLESS_URL": "http://your-paperless-instance:8000",
"PAPERLESS_API_KEY": "your-api-token",
"PAPERLESS_PUBLIC_URL": "https://your-public-domain.com"
}
}
```
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
- `https://your-public-domain.com` with your public Paperless-NGX URL (optional, falls back to PAPERLESS_URL)
### Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `PAPERLESS_URL` | Yes | — | Base URL of your Paperless-NGX instance |
| `PAPERLESS_API_KEY` | Yes | — | API token from your Paperless-NGX profile |
| `PAPERLESS_PUBLIC_URL` | No | `PAPERLESS_URL` | Public-facing URL for document links |
| `PAPERLESS_API_VERSION` | No | `5` | Paperless-ngx REST API version. Use `10` for Paperless-ngx v3+. If you see HTTP 406 errors, set this to `10`. |
| `PAPERLESS_MCP_UPLOAD_PATHS` | No | — | Colon-separated list of allowed directories for `file_path` uploads. **Recommended for security.** Example: `/var/uploads:/tmp/scans` |
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 documents with simple filters. Use this for straightforward listing tasks. For full-text queries, structured custom field filtering, or advanced Paperless filters, use `query_documents`.
Parameters:
- page (optional): Page number
- page_size (optional): Number of documents per page
- search (optional): Simple Paperless search term
- correspondent (optional): Correspondent ID
- document_type (optional): Document type ID
- tag (optional): Tag ID
- storage_path (optional): Storage path ID
- created__date__gte (optional): Created date on or after YYYY-MM-DD
- created__date__lte (optional): Created date on or before YYYY-MM-DD
- ordering (optional): Paperless ordering field
- archive_serial_number (optional): Archive serial number
- archive_serial_number__isnull (optional): Whether the archive serial number is empty
- custom_field_query (optional): Raw JSON-encoded Paperless custom field query string
- custom_fields__icontains (optional): Case-insensitive substring match across custom field values
```typescript
list_documents({
page: 1,
page_size: 25
})
```
#### query_documents
Canonical document query tool. Supports full-text querying, simple Paperless search, custom field filters, and documented `/api/documents/` Paperless query parameters.
Parameters:
- page (optional): Page number
- page_size (optional): Number of documents per page
- ordering (optional): Paperless ordering field
- query (optional): Full-text query string
- search (optional): Simple Paperless search term
- more_like_id (optional): Find documents similar to this document ID
- correspondent (optional): Correspondent ID
- document_type (optional): Document type ID
- tag (optional): Tag ID
- storage_path (optional): Storage path ID
- created__date__gte (optional): Created date on or after YYYY-MM-DD
- created__date__lte (optional): Created date on or before YYYY-MM-DD
- custom_field_query (optional): Structured Paperless custom field query using `[field_name_or_id, operator, value]` leaves or `["AND" | "OR", [clause1, clause2]]` groups
- paperless_filters (optional): Additional documented `/api/documents/` Paperless query parameters, passed as key/value pairs
```typescript
// Full-text query
query_documents({
query: "invoice 2024"
})
// Simple search term
query_documents({
search: "acme"
})
// Custom field exact match
query_documents({
custom_field_query: ["Invoice Number", "exact", "12345"]
})
// Custom field empty
query_documents({
custom_field_query: ["OR", [
["Invoice Number", "isnull", true],
["Invoice Number", "exact", ""]
]]
})
// Custom field missing
query_documents({
custom_field_query: ["Invoice Number", "exists", false]
})
// Combined filters
query_documents({
query: "invoice",
tag: 5,
created__date__gte: "2024-01-01",
custom_field_query: ["Invoice Number", "exists", true]
})
// One documented Paperless filter that is not a first-class argument
query_documents({
paperless_filters: {
id__in: [101, 202, 303]
}
})
```
#### get_document
Get a specific document by ID.
Parameters:
- id: Document ID
```typescript
get_document({
id: 123
})
```
#### search_documents
Deprecated compatibility wrapper for full-text search. Prefer `query_documents({ query: ... })` for new integrations.
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
})
```
#### get_document_thumbnail
Get a document thumbnail (image preview) by ID. Returns the thumbnail as a base64-encoded WebP image resource.
Parameters:
- id: Document ID
```typescript
get_document_thumbnail({
id: 123
})
```
#### 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]
})
// Modify custom fields
bulk_edit_documents({
documents: [12, 13],
method: "modify_custom_fields",
add_custom_fields: [
{ field: 2, value: "year" }
],
remove_custom_fields: []
})
// Set an empty custom field value, e.g. a date field used as a pending marker
bulk_edit_documents({
documents: [14],
method: "modify_custom_fields",
add_custom_fields: [
{ field: 9, value: "" }
],
remove_custom_fields: []
})
```
#### post_document
Upload a new document to Paperless-NGX.
**Two upload modes:**
1. **Base64 mode** (traditional): Provide `file` (base64-encoded content) + `filename`
2. **Filesystem mode** (efficient): Provide `file_path` (absolute path on server)
**Security Note:** When using `file_path`, set the `PAPERLESS_MCP_UPLOAD_PATHS` environment variable (colon-separated list of allowed directories) to restrict uploads to specific locations. Without this, any file on the server's filesystem could be uploaded.
Parameters:
- file (optional): Base64 encoded file content. Either `file` or `file_path` required.
- file_path (optional): Absolute path to file on server's filesystem. Either `file` or `file_path` required.
- filename (optional): Name of the file. Required with `file`, optional with `file_path` (derives from path).
- 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
**File size limit:** 100MB for both modes
```typescript
// Base64 mode (traditional)
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",
custom_fields: [1, 2]
})
// Filesystem mode (more efficient for large files)
post_document({
file_path: "/var/uploads/invoice.pdf",
title: "January Invoice",
correspondent: 1,
document_type: 2,
tags: [1, 3]
})
```
### Document Notes
#### list_document_notes
List all notes attached to a document.
Parameters:
- id: Document ID
```typescript
list_document_notes({
id: 123
})
```
#### create_document_note
Add a note to a document. Returns the document's full list of notes.
Parameters:
- id: Document ID
- note: The note text to add
```typescript
create_document_note({
id: 123,
note: "Invoice paid on 2026-06-30 from Commerzbank account."
})
```
#### delete_document_note
⚠️ Delete a single note from a document by its note ID. This operation is irreversible.
Parameters:
- id: Document ID
- note_id: The ID of the note to delete
- confirm: Must be `true` to confirm this destructive operation
```typescript
delete_document_note({
id: 123,
note_id: 5,
confirm: true
})
```
### 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): Number between 0 and 6:
0 - None
1 - Any word
2 - All words
3 - Exact match
4 - Regular expression
5 - Fuzzy word
6 - Automatic
```typescript
create_tag({
name: "Invoice",
color: "#ff0000",
match: "invoice",
matching_algorithm: 5
})
```
### 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): Number between 0 and 6:
0 - None
1 - Any word
2 - All words
3 - Exact match
4 - Regular expression
5 - Fuzzy word
6 - Automatic
```typescript
create_correspondent({
name: "ACME Corp",
match: "ACME",
matching_algorithm: 5
})
```
### 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): Number between 0 and 6:
0 - None
1 - Any word
2 - All words
3 - Exact match
4 - Regular expression
5 - Fuzzy word
6 - Automatic
```typescript
create_document_type({
name: "Invoice",
match: "invoice total amount due",
matching_algorithm: 1
})
```
### Custom Field Operations
#### list_custom_fields
Get all custom fields.
```typescript
list_custom_fields()
```
#### get_custom_field
Get a specific custom field by ID.
Parameters:
- id: Custom field ID
```typescript
get_custom_field({
id: 1
})
```
#### create_custom_field
Create a new custom field.
Parameters:
- name: Custom field name
- data_type: One of "string", "url", "date", "boolean", "integer", "float", "monetary", "documentlink", "select"
- extra_data (optional): Extra data for the custom field, such as select options
```typescript
create_custom_field({
name: "Invoice Number",
data_type: "string"
})
```
#### update_custom_field
Update an existing custom field.
Parameters:
- id: Custom field ID
- name (optional): New custom field name
- data_type (optional): New data type
- extra_data (optional): Extra data for the custom field
```typescript
update_custom_field({
id: 1,
name: "Updated Invoice Number",
data_type: "string"
})
```
#### delete_custom_field
Delete a custom field.
Parameters:
- id: Custom field ID
```typescript
delete_custom_field({
id: 1
})
```
#### bulk_edit_custom_fields
Perform bulk operations on multiple custom fields.
Parameters:
- custom_fields: Array of custom field IDs
- operation: One of "delete"
```typescript
bulk_edit_custom_fields({
custom_fields: [1, 2, 3],
operation: "delete"
})
```
### Mail Operations
Tools for managing Paperless mail accounts and the mail rules that drive
automatic email ingestion. Account passwords/tokens are never exposed: they are
redacted from every tool response.
#### list_mail_accounts
List mail accounts so you can pick the account ID needed when creating a mail
rule. Passwords are redacted.
Parameters:
- page (optional): Page number
- page_size (optional): Number of results per page
```typescript
list_mail_accounts()
```
#### get_mail_account
Get a single mail account by ID. Password/token fields are redacted.
Parameters:
- id: Mail account ID
```typescript
get_mail_account({
id: 1
})
```
#### process_mail_account
Manually trigger Paperless mail processing for one account. This can consume
matching mails according to the account's enabled mail rules.
Parameters:
- id: Mail account ID
```typescript
process_mail_account({
id: 1
})
```
#### list_mail_rules
List mail rules with optional pagination.
Parameters:
- page (optional): Page number
- page_size (optional): Number of results per page
```typescript
list_mail_rules()
```
#### get_mail_rule
Get a single mail rule by ID.
Parameters:
- id: Mail rule ID
```typescript
get_mail_rule({
id: 1
})
```
#### create_mail_rule
Create a mail rule. Use `list_mail_accounts` first to choose the account.
Required parameters:
- name: Rule name
- account: Mail account ID
- folder: IMAP folder to scan (e.g. "INBOX")
Common optional parameters:
- enabled (default true): Whether the rule is active
- filter_from / filter_to / filter_subject / filter_body: Match incoming mail
- maximum_age: Only process mail newer than this many days
- action: 1=Delete, 2=Move to folder, 3=Mark as read, 4=Flag, 5=Tag
- action_parameter: Target folder/tag for the chosen action
- assign_title_from: 1=Subject, 2=Attachment filename, 3=Do not assign
- assign_tags / assign_correspondent / assign_document_type: Metadata to apply
- assign_correspondent_from: 1=None, 2=Mail address, 3=Sender name, 4=Use assign_correspondent
- attachment_type: 1=Attachments only, 2=All files incl. inline
- consumption_scope: 1=Attachments only, 2=Full mail as .eml, 3=Both
- pdf_layout: 0=System default, 1=Text+HTML, 2=HTML+text, 3=HTML only, 4=Text only
```typescript
create_mail_rule({
name: "Invoices",
account: 1,
folder: "INBOX",
filter_subject: "invoice",
action: 3,
attachment_type: 1
})
```
#### update_mail_rule
Patch an existing mail rule. Only the fields you supply are changed.
Parameters:
- id: Mail rule ID
- ...any of the `create_mail_rule` fields to update
```typescript
update_mail_rule({
id: 1,
enabled: false
})
```
#### delete_mail_rule
Delete a mail rule. Requires an explicit confirmation flag. This changes future
mail ingestion behavior but does not delete any existing documents.
Parameters:
- id: Mail rule ID
- confirm: Must be `true` to confirm deletion
```typescript
delete_mail_rule({
id: 1,
confirm: true
})
```
## 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
## Testing
### Unit tests
Run the unit test suite (no external dependencies required):
```bash
npm test
```
### E2E tests
The E2E suite boots an empty Paperless-ngx instance, runs the compiled MCP server, and drives a deterministic serial scenario through `tools/call` requests — creating a tag, correspondent, and document type, uploading a PDF, then exercising list / get / search / download / thumbnail / bulk-edit on the same document. No LLM and no Paperless REST client outside MCP.
**Prerequisites:** Docker, Docker Compose, and `jq`.
```bash
# 1. Build the MCP server
npm run build
# 2. Start Paperless-ngx
docker compose -f docker-compose.e2e.yml up -d
# 3. Wait for Paperless to be ready, then get a token
TOKEN=$(curl -s -X POST http://localhost:8000/api/token/ \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')
# 4. Start the MCP server
node build/index.js --http --port 3001 \
--baseUrl http://localhost:8000 --token "$TOKEN" &
MCP_PID=$!
# 5. Run the E2E tests
MCP_URL=http://localhost:3001/mcp \
PAPERLESS_URL=http://localhost:8000 \
PAPERLESS_TOKEN="$TOKEN" \
npm run test:e2e
# 6. Cleanup
kill "$MCP_PID"
docker compose -f docker-compose.e2e.yml down -v
```
E2E tests also run automatically in CI on every pull request and push to `main`, covering both the `build/index.js` CLI and the published Docker image.
## 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.
#### Per-request API token (HTTP/Docker mode)
In HTTP mode, clients authenticate by supplying a Paperless-NGX API token via the standard `Authorization` header:
```
Authorization: Bearer <paperless-ngx-api-token>
```
The token is passed straight through to Paperless-NGX, so each client's own Paperless permissions are enforced end-to-end. This lets a single server instance serve multiple users, each with their own token. The same behaviour applies to both `/mcp` and `/sse` endpoints.
> **⚠️ Breaking change in v2.0.0 — HTTP mode is now authenticated by default.**
>
> Previously, a request with no `Authorization` header silently fell back to the server-configured `PAPERLESS_API_KEY`, which left the HTTP endpoint open to anyone who could reach the port. As of v2.0.0, requests without a `Bearer` token are rejected with `401 Unauthorized`. The server token is **never** used for unauthenticated requests unless you explicitly opt in with `--no-auth`.
| Scenario | `--no-auth` off (default) | `--no-auth` on |
|---|---|---|
| Client sends `Authorization: Bearer <tok>` | `<tok>` (client-supplied) | `<tok>` (client-supplied) |
| No header, `PAPERLESS_API_KEY` / `--token` set | `401 Unauthorized` | server token |
| No header, no server token | `401 Unauthorized` | `401 Unauthorized` |
**Migrating from v1.x:** if you relied on the old fallback (a single shared `PAPERLESS_API_KEY` with clients that don't send a token), you have two options:
1. **Recommended:** have each client send `Authorization: Bearer <paperless-token>`.
2. **Restore the old behaviour** (trusted/local networks only): start the server with the `--no-auth` flag, e.g. append it to the Docker `command`/args or your CLI invocation. This requires a server token (`PAPERLESS_API_KEY` or `--token`) to be configured.
<details>
<summary>Docker Deployment</summary>
The MCP server can be deployed using Docker and Docker Compose. The Docker image automatically runs in HTTP mode with SSE (Server-Sent Events) support on port 3000.
### Docker Compose Configuration
Create a `docker-compose.yml` file:
```yaml
services:
paperless-mcp:
container_name: paperless-mcp
image: ghcr.io/baruchiro/paperless-mcp:latest
environment:
- PAPERLESS_URL=http://your-paperless-ngx-server:8000
- PAPERLESS_API_KEY=your-paperless-api-key
- PAPERLESS_PUBLIC_URL=https://paperless-ngx.yourpublicurl.com
ports:
- "3000:3000"
restart: unless-stopped
```
Then run:
```bash
docker-compose up -d
```
### Using with Continue VS Code Extension
If you're using the [Continue VS Code extension](https://continue.dev/), you can configure it to use the Dockerized MCP server via SSE.
Create or edit `.continue/mcpServers/paperless-mcp.yaml` at your workspace root:
```yaml
name: Paperless
version: 0.0.1
schema: v1
mcpServers:
- name: Paperless
type: sse
url: http://localhost:3000/sse
```
**Notes:**
- Replace `localhost` with your Docker host's IP address or hostname if running on a remote server
- The Docker container handles authentication via environment variables, so no credentials are needed in the Continue config
- The SSE endpoint is available at `/sse` on the configured port (default: 3000)
</details>
# Credits
This project is a fork of [nloui/paperless-mcp](https://github.com/nloui/paperless-mcp). Many thanks to the original author for their work. Contributions and improvements may be returned upstream.
## Debugging
To debug the MCP server in VS Code, use the following launch configuration:
```json
{
"type": "node",
"request": "launch",
"name": "Debug Paperless MCP (HTTP, ts-node ESM)",
"program": "${workspaceFolder}/node_modules/ts-node/dist/bin.js",
"args": [
"--esm",
"src/index.ts",
"--http",
"--baseUrl",
"http://your-paperless-instance:8000",
"--token",
"your-api-token",
"--port",
"3002"
],
"env": {
"NODE_OPTIONS": "--loader ts-node/esm",
},
"console": "integratedTerminal",
"skipFiles": [
"<node_internals>/**"
]
}
```
**Important:** Before debugging, uncomment the following line in `src/index.ts` (around line 175):
```typescript
// await new Promise((resolve) => setTimeout(resolve, 1000000));
```
This prevents the server from exiting immediately and allows you to set breakpoints and debug the code.