lattice-mcp 1.4.0 → 1.6.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/AGENTS.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  > `lattice-mcp` is the **Model Context Protocol server for Lattice**, the container
4
4
  > orchestration platform that runs every `appleby.cloud` service. It exposes the
5
- > `lattice-api` admin surface to Claude Code as **133 typed tools** — workers, stacks,
6
- > containers, deployments, databases, registries, networks, volumes and instance config.
5
+ > `lattice-api` admin surface to Claude Code as **146 typed tools** — workers, stacks,
6
+ > containers, deployments, databases, automations, registries, networks, volumes and instance
7
+ > config.
7
8
  > This file orients any agent/worker before touching code in this repo.
8
9
  >
9
10
  > **⚠️ Golden rule — keep this file current:** any change that adds, removes or retypes a
@@ -42,7 +43,8 @@ Those live in [`lattice-api`](https://github.com/aidenappl/lattice-api) and
42
43
 
43
44
  | Path | Role |
44
45
  |------|------|
45
- | `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
46
+ | `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 146 `server.tool(...)` registrations, transport connect. |
47
+ | `verify.mjs` | `npm test` — static checks: syntax, duplicate tool names, doc counts, version consistency, `sanitise()` wiring. |
46
48
  | `package.json` | npm metadata. `bin.lattice-mcp` → `index.js`, so `npx lattice-mcp` works. |
47
49
  | `README.md` | User-facing setup + full tool table. |
48
50
  | `AGENTS.md` | This file. |
@@ -131,7 +133,8 @@ Four rules, in the order `sanitise()` applies them:
131
133
  | `env_vars` | Parsed as JSON, then values whose **key** matches `isSecretName()` are masked. The blob is not masked wholesale — variable names are the useful half. An unparseable blob *is* masked wholesale rather than passed through. |
132
134
  | `compose_yaml` | Assignment lines (`- NAME=value` and `NAME: value`) whose name matches `isSecretName()` are masked, without tracking YAML block structure. |
133
135
  | `value` when the sibling `is_secret` is `true` | Masked. Global env vars are only secret when flagged, and masking the rest would hide image tags, ports and hostnames. |
134
- | anything in `SECRET_FIELDS` | Masked at any nesting depth, in objects and arrays alike. |
136
+ | `config` of an object whose `type` is `http_request` | An automation step's outbound request. Header values are masked unless the header is `Content-Type`/`Accept`/`User-Agent`, the body is masked, and the URL keeps its origin with the path masked — Slack/Discord-style hooks carry the secret in the path. Method, host and header names stay readable. |
137
+ | anything in `SECRET_FIELDS` | Masked at any nesting depth, in objects and arrays alike. Includes `webhook_token` and `webhook_path` (the path embeds the automation's token). |
135
138
 
136
139
  `isSecretName()` matches password/secret/token/key/credential/dsn shapes but **excludes names
137
140
  ending in `_url`, `_uri`, `_endpoint`, `_host`, `_port`, `_issuer`** — `TOKEN_URL` and
@@ -160,9 +163,11 @@ exits immediately if either is missing. In practice these come from the `env` bl
160
163
 
161
164
  **Tool groups**, in file order:
162
165
 
163
- The counts below sum to **133**, matching the header and `grep -c 'server.tool(' index.js`. The
164
- first rows are the original, pre-`1.1.0` tools (registered top-of-file with no banner comment);
165
- every bolded row corresponds to a `// ───` banner group and matches its exact in-file name.
166
+ The header figure is the authoritative count `npm test` checks it against
167
+ `grep -c 'server.tool(' index.js`. The per-group counts below are descriptive and had already
168
+ drifted from it before 1.6.0 (they summed to 138 against 136 registrations); don't trust their
169
+ sum. The first rows are the original, pre-`1.1.0` tools (registered top-of-file with no banner
170
+ comment); every bolded row corresponds to a `// ───` banner group and matches its exact in-file name.
166
171
 
167
172
  | Group | Tools | Notes |
168
173
  |-------|-------|-------|
@@ -173,12 +178,13 @@ every bolded row corresponds to a `// ───` banner group and matches its ex
173
178
  | Deployments | 4 | list/get/logs, rollback. (`lattice_approve_deployment` is *not* here — it lives under **Stacks — lifecycle, compose & deploy tokens**.) |
174
179
  | Instance self-update | 2 | `lattice_update_api`, `lattice_update_web` — tell the API/web container to pull its latest image and redeploy itself |
175
180
  | Audit & API tokens | 4 | `lattice_get_audit_log`; API token list/create/delete |
176
- | **Database instances** | **19** | CRUD, `lattice_database_action` (start/stop/restart/remove enum), `lattice_get_database_connection`, `lattice_reveal_database_credentials`, `lattice_get_database_credentials` (deprecated), `lattice_get_database_events`, `lattice_get_database_logs`, `lattice_get_database_lifecycle_logs`, `lattice_open_database_console`, snapshot list/create/restore/delete |
181
+ | **Database instances** | **22** | CRUD, `lattice_database_action` (start/stop/restart/remove enum), `lattice_get_database_connection`, `lattice_reveal_database_credentials`, `lattice_get_database_credentials` (deprecated), `lattice_get_database_events`, `lattice_get_database_logs`, `lattice_get_database_lifecycle_logs`, `lattice_get_database_metrics`, `lattice_get_database_runs`, `lattice_get_database_backup_posture`, `lattice_open_database_console`, snapshot list/create/restore/delete |
177
182
  | **Worker port allocation** | **1** | `lattice_get_worker_port_availability` — claimed host ports on a worker plus a free suggestion |
178
183
  | **Backup destinations** | **6** | list/get/create/update/delete + `lattice_test_backup_destination` |
179
184
  | **Registries** | **8** | list/create/update/delete, `lattice_test_registry`, `lattice_test_registry_inline`, `lattice_list_registry_repositories`, `lattice_list_registry_tags` |
180
185
  | **Discovery & diagnostics** | **7** | `lattice_search`, `lattice_get_anomalies`, `lattice_get_fleet_metrics`, `lattice_get_versions`, `lattice_refresh_versions`, `lattice_get_container_metrics`, `lattice_get_self` |
181
186
  | **Stacks — lifecycle, compose & deploy tokens** | **13** | create/delete, `lattice_get_stack_containers`, compose update/sync/import, export/import, `lattice_save_stack_as_template`, deploy-token list/create/delete, `lattice_approve_deployment` |
187
+ | **Automations** | **10** | list/get/create/update/delete, enable, disable, `lattice_run_automation` (65 s client timeout — a run's budget is 50 s), `lattice_rotate_automation_token`, `lattice_list_automation_runs`. `actions` is a typed `z.discriminatedUnion` on `type`, mirroring the API's config shapes |
182
188
  | **Containers — definition CRUD** | **3** | `lattice_create_container`, `lattice_update_container`, `lattice_delete_container` |
183
189
  | **Workers — registration, tokens, volumes, networks** | **16** | worker create/update/delete, `lattice_get_worker_container_stats`, worker-token ×3, volume ×3, worker-network ×3, `lattice_list_all_networks`, `lattice_delete_network`, `lattice_force_remove_container` |
184
190
  | **Global env vars, templates, webhooks** | **12** | env-var CRUD (4), template list/create/delete (3), webhook list/create/update/delete/test (5) |
@@ -249,6 +255,52 @@ one. `verify.mjs` gained a tripwire asserting `sanitise()` is still wired into `
249
255
  defaults to on — unwiring that single call is a silent, total regression that no other check
250
256
  would notice.
251
257
 
258
+ **1.5.0** exposes the durability work that landed in `lattice-api` v1.3.27–v1.3.30. Three new
259
+ tools, 133 → 136.
260
+
261
+ `lattice_get_database_metrics` reads CPU/memory samples addressed by instance. Those samples were
262
+ always collected but had no read path: a managed database has no row in the `containers` table, and
263
+ every other metrics reader takes a container id.
264
+
265
+ `lattice_get_database_runs` answers "did the backup run?", which the snapshot list cannot. Scheduling
266
+ moved into the control plane, so every slot leaves a row whether or not it produced a snapshot — an
267
+ absent snapshot is a mystery, while a skipped run states its reason. This is not a hypothetical
268
+ convenience: the first production run of the new scheduler skipped four consecutive slots, and the
269
+ diagnosis came from these rows rather than from reading code.
270
+
271
+ `lattice_get_database_backup_posture` scores a database against 3-2-1 and is deliberately
272
+ conservative — an unconfirmed destination locality is never counted as off-site, because Lattice
273
+ cannot tell a bucket on the worker being backed up from one in another country.
274
+
275
+ Parameters added: `locality` on backup destinations, `mirror_backup_destination_id` and
276
+ `deletion_protection` on database update, `final_snapshot` on delete. The delete tool's `force`
277
+ description now also states what force does *not* do — it does not override deletion protection.
278
+
279
+ **1.6.0** mirrors `lattice-api` automations (`/admin/automations*`). Ten new tools, 136 → 146.
280
+ Unpublished at the time of writing — lattice-api's automation routes must be deployed first, or
281
+ every one of these tools 404s.
282
+
283
+ An automation is one trigger (webhook or UTC cron) plus ordered steps (`redeploy_container`,
284
+ `http_request`). It exists because a deploy token reaches exactly one stack: monitor-core's CI
285
+ redeployed one zone through `?container=` and the second zone silently fell a release and eight
286
+ migrations behind. Request shapes were taken from `routers/HandleAutomations.router.go`, not from
287
+ the structs, and every tool was exercised against a locally running `lattice-api` before
288
+ release. Three things an agent must know, all stated in the descriptions:
289
+
290
+ - **An automation created here runs as this server's token owner**, and every step is authorised
291
+ against that user each time it runs. Redefining (trigger/actions) or enabling re-binds the
292
+ run-as identity to the caller; renaming does not.
293
+ - **`redeploy_container` names a container by `stack_id` + `container_name`**, never an id — a
294
+ compose edit re-creates container ids.
295
+ - **`lattice_run_automation` is synchronous** and performs the real actions. It is the one tool
296
+ that raises `api()`'s timeout (to 65 s), because the API allows a run 50 s and aborting early
297
+ would report a failure for a run still going. `api()` gained an optional fifth `timeoutMs`
298
+ argument for this; the default is unchanged.
299
+
300
+ Masking was extended in the same change, because the new responses would otherwise have leaked:
301
+ `webhook_token` and `webhook_path` joined `SECRET_FIELDS`, and `http_request` step configs get a
302
+ dedicated rule (see *Sensitive value masking*).
303
+
252
304
  **1.4.0** exposes parameters added to `lattice-api` alongside the managed-database overhaul. No
253
305
  tool-count change.
254
306
 
@@ -268,6 +320,13 @@ exposes one tool with an enum rather than N tools. `lattice_database_action` cov
268
320
  `/start`, `/stop`, `/restart` and `/remove`. Worker actions are the historical exception — they
269
321
  predate this convention and remain separate tools.
270
322
 
323
+ **`remove` and delete are not the same operation, and the tool descriptions have to say so.**
324
+ `lattice_database_action("remove")` destroys a database's container and **keeps** its data volume;
325
+ `lattice_delete_database_instance` destroys the volume too and is irreversible except via snapshots.
326
+ The latter is asynchronous — the instance sits in `deleting` until the worker confirms — and returns
327
+ **409** when the worker is offline, which `force: true` overrides at the price of abandoning the
328
+ container and volume on disk.
329
+
271
330
  ## Ecosystem & related repos
272
331
 
273
332
  | Repo | Relationship |
package/README.md CHANGED
@@ -8,7 +8,7 @@ Model Context Protocol server for [Lattice](https://github.com/aidenappl/lattice
8
8
 
9
9
  ## Overview
10
10
 
11
- `lattice-mcp` is a single-file Node ESM program (`index.js`) that speaks MCP over stdio and translates tool calls into HTTP requests against the `lattice-api` admin surface. It exposes **133 typed tools** and holds no business logic, caching or state of its own — every behaviour (pagination, validation, side effects) comes from `lattice-api`.
11
+ `lattice-mcp` is a single-file Node ESM program (`index.js`) that speaks MCP over stdio and translates tool calls into HTTP requests against the `lattice-api` admin surface. It exposes **146 typed tools** and holds no business logic, caching or state of its own — every behaviour (pagination, validation, side effects) comes from `lattice-api`.
12
12
 
13
13
  Once configured, ask Claude Code things like:
14
14
 
@@ -17,6 +17,8 @@ Once configured, ask Claude Code things like:
17
17
  - "Which containers are unhealthy?" (`lattice_get_anomalies` is the best first call)
18
18
  - "Deploy stack 5" / "Rollback the last deployment on stack 12"
19
19
  - "What image tags can I deploy from the registry?"
20
+ - "Create an automation that redeploys monitor-core in both zones from one webhook"
21
+ - "Why didn't the nightly automation run?" (`lattice_list_automation_runs` — skipped firings say why)
20
22
 
21
23
  ## Role in the appleby.cloud ecosystem
22
24
 
@@ -87,9 +89,11 @@ something, and not enough to use. The tail is a fixed width so the mask does not
87
89
  length.
88
90
 
89
91
  This covers container and stack `env_vars`, `compose_yaml` environment blocks, global env vars
90
- flagged `is_secret`, database passwords, and freshly minted deploy/worker/API tokens. Variable
91
- *names* are left readable — they are the useful half as are addresses like `TOKEN_URL` and
92
- `AUTH_URL`.
92
+ flagged `is_secret`, database passwords, freshly minted deploy/worker/API tokens, automation
93
+ webhook tokens (`webhook_token` and the `webhook_path` that embeds it), and automation
94
+ `http_request` steps — header values, the body, and the URL path, where Slack/Discord-style hooks
95
+ keep their secret. Variable and header *names* are left readable — they are the useful half — as
96
+ are the URL's host and addresses like `TOKEN_URL` and `AUTH_URL`.
93
97
 
94
98
  This server authenticates as a Lattice **admin**, and the API only masks global env vars
95
99
  server-side for *non-admin* callers. Without this step, `lattice_list_env_vars` returns every
@@ -105,12 +109,13 @@ Set `LATTICE_ALLOW_SECRET_VALUES=1` to turn masking off if you genuinely need a
105
109
  | `npm install` | Install dependencies (not vendored) |
106
110
  | `node --check index.js` | Syntax gate — the only static check that exists |
107
111
  | `LATTICE_API_URL=… LATTICE_API_TOKEN=… node index.js` | Run the server on stdio |
108
- | `grep -c 'server.tool(' index.js` | Confirm the tool count (should be 133) |
112
+ | `grep -c 'server.tool(' index.js` | Confirm the tool count (should be 146) |
113
+ | `npm test` | `verify.mjs` — syntax, duplicate names, doc counts, version consistency, masking wiring |
109
114
  | `npm publish` | Publish to npm — **this is deployment** (requires 2FA passkey from an interactive terminal) |
110
115
 
111
116
  ## Tools
112
117
 
113
- All 133 tools, grouped as they appear in `index.js`. ⚠️ marks destructive tools; their descriptions state the blast radius.
118
+ All 146 tools, grouped as they appear in `index.js`. ⚠️ marks destructive tools; their descriptions state the blast radius.
114
119
 
115
120
  ### Overview & health
116
121
  | Tool | Description |
@@ -186,12 +191,15 @@ All 133 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
186
191
  | `lattice_get_database_instance` | Full instance config |
187
192
  | `lattice_create_database_instance` | Provision mysql/mariadb/postgres on a worker (omit `port` to auto-allocate) |
188
193
  | `lattice_update_database_instance` | Update config, limits, snapshot schedule |
189
- | `lattice_delete_database_instance` | Delete an instance ⚠️ |
190
- | `lattice_database_action` | start / stop / restart / remove ⚠️ |
194
+ | `lattice_delete_database_instance` | Destroy an instance **and its data volume** ⚠️ — async; `force` only for an offline worker |
195
+ | `lattice_database_action` | start / stop / restart / remove ⚠️ — `remove` is container-only, the data volume survives |
191
196
  | `lattice_get_database_connection` | Host, port, database and username — no secrets |
192
197
  | `lattice_reveal_database_credentials` | Reveal live credentials (audited; root only on request) |
193
198
  | `lattice_get_database_credentials` | **Deprecated** — root credentials via GET; use the reveal tool |
194
199
  | `lattice_get_database_events` | Lifecycle history — start here when a database looks wrong |
200
+ | `lattice_get_database_backup_posture` | 3-2-1 standing — copies, media, off-site, with warnings |
201
+ | `lattice_get_database_runs` | Scheduled snapshot attempts, including skipped slots and why |
202
+ | `lattice_get_database_metrics` | CPU/memory samples for a database (no containers-table row needed) |
195
203
  | `lattice_get_database_logs` | Container stdout/stderr |
196
204
  | `lattice_get_database_lifecycle_logs` | Worker lifecycle messages, incl. why a create failed |
197
205
  | `lattice_open_database_console` | Authorise an interactive SQL console session |
@@ -255,6 +263,22 @@ All 133 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
255
263
  | `lattice_delete_deploy_token` | Delete a CI deploy token ⚠️ |
256
264
  | `lattice_approve_deployment` | Approve a deployment awaiting approval |
257
265
 
266
+ ### Automations
267
+ A trigger (webhook or UTC cron) plus ordered steps. One webhook can redeploy containers in any number of stacks — a deploy token reaches one. Automations created here run as the user who owns this server's API token, authorised each time they run.
268
+
269
+ | Tool | Description |
270
+ |------|-------------|
271
+ | `lattice_list_automations` | Automations with their run-as user and last run — start here when a CI redeploy "didn't happen" |
272
+ | `lattice_get_automation` | One automation's trigger, steps, run-as user and last run |
273
+ | `lattice_create_automation` | Create one — webhook token returned once and masked |
274
+ | `lattice_update_automation` | Rename, or redefine trigger/steps (redefining makes you the run-as user) |
275
+ | `lattice_enable_automation` | Switch on — re-validates, makes you the run-as user |
276
+ | `lattice_disable_automation` | Switch off — the webhook then answers 200 `disabled` |
277
+ | `lattice_run_automation` | Fire now and wait for the verdict (up to 50s) — performs real actions |
278
+ | `lattice_rotate_automation_token` | New webhook token; the old URL 401s immediately |
279
+ | `lattice_delete_automation` | Delete — stops firing, kills the webhook URL ⚠️ |
280
+ | `lattice_list_automation_runs` | Run history incl. skipped firings and why, with every step's result |
281
+
258
282
  ### Container definitions
259
283
  | Tool | Description |
260
284
  |------|-------------|
@@ -319,7 +343,7 @@ Everything lives in one file:
319
343
 
320
344
  | Path | Role |
321
345
  |------|------|
322
- | `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
346
+ | `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 146 `server.tool(...)` registrations, transport connect. |
323
347
  | `package.json` | npm metadata; `bin.lattice-mcp` → `index.js`. |
324
348
  | `AGENTS.md` | Contributor/agent guide — conventions, handler contracts, verification. |
325
349
  | `README.md` | This file. |
package/index.js CHANGED
@@ -90,8 +90,46 @@ const SECRET_FIELDS = new Set([
90
90
  "secret_access_key", "access_key", "access_key_id", "token", "api_token",
91
91
  "admin_token", "deploy_token", "worker_token", "plaintext", "private_key",
92
92
  "encryption_key", "signing_key", "connection_string", "dsn",
93
+ // An automation's webhook secret, and the path that embeds it — either one
94
+ // is the whole credential for firing the automation.
95
+ "webhook_token", "webhook_path",
93
96
  ]);
94
97
 
98
+ // An automation's http_request step is an outbound request someone wrote, and
99
+ // its credentials live in three places: header values (Authorization,
100
+ // X-Api-Key, …), the body, and — for Slack/Discord-style hooks — the URL path
101
+ // itself. lattice-api returns all three to an admin, and this MCP is an admin.
102
+ // The method, the host and the header NAMES stay readable, because those are
103
+ // what a diagnosis needs.
104
+ const BENIGN_HEADERS = new Set(["content-type", "accept", "user-agent"]);
105
+
106
+ function maskUrlPath(raw) {
107
+ if (typeof raw !== "string" || raw === "") return raw;
108
+ try {
109
+ const u = new URL(raw);
110
+ const rest = raw.slice(u.origin.length);
111
+ return rest && rest !== "/" ? u.origin + mask(rest) : raw;
112
+ } catch {
113
+ return mask(raw);
114
+ }
115
+ }
116
+
117
+ function maskHttpRequestConfig(cfg) {
118
+ const out = sanitise(cfg);
119
+ if (out === null || typeof out !== "object" || Array.isArray(out)) return out;
120
+ if (typeof out.url === "string") out.url = maskUrlPath(out.url);
121
+ if (out.headers && typeof out.headers === "object" && !Array.isArray(out.headers)) {
122
+ out.headers = Object.fromEntries(
123
+ Object.entries(out.headers).map(([name, value]) => [
124
+ name,
125
+ BENIGN_HEADERS.has(name.toLowerCase()) ? value : mask(value),
126
+ ]),
127
+ );
128
+ }
129
+ if (typeof out.body === "string") out.body = mask(out.body);
130
+ return out;
131
+ }
132
+
95
133
  // Env-var and compose keys are free-form, so they are matched by shape rather
96
134
  // than by name. The `_url`/`_uri`/`_endpoint` exclusion keeps TOKEN_URL and
97
135
  // AUTH_URL readable — they are addresses, not credentials, and masking them
@@ -151,6 +189,8 @@ function sanitise(node) {
151
189
  out[k] = maskEnvBlob(v);
152
190
  } else if (k === "compose_yaml") {
153
191
  out[k] = maskComposeYAML(v);
192
+ } else if (k === "config" && node.type === "http_request") {
193
+ out[k] = maskHttpRequestConfig(v);
154
194
  } else if (k === "value" && node.is_secret === true) {
155
195
  // Global env vars: the value is only a secret when flagged as one,
156
196
  // and masking the rest would hide image tags, ports and hostnames.
@@ -166,7 +206,10 @@ function sanitise(node) {
166
206
 
167
207
  // --- HTTP helper ---
168
208
 
169
- async function api(method, path, params, body) {
209
+ // timeoutMs defaults to 30s. The one caller that raises it is
210
+ // lattice_run_automation: a run is synchronous and bounded by lattice-api's 50s
211
+ // budget, and aborting early would report a failure for a run still going.
212
+ async function api(method, path, params, body, timeoutMs = 30000) {
170
213
  const url = new URL(path, API_URL);
171
214
  if (params) {
172
215
  for (const [k, v] of Object.entries(params)) {
@@ -178,7 +221,7 @@ async function api(method, path, params, body) {
178
221
  headers: {
179
222
  Authorization: `Bearer ${API_TOKEN}`,
180
223
  },
181
- signal: AbortSignal.timeout(30000),
224
+ signal: AbortSignal.timeout(timeoutMs),
182
225
  };
183
226
  if (body) {
184
227
  opts.headers["Content-Type"] = "application/json";
@@ -221,7 +264,7 @@ function body(obj) {
221
264
 
222
265
  const server = new McpServer({
223
266
  name: "lattice",
224
- version: "1.4.0",
267
+ version: "1.6.0",
225
268
  });
226
269
 
227
270
  // Overview
@@ -578,7 +621,7 @@ server.tool("lattice_create_database_instance", "Provision a database instance o
578
621
  password: z.string().optional().describe("Application user's password"),
579
622
  cpu_limit: z.number().optional().describe("CPU limit in cores"),
580
623
  memory_limit: z.number().optional().describe("Memory limit in MEGABYTES (the API converts to bytes; values under 6MB are rejected by Docker)"),
581
- snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
624
+ snapshot_schedule: z.string().optional().describe("5-field cron expression for automatic snapshots, evaluated on the worker. Requires backup_destination_id — a schedule with no destination is rejected, because the control plane only registers schedules that have somewhere to write and it would otherwise save and never run"),
582
625
  retention_count: z.number().optional().describe("How many automatic snapshots to keep"),
583
626
  backup_destination_id: z.number().optional().describe("Backup destination for snapshots"),
584
627
  adopt_existing_volume: z.boolean().optional().describe("Reuse a leftover data volume of the same name. Off by default — the engine skips initialisation when its data directory is non-empty, so it keeps its OLD credentials while the API records the new ones, and nothing looks wrong until a connection fails"),
@@ -597,23 +640,28 @@ server.tool("lattice_update_database_instance", "Update a database instance's co
597
640
  cpu_limit: z.number().optional(),
598
641
  memory_limit: z.number().optional().describe("Memory limit in MEGABYTES"),
599
642
  health_status: z.enum(["none", "starting", "healthy", "unhealthy"]).optional(),
600
- snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
643
+ snapshot_schedule: z.string().optional().describe("5-field cron expression for automatic snapshots, evaluated on the worker. Requires backup_destination_id — a schedule with no destination is rejected, because the control plane only registers schedules that have somewhere to write and it would otherwise save and never run"),
601
644
  retention_count: z.number().optional(),
602
645
  backup_destination_id: z.number().optional(),
646
+ mirror_backup_destination_id: z.number().optional().describe("Second destination each snapshot is copied to after the primary succeeds. An off-site mirror is what satisfies the '1' in 3-2-1; a mirror failure degrades backup posture without failing the backup"),
603
647
  active: z.boolean().optional(),
648
+ deletion_protection: z.boolean().optional().describe("Refuse any delete of this database while true, including a forced one"),
604
649
  }, async ({ id, ...fields }) => {
605
650
  const res = await api("PUT", `/admin/database-instances/${id}`, null, body(fields));
606
651
  return { content: text(res) };
607
652
  });
608
653
 
609
- server.tool("lattice_delete_database_instance", "Delete a database instance record. Destructivedata is lost unless a snapshot exists. Check lattice_list_database_snapshots first", {
654
+ server.tool("lattice_delete_database_instance", "Permanently destroy a database: its container AND its data volume are removed on the worker, then the record is retired once the worker confirms. Irreversibleevery table is gone; only existing snapshots survive, so check lattice_list_database_snapshots first. Asynchronous: the instance sits in 'deleting' until the worker confirms, and a failed teardown leaves it in 'error' rather than disappearing. Returns 409 if the worker is offline, since nothing can be destroyed then — pass force to retire the record anyway and abandon the container and volume on disk. To keep the data, use lattice_database_action with 'remove' instead", {
610
655
  id: z.number().describe("Database instance ID"),
611
- }, async ({ id }) => {
612
- const res = await api("DELETE", `/admin/database-instances/${id}`);
656
+ force: z.boolean().optional().describe("Retire the record even though the worker is offline, abandoning its container and data volume on the worker (default false). Does NOT override deletion protection"),
657
+ final_snapshot: z.boolean().optional().describe("Take one last snapshot first and destroy the database only once it completes. Returns with the database still present; a failed snapshot means nothing is deleted. Requires a backup destination and a running instance"),
658
+ }, async ({ id, force, final_snapshot }) => {
659
+ const qs = [force ? "force=true" : null, final_snapshot ? "final_snapshot=true" : null].filter(Boolean).join("&");
660
+ const res = await api("DELETE", `/admin/database-instances/${id}${qs ? `?${qs}` : ""}`);
613
661
  return { content: text(res) };
614
662
  });
615
663
 
616
- server.tool("lattice_database_action", "Start, stop, restart or remove a database instance's container. 'remove' destroys the container data survives only if the volume or a snapshot does", {
664
+ server.tool("lattice_database_action", "Start, stop, restart or remove a database instance's container. 'remove' destroys the container but KEEPS the data volume and the record, so the database can be started again with its data — it is not a delete, and is idempotent on an already-removed container. Use lattice_delete_database_instance to destroy the data too", {
617
665
  id: z.number().describe("Database instance ID"),
618
666
  action: z.enum(["start", "stop", "restart", "remove"]).describe("Action to perform"),
619
667
  }, async ({ id, action }) => {
@@ -652,6 +700,30 @@ server.tool("lattice_get_database_events", "Get a database instance's lifecycle
652
700
  return { content: text(res) };
653
701
  });
654
702
 
703
+ server.tool("lattice_get_database_backup_posture", "A database's standing against the 3-2-1 rule (three copies, two media, one off-site). Reports what is TRUE rather than what was configured: a destination that has produced no fresh snapshot is not a copy, and one whose locality nobody confirmed is never counted as off-site — Lattice cannot tell a bucket on the worker being backed up from one in another country. Returns per-axis pass/fail, detail lines and warnings", {
704
+ id: z.number().describe("Database instance ID"),
705
+ }, async ({ id }) => {
706
+ const res = await api("GET", `/admin/database-instances/${id}/backup-posture`);
707
+ return { content: text(res) };
708
+ });
709
+
710
+ server.tool("lattice_get_database_runs", "Recent scheduled-snapshot attempts for a database, newest first — INCLUDING slots that were skipped and why. Scheduling lives in the control plane, so every slot leaves a row whether or not it produced a snapshot. Reach for this to answer 'did the backup run?', which lattice_list_database_snapshots cannot: an absent snapshot is a mystery, a skipped run states its reason (previous run still going, database stopped, worker offline, slot beyond the catch-up window)", {
711
+ id: z.number().describe("Database instance ID"),
712
+ limit: z.number().optional().describe("Max runs to return, newest first (default 50, max 500)"),
713
+ }, async ({ id, ...params }) => {
714
+ const res = await api("GET", `/admin/database-instances/${id}/runs`, params);
715
+ return { content: text(res) };
716
+ });
717
+
718
+ server.tool("lattice_get_database_metrics", "Get CPU and memory samples for a managed database instance. These samples were always collected but had no read path: managed databases have no row in the containers table, and every other metrics reader takes a container id. Use this rather than lattice_get_container_metrics for a database", {
719
+ id: z.number().describe("Database instance ID"),
720
+ limit: z.number().optional().describe("Max samples, newest first (default 50, max 500)"),
721
+ since: z.string().optional().describe("RFC3339 timestamp — only samples at or after this time"),
722
+ }, async ({ id, ...params }) => {
723
+ const res = await api("GET", `/admin/database-instances/${id}/metrics`, params);
724
+ return { content: text(res) };
725
+ });
726
+
655
727
  server.tool("lattice_get_database_logs", "Get a database container's stdout/stderr. Use together with lattice_get_database_events when diagnosing a failed or degraded instance — the events say what happened, the logs say why", {
656
728
  id: z.number().describe("Database instance ID"),
657
729
  stream: z.enum(["stdout", "stderr"]).optional().describe("Filter by stream"),
@@ -993,6 +1065,134 @@ server.tool("lattice_approve_deployment", "Approve a deployment that is waiting
993
1065
  return { content: text(res) };
994
1066
  });
995
1067
 
1068
+ // ─────────────────────────────────────────────────────────────────────────────
1069
+ // Automations
1070
+ // ─────────────────────────────────────────────────────────────────────────────
1071
+ //
1072
+ // An automation is one trigger (webhook or UTC cron) plus ordered steps. It
1073
+ // exists because a deploy token is bound to ONE stack: redeploying the same
1074
+ // service in two zones needed two tokens, and monitor-core's second zone was
1075
+ // silently never redeployed. Request shapes mirror
1076
+ // lattice-api routers/HandleAutomations.router.go; the rules they enforce live in
1077
+ // lattice-api automations/.
1078
+
1079
+ const automationTriggerSchema = z.object({
1080
+ type: z.enum(["webhook", "schedule"]).describe(
1081
+ "webhook: fired by POST /api/automations/<token> — requires the admin role, since the URL is a bearer credential. schedule: a 5-field cron evaluated in UTC",
1082
+ ),
1083
+ cron: z.string().optional().describe(
1084
+ "5-field UTC cron, schedule triggers only, e.g. \"0 3 * * *\". Values that could never fire (minute 60, day-of-week 7) are rejected",
1085
+ ),
1086
+ });
1087
+
1088
+ const continueOnError = z.boolean().optional().describe(
1089
+ "Run later steps even if this one fails. It changes flow, not the verdict: the run is still marked failed",
1090
+ );
1091
+
1092
+ const automationActionSchema = z.discriminatedUnion("type", [
1093
+ z.object({
1094
+ type: z.literal("redeploy_container").describe("Recreate (pull image:tag, replace) one container on whichever worker runs its stack. Editor role"),
1095
+ continue_on_error: continueOnError,
1096
+ config: z.object({
1097
+ stack_id: z.number().describe("Stack the container belongs to — lattice_list_stacks"),
1098
+ container_name: z.string().describe(
1099
+ "Container NAME within that stack — lattice_get_stack_containers. A name, not an id: a compose edit re-creates container ids",
1100
+ ),
1101
+ }),
1102
+ }),
1103
+ z.object({
1104
+ type: z.literal("http_request").describe("One outbound HTTP request; succeeds only on a 2xx. Admin role"),
1105
+ continue_on_error: continueOnError,
1106
+ config: z.object({
1107
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]),
1108
+ url: z.string().describe("HTTPS URL on a public host — private and internal addresses are refused"),
1109
+ headers: z.record(z.string(), z.string()).optional().describe(
1110
+ "Header name → value. Stored in plaintext by lattice-api; this server masks the values in every response",
1111
+ ),
1112
+ body: z.string().optional().describe("Request body; not allowed on GET/HEAD. Content-Type defaults to application/json"),
1113
+ timeout_seconds: z.number().int().min(0).max(30).optional().describe("Per-request timeout, default 10, max 30"),
1114
+ }),
1115
+ }),
1116
+ ]);
1117
+
1118
+ server.tool("lattice_list_automations", "List automations — a trigger (webhook or cron) plus ordered steps — with each one's run_as user and last_run. Start here when a CI redeploy 'didn't happen': last_run says what the latest firing did, and an inactive run_as means every firing is being refused", {}, async () => {
1119
+ const res = await api("GET", "/admin/automations");
1120
+ return { content: text(res) };
1121
+ });
1122
+
1123
+ server.tool("lattice_get_automation", "Get one automation: trigger, ordered steps, run_as user, running_run_id (set while a run holds its guard) and last_run. http_request header values, bodies and URL paths come back masked", {
1124
+ id: z.number().describe("Automation ID"),
1125
+ }, async ({ id }) => {
1126
+ const res = await api("GET", `/admin/automations/${id}`);
1127
+ return { content: text(res) };
1128
+ });
1129
+
1130
+ server.tool("lattice_create_automation", "Create an automation: one trigger plus ordered steps. Use it to redeploy containers in SEVERAL stacks from one CI call — a deploy token only reaches one stack. Steps run in order and stop at the first failure unless continue_on_error. The automation runs as the user who owns this MCP's API token, and every step is authorised against that user each time it runs. A webhook trigger and http_request steps require the admin role. For a webhook trigger the response carries webhook_token/webhook_path once; this server masks them — read the URL from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1. CI calls it as POST https://<lattice>/api/automations/<token>?commit=<sha>: 200 succeeded or disabled, 409 skipped (already running), 424 failed", {
1131
+ name: z.string().describe("Display name, up to 128 characters"),
1132
+ description: z.string().optional().describe("Optional description"),
1133
+ enabled: z.boolean().optional().describe("Default true — it fires on its trigger as soon as it is created"),
1134
+ trigger: automationTriggerSchema,
1135
+ actions: z.array(automationActionSchema).min(1).max(20).describe("Ordered steps, 1-20"),
1136
+ }, async ({ name, description, enabled, trigger, actions }) => {
1137
+ const res = await api("POST", "/admin/automations", null, body({ name, description, enabled, trigger, actions }));
1138
+ return { content: text(res) };
1139
+ });
1140
+
1141
+ server.tool("lattice_update_automation", "Update an automation. Omitted fields are left alone; description \"\" clears it. Passing trigger or actions REDEFINES it, which moves its run-as identity to this MCP token's user — who must be able to run every step — while a rename does not. Changing the trigger to webhook mints a token (returned once, masked); changing it away from webhook kills the old URL. To switch it on or off use lattice_enable_automation / lattice_disable_automation", {
1142
+ id: z.number().describe("Automation ID"),
1143
+ name: z.string().optional().describe("New name"),
1144
+ description: z.string().optional().describe("New description; \"\" clears it"),
1145
+ trigger: automationTriggerSchema.optional().describe("Replacement trigger — redefines the automation"),
1146
+ actions: z.array(automationActionSchema).min(1).max(20).optional().describe("Replacement step list — redefines the automation"),
1147
+ }, async ({ id, name, description, trigger, actions }) => {
1148
+ const res = await api("PUT", `/admin/automations/${id}`, null, body({ name, description, trigger, actions }));
1149
+ return { content: text(res) };
1150
+ });
1151
+
1152
+ server.tool("lattice_enable_automation", "Switch an automation on. Re-validates it first — a renamed container or deleted stack is refused here rather than on the next firing — and makes this MCP token's user its run-as identity", {
1153
+ id: z.number().describe("Automation ID"),
1154
+ }, async ({ id }) => {
1155
+ const res = await api("POST", `/admin/automations/${id}/enable`);
1156
+ return { content: text(res) };
1157
+ });
1158
+
1159
+ server.tool("lattice_disable_automation", "Switch an automation off. It fires nothing while disabled: its webhook answers 200 with result \"disabled\", and each firing is still recorded as a skipped run", {
1160
+ id: z.number().describe("Automation ID"),
1161
+ }, async ({ id }) => {
1162
+ const res = await api("POST", `/admin/automations/${id}/disable`);
1163
+ return { content: text(res) };
1164
+ });
1165
+
1166
+ server.tool("lattice_run_automation", "Fire an automation now and wait for the verdict (a run is bounded to 50s). Returns {result, run}: result is succeeded | failed | skipped | disabled, and run.steps has every step's outcome. This performs the real actions — it redeploys containers and sends HTTP requests", {
1167
+ id: z.number().describe("Automation ID"),
1168
+ }, async ({ id }) => {
1169
+ const res = await api("POST", `/admin/automations/${id}/run`, null, null, 65000);
1170
+ return { content: text(res) };
1171
+ });
1172
+
1173
+ server.tool("lattice_rotate_automation_token", "Replace a webhook automation's token. The old URL stops working immediately — CI still calling it gets 401 until updated. The new token is returned once and masked by this server; read it from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1", {
1174
+ id: z.number().describe("Automation ID (webhook trigger only)"),
1175
+ }, async ({ id }) => {
1176
+ const res = await api("POST", `/admin/automations/${id}/rotate-token`);
1177
+ return { content: text(res) };
1178
+ });
1179
+
1180
+ server.tool("lattice_delete_automation", "Delete an automation. It stops firing and its webhook URL stops working immediately; its run history is no longer reachable, though the audit log keeps every action it took. Destructive — lattice_disable_automation only switches it off", {
1181
+ id: z.number().describe("Automation ID"),
1182
+ }, async ({ id }) => {
1183
+ const res = await api("DELETE", `/admin/automations/${id}`);
1184
+ return { content: text(res) };
1185
+ });
1186
+
1187
+ server.tool("lattice_list_automation_runs", "An automation's run history, newest first — the first thing to reach for when an automation 'didn't work'. Every firing is a row, including skipped ones (disabled, already running, no runner slot, stale schedule slot) with skip_reason; failed_step names the first failing step, error explains a refused run, and steps[] carries each step's summary and error", {
1188
+ id: z.number().describe("Automation ID"),
1189
+ limit: z.number().optional().describe("Max runs (default 50, max 500)"),
1190
+ offset: z.number().optional().describe("Offset for pagination"),
1191
+ }, async ({ id, limit, offset }) => {
1192
+ const res = await api("GET", `/admin/automations/${id}/runs`, { limit, offset });
1193
+ return { content: text(res) };
1194
+ });
1195
+
996
1196
  // ─────────────────────────────────────────────────────────────────────────────
997
1197
  // Containers — definition CRUD
998
1198
  // ─────────────────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattice-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server for Lattice container orchestration platform",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/verify.mjs CHANGED
@@ -125,6 +125,19 @@ if (!/const ALLOW_SECRETS = process\.env\.LATTICE_ALLOW_SECRET_VALUES === "1"/.t
125
125
  fail("the masking opt-out is not the expected LATTICE_ALLOW_SECRET_VALUES === \"1\" check — masking may no longer default to on");
126
126
  }
127
127
 
128
+ // Automation responses carry two credentials the generic field list did not
129
+ // cover when automations were added: the webhook token (and the path that
130
+ // embeds it), and http_request step configs, whose secrets sit in header values,
131
+ // the body and the URL path. Dropping either rule leaks a working credential.
132
+ for (const field of ["webhook_token", "webhook_path"]) {
133
+ if (!new RegExp(`const SECRET_FIELDS = new Set\\(\\[[^\\]]*"${field}"`, "s").test(source)) {
134
+ fail(`"${field}" is no longer in SECRET_FIELDS — automation webhook credentials would reach the transcript`);
135
+ }
136
+ }
137
+ if (!/k === "config" && node\.type === "http_request"[\s\S]{0,80}maskHttpRequestConfig\(v\)/.test(source)) {
138
+ fail("sanitise() no longer routes http_request step configs through maskHttpRequestConfig() — header values, bodies and webhook URL paths would leak");
139
+ }
140
+
128
141
  // ── Report ───────────────────────────────────────────────────────────────────
129
142
  if (failures.length > 0) {
130
143
  console.error("verification failed:\n");
@@ -138,3 +151,4 @@ console.log(`✓ ${toolCount} tools registered, no duplicates, all lattice_-pref
138
151
  console.log(`✓ README.md and AGENTS.md agree on the tool count`);
139
152
  console.log(`✓ every tool appears in the README tool tables`);
140
153
  console.log(`✓ sanitise() is wired into api() and masking defaults to on`);
154
+ console.log(`✓ automation webhook tokens and http_request configs are masked`);