Skip to main content
Glama
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.

[![Python](https://img.shields.io/badge/python-3.13+-3776AB?logo=python&logoColor=white)](https://www.python.org)
[![FastMCP](https://img.shields.io/badge/FastMCP-3.4.7-4B32C3)](https://github.com/jlowin/fastmcp)
[![NPM](https://img.shields.io/badge/Nginx%20Proxy%20Manager-2.15.1-F15833)](https://nginxproxymanager.com)
[![Tests](https://img.shields.io/badge/tests-420%20passing-3FB950)](#testing)
[![Tools](https://img.shields.io/badge/MCP%20tools-66-8957E5)](#tool-catalog)
[![Ruff](https://img.shields.io/badge/lint-ruff-D7FF64?logo=ruff&logoColor=black)](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 &amp; 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>

Maintenance

ActivityMaintained
ResponsivenessNo issues