nginx-proxy-manager-mcp-server 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from starlette.responses import JSONResponse
7
+ from starlette.types import Receive, Scope, Send
8
+
9
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
10
+
11
+
12
+ class HealthCheckMiddleware:
13
+ """Serve an unauthenticated liveness endpoint for SSE deployments."""
14
+
15
+ def __init__(self, app: ASGIApp, path: str = "/healthz") -> None:
16
+ self.app = app
17
+ self.path = path
18
+
19
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
20
+ if scope["type"] == "http" and scope.get("path") == self.path:
21
+ response = JSONResponse({"ok": True, "service": "nginx-proxy-manager-mcp"})
22
+ await response(scope, receive, send)
23
+ return
24
+
25
+ await self.app(scope, receive, send)
26
+
27
+
28
+ class BearerTokenAuthMiddleware:
29
+ """Require a static bearer token for HTTP/SSE MCP transports."""
30
+
31
+ def __init__(self, app: ASGIApp, token: str) -> None:
32
+ self.app = app
33
+ self.token = token
34
+
35
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
36
+ if scope["type"] != "http":
37
+ await self.app(scope, receive, send)
38
+ return
39
+
40
+ headers = {
41
+ key.decode("latin-1").lower(): value.decode("latin-1")
42
+ for key, value in scope.get("headers", [])
43
+ }
44
+ expected = f"Bearer {self.token}"
45
+ provided = headers.get("authorization", "")
46
+ if not secrets.compare_digest(provided, expected):
47
+ response = JSONResponse(
48
+ {"error": "unauthorized", "detail": "Missing or invalid bearer token"},
49
+ status_code=401,
50
+ headers={"WWW-Authenticate": "Bearer"},
51
+ )
52
+ await response(scope, receive, send)
53
+ return
54
+
55
+ await self.app(scope, receive, send)
@@ -0,0 +1,101 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+
7
+ class NPMAPIError(RuntimeError):
8
+ """Actionable, redacted error from the Nginx Proxy Manager API."""
9
+
10
+ def __init__(self, method: str, path: str, status_code: int, detail: str) -> None:
11
+ self.method = method.upper()
12
+ self.path = path
13
+ self.status_code = status_code
14
+ self.detail = detail
15
+ super().__init__(f"NPM API {self.method} {self.path} failed: HTTP {status_code}: {detail}")
16
+
17
+
18
+ class NPMClient:
19
+ def __init__(self) -> None:
20
+ self.base_url = os.environ["NPM_URL"].rstrip("/")
21
+ self._email = os.environ["NPM_EMAIL"]
22
+ self._password = os.environ["NPM_PASSWORD"]
23
+ self.token: str | None = None
24
+ self._http = httpx.Client(timeout=30)
25
+
26
+ def _raise_api_error(self, method: str, path: str, resp: httpx.Response) -> None:
27
+ detail = ""
28
+ if resp.content:
29
+ try:
30
+ body = resp.json()
31
+ except ValueError:
32
+ detail = resp.text.strip()
33
+ else:
34
+ if isinstance(body, dict):
35
+ detail = str(
36
+ body.get("message")
37
+ or body.get("error")
38
+ or body.get("detail")
39
+ or body
40
+ )
41
+ else:
42
+ detail = str(body)
43
+ if not detail:
44
+ detail = resp.reason_phrase or "request failed"
45
+ raise NPMAPIError(method, path, resp.status_code, detail)
46
+
47
+ def _authenticate(self) -> None:
48
+ resp = self._http.post(
49
+ f"{self.base_url}/api/tokens",
50
+ json={"identity": self._email, "secret": self._password},
51
+ )
52
+ if resp.is_error:
53
+ self._raise_api_error("POST", "/tokens", resp)
54
+ self.token = resp.json()["token"]
55
+
56
+ def _auth_headers(self) -> dict[str, str]:
57
+ return {"Authorization": f"Bearer {self.token}"}
58
+
59
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
60
+ if not self.token:
61
+ self._authenticate()
62
+ resp = self._http.request(
63
+ method,
64
+ f"{self.base_url}/api{path}",
65
+ headers=self._auth_headers(),
66
+ **kwargs,
67
+ )
68
+ if resp.status_code == 401:
69
+ self._authenticate()
70
+ resp = self._http.request(
71
+ method,
72
+ f"{self.base_url}/api{path}",
73
+ headers=self._auth_headers(),
74
+ **kwargs,
75
+ )
76
+ if resp.is_error:
77
+ self._raise_api_error(method, path, resp)
78
+ if not resp.content:
79
+ return None
80
+ return resp.json()
81
+
82
+ def get(self, path: str, **kwargs: Any) -> Any:
83
+ return self.request("GET", path, **kwargs)
84
+
85
+ def post(self, path: str, **kwargs: Any) -> Any:
86
+ return self.request("POST", path, **kwargs)
87
+
88
+ def put(self, path: str, **kwargs: Any) -> Any:
89
+ return self.request("PUT", path, **kwargs)
90
+
91
+ def delete(self, path: str, **kwargs: Any) -> Any:
92
+ return self.request("DELETE", path, **kwargs)
93
+
94
+ def close(self) -> None:
95
+ self._http.close()
96
+
97
+ def __enter__(self) -> "NPMClient":
98
+ return self
99
+
100
+ def __exit__(self, *args: Any) -> None:
101
+ self.close()
File without changes
@@ -0,0 +1,70 @@
1
+ import os
2
+ import sys
3
+
4
+ from fastmcp import FastMCP
5
+ from starlette.middleware import Middleware
6
+
7
+ from nginx_proxy_manager_mcp.auth import BearerTokenAuthMiddleware, HealthCheckMiddleware
8
+ from nginx_proxy_manager_mcp.npm_client import NPMClient
9
+ from nginx_proxy_manager_mcp.tools.access_lists import register_access_list_tools
10
+ from nginx_proxy_manager_mcp.tools.proxy_hosts import register_proxy_host_tools
11
+ from nginx_proxy_manager_mcp.tools.redirection_hosts import register_redirection_host_tools
12
+ from nginx_proxy_manager_mcp.tools.ssl_certs import register_ssl_cert_tools
13
+ from nginx_proxy_manager_mcp.tools.streams import register_stream_tools
14
+
15
+ mcp = FastMCP("nginx-proxy-manager-mcp")
16
+
17
+
18
+ def validate_env() -> None:
19
+ required = ["NPM_URL", "NPM_EMAIL", "NPM_PASSWORD"]
20
+ missing = [v for v in required if not os.environ.get(v)]
21
+ if missing:
22
+ print(f"ERROR: Missing required env vars: {', '.join(missing)}", file=sys.stderr)
23
+ sys.exit(1)
24
+
25
+
26
+ def get_transport() -> str:
27
+ transport = os.environ.get("MCP_TRANSPORT", "sse").lower()
28
+ if transport not in {"sse", "stdio"}:
29
+ print("ERROR: MCP_TRANSPORT must be one of: sse, stdio", file=sys.stderr)
30
+ sys.exit(1)
31
+ return transport
32
+
33
+
34
+ def get_sse_middleware() -> list[Middleware]:
35
+ token = os.environ.get("MCP_BEARER_TOKEN")
36
+ middleware = [Middleware(HealthCheckMiddleware)]
37
+ if not token:
38
+ return middleware
39
+ middleware.append(Middleware(BearerTokenAuthMiddleware, token=token))
40
+ return middleware
41
+
42
+
43
+ def get_host() -> str:
44
+ return os.environ.get("MCP_HOST", "127.0.0.1")
45
+
46
+
47
+ def build_server() -> FastMCP:
48
+ validate_env()
49
+ client = NPMClient()
50
+ register_proxy_host_tools(mcp, client)
51
+ register_ssl_cert_tools(mcp, client)
52
+ register_access_list_tools(mcp, client)
53
+ register_redirection_host_tools(mcp, client)
54
+ register_stream_tools(mcp, client)
55
+ return mcp
56
+
57
+
58
+ def main() -> None:
59
+ build_server()
60
+ transport = get_transport()
61
+ if transport == "stdio":
62
+ mcp.run(transport="stdio")
63
+ else:
64
+ host = get_host()
65
+ port = int(os.environ.get("MCP_PORT", "8000"))
66
+ mcp.run(transport="sse", host=host, port=port, middleware=get_sse_middleware())
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
File without changes
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from fastmcp import FastMCP
6
+ from nginx_proxy_manager_mcp.npm_client import NPMClient
7
+
8
+ from nginx_proxy_manager_mcp.tools.payloads import update_payload
9
+ from nginx_proxy_manager_mcp.tools.previews import dry_run_preview
10
+ from nginx_proxy_manager_mcp.validation import validate_access_clients, validate_access_items, validate_positive_id
11
+
12
+
13
+ class AccessListTools:
14
+ def __init__(self, client: NPMClient) -> None:
15
+ self.client = client
16
+
17
+ def list_access_lists(self) -> list[dict]:
18
+ """List all access lists."""
19
+ return self.client.get("/nginx/access-lists")
20
+
21
+ def get_access_list(self, id: int) -> dict:
22
+ """Get access list detail including IP rules and basic auth users."""
23
+ validate_positive_id(id)
24
+ return self.client.get(f"/nginx/access-lists/{id}")
25
+
26
+ def create_access_list(
27
+ self,
28
+ name: str,
29
+ satisfy_any: bool = False,
30
+ pass_auth: bool = False,
31
+ clients: Optional[list[dict]] = None,
32
+ items: Optional[list[dict]] = None,
33
+ dry_run: bool = False,
34
+ ) -> dict:
35
+ """
36
+ Create a new access list.
37
+ clients: [{"address": "1.2.3.4", "directive": "allow|deny"}]
38
+ items: [{"username": "user", "password": "pass"}]
39
+ """
40
+ validated_clients = validate_access_clients(clients)
41
+ validated_items = validate_access_items(items)
42
+ payload = {
43
+ "name": name,
44
+ "satisfy_any": satisfy_any,
45
+ "pass_auth": pass_auth,
46
+ "clients": validated_clients,
47
+ "items": validated_items,
48
+ }
49
+ if dry_run:
50
+ return dry_run_preview("POST", "/nginx/access-lists", payload)
51
+ return self.client.post("/nginx/access-lists", json=payload)
52
+
53
+ def update_access_list(
54
+ self,
55
+ id: int,
56
+ name: Optional[str] = None,
57
+ satisfy_any: Optional[bool] = None,
58
+ pass_auth: Optional[bool] = None,
59
+ clients: Optional[list[dict]] = None,
60
+ items: Optional[list[dict]] = None,
61
+ dry_run: bool = False,
62
+ ) -> dict:
63
+ """Update an access list. Set dry_run=True to preview the merged payload."""
64
+ validate_positive_id(id)
65
+ if clients is not None:
66
+ validate_access_clients(clients)
67
+ if items is not None:
68
+ validate_access_items(items)
69
+ existing = self.client.get(f"/nginx/access-lists/{id}")
70
+ updates = {
71
+ k: v
72
+ for k, v in {
73
+ "name": name,
74
+ "satisfy_any": satisfy_any,
75
+ "pass_auth": pass_auth,
76
+ "clients": clients,
77
+ "items": items,
78
+ }.items()
79
+ if v is not None
80
+ }
81
+ existing = update_payload("access-lists", existing, updates)
82
+ if dry_run:
83
+ return dry_run_preview("PUT", f"/nginx/access-lists/{id}", existing)
84
+ return self.client.put(f"/nginx/access-lists/{id}", json=existing)
85
+
86
+ def delete_access_list(self, id: int, dry_run: bool = False) -> bool | dict:
87
+ """Delete an access list. Set dry_run=True to preview only."""
88
+ validate_positive_id(id)
89
+ if dry_run:
90
+ return dry_run_preview("DELETE", f"/nginx/access-lists/{id}")
91
+ return self.client.delete(f"/nginx/access-lists/{id}")
92
+
93
+
94
+ def register_access_list_tools(mcp: FastMCP, client: NPMClient) -> None:
95
+ tools = AccessListTools(client)
96
+ mcp.tool()(tools.list_access_lists)
97
+ mcp.tool()(tools.get_access_list)
98
+ mcp.tool()(tools.create_access_list)
99
+ mcp.tool()(tools.update_access_list)
100
+ mcp.tool()(tools.delete_access_list)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ # NPM backend/schema/paths/nginx/{proxy-hosts,redirection-hosts}/hostID/put.json,
7
+ # streams/streamID/put.json, access-lists/listID/put.json requestBody properties.
8
+ UPDATE_FIELDS = {
9
+ "proxy-hosts": {
10
+ "domain_names", "forward_scheme", "forward_host", "forward_port",
11
+ "certificate_id", "ssl_forced", "hsts_enabled", "hsts_subdomains",
12
+ "trust_forwarded_proto", "http2_support", "block_exploits", "caching_enabled",
13
+ "allow_websocket_upgrade", "access_list_id", "advanced_config", "enabled",
14
+ "meta", "locations",
15
+ },
16
+ "redirection-hosts": {
17
+ "domain_names", "forward_http_code", "forward_scheme", "forward_domain_name",
18
+ "preserve_path", "certificate_id", "ssl_forced", "hsts_enabled",
19
+ "hsts_subdomains", "http2_support", "block_exploits", "advanced_config", "meta",
20
+ },
21
+ "streams": {
22
+ "incoming_port", "forwarding_host", "forwarding_port", "tcp_forwarding",
23
+ "udp_forwarding", "certificate_id", "meta",
24
+ },
25
+ "access-lists": {"name", "satisfy_any", "pass_auth", "items", "clients"},
26
+ }
27
+
28
+ # NPM backend/schema/common.json: access_items/access_clients items properties.
29
+ ACCESS_ENTRY_FIELDS = {
30
+ "items": {"username", "password"},
31
+ "clients": {"address", "directive"},
32
+ }
33
+
34
+
35
+ def update_payload(
36
+ resource: str, existing: dict[str, Any], updates: dict[str, Any],
37
+ ) -> dict[str, Any]:
38
+ """Merge changes while retaining only fields accepted by the NPM PUT schema."""
39
+ payload = {
40
+ key: value for key, value in {**existing, **updates}.items()
41
+ if key in UPDATE_FIELDS[resource]
42
+ }
43
+ if resource == "access-lists":
44
+ for key, fields in ACCESS_ENTRY_FIELDS.items():
45
+ if key in payload:
46
+ payload[key] = [
47
+ {field: value for field, value in entry.items() if field in fields}
48
+ for entry in payload[key]
49
+ ]
50
+ return payload
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def dry_run_preview(method: str, path: str, json: Any = None) -> dict[str, Any]:
7
+ """Return a structured preview for a mutating NPM API request."""
8
+ preview: dict[str, Any] = {
9
+ "dry_run": True,
10
+ "method": method,
11
+ "path": path,
12
+ }
13
+ if json is not None:
14
+ preview["json"] = json
15
+ return preview
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+ from typing import Optional, TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from fastmcp import FastMCP
6
+ from nginx_proxy_manager_mcp.npm_client import NPMClient
7
+
8
+ from nginx_proxy_manager_mcp.tools.payloads import update_payload
9
+ from nginx_proxy_manager_mcp.tools.previews import dry_run_preview
10
+ from nginx_proxy_manager_mcp.validation import (
11
+ validate_domain_names,
12
+ validate_email,
13
+ validate_forward_scheme,
14
+ validate_host,
15
+ validate_optional_positive_id,
16
+ validate_port,
17
+ validate_positive_id,
18
+ )
19
+
20
+
21
+ class ProxyHostTools:
22
+ def __init__(self, client: NPMClient) -> None:
23
+ self.client = client
24
+
25
+ def list_proxy_hosts(self) -> list[dict]:
26
+ """List all proxy hosts with their current status."""
27
+ return self.client.get("/nginx/proxy-hosts")
28
+
29
+ def get_proxy_host(self, id: int) -> dict:
30
+ """Get a single proxy host by ID."""
31
+ validate_positive_id(id)
32
+ return self.client.get(f"/nginx/proxy-hosts/{id}")
33
+
34
+ def create_proxy_host(
35
+ self,
36
+ domain_names: list[str],
37
+ forward_scheme: str,
38
+ forward_host: str,
39
+ forward_port: int,
40
+ ssl_forced: bool = False,
41
+ certificate_id: Optional[int] = None,
42
+ access_list_id: Optional[int] = None,
43
+ block_exploits: bool = True,
44
+ caching_enabled: bool = False,
45
+ allow_websocket_upgrade: bool = False,
46
+ dry_run: bool = False,
47
+ ) -> dict:
48
+ """Create a new proxy host. Set dry_run=True to preview without changing NPM."""
49
+ validate_domain_names(domain_names)
50
+ validate_forward_scheme(forward_scheme)
51
+ validate_host(forward_host, "forward_host")
52
+ validate_port(forward_port, "forward_port")
53
+ validate_optional_positive_id(certificate_id, "certificate_id")
54
+ validate_optional_positive_id(access_list_id, "access_list_id")
55
+ payload = {
56
+ "domain_names": domain_names,
57
+ "forward_scheme": forward_scheme,
58
+ "forward_host": forward_host,
59
+ "forward_port": forward_port,
60
+ "ssl_forced": ssl_forced,
61
+ "certificate_id": certificate_id or 0,
62
+ "access_list_id": access_list_id or 0,
63
+ "block_exploits": block_exploits,
64
+ "caching_enabled": caching_enabled,
65
+ "allow_websocket_upgrade": allow_websocket_upgrade,
66
+ "advanced_config": "",
67
+ "meta": {},
68
+ "locations": [],
69
+ }
70
+ if dry_run:
71
+ return dry_run_preview("POST", "/nginx/proxy-hosts", payload)
72
+ return self.client.post("/nginx/proxy-hosts", json=payload)
73
+
74
+ def create_proxy_host_with_letsencrypt(
75
+ self,
76
+ domain_names: list[str],
77
+ forward_scheme: str,
78
+ forward_host: str,
79
+ forward_port: int,
80
+ email: str,
81
+ access_list_id: Optional[int] = None,
82
+ block_exploits: bool = True,
83
+ caching_enabled: bool = False,
84
+ allow_websocket_upgrade: bool = False,
85
+ dry_run: bool = False,
86
+ ) -> dict:
87
+ """Create a Let's Encrypt certificate, then create an HTTPS-forced proxy host."""
88
+ validate_domain_names(domain_names)
89
+ validate_forward_scheme(forward_scheme)
90
+ validate_host(forward_host, "forward_host")
91
+ validate_port(forward_port, "forward_port")
92
+ validate_email(email)
93
+ validate_optional_positive_id(access_list_id, "access_list_id")
94
+ cert_payload = {
95
+ "provider": "letsencrypt",
96
+ "domain_names": domain_names,
97
+ "meta": {
98
+ "letsencrypt_email": email,
99
+ "letsencrypt_agree": True,
100
+ "dns_challenge": False,
101
+ },
102
+ }
103
+ proxy_payload = {
104
+ "domain_names": domain_names,
105
+ "forward_scheme": forward_scheme,
106
+ "forward_host": forward_host,
107
+ "forward_port": forward_port,
108
+ "ssl_forced": True,
109
+ "certificate_id": "<created certificate id>",
110
+ "access_list_id": access_list_id or 0,
111
+ "block_exploits": block_exploits,
112
+ "caching_enabled": caching_enabled,
113
+ "allow_websocket_upgrade": allow_websocket_upgrade,
114
+ "advanced_config": "",
115
+ "meta": {},
116
+ "locations": [],
117
+ }
118
+ if dry_run:
119
+ return {
120
+ "dry_run": True,
121
+ "steps": [
122
+ dry_run_preview("POST", "/nginx/certificates", cert_payload),
123
+ dry_run_preview("POST", "/nginx/proxy-hosts", proxy_payload),
124
+ ],
125
+ }
126
+
127
+ try:
128
+ certificate = self.client.post("/nginx/certificates", json=cert_payload)
129
+ except Exception as exc:
130
+ return {
131
+ "ok": False,
132
+ "failed_step": "create_certificate",
133
+ "error": str(exc),
134
+ "created_certificate": None,
135
+ "created_proxy_host": None,
136
+ }
137
+
138
+ certificate_id = certificate.get("id") if isinstance(certificate, dict) else None
139
+ if not certificate_id:
140
+ return {
141
+ "ok": False,
142
+ "failed_step": "create_certificate",
143
+ "error": "NPM did not return a certificate id",
144
+ "created_certificate": certificate,
145
+ "created_proxy_host": None,
146
+ }
147
+
148
+ proxy_payload["certificate_id"] = certificate_id
149
+ try:
150
+ proxy_host = self.client.post("/nginx/proxy-hosts", json=proxy_payload)
151
+ except Exception as exc:
152
+ return {
153
+ "ok": False,
154
+ "failed_step": "create_proxy_host",
155
+ "error": str(exc),
156
+ "created_certificate": certificate,
157
+ "created_proxy_host": None,
158
+ }
159
+
160
+ return {
161
+ "ok": True,
162
+ "certificate": certificate,
163
+ "proxy_host": proxy_host,
164
+ }
165
+
166
+ def update_proxy_host(
167
+ self,
168
+ id: int,
169
+ domain_names: Optional[list[str]] = None,
170
+ forward_scheme: Optional[str] = None,
171
+ forward_host: Optional[str] = None,
172
+ forward_port: Optional[int] = None,
173
+ ssl_forced: Optional[bool] = None,
174
+ certificate_id: Optional[int] = None,
175
+ access_list_id: Optional[int] = None,
176
+ block_exploits: Optional[bool] = None,
177
+ caching_enabled: Optional[bool] = None,
178
+ allow_websocket_upgrade: Optional[bool] = None,
179
+ dry_run: bool = False,
180
+ ) -> dict:
181
+ """Update fields on a proxy host. Set dry_run=True to preview the merged payload."""
182
+ validate_positive_id(id)
183
+ if domain_names is not None:
184
+ validate_domain_names(domain_names)
185
+ if forward_scheme is not None:
186
+ validate_forward_scheme(forward_scheme)
187
+ if forward_host is not None:
188
+ validate_host(forward_host, "forward_host")
189
+ if forward_port is not None:
190
+ validate_port(forward_port, "forward_port")
191
+ validate_optional_positive_id(certificate_id, "certificate_id")
192
+ validate_optional_positive_id(access_list_id, "access_list_id")
193
+ existing = self.client.get(f"/nginx/proxy-hosts/{id}")
194
+ updates = {
195
+ k: v
196
+ for k, v in {
197
+ "domain_names": domain_names,
198
+ "forward_scheme": forward_scheme,
199
+ "forward_host": forward_host,
200
+ "forward_port": forward_port,
201
+ "ssl_forced": ssl_forced,
202
+ "certificate_id": certificate_id,
203
+ "access_list_id": access_list_id,
204
+ "block_exploits": block_exploits,
205
+ "caching_enabled": caching_enabled,
206
+ "allow_websocket_upgrade": allow_websocket_upgrade,
207
+ }.items()
208
+ if v is not None
209
+ }
210
+ existing = update_payload("proxy-hosts", existing, updates)
211
+ if dry_run:
212
+ return dry_run_preview("PUT", f"/nginx/proxy-hosts/{id}", existing)
213
+ return self.client.put(f"/nginx/proxy-hosts/{id}", json=existing)
214
+
215
+ def delete_proxy_host(self, id: int, dry_run: bool = False) -> bool | dict:
216
+ """Delete a proxy host permanently. Set dry_run=True to preview only."""
217
+ validate_positive_id(id)
218
+ if dry_run:
219
+ return dry_run_preview("DELETE", f"/nginx/proxy-hosts/{id}")
220
+ return self.client.delete(f"/nginx/proxy-hosts/{id}")
221
+
222
+ def enable_proxy_host(self, id: int, dry_run: bool = False) -> dict:
223
+ """Enable a previously disabled proxy host. Set dry_run=True to preview only."""
224
+ validate_positive_id(id)
225
+ if dry_run:
226
+ return dry_run_preview("POST", f"/nginx/proxy-hosts/{id}/enable")
227
+ return self.client.post(f"/nginx/proxy-hosts/{id}/enable")
228
+
229
+ def disable_proxy_host(self, id: int, dry_run: bool = False) -> dict:
230
+ """Disable a proxy host without deleting it. Set dry_run=True to preview only."""
231
+ validate_positive_id(id)
232
+ if dry_run:
233
+ return dry_run_preview("POST", f"/nginx/proxy-hosts/{id}/disable")
234
+ return self.client.post(f"/nginx/proxy-hosts/{id}/disable")
235
+
236
+
237
+ def register_proxy_host_tools(mcp: FastMCP, client: NPMClient) -> None:
238
+ tools = ProxyHostTools(client)
239
+ mcp.tool()(tools.list_proxy_hosts)
240
+ mcp.tool()(tools.get_proxy_host)
241
+ mcp.tool()(tools.create_proxy_host)
242
+ mcp.tool()(tools.create_proxy_host_with_letsencrypt)
243
+ mcp.tool()(tools.update_proxy_host)
244
+ mcp.tool()(tools.delete_proxy_host)
245
+ mcp.tool()(tools.enable_proxy_host)
246
+ mcp.tool()(tools.disable_proxy_host)