@yawlabs/caddy-mcp 0.1.1 → 0.3.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.
- package/README.md +164 -58
- package/dist/index.js +230 -50
- package/dist/server.js +230 -50
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -2,115 +2,221 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@yawlabs/caddy-mcp)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/YawLabs/caddy-mcp/stargazers)
|
|
6
|
+
[](https://github.com/YawLabs/caddy-mcp/actions/workflows/ci.yml) [](https://github.com/YawLabs/caddy-mcp/actions/workflows/release.yml)
|
|
5
7
|
|
|
6
|
-
**
|
|
8
|
+
**Manage Caddy web servers from Claude Code, Cursor, and any MCP client.** 17 tools + 4 resources covering every endpoint of Caddy's admin API — config, routes, reverse proxies, TLS, PKI, metrics.
|
|
7
9
|
|
|
8
10
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
[](https://mcp.hosting/install?name=Caddy&command=npx&args=-y%2C%40yawlabs%2Fcaddy-mcp&env=CADDY_ADMIN_URL%2CCADDY_API_TOKEN&description=Manage%20Caddy%20web%20servers%20-%20config%2C%20routes%2C%20TLS%2C%20PKI&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fcaddy-mcp)
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
npx @yawlabs/caddy-mcp
|
|
14
|
-
```
|
|
14
|
+
One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## Why this one?
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
npm install -g @yawlabs/caddy-mcp
|
|
20
|
-
caddy-mcp
|
|
21
|
-
```
|
|
18
|
+
Other Caddy MCP servers wrap half the admin API and silently swallow errors. This one doesn't.
|
|
22
19
|
|
|
23
|
-
|
|
20
|
+
- **Complete admin API coverage** — every documented endpoint: `/load`, `/config/*`, `/id/*`, `/stop`, `/adapt`, `/pki/ca/*`, `/reverse_proxy/upstreams`, `/metrics`. No placeholder tools that 404.
|
|
21
|
+
- **Safe concurrent writes** — uses ETags (`If-Match`) so your changes never silently overwrite someone else's. Surfaces `HTTP 412 Precondition Failed` as a clear message, not a cryptic error.
|
|
22
|
+
- **Safe-by-default mutations** — `caddy_config_set` defaults to idempotent `overwrite` (PATCH), not `append` (POST). Calling twice doesn't duplicate your route.
|
|
23
|
+
- **Defensive parsing** — `caddy_list_routes` never crashes on malformed config, even if routes are null, handlers are strings, or matchers are non-arrays. Regression-tested.
|
|
24
|
+
- **No leaked credentials in errors** — if `CADDY_ADMIN_URL` contains a token in the path/query, the connect-failed message shows only the origin.
|
|
25
|
+
- **Fallback error surfacing** — when a TLS write PATCH fails and the POST fallback also fails, both error bodies are returned so you know what actually went wrong.
|
|
26
|
+
- **Tool annotations** — every tool declares `readOnlyHint`, `destructiveHint`, and `idempotentHint`, so MCP clients can skip confirmations for safe ops.
|
|
27
|
+
- **Instant startup** — ships as a single bundle with two runtime deps (the MCP SDK + Zod). No 5-minute `node_modules` install.
|
|
28
|
+
- **Input hardening** — adapter names, `@id` values, server names, and CA ids are all regex-validated with length caps. Blocks CRLF header injection and ReDoS.
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
## Quick start
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
**1. Enable the Caddy admin API**
|
|
33
|
+
|
|
34
|
+
Caddy ships with the admin API enabled on `localhost:2019` by default. If you're running Caddy in Docker or on a remote host, expose it via `CADDY_ADMIN_URL`.
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
**2. Create `.mcp.json` in your project root**
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
macOS / Linux / WSL:
|
|
34
39
|
|
|
35
40
|
```json
|
|
36
41
|
{
|
|
37
42
|
"mcpServers": {
|
|
38
|
-
"caddy
|
|
43
|
+
"caddy": {
|
|
39
44
|
"command": "npx",
|
|
40
|
-
"args": ["@yawlabs/caddy-mcp"]
|
|
41
|
-
"env": {
|
|
42
|
-
"CADDY_ADMIN_URL": "http://localhost:2019"
|
|
43
|
-
}
|
|
45
|
+
"args": ["-y", "@yawlabs/caddy-mcp"]
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
```
|
|
48
50
|
|
|
51
|
+
Windows:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"caddy": {
|
|
57
|
+
"command": "cmd",
|
|
58
|
+
"args": ["/c", "npx", "-y", "@yawlabs/caddy-mcp"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> **Why the extra step on Windows?** Since Node 20, `child_process.spawn` cannot directly execute `.cmd` files (that's what `npx` is on Windows). Wrapping with `cmd /c` is the standard workaround. This file is safe to commit — it contains no secrets.
|
|
65
|
+
|
|
66
|
+
**3. Restart and approve**
|
|
67
|
+
|
|
68
|
+
Restart Claude Code (or your MCP client) and approve the Caddy MCP server when prompted.
|
|
69
|
+
|
|
70
|
+
That's it. Now ask your AI assistant:
|
|
71
|
+
|
|
72
|
+
> "Proxy api.local to localhost:3000"
|
|
73
|
+
>
|
|
74
|
+
> "What routes are configured on srv0?"
|
|
75
|
+
>
|
|
76
|
+
> "Show me the Prometheus metrics"
|
|
77
|
+
|
|
49
78
|
## Configuration
|
|
50
79
|
|
|
51
|
-
| Environment
|
|
80
|
+
| Environment variable | Default | Description |
|
|
52
81
|
|---|---|---|
|
|
53
|
-
| `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL |
|
|
54
|
-
| `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints |
|
|
82
|
+
| `CADDY_ADMIN_URL` | `http://localhost:2019` | Caddy admin API URL. Set to `http://caddy:2019` inside Docker, or an https URL for remote admin. |
|
|
83
|
+
| `CADDY_API_TOKEN` | (none) | Optional Bearer token for authenticated admin endpoints. Only needed if you've configured Caddy with auth. |
|
|
84
|
+
|
|
85
|
+
**Alternate MCP clients:**
|
|
86
|
+
|
|
87
|
+
| Client | Config file |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Claude Code | `.mcp.json` (project root) or `~/.claude.json` (global) |
|
|
90
|
+
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) |
|
|
91
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
92
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
|
|
93
|
+
| VS Code | `.vscode/mcp.json` |
|
|
94
|
+
|
|
95
|
+
Use the same JSON block shown above in any of these.
|
|
55
96
|
|
|
56
97
|
## Tools
|
|
57
98
|
|
|
58
|
-
### Config management
|
|
99
|
+
### Config management (5)
|
|
59
100
|
|
|
60
|
-
- **caddy_config_get** — Read config at any JSON path (or full config)
|
|
61
|
-
- **caddy_config_set** —
|
|
62
|
-
- **caddy_config_delete** — Delete config at a path
|
|
63
|
-
- **caddy_config_by_id** —
|
|
64
|
-
- **caddy_load** — Replace entire config atomically
|
|
101
|
+
- **caddy_config_get** — Read config at any JSON path (or the full config).
|
|
102
|
+
- **caddy_config_set** — Write config at a path. Modes: `overwrite` (PATCH, default, idempotent), `append` (POST), `insert` (PUT, for array positions).
|
|
103
|
+
- **caddy_config_delete** — Delete config at a path.
|
|
104
|
+
- **caddy_config_by_id** — Get/set/delete config by `@id` tag — much easier than navigating deep paths.
|
|
105
|
+
- **caddy_load** — Replace the entire config atomically. 60-second timeout for cert provisioning.
|
|
65
106
|
|
|
66
|
-
### Route operations
|
|
107
|
+
### Route operations (4)
|
|
67
108
|
|
|
68
|
-
- **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']
|
|
69
|
-
- **caddy_add_route** — Add a route with full match/handle control (any Caddy handler)
|
|
70
|
-
- **
|
|
109
|
+
- **caddy_reverse_proxy** — Add a reverse proxy in one call: `from='api.local' to=['localhost:3000']`.
|
|
110
|
+
- **caddy_add_route** — Add a route with full match/handle control (any Caddy handler).
|
|
111
|
+
- **caddy_remove_route** — Remove a route by `@id` (preferred) or by index. Requires `confirm=true`.
|
|
112
|
+
- **caddy_list_routes** — Human-readable route summary. Defensive: never crashes on weird config.
|
|
71
113
|
|
|
72
|
-
### TLS & config conversion
|
|
114
|
+
### TLS & config conversion (2)
|
|
73
115
|
|
|
74
|
-
- **caddy_tls** — Check
|
|
75
|
-
- **caddy_adapt** — Convert Caddyfile to JSON
|
|
116
|
+
- **caddy_tls** — Check or set TLS settings: ACME email, ACME CA URL. Falls back gracefully when paths don't yet exist.
|
|
117
|
+
- **caddy_adapt** — Convert a Caddyfile (or nginx config) to Caddy JSON without applying it. Great for previewing.
|
|
76
118
|
|
|
77
|
-
### Server operations
|
|
119
|
+
### Server operations (6)
|
|
78
120
|
|
|
79
|
-
- **caddy_status** — Connectivity check + config summary
|
|
80
|
-
- **caddy_list_servers** — List all HTTP servers with names, addresses, and TLS status
|
|
81
|
-
- **caddy_upstreams** — Reverse proxy backend health
|
|
82
|
-
- **caddy_metrics** — Prometheus metrics (request counts, durations, connections)
|
|
83
|
-
- **caddy_pki** — CA info and certificate chains
|
|
84
|
-
- **caddy_stop** — Graceful shutdown
|
|
121
|
+
- **caddy_status** — Connectivity check + config summary (server count, routes, TLS mode).
|
|
122
|
+
- **caddy_list_servers** — List all HTTP servers with names, addresses, route counts, and TLS status.
|
|
123
|
+
- **caddy_upstreams** — Reverse proxy backend health.
|
|
124
|
+
- **caddy_metrics** — Prometheus metrics (request counts, durations, connections, TLS handshakes).
|
|
125
|
+
- **caddy_pki** — CA info and certificate chains (default CA: `local`).
|
|
126
|
+
- **caddy_stop** — Graceful shutdown. Requires `confirm=true` to prevent accidents.
|
|
85
127
|
|
|
86
128
|
## Resources
|
|
87
129
|
|
|
88
|
-
-
|
|
89
|
-
|
|
130
|
+
Browsable read-only data — MCP clients can fetch these directly without a tool call:
|
|
131
|
+
|
|
132
|
+
- `caddy://config` — Current full Caddy JSON configuration.
|
|
133
|
+
- `caddy://servers` — Summary of all configured HTTP servers.
|
|
134
|
+
- `caddy://upstreams` — Reverse proxy upstream health status.
|
|
135
|
+
- `caddy://metrics` — Prometheus metrics (text exposition format).
|
|
90
136
|
|
|
91
137
|
## Examples
|
|
92
138
|
|
|
139
|
+
### Add a reverse proxy
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
> "Proxy api.example.com to my app on port 3000"
|
|
143
|
+
→ caddy_reverse_proxy({ from: "api.example.com", to: ["localhost:3000"] })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Preview a Caddyfile before applying it
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
> "Convert this Caddyfile to JSON so I can review it:
|
|
150
|
+
example.com {
|
|
151
|
+
reverse_proxy localhost:8080
|
|
152
|
+
}"
|
|
153
|
+
→ caddy_adapt({ config: "..." })
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Diagnose slow routes
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
> "Fetch Prometheus metrics and tell me which route is slowest"
|
|
160
|
+
→ caddy_metrics()
|
|
93
161
|
```
|
|
94
|
-
> "Proxy api.local to my dev server on port 3000"
|
|
95
|
-
→ caddy_reverse_proxy(from: "api.local", to: ["localhost:3000"])
|
|
96
162
|
|
|
97
|
-
|
|
98
|
-
→ caddy_list_routes()
|
|
163
|
+
### Safely update a route by @id
|
|
99
164
|
|
|
100
|
-
|
|
101
|
-
|
|
165
|
+
```
|
|
166
|
+
> "Update the route with @id 'api-v2' to point to the new backend"
|
|
167
|
+
→ caddy_config_by_id({ id: "api-v2", action: "set", value: {...} })
|
|
168
|
+
# Uses ETags — you'll get HTTP 412 if someone else changed it first
|
|
169
|
+
```
|
|
102
170
|
|
|
103
|
-
|
|
104
|
-
→ caddy_adapt(config: "example.com {\n reverse_proxy localhost:8080\n}")
|
|
171
|
+
### Atomic deploy
|
|
105
172
|
|
|
106
|
-
> "Is Caddy running?"
|
|
107
|
-
→ caddy_status()
|
|
108
173
|
```
|
|
174
|
+
> "Replace the whole config with this Caddyfile"
|
|
175
|
+
→ caddy_adapt({ config: "..." }) # validate first
|
|
176
|
+
→ caddy_load({ config: adaptedJson }) # apply atomically
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Troubleshooting
|
|
180
|
+
|
|
181
|
+
**"Cannot connect to Caddy admin API"**
|
|
182
|
+
|
|
183
|
+
- Make sure Caddy is running. `caddy run` or `systemctl status caddy`.
|
|
184
|
+
- Check the admin endpoint. Default is `http://localhost:2019`. If Caddy is in Docker, use the container hostname.
|
|
185
|
+
- Set `CADDY_ADMIN_URL` in your MCP config `env` to match.
|
|
186
|
+
|
|
187
|
+
**"HTTP 412 Precondition Failed"**
|
|
188
|
+
|
|
189
|
+
- Someone (or something) changed the config between your read and your write.
|
|
190
|
+
- The cached ETag has been invalidated. Re-read the config and retry.
|
|
191
|
+
|
|
192
|
+
**"HTTP 403" on /load or /config writes**
|
|
193
|
+
|
|
194
|
+
- You have `admin.listen` or `admin.origins` restrictions set in your Caddy config, or you're missing an `Authorization` header.
|
|
195
|
+
- Set `CADDY_API_TOKEN` in your MCP config env if Caddy expects a Bearer token.
|
|
196
|
+
|
|
197
|
+
**Windows: MCP server doesn't start**
|
|
198
|
+
|
|
199
|
+
- Use the `cmd /c npx ...` pattern from the Quick start section. Node 20+ can't spawn `.cmd` files directly.
|
|
109
200
|
|
|
110
201
|
## Requirements
|
|
111
202
|
|
|
112
203
|
- Node.js 18+
|
|
113
|
-
- Caddy
|
|
204
|
+
- Caddy 2.x with admin API enabled (default: `localhost:2019`)
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git clone https://github.com/YawLabs/caddy-mcp.git
|
|
210
|
+
cd caddy-mcp
|
|
211
|
+
npm install
|
|
212
|
+
npm run lint # Biome check
|
|
213
|
+
npm run lint:fix # Auto-fix
|
|
214
|
+
npm run build # tsup bundle
|
|
215
|
+
npm test # Vitest (83 tests)
|
|
216
|
+
npm run typecheck # tsc --noEmit
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including release process.
|
|
114
220
|
|
|
115
221
|
## License
|
|
116
222
|
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
9
9
|
var DEFAULT_URL = "http://localhost:2019";
|
|
10
10
|
var TIMEOUT = 1e4;
|
|
11
11
|
var etagCache = /* @__PURE__ */ new Map();
|
|
12
|
+
var MAX_ETAG_CACHE = 256;
|
|
13
|
+
function setEtag(path, etag) {
|
|
14
|
+
if (etagCache.size >= MAX_ETAG_CACHE && !etagCache.has(path)) {
|
|
15
|
+
const oldest = etagCache.keys().next().value;
|
|
16
|
+
if (oldest !== void 0) etagCache.delete(oldest);
|
|
17
|
+
}
|
|
18
|
+
etagCache.set(path, etag);
|
|
19
|
+
}
|
|
12
20
|
function getBaseUrl() {
|
|
13
21
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
14
22
|
}
|
|
@@ -43,7 +51,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
|
|
|
43
51
|
const text = await res.text();
|
|
44
52
|
const etag = res.headers.get("ETag") || void 0;
|
|
45
53
|
if (method === "GET" && etag && isConfigPath) {
|
|
46
|
-
|
|
54
|
+
setEtag(path, etag);
|
|
47
55
|
}
|
|
48
56
|
if (isWrite && res.ok && isConfigPath) {
|
|
49
57
|
etagCache.delete(path);
|
|
@@ -68,10 +76,16 @@ async function caddyRequest(method, path, body, contentType, timeout) {
|
|
|
68
76
|
} catch (err) {
|
|
69
77
|
const msg = err instanceof Error ? err.message : String(err);
|
|
70
78
|
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
79
|
+
const baseUrl = getBaseUrl();
|
|
80
|
+
let origin = baseUrl;
|
|
81
|
+
try {
|
|
82
|
+
origin = new URL(baseUrl).origin;
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
71
85
|
return {
|
|
72
86
|
ok: false,
|
|
73
87
|
status: 0,
|
|
74
|
-
error: `Cannot connect to Caddy admin API at ${
|
|
88
|
+
error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
|
|
75
89
|
};
|
|
76
90
|
}
|
|
77
91
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
@@ -168,6 +182,40 @@ function registerResources(server) {
|
|
|
168
182
|
};
|
|
169
183
|
}
|
|
170
184
|
);
|
|
185
|
+
server.resource(
|
|
186
|
+
"caddy-metrics",
|
|
187
|
+
"caddy://metrics",
|
|
188
|
+
{ description: "Prometheus metrics (text exposition format)" },
|
|
189
|
+
async () => {
|
|
190
|
+
const res = await getMetrics();
|
|
191
|
+
return {
|
|
192
|
+
contents: [
|
|
193
|
+
{
|
|
194
|
+
uri: "caddy://metrics",
|
|
195
|
+
mimeType: "text/plain",
|
|
196
|
+
text: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
server.resource(
|
|
203
|
+
"caddy-servers",
|
|
204
|
+
"caddy://servers",
|
|
205
|
+
{ description: "Summary of all configured HTTP servers" },
|
|
206
|
+
async () => {
|
|
207
|
+
const res = await configGet("apps/http/servers");
|
|
208
|
+
return {
|
|
209
|
+
contents: [
|
|
210
|
+
{
|
|
211
|
+
uri: "caddy://servers",
|
|
212
|
+
mimeType: "application/json",
|
|
213
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
);
|
|
171
219
|
}
|
|
172
220
|
|
|
173
221
|
// src/tools/adapt.ts
|
|
@@ -193,7 +241,7 @@ function registerAdaptTools(server) {
|
|
|
193
241
|
"Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
|
|
194
242
|
{
|
|
195
243
|
config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
|
|
196
|
-
adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
244
|
+
adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
197
245
|
},
|
|
198
246
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
199
247
|
async ({ config, adapter }) => {
|
|
@@ -230,12 +278,12 @@ function registerConfigTools(server) {
|
|
|
230
278
|
);
|
|
231
279
|
server.tool(
|
|
232
280
|
"caddy_config_set",
|
|
233
|
-
"Write config at a JSON path. Mode '
|
|
281
|
+
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
|
|
234
282
|
{
|
|
235
283
|
path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
236
284
|
value: z2.any().describe("The JSON value to set at the path"),
|
|
237
|
-
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("
|
|
238
|
-
"'
|
|
285
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
286
|
+
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
|
|
239
287
|
)
|
|
240
288
|
},
|
|
241
289
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -268,13 +316,16 @@ function registerConfigTools(server) {
|
|
|
268
316
|
"caddy_config_by_id",
|
|
269
317
|
"Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
|
|
270
318
|
{
|
|
271
|
-
id: z2.string().regex(/^[\w-]
|
|
319
|
+
id: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
272
320
|
action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
273
321
|
value: z2.any().optional().describe("New value (required for 'set' action)"),
|
|
274
|
-
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object")
|
|
322
|
+
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
323
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
324
|
+
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
325
|
+
)
|
|
275
326
|
},
|
|
276
327
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
277
|
-
async ({ id, action, value, subpath }) => {
|
|
328
|
+
async ({ id, action, value, subpath, mode }) => {
|
|
278
329
|
if (action === "get") {
|
|
279
330
|
return formatResult(await configByIdGet(id, subpath));
|
|
280
331
|
}
|
|
@@ -285,7 +336,8 @@ function registerConfigTools(server) {
|
|
|
285
336
|
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
286
337
|
};
|
|
287
338
|
}
|
|
288
|
-
|
|
339
|
+
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
340
|
+
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
289
341
|
}
|
|
290
342
|
if (action === "delete") {
|
|
291
343
|
return formatResult(await configByIdDelete(id, subpath));
|
|
@@ -375,7 +427,7 @@ ${lines.join("\n")}` }]
|
|
|
375
427
|
"caddy_pki",
|
|
376
428
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
377
429
|
{
|
|
378
|
-
ca: z3.string().regex(/^[\w-]
|
|
430
|
+
ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
379
431
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
380
432
|
},
|
|
381
433
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -410,6 +462,10 @@ ${lines.join("\n")}` }]
|
|
|
410
462
|
|
|
411
463
|
// src/tools/routes.ts
|
|
412
464
|
import { z as z4 } from "zod";
|
|
465
|
+
function safeJoin(value) {
|
|
466
|
+
if (!Array.isArray(value)) return "";
|
|
467
|
+
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
468
|
+
}
|
|
413
469
|
function parseFrom(from) {
|
|
414
470
|
const cleaned = from.replace(/^https?:\/\//, "");
|
|
415
471
|
const match = {};
|
|
@@ -445,7 +501,7 @@ function registerRouteTools(server) {
|
|
|
445
501
|
{
|
|
446
502
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
447
503
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
448
|
-
server: z4.string().regex(/^[\w-]
|
|
504
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
449
505
|
},
|
|
450
506
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
451
507
|
async ({ from, to, server: srv }) => {
|
|
@@ -477,7 +533,7 @@ function registerRouteTools(server) {
|
|
|
477
533
|
{
|
|
478
534
|
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
479
535
|
handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
480
|
-
server: z4.string().regex(/^[\w-]
|
|
536
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
481
537
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
482
538
|
},
|
|
483
539
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -494,38 +550,58 @@ function registerRouteTools(server) {
|
|
|
494
550
|
"caddy_list_routes",
|
|
495
551
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
496
552
|
{
|
|
497
|
-
server: z4.string().regex(/^[\w-]
|
|
553
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
498
554
|
},
|
|
499
555
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
500
556
|
async ({ server: srv }) => {
|
|
501
557
|
const serverRes = await configGet(`apps/http/servers/${srv}`);
|
|
502
558
|
if (!serverRes.ok) return formatResult(serverRes);
|
|
503
|
-
const serverConfig = serverRes.data;
|
|
504
|
-
const routes = serverConfig
|
|
505
|
-
const listen = serverConfig
|
|
559
|
+
const serverConfig = serverRes.data || {};
|
|
560
|
+
const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
|
|
561
|
+
const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
|
|
562
|
+
const listenStr = listen.map(String).join(", ") || "default";
|
|
506
563
|
if (routes.length === 0) {
|
|
507
564
|
return {
|
|
508
565
|
content: [
|
|
509
566
|
{
|
|
510
567
|
type: "text",
|
|
511
|
-
text: `Server ${srv} (listen: ${
|
|
568
|
+
text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
|
|
512
569
|
}
|
|
513
570
|
]
|
|
514
571
|
};
|
|
515
572
|
}
|
|
516
|
-
const lines = [`Server: ${srv} (listen: ${
|
|
573
|
+
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
517
574
|
for (let i = 0; i < routes.length; i++) {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
575
|
+
const rawRoute = routes[i];
|
|
576
|
+
if (!rawRoute || typeof rawRoute !== "object") {
|
|
577
|
+
lines.push(` Route ${i}: <invalid>`);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const route = rawRoute;
|
|
581
|
+
const idVal = typeof route["@id"] === "string" ? route["@id"] : void 0;
|
|
582
|
+
const groupVal = typeof route.group === "string" ? route.group : void 0;
|
|
583
|
+
const id = idVal ? ` @id="${idVal}"` : "";
|
|
584
|
+
const group = groupVal ? ` group="${groupVal}"` : "";
|
|
585
|
+
const matchList = Array.isArray(route.match) ? route.match : [];
|
|
586
|
+
const matchers = matchList.map((rawMatcher) => {
|
|
587
|
+
if (!rawMatcher || typeof rawMatcher !== "object") return "catch-all";
|
|
588
|
+
const m = rawMatcher;
|
|
522
589
|
const parts = [];
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
if (
|
|
590
|
+
const host = safeJoin(m.host);
|
|
591
|
+
if (host) parts.push(`host=[${host}]`);
|
|
592
|
+
const path = safeJoin(m.path);
|
|
593
|
+
if (path) parts.push(`path=[${path}]`);
|
|
594
|
+
const method = safeJoin(m.method);
|
|
595
|
+
if (method) parts.push(`method=[${method}]`);
|
|
596
|
+
if (typeof m.protocol === "string") parts.push(`protocol=${m.protocol}`);
|
|
597
|
+
if (m.remote_ip && typeof m.remote_ip === "object") {
|
|
598
|
+
const ranges = safeJoin(m.remote_ip.ranges);
|
|
599
|
+
parts.push(`remote_ip=[${ranges || "..."}]`);
|
|
600
|
+
}
|
|
601
|
+
if (m.client_ip && typeof m.client_ip === "object") {
|
|
602
|
+
const ranges = safeJoin(m.client_ip.ranges);
|
|
603
|
+
parts.push(`client_ip=[${ranges || "..."}]`);
|
|
604
|
+
}
|
|
529
605
|
if (m.query) parts.push("query=...");
|
|
530
606
|
if (m.header) parts.push("header=...");
|
|
531
607
|
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
@@ -548,23 +624,51 @@ function registerRouteTools(server) {
|
|
|
548
624
|
if (parts.length === 0) return "catch-all";
|
|
549
625
|
return parts.join(" ");
|
|
550
626
|
}).join(" | ");
|
|
551
|
-
const
|
|
627
|
+
const handleList = Array.isArray(route.handle) ? route.handle : [];
|
|
628
|
+
const handlers = handleList.map((rawHandler) => {
|
|
629
|
+
if (!rawHandler || typeof rawHandler !== "object") return "unknown";
|
|
630
|
+
const h = rawHandler;
|
|
552
631
|
if (h.handler === "reverse_proxy") {
|
|
553
|
-
const
|
|
632
|
+
const upstreamsArr = Array.isArray(h.upstreams) ? h.upstreams : [];
|
|
633
|
+
const upstreams = upstreamsArr.map((u) => {
|
|
634
|
+
if (u && typeof u === "object") {
|
|
635
|
+
const dial = u.dial;
|
|
636
|
+
if (typeof dial === "string") return dial;
|
|
637
|
+
}
|
|
638
|
+
return "?";
|
|
639
|
+
}).join(",");
|
|
554
640
|
return `reverse_proxy(${upstreams})`;
|
|
555
641
|
}
|
|
556
|
-
if (h.handler === "file_server")
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
642
|
+
if (h.handler === "file_server") {
|
|
643
|
+
const root = typeof h.root === "string" ? h.root : ".";
|
|
644
|
+
return `file_server(${root})`;
|
|
645
|
+
}
|
|
646
|
+
if (h.handler === "static_response") {
|
|
647
|
+
const status = typeof h.status_code === "number" ? h.status_code : 200;
|
|
648
|
+
return `static_response(${status})`;
|
|
649
|
+
}
|
|
650
|
+
if (h.handler === "rewrite") {
|
|
651
|
+
const uri = typeof h.uri === "string" ? h.uri : "...";
|
|
652
|
+
return `rewrite(${uri})`;
|
|
653
|
+
}
|
|
654
|
+
if (h.handler === "subroute") {
|
|
655
|
+
const count = Array.isArray(h.routes) ? h.routes.length : 0;
|
|
656
|
+
return `subroute(${count} routes)`;
|
|
657
|
+
}
|
|
560
658
|
if (h.handler === "encode") return "encode";
|
|
561
659
|
if (h.handler === "headers") return "headers";
|
|
562
|
-
if (h.handler === "authentication")
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
660
|
+
if (h.handler === "authentication") {
|
|
661
|
+
const providers = h.providers && typeof h.providers === "object" ? Object.keys(h.providers).join(",") : "...";
|
|
662
|
+
return `auth(${providers})`;
|
|
663
|
+
}
|
|
664
|
+
if (h.handler === "error") {
|
|
665
|
+
const status = typeof h.status_code === "number" ? h.status_code : "...";
|
|
666
|
+
return `error(${status})`;
|
|
667
|
+
}
|
|
668
|
+
return typeof h.handler === "string" ? h.handler : "unknown";
|
|
566
669
|
}).join(" \u2192 ");
|
|
567
|
-
|
|
670
|
+
const terminal = route.terminal === true ? " [terminal]" : "";
|
|
671
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
|
|
568
672
|
}
|
|
569
673
|
return {
|
|
570
674
|
content: [
|
|
@@ -574,6 +678,67 @@ function registerRouteTools(server) {
|
|
|
574
678
|
};
|
|
575
679
|
}
|
|
576
680
|
);
|
|
681
|
+
server.tool(
|
|
682
|
+
"caddy_remove_route",
|
|
683
|
+
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
|
|
684
|
+
{
|
|
685
|
+
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
|
|
686
|
+
index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
|
|
687
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
|
|
688
|
+
confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
|
|
689
|
+
},
|
|
690
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
691
|
+
async ({ id, index, server: srv, confirm }) => {
|
|
692
|
+
if (!id && index === void 0) {
|
|
693
|
+
return {
|
|
694
|
+
isError: true,
|
|
695
|
+
content: [{ type: "text", text: "Error: must provide either id or index" }]
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (!confirm) {
|
|
699
|
+
const target = id ? `@id="${id}"` : `route ${index} on server "${srv}"`;
|
|
700
|
+
return {
|
|
701
|
+
isError: true,
|
|
702
|
+
content: [
|
|
703
|
+
{
|
|
704
|
+
type: "text",
|
|
705
|
+
text: `Refusing to remove ${target} without confirm=true. Re-run with confirm:true to proceed.`
|
|
706
|
+
}
|
|
707
|
+
]
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (id) {
|
|
711
|
+
const res2 = await configByIdDelete(id);
|
|
712
|
+
if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
|
|
713
|
+
return formatResult(res2);
|
|
714
|
+
}
|
|
715
|
+
const readRes = await configGet(`apps/http/servers/${srv}/routes`);
|
|
716
|
+
if (!readRes.ok) return formatResult(readRes);
|
|
717
|
+
const routes = readRes.data;
|
|
718
|
+
if (!Array.isArray(routes)) {
|
|
719
|
+
return {
|
|
720
|
+
isError: true,
|
|
721
|
+
content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (index >= routes.length) {
|
|
725
|
+
return {
|
|
726
|
+
isError: true,
|
|
727
|
+
content: [
|
|
728
|
+
{
|
|
729
|
+
type: "text",
|
|
730
|
+
text: `Error: index ${index} out of range \u2014 server "${srv}" has ${routes.length} route(s)`
|
|
731
|
+
}
|
|
732
|
+
]
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
const res = await configDelete(`apps/http/servers/${srv}/routes/${index}`);
|
|
736
|
+
if (res.ok) {
|
|
737
|
+
return { content: [{ type: "text", text: `Route ${index} removed from server "${srv}".` }] };
|
|
738
|
+
}
|
|
739
|
+
return formatResult(res);
|
|
740
|
+
}
|
|
741
|
+
);
|
|
577
742
|
}
|
|
578
743
|
|
|
579
744
|
// src/tools/tls.ts
|
|
@@ -588,6 +753,21 @@ function buildTlsConfig(fields) {
|
|
|
588
753
|
}
|
|
589
754
|
};
|
|
590
755
|
}
|
|
756
|
+
function bothErrors(label, patchRes, postRes) {
|
|
757
|
+
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
758
|
+
const postErr = postRes.error || `HTTP ${postRes.status}`;
|
|
759
|
+
return {
|
|
760
|
+
isError: true,
|
|
761
|
+
content: [
|
|
762
|
+
{
|
|
763
|
+
type: "text",
|
|
764
|
+
text: `Error: Failed to set ${label}.
|
|
765
|
+
PATCH attempt: ${patchErr}
|
|
766
|
+
POST fallback: ${postErr}`
|
|
767
|
+
}
|
|
768
|
+
]
|
|
769
|
+
};
|
|
770
|
+
}
|
|
591
771
|
function registerTlsTools(server) {
|
|
592
772
|
server.tool(
|
|
593
773
|
"caddy_tls",
|
|
@@ -608,11 +788,11 @@ function registerTlsTools(server) {
|
|
|
608
788
|
isError: true,
|
|
609
789
|
content: [{ type: "text", text: "Error: email is required for set_email action" }]
|
|
610
790
|
};
|
|
611
|
-
const
|
|
612
|
-
if (
|
|
613
|
-
const
|
|
614
|
-
if (
|
|
615
|
-
return
|
|
791
|
+
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
792
|
+
if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
793
|
+
const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
|
|
794
|
+
if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
795
|
+
return bothErrors("ACME email", patchRes, postRes);
|
|
616
796
|
}
|
|
617
797
|
if (action === "set_acme_ca") {
|
|
618
798
|
if (!ca)
|
|
@@ -620,11 +800,11 @@ function registerTlsTools(server) {
|
|
|
620
800
|
isError: true,
|
|
621
801
|
content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
|
|
622
802
|
};
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
const
|
|
626
|
-
if (
|
|
627
|
-
return
|
|
803
|
+
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
804
|
+
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
805
|
+
const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
|
|
806
|
+
if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
807
|
+
return bothErrors("ACME CA", patchRes, postRes);
|
|
628
808
|
}
|
|
629
809
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
630
810
|
}
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,14 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
var DEFAULT_URL = "http://localhost:2019";
|
|
8
8
|
var TIMEOUT = 1e4;
|
|
9
9
|
var etagCache = /* @__PURE__ */ new Map();
|
|
10
|
+
var MAX_ETAG_CACHE = 256;
|
|
11
|
+
function setEtag(path, etag) {
|
|
12
|
+
if (etagCache.size >= MAX_ETAG_CACHE && !etagCache.has(path)) {
|
|
13
|
+
const oldest = etagCache.keys().next().value;
|
|
14
|
+
if (oldest !== void 0) etagCache.delete(oldest);
|
|
15
|
+
}
|
|
16
|
+
etagCache.set(path, etag);
|
|
17
|
+
}
|
|
10
18
|
function getBaseUrl() {
|
|
11
19
|
return (process.env.CADDY_ADMIN_URL || DEFAULT_URL).replace(/\/+$/, "");
|
|
12
20
|
}
|
|
@@ -41,7 +49,7 @@ async function caddyRequest(method, path, body, contentType, timeout) {
|
|
|
41
49
|
const text = await res.text();
|
|
42
50
|
const etag = res.headers.get("ETag") || void 0;
|
|
43
51
|
if (method === "GET" && etag && isConfigPath) {
|
|
44
|
-
|
|
52
|
+
setEtag(path, etag);
|
|
45
53
|
}
|
|
46
54
|
if (isWrite && res.ok && isConfigPath) {
|
|
47
55
|
etagCache.delete(path);
|
|
@@ -66,10 +74,16 @@ async function caddyRequest(method, path, body, contentType, timeout) {
|
|
|
66
74
|
} catch (err) {
|
|
67
75
|
const msg = err instanceof Error ? err.message : String(err);
|
|
68
76
|
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
77
|
+
const baseUrl = getBaseUrl();
|
|
78
|
+
let origin = baseUrl;
|
|
79
|
+
try {
|
|
80
|
+
origin = new URL(baseUrl).origin;
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
69
83
|
return {
|
|
70
84
|
ok: false,
|
|
71
85
|
status: 0,
|
|
72
|
-
error: `Cannot connect to Caddy admin API at ${
|
|
86
|
+
error: `Cannot connect to Caddy admin API at ${origin} \u2014 is Caddy running?`
|
|
73
87
|
};
|
|
74
88
|
}
|
|
75
89
|
if (msg.includes("abort") || msg.includes("timeout")) {
|
|
@@ -166,6 +180,40 @@ function registerResources(server) {
|
|
|
166
180
|
};
|
|
167
181
|
}
|
|
168
182
|
);
|
|
183
|
+
server.resource(
|
|
184
|
+
"caddy-metrics",
|
|
185
|
+
"caddy://metrics",
|
|
186
|
+
{ description: "Prometheus metrics (text exposition format)" },
|
|
187
|
+
async () => {
|
|
188
|
+
const res = await getMetrics();
|
|
189
|
+
return {
|
|
190
|
+
contents: [
|
|
191
|
+
{
|
|
192
|
+
uri: "caddy://metrics",
|
|
193
|
+
mimeType: "text/plain",
|
|
194
|
+
text: res.ok ? String(res.data ?? "") : `Error: ${res.error}`
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
server.resource(
|
|
201
|
+
"caddy-servers",
|
|
202
|
+
"caddy://servers",
|
|
203
|
+
{ description: "Summary of all configured HTTP servers" },
|
|
204
|
+
async () => {
|
|
205
|
+
const res = await configGet("apps/http/servers");
|
|
206
|
+
return {
|
|
207
|
+
contents: [
|
|
208
|
+
{
|
|
209
|
+
uri: "caddy://servers",
|
|
210
|
+
mimeType: "application/json",
|
|
211
|
+
text: res.ok ? JSON.stringify(res.data ?? {}, null, 2) : `Error: ${res.error}`
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
);
|
|
169
217
|
}
|
|
170
218
|
|
|
171
219
|
// src/tools/adapt.ts
|
|
@@ -191,7 +239,7 @@ function registerAdaptTools(server) {
|
|
|
191
239
|
"Convert a Caddyfile or other config format to Caddy JSON without loading it. Useful for previewing what a Caddyfile produces. Returns the adapted JSON and any warnings separately.",
|
|
192
240
|
{
|
|
193
241
|
config: z.string().describe("The raw config text (e.g., Caddyfile contents)"),
|
|
194
|
-
adapter: z.string().optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
242
|
+
adapter: z.string().regex(/^[a-z0-9_-]+$/i, "Adapter must be alphanumeric, hyphens, or underscores").max(64).optional().default("caddyfile").describe("Config format adapter (default: 'caddyfile')")
|
|
195
243
|
},
|
|
196
244
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
197
245
|
async ({ config, adapter }) => {
|
|
@@ -228,12 +276,12 @@ function registerConfigTools(server) {
|
|
|
228
276
|
);
|
|
229
277
|
server.tool(
|
|
230
278
|
"caddy_config_set",
|
|
231
|
-
"Write config at a JSON path. Mode '
|
|
279
|
+
"Write config at a JSON path. Mode 'overwrite' (default) replaces existing values (PATCH) \u2014 safe and idempotent. Mode 'append' adds to arrays or creates keys (POST) \u2014 NOT idempotent: calling twice with the same route duplicates it. Mode 'insert' places at a specific array index (PUT) \u2014 useful for route ordering.",
|
|
232
280
|
{
|
|
233
281
|
path: z2.string().describe("Config path to write to (e.g., 'apps/http/servers/srv0/routes')"),
|
|
234
282
|
value: z2.any().describe("The JSON value to set at the path"),
|
|
235
|
-
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("
|
|
236
|
-
"'
|
|
283
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
284
|
+
"'overwrite' = PATCH (replace existing, default, idempotent), 'append' = POST (add to arrays / create keys, NOT idempotent), 'insert' = PUT (insert at array index)"
|
|
237
285
|
)
|
|
238
286
|
},
|
|
239
287
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -266,13 +314,16 @@ function registerConfigTools(server) {
|
|
|
266
314
|
"caddy_config_by_id",
|
|
267
315
|
"Access config by @id tag. Any config object with an '@id' field can be read, updated, or deleted by its ID instead of needing its full path. This is the recommended way to manage individual routes and config objects.",
|
|
268
316
|
{
|
|
269
|
-
id: z2.string().regex(/^[\w-]
|
|
317
|
+
id: z2.string().regex(/^[\w-]{1,128}$/).describe("The @id value of the config object"),
|
|
270
318
|
action: z2.enum(["get", "set", "delete"]).optional().default("get").describe("Action to perform"),
|
|
271
319
|
value: z2.any().optional().describe("New value (required for 'set' action)"),
|
|
272
|
-
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object")
|
|
320
|
+
subpath: z2.string().optional().default("").describe("Optional sub-path within the identified object"),
|
|
321
|
+
mode: z2.enum(["append", "overwrite", "insert"]).optional().default("overwrite").describe(
|
|
322
|
+
"For 'set' action: 'overwrite' = PATCH (replace existing, default), 'append' = POST (add to arrays, create on objects), 'insert' = PUT (insert at array index)"
|
|
323
|
+
)
|
|
273
324
|
},
|
|
274
325
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
275
|
-
async ({ id, action, value, subpath }) => {
|
|
326
|
+
async ({ id, action, value, subpath, mode }) => {
|
|
276
327
|
if (action === "get") {
|
|
277
328
|
return formatResult(await configByIdGet(id, subpath));
|
|
278
329
|
}
|
|
@@ -283,7 +334,8 @@ function registerConfigTools(server) {
|
|
|
283
334
|
content: [{ type: "text", text: "Error: value is required for 'set' action" }]
|
|
284
335
|
};
|
|
285
336
|
}
|
|
286
|
-
|
|
337
|
+
const method = mode === "append" ? "POST" : mode === "insert" ? "PUT" : "PATCH";
|
|
338
|
+
return formatResult(await configByIdSet(id, value, method, subpath));
|
|
287
339
|
}
|
|
288
340
|
if (action === "delete") {
|
|
289
341
|
return formatResult(await configByIdDelete(id, subpath));
|
|
@@ -373,7 +425,7 @@ ${lines.join("\n")}` }]
|
|
|
373
425
|
"caddy_pki",
|
|
374
426
|
"Get PKI certificate authority info or the CA certificate chain.",
|
|
375
427
|
{
|
|
376
|
-
ca: z3.string().regex(/^[\w-]
|
|
428
|
+
ca: z3.string().regex(/^[\w-]{1,128}$/).optional().default("local").describe("CA ID (default: 'local')"),
|
|
377
429
|
certificates: z3.boolean().optional().default(false).describe("If true, return the full CA certificate chain")
|
|
378
430
|
},
|
|
379
431
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -408,6 +460,10 @@ ${lines.join("\n")}` }]
|
|
|
408
460
|
|
|
409
461
|
// src/tools/routes.ts
|
|
410
462
|
import { z as z4 } from "zod";
|
|
463
|
+
function safeJoin(value) {
|
|
464
|
+
if (!Array.isArray(value)) return "";
|
|
465
|
+
return value.filter((v) => v !== null && v !== void 0).map(String).join(",");
|
|
466
|
+
}
|
|
411
467
|
function parseFrom(from) {
|
|
412
468
|
const cleaned = from.replace(/^https?:\/\//, "");
|
|
413
469
|
const match = {};
|
|
@@ -443,7 +499,7 @@ function registerRouteTools(server) {
|
|
|
443
499
|
{
|
|
444
500
|
from: z4.string().describe("Domain, path, or domain/path to match (e.g., 'api.local', '/api/*', 'app.local/ws')"),
|
|
445
501
|
to: z4.array(z4.string()).describe("Upstream addresses (e.g., ['localhost:3000', 'localhost:3001'])"),
|
|
446
|
-
server: z4.string().regex(/^[\w-]
|
|
502
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
447
503
|
},
|
|
448
504
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
449
505
|
async ({ from, to, server: srv }) => {
|
|
@@ -475,7 +531,7 @@ function registerRouteTools(server) {
|
|
|
475
531
|
{
|
|
476
532
|
match: z4.array(z4.record(z4.string(), z4.any())).describe("Array of match objects (e.g., [{ host: ['example.com'], path: ['/api/*'] }])"),
|
|
477
533
|
handle: z4.array(z4.record(z4.string(), z4.any())).describe("Array of handler objects (e.g., [{ handler: 'file_server', root: '/var/www' }])"),
|
|
478
|
-
server: z4.string().regex(/^[\w-]
|
|
534
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)"),
|
|
479
535
|
terminal: z4.boolean().optional().default(true).describe("Stop processing further routes after this one matches")
|
|
480
536
|
},
|
|
481
537
|
{ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
@@ -492,38 +548,58 @@ function registerRouteTools(server) {
|
|
|
492
548
|
"caddy_list_routes",
|
|
493
549
|
"List all routes on a Caddy HTTP server with a human-readable summary of matchers and handlers.",
|
|
494
550
|
{
|
|
495
|
-
server: z4.string().regex(/^[\w-]
|
|
551
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name (default: srv0)")
|
|
496
552
|
},
|
|
497
553
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
498
554
|
async ({ server: srv }) => {
|
|
499
555
|
const serverRes = await configGet(`apps/http/servers/${srv}`);
|
|
500
556
|
if (!serverRes.ok) return formatResult(serverRes);
|
|
501
|
-
const serverConfig = serverRes.data;
|
|
502
|
-
const routes = serverConfig
|
|
503
|
-
const listen = serverConfig
|
|
557
|
+
const serverConfig = serverRes.data || {};
|
|
558
|
+
const routes = Array.isArray(serverConfig.routes) ? serverConfig.routes : [];
|
|
559
|
+
const listen = Array.isArray(serverConfig.listen) ? serverConfig.listen : [];
|
|
560
|
+
const listenStr = listen.map(String).join(", ") || "default";
|
|
504
561
|
if (routes.length === 0) {
|
|
505
562
|
return {
|
|
506
563
|
content: [
|
|
507
564
|
{
|
|
508
565
|
type: "text",
|
|
509
|
-
text: `Server ${srv} (listen: ${
|
|
566
|
+
text: `Server ${srv} (listen: ${listenStr}) \u2014 no routes configured`
|
|
510
567
|
}
|
|
511
568
|
]
|
|
512
569
|
};
|
|
513
570
|
}
|
|
514
|
-
const lines = [`Server: ${srv} (listen: ${
|
|
571
|
+
const lines = [`Server: ${srv} (listen: ${listenStr})`, ""];
|
|
515
572
|
for (let i = 0; i < routes.length; i++) {
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
573
|
+
const rawRoute = routes[i];
|
|
574
|
+
if (!rawRoute || typeof rawRoute !== "object") {
|
|
575
|
+
lines.push(` Route ${i}: <invalid>`);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const route = rawRoute;
|
|
579
|
+
const idVal = typeof route["@id"] === "string" ? route["@id"] : void 0;
|
|
580
|
+
const groupVal = typeof route.group === "string" ? route.group : void 0;
|
|
581
|
+
const id = idVal ? ` @id="${idVal}"` : "";
|
|
582
|
+
const group = groupVal ? ` group="${groupVal}"` : "";
|
|
583
|
+
const matchList = Array.isArray(route.match) ? route.match : [];
|
|
584
|
+
const matchers = matchList.map((rawMatcher) => {
|
|
585
|
+
if (!rawMatcher || typeof rawMatcher !== "object") return "catch-all";
|
|
586
|
+
const m = rawMatcher;
|
|
520
587
|
const parts = [];
|
|
521
|
-
|
|
522
|
-
if (
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
if (
|
|
588
|
+
const host = safeJoin(m.host);
|
|
589
|
+
if (host) parts.push(`host=[${host}]`);
|
|
590
|
+
const path = safeJoin(m.path);
|
|
591
|
+
if (path) parts.push(`path=[${path}]`);
|
|
592
|
+
const method = safeJoin(m.method);
|
|
593
|
+
if (method) parts.push(`method=[${method}]`);
|
|
594
|
+
if (typeof m.protocol === "string") parts.push(`protocol=${m.protocol}`);
|
|
595
|
+
if (m.remote_ip && typeof m.remote_ip === "object") {
|
|
596
|
+
const ranges = safeJoin(m.remote_ip.ranges);
|
|
597
|
+
parts.push(`remote_ip=[${ranges || "..."}]`);
|
|
598
|
+
}
|
|
599
|
+
if (m.client_ip && typeof m.client_ip === "object") {
|
|
600
|
+
const ranges = safeJoin(m.client_ip.ranges);
|
|
601
|
+
parts.push(`client_ip=[${ranges || "..."}]`);
|
|
602
|
+
}
|
|
527
603
|
if (m.query) parts.push("query=...");
|
|
528
604
|
if (m.header) parts.push("header=...");
|
|
529
605
|
if (m.expression) parts.push(`expr(${typeof m.expression === "string" ? m.expression : "..."})`);
|
|
@@ -546,23 +622,51 @@ function registerRouteTools(server) {
|
|
|
546
622
|
if (parts.length === 0) return "catch-all";
|
|
547
623
|
return parts.join(" ");
|
|
548
624
|
}).join(" | ");
|
|
549
|
-
const
|
|
625
|
+
const handleList = Array.isArray(route.handle) ? route.handle : [];
|
|
626
|
+
const handlers = handleList.map((rawHandler) => {
|
|
627
|
+
if (!rawHandler || typeof rawHandler !== "object") return "unknown";
|
|
628
|
+
const h = rawHandler;
|
|
550
629
|
if (h.handler === "reverse_proxy") {
|
|
551
|
-
const
|
|
630
|
+
const upstreamsArr = Array.isArray(h.upstreams) ? h.upstreams : [];
|
|
631
|
+
const upstreams = upstreamsArr.map((u) => {
|
|
632
|
+
if (u && typeof u === "object") {
|
|
633
|
+
const dial = u.dial;
|
|
634
|
+
if (typeof dial === "string") return dial;
|
|
635
|
+
}
|
|
636
|
+
return "?";
|
|
637
|
+
}).join(",");
|
|
552
638
|
return `reverse_proxy(${upstreams})`;
|
|
553
639
|
}
|
|
554
|
-
if (h.handler === "file_server")
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
640
|
+
if (h.handler === "file_server") {
|
|
641
|
+
const root = typeof h.root === "string" ? h.root : ".";
|
|
642
|
+
return `file_server(${root})`;
|
|
643
|
+
}
|
|
644
|
+
if (h.handler === "static_response") {
|
|
645
|
+
const status = typeof h.status_code === "number" ? h.status_code : 200;
|
|
646
|
+
return `static_response(${status})`;
|
|
647
|
+
}
|
|
648
|
+
if (h.handler === "rewrite") {
|
|
649
|
+
const uri = typeof h.uri === "string" ? h.uri : "...";
|
|
650
|
+
return `rewrite(${uri})`;
|
|
651
|
+
}
|
|
652
|
+
if (h.handler === "subroute") {
|
|
653
|
+
const count = Array.isArray(h.routes) ? h.routes.length : 0;
|
|
654
|
+
return `subroute(${count} routes)`;
|
|
655
|
+
}
|
|
558
656
|
if (h.handler === "encode") return "encode";
|
|
559
657
|
if (h.handler === "headers") return "headers";
|
|
560
|
-
if (h.handler === "authentication")
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
658
|
+
if (h.handler === "authentication") {
|
|
659
|
+
const providers = h.providers && typeof h.providers === "object" ? Object.keys(h.providers).join(",") : "...";
|
|
660
|
+
return `auth(${providers})`;
|
|
661
|
+
}
|
|
662
|
+
if (h.handler === "error") {
|
|
663
|
+
const status = typeof h.status_code === "number" ? h.status_code : "...";
|
|
664
|
+
return `error(${status})`;
|
|
665
|
+
}
|
|
666
|
+
return typeof h.handler === "string" ? h.handler : "unknown";
|
|
564
667
|
}).join(" \u2192 ");
|
|
565
|
-
|
|
668
|
+
const terminal = route.terminal === true ? " [terminal]" : "";
|
|
669
|
+
lines.push(` Route ${i}:${id}${group} ${matchers} \u2192 ${handlers}${terminal}`);
|
|
566
670
|
}
|
|
567
671
|
return {
|
|
568
672
|
content: [
|
|
@@ -572,6 +676,67 @@ function registerRouteTools(server) {
|
|
|
572
676
|
};
|
|
573
677
|
}
|
|
574
678
|
);
|
|
679
|
+
server.tool(
|
|
680
|
+
"caddy_remove_route",
|
|
681
|
+
"Remove a route. Target by @id (preferred \u2014 stable across reorderings) or by array index on a specific server. Uses ETags to prevent concurrent overwrites.",
|
|
682
|
+
{
|
|
683
|
+
id: z4.string().regex(/^[\w-]{1,128}$/).optional().describe("The @id of the route to remove (preferred \u2014 stable even if routes get reordered)"),
|
|
684
|
+
index: z4.number().int().nonnegative().optional().describe("Zero-based index of the route in the server's routes array (only used if id is not provided)"),
|
|
685
|
+
server: z4.string().regex(/^[\w-]{1,128}$/).optional().default("srv0").describe("Caddy server name when using index (default: srv0). Ignored when id is provided."),
|
|
686
|
+
confirm: z4.boolean().optional().default(false).describe("Must be true to actually remove the route (safety)")
|
|
687
|
+
},
|
|
688
|
+
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
689
|
+
async ({ id, index, server: srv, confirm }) => {
|
|
690
|
+
if (!id && index === void 0) {
|
|
691
|
+
return {
|
|
692
|
+
isError: true,
|
|
693
|
+
content: [{ type: "text", text: "Error: must provide either id or index" }]
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
if (!confirm) {
|
|
697
|
+
const target = id ? `@id="${id}"` : `route ${index} on server "${srv}"`;
|
|
698
|
+
return {
|
|
699
|
+
isError: true,
|
|
700
|
+
content: [
|
|
701
|
+
{
|
|
702
|
+
type: "text",
|
|
703
|
+
text: `Refusing to remove ${target} without confirm=true. Re-run with confirm:true to proceed.`
|
|
704
|
+
}
|
|
705
|
+
]
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
if (id) {
|
|
709
|
+
const res2 = await configByIdDelete(id);
|
|
710
|
+
if (res2.ok) return { content: [{ type: "text", text: `Route @id="${id}" removed.` }] };
|
|
711
|
+
return formatResult(res2);
|
|
712
|
+
}
|
|
713
|
+
const readRes = await configGet(`apps/http/servers/${srv}/routes`);
|
|
714
|
+
if (!readRes.ok) return formatResult(readRes);
|
|
715
|
+
const routes = readRes.data;
|
|
716
|
+
if (!Array.isArray(routes)) {
|
|
717
|
+
return {
|
|
718
|
+
isError: true,
|
|
719
|
+
content: [{ type: "text", text: `Error: server "${srv}" has no routes array (or it is malformed)` }]
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
if (index >= routes.length) {
|
|
723
|
+
return {
|
|
724
|
+
isError: true,
|
|
725
|
+
content: [
|
|
726
|
+
{
|
|
727
|
+
type: "text",
|
|
728
|
+
text: `Error: index ${index} out of range \u2014 server "${srv}" has ${routes.length} route(s)`
|
|
729
|
+
}
|
|
730
|
+
]
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const res = await configDelete(`apps/http/servers/${srv}/routes/${index}`);
|
|
734
|
+
if (res.ok) {
|
|
735
|
+
return { content: [{ type: "text", text: `Route ${index} removed from server "${srv}".` }] };
|
|
736
|
+
}
|
|
737
|
+
return formatResult(res);
|
|
738
|
+
}
|
|
739
|
+
);
|
|
575
740
|
}
|
|
576
741
|
|
|
577
742
|
// src/tools/tls.ts
|
|
@@ -586,6 +751,21 @@ function buildTlsConfig(fields) {
|
|
|
586
751
|
}
|
|
587
752
|
};
|
|
588
753
|
}
|
|
754
|
+
function bothErrors(label, patchRes, postRes) {
|
|
755
|
+
const patchErr = patchRes.error || `HTTP ${patchRes.status}`;
|
|
756
|
+
const postErr = postRes.error || `HTTP ${postRes.status}`;
|
|
757
|
+
return {
|
|
758
|
+
isError: true,
|
|
759
|
+
content: [
|
|
760
|
+
{
|
|
761
|
+
type: "text",
|
|
762
|
+
text: `Error: Failed to set ${label}.
|
|
763
|
+
PATCH attempt: ${patchErr}
|
|
764
|
+
POST fallback: ${postErr}`
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
};
|
|
768
|
+
}
|
|
589
769
|
function registerTlsTools(server) {
|
|
590
770
|
server.tool(
|
|
591
771
|
"caddy_tls",
|
|
@@ -606,11 +786,11 @@ function registerTlsTools(server) {
|
|
|
606
786
|
isError: true,
|
|
607
787
|
content: [{ type: "text", text: "Error: email is required for set_email action" }]
|
|
608
788
|
};
|
|
609
|
-
const
|
|
610
|
-
if (
|
|
611
|
-
const
|
|
612
|
-
if (
|
|
613
|
-
return
|
|
789
|
+
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/email", email);
|
|
790
|
+
if (patchRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
791
|
+
const postRes = await configPost("apps/tls", buildTlsConfig({ email }));
|
|
792
|
+
if (postRes.ok) return { content: [{ type: "text", text: `ACME email set to: ${email}` }] };
|
|
793
|
+
return bothErrors("ACME email", patchRes, postRes);
|
|
614
794
|
}
|
|
615
795
|
if (action === "set_acme_ca") {
|
|
616
796
|
if (!ca)
|
|
@@ -618,11 +798,11 @@ function registerTlsTools(server) {
|
|
|
618
798
|
isError: true,
|
|
619
799
|
content: [{ type: "text", text: "Error: ca is required for set_acme_ca action" }]
|
|
620
800
|
};
|
|
621
|
-
const
|
|
622
|
-
if (
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
return
|
|
801
|
+
const patchRes = await configPatch("apps/tls/automation/policies/0/issuers/0/ca", ca);
|
|
802
|
+
if (patchRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
803
|
+
const postRes = await configPost("apps/tls", buildTlsConfig({ ca }));
|
|
804
|
+
if (postRes.ok) return { content: [{ type: "text", text: `ACME CA set to: ${ca}` }] };
|
|
805
|
+
return bothErrors("ACME CA", patchRes, postRes);
|
|
626
806
|
}
|
|
627
807
|
return { isError: true, content: [{ type: "text", text: `Unknown action: ${action}` }] };
|
|
628
808
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/caddy-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for managing Caddy web servers via the admin API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit",
|
|
32
32
|
"test:ci": "npm run build && npm test",
|
|
33
33
|
"prepublishOnly": "npm run build",
|
|
34
|
+
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|
|
34
35
|
"start": "node dist/index.js"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|