appleby-proxy-mcp 0.0.0-stage → 1.0.0

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.
Files changed (5) hide show
  1. package/AGENTS.md +111 -0
  2. package/README.md +125 -2
  3. package/index.js +537 -0
  4. package/package.json +32 -4
  5. package/verify.mjs +140 -0
package/AGENTS.md ADDED
@@ -0,0 +1,111 @@
1
+ # AGENTS.md — proxy-mcp
2
+
3
+ ## What this repo is
4
+
5
+ The MCP server for the appleby.cloud **Nginx Proxy Manager** (NPM on `aplb-proxy`):
6
+ **43 typed tools** over proxy-api's `/npm/v1` facade. A single-file Node ESM program, published to npm as
7
+ **`appleby-proxy-mcp`** (`proxy-mcp` on npm is an unrelated package) and run with
8
+ `npx -y appleby-proxy-mcp`.
9
+
10
+ It owns only the translation layer: tool names, zod schemas, descriptions, URL construction, and
11
+ a second masking pass. It does not own permissions (proxy-api's `npm.Allowed`), credential
12
+ redaction (proxy-api's `npm.Redact`), the audit trail (proxy-api's `npm.write` Monitor events)
13
+ or NPM's credentials (proxy-api holds them).
14
+
15
+ ## Stack & dependencies
16
+
17
+ - Node ≥ 18 (global `fetch`, `AbortSignal.timeout`), ESM, top-level `await`.
18
+ - `@modelcontextprotocol/sdk` ^1.29, `zod` ^4.4 (declared explicitly, as in lattice-mcp).
19
+ - No build step. `npm test` runs `verify.mjs`.
20
+
21
+ ## Project structure
22
+
23
+ | Path | Role |
24
+ |------|------|
25
+ | `index.js` | Setup flow, config, masking, `api()`/`npm()` helpers, shared schemas, every tool grouped under `// ───` banners, transport |
26
+ | `verify.mjs` | `npm test`: syntax, duplicate/misnamed tools, doc counts, version consistency, masking behaviour, proxy-api boundary |
27
+ | `README.md` | Human-facing setup and the full tool tables (verify.mjs checks every tool is listed) |
28
+
29
+ Keep new tools inside their banner group.
30
+
31
+ ## Running, building & testing
32
+
33
+ ```bash
34
+ npm install
35
+ npm test # must pass before publishing
36
+ PROXY_API_URL=https://proxy-api.appleby.cloud PROXY_API_TOKEN=frt_… node index.js
37
+ ```
38
+
39
+ Smoke test over stdio (the sleeps matter — requests otherwise race the handshake):
40
+
41
+ ```bash
42
+ { echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
43
+ sleep 2; echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
44
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; sleep 2; } \
45
+ | PROXY_API_URL=x PROXY_API_TOKEN=x node index.js
46
+ ```
47
+
48
+ ## How code is written here
49
+
50
+ - **Request shapes come from NPM's `/api/schema`, never from guesses.** NPM validates bodies
51
+ with `additionalProperties: false`, so an extra key is a 400 and a missing enum value a silent
52
+ wrong default. Fetch `GET /api/schema` from the running NPM (unauthenticated) when adding or
53
+ changing a tool, and note the NPM version.
54
+ - `body()` strips undefined keys, so updates send only what the caller passed. Fields that are
55
+ whole-value replacements (`advanced_config`, `locations`, access-list `items`/`clients`) say so
56
+ in their descriptions.
57
+ - Destructive tools say "Destructive" and name the read to do first.
58
+ - `sanitise()` runs on every response in `api()`; proxy-api's `"[redacted]"` passes through.
59
+
60
+ ## Domain & architecture
61
+
62
+ ```
63
+ agent ─stdio─▶ proxy-mcp ─HTTPS, Bearer frt_─▶ proxy-api.appleby.cloud (LAN) ─▶ /npm/v1/<path>
64
+ │ Forta token + grant on platform 11
65
+ │ npm.Allowed · npm.Redact · npm.write event
66
+ ▼
67
+ NPM API (agent account) on aplb-proxy
68
+ ```
69
+
70
+ Unavailable by design (proxy-api returns 403): `/tokens`, `/users`, certificate download/upload.
71
+ `verify.mjs` fails if a tool targets one of them.
72
+
73
+ ## Ecosystem & related repos
74
+
75
+ - **Calls:** [`proxy-api`](https://github.com/aidenappl/proxy-api) only.
76
+ - **Configured in:** `agent-config/mcp/servers.json` (`proxy` entry, `${FORTA_API_TOKEN}`).
77
+ - Siblings: `lattice-mcp` (the template for `verify.mjs`), `forta-mcp`, `monitor-mcp`,
78
+ `keyring-mcp`, `openbucket-mcp`.
79
+
80
+ ## Operations
81
+
82
+ - **Publishing is deployment:** bump `package.json` and `VERSION` in `index.js`, add a release
83
+ entry below, `npm test`, then `npm publish` from an interactive terminal (2FA passkey). Running
84
+ clients keep the old version until restarted.
85
+ - Failures surface as proxy-api's envelope: `error_message` "grant required" (token owner lacks a
86
+ platform 11 grant), "not available through proxy-api" (allowlist), "nginx proxy manager
87
+ unreachable" (502 from proxy-api).
88
+
89
+ ## Rules & guardrails
90
+
91
+ - Never add a tool for a path proxy-api refuses, and never widen proxy-api's allowlist to make one
92
+ work without updating proxy-api's tests and AGENTS.md in the same change.
93
+ - Never remove a key from `SECRET_FIELDS` or turn masking off by default.
94
+ - A new proxy-api route should add or consciously skip a tool here in the same change.
95
+
96
+ ## Verification
97
+
98
+ ```bash
99
+ node --check index.js
100
+ npm test
101
+ ```
102
+
103
+ ## Releases
104
+
105
+ - **1.0.0** — first release: 43 tools for NPM 2.16 (proxy, redirection and 404 hosts, streams,
106
+ certificates, access lists, default site, log viewer, audit log).
107
+
108
+ ## Keeping this file updated
109
+
110
+ Any change to tools, auth, structure or the proxy-api contract updates this file (and README.md's
111
+ tool tables and count) in the same change. `npm test` enforces the counts and the release entry.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # proxy-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MCP server for the appleby.cloud Nginx Proxy Manager — **43 typed tools** over proxy-api.
4
+
5
+ > **appleby.cloud platform** · MCP server · npm `appleby-proxy-mcp`
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ Gives agents (pi, Claude Code) the Nginx Proxy Manager on `aplb-proxy`: proxy, redirection and
12
+ 404 hosts, streams, certificates, access lists, the default site, NPM's log viewer and its audit
13
+ log. Every call goes through [`proxy-api`](https://github.com/aidenappl/proxy-api)'s `/npm/v1`
14
+ facade, which authenticates with Forta, enforces an allowlist, redacts credentials and records
15
+ every write in Monitor. NPM's own credentials never reach this server.
16
+
17
+ The npm name is `appleby-proxy-mcp` — `proxy-mcp` on npm is an unrelated package.
18
+
19
+ ## Role in the appleby.cloud ecosystem
20
+
21
+ One of the platform `*-mcp` servers (lattice, monitor, forta, keyring, openbucket, proxy). It
22
+ holds no logic: request shapes come from NPM 2.16's `/api/schema`, permissions from proxy-api.
23
+
24
+ ## Tech stack
25
+
26
+ Node ≥ 18 (ESM) · `@modelcontextprotocol/sdk` · `zod`
27
+
28
+ ## Getting started
29
+
30
+ ### Prerequisites
31
+
32
+ - A Forta API token (`frt_…`) whose owner holds a grant on the **APLB Proxy Manager** platform
33
+ (Forta id 11).
34
+ - LAN access to `proxy-api.appleby.cloud` (LAN-only DNS).
35
+
36
+ ### Setup
37
+
38
+ ```bash
39
+ npx appleby-proxy-mcp --setup # writes the "proxy" block into ~/.mcp.json
40
+ ```
41
+
42
+ Or by hand: `command: npx`, `args: ["-y", "appleby-proxy-mcp"]`, env `PROXY_API_URL`
43
+ (`https://proxy-api.appleby.cloud`) and `PROXY_API_TOKEN`. In agent-config it is the `proxy`
44
+ entry in `mcp/servers.json`, reusing `${FORTA_API_TOKEN}`.
45
+
46
+ ## Development
47
+
48
+ | Command | What it does |
49
+ |---------|--------------|
50
+ | `npm install` | Install the two dependencies |
51
+ | `npm test` | `verify.mjs`: syntax, tool names/counts, versions, masking, proxy-api's boundary |
52
+ | `node index.js --setup` | Interactive setup |
53
+ | `PROXY_API_URL=… PROXY_API_TOKEN=… node index.js` | Run on stdio |
54
+
55
+ ## Tools
56
+
57
+ ### Health & reports
58
+
59
+ | Tool | What it does |
60
+ |------|--------------|
61
+ | `proxy_health` | proxy-api's own healthcheck |
62
+ | `proxy_check_version` | NPM's version and whether a newer release exists |
63
+ | `proxy_get_host_report` | Host counts by type |
64
+
65
+ ### Proxy hosts
66
+
67
+ | Tool | What it does |
68
+ |------|--------------|
69
+ | `proxy_list_proxy_hosts` | List (optionally expand access_list / owner / certificate) |
70
+ | `proxy_get_proxy_host` | One host with advanced config and locations |
71
+ | `proxy_create_proxy_host` | Create (Forta gate recipe in the description) |
72
+ | `proxy_update_proxy_host` | Partial update; advanced_config and locations replace wholesale |
73
+ | `proxy_enable_proxy_host` / `proxy_disable_proxy_host` | Toggle |
74
+ | `proxy_delete_proxy_host` | Delete |
75
+
76
+ ### Redirection & 404 hosts, streams
77
+
78
+ | Tool | What it does |
79
+ |------|--------------|
80
+ | `proxy_list_redirection_hosts`, `proxy_get_redirection_host` | Read |
81
+ | `proxy_create_redirection_host`, `proxy_update_redirection_host`, `proxy_delete_redirection_host` | Write |
82
+ | `proxy_enable_redirection_host`, `proxy_disable_redirection_host` | Toggle |
83
+ | `proxy_list_dead_hosts`, `proxy_create_dead_host`, `proxy_update_dead_host`, `proxy_delete_dead_host` | 404 hosts |
84
+ | `proxy_list_streams`, `proxy_create_stream`, `proxy_update_stream`, `proxy_delete_stream` | TCP/UDP streams |
85
+
86
+ ### Certificates
87
+
88
+ | Tool | What it does |
89
+ |------|--------------|
90
+ | `proxy_list_certificates`, `proxy_get_certificate` | Read (keys and DNS credentials redacted) |
91
+ | `proxy_list_dns_providers` | Certbot DNS plugins |
92
+ | `proxy_create_certificate` | Let's Encrypt request (prefer the existing `*.appleby.cloud` wildcard) |
93
+ | `proxy_renew_certificate` | Renew now |
94
+ | `proxy_test_http_challenge` | HTTP-01 reachability check |
95
+ | `proxy_delete_certificate` | Delete |
96
+
97
+ Downloading and uploading certificates are deliberately unavailable.
98
+
99
+ ### Access lists & settings
100
+
101
+ | Tool | What it does |
102
+ |------|--------------|
103
+ | `proxy_list_access_lists`, `proxy_get_access_list` | Read (passwords redacted) |
104
+ | `proxy_create_access_list`, `proxy_update_access_list`, `proxy_delete_access_list` | Write |
105
+ | `proxy_list_settings` | Settings |
106
+ | `proxy_update_default_site` | What unknown hostnames get |
107
+
108
+ ### Logs & audit
109
+
110
+ | Tool | What it does |
111
+ |------|--------------|
112
+ | `proxy_list_log_sources` | What the log viewer can read |
113
+ | `proxy_tail_log` | Tail system, Let's Encrypt or a host's access/error log |
114
+ | `proxy_list_audit_log`, `proxy_get_audit_log_entry` | NPM's audit trail |
115
+
116
+ For request history and analytics use Monitor (service `proxy`, zone `appleby`).
117
+
118
+ ## Deployment
119
+
120
+ Publishing is deployment: `npm publish` (2FA passkey, interactive terminal). Clients pick up a
121
+ new version only after they restart.
122
+
123
+ ## Contributing & further reading
124
+
125
+ - [`AGENTS.md`](AGENTS.md)
126
+ - [`proxy-api`](https://github.com/aidenappl/proxy-api) — the facade, allowlist and redaction
package/index.js ADDED
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { createInterface } from "readline";
6
+ import { readFileSync, writeFileSync, existsSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+
10
+ const VERSION = "1.0.0";
11
+
12
+ // --- Interactive setup ---
13
+
14
+ if (process.argv.includes("--setup")) {
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
17
+
18
+ console.log("\n Proxy MCP Setup\n");
19
+
20
+ const apiUrl = (await ask(" proxy-api URL (https://proxy-api.appleby.cloud): ")).trim() || "https://proxy-api.appleby.cloud";
21
+ const apiToken = (await ask(" Forta API Token (frt_…): ")).trim();
22
+ rl.close();
23
+
24
+ if (!apiToken.startsWith("frt_")) {
25
+ console.error("\n Error: proxy-api accepts a Forta API token (frt_…) whose owner holds a grant on");
26
+ console.error(" the APLB Proxy Manager platform. Create one in the Forta dashboard.\n");
27
+ process.exit(1);
28
+ }
29
+
30
+ const mcpPath = join(homedir(), ".mcp.json");
31
+ let config = { mcpServers: {} };
32
+ if (existsSync(mcpPath)) {
33
+ try { config = JSON.parse(readFileSync(mcpPath, "utf-8")); } catch {}
34
+ if (!config.mcpServers) config.mcpServers = {};
35
+ }
36
+
37
+ config.mcpServers.proxy = {
38
+ command: "npx",
39
+ args: ["-y", "appleby-proxy-mcp"],
40
+ env: {
41
+ PROXY_API_URL: apiUrl,
42
+ PROXY_API_TOKEN: apiToken,
43
+ },
44
+ };
45
+
46
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
47
+ console.log(`\n Written to ${mcpPath}`);
48
+ console.log(" Restart Claude Code to load the Proxy MCP server.\n");
49
+ process.exit(0);
50
+ }
51
+
52
+ // --- MCP Server ---
53
+
54
+ const API_URL = process.env.PROXY_API_URL;
55
+ const API_TOKEN = process.env.PROXY_API_TOKEN;
56
+
57
+ if (!API_URL || !API_TOKEN) {
58
+ console.error("PROXY_API_URL and PROXY_API_TOKEN are required.");
59
+ console.error("Run `npx appleby-proxy-mcp --setup` to configure.");
60
+ process.exit(1);
61
+ }
62
+
63
+ // --- Sensitive value masking ---
64
+ //
65
+ // proxy-api already redacts credentials from every NPM response (DNS-provider
66
+ // API tokens, private keys, access-list passwords) and refuses certificate
67
+ // downloads outright. This is the second layer: anything that reaches a
68
+ // model's context is in a transcript forever, so a key proxy-api's list does
69
+ // not know about yet is still masked here.
70
+ //
71
+ // Sensitive values are masked to their first two characters plus a
72
+ // fixed-width tail. proxy-api's own "[redacted]" passes through untouched.
73
+ // Set PROXY_ALLOW_SECRET_VALUES=1 to pass values through unmasked.
74
+
75
+ const ALLOW_SECRETS = process.env.PROXY_ALLOW_SECRET_VALUES === "1";
76
+
77
+ function mask(value) {
78
+ if (typeof value !== "string" || value === "" || value === "[redacted]") return value;
79
+ if (value.length < 3) return "**********";
80
+ return value.slice(0, 2) + "**********";
81
+ }
82
+
83
+ const SECRET_FIELDS = new Set([
84
+ "dns_provider_credentials", "certificate_key", "password", "secret", "token",
85
+ "access_token", "refresh_token", "private_key", "api_key",
86
+ ]);
87
+
88
+ function sanitise(node) {
89
+ if (ALLOW_SECRETS || node === null || typeof node !== "object") return node;
90
+ if (Array.isArray(node)) return node.map(sanitise);
91
+ const out = {};
92
+ for (const [k, v] of Object.entries(node)) {
93
+ out[k] = SECRET_FIELDS.has(k) && typeof v === "string" ? mask(v) : sanitise(v);
94
+ }
95
+ return out;
96
+ }
97
+
98
+ // --- HTTP helper ---
99
+ //
100
+ // Every NPM call goes through proxy-api's facade: /npm/v1/<path> → NPM's
101
+ // /api/<path>, as the NPM agent account. proxy-api answers in the standard
102
+ // appleby.cloud envelope ({success, data} / {success:false, error_message}).
103
+
104
+ async function api(method, path, params, body) {
105
+ const url = new URL(path, API_URL);
106
+ if (params) {
107
+ for (const [k, v] of Object.entries(params)) {
108
+ if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
109
+ }
110
+ }
111
+ const opts = {
112
+ method,
113
+ headers: { Authorization: `Bearer ${API_TOKEN}` },
114
+ signal: AbortSignal.timeout(60000),
115
+ };
116
+ if (body !== undefined) {
117
+ opts.headers["Content-Type"] = "application/json";
118
+ opts.body = JSON.stringify(body);
119
+ }
120
+ try {
121
+ const res = await fetch(url.toString(), opts);
122
+ const text = await res.text();
123
+ if (!text) return { success: res.ok, status: res.status };
124
+ try {
125
+ return sanitise(JSON.parse(text));
126
+ } catch {
127
+ return { success: res.ok, status: res.status, body: text.slice(0, 2000) };
128
+ }
129
+ } catch (err) {
130
+ return { success: false, error: err.message, error_message: `API request failed: ${err.message}` };
131
+ }
132
+ }
133
+
134
+ const npm = (method, path, params, body) => api(method, `/npm/v1/${path}`, params, body);
135
+
136
+ function text(data) {
137
+ return [{ type: "text", text: JSON.stringify(data, null, 2) }];
138
+ }
139
+
140
+ // Strips undefined keys so a PUT only sends the fields the caller supplied.
141
+ // NPM validates bodies with additionalProperties: false, so a stray key is a 400.
142
+ function body(obj) {
143
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
144
+ }
145
+
146
+ // --- Shared schemas (from NPM 2.16's /api/schema) ---
147
+
148
+ const id = z.number().int().positive();
149
+ const domainNames = z.array(z.string()).min(1).describe("Hostnames this host answers for, e.g. ['app.appleby.cloud']");
150
+ const certificateId = z.union([z.number().int().min(0), z.literal("new")]).optional()
151
+ .describe("Certificate ID from proxy_list_certificates (0 = none). 'new' requests a new Let's Encrypt cert, which needs meta.letsencrypt_* fields");
152
+ const advancedConfig = z.string().optional().describe("nginx directives placed in the server block (the Advanced tab). Replaces the existing value entirely");
153
+ const meta = z.record(z.string(), z.any()).optional().describe("NPM meta object (rarely needed)");
154
+ const sslFlags = {
155
+ ssl_forced: z.boolean().optional().describe("Redirect HTTP to HTTPS"),
156
+ hsts_enabled: z.boolean().optional(),
157
+ hsts_subdomains: z.boolean().optional(),
158
+ http2_support: z.boolean().optional(),
159
+ };
160
+ const location = z.object({
161
+ path: z.string().min(1).describe("Location path, e.g. /api"),
162
+ forward_scheme: z.enum(["http", "https"]),
163
+ forward_host: z.string().min(1),
164
+ forward_port: z.number().int().min(1).max(65535),
165
+ forward_path: z.string().optional(),
166
+ advanced_config: z.string().optional(),
167
+ access_list_id: z.number().int().min(0).optional(),
168
+ });
169
+ const proxyHostFields = {
170
+ forward_scheme: z.enum(["http", "https"]),
171
+ forward_host: z.string().describe("Upstream host or IP, e.g. 10.15.10.76"),
172
+ forward_port: z.number().int().min(1).max(65535),
173
+ certificate_id: certificateId,
174
+ ...sslFlags,
175
+ trust_forwarded_proto: z.boolean().optional().describe("Trust X-Forwarded-Proto from upstream proxies"),
176
+ block_exploits: z.boolean().optional(),
177
+ caching_enabled: z.boolean().optional(),
178
+ allow_websocket_upgrade: z.boolean().optional(),
179
+ access_list_id: z.number().int().min(0).optional().describe("Access list ID (0 = public)"),
180
+ advanced_config: advancedConfig,
181
+ enabled: z.boolean().optional(),
182
+ meta,
183
+ locations: z.array(location).optional().describe("Custom locations. Replaces the existing list entirely"),
184
+ };
185
+ const expand = (values) => z.array(z.enum(values)).optional().describe(`Related objects to embed: ${values.join(", ")}`);
186
+ const expandParam = (e) => (e && e.length ? { expand: e.join(",") } : null);
187
+
188
+ const server = new McpServer({
189
+ name: "proxy",
190
+ version: VERSION,
191
+ });
192
+
193
+ // ─── Health ──────────────────────────────────────────────────────────────────
194
+
195
+ server.tool("proxy_health", "Check proxy-api is up (its own /healthcheck — not NPM's)", {}, async () => {
196
+ const res = await api("GET", "/healthcheck");
197
+ return { content: text(res) };
198
+ });
199
+
200
+ server.tool("proxy_check_version", "NPM's running version and whether a newer release exists (NPM asks GitHub). Use before and after an upgrade of the jc21/nginx-proxy-manager image on aplb-proxy", {}, async () => {
201
+ const res = await npm("GET", "version/check");
202
+ return { content: text(res) };
203
+ });
204
+
205
+ server.tool("proxy_get_host_report", "Count of proxy, redirection, stream and 404 hosts NPM manages", {}, async () => {
206
+ const res = await npm("GET", "reports/hosts");
207
+ return { content: text(res) };
208
+ });
209
+
210
+ // ─── Proxy hosts ─────────────────────────────────────────────────────────────
211
+
212
+ server.tool("proxy_list_proxy_hosts", "List NPM proxy hosts: domain_names, forward_scheme/host/port, certificate_id, ssl_forced, block_exploits, websockets, access_list_id, advanced_config, enabled, locations. Start here to find a host's id", {
213
+ expand: expand(["access_list", "owner", "certificate"]),
214
+ }, async ({ expand: e }) => {
215
+ const res = await npm("GET", "nginx/proxy-hosts", expandParam(e));
216
+ return { content: text(res) };
217
+ });
218
+
219
+ server.tool("proxy_get_proxy_host", "Get one proxy host, including its advanced_config and custom locations", {
220
+ id,
221
+ expand: expand(["access_list", "owner", "certificate"]),
222
+ }, async ({ id: hostId, expand: e }) => {
223
+ const res = await npm("GET", `nginx/proxy-hosts/${hostId}`, expandParam(e));
224
+ return { content: text(res) };
225
+ });
226
+
227
+ server.tool("proxy_create_proxy_host", "Create a proxy host. NPM writes and reloads nginx immediately; a config nginx rejects is saved as <id>.conf.err and the host goes offline (meta.nginx_online=false) — check the response's meta. For a Forta-gated host on *.appleby.cloud, put `set $forta_client_id \"<client_id>\"; include /data/nginx/custom/forta-gate.conf;` in advanced_config and use a DNS-01 certificate", {
228
+ domain_names: domainNames,
229
+ ...proxyHostFields,
230
+ }, async (args) => {
231
+ const res = await npm("POST", "nginx/proxy-hosts", null, body(args));
232
+ return { content: text(res) };
233
+ });
234
+
235
+ server.tool("proxy_update_proxy_host", "Update a proxy host. Only the fields you pass change — but advanced_config and locations each replace the whole value, so read the host first with proxy_get_proxy_host and send the full edited text. NPM regenerates and reloads the host's nginx config", {
236
+ id,
237
+ domain_names: domainNames.optional(),
238
+ ...Object.fromEntries(Object.entries(proxyHostFields).map(([k, v]) => [k, v.optional()])),
239
+ }, async ({ id: hostId, ...fields }) => {
240
+ const res = await npm("PUT", `nginx/proxy-hosts/${hostId}`, null, body(fields));
241
+ return { content: text(res) };
242
+ });
243
+
244
+ server.tool("proxy_enable_proxy_host", "Enable a disabled proxy host (regenerates its nginx config)", { id }, async ({ id: hostId }) => {
245
+ const res = await npm("POST", `nginx/proxy-hosts/${hostId}/enable`, null, {});
246
+ return { content: text(res) };
247
+ });
248
+
249
+ server.tool("proxy_disable_proxy_host", "Disable a proxy host: its domains stop being served by this host immediately (requests fall to the default site). Reversible with proxy_enable_proxy_host", { id }, async ({ id: hostId }) => {
250
+ const res = await npm("POST", `nginx/proxy-hosts/${hostId}/disable`, null, {});
251
+ return { content: text(res) };
252
+ });
253
+
254
+ server.tool("proxy_delete_proxy_host", "Delete a proxy host and its nginx config. Destructive: every domain on it stops being proxied. Confirm the id with proxy_get_proxy_host first; prefer proxy_disable_proxy_host when unsure", { id }, async ({ id: hostId }) => {
255
+ const res = await npm("DELETE", `nginx/proxy-hosts/${hostId}`);
256
+ return { content: text(res) };
257
+ });
258
+
259
+ // ─── Redirection hosts ───────────────────────────────────────────────────────
260
+
261
+ const redirectionFields = {
262
+ forward_http_code: z.number().int().describe("HTTP status for the redirect, e.g. 301, 302, 307, 308"),
263
+ forward_scheme: z.enum(["auto", "http", "https"]),
264
+ forward_domain_name: z.string().describe("Target domain, e.g. forta.appleby.cloud"),
265
+ preserve_path: z.boolean().optional().describe("Append the request path to the target"),
266
+ certificate_id: certificateId,
267
+ ...sslFlags,
268
+ block_exploits: z.boolean().optional(),
269
+ advanced_config: advancedConfig,
270
+ meta,
271
+ };
272
+
273
+ server.tool("proxy_list_redirection_hosts", "List NPM redirection hosts (domain → domain redirects)", {
274
+ expand: expand(["owner", "certificate"]),
275
+ }, async ({ expand: e }) => {
276
+ const res = await npm("GET", "nginx/redirection-hosts", expandParam(e));
277
+ return { content: text(res) };
278
+ });
279
+
280
+ server.tool("proxy_get_redirection_host", "Get one redirection host", { id }, async ({ id: hostId }) => {
281
+ const res = await npm("GET", `nginx/redirection-hosts/${hostId}`);
282
+ return { content: text(res) };
283
+ });
284
+
285
+ server.tool("proxy_create_redirection_host", "Create a redirection host", {
286
+ domain_names: domainNames,
287
+ ...redirectionFields,
288
+ }, async (args) => {
289
+ const res = await npm("POST", "nginx/redirection-hosts", null, body(args));
290
+ return { content: text(res) };
291
+ });
292
+
293
+ server.tool("proxy_update_redirection_host", "Update a redirection host. Only the fields you pass change", {
294
+ id,
295
+ domain_names: domainNames.optional(),
296
+ ...Object.fromEntries(Object.entries(redirectionFields).map(([k, v]) => [k, v.optional()])),
297
+ }, async ({ id: hostId, ...fields }) => {
298
+ const res = await npm("PUT", `nginx/redirection-hosts/${hostId}`, null, body(fields));
299
+ return { content: text(res) };
300
+ });
301
+
302
+ server.tool("proxy_enable_redirection_host", "Enable a redirection host", { id }, async ({ id: hostId }) => {
303
+ const res = await npm("POST", `nginx/redirection-hosts/${hostId}/enable`, null, {});
304
+ return { content: text(res) };
305
+ });
306
+
307
+ server.tool("proxy_disable_redirection_host", "Disable a redirection host", { id }, async ({ id: hostId }) => {
308
+ const res = await npm("POST", `nginx/redirection-hosts/${hostId}/disable`, null, {});
309
+ return { content: text(res) };
310
+ });
311
+
312
+ server.tool("proxy_delete_redirection_host", "Delete a redirection host. Destructive", { id }, async ({ id: hostId }) => {
313
+ const res = await npm("DELETE", `nginx/redirection-hosts/${hostId}`);
314
+ return { content: text(res) };
315
+ });
316
+
317
+ // ─── 404 hosts ───────────────────────────────────────────────────────────────
318
+
319
+ const deadHostFields = {
320
+ certificate_id: certificateId,
321
+ ...sslFlags,
322
+ advanced_config: advancedConfig,
323
+ meta,
324
+ };
325
+
326
+ server.tool("proxy_list_dead_hosts", "List NPM 404 hosts (domains NPM answers with 404)", {
327
+ expand: expand(["owner", "certificate"]),
328
+ }, async ({ expand: e }) => {
329
+ const res = await npm("GET", "nginx/dead-hosts", expandParam(e));
330
+ return { content: text(res) };
331
+ });
332
+
333
+ server.tool("proxy_create_dead_host", "Create a 404 host", {
334
+ domain_names: domainNames,
335
+ ...deadHostFields,
336
+ }, async (args) => {
337
+ const res = await npm("POST", "nginx/dead-hosts", null, body(args));
338
+ return { content: text(res) };
339
+ });
340
+
341
+ server.tool("proxy_update_dead_host", "Update a 404 host. Only the fields you pass change", {
342
+ id,
343
+ domain_names: domainNames.optional(),
344
+ ...deadHostFields,
345
+ }, async ({ id: hostId, ...fields }) => {
346
+ const res = await npm("PUT", `nginx/dead-hosts/${hostId}`, null, body(fields));
347
+ return { content: text(res) };
348
+ });
349
+
350
+ server.tool("proxy_delete_dead_host", "Delete a 404 host. Destructive", { id }, async ({ id: hostId }) => {
351
+ const res = await npm("DELETE", `nginx/dead-hosts/${hostId}`);
352
+ return { content: text(res) };
353
+ });
354
+
355
+ // ─── Streams ─────────────────────────────────────────────────────────────────
356
+
357
+ const streamFields = {
358
+ incoming_port: z.number().int().min(1).max(65535).describe("Port NPM listens on (must also be published by the NPM container)"),
359
+ forwarding_host: z.string().describe("Upstream domain, IPv4 or IPv6"),
360
+ forwarding_port: z.number().int().min(1).max(65535),
361
+ tcp_forwarding: z.boolean().optional(),
362
+ udp_forwarding: z.boolean().optional(),
363
+ certificate_id: certificateId,
364
+ meta,
365
+ };
366
+
367
+ server.tool("proxy_list_streams", "List NPM TCP/UDP streams", {
368
+ expand: expand(["owner", "certificate"]),
369
+ }, async ({ expand: e }) => {
370
+ const res = await npm("GET", "nginx/streams", expandParam(e));
371
+ return { content: text(res) };
372
+ });
373
+
374
+ server.tool("proxy_create_stream", "Create a TCP/UDP stream. The incoming port must also be published on aplb-proxy's NPM container to be reachable", {
375
+ ...streamFields,
376
+ }, async (args) => {
377
+ const res = await npm("POST", "nginx/streams", null, body(args));
378
+ return { content: text(res) };
379
+ });
380
+
381
+ server.tool("proxy_update_stream", "Update a stream. Only the fields you pass change", {
382
+ id,
383
+ ...Object.fromEntries(Object.entries(streamFields).map(([k, v]) => [k, v.optional()])),
384
+ }, async ({ id: streamId, ...fields }) => {
385
+ const res = await npm("PUT", `nginx/streams/${streamId}`, null, body(fields));
386
+ return { content: text(res) };
387
+ });
388
+
389
+ server.tool("proxy_delete_stream", "Delete a stream. Destructive", { id }, async ({ id: streamId }) => {
390
+ const res = await npm("DELETE", `nginx/streams/${streamId}`);
391
+ return { content: text(res) };
392
+ });
393
+
394
+ // ─── Certificates ────────────────────────────────────────────────────────────
395
+
396
+ server.tool("proxy_list_certificates", "List NPM certificates: id, nice_name, domain_names, provider, expires_on. Private keys and DNS-provider credentials are always redacted, and downloading a certificate is not available through proxy-api", {
397
+ expand: expand(["owner"]),
398
+ }, async ({ expand: e }) => {
399
+ const res = await npm("GET", "nginx/certificates", expandParam(e));
400
+ return { content: text(res) };
401
+ });
402
+
403
+ server.tool("proxy_get_certificate", "Get one certificate (credentials redacted)", { id }, async ({ id: certId }) => {
404
+ const res = await npm("GET", `nginx/certificates/${certId}`);
405
+ return { content: text(res) };
406
+ });
407
+
408
+ server.tool("proxy_list_dns_providers", "List the certbot DNS plugins NPM supports, for a DNS-01 certificate request", {}, async () => {
409
+ const res = await npm("GET", "nginx/certificates/dns-providers");
410
+ return { content: text(res) };
411
+ });
412
+
413
+ server.tool("proxy_create_certificate", "Request a Let's Encrypt certificate. Every *.appleby.cloud name is already covered by the wildcard (find it with proxy_list_certificates) — reuse that instead of issuing more. DNS-01 needs meta.dns_challenge=true, meta.dns_provider and meta.dns_provider_credentials (a credential: prefer the existing wildcard)", {
414
+ provider: z.literal("letsencrypt").describe("Only letsencrypt: uploading custom certificates is not available through proxy-api"),
415
+ nice_name: z.string().optional(),
416
+ domain_names: domainNames,
417
+ meta: z.object({
418
+ dns_challenge: z.boolean().optional(),
419
+ dns_provider: z.string().optional().describe("From proxy_list_dns_providers, e.g. cloudflare"),
420
+ dns_provider_credentials: z.string().optional(),
421
+ propagation_seconds: z.number().int().min(0).optional(),
422
+ key_type: z.enum(["rsa", "ecdsa"]).optional(),
423
+ }).optional(),
424
+ }, async (args) => {
425
+ const res = await npm("POST", "nginx/certificates", null, body(args));
426
+ return { content: text(res) };
427
+ });
428
+
429
+ server.tool("proxy_renew_certificate", "Renew a Let's Encrypt certificate now (NPM also renews automatically at 30 days). Can take a minute — DNS-01 waits for propagation", { id }, async ({ id: certId }) => {
430
+ const res = await npm("POST", `nginx/certificates/${certId}/renew`, null, {});
431
+ return { content: text(res) };
432
+ });
433
+
434
+ server.tool("proxy_test_http_challenge", "Check whether domains can pass Let's Encrypt's HTTP-01 challenge through this NPM (reachability of /.well-known/acme-challenge from the internet)", {
435
+ domains: z.array(z.string()).min(1),
436
+ }, async ({ domains }) => {
437
+ const res = await npm("POST", "nginx/certificates/test-http", null, { domains });
438
+ return { content: text(res) };
439
+ });
440
+
441
+ server.tool("proxy_delete_certificate", "Delete a certificate. Destructive: hosts using it lose TLS. Check proxy_list_proxy_hosts for certificate_id users first", { id }, async ({ id: certId }) => {
442
+ const res = await npm("DELETE", `nginx/certificates/${certId}`);
443
+ return { content: text(res) };
444
+ });
445
+
446
+ // ─── Access lists ────────────────────────────────────────────────────────────
447
+
448
+ const accessListFields = {
449
+ name: z.string(),
450
+ satisfy_any: z.boolean().optional().describe("Pass if EITHER the IP rules or basic auth match (default: both)"),
451
+ pass_auth: z.boolean().optional().describe("Forward the Authorization header to the upstream"),
452
+ items: z.array(z.object({ username: z.string().min(1), password: z.string().optional() })).optional()
453
+ .describe("Basic-auth users. Replaces the list; an empty password keeps the stored one"),
454
+ clients: z.array(z.object({ address: z.string().describe("IPv4/IPv6 address or CIDR, or 'all'"), directive: z.enum(["allow", "deny"]) })).optional()
455
+ .describe("IP rules, in order. Replaces the list"),
456
+ };
457
+
458
+ server.tool("proxy_list_access_lists", "List NPM access lists (IP allow/deny + basic auth). Passwords are redacted. For appleby.cloud SSO, prefer the Forta gate (see proxy_create_proxy_host) over basic auth", {
459
+ expand: expand(["owner", "items", "clients", "proxy_hosts"]),
460
+ }, async ({ expand: e }) => {
461
+ const res = await npm("GET", "nginx/access-lists", expandParam(e));
462
+ return { content: text(res) };
463
+ });
464
+
465
+ server.tool("proxy_get_access_list", "Get one access list", {
466
+ id,
467
+ expand: expand(["owner", "items", "clients", "proxy_hosts"]),
468
+ }, async ({ id: listId, expand: e }) => {
469
+ const res = await npm("GET", `nginx/access-lists/${listId}`, expandParam(e));
470
+ return { content: text(res) };
471
+ });
472
+
473
+ server.tool("proxy_create_access_list", "Create an access list", accessListFields, async (args) => {
474
+ const res = await npm("POST", "nginx/access-lists", null, body(args));
475
+ return { content: text(res) };
476
+ });
477
+
478
+ server.tool("proxy_update_access_list", "Update an access list. Only the fields you pass change; items and clients replace their whole lists", {
479
+ id,
480
+ ...Object.fromEntries(Object.entries(accessListFields).map(([k, v]) => [k, v.optional()])),
481
+ }, async ({ id: listId, ...fields }) => {
482
+ const res = await npm("PUT", `nginx/access-lists/${listId}`, null, body(fields));
483
+ return { content: text(res) };
484
+ });
485
+
486
+ server.tool("proxy_delete_access_list", "Delete an access list. Hosts using it become public. Check proxy_get_access_list with expand proxy_hosts first", { id }, async ({ id: listId }) => {
487
+ const res = await npm("DELETE", `nginx/access-lists/${listId}`);
488
+ return { content: text(res) };
489
+ });
490
+
491
+ // ─── Settings ────────────────────────────────────────────────────────────────
492
+
493
+ server.tool("proxy_list_settings", "List NPM settings (currently only default-site: what NPM serves for unknown hostnames)", {}, async () => {
494
+ const res = await npm("GET", "settings");
495
+ return { content: text(res) };
496
+ });
497
+
498
+ server.tool("proxy_update_default_site", "Set what NPM serves for a hostname no host claims: congratulations page, 404, 444 (close the connection), a redirect (meta.redirect = URL) or custom HTML (meta.html)", {
499
+ value: z.enum(["congratulations", "404", "444", "redirect", "html"]),
500
+ meta: z.object({ redirect: z.string().optional(), html: z.string().optional() }).optional(),
501
+ }, async (args) => {
502
+ const res = await npm("PUT", "settings/default-site", null, body(args));
503
+ return { content: text(res) };
504
+ });
505
+
506
+ // ─── Logs & audit ────────────────────────────────────────────────────────────
507
+
508
+ server.tool("proxy_list_log_sources", "List the log files NPM's log viewer can read: system, letsencrypt, and each host's access/error logs", {}, async () => {
509
+ const res = await npm("GET", "logs/sources");
510
+ return { content: text(res) };
511
+ });
512
+
513
+ server.tool("proxy_tail_log", "Tail an NPM log. type=host needs host_type + host_id + channel (access|error); type=letsencrypt shows certbot runs (cert renewal failures); type=system is the NPM backend. For history and analytics use Monitor (service \"proxy\", zone appleby) instead — every request and nginx error is there", {
514
+ type: z.enum(["system", "letsencrypt", "host"]),
515
+ host_type: z.enum(["proxy", "redirection", "dead", "stream"]).optional(),
516
+ host_id: z.number().int().positive().optional(),
517
+ channel: z.enum(["access", "error"]).optional(),
518
+ lines: z.number().int().min(1).max(5000).optional().describe("Lines from the end (default NPM's)"),
519
+ level: z.enum(["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]).optional().describe("system logs only"),
520
+ search: z.string().optional().describe("Substring filter"),
521
+ }, async (args) => {
522
+ const res = await npm("GET", "logs/tail", args);
523
+ return { content: text(res) };
524
+ });
525
+
526
+ server.tool("proxy_list_audit_log", "NPM's audit log: who created, changed, enabled or deleted which host, certificate or access list. proxy-api's own writes appear as the NPM agent account; Monitor's npm.write events say which Forta user made them", {}, async () => {
527
+ const res = await npm("GET", "audit-log");
528
+ return { content: text(res) };
529
+ });
530
+
531
+ server.tool("proxy_get_audit_log_entry", "Get one audit log entry, with its full before/after meta", { id }, async ({ id: entryId }) => {
532
+ const res = await npm("GET", `audit-log/${entryId}`);
533
+ return { content: text(res) };
534
+ });
535
+
536
+ const transport = new StdioServerTransport();
537
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "appleby-proxy-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the appleby.cloud Nginx Proxy Manager, through proxy-api (repo: proxy-mcp)",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "appleby-proxy-mcp": "index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "test": "node verify.mjs"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "nginx",
17
+ "nginx-proxy-manager",
18
+ "reverse-proxy",
19
+ "model-context-protocol"
20
+ ],
21
+ "author": "Aiden Appleby",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/aidenappl/proxy-mcp.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "zod": "^4.4.3"
33
+ }
34
+ }
package/verify.mjs ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Static verification for proxy-mcp (published as appleby-proxy-mcp).
4
+ *
5
+ * index.js cannot be imported to check it: it ends in a top-level await that
6
+ * connects the stdio transport. Everything here parses the source instead.
7
+ * Shape: lattice-mcp's verify.mjs.
8
+ *
9
+ * - Duplicate tool names: server.tool() silently keeps the last registration.
10
+ * - Tool counts in README.md / AGENTS.md drifting from the code.
11
+ * - Tools not prefixed proxy_.
12
+ * - Version drift between package.json, index.js and AGENTS.md.
13
+ * - sanitise() unwired from api(), or no longer masking.
14
+ * - A tool aimed at an NPM path proxy-api refuses (npm.Allowed in proxy-api):
15
+ * it would only ever return 403, and its presence would suggest the
16
+ * boundary had moved.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { execFileSync } from "node:child_process";
21
+ import vm from "node:vm";
22
+
23
+ const failures = [];
24
+ const fail = (msg) => failures.push(msg);
25
+
26
+ const source = readFileSync("index.js", "utf8");
27
+
28
+ // ── Syntax ───────────────────────────────────────────────────────────────────
29
+ try {
30
+ execFileSync(process.execPath, ["--check", "index.js"], { stdio: "pipe" });
31
+ } catch (err) {
32
+ fail(`index.js failed to parse:\n${err.stderr?.toString() ?? err.message}`);
33
+ }
34
+
35
+ // ── Tool registrations ───────────────────────────────────────────────────────
36
+ const names = [...source.matchAll(/server\.tool\(\s*"([^"]+)"/g)].map((m) => m[1]);
37
+ if (names.length === 0) fail("no server.tool() registrations found — did the call shape change?");
38
+
39
+ const seen = new Set();
40
+ const duplicates = new Set();
41
+ for (const name of names) {
42
+ if (seen.has(name)) duplicates.add(name);
43
+ seen.add(name);
44
+ }
45
+ if (duplicates.size > 0) fail(`duplicate tool names: ${[...duplicates].join(", ")}`);
46
+
47
+ const misnamed = names.filter((n) => !n.startsWith("proxy_"));
48
+ if (misnamed.length > 0) fail(`tools not prefixed with proxy_: ${misnamed.join(", ")}`);
49
+
50
+ // ── proxy-api's boundary ─────────────────────────────────────────────────────
51
+ const targets = [...source.matchAll(/npm\("(?:GET|POST|PUT|DELETE)",\s*[`"]([^`"]+)[`"]/g)].map((m) => m[1]);
52
+ const forbidden = targets.filter((p) => /^(tokens|users)(\/|$)|\/(download|upload)$/.test(p));
53
+ if (forbidden.length > 0) fail(`tools target paths proxy-api refuses: ${forbidden.join(", ")}`);
54
+
55
+ // ── Documented counts must match the code ────────────────────────────────────
56
+ const toolCount = names.length;
57
+ for (const file of ["README.md", "AGENTS.md"]) {
58
+ const doc = readFileSync(file, "utf8");
59
+ const match = doc.match(/\*\*(\d+) typed tools\*\*/);
60
+ if (!match) {
61
+ fail(`${file}: could not find a "**N typed tools**" figure to check against`);
62
+ continue;
63
+ }
64
+ if (Number(match[1]) !== toolCount) {
65
+ fail(`${file} documents ${match[1]} tools but index.js registers ${toolCount} — update the docs in the same change`);
66
+ }
67
+ }
68
+ const readme = readFileSync("README.md", "utf8");
69
+ const undocumented = names.filter((n) => !readme.includes(`\`${n}\``));
70
+ if (undocumented.length > 0) fail(`tools missing from the README tool tables: ${undocumented.join(", ")}`);
71
+
72
+ // ── Version consistency ──────────────────────────────────────────────────────
73
+ let pkg = {};
74
+ try {
75
+ pkg = JSON.parse(readFileSync("package.json", "utf8"));
76
+ } catch (err) {
77
+ fail(`package.json is not valid JSON: ${err.message}`);
78
+ }
79
+ const codeVersion = source.match(/const VERSION = "([^"]+)"/)?.[1];
80
+ if (!codeVersion) fail("could not find `const VERSION = \"…\"` in index.js");
81
+ else if (codeVersion !== pkg.version) fail(`version mismatch: package.json is ${pkg.version} but index.js declares ${codeVersion}`);
82
+ if (!/new McpServer\(\{[^}]*version:\s*VERSION/s.test(source)) fail("McpServer is not declared with version: VERSION");
83
+ const agents = readFileSync("AGENTS.md", "utf8");
84
+ if (pkg.version && !agents.includes(`**${pkg.version}**`)) fail(`AGENTS.md has no "**${pkg.version}**" release entry`);
85
+
86
+ // ── Secret masking ───────────────────────────────────────────────────────────
87
+ if (!/return sanitise\(JSON\.parse\(text\)\)/.test(source)) {
88
+ fail("api() no longer passes its parsed response through sanitise() — every tool now leaks secrets");
89
+ }
90
+ if (!/const ALLOW_SECRETS = process\.env\.PROXY_ALLOW_SECRET_VALUES === "1"/.test(source)) {
91
+ fail("the masking opt-out is not the expected PROXY_ALLOW_SECRET_VALUES === \"1\" check — masking may no longer default to on");
92
+ }
93
+
94
+ const maskingStart = source.indexOf("// --- Sensitive value masking ---");
95
+ const maskingEnd = source.indexOf("// --- HTTP helper ---");
96
+ let maskingCases = 0;
97
+ if (maskingStart === -1 || maskingEnd === -1) {
98
+ fail("could not find the masking section markers in index.js");
99
+ } else {
100
+ const sandbox = { process: { env: {} } };
101
+ vm.createContext(sandbox);
102
+ vm.runInContext(`${source.slice(maskingStart, maskingEnd)}\nthis.sanitise = sanitise;`, sandbox);
103
+ const expect = (label, got, want) => {
104
+ maskingCases++;
105
+ if (got !== want) fail(`masking: ${label} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
106
+ };
107
+ // Assembled at runtime so no literal looks like a real credential.
108
+ const fakeToken = "dns_cloudflare_api_token=" + "z".repeat(20);
109
+ const out = sandbox.sanitise({
110
+ data: [{
111
+ id: 46,
112
+ nice_name: "*.appleby.cloud",
113
+ meta: { dns_challenge: true, dns_provider: "cloudflare", dns_provider_credentials: fakeToken, certificate_key: "-----BEGIN" },
114
+ items: [{ username: "admin", password: "hunter2" }],
115
+ proxied: "[redacted]",
116
+ }],
117
+ }).data[0];
118
+ expect("DNS provider credentials", out.meta.dns_provider_credentials, "dn**********");
119
+ expect("private key", out.meta.certificate_key, "--**********");
120
+ expect("access list password", out.items[0].password, "hu**********");
121
+ expect("ordinary field stays readable", out.meta.dns_provider, "cloudflare");
122
+ expect("username stays readable", out.items[0].username, "admin");
123
+ expect("proxy-api's own [redacted] passes through", out.proxied, "[redacted]");
124
+ expect("non-object passes through", sandbox.sanitise(true), true);
125
+ }
126
+
127
+ // ── Report ───────────────────────────────────────────────────────────────────
128
+ if (failures.length > 0) {
129
+ console.error("verification failed:\n");
130
+ for (const f of failures) console.error(` ✗ ${f}`);
131
+ process.exit(1);
132
+ }
133
+
134
+ console.log("✓ index.js parses");
135
+ console.log(`✓ version ${pkg.version} consistent across package.json, index.js and AGENTS.md`);
136
+ console.log(`✓ ${toolCount} tools registered, no duplicates, all proxy_-prefixed`);
137
+ console.log(`✓ no tool targets a path proxy-api refuses (${targets.length} NPM calls checked)`);
138
+ console.log("✓ README.md and AGENTS.md agree on the tool count; every tool is in the README tables");
139
+ console.log("✓ sanitise() is wired into api() and masking defaults to on");
140
+ console.log(`✓ ${maskingCases} masking behaviour cases pass`);