@orellbuehler/paperless-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orell Bühler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # paperless-mcp
2
+
3
+ MCP server for [Paperless-ngx](https://docs.paperless-ngx.com/) that exposes the REST API as tools for AI agents. Includes optional semantic search via local vector embeddings.
4
+
5
+ ## Install
6
+
7
+ The package is published as [`@orellbuehler/paperless-mcp`](https://www.npmjs.com/package/@orellbuehler/paperless-mcp) and runs directly with `npx` — no clone or build needed:
8
+
9
+ ```bash
10
+ claude mcp add paperless \
11
+ --env PAPERLESS_URL=https://your-paperless-instance.example.com \
12
+ --env PAPERLESS_TOKEN=your-api-token \
13
+ -- npx -y @orellbuehler/paperless-mcp
14
+ ```
15
+
16
+ See [Usage with Claude Code](#usage-with-claude-code) for the equivalent JSON config.
17
+
18
+ Semantic search is off by default. To enable it, set `EMBEDDINGS_ENABLED=true`; the `better-sqlite3` and `sqlite-vec` native modules are installed automatically as optional dependencies (this requires a build toolchain on your platform). The core document tools work without them.
19
+
20
+ ## Setup (from source)
21
+
22
+ For local development, clone the repo and build the `dist/` output:
23
+
24
+ ```bash
25
+ npm install
26
+ npm run build
27
+ ```
28
+
29
+ ## Usage with Claude Code
30
+
31
+ 1. Get your API token from Paperless-ngx (Settings > Administration, or `POST /api/token/`)
32
+
33
+ 2. Set the environment variables by editing `~/.claude/settings.json`. Using the published package:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "paperless": {
39
+ "command": "npx",
40
+ "args": ["-y", "@orellbuehler/paperless-mcp"],
41
+ "env": {
42
+ "PAPERLESS_URL": "https://your-paperless-instance.example.com",
43
+ "PAPERLESS_TOKEN": "your-api-token"
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ If you built from source instead, use `"command": "node"` with `"args": ["/path/to/paperless-mcp/dist/index.js"]`. To enable semantic search, add `"EMBEDDINGS_ENABLED": "true"` (and `"OPENAI_API_KEY"` if using the OpenAI embedding provider).
51
+
52
+ 3. Restart Claude Code. The tools will be available immediately.
53
+
54
+ 4. If you enabled semantic search, run `sync_embeddings` to index your documents.
55
+
56
+ ## Updating
57
+
58
+ The server runs from the compiled `dist/` output, so updating is just rebuild + restart — there's no need to re-run `claude mcp add` (the launch command and path don't change):
59
+
60
+ ```bash
61
+ git pull # if you track a remote
62
+ npm install # only if dependencies changed
63
+ npm run build # recompile src/ -> dist/
64
+ ```
65
+
66
+ Then restart Claude Code (or your MCP client) so it re-spawns the server with the new build. Verify with `claude mcp list` (should show `paperless ✓ connected`) or run `/mcp` inside a session.
67
+
68
+ To change connection settings (URL, token, embedding provider), edit the `env` block in your config, or re-register the server:
69
+
70
+ ```bash
71
+ claude mcp remove paperless
72
+ claude mcp add paperless --scope user \
73
+ --env PAPERLESS_URL=http://localhost:8000 \
74
+ --env PAPERLESS_TOKEN=your-api-token \
75
+ -- node /path/to/paperless-mcp/dist/index.js
76
+ ```
77
+
78
+ ## Available Tools
79
+
80
+ ### Core API Tools
81
+
82
+ | Category | Tools |
83
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
84
+ | Search | `search_documents`, `search_autocomplete` |
85
+ | Documents | `list_documents`, `get_document`, `get_documents`, `download_document`, `update_document`, `delete_document`, `upload_document` |
86
+ | Document details | `get_document_metadata`, `get_document_suggestions`, `get_document_notes`, `add_document_note`, `delete_document_note` |
87
+ | Bulk operations | `bulk_edit_documents`, `get_next_asn` |
88
+ | Correspondents | `list_correspondents`, `get_correspondent`, `create_correspondent`, `update_correspondent`, `delete_correspondent` |
89
+ | Document types | `list_document_types`, `get_document_type`, `create_document_type`, `update_document_type`, `delete_document_type` |
90
+ | Tags | `list_tags`, `get_tag`, `create_tag`, `update_tag`, `delete_tag` |
91
+ | Saved views | `list_saved_views`, `get_saved_view`, `create_saved_view`, `update_saved_view` |
92
+ | Storage paths | `list_storage_paths`, `get_storage_path`, `create_storage_path`, `update_storage_path` |
93
+ | Custom fields | `list_custom_fields`, `get_custom_field`, `create_custom_field`, `update_custom_field` |
94
+ | Users | `list_users`, `get_user`, `create_user`, `update_user` |
95
+ | Groups | `list_groups`, `get_group`, `create_group`, `update_group` |
96
+ | Paperless workflows | `list_workflows`, `get_workflow`, `create_workflow`, `update_workflow` |
97
+ | System | `get_status`, `get_statistics`, `list_tasks` |
98
+
99
+ > **Note:** `list_documents` and `search_documents` return document metadata only (no OCR text) to keep responses small. Use `get_document` (single) or `get_documents` (batch) to retrieve full content.
100
+ >
101
+ > Saved views, users/groups, and workflows support read + create + update only — no delete tools (use the Paperless web UI to delete). User management covers accounts and group membership; it does not set per-document permissions. Notes support add and delete only (no edit), so there is no note-editing tool.
102
+
103
+ ### Extended Tools
104
+
105
+ | Category | Tools | Description |
106
+ | --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- |
107
+ | Semantic search | `semantic_search`, `sync_embeddings`, `embedding_status` | Vector similarity search using local sqlite-vec database |
108
+ | Content | `get_document_content` | Extract OCR'd text content from documents |
109
+ | Workflows | `auto_classify_document`, `process_inbox`, `bulk_tag_by_content` | AI-assisted classification and bulk operations |
110
+ | Helpers | `get_documents_by_correspondent`, `monthly_summary`, `upload_from_url` | Convenience tools for common workflows |
111
+
112
+ ## Environment Variables
113
+
114
+ | Variable | Required | Description |
115
+ | ---------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `PAPERLESS_URL` | Yes | Base URL of your Paperless-ngx instance |
117
+ | `PAPERLESS_TOKEN` | Yes | API token. In `stdio` mode this is the user's token; in `http` mode it is the admin/indexer token (builds the shared embedding index and gates `sync_embeddings`) |
118
+ | `MCP_TRANSPORT` | No | `stdio` (default) or `http` |
119
+ | `PORT` | No | Port for the HTTP server (default: `3001`, http mode only) |
120
+ | `EMBEDDINGS_ENABLED` | No | Set to `true` to enable semantic search tools (default: off) |
121
+ | `MCP_ALLOWED_ORIGINS` | No | Comma-separated `Origin` allowlist for browser clients (http mode). Empty (default) blocks all cross-origin browser requests; use `*` to allow any |
122
+ | `MCP_ALLOWED_HOSTS` | No | Comma-separated `Host` allowlist for DNS-rebinding protection (http mode). Empty (default) disables host validation |
123
+ | `EMBEDDING_PROVIDER` | No | `openai` or `ollama` (default: `openai`) |
124
+ | `OPENAI_API_KEY` | If using OpenAI | Required for OpenAI embeddings |
125
+ | `OLLAMA_URL` | If using Ollama | Ollama server URL (default: `http://localhost:11434`) |
126
+ | `EMBEDDING_MODEL` | No | Model name (defaults per provider) |
127
+ | `EMBEDDING_DIMENSIONS` | No | Vector dimensions (defaults per provider) |
128
+ | `PAPERLESS_MCP_DATA` | No | Directory for the vector DB (default: `~/.paperless-mcp`) |
129
+
130
+ ## Transports
131
+
132
+ The server supports two transports, selected by `MCP_TRANSPORT`.
133
+
134
+ ### stdio (default)
135
+
136
+ Single-user. The MCP client launches the server as a subprocess and it uses
137
+ `PAPERLESS_TOKEN` for all requests. This is the configuration shown above.
138
+
139
+ ### HTTP (multi-user)
140
+
141
+ Run the server as a shared HTTP service (e.g. a sidecar next to your Paperless-ngx
142
+ deployment) so other users on your network can connect:
143
+
144
+ ```bash
145
+ MCP_TRANSPORT=http PORT=3001 \
146
+ PAPERLESS_URL=https://paperless.example.com \
147
+ PAPERLESS_TOKEN=<admin-token> \
148
+ node dist/index.js
149
+ ```
150
+
151
+ Clients connect to `http://<host>:3001/mcp` and authenticate with **their own**
152
+ Paperless API token via an `Authorization: Bearer <token>` header (or
153
+ `X-Paperless-Token`). Every Paperless call is made with that token, so each user
154
+ only sees the documents their account permits.
155
+
156
+ `PAPERLESS_TOKEN` is the admin/indexer token: it builds the shared semantic-search
157
+ index, and the `sync_embeddings` tool is only available to a session using the
158
+ admin token. `semantic_search` results are filtered through the requesting user's
159
+ token, so users never see documents they cannot access.
160
+
161
+ Non-browser MCP clients (which don't send an `Origin` header) work out of the box.
162
+ Browser-based clients are blocked unless you list their origin in
163
+ `MCP_ALLOWED_ORIGINS`. If the server is reachable on a public hostname, set
164
+ `MCP_ALLOWED_HOSTS` to the expected host(s) for DNS-rebinding protection.
165
+
166
+ ## Run as an HTTP sidecar (Docker Compose)
167
+
168
+ A `Dockerfile` is included. Add the server as a service next to your existing
169
+ Paperless-ngx compose stack:
170
+
171
+ ```yaml
172
+ paperless-mcp:
173
+ build: https://github.com/<you>/paperless-mcp.git
174
+ restart: unless-stopped
175
+ depends_on:
176
+ - webserver
177
+ ports:
178
+ - 3001:3001
179
+ volumes:
180
+ - /mnt/ssd/paperless_ngx/mcp:/data
181
+ environment:
182
+ MCP_TRANSPORT: http
183
+ PORT: 3001
184
+ PAPERLESS_URL: http://webserver:8000
185
+ PAPERLESS_TOKEN: <admin-token>
186
+ PAPERLESS_MCP_DATA: /data
187
+ EMBEDDINGS_ENABLED: "true"
188
+ EMBEDDING_PROVIDER: openai
189
+ OPENAI_API_KEY: <key>
190
+ ```
191
+
192
+ LAN clients connect to `http://<host>:3001/mcp` with their own Paperless API
193
+ token. Run `sync_embeddings` once with the admin token to build the shared
194
+ semantic index.
package/dist/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { PaperlessClient } from "./paperless/client.js";
2
+ const baseUrl = (process.env.PAPERLESS_URL || "").replace(/\/+$/, "");
3
+ const adminToken = process.env.PAPERLESS_TOKEN || "";
4
+ if (!baseUrl || !adminToken) {
5
+ console.error("PAPERLESS_URL and PAPERLESS_TOKEN environment variables are required");
6
+ process.exit(1);
7
+ }
8
+ function csv(value) {
9
+ return (value || "")
10
+ .split(",")
11
+ .map((s) => s.trim())
12
+ .filter(Boolean);
13
+ }
14
+ export const config = {
15
+ baseUrl,
16
+ adminToken,
17
+ transport: (process.env.MCP_TRANSPORT === "http" ? "http" : "stdio"),
18
+ port: parseInt(process.env.PORT || "3001", 10),
19
+ embeddingsEnabled: /^(1|true|yes)$/i.test(process.env.EMBEDDINGS_ENABLED || ""),
20
+ allowedOrigins: csv(process.env.MCP_ALLOWED_ORIGINS),
21
+ allowedHosts: csv(process.env.MCP_ALLOWED_HOSTS),
22
+ };
23
+ export const adminClient = new PaperlessClient(baseUrl, adminToken);
24
+ const clientCache = new Map();
25
+ const MAX_CACHE = 100;
26
+ export function clientFor(token) {
27
+ if (token === adminToken)
28
+ return adminClient;
29
+ const existing = clientCache.get(token);
30
+ if (existing) {
31
+ clientCache.delete(token);
32
+ clientCache.set(token, existing);
33
+ return existing;
34
+ }
35
+ const client = new PaperlessClient(baseUrl, token);
36
+ if (clientCache.size >= MAX_CACHE) {
37
+ const oldest = clientCache.keys().next().value;
38
+ if (oldest !== undefined)
39
+ clientCache.delete(oldest);
40
+ }
41
+ clientCache.set(token, client);
42
+ return client;
43
+ }
@@ -0,0 +1,62 @@
1
+ const EMBEDDING_PROVIDER = (process.env.EMBEDDING_PROVIDER || "openai");
2
+ const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL ||
3
+ (EMBEDDING_PROVIDER === "openai" ? "text-embedding-3-small" : "nomic-embed-text");
4
+ const EMBEDDING_DIMENSIONS = parseInt(process.env.EMBEDDING_DIMENSIONS || (EMBEDDING_PROVIDER === "openai" ? "1536" : "768"), 10);
5
+ if (isNaN(EMBEDDING_DIMENSIONS) || EMBEDDING_DIMENSIONS <= 0) {
6
+ console.error("EMBEDDING_DIMENSIONS must be a positive integer");
7
+ process.exit(1);
8
+ }
9
+ const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
10
+ const OLLAMA_URL = (process.env.OLLAMA_URL || "http://localhost:11434").replace(/\/+$/, "");
11
+ export function getEmbeddingDimensions() {
12
+ return EMBEDDING_DIMENSIONS;
13
+ }
14
+ export function getProviderInfo() {
15
+ return { provider: EMBEDDING_PROVIDER, model: EMBEDDING_MODEL, dimensions: EMBEDDING_DIMENSIONS };
16
+ }
17
+ async function embedOpenAI(texts) {
18
+ if (!OPENAI_API_KEY)
19
+ throw new Error("OPENAI_API_KEY is required when using OpenAI embeddings");
20
+ const res = await fetch("https://api.openai.com/v1/embeddings", {
21
+ method: "POST",
22
+ headers: {
23
+ Authorization: `Bearer ${OPENAI_API_KEY}`,
24
+ "Content-Type": "application/json",
25
+ },
26
+ body: JSON.stringify({
27
+ model: EMBEDDING_MODEL,
28
+ input: texts,
29
+ dimensions: EMBEDDING_DIMENSIONS,
30
+ }),
31
+ });
32
+ if (!res.ok) {
33
+ const body = await res.text();
34
+ throw new Error(`OpenAI embedding error: ${res.status} ${body}`);
35
+ }
36
+ const data = (await res.json());
37
+ return data.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
38
+ }
39
+ async function embedOllama(texts) {
40
+ const res = await fetch(`${OLLAMA_URL}/api/embed`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ model: EMBEDDING_MODEL, input: texts }),
44
+ });
45
+ if (!res.ok) {
46
+ const body = await res.text();
47
+ throw new Error(`Ollama embedding error: ${res.status} ${body}`);
48
+ }
49
+ const data = (await res.json());
50
+ return data.embeddings;
51
+ }
52
+ export async function embed(texts) {
53
+ if (texts.length === 0)
54
+ return [];
55
+ if (EMBEDDING_PROVIDER === "ollama")
56
+ return embedOllama(texts);
57
+ return embedOpenAI(texts);
58
+ }
59
+ export async function embedSingle(text) {
60
+ const [result] = await embed([text]);
61
+ return result;
62
+ }
package/dist/http.js ADDED
@@ -0,0 +1,124 @@
1
+ import { createServer as createHttpServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { config, clientFor } from "./config.js";
5
+ import { createServer } from "./server.js";
6
+ export function extractToken(headers) {
7
+ const auth = headers["authorization"];
8
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) {
9
+ const t = auth.slice("Bearer ".length).trim();
10
+ if (t)
11
+ return t;
12
+ }
13
+ const x = headers["x-paperless-token"];
14
+ if (typeof x === "string" && x.trim())
15
+ return x.trim();
16
+ return null;
17
+ }
18
+ export function originAllowed(origin) {
19
+ if (!origin)
20
+ return true; // non-browser clients don't send Origin
21
+ return config.allowedOrigins.includes("*") || config.allowedOrigins.includes(origin);
22
+ }
23
+ export function hostAllowed(host) {
24
+ if (config.allowedHosts.length === 0)
25
+ return true; // host validation disabled
26
+ if (!host)
27
+ return false;
28
+ return config.allowedHosts.includes(host) || config.allowedHosts.includes(host.split(":")[0]);
29
+ }
30
+ function setCors(res, origin) {
31
+ if (config.allowedOrigins.includes("*")) {
32
+ res.setHeader("Access-Control-Allow-Origin", "*");
33
+ }
34
+ else if (origin && config.allowedOrigins.includes(origin)) {
35
+ res.setHeader("Access-Control-Allow-Origin", origin);
36
+ res.setHeader("Vary", "Origin");
37
+ }
38
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
39
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Paperless-Token, mcp-session-id");
40
+ res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
41
+ }
42
+ async function readBody(req) {
43
+ const chunks = [];
44
+ for await (const c of req)
45
+ chunks.push(c);
46
+ if (chunks.length === 0)
47
+ return undefined;
48
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
49
+ }
50
+ export function startHttpServer() {
51
+ const transports = new Map();
52
+ const httpServer = createHttpServer(async (req, res) => {
53
+ const origin = req.headers.origin;
54
+ setCors(res, origin);
55
+ if (req.method === "OPTIONS") {
56
+ res.writeHead(204).end();
57
+ return;
58
+ }
59
+ if (!hostAllowed(req.headers.host)) {
60
+ res.writeHead(403, { "Content-Type": "application/json" });
61
+ res.end(JSON.stringify({ error: "Host not allowed" }));
62
+ return;
63
+ }
64
+ if (!originAllowed(origin)) {
65
+ res.writeHead(403, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ error: "Origin not allowed" }));
67
+ return;
68
+ }
69
+ const url = new URL(req.url || "/", `http://localhost:${config.port}`);
70
+ if (url.pathname !== "/mcp") {
71
+ res.writeHead(404).end();
72
+ return;
73
+ }
74
+ const rawSessionId = req.headers["mcp-session-id"];
75
+ const sessionId = Array.isArray(rawSessionId) ? rawSessionId[0] : rawSessionId;
76
+ try {
77
+ if (sessionId) {
78
+ const existing = transports.get(sessionId);
79
+ if (!existing) {
80
+ // Unknown/expired session — let the client re-initialize.
81
+ res.writeHead(404, { "Content-Type": "application/json" });
82
+ res.end(JSON.stringify({ error: "Unknown or expired session" }));
83
+ return;
84
+ }
85
+ const body = await readBody(req);
86
+ await existing.handleRequest(req, res, body);
87
+ return;
88
+ }
89
+ // No session id => initialize request: require a token.
90
+ const token = extractToken(req.headers);
91
+ if (!token) {
92
+ res.writeHead(401, { "Content-Type": "application/json" });
93
+ res.end(JSON.stringify({ error: "Missing Paperless token (Authorization: Bearer <token>)" }));
94
+ return;
95
+ }
96
+ const transport = new StreamableHTTPServerTransport({
97
+ sessionIdGenerator: () => randomUUID(),
98
+ onsessioninitialized: (sid) => {
99
+ transports.set(sid, transport);
100
+ },
101
+ onsessionclosed: (sid) => {
102
+ transports.delete(sid);
103
+ },
104
+ });
105
+ transport.onclose = () => {
106
+ if (transport.sessionId)
107
+ transports.delete(transport.sessionId);
108
+ };
109
+ const server = await createServer(clientFor(token));
110
+ await server.connect(transport);
111
+ const body = await readBody(req);
112
+ await transport.handleRequest(req, res, body);
113
+ }
114
+ catch (e) {
115
+ if (!res.headersSent) {
116
+ res.writeHead(500, { "Content-Type": "application/json" });
117
+ res.end(JSON.stringify({ error: String(e) }));
118
+ }
119
+ }
120
+ });
121
+ httpServer.listen(config.port, () => {
122
+ console.error(`paperless-mcp HTTP server listening on :${config.port}/mcp`);
123
+ });
124
+ }
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { config, adminClient } from "./config.js";
4
+ import { createServer } from "./server.js";
5
+ import { startHttpServer } from "./http.js";
6
+ if (config.transport === "http") {
7
+ startHttpServer();
8
+ }
9
+ else {
10
+ const server = await createServer(adminClient);
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ }
@@ -0,0 +1,54 @@
1
+ export class PaperlessClient {
2
+ baseUrl;
3
+ token;
4
+ constructor(baseUrl, token) {
5
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
6
+ this.token = token;
7
+ }
8
+ async fetch(path, options = {}) {
9
+ const res = await fetch(`${this.baseUrl}${path}`, {
10
+ ...options,
11
+ headers: {
12
+ Authorization: `Token ${this.token}`,
13
+ ...(options.body && typeof options.body === "string"
14
+ ? { "Content-Type": "application/json" }
15
+ : {}),
16
+ ...options.headers,
17
+ },
18
+ });
19
+ if (!res.ok) {
20
+ const body = await res.text();
21
+ throw new Error(`${res.status} ${res.statusText}: ${body}`);
22
+ }
23
+ if (res.status === 204)
24
+ return { success: true };
25
+ return res.json();
26
+ }
27
+ async fetchAllPages(path) {
28
+ const all = [];
29
+ let page = 1;
30
+ while (true) {
31
+ const sep = path.includes("?") ? "&" : "?";
32
+ const data = (await this.fetch(`${path}${sep}page=${page}&page_size=100`));
33
+ all.push(...data.results);
34
+ if (!data.next)
35
+ break;
36
+ page++;
37
+ }
38
+ return all;
39
+ }
40
+ async getDocumentContent(id) {
41
+ const doc = (await this.fetch(`/api/documents/${id}/`));
42
+ return doc.content || "";
43
+ }
44
+ download(path) {
45
+ return fetch(`${this.baseUrl}${path}`, { headers: { Authorization: `Token ${this.token}` } });
46
+ }
47
+ upload(path, form) {
48
+ return fetch(`${this.baseUrl}${path}`, {
49
+ method: "POST",
50
+ headers: { Authorization: `Token ${this.token}` },
51
+ body: form,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,44 @@
1
+ export function buildQS(params) {
2
+ const sp = new URLSearchParams();
3
+ for (const [k, v] of Object.entries(params)) {
4
+ if (v === undefined || v === null)
5
+ continue;
6
+ if (Array.isArray(v)) {
7
+ if (v.length === 0)
8
+ continue;
9
+ if (k === "id__in")
10
+ sp.set(k, v.join(","));
11
+ else
12
+ v.forEach((item) => sp.append(k, String(item)));
13
+ }
14
+ else {
15
+ sp.set(k, String(v));
16
+ }
17
+ }
18
+ const s = sp.toString();
19
+ return s ? `?${s}` : "";
20
+ }
21
+ export function ok(data) {
22
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
23
+ }
24
+ export function err(e) {
25
+ return { content: [{ type: "text", text: String(e) }], isError: true };
26
+ }
27
+ function omitContent(doc) {
28
+ if (doc && typeof doc === "object" && "content" in doc) {
29
+ const { content, ...rest } = doc;
30
+ return rest;
31
+ }
32
+ return doc;
33
+ }
34
+ export function summarizeDocs(data) {
35
+ if (Array.isArray(data))
36
+ return data.map(omitContent);
37
+ if (data && typeof data === "object") {
38
+ const obj = data;
39
+ if (Array.isArray(obj.results))
40
+ return { ...obj, results: obj.results.map(omitContent) };
41
+ return omitContent(obj);
42
+ }
43
+ return data;
44
+ }
package/dist/server.js ADDED
@@ -0,0 +1,20 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { config } from "./config.js";
3
+ import { registerCoreTools } from "./tools/core.js";
4
+ import { registerWorkflowTools } from "./tools/workflow.js";
5
+ import { registerHelperTools } from "./tools/helpers.js";
6
+ import { registerUserTools } from "./tools/users.js";
7
+ import { registerAutomationTools } from "./tools/automation.js";
8
+ export async function createServer(client) {
9
+ const server = new McpServer({ name: "paperless-mcp", version: "1.0.0" });
10
+ registerCoreTools(server, client);
11
+ registerWorkflowTools(server, client);
12
+ registerHelperTools(server, client);
13
+ registerUserTools(server, client);
14
+ registerAutomationTools(server, client);
15
+ if (config.embeddingsEnabled) {
16
+ const { registerSearchTools } = await import("./tools/search.js");
17
+ registerSearchTools(server, client);
18
+ }
19
+ return server;
20
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ import { buildQS, ok, err } from "../paperless/format.js";
3
+ const triggersSchema = z
4
+ .array(z.record(z.unknown()))
5
+ .optional()
6
+ .describe("Workflow triggers. Each object: { type: 1=consumption | 2=document added | 3=document updated; " +
7
+ "sources?: number[] (1=consume folder, 2=api upload, 3=mail fetch); filter_filename?; filter_path?; " +
8
+ "filter_has_tags?: number[]; filter_has_correspondent?: number; filter_has_document_type?: number; " +
9
+ "matching_algorithm?: number; match?: string }");
10
+ const actionsSchema = z
11
+ .array(z.record(z.unknown()))
12
+ .optional()
13
+ .describe("Workflow actions. Each object: { type: 1=assignment | 2=removal | 3=email | 4=webhook; " +
14
+ "assign_title?; assign_tags?: number[]; assign_correspondent?: number; assign_document_type?: number; " +
15
+ "assign_storage_path?: number; assign_owner?: number; remove_tags?: number[]; email?: object; webhook?: object }");
16
+ export function registerAutomationTools(server, client) {
17
+ server.tool("list_workflows", "List all Paperless workflows (document automation; replaces the old consumption templates)", {
18
+ page: z.number().optional(),
19
+ page_size: z.number().optional(),
20
+ }, async (params) => {
21
+ try {
22
+ return ok(await client.fetch(`/api/workflows/${buildQS(params)}`));
23
+ }
24
+ catch (e) {
25
+ return err(e);
26
+ }
27
+ });
28
+ server.tool("get_workflow", "Get a single workflow by ID, including its triggers and actions", { id: z.number() }, async ({ id }) => {
29
+ try {
30
+ return ok(await client.fetch(`/api/workflows/${id}/`));
31
+ }
32
+ catch (e) {
33
+ return err(e);
34
+ }
35
+ });
36
+ server.tool("create_workflow", "Create a new workflow with inline triggers and actions", {
37
+ name: z.string(),
38
+ order: z.number().optional(),
39
+ enabled: z.boolean().optional(),
40
+ triggers: triggersSchema,
41
+ actions: actionsSchema,
42
+ }, async (body) => {
43
+ try {
44
+ return ok(await client.fetch("/api/workflows/", {
45
+ method: "POST",
46
+ body: JSON.stringify(body),
47
+ }));
48
+ }
49
+ catch (e) {
50
+ return err(e);
51
+ }
52
+ });
53
+ server.tool("update_workflow", "Update an existing workflow (partial update)", {
54
+ id: z.number(),
55
+ name: z.string().optional(),
56
+ order: z.number().optional(),
57
+ enabled: z.boolean().optional(),
58
+ triggers: triggersSchema,
59
+ actions: actionsSchema,
60
+ }, async ({ id, ...body }) => {
61
+ try {
62
+ return ok(await client.fetch(`/api/workflows/${id}/`, {
63
+ method: "PATCH",
64
+ body: JSON.stringify(body),
65
+ }));
66
+ }
67
+ catch (e) {
68
+ return err(e);
69
+ }
70
+ });
71
+ }