2 Commits

Author SHA1 Message Date
b3nw baeae54326 chore(release): bump version to 0.0.4
- pyproject.toml: 0.0.2 -> 0.0.4

- README.md: mark delete/enable/disable tools as v0.0.4+
2026-06-14 19:00:33 +00:00
Bryan Rebhahn 690408d6c7 Add proxy-host lifecycle verbs: delete/enable/disable (#4)
* feat(proxy-hosts): add delete, enable, and disable verbs (#1)

Add delete_proxy_host (DELETE /nginx/proxy-hosts/{id}), enable_proxy_host
(POST .../enable) and disable_proxy_host (POST .../disable) MCP verbs plus
client methods, following existing NpmClient auth + tool patterns. README
documents the new tools with placeholder-only examples.

* docs(proxy-hosts): correct enable/disable idempotency wording (#2)

NPM's POST /enable and /disable return HTTP 400 when the host is already in
the target state; update enable/disable docstrings (server.py + client.py)
and README to describe this instead of claiming a no-op success.
2026-06-14 13:53:55 -05:00
4 changed files with 213 additions and 6 deletions
+30
View File
@@ -134,8 +134,38 @@ Add to your `claude_desktop_config.json`:
| `list_access_lists` | List access lists for authentication/IP restrictions | | `list_access_lists` | List access lists for authentication/IP restrictions |
| `create_proxy_host` | Create a new proxy host | | `create_proxy_host` | Create a new proxy host |
| `update_proxy_host` | Update an existing proxy host (v0.0.3+) | | `update_proxy_host` | Update an existing proxy host (v0.0.3+) |
| `delete_proxy_host` | Delete a proxy host permanently (v0.0.4+) |
| `enable_proxy_host` | Enable (bring online) a disabled proxy host (v0.0.4+) |
| `disable_proxy_host` | Disable (take offline) a proxy host without deleting it (v0.0.4+) |
| `create_certificate` | Provision a new Let's Encrypt SSL certificate (v0.0.3+) | | `create_certificate` | Provision a new Let's Encrypt SSL certificate (v0.0.3+) |
## Managing Proxy Host Lifecycle
Beyond creating and updating hosts, the server can delete a host outright or
toggle a host on and off without losing its configuration. Find the host ID
with `list_proxy_hosts` first.
```text
# Take a host offline temporarily (config is preserved)
disable_proxy_host(42)
# Bring it back online
enable_proxy_host(42)
# Permanently remove a host (cannot be undone)
delete_proxy_host(42)
```
Notes:
- `enable_proxy_host` / `disable_proxy_host` map to NPM's
`POST /nginx/proxy-hosts/{id}/enable` and `/disable` endpoints. If the host
is already in the requested state, NPM returns an HTTP 400 error
(e.g. `Host is already enabled`), which the tool surfaces as an API error.
- `delete_proxy_host` maps to `DELETE /nginx/proxy-hosts/{id}` and is
destructive — the reverse proxy stops serving the host's domains
immediately. Recreate it with `create_proxy_host` if you need it back.
## Log Access Setup ## Log Access Setup
The `get_proxy_host_logs` tool reads nginx log files directly from disk. Since NPM has no API for log retrieval, you need to mount NPM's log directory into the MCP container. The `get_proxy_host_logs` tool reads nginx log files directly from disk. Since NPM has no API for log retrieval, you need to mount NPM's log directory into the MCP container.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "npm-mcp" name = "npm-mcp"
version = "0.0.2" version = "0.0.4"
description = "MCP server for Nginx Proxy Manager" description = "MCP server for Nginx Proxy Manager"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+65
View File
@@ -362,6 +362,71 @@ class NpmClient:
response = await self._request("PUT", f"/nginx/proxy-hosts/{host_id}", json=payload) response = await self._request("PUT", f"/nginx/proxy-hosts/{host_id}", json=payload)
return ProxyHost(**response.json()) return ProxyHost(**response.json())
async def delete_proxy_host(self, host_id: int) -> bool:
"""Delete a proxy host by ID.
Calls ``DELETE /nginx/proxy-hosts/{id}``. The reverse proxy stops
serving the host's domains immediately and the configuration is
removed. This is destructive and cannot be undone.
Args:
host_id: The proxy host ID to delete
Returns:
True if NPM reported the host was deleted
Raises:
NpmNotFoundError: If the proxy host does not exist
NpmApiError: For other API errors
"""
response = await self._request("DELETE", f"/nginx/proxy-hosts/{host_id}")
# NPM returns the JSON literal `true` on a successful delete.
return bool(response.json())
async def enable_proxy_host(self, host_id: int) -> bool:
"""Enable (bring online) a proxy host by ID.
Calls ``POST /nginx/proxy-hosts/{id}/enable``. If the host is already
enabled, NPM responds with HTTP 400 ("Host is already enabled"), which
raises NpmApiError.
Args:
host_id: The proxy host ID to enable
Returns:
True if NPM reported the host was enabled
Raises:
NpmNotFoundError: If the proxy host does not exist
NpmApiError: For other API errors
"""
response = await self._request("POST", f"/nginx/proxy-hosts/{host_id}/enable")
# NPM returns the JSON literal `true` on success.
return bool(response.json())
async def disable_proxy_host(self, host_id: int) -> bool:
"""Disable (take offline) a proxy host by ID.
Calls ``POST /nginx/proxy-hosts/{id}/disable``. The host's
configuration is preserved; the reverse proxy simply stops serving
its domains until it is re-enabled. If the host is already disabled,
NPM responds with HTTP 400 ("Host is already disabled"), which raises
NpmApiError.
Args:
host_id: The proxy host ID to disable
Returns:
True if NPM reported the host was disabled
Raises:
NpmNotFoundError: If the proxy host does not exist
NpmApiError: For other API errors
"""
response = await self._request("POST", f"/nginx/proxy-hosts/{host_id}/disable")
# NPM returns the JSON literal `true` on success.
return bool(response.json())
async def create_certificate( async def create_certificate(
self, self,
domain_names: list[str], domain_names: list[str],
+117 -5
View File
@@ -86,7 +86,7 @@ async def list_proxy_hosts() -> str:
result = [] result = []
for host in hosts: for host in hosts:
domains = ", ".join(host.domain_names) domains = ", ".join(host.domain_names)
ssl_status = "🔒 SSL" if host.ssl_forced else "🔓 HTTP" ssl_status = "\U0001f512 SSL" if host.ssl_forced else "\U0001f513 HTTP"
enabled_status = "" if host.enabled else "" enabled_status = "" if host.enabled else ""
result.append( result.append(
@@ -313,8 +313,8 @@ async def create_proxy_host(
"""Create a new proxy host in Nginx Proxy Manager. """Create a new proxy host in Nginx Proxy Manager.
Args: Args:
domain_names: List of domain names (e.g., ["app.ext.ben.io"]) domain_names: List of domain names (e.g., ["app.example.com"])
forward_host: Backend host/IP to forward to (e.g., "192.168.1.100" or "container-name") forward_host: Backend host/IP to forward to (e.g., "10.0.0.50" or "container-name")
forward_port: Backend port to forward to (e.g., 8080) forward_port: Backend port to forward to (e.g., 8080)
forward_scheme: Backend protocol - "http" or "https" (default from config) forward_scheme: Backend protocol - "http" or "https" (default from config)
certificate_id: SSL certificate ID. Use list_certificates to find available certs. certificate_id: SSL certificate ID. Use list_certificates to find available certs.
@@ -335,10 +335,10 @@ async def create_proxy_host(
Example: Example:
create_proxy_host( create_proxy_host(
domain_names=["myapp.ext.ben.io"], domain_names=["myapp.example.com"],
forward_host="10.0.0.50", forward_host="10.0.0.50",
forward_port=3000, forward_port=3000,
certificate_id=24, # *.ext.ben.io wildcard certificate_id=24, # *.example.com wildcard
) )
""" """
try: try:
@@ -459,6 +459,118 @@ async def update_proxy_host(
return _format_error(e) return _format_error(e)
@mcp.tool()
async def delete_proxy_host(host_id: int) -> str:
"""Delete a proxy host from Nginx Proxy Manager.
Permanently removes the proxy host configuration via
DELETE /nginx/proxy-hosts/{id}. The reverse proxy stops serving the
host's domains immediately. This action cannot be undone — recreate the
host with create_proxy_host if you need it back.
Args:
host_id: The ID of the proxy host to delete (use list_proxy_hosts to find IDs)
Returns:
Confirmation that the proxy host was deleted.
Example:
delete_proxy_host(42) # permanently remove proxy host 42
"""
try:
client = get_client()
# Resolve the domains first so the confirmation message is meaningful.
domains: str | None = None
try:
host = await client.get_proxy_host(host_id)
domains = ", ".join(host.domain_names)
except Exception:
domains = None
await client.delete_proxy_host(host_id)
if domains:
return f"Successfully deleted proxy host [{host_id}] ({domains})."
return f"Successfully deleted proxy host [{host_id}]."
except Exception as e:
return _format_error(e)
@mcp.tool()
async def enable_proxy_host(host_id: int) -> str:
"""Enable a proxy host in Nginx Proxy Manager.
Brings a previously disabled proxy host back online via
POST /nginx/proxy-hosts/{id}/enable, so the reverse proxy serves its
domains again. If the host is already enabled, NPM returns an HTTP 400
error ("Host is already enabled"), which is surfaced as an API error.
Args:
host_id: The ID of the proxy host to enable (use list_proxy_hosts to find IDs)
Returns:
Confirmation that the proxy host was enabled.
Example:
enable_proxy_host(42) # bring proxy host 42 back online
"""
try:
client = get_client()
await client.enable_proxy_host(host_id)
try:
host = await client.get_proxy_host(host_id)
domains = ", ".join(host.domain_names)
return f"Successfully enabled proxy host [{host_id}] ({domains})."
except Exception:
return f"Successfully enabled proxy host [{host_id}]."
except Exception as e:
return _format_error(e)
@mcp.tool()
async def disable_proxy_host(host_id: int) -> str:
"""Disable a proxy host in Nginx Proxy Manager.
Takes a proxy host offline via POST /nginx/proxy-hosts/{id}/disable
without deleting it. The reverse proxy stops serving the host's domains
until it is re-enabled with enable_proxy_host; the configuration is
preserved. If the host is already disabled, NPM returns an HTTP 400
error ("Host is already disabled"), which is surfaced as an API error.
Args:
host_id: The ID of the proxy host to disable (use list_proxy_hosts to find IDs)
Returns:
Confirmation that the proxy host was disabled.
Example:
disable_proxy_host(42) # take proxy host 42 offline, keep its config
"""
try:
client = get_client()
# Resolve domains before disabling (host stays readable while disabled).
domains: str | None = None
try:
host = await client.get_proxy_host(host_id)
domains = ", ".join(host.domain_names)
except Exception:
domains = None
await client.disable_proxy_host(host_id)
if domains:
return f"Successfully disabled proxy host [{host_id}] ({domains})."
return f"Successfully disabled proxy host [{host_id}]."
except Exception as e:
return _format_error(e)
@mcp.tool() @mcp.tool()
async def get_proxy_host_logs( async def get_proxy_host_logs(
host_id: int, host_id: int,