npm-mcp
Enables conversational management of Nginx Proxy Manager, including reverse-proxy routing, TLS certificates, access lists, and stream forwards, with built-in safety guardrails for production use.
README
<div align="center">
npm-mcp
Model Context Protocol server for Nginx Proxy Manager
Manage reverse-proxy routing, TLS certificates, access lists, and stream forwards conversationally — with guardrails that assume you will eventually point it at production.
</div>
Contents
- Why this exists
- How it works
- Quick start
- Authentication
- Tool catalog
- Safety model
- Configuration
- Deployment
- Testing
- 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
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
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>
{
"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>
{
"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_guidancefirst. 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 /tokenshas two possible responses: a token, or a 2FA challenge. If the account has 2FA enabled, setNPM_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, andrequestTokenaccepts 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. expandis a per-endpoint enum, not a passthrough — proxy-hosts takesaccess_list,owner,certificate; certificates take onlyowner. 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=1to 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 ofdomain_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.netmust protectapp.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 missesdisable_user_2fa— aDELETEthat strips someone's second factor. - S6 is a rule, not a list. An enumerated version silently omitted
update_user, so the latch stayed shut whileis_disabled: truelocked 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
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 customNPM_MCP_HTTP_HOSTthe naive version marks a perfectly healthy container as unhealthy forever. It also short-circuits understdio, 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
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.Lockwhose 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 | Product contract — decisions D1–D13, controls S1–S8, acceptance criteria A1–A10 |
| docs/api-surface.md | All 68 operations with body fields and required-ness |
| docs/module-contract.md | Internal module interfaces |
| docs/findings.md | Two things the OpenAPI document gets wrong, measured against a live instance |
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>
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。