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 +67 -8
- package/README.md +33 -9
- package/index.js +209 -9
- package/package.json +1 -1
- package/verify.mjs +14 -0
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 **
|
|
6
|
-
> containers, deployments, databases, registries, networks, volumes and instance
|
|
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
|
|
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
|
-
|
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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** | **
|
|
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 **
|
|
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,
|
|
91
|
-
|
|
92
|
-
`
|
|
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
|
|
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
|
|
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` |
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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("
|
|
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("
|
|
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", "
|
|
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. Irreversible — every 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
|
-
|
|
612
|
-
|
|
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
|
|
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
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`);
|