paperless-mcp
by dguastaf
README.md
# paperless-mcp
An MCP server that exposes a self-hosted [Paperless-ngx](https://docs.paperless-ngx.com/) instance as tools an
MCP client (Claude Desktop, Claude Code) can call — search documents, fetch a document's OCR'd text, and list
tags/document types/correspondents/custom fields. Paperless already does OCR and full-text indexing; this
server doesn't add its own index or vector store, it just gives Claude a way to query Paperless's.
Designed to run **LAN-only**: no public exposure, no router port-forwarding. Only reachable from MCP clients
on the home network.
## Two independent auth layers
1. **This server → Paperless** — a Paperless API token, sent as `Authorization: Token <token>` on every
request to Paperless. Generated from a **dedicated read-only Paperless user** (not your admin account), so
read-only is enforced by Paperless itself, not just by this server's code never calling write endpoints.
2. **MCP client → this server** — a static bearer token (`Authorization: Bearer <token>`), checked before any
request reaches the MCP transport. The LAN isn't a trust boundary by itself, and these documents include
tax/financial data, so this server should never be reachable without it.
## 1. Paperless-side setup
1. In Paperless's admin UI, create a new user (e.g. `mcp-readonly`) and grant it **view-only** permissions on
documents (and whatever tags/types/correspondents/custom fields it should be able to list) — not the admin
account.
2. Log in as that user and generate an API token: user profile → "API Auth Token", or `POST /api/token/` with
that user's username/password.
3. Verify read-only is actually enforced, independent of this server's code:
```bash
curl -i -X PATCH \
-H "Authorization: Token <token>" \
-H "Content-Type: application/json" \
-d '{"title": "should not work"}' \
http://<paperless-host>/api/documents/<some-id>/
```
This should come back `403 Forbidden`.
4. Confirm the base URL this server should use — see "Networking on naspi" below.
## 2. Configure
```bash
cp .env.example .env
```
Fill in:
- `PAPERLESS_BASE_URL` — how this container reaches Paperless (see below).
- `PAPERLESS_API_TOKEN` — the read-only user's token from step 1.
- `MCP_BEARER_TOKEN` — a long random string (e.g. `openssl rand -hex 32`). This is what MCP clients must send.
- `PORT` — defaults to `8420`.
- `LAN_BIND_IP` — required. naspi's LAN-facing IP; `docker compose up` refuses to start without it, so port
8420 never binds to a public interface by accident. See "Networking on naspi" below.
## 3. Run locally (before containerizing)
```bash
npm install
npm test # unit tests — no live Paperless instance needed
npm run dev # starts the server against your real Paperless instance
```
Then use the MCP inspector to call tools manually and confirm real results come back:
```bash
npx @modelcontextprotocol/inspector
```
Point it at `http://localhost:8420/mcp` with header `Authorization: Bearer <MCP_BEARER_TOKEN>`, and try
`search_documents`, `get_document`, and `list_facets`.
## 4. Networking on naspi
Two options, in order of preference:
- **Join Paperless's existing Docker network** (preferred — internal, no LAN hop). Find its network name with
`docker network ls` / `docker inspect <paperless-webserver-container>`, then set that as the `external`
network name in `docker-compose.yml`, and set `PAPERLESS_BASE_URL` to the Paperless container's name on that
network (e.g. `http://paperless-webserver-1:8000`).
- **Reach Paperless over the LAN IP/port** if the containers can't share a network, e.g.
`PAPERLESS_BASE_URL=http://10.4.0.100:<paperless-port>`.
Set `LAN_BIND_IP` in `.env` to naspi's LAN-only IP so docker-compose publishes port 8420 only on that
interface — required even if naspi doesn't currently have a public IP, since it's a cheap guard against
future network changes exposing this server by accident.
## 5. Deploy
```bash
docker compose up -d --build
```
Verify it's LAN-only:
```bash
# From a device on the home network — should succeed (401 without the bearer token is expected/correct):
curl -i http://10.4.0.100:8420/healthz
# From outside the LAN — should fail/time out, confirming no public exposure.
```
## 6. Connect an MCP client
Point Claude Desktop / Claude Code's remote MCP server config at:
- URL: `http://10.4.0.100:8420/mcp`
- Header: `Authorization: Bearer <MCP_BEARER_TOKEN>`
Then ask it something like *"what does error code E4 mean on my dryer"* or *"did I take the standard
deduction on my 2024 taxes and what was my AGI"* — it should call `list_facets`/`search_documents` to find the
right document, `get_document` to read it, and answer from the actual OCR'd content.
## Maintenance
- **Regenerating tokens**: revoke/regenerate the Paperless API token from the `mcp-readonly` user's profile;
update `.env` and redeploy. Rotate `MCP_BEARER_TOKEN` the same way — update `.env`, redeploy, and update
every MCP client's config.
- **Redeploying**: `docker compose up -d --build` picks up code or `.env` changes.
- **Scaling to more MCP servers on naspi later**: keep each server on its own port for now. If a second one
gets added, put a single LAN-only reverse proxy (e.g. Caddy) in front of all of them with path routing
(`/mcp/paperless`, `/mcp/other`) instead of tracking a growing list of ports — this server doesn't need to
change for that, only how it's fronted.