npm-mcp
by omichelbraga
README.md
<div align="center">
# npm-mcp
**Model Context Protocol server for [Nginx Proxy Manager](https://nginxproxymanager.com)**
Manage reverse-proxy routing, TLS certificates, access lists, and stream forwards
conversationally — with guardrails that assume you will eventually point it at production.
[](https://www.python.org)
[](https://github.com/jlowin/fastmcp)
[](https://nginxproxymanager.com)
[](#testing)
[](#tool-catalog)
[](https://docs.astral.sh/ruff/)
</div>
---
## Contents
- [Why this exists](#why-this-exists)
- [How it works](#how-it-works)
- [Quick start](#quick-start)
- [Authentication](#authentication)
- [Tool catalog](#tool-catalog)
- [Safety model](#safety-model)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Testing](#testing)
- [Design notes](#design-notes)
---
## Why this exists
Nginx Proxy Manager has a full REST API and no MCP server. This is that server —
but the interesting part isn't the plumbing, it's the constraints.
A reverse proxy is a **single point of failure for everything behind it**. An
agent with write access to one can take down services it was never asked to
touch. So the design starts from that:
> **Tools are generated from the API's own OpenAPI document, not hand-written.**
> The document is pinned in-tree, and a drift test fails CI if the upstream
> surface changes — instead of tools silently 404-ing at runtime.
> **Every result crosses one redaction boundary that fails closed.**
> It raises on anything it can't inspect rather than passing it through.
> **Guardrails are mutation-tested.**
> Every safety control has a test proven to go red when the control is disabled.
---
## How it works
```mermaid
flowchart LR
C["MCP Client"] -->|"Bearer (optional)"| S
subgraph S["npm-mcp"]
direction TB
A["Bearer verifier<br/><i>hmac.compare_digest</i>"] --> G["Guardrails<br/><i>S1 · S2 · S6 · S7 · S8</i>"]
G --> T["66 generated tools"]
T --> R["serialize_result()<br/><i>redact + cap</i>"]
end
S -->|"JWT, auto-refreshed"| N["Nginx Proxy Manager"]
P["npm-openapi.json<br/><i>pinned, in-package</i>"] -.->|generates| T
```
Tool signatures are built from the pinned document at import time, so
`create_proxy_host` exposes **18 typed arguments** with real enums — not an
opaque `**kwargs` passthrough.
---
## Quick start
```bash
uv sync
cp .env.example .env # then fill in NPM_URL / NPM_IDENTITY / NPM_SECRET
uv run npm-mcp
```
<details>
<summary><b>Claude Desktop / stdio</b></summary>
```json
{
"mcpServers": {
"npm": {
"command": "uv",
"args": ["run", "npm-mcp"],
"env": {
"NPM_URL": "https://nginx-proxy-manager.example.net",
"NPM_IDENTITY": "npm-mcp@example.net",
"NPM_SECRET": "…",
"NPM_MCP_TRANSPORT": "stdio"
}
}
}
}
```
</details>
<details>
<summary><b>Streamable HTTP</b> — note the <code>/mcp</code> path</summary>
```json
{
"mcpServers": {
"npm": {
"type": "http",
"url": "https://npm-mcp.example.net/mcp",
"headers": { "Authorization": "Bearer <NPM_MCP_BEARER_TOKEN>" }
}
}
}
```
FastMCP serves at `/mcp`. A trailing slash 307-redirects, which some clients
mishandle — don't let a proxy rewrite the path.
</details>
> [!TIP]
> Call **`get_guidance`** first. It reports response shapes, the
> disable-vs-delete distinction, which latches are currently open, and the
> active protected-domain list.
---
## Authentication
Two layers, easy to conflate:
| | Direction | Mechanism |
|:--|:--|:--|
| **Inbound** | client → npm-mcp | Optional `Authorization: Bearer …` via `NPM_MCP_BEARER_TOKEN`, compared with `hmac.compare_digest`. **Unset ⇒ no authentication at all.** |
| **Outbound** | npm-mcp → NPM | Account credentials → short-lived JWT, refreshed automatically. Callers never see or supply it. |
NPM issues no long-lived API keys, which is why the server holds credentials
rather than accepting a token.
> [!IMPORTANT]
> `POST /tokens` has **two** possible responses: a token, or a 2FA challenge.
> If the account has 2FA enabled, set `NPM_TOTP_SECRET` — otherwise the server
> fails **at startup**, naming both remedies, rather than coming up healthy and
> breaking on the first tool call.
---
## Tool catalog
**66 tools** = 65 API operations + `get_guidance`.
| Family | # | Representative tools |
|:--|--:|:--|
| 🔀 Proxy hosts | 7 | `get_proxy_hosts` · `create_proxy_host` · `update_proxy_host` · `delete_proxy_host` · `enable_proxy_host` · `disable_proxy_host` |
| ↪️ Redirection hosts | 7 | `*_redirection_host` |
| 🚫 404 hosts | 7 | `create_404_host` · `*_dead_host` |
| 🔌 Streams | 7 | `*_stream` |
| 🔐 Access lists | 5 | `get_access_lists` · `create_access_list` · `update_access_list` · `delete_access_list` |
| 📜 Certificates | 10 | `get_certificates` · `create_certificate` · `renew_certificate` · `upload_certificate` · `validate_certificates` · `download_certificate` · `test_http_reach` · `get_dns_providers` |
| 👤 Users | 8 | `get_users` · `create_user` · `update_user` · `update_user_auth` · `update_user_permissions` · `login_as_user` |
| 🔑 User 2FA | 5 | `setup_user_2fa` · `enable_user_2fa` · `disable_user_2fa` · `get_user_2fa_status` · `regen_user_2fa_codes` |
| ⚙️ Settings | 3 | `get_settings` · `update_setting` |
| 📋 Audit log | 2 | `get_audit_logs` · `get_audit_log` |
| ℹ️ Meta | 4 | `health` · `check_version` · `reports_hosts` · `schema` |
| 🧭 Guidance | 1 | `get_guidance` |
Names derive from the OpenAPI `operationId`, so list operations are `get_*`,
not `list_*`.
> [!WARNING]
> **Three operations are deliberately not exposed:** `requestToken`,
> `refreshToken`, `loginWith2FA`. They're the server's own auth plumbing, and
> `requestToken` accepts an *arbitrary* identity and secret — registering it
> would turn this server into a credential-testing oracle against NPM, with
> every attempt attributed to the service account.
<details>
<summary><b>Two API quirks worth knowing</b></summary>
- **No pagination exists.** Not one endpoint accepts `limit`/`offset`. Tools
accept them and slice **client-side**; the tool descriptions say so.
- **`expand` is a per-endpoint enum**, not a passthrough — proxy-hosts takes
`access_list,owner,certificate`; certificates take only `owner`. Out-of-enum
values are rejected before the request is sent.
</details>
---
## Safety model
> [!CAUTION]
> **Writes are enabled by default.** This server can rewrite the routing table
> for every service behind the proxy. Set `NPM_READ_ONLY=1` to disable all
> mutations.
| | Control | Override |
|:--|:--|:--|
| `NPM_READ_ONLY` | Rejects every mutating tool, checked before any guardrail read | — |
| **S1** | Refuses `delete` / `disable` / **`update`** on protected hosts, *and* on the certificates and access lists those hosts depend on | `NPM_ALLOW_SELF_MUTATION` |
| **S2** | Every `DELETE` requires `confirm: true`; without it the tool returns what *would* be affected and writes nothing | per-call |
| **S5** | Every mutation emits one audit line; NPM's own audit log is queryable | — |
| **S6** | Every mutating operation under `/users` or `/settings` is latched | `NPM_ALLOW_ACCOUNT_MUTATION` |
| **S7** | Refuses to modify, disable, delete, or `login_as` **its own account** | **none** |
| **S8** | `download_certificate` returns TLS **private keys**, so it's latched | `NPM_ALLOW_CERT_EXPORT` |
<details>
<summary><b>Why these specific shapes</b> — each closes a bypass found in review</summary>
- **S1 matches ANY protected domain, not ALL.** ALL would let the guardrail be
disarmed *through the tools it guards*: add one unrelated domain to a host and
protection evaporates.
- **S1 covers `update`, not just delete/disable.** Otherwise you strip the
protected name out of `domain_names`, then delete cleanly — same outage.
- **S1 matches on current upstream state, never the submitted body.** Checking
the request would let the strip-then-update path walk straight through.
- **S1 wildcards match in both directions.** `NPM_PROTECTED_DOMAINS=*.example.net`
must protect `app.example.net`. It once matched nothing *and* suppressed the
"unprotected" warning, because the value was explicitly set.
- **S2 scopes by HTTP method, not name prefix.** A `delete_*` rule misses
`disable_user_2fa` — a `DELETE` that strips someone's second factor.
- **S6 is a rule, not a list.** An enumerated version silently omitted
`update_user`, so the latch stayed shut while `is_disabled: true` locked out
an admin.
- **S7 has no override.** A server that can delete its own credentials locks
itself out permanently.
</details>
---
## Configuration
<details open>
<summary><b>Required</b></summary>
| Variable | Meaning |
|:--|:--|
| `NPM_URL` | Base URL of the NPM instance |
| `NPM_IDENTITY` | Account email |
| `NPM_SECRET` | Account password |
</details>
<details>
<summary><b>Transport & inbound auth</b></summary>
| Variable | Default | Meaning |
|:--|:--|:--|
| `NPM_MCP_BEARER_TOKEN` | *unset* | Inbound token. **Unset ⇒ no inbound auth** |
| `NPM_MCP_TRANSPORT` | `streamable-http` | `stdio` \| `streamable-http` |
| `NPM_MCP_HTTP_HOST` | `0.0.0.0` | Bind address |
| `NPM_MCP_HTTP_PORT` | `8000` | Bind port |
</details>
<details>
<summary><b>Safety latches</b></summary>
| Variable | Default | Lifts |
|:--|:--|:--|
| `NPM_READ_ONLY` | `0` | — (`1` blocks all writes) |
| `NPM_PROTECTED_DOMAINS` | *derived from `NPM_URL`* | S1 denylist, comma-separated |
| `NPM_ALLOW_SELF_MUTATION` | `0` | S1 |
| `NPM_ALLOW_ACCOUNT_MUTATION` | `0` | S6 |
| `NPM_ALLOW_CERT_EXPORT` | `0` | S8 |
</details>
<details>
<summary><b>Upstream behaviour</b></summary>
| Variable | Default | Meaning |
|:--|:--|:--|
| `NPM_TOTP_SECRET` | *unset* | Base32 seed; only if the account has 2FA |
| `NPM_TLS_VERIFY` | `1` | Verify NPM's certificate |
| `NPM_TIMEOUT` | `30` | Upstream timeout, seconds |
| `NPM_MAX_RESPONSE_CHARS` | `50000` | Response cap before truncation |
| `NPM_GUIDANCE_GATE` | `1` | Hint toward `get_guidance` on early mutations |
| `LOG_LEVEL` | `INFO` | |
</details>
---
## Deployment
```bash
docker build -t npm-mcp:latest .
docker compose up -d
```
The container joins an existing Docker network alongside NPM and publishes
**no ports**. NPM reaches it by container DNS and terminates TLS, so the Bearer
token never crosses the wire in plaintext.
<details>
<summary><b>Three details that bite</b></summary>
- **No `build:` key in the compose file.** A compose-*string* deploy (Portainer,
for one) ships no build context, so the image is built first and referenced by
tag.
- **The healthcheck resolves the bind host** instead of hardcoding `127.0.0.1`.
With a custom `NPM_MCP_HTTP_HOST` the naive version marks a perfectly healthy
container as unhealthy forever. It also short-circuits under `stdio`, where
nothing is listening at all.
- **The authenticating warm-up runs in the server lifespan**, so a
misconfiguration fails the healthcheck rather than coming up green and
breaking on first use.
</details>
---
## Testing
```bash
uv run pytest # 420 tests
uv run ruff check
uv run ruff format --check
```
Roughly **4,700 lines of tests against 3,300 lines of source**, but the count
matters less than the shape:
- 🧬 **Mutation-verified guardrails** — every safety control has a test proven to
fail when the control is disabled. Written after discovering an `asyncio.Lock`
whose removal kept the suite green.
- 🌐 **Zero network access** — every upstream call is `respx`-mocked. A test that
needs the network is a broken test.
- 🔍 **A7 sweep** — all 65 tools are called against an upstream returning secrets
at four nesting depths, with a negative control asserting the fixture really
contains them, so the sweep can't pass vacuously.
- 📐 **Schema drift guard** — operation counts, payload shapes, and the packaged
data file are all asserted, so an upstream upgrade fails here rather than in
production.
---
## Design notes
| Document | Contents |
|:--|:--|
| [spec.md](spec.md) | Product contract — decisions **D1–D13**, controls **S1–S8**, acceptance criteria **A1–A10** |
| [docs/api-surface.md](docs/api-surface.md) | All 68 operations with body fields and required-ness |
| [docs/module-contract.md](docs/module-contract.md) | Internal module interfaces |
| [docs/findings.md](docs/findings.md) | Two things the OpenAPI document gets wrong, measured against a live instance |
| [`npm_mcp/data/npm-openapi.json`](src/npm_mcp/data/npm-openapi.json) | Verbatim copy of the instance's `/api/schema` — **inside the package**, because it's a runtime dependency, not documentation |
<div align="center">
<sub>Built against Nginx Proxy Manager 2.15.1 · 44 paths · 68 operations</sub>
</div>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues