lattice-mcp 1.1.2 → 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 **126 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 126 `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,7 +160,7 @@ 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 **126**, matching the header and `grep -c 'server.tool(' index.js`. The
163
+ The counts below sum to **133**, matching the header and `grep -c 'server.tool(' index.js`. The
130
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
 
@@ -139,7 +173,8 @@ every bolded row corresponds to a `// ───` banner group and matches its ex
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` |
@@ -168,6 +203,30 @@ params the handlers already accept but the tools were dropping: `lattice_get_aud
168
203
  `user_id` / `action` / `resource_type` / `offset` (answer "who deleted stack X" server-side instead
169
204
  of scanning 50 rows), and `lattice_get_container_logs` gains `offset` / `worker_id`. Tool count 125 → 126.
170
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
+
171
230
  **Consolidations.** Where `lattice-api` exposes several paths served by one handler, this repo
172
231
  exposes one tool with an enum rather than N tools. `lattice_database_action` covers
173
232
  `/start`, `/stop`, `/restart` and `/remove`. Worker actions are the historical exception — they
@@ -204,8 +263,11 @@ predate this convention and remain separate tools.
204
263
 
205
264
  - **Never hardcode a token, URL or hostname.** Everything comes from env.
206
265
  - **Never log request or response bodies.** Responses routinely contain env vars, registry
207
- credentials and database passwords. `lattice_get_database_credentials` returns live secrets
208
- 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.
209
271
  - **Never add a tool without reading its handler in `lattice-api`.** Inferring a request shape
210
272
  from a struct has produced real, shipped bugs across this family of servers.
211
273
  - **Do not break tool names.** They are a public contract: renaming one silently breaks any
@@ -237,6 +299,12 @@ For any tool you added or changed, make **one real call against the live API** a
237
299
  response shape. Schema-only verification is not enough: it catches typos, not wrong units,
238
300
  wrong enum values, or parameters the handler ignores.
239
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
+
240
308
  **Never report work complete on the strength of `tools/list` alone.**
241
309
 
242
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 **126 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,12 +105,12 @@ 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 126) |
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 126 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 |
@@ -162,15 +184,26 @@ All 126 tools, grouped as they appear in `index.js`. ⚠️ marks destructive to
162
184
  |------|-------------|
163
185
  | `lattice_list_database_instances` | List managed databases (filter by worker, engine, status) |
164
186
  | `lattice_get_database_instance` | Full instance config |
165
- | `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) |
166
188
  | `lattice_update_database_instance` | Update config, limits, snapshot schedule |
167
189
  | `lattice_delete_database_instance` | Delete an instance ⚠️ |
168
190
  | `lattice_database_action` | start / stop / restart / remove ⚠️ |
169
- | `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 |
170
198
  | `lattice_list_database_snapshots` | Snapshots for an instance |
171
199
  | `lattice_create_database_snapshot` | Take a snapshot now |
172
200
  | `lattice_restore_database_snapshot` | Restore from a snapshot ⚠️ |
173
- | `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 |
174
207
 
175
208
  ### Backup destinations
176
209
  | Tool | Description |
@@ -286,7 +319,7 @@ Everything lives in one file:
286
319
 
287
320
  | Path | Role |
288
321
  |------|------|
289
- | `index.js` | The whole server: `--setup` flow, config read, `api()` HTTP helper, `text()`/`body()` helpers, all 126 `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. |
290
323
  | `package.json` | npm metadata; `bin.lattice-mcp` → `index.js`. |
291
324
  | `AGENTS.md` | Contributor/agent guide — conventions, handler contracts, verification. |
292
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.2",
224
+ version: "1.2.0",
118
225
  });
119
226
 
120
227
  // Overview
@@ -438,7 +545,7 @@ server.tool("lattice_delete_api_token", "Delete an API token", {
438
545
  server.tool("lattice_list_database_instances", "List managed database instances with engine, version, status, worker and health. Filter by worker, engine or status", {
439
546
  worker_id: z.number().optional().describe("Filter by worker ID"),
440
547
  engine: z.enum(["mysql", "mariadb", "postgres"]).optional().describe("Filter by engine"),
441
- 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"),
442
549
  limit: z.number().optional().describe("Max instances to return"),
443
550
  offset: z.number().optional().describe("Pagination offset"),
444
551
  }, async (args) => {
@@ -453,18 +560,18 @@ server.tool("lattice_get_database_instance", "Get one database instance: engine,
453
560
  return { content: text(res) };
454
561
  });
455
562
 
456
- 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", {
457
564
  name: z.string().describe("Instance name (must be unique)"),
458
565
  engine: z.enum(["mysql", "mariadb", "postgres"]).describe("Database engine"),
459
566
  worker_id: z.number().describe("Worker to provision on"),
460
567
  engine_version: z.string().optional().describe("Engine version tag; the API picks a default when omitted"),
461
- 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)"),
462
569
  root_password: z.string().optional().describe("Root/superuser password"),
463
570
  database_name: z.string().optional().describe("Initial database to create"),
464
571
  username: z.string().optional().describe("Application user to create"),
465
572
  password: z.string().optional().describe("Application user's password"),
466
573
  cpu_limit: z.number().optional().describe("CPU limit in cores"),
467
- 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)"),
468
575
  snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
469
576
  retention_count: z.number().optional().describe("How many automatic snapshots to keep"),
470
577
  backup_destination_id: z.number().optional().describe("Backup destination for snapshots"),
@@ -476,13 +583,13 @@ server.tool("lattice_create_database_instance", "Provision a database instance o
476
583
  server.tool("lattice_update_database_instance", "Update a database instance's configuration. Only the fields you pass are changed", {
477
584
  id: z.number().describe("Database instance ID"),
478
585
  name: z.string().optional(),
479
- status: z.string().optional(),
480
- 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"),
481
588
  root_password: z.string().optional().describe("New root password"),
482
589
  password: z.string().optional().describe("New application user password"),
483
590
  cpu_limit: z.number().optional(),
484
- memory_limit: z.number().optional(),
485
- 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(),
486
593
  snapshot_schedule: z.string().optional().describe("Cron expression for automatic snapshots"),
487
594
  retention_count: z.number().optional(),
488
595
  backup_destination_id: z.number().optional(),
@@ -507,13 +614,69 @@ server.tool("lattice_database_action", "Start, stop, restart or remove a databas
507
614
  return { content: text(res) };
508
615
  });
509
616
 
510
- 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", {
511
633
  id: z.number().describe("Database instance ID"),
512
634
  }, async ({ id }) => {
513
635
  const res = await api("GET", `/admin/database-instances/${id}/credentials`);
514
636
  return { content: text(res) };
515
637
  });
516
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
+
517
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", {
518
681
  id: z.number().describe("Database instance ID"),
519
682
  }, async ({ id }) => {
@@ -801,7 +964,7 @@ server.tool("lattice_list_deploy_tokens", "List a stack's deploy tokens — the
801
964
  return { content: text(res) };
802
965
  });
803
966
 
804
- 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>", {
805
968
  id: z.number().describe("Stack ID"),
806
969
  name: z.string().describe("Token name, e.g. 'github-actions'"),
807
970
  }, async ({ id, name }) => {
@@ -922,7 +1085,7 @@ server.tool("lattice_list_worker_tokens", "List a worker's registration tokens.
922
1085
  return { content: text(res) };
923
1086
  });
924
1087
 
925
- 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", {
926
1089
  id: z.number().describe("Worker ID"),
927
1090
  name: z.string().describe("Token name"),
928
1091
  }, async ({ id, name }) => {
@@ -1009,7 +1172,7 @@ server.tool("lattice_force_remove_container", "Force-remove a container on a wor
1009
1172
  // Global env vars, templates, webhooks
1010
1173
  // ─────────────────────────────────────────────────────────────────────────────
1011
1174
 
1012
- 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 () => {
1013
1176
  const res = await api("GET", "/admin/env-vars");
1014
1177
  return { content: text(res) };
1015
1178
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattice-mcp",
3
- "version": "1.1.2",
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`);