lattice-mcp 1.1.1 → 1.2.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.
@@ -0,0 +1,33 @@
1
+ name: Verify
2
+
3
+ # lattice-mcp is published to npm by hand, so there is no build-and-deploy
4
+ # pipeline to hang this off. This workflow exists purely as a correctness gate on
5
+ # push and pull request.
6
+ on:
7
+ push:
8
+ branches: [master]
9
+ pull_request:
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ verify:
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4.2.2
19
+
20
+ - name: Set up Node
21
+ uses: actions/setup-node@v4.1.0
22
+ with:
23
+ node-version: 20
24
+ cache: npm
25
+
26
+ - name: Install dependencies
27
+ run: npm ci
28
+
29
+ # Parses index.js, rejects duplicate tool names (server.tool silently
30
+ # lets the last registration win), and fails if README.md/AGENTS.md have
31
+ # drifted from the registered tool count.
32
+ - name: Verify
33
+ run: npm test
package/AGENTS.md CHANGED
@@ -2,7 +2,7 @@
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 **125 typed tools** — workers, stacks,
5
+ > `lattice-api` admin surface to Claude Code as **133 typed tools** — workers, stacks,
6
6
  > containers, deployments, databases, registries, networks, volumes and instance config.
7
7
  > This file orients any agent/worker before touching code in this repo.
8
8
  >
@@ -42,7 +42,7 @@ Those live in [`lattice-api`](https://github.com/aidenappl/lattice-api) and
42
42
 
43
43
  | Path | Role |
44
44
  |------|------|
45
- | `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 125 `server.tool(...)` registrations, transport connect. |
45
+ | `index.js` | Everything: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
46
46
  | `package.json` | npm metadata. `bin.lattice-mcp` → `index.js`, so `npx lattice-mcp` works. |
47
47
  | `README.md` | User-facing setup + full tool table. |
48
48
  | `AGENTS.md` | This file. |
@@ -113,6 +113,40 @@ and supply the real token.
113
113
  `HandleDatabaseAction` derives its action from the **last URL path segment**, not from a body
114
114
  field.
115
115
 
116
+ ### Sensitive value masking
117
+
118
+ `api()` passes every decoded JSON response through **`sanitise()`** before returning it. This is
119
+ central, not per-tool, so a newly added tool is safe by default rather than by remembering.
120
+
121
+ `mask()` keeps a value's **first two characters** and appends a **fixed-width tail** —
122
+ `"supersecret"` → `"su**********"`. The prefix is what makes the mask useful rather than merely
123
+ safe: you can still tell a `frt_` token from an `obk_` one, spot that two containers share a
124
+ key, or confirm a rotation actually changed a value. The tail is fixed width so the mask does
125
+ not disclose the real length. Values under three characters are masked whole.
126
+
127
+ Four rules, in the order `sanitise()` applies them:
128
+
129
+ | Field | Handling |
130
+ |-------|----------|
131
+ | `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
+ | `compose_yaml` | Assignment lines (`- NAME=value` and `NAME: value`) whose name matches `isSecretName()` are masked, without tracking YAML block structure. |
133
+ | `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. |
135
+
136
+ `isSecretName()` matches password/secret/token/key/credential/dsn shapes but **excludes names
137
+ ending in `_url`, `_uri`, `_endpoint`, `_host`, `_port`, `_issuer`** — `TOKEN_URL` and
138
+ `AUTH_URL` are addresses, not credentials, and masking them makes an SSO misconfiguration much
139
+ harder to diagnose.
140
+
141
+ **Why this repo needs it more than the others:** this MCP authenticates as a Lattice **admin**,
142
+ and `lattice-api` only masks global env vars server-side *for non-admin callers*
143
+ (`HandleGlobalEnvVars.router.go`). Before masking, `lattice_list_env_vars` returned every
144
+ `is_secret` value in plaintext despite its own description claiming otherwise.
145
+
146
+ `LATTICE_ALLOW_SECRET_VALUES=1` disables masking entirely — for the cases where you genuinely
147
+ need a working credential (`lattice_reveal_database_credentials`, the token-creation tools).
148
+ It is off by default and should stay that way.
149
+
116
150
  ## Domain & architecture
117
151
 
118
152
  **Auth.** A single long-lived bearer token (`LATTICE_API_TOKEN`) from
@@ -126,20 +160,21 @@ exits immediately if either is missing. In practice these come from the `env` bl
126
160
 
127
161
  **Tool groups**, in file order:
128
162
 
129
- The counts below sum to **125**, matching the header and `grep -c 'server.tool(' index.js`. The
130
- first six rows are the original, pre-`1.1.0` tools (registered top-of-file with no banner comment);
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);
131
165
  every bolded row corresponds to a `// ───` banner group and matches its exact in-file name.
132
166
 
133
167
  | Group | Tools | Notes |
134
168
  |-------|-------|-------|
135
- | Overview & health | 2 | `lattice_overview`, `lattice_health` |
169
+ | Overview & health | 3 | `lattice_overview`, `lattice_health`, `lattice_get_version` |
136
170
  | Workers | 3 + 4 actions | list/get/metrics; reboot, upgrade, stop-all, start-all |
137
171
  | Stacks | 2 + 5 actions | list/get; deploy, restart, stop, start, update |
138
172
  | Containers | 4 + 8 actions | list/get/logs/lifecycle; start, stop, restart, kill, pause, unpause, remove, recreate. (`lattice_get_container_metrics` is *not* here — it lives under **Discovery & diagnostics**.) |
139
173
  | Deployments | 4 | list/get/logs, rollback. (`lattice_approve_deployment` is *not* here — it lives under **Stacks — lifecycle, compose & deploy tokens**.) |
140
174
  | Instance self-update | 2 | `lattice_update_api`, `lattice_update_web` — tell the API/web container to pull its latest image and redeploy itself |
141
175
  | Audit & API tokens | 4 | `lattice_get_audit_log`; API token list/create/delete |
142
- | **Database instances** | **11** | CRUD, `lattice_database_action` (start/stop/restart/remove enum), `lattice_get_database_credentials`, snapshot list/create/restore/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 |
177
+ | **Worker port allocation** | **1** | `lattice_get_worker_port_availability` — claimed host ports on a worker plus a free suggestion |
143
178
  | **Backup destinations** | **6** | list/get/create/update/delete + `lattice_test_backup_destination` |
144
179
  | **Registries** | **8** | list/create/update/delete, `lattice_test_registry`, `lattice_test_registry_inline`, `lattice_list_registry_repositories`, `lattice_list_registry_tags` |
145
180
  | **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` |
@@ -162,6 +197,36 @@ always 400'd); and `lattice_test_backup_destination` now marks `worker_id` as **
162
197
  param the handler 400s without — the test dispatches over the worker's WebSocket). It also hardens
163
198
  `api()` against non-JSON responses (see *How code is written here*) and declares `zod` explicitly.
164
199
 
200
+ **1.1.2** adds `lattice_get_version` (`GET /version`, so agents can read the deployed API version
201
+ for deploy-drift checks without shelling out to `curl`), and widens two read tools to pass filter
202
+ params the handlers already accept but the tools were dropping: `lattice_get_audit_log` gains
203
+ `user_id` / `action` / `resource_type` / `offset` (answer "who deleted stack X" server-side instead
204
+ of scanning 50 rows), and `lattice_get_container_logs` gains `offset` / `worker_id`. Tool count 125 → 126.
205
+
206
+ **1.2.0** covers the managed-database overhaul in `lattice-api`. Tool count 126 → 133.
207
+
208
+ New: `lattice_get_database_connection` (host/port/database/username, no secrets),
209
+ `lattice_reveal_database_credentials` (`POST .../reveal` — audited, root only when `include_root`
210
+ is set), `lattice_get_database_events` (**the first thing to reach for when a database is in an
211
+ unexpected state** — the lifecycle history explains how it got there), `lattice_get_database_logs`,
212
+ `lattice_get_database_lifecycle_logs`, `lattice_open_database_console`, and
213
+ `lattice_get_worker_port_availability`.
214
+
215
+ It also fixes two long-standing request-shape bugs verified against the handlers, both of the exact
216
+ kind this repo's rules warn about:
217
+
218
+ - `memory_limit` was documented as **bytes**. The API takes **megabytes** and multiplies before
219
+ sending to the worker, so any agent that followed the description passed a value roughly a
220
+ million times too small — and Docker rejects any limit under 6MB, so the create failed.
221
+ - `status` was a free string whose description advertised `creating`, which is not a value the
222
+ platform has ever used. It is now an enum matching `structs.DatabaseStatus`
223
+ (`pending`/`provisioning`/`running`/`stopped`/`restarting`/`degraded`/`deleting`/`error`), as is
224
+ `health_status`. The API now rejects anything else with a 400.
225
+
226
+ `lattice_create_database_instance`'s `port` is now documented as optional-and-preferably-omitted:
227
+ the API allocates a free port from 20000-29999 and returns 409 naming the conflict if you pin one
228
+ that is taken.
229
+
165
230
  **Consolidations.** Where `lattice-api` exposes several paths served by one handler, this repo
166
231
  exposes one tool with an enum rather than N tools. `lattice_database_action` covers
167
232
  `/start`, `/stop`, `/restart` and `/remove`. Worker actions are the historical exception — they
@@ -198,8 +263,11 @@ predate this convention and remain separate tools.
198
263
 
199
264
  - **Never hardcode a token, URL or hostname.** Everything comes from env.
200
265
  - **Never log request or response bodies.** Responses routinely contain env vars, registry
201
- credentials and database passwords. `lattice_get_database_credentials` returns live secrets
202
- by design do not add convenience logging anywhere in `api()`.
266
+ credentials and database passwords do not add convenience logging anywhere in `api()`.
267
+ - **Never weaken `sanitise()`.** It must stay recursive, applied centrally in `api()`, and on by
268
+ default. Masking is the only thing standing between an admin token's responses and a
269
+ permanent transcript. If a tool needs real values, the answer is
270
+ `LATTICE_ALLOW_SECRET_VALUES=1` in that server's env, not an exemption in the code.
203
271
  - **Never add a tool without reading its handler in `lattice-api`.** Inferring a request shape
204
272
  from a struct has produced real, shipped bugs across this family of servers.
205
273
  - **Do not break tool names.** They are a public contract: renaming one silently breaks any
@@ -231,6 +299,12 @@ For any tool you added or changed, make **one real call against the live API** a
231
299
  response shape. Schema-only verification is not enough: it catches typos, not wrong units,
232
300
  wrong enum values, or parameters the handler ignores.
233
301
 
302
+ **If you touched `api()`, `sanitise()`, `mask()` or the field lists**, re-verify masking: run a
303
+ nested fixture (objects inside arrays inside objects, an `env_vars` blob, a `compose_yaml`
304
+ string) through `sanitise()` and assert no live value survives at any depth. `npm test` checks
305
+ statically that `api()` still calls `sanitise()`, but it cannot check that the field lists are
306
+ still right.
307
+
234
308
  **Never report work complete on the strength of `tools/list` alone.**
235
309
 
236
310
  ## Keeping this file updated
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 **125 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 **133 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
 
@@ -74,6 +74,28 @@ Restart Claude Code after setup so the new server and tools are picked up.
74
74
  |----------|----------|-------------|
75
75
  | `LATTICE_API_URL` | Yes | Lattice API base URL |
76
76
  | `LATTICE_API_TOKEN` | Yes | Bearer token for authentication (sent on every request) |
77
+ | `LATTICE_ALLOW_SECRET_VALUES` | No | Set to `1` to disable secret masking in responses |
78
+
79
+ ## Secret values are masked
80
+
81
+ Every response is passed through a masking step before it reaches the model. Anything that looks
82
+ like a credential keeps its **first two characters** and loses the rest to a fixed-width tail —
83
+ `supersecret` becomes `su**********`.
84
+
85
+ That is enough to tell two credentials apart, or to confirm a rotation actually changed
86
+ something, and not enough to use. The tail is a fixed width so the mask does not reveal the real
87
+ length.
88
+
89
+ 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`.
93
+
94
+ This server authenticates as a Lattice **admin**, and the API only masks global env vars
95
+ server-side for *non-admin* callers. Without this step, `lattice_list_env_vars` returns every
96
+ secret value in plaintext.
97
+
98
+ Set `LATTICE_ALLOW_SECRET_VALUES=1` to turn masking off if you genuinely need a working value.
77
99
 
78
100
  ## Development
79
101
 
@@ -83,18 +105,19 @@ Restart Claude Code after setup so the new server and tools are picked up.
83
105
  | `npm install` | Install dependencies (not vendored) |
84
106
  | `node --check index.js` | Syntax gate — the only static check that exists |
85
107
  | `LATTICE_API_URL=… LATTICE_API_TOKEN=… node index.js` | Run the server on stdio |
86
- | `grep -c 'server.tool(' index.js` | Confirm the tool count (should be 125) |
108
+ | `grep -c 'server.tool(' index.js` | Confirm the tool count (should be 133) |
87
109
  | `npm publish` | Publish to npm — **this is deployment** (requires 2FA passkey from an interactive terminal) |
88
110
 
89
111
  ## Tools
90
112
 
91
- All 125 tools, grouped as they appear in `index.js`. ⚠️ marks destructive tools; their descriptions state the blast radius.
113
+ All 133 tools, grouped as they appear in `index.js`. ⚠️ marks destructive tools; their descriptions state the blast radius.
92
114
 
93
115
  ### Overview & health
94
116
  | Tool | Description |
95
117
  |------|-------------|
96
118
  | `lattice_overview` | Fleet overview — worker/stack/container counts, failed stacks, CPU/memory |
97
119
  | `lattice_health` | API health and database connectivity |
120
+ | `lattice_get_version` | Deployed lattice-api version string — check deploy drift against GitHub tags |
98
121
 
99
122
  ### Workers
100
123
  | Tool | Description |
@@ -123,7 +146,7 @@ All 125 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
123
146
  |------|-------------|
124
147
  | `lattice_list_containers` | List containers with status, image, ports, health |
125
148
  | `lattice_get_container` | Full container details |
126
- | `lattice_get_container_logs` | Recent container logs (stdout/stderr) |
149
+ | `lattice_get_container_logs` | Recent container logs (stdout/stderr); `offset` paginates into older logs, filter by `stream`/`worker_id` |
127
150
  | `lattice_get_container_lifecycle` | Lifecycle events (start, stop, health changes) |
128
151
  | `lattice_start_container` | Start a stopped container |
129
152
  | `lattice_stop_container` | Stop a running container |
@@ -151,7 +174,7 @@ All 125 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
151
174
  ### Audit & API tokens
152
175
  | Tool | Description |
153
176
  |------|-------------|
154
- | `lattice_get_audit_log` | Recent audit log entries (who did what, when) |
177
+ | `lattice_get_audit_log` | Audit log entries (who did what, when); filter by `user_id`/`action`/`resource_type`, `offset` paginates |
155
178
  | `lattice_list_api_tokens` | List API tokens |
156
179
  | `lattice_create_api_token` | Create a new API token |
157
180
  | `lattice_delete_api_token` | Delete an API token ⚠️ |
@@ -161,15 +184,26 @@ All 125 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
161
184
  |------|-------------|
162
185
  | `lattice_list_database_instances` | List managed databases (filter by worker, engine, status) |
163
186
  | `lattice_get_database_instance` | Full instance config |
164
- | `lattice_create_database_instance` | Provision mysql/mariadb/postgres on a worker |
187
+ | `lattice_create_database_instance` | Provision mysql/mariadb/postgres on a worker (omit `port` to auto-allocate) |
165
188
  | `lattice_update_database_instance` | Update config, limits, snapshot schedule |
166
189
  | `lattice_delete_database_instance` | Delete an instance ⚠️ |
167
190
  | `lattice_database_action` | start / stop / restart / remove ⚠️ |
168
- | `lattice_get_database_credentials` | Connection credentials (returns secrets) |
191
+ | `lattice_get_database_connection` | Host, port, database and username — no secrets |
192
+ | `lattice_reveal_database_credentials` | Reveal live credentials (audited; root only on request) |
193
+ | `lattice_get_database_credentials` | **Deprecated** — root credentials via GET; use the reveal tool |
194
+ | `lattice_get_database_events` | Lifecycle history — start here when a database looks wrong |
195
+ | `lattice_get_database_logs` | Container stdout/stderr |
196
+ | `lattice_get_database_lifecycle_logs` | Worker lifecycle messages, incl. why a create failed |
197
+ | `lattice_open_database_console` | Authorise an interactive SQL console session |
169
198
  | `lattice_list_database_snapshots` | Snapshots for an instance |
170
199
  | `lattice_create_database_snapshot` | Take a snapshot now |
171
200
  | `lattice_restore_database_snapshot` | Restore from a snapshot ⚠️ |
172
- | `lattice_delete_database_snapshot` | Delete a snapshot ⚠️ |
201
+ | `lattice_delete_database_snapshot` | Delete a snapshot and its remote file ⚠️ |
202
+
203
+ ### Worker port allocation
204
+ | Tool | Description |
205
+ |------|-------------|
206
+ | `lattice_get_worker_port_availability` | Claimed host ports on a worker, plus a free suggestion |
173
207
 
174
208
  ### Backup destinations
175
209
  | Tool | Description |
@@ -285,7 +319,7 @@ Everything lives in one file:
285
319
 
286
320
  | Path | Role |
287
321
  |------|------|
288
- | `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 125 `server.tool(...)` registrations, transport connect. |
322
+ | `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 133 `server.tool(...)` registrations, transport connect. |
289
323
  | `package.json` | npm metadata; `bin.lattice-mcp` → `index.js`. |
290
324
  | `AGENTS.md` | Contributor/agent guide — conventions, handler contracts, verification. |
291
325
  | `README.md` | This file. |
package/index.js CHANGED
@@ -57,6 +57,113 @@ if (!API_URL || !API_TOKEN) {
57
57
  process.exit(1);
58
58
  }
59
59
 
60
+ // --- Sensitive value masking ---
61
+ //
62
+ // Lattice's admin API answers to an admin token, so it returns live
63
+ // credentials in full: container and stack env vars, global env var values
64
+ // (masking there is server-side and non-admin-only), database passwords,
65
+ // freshly minted deploy/worker/API tokens. None of that is needed to answer
66
+ // the questions these tools exist to answer, and anything that reaches a
67
+ // model's context is in a transcript forever.
68
+ //
69
+ // Sensitive values are masked to their first two characters plus a
70
+ // fixed-width tail: "supersecret" -> "su**********". The prefix keeps a value
71
+ // identifiable and comparable — you can still tell a frt_ token from an obk_
72
+ // one, or spot that two containers share the same key — while the fixed tail
73
+ // avoids disclosing the length.
74
+ //
75
+ // Set LATTICE_ALLOW_SECRET_VALUES=1 to pass values through unmasked.
76
+
77
+ const ALLOW_SECRETS = process.env.LATTICE_ALLOW_SECRET_VALUES === "1";
78
+
79
+ function mask(value) {
80
+ if (typeof value !== "string" || value === "") return value;
81
+ // Under three characters there is no prefix worth keeping — a two-char
82
+ // secret would otherwise round-trip as itself.
83
+ if (value.length < 3) return "**********";
84
+ return value.slice(0, 2) + "**********";
85
+ }
86
+
87
+ // Response fields that are a credential wherever they appear.
88
+ const SECRET_FIELDS = new Set([
89
+ "password", "root_password", "client_secret", "secret", "secret_key",
90
+ "secret_access_key", "access_key", "access_key_id", "token", "api_token",
91
+ "admin_token", "deploy_token", "worker_token", "plaintext", "private_key",
92
+ "encryption_key", "signing_key", "connection_string", "dsn",
93
+ ]);
94
+
95
+ // Env-var and compose keys are free-form, so they are matched by shape rather
96
+ // than by name. The `_url`/`_uri`/`_endpoint` exclusion keeps TOKEN_URL and
97
+ // AUTH_URL readable — they are addresses, not credentials, and masking them
98
+ // makes an SSO misconfiguration much harder to diagnose.
99
+ const SECRET_NAME = /(pass(word|wd)?|secret|token|api[-_]?key|access[-_]?key|private[-_]?key|signing[-_]?key|encryption[-_]?key|credential|dsn|salt)/i;
100
+ const ADDRESS_NAME = /_(url|uri|endpoint|host|port|issuer)$/i;
101
+
102
+ function isSecretName(name) {
103
+ return SECRET_NAME.test(name) && !ADDRESS_NAME.test(name);
104
+ }
105
+
106
+ // Container and stack env vars arrive as a JSON object encoded in a string.
107
+ // Masking the whole blob would hide the variable names too, which are the
108
+ // useful half — so it is parsed, filtered by key, and re-encoded.
109
+ function maskEnvBlob(raw) {
110
+ if (typeof raw !== "string" || raw === "") return raw;
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(raw);
114
+ } catch {
115
+ // Not the JSON object shape we expect — mask it wholesale rather than
116
+ // pass an unknown blob through.
117
+ return mask(raw);
118
+ }
119
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return raw;
120
+ const out = {};
121
+ for (const [k, v] of Object.entries(parsed)) {
122
+ out[k] = isSecretName(k) && typeof v === "string" ? mask(v) : v;
123
+ }
124
+ return JSON.stringify(out);
125
+ }
126
+
127
+ // Compose YAML carries the same secrets in `environment:` blocks, in either
128
+ // `- NAME=value` or `NAME: value` form. Matching the assignment line directly
129
+ // avoids having to track YAML block structure.
130
+ const COMPOSE_ENV_LINE = /^(\s*-?\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*[:=]\s*)(.+)$/;
131
+
132
+ function maskComposeYAML(raw) {
133
+ if (typeof raw !== "string" || raw === "") return raw;
134
+ return raw
135
+ .split("\n")
136
+ .map((line) => {
137
+ const m = line.match(COMPOSE_ENV_LINE);
138
+ if (!m || !isSecretName(m[2])) return line;
139
+ return m[1] + m[2] + m[3] + mask(m[4].trim());
140
+ })
141
+ .join("\n");
142
+ }
143
+
144
+ // Walks a decoded response and masks every sensitive value in place.
145
+ function sanitise(node) {
146
+ if (ALLOW_SECRETS || node === null || typeof node !== "object") return node;
147
+ if (Array.isArray(node)) return node.map(sanitise);
148
+ const out = {};
149
+ for (const [k, v] of Object.entries(node)) {
150
+ if (k === "env_vars") {
151
+ out[k] = maskEnvBlob(v);
152
+ } else if (k === "compose_yaml") {
153
+ out[k] = maskComposeYAML(v);
154
+ } else if (k === "value" && node.is_secret === true) {
155
+ // Global env vars: the value is only a secret when flagged as one,
156
+ // and masking the rest would hide image tags, ports and hostnames.
157
+ out[k] = mask(v);
158
+ } else if (SECRET_FIELDS.has(k) && typeof v === "string") {
159
+ out[k] = mask(v);
160
+ } else {
161
+ out[k] = sanitise(v);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
60
167
  // --- HTTP helper ---
61
168
 
62
169
  async function api(method, path, params, body) {
@@ -90,7 +197,7 @@ async function api(method, path, params, body) {
90
197
  // Never log bodies anywhere; responses can carry secrets.
91
198
  const raw = await res.text();
92
199
  try {
93
- return JSON.parse(raw);
200
+ return sanitise(JSON.parse(raw));
94
201
  } catch {
95
202
  return {
96
203
  success: false,
@@ -114,7 +221,7 @@ function body(obj) {
114
221
 
115
222
  const server = new McpServer({
116
223
  name: "lattice",
117
- version: "1.1.1",
224
+ version: "1.2.0",
118
225
  });
119
226
 
120
227
  // Overview
@@ -129,6 +236,12 @@ server.tool("lattice_health", "Check API health and database connectivity", {},
129
236
  return { content: text(res) };
130
237
  });
131
238
 
239
+ // Version
240
+ server.tool("lattice_get_version", "Get the deployed lattice-api version string. Use to check deploy drift against GitHub tags/commits (e.g. for /howfarbehind).", {}, async () => {
241
+ const res = await api("GET", "/version");
242
+ return { content: text(res) };
243
+ });
244
+
132
245
  // Workers
133
246
  server.tool("lattice_list_workers", "List all workers with status, IP, Docker version, runner version, last heartbeat", {
134
247
  status: z.enum(["online", "offline", "disconnected"]).optional().describe("Filter by worker status"),
@@ -189,9 +302,11 @@ server.tool("lattice_get_container", "Get full container details including confi
189
302
  server.tool("lattice_get_container_logs", "Get recent container logs (stdout/stderr)", {
190
303
  id: z.number().describe("Container ID"),
191
304
  limit: z.number().optional().describe("Number of log lines (default 50)"),
305
+ offset: z.number().optional().describe("Skip this many lines — paginate past the tail into older logs"),
192
306
  stream: z.enum(["stdout", "stderr"]).optional().describe("Filter by stream"),
193
- }, async ({ id, limit, stream }) => {
194
- const res = await api("GET", `/admin/containers/${id}/logs`, { limit, stream });
307
+ worker_id: z.number().optional().describe("Filter by worker ID"),
308
+ }, async ({ id, limit, offset, stream, worker_id }) => {
309
+ const res = await api("GET", `/admin/containers/${id}/logs`, { limit, offset, stream, worker_id });
195
310
  return { content: text(res) };
196
311
  });
197
312
 
@@ -228,10 +343,14 @@ server.tool("lattice_get_deployment_logs", "Get deployment logs: pull, create, s
228
343
  });
229
344
 
230
345
  // Audit log
231
- server.tool("lattice_get_audit_log", "Get recent audit log entries (who did what, when)", {
346
+ server.tool("lattice_get_audit_log", "Get recent audit log entries (who did what, when). Filter by user, action, or resource type to answer 'who deleted X' without scanning.", {
232
347
  limit: z.number().optional().describe("Number of entries (default 50)"),
233
- }, async ({ limit }) => {
234
- const res = await api("GET", "/admin/audit-log", { limit });
348
+ offset: z.number().optional().describe("Skip this many entries for pagination"),
349
+ user_id: z.number().optional().describe("Filter by the user who performed the action"),
350
+ action: z.string().optional().describe("Filter by action (e.g. create, update, delete, deploy)"),
351
+ resource_type: z.string().optional().describe("Filter by resource type (e.g. stack, container, worker, registry)"),
352
+ }, async ({ limit, offset, user_id, action, resource_type }) => {
353
+ const res = await api("GET", "/admin/audit-log", { limit, offset, user_id, action, resource_type });
235
354
  return { content: text(res) };
236
355
  });
237
356
 
@@ -426,7 +545,7 @@ server.tool("lattice_delete_api_token", "Delete an API token", {
426
545
  server.tool("lattice_list_database_instances", "List managed database instances with engine, version, status, worker and health. Filter by worker, engine or status", {
427
546
  worker_id: z.number().optional().describe("Filter by worker ID"),
428
547
  engine: z.enum(["mysql", "mariadb", "postgres"]).optional().describe("Filter by engine"),
429
- status: z.string().optional().describe("Filter by status (running, stopped, creating, error)"),
548
+ status: z.enum(["pending", "provisioning", "running", "stopped", "restarting", "degraded", "deleting", "error"]).optional().describe("Filter by lifecycle status"),
430
549
  limit: z.number().optional().describe("Max instances to return"),
431
550
  offset: z.number().optional().describe("Pagination offset"),
432
551
  }, async (args) => {
@@ -441,18 +560,18 @@ server.tool("lattice_get_database_instance", "Get one database instance: engine,
441
560
  return { content: text(res) };
442
561
  });
443
562
 
444
- server.tool("lattice_create_database_instance", "Provision a database instance on a worker. Creates a real container pick a port that is free on that worker", {
563
+ server.tool("lattice_create_database_instance", "Provision a database instance on a worker. Creates a real container. Omit `port` to have a free one allocated automatically — that is the recommended path; a port already in use returns 409 naming the conflict", {
445
564
  name: z.string().describe("Instance name (must be unique)"),
446
565
  engine: z.enum(["mysql", "mariadb", "postgres"]).describe("Database engine"),
447
566
  worker_id: z.number().describe("Worker to provision on"),
448
567
  engine_version: z.string().optional().describe("Engine version tag; the API picks a default when omitted"),
449
- port: z.number().optional().describe("Host port to expose"),
568
+ port: z.number().optional().describe("Host port to expose. Omit to auto-allocate a free port from the managed range (20000-29999)"),
450
569
  root_password: z.string().optional().describe("Root/superuser password"),
451
570
  database_name: z.string().optional().describe("Initial database to create"),
452
571
  username: z.string().optional().describe("Application user to create"),
453
572
  password: z.string().optional().describe("Application user's password"),
454
573
  cpu_limit: z.number().optional().describe("CPU limit in cores"),
455
- memory_limit: z.number().optional().describe("Memory limit in bytes"),
574
+ memory_limit: z.number().optional().describe("Memory limit in MEGABYTES (the API converts to bytes; values under 6MB are rejected by Docker)"),
456
575
  snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
457
576
  retention_count: z.number().optional().describe("How many automatic snapshots to keep"),
458
577
  backup_destination_id: z.number().optional().describe("Backup destination for snapshots"),
@@ -464,13 +583,13 @@ server.tool("lattice_create_database_instance", "Provision a database instance o
464
583
  server.tool("lattice_update_database_instance", "Update a database instance's configuration. Only the fields you pass are changed", {
465
584
  id: z.number().describe("Database instance ID"),
466
585
  name: z.string().optional(),
467
- status: z.string().optional(),
468
- port: z.number().optional(),
586
+ status: z.enum(["pending", "provisioning", "running", "stopped", "restarting", "degraded", "deleting", "error"]).optional().describe("Lifecycle status — normally managed by the reconciler; set manually only to correct drift"),
587
+ port: z.number().optional().describe("New host port; rejected with 409 if already claimed on the worker"),
469
588
  root_password: z.string().optional().describe("New root password"),
470
589
  password: z.string().optional().describe("New application user password"),
471
590
  cpu_limit: z.number().optional(),
472
- memory_limit: z.number().optional(),
473
- health_status: z.string().optional(),
591
+ memory_limit: z.number().optional().describe("Memory limit in MEGABYTES"),
592
+ health_status: z.enum(["none", "starting", "healthy", "unhealthy"]).optional(),
474
593
  snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
475
594
  retention_count: z.number().optional(),
476
595
  backup_destination_id: z.number().optional(),
@@ -495,13 +614,69 @@ server.tool("lattice_database_action", "Start, stop, restart or remove a databas
495
614
  return { content: text(res) };
496
615
  });
497
616
 
498
- server.tool("lattice_get_database_credentials", "Get a database instance's connection credentials. Returns secret valuesavoid unless the credentials are actually needed", {
617
+ server.tool("lattice_get_database_connection", "Get a database instance's host, port, database and username. Contains no secretsprefer this over the credential tools when you only need to know where a database lives", {
618
+ id: z.number().describe("Database instance ID"),
619
+ }, async ({ id }) => {
620
+ const res = await api("GET", `/admin/database-instances/${id}/connection`);
621
+ return { content: text(res) };
622
+ });
623
+
624
+ server.tool("lattice_reveal_database_credentials", "Reveal a database instance's live credentials. Every call is audited and recorded against the instance. Returns the application user by default; set include_root only when root access is genuinely required. Passwords come back masked to their first two characters — enough to confirm which credential is deployed, not enough to use. Set LATTICE_ALLOW_SECRET_VALUES=1 in the MCP server env to get the real values, or read them from the Lattice UI", {
625
+ id: z.number().describe("Database instance ID"),
626
+ include_root: z.boolean().optional().describe("Also return the root/superuser password (default false)"),
627
+ }, async ({ id, include_root }) => {
628
+ const res = await api("POST", `/admin/database-instances/${id}/reveal`, null, body({ include_root }));
629
+ return { content: text(res) };
630
+ });
631
+
632
+ server.tool("lattice_get_database_credentials", "DEPRECATED — returns root credentials from a plain GET. Use lattice_reveal_database_credentials instead, which is audited and scoped", {
499
633
  id: z.number().describe("Database instance ID"),
500
634
  }, async ({ id }) => {
501
635
  const res = await api("GET", `/admin/database-instances/${id}/credentials`);
502
636
  return { content: text(res) };
503
637
  });
504
638
 
639
+ server.tool("lattice_get_database_events", "Get a database instance's lifecycle history — every status transition, failure, reconciliation, console open and credential reveal. START HERE when a database is in an unexpected state: this is what explains how it got there", {
640
+ id: z.number().describe("Database instance ID"),
641
+ kind: z.enum(["requested", "accepted", "transition", "health", "failed", "reconciled", "console_open", "reveal"]).optional().describe("Filter by event kind"),
642
+ limit: z.number().optional().describe("Max events to return"),
643
+ }, async ({ id, ...params }) => {
644
+ const res = await api("GET", `/admin/database-instances/${id}/events`, params);
645
+ return { content: text(res) };
646
+ });
647
+
648
+ 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", {
649
+ id: z.number().describe("Database instance ID"),
650
+ stream: z.enum(["stdout", "stderr"]).optional().describe("Filter by stream"),
651
+ limit: z.number().optional().describe("Max log lines to return"),
652
+ }, async ({ id, ...params }) => {
653
+ const res = await api("GET", `/admin/database-instances/${id}/logs`, params);
654
+ return { content: text(res) };
655
+ });
656
+
657
+ server.tool("lattice_get_database_lifecycle_logs", "Get worker-emitted lifecycle messages for a database container, including provisioning progress and the reason a create failed", {
658
+ id: z.number().describe("Database instance ID"),
659
+ limit: z.number().optional().describe("Max entries to return"),
660
+ }, async ({ id, ...params }) => {
661
+ const res = await api("GET", `/admin/database-instances/${id}/lifecycle`, params);
662
+ return { content: text(res) };
663
+ });
664
+
665
+ server.tool("lattice_open_database_console", "Authorise an interactive console session against a running database and return the worker, container and SQL client command to run. The session itself runs over the admin WebSocket, so this returns the authorisation, not a live shell", {
666
+ id: z.number().describe("Database instance ID"),
667
+ }, async ({ id }) => {
668
+ const res = await api("POST", `/admin/database-instances/${id}/console`);
669
+ return { content: text(res) };
670
+ });
671
+
672
+ server.tool("lattice_get_worker_port_availability", "List host ports already claimed on a worker (by databases and by stack containers) and get a free suggestion. Check this before pinning a database to a specific port", {
673
+ id: z.number().describe("Worker ID"),
674
+ port: z.number().optional().describe("Check one specific port instead of listing all claims"),
675
+ }, async ({ id, ...params }) => {
676
+ const res = await api("GET", `/admin/workers/${id}/port-availability`, params);
677
+ return { content: text(res) };
678
+ });
679
+
505
680
  server.tool("lattice_list_database_snapshots", "List snapshots for a database instance, with size and creation time. Check this before deleting or restoring an instance", {
506
681
  id: z.number().describe("Database instance ID"),
507
682
  }, async ({ id }) => {
@@ -789,7 +964,7 @@ server.tool("lattice_list_deploy_tokens", "List a stack's deploy tokens — the
789
964
  return { content: text(res) };
790
965
  });
791
966
 
792
- server.tool("lattice_create_deploy_token", "Create a deploy token for a stack. The plaintext is returned once; use it as https://<lattice>/api/deploy/<token>?container=<name>", {
967
+ server.tool("lattice_create_deploy_token", "Create a deploy token for a stack. The plaintext is returned once and this server masks it to its first two characters, so the usable value never enters a transcript — read it from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1. Used as https://<lattice>/api/deploy/<token>?container=<name>", {
793
968
  id: z.number().describe("Stack ID"),
794
969
  name: z.string().describe("Token name, e.g. 'github-actions'"),
795
970
  }, async ({ id, name }) => {
@@ -910,7 +1085,7 @@ server.tool("lattice_list_worker_tokens", "List a worker's registration tokens.
910
1085
  return { content: text(res) };
911
1086
  });
912
1087
 
913
- server.tool("lattice_create_worker_token", "Create a registration token for a worker, used by the runner to connect. Plaintext is returned once", {
1088
+ server.tool("lattice_create_worker_token", "Create a registration token for a worker, used by the runner to connect. Plaintext is returned once and this server masks it to its first two characters — read the usable value from the Lattice UI, or set LATTICE_ALLOW_SECRET_VALUES=1", {
914
1089
  id: z.number().describe("Worker ID"),
915
1090
  name: z.string().describe("Token name"),
916
1091
  }, async ({ id, name }) => {
@@ -997,7 +1172,7 @@ server.tool("lattice_force_remove_container", "Force-remove a container on a wor
997
1172
  // Global env vars, templates, webhooks
998
1173
  // ─────────────────────────────────────────────────────────────────────────────
999
1174
 
1000
- server.tool("lattice_list_env_vars", "List global environment variables available for interpolation into stack and container configs as ${NAME}. Values marked is_secret are masked", {}, async () => {
1175
+ server.tool("lattice_list_env_vars", "List global environment variables available for interpolation into stack and container configs as ${NAME}. Values marked is_secret are masked to their first two characters by this server — the API itself only masks them for non-admin callers, and this MCP authenticates as an admin", {}, async () => {
1001
1176
  const res = await api("GET", "/admin/env-vars");
1002
1177
  return { content: text(res) };
1003
1178
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattice-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for Lattice container orchestration platform",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,8 @@
8
8
  "lattice-mcp": "index.js"
9
9
  },
10
10
  "scripts": {
11
- "start": "node index.js"
11
+ "start": "node index.js",
12
+ "test": "node verify.mjs"
12
13
  },
13
14
  "keywords": [
14
15
  "mcp",
package/verify.mjs ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Static verification for lattice-mcp.
4
+ *
5
+ * index.js cannot simply be imported to check it: it ends in a top-level await
6
+ * that connects the stdio transport, so importing would hang waiting for a
7
+ * client. Everything here is therefore checked by parsing the source.
8
+ *
9
+ * What this catches:
10
+ *
11
+ * - Duplicate tool names. `server.tool()` silently accepts a duplicate — the
12
+ * last registration wins and the earlier tool disappears with no error. That
13
+ * is invisible until an agent calls the vanished tool.
14
+ * - Tool counts in README.md and AGENTS.md drifting from the code. This repo
15
+ * already shipped a release where the MCP lagged the API by two months; the
16
+ * documented count is the cheapest tripwire for that.
17
+ * - Tools not following the lattice_ prefix, which the client relies on.
18
+ * - sanitise() being unwired from api(). Masking is applied in exactly one
19
+ * place; removing that call leaves every tool working and every response
20
+ * leaking, which no other check would notice.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { execFileSync } from "node:child_process";
25
+
26
+ const failures = [];
27
+ const fail = (msg) => failures.push(msg);
28
+
29
+ const source = readFileSync("index.js", "utf8");
30
+
31
+ // ── Syntax ───────────────────────────────────────────────────────────────────
32
+ try {
33
+ execFileSync(process.execPath, ["--check", "index.js"], { stdio: "pipe" });
34
+ } catch (err) {
35
+ fail(`index.js failed to parse:\n${err.stderr?.toString() ?? err.message}`);
36
+ }
37
+
38
+ // ── Tool registrations ───────────────────────────────────────────────────────
39
+ const names = [...source.matchAll(/server\.tool\(\s*"([^"]+)"/g)].map((m) => m[1]);
40
+
41
+ if (names.length === 0) {
42
+ fail("no server.tool() registrations found — did the call shape change?");
43
+ }
44
+
45
+ const seen = new Set();
46
+ const duplicates = new Set();
47
+ for (const name of names) {
48
+ if (seen.has(name)) duplicates.add(name);
49
+ seen.add(name);
50
+ }
51
+ if (duplicates.size > 0) {
52
+ fail(
53
+ `duplicate tool names (the later registration silently replaces the earlier): ${[...duplicates].join(", ")}`,
54
+ );
55
+ }
56
+
57
+ const misnamed = names.filter((n) => !n.startsWith("lattice_"));
58
+ if (misnamed.length > 0) {
59
+ fail(`tools not prefixed with lattice_: ${misnamed.join(", ")}`);
60
+ }
61
+
62
+ // ── Documented counts must match the code ────────────────────────────────────
63
+ const toolCount = names.length;
64
+
65
+ for (const file of ["README.md", "AGENTS.md"]) {
66
+ const doc = readFileSync(file, "utf8");
67
+ const match = doc.match(/\*\*(\d+) typed tools\*\*/);
68
+ if (!match) {
69
+ fail(`${file}: could not find a "**N typed tools**" figure to check against`);
70
+ continue;
71
+ }
72
+ const documented = Number(match[1]);
73
+ if (documented !== toolCount) {
74
+ fail(
75
+ `${file} documents ${documented} tools but index.js registers ${toolCount} — ` +
76
+ `update the docs in the same change (see "Keeping this file updated")`,
77
+ );
78
+ }
79
+ }
80
+
81
+ // README lists every tool in a table; make sure the listing is complete too.
82
+ const readme = readFileSync("README.md", "utf8");
83
+ const undocumented = names.filter((n) => !readme.includes(`\`${n}\``));
84
+ if (undocumented.length > 0) {
85
+ fail(`tools missing from the README tool tables: ${undocumented.join(", ")}`);
86
+ }
87
+
88
+ // ── Version consistency ──────────────────────────────────────────────────────
89
+ // The version lives in two places: package.json and the McpServer declaration.
90
+ // They drift silently — nothing reads both — and the failure surfaces only at
91
+ // `npm publish`, as "cannot publish over the previously published versions",
92
+ // after the release has already been tagged and pushed.
93
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
94
+ const serverVersion = source.match(/new McpServer\(\{[^}]*version:\s*"([^"]+)"/s)?.[1];
95
+
96
+ if (!serverVersion) {
97
+ fail("could not find the McpServer version declaration in index.js");
98
+ } else if (serverVersion !== pkg.version) {
99
+ fail(
100
+ `version mismatch: package.json is ${pkg.version} but index.js declares ${serverVersion} — ` +
101
+ `bump both`,
102
+ );
103
+ }
104
+
105
+ // AGENTS.md documents each release; a bump with no matching entry means the
106
+ // release notes are already behind.
107
+ const agents = readFileSync("AGENTS.md", "utf8");
108
+ if (pkg.version && !agents.includes(`**${pkg.version}**`)) {
109
+ fail(`AGENTS.md has no "**${pkg.version}**" release entry — document the release in the same change`);
110
+ }
111
+
112
+ // ── Secret masking ───────────────────────────────────────────────────────────
113
+ // sanitise() is the only thing keeping env vars, database passwords and freshly
114
+ // minted tokens out of a transcript, and it works by being applied centrally in
115
+ // api(). Unwiring that one call is a silent, total regression — every tool keeps
116
+ // working and every response starts leaking. This is the tripwire for it.
117
+ if (!/return sanitise\(JSON\.parse\(raw\)\)/.test(source)) {
118
+ fail("api() no longer passes its parsed response through sanitise() — every tool now leaks secrets");
119
+ }
120
+ if (!/function sanitise\(/.test(source) || !/function mask\(/.test(source)) {
121
+ fail("sanitise()/mask() are missing from index.js");
122
+ }
123
+ // The masking must default to on; only an explicit opt-in env var disables it.
124
+ if (!/const ALLOW_SECRETS = process\.env\.LATTICE_ALLOW_SECRET_VALUES === "1"/.test(source)) {
125
+ fail("the masking opt-out is not the expected LATTICE_ALLOW_SECRET_VALUES === \"1\" check — masking may no longer default to on");
126
+ }
127
+
128
+ // ── Report ───────────────────────────────────────────────────────────────────
129
+ if (failures.length > 0) {
130
+ console.error("verification failed:\n");
131
+ for (const f of failures) console.error(` ✗ ${f}`);
132
+ process.exit(1);
133
+ }
134
+
135
+ console.log(`✓ index.js parses`);
136
+ console.log(`✓ version ${pkg.version} consistent across package.json, index.js and AGENTS.md`);
137
+ console.log(`✓ ${toolCount} tools registered, no duplicates, all lattice_-prefixed`);
138
+ console.log(`✓ README.md and AGENTS.md agree on the tool count`);
139
+ console.log(`✓ every tool appears in the README tool tables`);
140
+ console.log(`✓ sanitise() is wired into api() and masking defaults to on`);