Skip to main content
Glama
dguastaf

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.