npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smic/paperless-mcp

v0.1.1

Published

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.

Readme

Paperless-NGX MCP Server

An MCP (Model Context Protocol) server for a Paperless-NGX instance. It gives an AI assistant tools to search, read, upload and organise documents, tags, correspondents and document types.

Two things to know before wiring it up:

  • It starts read-only. Everything that can change your Paperless instance is absent from tools/list until you opt in — see Tool access modes.
  • --http requires an authentication secret and binds loopback. It refuses to start without a secret — see Authentication.

Supported versions

| | Supported | | --- | --- | | Paperless-ngx | 2.16.0 – 3.1.x (verified against 3.1.3) | | Paperless-ngx REST API | version 9 | | Node.js | 22 LTS and 24 LTS |

The client asks for REST API version 9 with an Accept: application/json; version=9 header. Version 9 arrived in Paperless-ngx 2.16.0 and is still accepted by 3.1.x; instances at 2.15.x or older answer 406 Not Acceptable. That becomes an explicit error naming the API version the instance offers (from its X-Api-Version header) and what to upgrade — the upstream response body is never read, logged or forwarded into a tool result.

Quick Start

npm install -g @smic/paperless-mcp

Get an API token from Paperless-NGX: log in, click your username (top right) → My Profile → the circular arrow button.

Then point your MCP client at it — for the Claude desktop app, edit ~/Library/Application Support/Claude/claude_desktop_config.json; for the Cline VSCode extension, ~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json:

{
  "mcpServers": {
    "paperless": {
      "command": "paperless-mcp",
      "env": {
        "PAPERLESS_URL": "http://your-paperless-instance:8000",
        "PAPERLESS_API_TOKEN": "<API_TOKEN>"
      }
    }
  }
}

The global install puts a single executable called paperless-mcp on your PATH. Without one, use "command": "npx" with "args": ["-y", "@smic/paperless-mcp"].

The base URL and token can also be passed positionally, which is what older configurations do — "args": ["-y", "@smic/paperless-mcp", "http://your-paperless-instance:8000", "<API_TOKEN>"].

[!WARNING] A positional token is a process argument: it is visible in ps to every user on the host, in shell history, and in docker inspect. On anything shared, use the environment — or better, PAPERLESS_API_TOKEN_FILE. See Configuration.

That is it. You can now ask the assistant things like "show me all documents tagged Invoice", "search for documents containing tax return", or — once writes are enabled — "create a tag called Receipts with color #FF0000".

From a Git checkout

What you want if you intend to change anything:

git clone https://github.com/smichaelsen/paperless-mcp.git
cd paperless-mcp
npm ci && npm run build
npm link          # puts `paperless-mcp` on your PATH, pointing at this checkout

Then use "command": "paperless-mcp" with no package name in args, or point command at <checkout>/build/index.js with node.

Configuration

The base URL and token come from positional arguments (paperless-mcp <baseUrl> <token>) or from the environment. Environment variables are consulted only when the matching argument is missing — a positional token always wins, and PAPERLESS_API_TOKEN_FILE is not even read in that case. In --http mode the environment is the only source; positional arguments are ignored.

| Variable | Purpose | | --- | --- | | PAPERLESS_URL | Base URL of your Paperless-NGX instance, e.g. https://paperless.example. | | PAPERLESS_API_TOKEN | Paperless API token. | | PAPERLESS_API_TOKEN_FILE | Path to a file containing the API token. Takes precedence over PAPERLESS_API_TOKEN. | | API_KEY | Deprecated alias for PAPERLESS_API_TOKEN. Still honoured; logs a deprecation notice. | | PAPERLESS_ALLOW_WRITES | Register the write-class tools. Off by default — see Tool access modes. | | PAPERLESS_ALLOW_DESTRUCTIVE | Register the destructive-class tools. Off by default, never implied by PAPERLESS_ALLOW_WRITES. | | PAPERLESS_MCP_AUTH_TOKEN_FILE | --http only, required. Path to a file containing the bearer secret clients must present. Takes precedence over PAPERLESS_MCP_AUTH_TOKEN. | | PAPERLESS_MCP_AUTH_TOKEN | --http only. The bearer secret inline. Prefer the _FILE form. | | PAPERLESS_MCP_ALLOW_UNAUTHENTICATED | --http only. Explicitly start without authentication. Refused unless the bind address is loopback. | | PAPERLESS_MCP_BIND_ADDRESS | --http only. Interface the listener binds to. Default 127.0.0.1 — loopback only. | | PAPERLESS_MCP_ENABLE_LEGACY_SSE | --http only. Register the deprecated GET /sse + POST /messages routes. Off by default. | | PAPERLESS_MCP_MAX_BODY | --http only. Largest accepted JSON body, e.g. 10mb (the default) or 512kb. This is also the upload ceiling: post_document carries the file base64-encoded inside the JSON-RPC body, so 10mb is about 7.5 MB of actual file. | | PAPERLESS_MCP_RATE_LIMIT_MAX | --http only. Requests per window per client address. Default 600; 0 disables rate limiting. Behind a reverse proxy every request shares the proxy's address, so the limit becomes one global bucket and one noisy client 429s everyone — including a caller holding the correct secret. Rate-limit at the proxy instead and raise or disable this. | | PAPERLESS_MCP_RATE_LIMIT_WINDOW_MS | --http only. Rate-limit window in milliseconds. Default 60000. | | PAPERLESS_MCP_ALLOWED_HOSTS | --http only. Comma-separated hostnames accepted in the Host header (ports ignored). Replaces the default localhost,127.0.0.1,[::1] rather than extending it. * disables the check. | | PAPERLESS_MCP_ALLOWED_ORIGINS | --http only. Comma-separated origins accepted in the Origin header. Default: none — a request carrying any Origin is rejected, while requests without one (every non-browser MCP client) pass. * disables the check. |

File-backed credentials. PAPERLESS_API_TOKEN_FILE and PAPERLESS_MCP_AUTH_TOKEN_FILE are the Docker/Kubernetes secrets form and the recommended one: the file is read once at startup, surrounding whitespace (including the trailing newline) is stripped, and an unreadable or empty file aborts startup with a message that never contains the secret. A read-only mount is enough. Neither is accepted as a --flag, on purpose: an argument is visible in ps, in shell history and in docker inspect. The Paperless token does still have the positional paperless-mcp <baseUrl> <token> form, with exactly that exposure; the --http bearer secret has no command-line form at all. Generate one with openssl rand -base64 32 > paperless_mcp_auth.txt.

compose.example.yaml wires both files up as Docker secrets — see Container deployment.

Logging. Operational events go to stderr as single-line JSON. A failed request logs the HTTP method, a normalized endpoint class (/documents/:id/), the status, the duration and an error class — nothing else:

{"level":"error","event":"paperless_request_failed","method":"GET","endpoint":"/documents/:id/","status":500,"duration_ms":34,"error_class":"HttpStatusError"}

Tokens, authorization headers, request bodies, uploaded files, document titles/content and raw Paperless responses are never logged.

Tool access modes

Every tool belongs to exactly one access class:

  • read — cannot change anything in Paperless.
  • write — creates or updates objects. Never deletes one, never replaces a permission set.
  • destructive — deletes documents, pages or objects, or replaces permissions. Effects that cannot be undone from this server.

The process starts read-only, and the two opt-ins are independent: enabling writes does not enable destructive operations. Whatever is not enabled is never registered, so it is absent from tools/list rather than advertised-and-refusing — a model cannot ask for a tool it cannot see, and your client's allowlist has less to cover.

| Mode | Start it with | Tools advertised | | --- | --- | --- | | read-only (default) | nothing to set | 9 | | write | PAPERLESS_ALLOW_WRITES=true or --allow-writes | 16 | | destructive | additionally PAPERLESS_ALLOW_DESTRUCTIVE=true or --allow-destructive | 20 |

The flags may appear anywhere on the command line; they are not mistaken for the positional <baseUrl> <token>. Accepted true values are 1, true, yes, y, on, enable, enabled (case-insensitive); anything unrecognized is treated as off and logged, because a typo must never widen what the server exposes. PAPERLESS_ALLOW_DESTRUCTIVE on its own also enables writes — every destructive operation is a write — and says so in the log. The active mode is logged once at startup:

{"level":"info","event":"tool_access_mode","mode":"write","writes":true,"destructive":false,"tools":16}

bulk_edit_documents

The one tool whose destructiveness depends on an argument: the same method enum spans setting a correspondent and permanently deleting documents. In write mode it is therefore narrowed — method offers only set_correspondent, set_document_type, set_storage_path, add_tag, remove_tag, modify_tags, reprocess, merge, split and rotate, while delete, delete_pages and set_permissions are neither advertised nor accepted by the handler. The delete_originals and permissions arguments are gone from the schema and merge/split are sent with an explicit delete_originals: false, so they create a new document and leave the originals in place. pages stays because Paperless requires it to split; on its own it does nothing, since the method that would delete pages is not reachable. PAPERLESS_ALLOW_DESTRUCTIVE brings the full enum and all arguments back.

Client allowlist and approval policy

Server-side gating decides what exists; the client's allowlist decides what runs without asking. Run the narrowest mode each client needs rather than one permissive server for everything: read-only for an assistant that answers questions about your documents, --allow-writes for one that files and tags incoming mail, --allow-destructive plus an allowlist of nothing for a cleanup session. Give the server its own Paperless account with only the permissions it needs — the access mode is a guard rail in this process; the Paperless permission model is the one an attacker cannot argue with.

The read class is the only one worth auto-approving, and read-only is still not the same as harmless: search_documents and download_document return the contents of your documents, so a prompt injection hidden in a scanned document can use them to find sensitive material and hand it to whatever other tool the assistant has for sending data out (web requests, mail, shell). Ask every time for the write class — update_document and bulk_edit_documents act on many documents at once, so see the documents array first — and ask, and read the arguments, for the destructive class, where nothing can be undone from this server. Keep destructive operations out of unattended runs by starting those processes without PAPERLESS_ALLOW_DESTRUCTIVE, so the tools are not there to be called.

Available Tools

Full parameter lists and descriptions live in the MCP tool schemas, which your client reads from tools/list. This is the map.

| Tool | Class | What it does | | --- | --- | --- | | get_document | read | Full metadata, content preview, tags, correspondent and type for one document. | | search_documents | read | Full-text search. Returns metadata without the OCR content field. There is no list_documents: this enumerates too, via page/page_size. | | download_document | read | The file as base64. original: true for the uploaded original instead of the archived version. | | post_document | write | Upload a new document (base64 file + filename), optionally with title, created date, correspondent, type, storage path, tags, ASN, custom fields. | | update_document | write | Correct title, created date, correspondent, type, storage path, tags or ASN on an existing document. tags replaces the whole list — use bulk_edit_documents with add_tag/remove_tag to change one. | | bulk_edit_documents | write (narrowed) / destructive | Act on many documents at once; see above for what write mode leaves out. | | list_tags | read | Paginated, 25 per page (page, page_size). | | get_tag | read | One tag by ID — resolves a name for an ID a document references. | | create_tag | write | name, optional color, match, matching_algorithm. | | update_tag | write | Change an existing tag's name, color or matching rules. | | delete_tag | destructive | Removes the tag from every document that uses it. Cannot be undone. | | bulk_edit_tags | destructive | set_permissions or delete across many tags. | | list_correspondents | read | Paginated, like list_tags. | | get_correspondent | read | One correspondent by ID. | | create_correspondent | write | name, optional match, matching_algorithm. | | bulk_edit_correspondents | destructive | set_permissions or delete across many correspondents. | | list_document_types | read | Paginated, like list_tags. | | get_document_type | read | One document type by ID. | | create_document_type | write | name, optional match, matching_algorithm. | | bulk_edit_document_types | destructive | set_permissions or delete across many document types. |

matching_algorithm takes two different forms, and they are not interchangeable. create_correspondent and create_document_type take the string enum any | all | exact | regular expression | fuzzy. create_tag and update_tag take the equivalent integer instead: 0=any, 1=all, 2=exact, 3=regular expression, 4=fuzzy. Passing a string to a tag tool is a validation error, and vice versa.

readOnlyHint is true for the read class only; openWorldHint is false throughout, since every tool talks to exactly one configured instance. destructiveHint is true for anything that overwrites rather than adds — including update_document and update_tag, because tags replaces the whole list and the nullable relations clear a field outright, and every bulk_edit_* object tool, because both of its operations qualify: delete removes objects, and set_permissions with merge: false replaces the permission set. post_document, create_* and bulk_edit_documents are not idempotent; everything else is.

The server reports a clear error when the URL or token is wrong, when Paperless is unreachable, when an operation fails, when parameters are invalid, and when the instance does not support the requested REST API version (see Supported versions).

Running the MCP Server

Both transports honour --allow-writes and --allow-destructive (and their environment equivalents); without them the server is read-only.

stdio (default)

How MCP clients such as Claude Desktop launch it, and what the Quick Start configures.

paperless-mcp <baseUrl> <token>
npm run start -- <baseUrl> <token>    # from a checkout

HTTP (Streamable HTTP transport)

--http serves MCP over HTTP, with --port (default 3000). The URL and token are read from the environment only. It requires a bearer secret and refuses to start without one:

PAPERLESS_URL=http://localhost:8000 \
PAPERLESS_API_TOKEN_FILE=/run/secrets/paperless_token \
PAPERLESS_MCP_AUTH_TOKEN_FILE=/run/secrets/paperless_mcp_auth \
  paperless-mcp --http --port 3000
  • The MCP API is at POST /mcp; GET and DELETE /mcp answer 405.
  • The listener binds 127.0.0.1 — loopback only — unless PAPERLESS_MCP_BIND_ADDRESS says otherwise.
  • Every request must carry Authorization: Bearer <secret>.
  • Requests are handled statelessly (StreamableHTTPServerTransport): no session id, no session table, and a fresh McpServer and transport per connection, so no mutable state is shared between clients.
  • The deprecated GET /sse + POST /messages routes are not registered unless PAPERLESS_MCP_ENABLE_LEGACY_SSE is set.
  • GET /healthz and GET /readyz answer container and load-balancer probes without credentials, and say nothing but a status code and one fixed field.

Middleware order is deliberate and security-relevant: Host/Origin check → rate limit → authentication → body parsing → transport. Rate limits, body-size limits, probe caching and the reasoning behind all of it: docs/http-transport.md.

Authentication

The HTTP listener hands out the whole enabled tool surface — and it holds a Paperless API token — so every MCP transport route needs a shared bearer secret, from PAPERLESS_MCP_AUTH_TOKEN_FILE (preferred) or PAPERLESS_MCP_AUTH_TOKEN.

curl -s http://127.0.0.1:3000/mcp \
  -H "Authorization: Bearer <SECRET>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

Every failure mode answers the same 401, the comparison is constant time, and the secret never reaches a log line — see docs/http-transport.md for that and the rest of the hardening.

[!IMPORTANT] --http exits with a message rather than starting unauthenticated. If you genuinely want an open listener — local development, nothing else on the machine — set PAPERLESS_MCP_ALLOW_UNAUTHENTICATED=true. It is refused in combination with a non-loopback PAPERLESS_MCP_BIND_ADDRESS.

Network exposure and TLS

The loopback default means nothing outside this host can reach the listener, and the documented Compose deployment publishes no host port at all. Reaching it from elsewhere is an explicit opt-in — PAPERLESS_MCP_BIND_ADDRESS=0.0.0.0 — which logs a http_bind_not_loopback warning at startup, because from that moment the port is only as private as the network around it.

This server speaks plain HTTP and does not terminate TLS. Anything beyond loopback — and certainly anything beyond a trusted private network — must go through a reverse proxy (nginx, Caddy, Traefik) or a tunnel (Cloudflare Tunnel, Tailscale) that terminates TLS and forwards to the listener. Without that, the bearer secret crosses the wire in clear text on every request. If the proxy reaches the server under a name other than a loopback one, add it to PAPERLESS_MCP_ALLOWED_HOSTS.

DNS-rebinding protection

Any web page can POST to http://localhost:3000/mcp, so the Host and Origin headers are validated before a request reaches a transport: by default only loopback hostnames are accepted and every browser origin is rejected. The host list replaces the default rather than extending it, so keep the loopback names if you still connect that way, and add any other name you reach the server under — a Docker service name, a reverse proxy — otherwise those requests get a 403:

PAPERLESS_MCP_ALLOWED_HOSTS=localhost,127.0.0.1,[::1],paperless-mcp

[!NOTE] This is a header check, not an access control and not a network restriction: the Host header is written by the caller, so any host that can reach the port can send Host: localhost and pass it. What keeps the network out is the loopback default bind and bearer authentication. What this check contributes is the DNS-rebinding case specifically — a browser cannot be tricked into driving the server from a page on another origin.

The configuration in effect is printed at startup:

{"level":"info","event":"http_server_listening","address":"127.0.0.1","port":3000,"transport":"streamable-http","session_mode":"stateless","auth":"bearer (PAPERLESS_MCP_AUTH_TOKEN_FILE)","legacy_sse":"disabled","max_body":"10mb","rate_limit":"600/60000ms","allowed_hosts":"localhost,127.0.0.1,[::1]","allowed_origins":"(none)"}

Container deployment

ghcr.io/smichaelsen/paperless-mcp:latest is built and pushed on every push to main (.github/workflows/docker-publish.yml). Two tags are published, latest and the branch name main, pointing at the same digest; there is no version tag — the image tracks main, not a release. If an anonymous docker pull is denied, the GHCR package is still private: switch its visibility to public under Packages → paperless-mcp → Package settings.

docker pull ghcr.io/smichaelsen/paperless-mcp:latest
# or build it yourself:
docker build -t paperless-mcp .

docker run --rm --init \
  -e PAPERLESS_URL=https://paperless.example \
  -e PAPERLESS_API_TOKEN=your-api-token \
  -p 127.0.0.1:3000:3000 \
  paperless-mcp

--init matters: PID 1 in this image is node, and the kernel applies no default signal action to PID 1, so without an init docker stop waits out the full timeout and then SIGKILLs. The Compose example sets init: true.

The image

Dockerfile builds a three-stage image whose final stage contains the compiled JavaScript, the production dependency tree, and nothing else.

| | | | --- | --- | | Base | node:24-bookworm-slim, pinned by digest | | Runs as | node (uid 1000) — never root | | Not in the final stage | TypeScript, vitest, ts-node, the src/ tree, the test suite, npm/npx/corepack | | Writable paths | none; the Compose example mounts the root filesystem read-only | | Persistent state | none — no volume is needed, and deleting the container loses nothing |

The pin is the digest of the multi-arch OCI index, so it still resolves on amd64 and arm64, rather than the tag alone: node:24-bookworm-slim names a different image every few days, and a tag-only pin makes builds unreproducible. The tag is kept next to the digest as documentation. Dependabot (.github/dependabot.yml) watches npm, this Dockerfile and the Actions used by CI, which is what makes a digest pin maintainable rather than a way to freeze in old Debian packages. npm, npx and corepack are deleted from the final stage: a running MCP server never uses them, npm's own dependency tree would show up in every image scan, and a process that manages to execute in the container then has no package installer to hand.

Hardened Compose example

compose.example.yaml is the recommended deployment. Copy it to compose.yaml, create the two secret files it documents, and adjust PAPERLESS_URL.

| Control | Setting | | --- | --- | | Published host port | none — expose: ["3000"], no ports: | | Root filesystem | read_only: true, with a single noexec,nosuid,nodev tmpfs on /tmp | | Capabilities | cap_drop: [ALL] | | Privilege escalation | security_opt: [no-new-privileges:true] | | User | user: "1000:1000", on top of the image's own USER node | | Resources | 1 CPU, 256 MB, 128 PIDs | | Restart policy | unless-stopped | | Log rotation | json-file, 10 MB × 3 | | Docker socket | not mounted | | Application-data mounts | none — the only mounts are the two read-only secret files | | Secrets | Docker secrets via PAPERLESS_API_TOKEN_FILE and PAPERLESS_MCP_AUTH_TOKEN_FILE |

Nothing is published to the host, so the server is not reachable from the LAN or the internet by default. Other services on the Compose network reach it at http://paperless-mcp:3000/mcp; anything beyond that network goes through a reverse proxy or tunnel attached to the same network, which is also what terminates TLS.

Two settings in the file are load-bearing and easy to get wrong:

  • PAPERLESS_MCP_BIND_ADDRESS: "0.0.0.0". The listener binds loopback by default, which inside a container means "not reachable from the Compose network at all". The container network is the boundary here and the bearer secret is the access control.
  • PAPERLESS_MCP_ALLOWED_HOSTS keeps the loopback names. That list replaces the default rather than extending it, and the image's HEALTHCHECK calls http://127.0.0.1:3000/healthz — drop 127.0.0.1 and every healthcheck becomes a 403.

The tool access mode is read-only unless you say otherwise; the example has PAPERLESS_ALLOW_WRITES and PAPERLESS_ALLOW_DESTRUCTIVE commented out so widening the surface is a deliberate edit.

The image's HEALTHCHECK polls GET /healthz, which never touches Paperless — a Paperless outage must not restart this container. GET /readyz is the one that reports on the upstream (503 when it is down), for a reverse proxy or load balancer to poll. Both answer without credentials and say nothing beyond a status code; see docs/http-transport.md.

Smoke test

The hardened example is expected to pass a full smoke test that runs from a clean checkout against compose.smoke.yaml, needing no real Paperless instance and no published image. It is a maintainer runbook rather than something a user has to run: docs/container-smoke-test.md.

Development and testing

git clone https://github.com/smichaelsen/paperless-mcp.git
cd paperless-mcp && npm ci
# change things under src/, then run the quality gate:
npm run typecheck   # tsc --noEmit
npm test            # vitest, unit tests only, no network
npm run build       # tsc -> build/index.js (the published bin)
npm run audit:prod  # npm audit --omit=dev --audit-level=high
npm run start -- http://localhost:8000 your-api-token

Those four checks are the CI quality gate (.github/workflows/ci.yml), run on every pull request and on pushes to main, on Node.js 22 and 24. Unit tests mock fetch, so the default run never talks to a Paperless instance. The server is built on the official MCP TypeScript SDK and zod, over the Paperless-NGX REST API.

The tests in tests/integration/ run against a real Paperless-ngx instance and are opt-in: without PAPERLESS_TEST_URL they skip themselves, so they are not part of the CI gate. Point them at a disposable instance — a throwaway container, not your production archive — even though they only ever create their own fixtures, named mcp-it-<random>-… and removed again in the cleanup hook, including on failure.

PAPERLESS_TEST_URL=http://localhost:8000 \
PAPERLESS_TEST_TOKEN=your-api-token \
npm run test:integration

PAPERLESS_TEST_TOKEN is required once the URL is set; PAPERLESS_TEST_UPLOAD=1 additionally runs the upload/consume test, which needs a running consumer and takes up to two minutes.

License and attribution

This project is released under the MIT License.

It is a hard fork of nloui/paperless-mcp, and parts of the tree are still derived from that work: 530 of the 3606 lines under src/ (15%), concentrated in src/api/PaperlessAPI.ts and the document, tag, correspondent and document-type tool modules. Upstream ships no LICENSE file anywhere — not in the repository, not in its npm tarball; its package.json declares "license": "ISC" and "author": "Nick Loui", and has since its first commit.

NOTICE carries that attribution: which files are affected and by how much, the ISC terms the upstream portions come under, and — read this part before relying on it — exactly which pieces of that notice had to be reconstructed, because upstream never published one to quote. NOTICE is listed in files in package.json, so it ships inside the npm package too. Keeping it out of LICENSE is deliberate: a permissive fork states its own grant in LICENSE and the inherited one alongside it, so that nothing reads as though upstream licensed its work under MIT, and so that the MIT text stays machine-detectable.

There are no upstream pull requests and no attempt to stay mergeable with upstream; the two trees have diverged substantially.

Releasing

See RELEASING.md. In short: bump the version on main, then create a GitHub release whose tag matches it, and .github/workflows/npm-publish.yml runs the quality gate and publishes to npm.