@lumibase/mcp-server 1.0.0-rc.1 → 1.0.0-rc.3

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.
Files changed (3) hide show
  1. package/dist/index.cjs +392 -53
  2. package/dist/index.js +392 -53
  3. package/package.json +4 -4
package/dist/index.cjs CHANGED
@@ -16,6 +16,12 @@ var LumiBaseApiError = class extends Error {
16
16
  status;
17
17
  errors;
18
18
  };
19
+ var McpUnavailableError = class extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "McpUnavailableError";
23
+ }
24
+ };
19
25
  var LumiBaseClient = class {
20
26
  baseUrl;
21
27
  origin;
@@ -57,6 +63,40 @@ var LumiBaseClient = class {
57
63
  delete(path) {
58
64
  return this.request("DELETE", path);
59
65
  }
66
+ /**
67
+ * Sends a JSON-RPC request to the governed MCP endpoint (`POST /api/v1/mcp`).
68
+ *
69
+ * Deliberately NOT built on `request()`. That helper returns `json.data`,
70
+ * which is the REST envelope — a JSON-RPC response carries `result` / `error`
71
+ * at the top level and has no `data` key at all, so routing this through
72
+ * `request()` would quietly resolve to `undefined` for every call. The bug
73
+ * would look like "governance returned nothing" rather than "we read the wrong
74
+ * field".
75
+ *
76
+ * @throws {McpUnavailableError} when the site has not enabled `contentOs.mcp`.
77
+ * @throws {LumiBaseApiError} for transport/HTTP failures and JSON-RPC errors.
78
+ */
79
+ async jsonRpc(method, params) {
80
+ const res = await fetch(`${this.baseUrl}/mcp`, {
81
+ method: "POST",
82
+ headers: this.headers,
83
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...params ? { params } : {} })
84
+ });
85
+ const body = await res.json().catch(() => null);
86
+ if (!res.ok) {
87
+ const errors = body?.errors ?? [{ code: "UNKNOWN", message: `HTTP ${res.status}` }];
88
+ if (errors.some((e) => e.code === "MCP_DISABLED")) {
89
+ throw new McpUnavailableError(errors[0]?.message ?? "MCP endpoint disabled");
90
+ }
91
+ throw new LumiBaseApiError(res.status, errors);
92
+ }
93
+ if (body?.error) {
94
+ throw new LumiBaseApiError(res.status, [
95
+ { code: `JSONRPC_${body.error.code}`, message: body.error.message }
96
+ ]);
97
+ }
98
+ return body?.result;
99
+ }
60
100
  /**
61
101
  * GET a root-level (non-`/api/v1`) endpoint such as `/health` or `/metrics`
62
102
  * and return the raw response body as text. These endpoints are not
@@ -109,12 +149,6 @@ function configFromEnv() {
109
149
  return { url, siteId, token };
110
150
  }
111
151
 
112
- // src/tools/access.ts
113
- var import_zod3 = require("zod");
114
-
115
- // src/tools/_crud.ts
116
- var import_zod2 = require("zod");
117
-
118
152
  // src/tools/_shared.ts
119
153
  function formatError(err) {
120
154
  if (err instanceof LumiBaseApiError) {
@@ -154,6 +188,285 @@ async function run(fn) {
154
188
  }
155
189
  }
156
190
  var confirmDescription = "Must be true to confirm the destructive operation";
191
+ var DECISION_STATUSES = /* @__PURE__ */ new Set(["executed", "pending_approval", "denied"]);
192
+ function asGovernedDecision(value) {
193
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
194
+ const status = value.status;
195
+ if (typeof status !== "string" || !DECISION_STATUSES.has(status)) return void 0;
196
+ return value;
197
+ }
198
+ function renderDecision(decision, executedText) {
199
+ if (decision.status === "executed") {
200
+ return decision.data === void 0 ? okText(executedText) : ok(decision.data);
201
+ }
202
+ if (decision.status === "pending_approval") {
203
+ const id = decision.agentApprovalId ?? decision.approvalId;
204
+ const space = decision.approvalSpace ?? (decision.agentApprovalId ? "agent" : id ? "legacy_ai" : void 0);
205
+ const endpoint = space === "agent" ? "POST /api/v1/agent/approvals/{approvalId}/decide" : space === "legacy_ai" ? "POST /api/v1/ai/approvals/{approvalId}/decide" : void 0;
206
+ const lines = [
207
+ "Not executed \u2014 pending approval.",
208
+ ...id ? [`Approval id: ${id}`] : [],
209
+ ...endpoint ? [`Decide at: ${endpoint}`] : [],
210
+ ...decision.runId ? [`Run: ${decision.runId}`] : [],
211
+ ...decision.message ? [decision.message] : []
212
+ ];
213
+ return okText(lines.join("\n"));
214
+ }
215
+ const reason = decision.code ? `[${decision.code}] ` : "";
216
+ return {
217
+ content: [{ type: "text", text: `Not executed \u2014 denied. ${reason}${decision.message ?? ""}`.trim() }],
218
+ isError: true
219
+ };
220
+ }
221
+ function okAfter(response, executedText) {
222
+ const decision = asGovernedDecision(response);
223
+ return decision === void 0 ? okText(executedText) : renderDecision(decision, executedText);
224
+ }
225
+
226
+ // src/governed.ts
227
+ var GOVERNED_TOOLS = {
228
+ // ── items ────────────────────────────────────────────────────────────────
229
+ create_item: { skill: "createItem" },
230
+ update_item: { skill: "updateItem" },
231
+ delete_item: { skill: "deleteItem" },
232
+ // ── schema ───────────────────────────────────────────────────────────────
233
+ delete_collection: { skill: "deleteCollection" },
234
+ // `field_name` is this transport's name for the field; the skill reads `name`.
235
+ // Renamed explicitly rather than dropped (see repro R17).
236
+ delete_field: { skill: "deleteField", rename: { field_name: "name" } },
237
+ create_relation: { skill: "createRelation" },
238
+ delete_relation: { skill: "deleteRelation" },
239
+ // ── access ───────────────────────────────────────────────────────────────
240
+ delete_role: { skill: "deleteRole" },
241
+ delete_policy: { skill: "deletePolicy" },
242
+ // ── automation ───────────────────────────────────────────────────────────
243
+ delete_flow: { skill: "deleteFlow" },
244
+ run_flow: { skill: "runFlow" },
245
+ create_intent: { skill: "createIntent" },
246
+ delete_intent: { skill: "deleteIntent" },
247
+ // ── config ───────────────────────────────────────────────────────────────
248
+ upsert_setting: { skill: "upsertSetting" },
249
+ delete_setting: { skill: "deleteSetting" },
250
+ create_translation: { skill: "createTranslation" },
251
+ update_translation: { skill: "updateTranslation" },
252
+ delete_translation: { skill: "deleteTranslation" },
253
+ create_webhook: { skill: "createWebhook" },
254
+ update_webhook: { skill: "updateWebhook" },
255
+ delete_webhook: { skill: "deleteWebhook" },
256
+ // ── api keys / users / teams ──────────────────────────────────────────────
257
+ create_api_key: { skill: "createApiKey" },
258
+ rotate_api_key: { skill: "rotateApiKey" },
259
+ revoke_api_key: { skill: "revokeApiKey" },
260
+ invite_user: { skill: "inviteUser" },
261
+ update_user: { skill: "updateUser" },
262
+ remove_user: { skill: "removeUser" },
263
+ create_team: { skill: "createTeam" },
264
+ delete_team: { skill: "deleteTeam" },
265
+ // The team tools take the team as `id`; the skill names it `teamId` because it
266
+ // also takes a `userId` and one bare `id` would be ambiguous.
267
+ add_team_member: { skill: "addTeamMember", rename: { id: "teamId" } },
268
+ remove_team_member: { skill: "removeTeamMember", rename: { id: "teamId" } },
269
+ // ── extensions ───────────────────────────────────────────────────────────
270
+ uninstall_extension: { skill: "uninstallExtension" },
271
+ // ── cdc ──────────────────────────────────────────────────────────────────
272
+ // The change-feed skills name the subscription `subscriptionId` (as
273
+ // `getCdcSubscriptionStatus`/`replayCdcSubscription` do); the CRUD-generated
274
+ // stdio tool takes it as `id`, like every other `delete_*` tool.
275
+ // Known divergence, not a skipped gate: the harness builds `SubscriptionService`
276
+ // without `cache`/`audit`, so REST's `cdc_subscription_deleted` audit-log row
277
+ // and feed-flag cache eviction do not happen on this path. The run, tool-call
278
+ // and approval rows record the deletion instead, and the flag cache expires on
279
+ // its own TTL.
280
+ delete_cdc_subscription: { skill: "deleteCdcSubscription", rename: { id: "subscriptionId" } }
281
+ };
282
+ var UNGOVERNED_MUTATIONS = {
283
+ // Skill exists, but the canonical contract is narrower than what this tool
284
+ // advertises. Routing now would reject arguments callers legitimately send.
285
+ create_collection: "contract-narrower-than-tool: 16 advertised properties have no canonical counterpart",
286
+ create_policy: "contract-narrower-than-tool: enforceTfa/ipAllow/ipDeny/validFrom/validUntil",
287
+ create_role: "contract-narrower-than-tool: systemKey",
288
+ cdc_subscription_replay: "contract-narrower-than-tool: cursor has no canonical counterpart",
289
+ create_cdc_subscription: "contract-narrower-than-tool: payload_mode \u2014 the createCdcSubscription handler never forwards it, so a snapshot subscription would be created as reference",
290
+ // Skill and canonical contract both exist and accept the advertised arguments,
291
+ // but the skill's handler skips checks the REST route applies. Routing would
292
+ // add HITL and remove those checks, so these stay on REST until the handler
293
+ // carries them.
294
+ create_flow: "skill-weaker-than-rest: createFlow skips the active-graph validation and schedule-cron check of POST /flows and never sets nextRunAt, so an active schedule flow would never fire",
295
+ install_extension: "skill-weaker-than-rest: installExtension skips the bundle signature check, the reserved lumibase-* namespace check and the per-action extensions:* permission probes of POST /extensions",
296
+ update_extension: "skill-weaker-than-rest: updateExtension skips the per-action extensions:* permission probes, the unverified-official enable refusal, sandbox cache eviction and CDC subscription sync of PATCH /extensions/:id",
297
+ // No skill at all: nothing to route to. Listed so the set is closed.
298
+ add_policy_permission: "no-skill",
299
+ apply_access_import: "no-skill",
300
+ apply_schema: "no-skill",
301
+ approve_content: "no-skill",
302
+ assign_role_user: "no-skill",
303
+ attach_api_key_policy: "no-skill",
304
+ attach_api_key_role: "no-skill",
305
+ attach_policy_user: "no-skill",
306
+ attach_role_policy: "no-skill",
307
+ compile_intent: "no-skill",
308
+ create_preset: "no-skill",
309
+ create_release: "no-skill",
310
+ create_share: "no-skill",
311
+ delete_media: "no-skill",
312
+ delete_policy_permission: "no-skill",
313
+ delete_preset: "no-skill",
314
+ delete_release: "no-skill",
315
+ delete_tm: "no-skill",
316
+ detach_api_key_policy: "no-skill",
317
+ detach_api_key_role: "no-skill",
318
+ detach_policy_user: "no-skill",
319
+ detach_role_policy: "no-skill",
320
+ drop_materialization: "no-skill",
321
+ install_marketplace_extension: "no-skill",
322
+ publish_extension: "no-skill",
323
+ publish_release: "no-skill",
324
+ refresh_materialization: "no-skill",
325
+ reject_content: "no-skill",
326
+ remove_role_user: "no-skill",
327
+ restore_backup: "no-skill",
328
+ revoke_share: "no-skill",
329
+ run_panel: "no-skill",
330
+ submit_review: "no-skill",
331
+ translate_text: "no-skill",
332
+ update_cdc_subscription: "no-skill",
333
+ update_collection: "no-skill",
334
+ update_flow: "no-skill",
335
+ update_intent: "no-skill",
336
+ update_policy: "no-skill",
337
+ update_policy_permission: "no-skill",
338
+ update_preset: "no-skill",
339
+ update_release: "no-skill",
340
+ update_role: "no-skill",
341
+ update_team: "no-skill",
342
+ update_tm: "no-skill",
343
+ upsert_field: "no-skill",
344
+ upsert_tm: "no-skill"
345
+ };
346
+ var MUTATION_VERB = /^(create|update|delete|remove|upsert|set|add|attach|detach|revoke|rotate|invite|install|uninstall|enable|disable|publish|unpublish|promote|apply|run|trigger|restore|replay|drop|materialize|reset|assign|unassign|approve|reject|submit|claim|decide|import|veto|freeze|lift|seed|sync|purge|bump|stage|commit|schedule|cancel|retry|archive|clone|duplicate|move|rename|reorder|translate|compile|generate|refresh|configure)/;
347
+ var MUTATION_EXCEPTIONS = /* @__PURE__ */ new Set(["cdc_subscription_replay"]);
348
+ function isMutationTool(name) {
349
+ return MUTATION_EXCEPTIONS.has(name) || MUTATION_VERB.test(name);
350
+ }
351
+ var PROMPT_ONLY_ARGS = /* @__PURE__ */ new Set(["confirm"]);
352
+ var camel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
353
+ function toSkillArgs(args, binding) {
354
+ const out = {};
355
+ for (const [key, value] of Object.entries(args)) {
356
+ if (PROMPT_ONLY_ARGS.has(key)) continue;
357
+ if (value === void 0) continue;
358
+ out[binding.rename?.[key] ?? camel(key)] = value;
359
+ }
360
+ return out;
361
+ }
362
+ function governedModeFromEnv(env = process.env) {
363
+ const raw = (env["LUMIBASE_MCP_GOVERNED"] ?? "auto").toLowerCase();
364
+ if (raw === "true" || raw === "on" || raw === "1") return "on";
365
+ if (raw === "false" || raw === "off" || raw === "0") return "off";
366
+ return "auto";
367
+ }
368
+ var GovernedDispatcher = class {
369
+ constructor(client, options = {}) {
370
+ this.client = client;
371
+ this.mode = options.mode ?? governedModeFromEnv();
372
+ this.warn = options.warn ?? ((message) => console.error(message));
373
+ }
374
+ client;
375
+ mode;
376
+ warn;
377
+ available;
378
+ get enabled() {
379
+ return this.mode !== "off";
380
+ }
381
+ /**
382
+ * True when this deployment requires every mutation to go through governance.
383
+ *
384
+ * Read by the registration wrapper to refuse mutations that have no governed
385
+ * mapping. Without it, mode `on` only governed the 27 mapped tools and left the
386
+ * rest on REST — so the setting that exists to guarantee governance did not,
387
+ * and the guarantee failed silently for exactly the calls nobody had mapped
388
+ * yet.
389
+ */
390
+ get requiresGovernance() {
391
+ return this.mode === "on";
392
+ }
393
+ /**
394
+ * The refusal returned for a mutation that cannot be governed.
395
+ *
396
+ * Names the reason from {@link UNGOVERNED_MUTATIONS} when there is one, so the
397
+ * operator can tell "we know about this gap" from "nobody classified this tool".
398
+ */
399
+ refuseUngoverned(tool) {
400
+ const reason = UNGOVERNED_MUTATIONS[tool];
401
+ return {
402
+ content: [
403
+ {
404
+ type: "text",
405
+ text: `Refused: "${tool}" changes state but has no governed mapping, and LUMIBASE_MCP_GOVERNED=on requires every mutation to run through the agent harness (autonomy levels, HITL approval, kill switch, run audit).
406
+ ` + (reason ? `Known gap: ${reason}.
407
+ ` : "This tool is in neither the governed nor the declared-ungoverned table, which means it was added without a governance decision.\n") + "Set LUMIBASE_MCP_GOVERNED=auto or off to accept ungoverned REST calls for it."
408
+ }
409
+ ],
410
+ isError: true
411
+ };
412
+ }
413
+ /** True when the governed endpoint answered a probe. */
414
+ probe() {
415
+ this.available ??= (async () => {
416
+ try {
417
+ await this.client.jsonRpc("tools/list");
418
+ return true;
419
+ } catch (err) {
420
+ if (this.mode === "on") return false;
421
+ this.warn(
422
+ err instanceof McpUnavailableError ? "[lumibase-mcp] governed tool calls unavailable: this site has contentOs.mcp disabled. Falling back to direct REST calls, which skip agent governance (autonomy levels, HITL approval, kill switch, run audit). Set LUMIBASE_MCP_GOVERNED=on to refuse instead of falling back." : `[lumibase-mcp] governed endpoint probe failed (${err instanceof Error ? err.message : String(err)}); falling back to direct REST calls, which skip agent governance. Set LUMIBASE_MCP_GOVERNED=on to refuse instead of falling back.`
423
+ );
424
+ return false;
425
+ }
426
+ })();
427
+ return this.available;
428
+ }
429
+ /**
430
+ * Runs `tool` through the harness.
431
+ *
432
+ * @returns the rendered tool result, or `undefined` when the caller should run
433
+ * its own REST handler instead (mode `auto` with governance unavailable).
434
+ */
435
+ async dispatch(tool, args, binding) {
436
+ if (!this.enabled) return void 0;
437
+ if (!await this.probe()) {
438
+ if (this.mode === "on") {
439
+ return {
440
+ content: [
441
+ {
442
+ type: "text",
443
+ text: `Refused: "${tool}" must run through the governed harness, but this site has contentOs.mcp disabled. Enable it, or set LUMIBASE_MCP_GOVERNED=off to accept ungoverned REST calls.`
444
+ }
445
+ ],
446
+ isError: true
447
+ };
448
+ }
449
+ return void 0;
450
+ }
451
+ try {
452
+ const result = await this.client.jsonRpc("tools/call", { name: binding.skill, arguments: toSkillArgs(args, binding) });
453
+ const decision = asGovernedDecision(result?.structuredContent);
454
+ if (decision) return renderDecision(decision, `${tool} executed.`);
455
+ return {
456
+ content: result?.content ?? [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }],
457
+ ...result?.isError === void 0 ? {} : { isError: result.isError }
458
+ };
459
+ } catch (err) {
460
+ return fail(err);
461
+ }
462
+ }
463
+ };
464
+
465
+ // src/tools/access.ts
466
+ var import_zod3 = require("zod");
467
+
468
+ // src/tools/_crud.ts
469
+ var import_zod2 = require("zod");
157
470
 
158
471
  // src/tools/path.ts
159
472
  var import_zod = require("zod");
@@ -240,8 +553,8 @@ function registerCrud(server, client, opts) {
240
553
  async (args) => {
241
554
  const id = String(args[idParam]);
242
555
  return run(async () => {
243
- await client.delete(`${basePath}/${encodePathSegment(id)}`);
244
- return okText(`${resource} "${id}" deleted.`);
556
+ const response = await client.delete(`${basePath}/${encodePathSegment(id)}`);
557
+ return okAfter(response, `${resource} "${id}" deleted.`);
245
558
  });
246
559
  }
247
560
  );
@@ -313,8 +626,8 @@ function registerAccessTools(server, client) {
313
626
  }
314
627
  },
315
628
  async ({ id, policyId }) => run(async () => {
316
- await client.delete(`/roles/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
317
- return okText(`Policy "${policyId}" detached from role "${id}".`);
629
+ const response = await client.delete(`/roles/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
630
+ return okAfter(response, `Policy "${policyId}" detached from role "${id}".`);
318
631
  })
319
632
  );
320
633
  server.registerTool(
@@ -336,8 +649,8 @@ function registerAccessTools(server, client) {
336
649
  }
337
650
  },
338
651
  async ({ id, userId }) => run(async () => {
339
- await client.delete(`/roles/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
340
- return okText(`User "${userId}" removed from role "${id}".`);
652
+ const response = await client.delete(`/roles/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
653
+ return okAfter(response, `User "${userId}" removed from role "${id}".`);
341
654
  })
342
655
  );
343
656
  registerCrud(server, client, {
@@ -379,8 +692,8 @@ function registerAccessTools(server, client) {
379
692
  }
380
693
  },
381
694
  async ({ id, permId }) => run(async () => {
382
- await client.delete(`/policies/${encodePathSegment(id)}/permissions/${encodePathSegment(permId)}`);
383
- return okText(`Permission "${permId}" deleted from policy "${id}".`);
695
+ const response = await client.delete(`/policies/${encodePathSegment(id)}/permissions/${encodePathSegment(permId)}`);
696
+ return okAfter(response, `Permission "${permId}" deleted from policy "${id}".`);
384
697
  })
385
698
  );
386
699
  server.registerTool(
@@ -407,8 +720,8 @@ function registerAccessTools(server, client) {
407
720
  }
408
721
  },
409
722
  async ({ id, userId }) => run(async () => {
410
- await client.delete(`/policies/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
411
- return okText(`Policy "${id}" detached from user "${userId}".`);
723
+ const response = await client.delete(`/policies/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
724
+ return okAfter(response, `Policy "${id}" detached from user "${userId}".`);
412
725
  })
413
726
  );
414
727
  server.registerTool(
@@ -516,8 +829,8 @@ function registerAdminTools(server, client) {
516
829
  inputSchema: { id: idPathSegmentSchema, confirm: import_zod4.z.literal(true).describe(confirmDescription) }
517
830
  },
518
831
  async ({ id }) => run(async () => {
519
- await client.delete(`/materialize/${encodePathSegment(id)}`);
520
- return okText(`Materialization "${id}" dropped.`);
832
+ const response = await client.delete(`/materialize/${encodePathSegment(id)}`);
833
+ return okAfter(response, `Materialization "${id}" dropped.`);
521
834
  })
522
835
  );
523
836
  }
@@ -693,8 +1006,8 @@ function registerApiKeyTools(server, client) {
693
1006
  }
694
1007
  },
695
1008
  async ({ id, roleId }) => run(async () => {
696
- await client.delete(`/api-keys/${encodePathSegment(id)}/roles/${encodePathSegment(roleId)}`);
697
- return okText(`Role "${roleId}" detached from API key "${id}".`);
1009
+ const response = await client.delete(`/api-keys/${encodePathSegment(id)}/roles/${encodePathSegment(roleId)}`);
1010
+ return okAfter(response, `Role "${roleId}" detached from API key "${id}".`);
698
1011
  })
699
1012
  );
700
1013
  server.registerTool(
@@ -721,8 +1034,8 @@ function registerApiKeyTools(server, client) {
721
1034
  }
722
1035
  },
723
1036
  async ({ id, policyId }) => run(async () => {
724
- await client.delete(`/api-keys/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
725
- return okText(`Policy "${policyId}" detached from API key "${id}".`);
1037
+ const response = await client.delete(`/api-keys/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
1038
+ return okAfter(response, `Policy "${policyId}" detached from API key "${id}".`);
726
1039
  })
727
1040
  );
728
1041
  }
@@ -914,8 +1227,8 @@ function registerCollectionTools(server, client) {
914
1227
  },
915
1228
  async ({ name, confirm: _ }) => {
916
1229
  try {
917
- await client.delete(`/collections/${encodePathSegment(name)}`);
918
- return { content: [{ type: "text", text: `Collection "${name}" deleted.` }] };
1230
+ const response = await client.delete(`/collections/${encodePathSegment(name)}`);
1231
+ return okAfter(response, `Collection "${name}" deleted.`);
919
1232
  } catch (err) {
920
1233
  return { content: [{ type: "text", text: `Error: ${formatError2(err)}` }], isError: true };
921
1234
  }
@@ -1071,8 +1384,8 @@ function registerContentConfigTools(server, client) {
1071
1384
  }
1072
1385
  },
1073
1386
  async ({ key }) => run(async () => {
1074
- await client.delete(`/settings/${encodePathSegment(key)}`);
1075
- return okText(`Setting "${key}" deleted.`);
1387
+ const response = await client.delete(`/settings/${encodePathSegment(key)}`);
1388
+ return okAfter(response, `Setting "${key}" deleted.`);
1076
1389
  })
1077
1390
  );
1078
1391
  }
@@ -1235,8 +1548,8 @@ function registerExtensionTools(server, client) {
1235
1548
  inputSchema: { id: idPathSegmentSchema, confirm: import_zod12.z.literal(true).describe(confirmDescription) }
1236
1549
  },
1237
1550
  async ({ id }) => run(async () => {
1238
- await client.delete(`/extensions/${encodePathSegment(id)}`);
1239
- return okText(`Extension "${id}" uninstalled.`);
1551
+ const response = await client.delete(`/extensions/${encodePathSegment(id)}`);
1552
+ return okAfter(response, `Extension "${id}" uninstalled.`);
1240
1553
  })
1241
1554
  );
1242
1555
  server.registerTool(
@@ -1392,12 +1705,10 @@ function registerFieldTools(server, client) {
1392
1705
  async ({ collection, field_name, force }) => {
1393
1706
  try {
1394
1707
  const qs = force ? "?force=true" : "";
1395
- await client.delete(
1708
+ const response = await client.delete(
1396
1709
  `/collections/${encodePathSegment(collection)}/fields/${encodePathSegment(field_name)}${qs}`
1397
1710
  );
1398
- return {
1399
- content: [{ type: "text", text: `Field "${field_name}" deleted from "${collection}".` }]
1400
- };
1711
+ return okAfter(response, `Field "${field_name}" deleted from "${collection}".`);
1401
1712
  } catch (err) {
1402
1713
  return { content: [{ type: "text", text: `Error: ${formatError3(err)}` }], isError: true };
1403
1714
  }
@@ -1564,7 +1875,7 @@ function registerItemTools(server, client) {
1564
1875
  async ({ collection, data: itemData, status }) => {
1565
1876
  try {
1566
1877
  const data = await client.post(`/items/${encodePathSegment(collection)}`, {
1567
- ...itemData,
1878
+ data: itemData,
1568
1879
  status
1569
1880
  });
1570
1881
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
@@ -1587,7 +1898,7 @@ function registerItemTools(server, client) {
1587
1898
  try {
1588
1899
  const data = await client.patch(
1589
1900
  `/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`,
1590
- itemData
1901
+ { data: itemData }
1591
1902
  );
1592
1903
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1593
1904
  } catch (err) {
@@ -1607,8 +1918,10 @@ function registerItemTools(server, client) {
1607
1918
  },
1608
1919
  async ({ collection, id }) => {
1609
1920
  try {
1610
- await client.delete(`/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`);
1611
- return { content: [{ type: "text", text: `Item "${id}" deleted from "${collection}".` }] };
1921
+ const response = await client.delete(
1922
+ `/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`
1923
+ );
1924
+ return okAfter(response, `Item "${id}" deleted from "${collection}".`);
1612
1925
  } catch (err) {
1613
1926
  return { content: [{ type: "text", text: `Error: ${formatError4(err)}` }], isError: true };
1614
1927
  }
@@ -1696,8 +2009,8 @@ function registerReleaseTools(server, client) {
1696
2009
  }
1697
2010
  },
1698
2011
  async ({ id }) => run(async () => {
1699
- await client.delete(`/releases/${encodePathSegment(id)}`);
1700
- return okText(`Release "${id}" deleted.`);
2012
+ const response = await client.delete(`/releases/${encodePathSegment(id)}`);
2013
+ return okAfter(response, `Release "${id}" deleted.`);
1701
2014
  })
1702
2015
  );
1703
2016
  }
@@ -1731,8 +2044,8 @@ function registerShareTools(server, client) {
1731
2044
  }
1732
2045
  },
1733
2046
  async ({ id }) => run(async () => {
1734
- await client.post(`/shares/${encodePathSegment(id)}/revoke`, {});
1735
- return okText(`Share link "${id}" revoked.`);
2047
+ const response = await client.post(`/shares/${encodePathSegment(id)}/revoke`, {});
2048
+ return okAfter(response, `Share link "${id}" revoked.`);
1736
2049
  })
1737
2050
  );
1738
2051
  }
@@ -1841,8 +2154,8 @@ function registerRelationTools(server, client) {
1841
2154
  }
1842
2155
  },
1843
2156
  async ({ id }) => run(async () => {
1844
- await client.delete(`/relations/${encodePathSegment(id)}`);
1845
- return okText(`Relation "${id}" deleted.`);
2157
+ const response = await client.delete(`/relations/${encodePathSegment(id)}`);
2158
+ return okAfter(response, `Relation "${id}" deleted.`);
1846
2159
  })
1847
2160
  );
1848
2161
  }
@@ -1887,8 +2200,8 @@ function registerSearchMediaTools(server, client) {
1887
2200
  }
1888
2201
  },
1889
2202
  async ({ key }) => run(async () => {
1890
- await client.delete(`/media/${encodeMediaKey(key)}`);
1891
- return okText(`Media asset "${key}" deleted.`);
2203
+ const response = await client.delete(`/media/${encodeMediaKey(key)}`);
2204
+ return okAfter(response, `Media asset "${key}" deleted.`);
1892
2205
  })
1893
2206
  );
1894
2207
  server.registerTool(
@@ -1984,8 +2297,8 @@ function registerTranslationMemoryTools(server, client) {
1984
2297
  }
1985
2298
  },
1986
2299
  async ({ id }) => run(async () => {
1987
- await client.delete(`/tm/${encodePathSegment(id)}`);
1988
- return okText(`Translation-memory entry "${id}" deleted.`);
2300
+ const response = await client.delete(`/tm/${encodePathSegment(id)}`);
2301
+ return okAfter(response, `Translation-memory entry "${id}" deleted.`);
1989
2302
  })
1990
2303
  );
1991
2304
  }
@@ -2033,8 +2346,8 @@ function registerUsersTeamsTools(server, client) {
2033
2346
  inputSchema: { id: idPathSegmentSchema, confirm: import_zod23.z.literal(true).describe(confirmDescription) }
2034
2347
  },
2035
2348
  async ({ id }) => run(async () => {
2036
- await client.delete(`/users/${encodePathSegment(id)}`);
2037
- return okText(`User "${id}" removed from the site.`);
2349
+ const response = await client.delete(`/users/${encodePathSegment(id)}`);
2350
+ return okAfter(response, `User "${id}" removed from the site.`);
2038
2351
  })
2039
2352
  );
2040
2353
  server.registerTool(
@@ -2074,8 +2387,8 @@ function registerUsersTeamsTools(server, client) {
2074
2387
  inputSchema: { id: idPathSegmentSchema, confirm: import_zod23.z.literal(true).describe(confirmDescription) }
2075
2388
  },
2076
2389
  async ({ id }) => run(async () => {
2077
- await client.delete(`/teams/${encodePathSegment(id)}`);
2078
- return okText(`Team "${id}" deleted.`);
2390
+ const response = await client.delete(`/teams/${encodePathSegment(id)}`);
2391
+ return okAfter(response, `Team "${id}" deleted.`);
2079
2392
  })
2080
2393
  );
2081
2394
  server.registerTool(
@@ -2102,8 +2415,8 @@ function registerUsersTeamsTools(server, client) {
2102
2415
  }
2103
2416
  },
2104
2417
  async ({ id, userId }) => run(async () => {
2105
- await client.delete(`/teams/${encodePathSegment(id)}/members/${encodePathSegment(userId)}`);
2106
- return okText(`User "${userId}" removed from team "${id}".`);
2418
+ const response = await client.delete(`/teams/${encodePathSegment(id)}/members/${encodePathSegment(userId)}`);
2419
+ return okAfter(response, `User "${userId}" removed from team "${id}".`);
2107
2420
  })
2108
2421
  );
2109
2422
  }
@@ -2132,7 +2445,33 @@ function registerWebhookTools(server, client) {
2132
2445
  }
2133
2446
 
2134
2447
  // src/tools/index.ts
2135
- function registerAllTools(server, client) {
2448
+ function registerAllTools(server, client, options = {}) {
2449
+ const dispatcher = options.dispatcher === void 0 ? new GovernedDispatcher(client) : options.dispatcher;
2450
+ const target = dispatcher?.enabled ? withGovernedHandlers(server, dispatcher) : server;
2451
+ registerModules(target, client);
2452
+ }
2453
+ function withGovernedHandlers(server, dispatcher) {
2454
+ return new Proxy(server, {
2455
+ get(t, prop, receiver) {
2456
+ if (prop !== "registerTool") return Reflect.get(t, prop, receiver);
2457
+ return (name, config, handler) => {
2458
+ const binding = GOVERNED_TOOLS[name];
2459
+ let wrapped = handler;
2460
+ if (binding) {
2461
+ wrapped = async (args) => await dispatcher.dispatch(name, args, binding) ?? await handler(args);
2462
+ } else if (dispatcher.requiresGovernance && isMutationTool(name)) {
2463
+ wrapped = async () => dispatcher.refuseUngoverned(name);
2464
+ }
2465
+ return t.registerTool(
2466
+ name,
2467
+ config,
2468
+ wrapped
2469
+ );
2470
+ };
2471
+ }
2472
+ });
2473
+ }
2474
+ function registerModules(server, client) {
2136
2475
  registerCollectionTools(server, client);
2137
2476
  registerFieldTools(server, client);
2138
2477
  registerItemTools(server, client);
package/dist/index.js CHANGED
@@ -15,6 +15,12 @@ var LumiBaseApiError = class extends Error {
15
15
  status;
16
16
  errors;
17
17
  };
18
+ var McpUnavailableError = class extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "McpUnavailableError";
22
+ }
23
+ };
18
24
  var LumiBaseClient = class {
19
25
  baseUrl;
20
26
  origin;
@@ -56,6 +62,40 @@ var LumiBaseClient = class {
56
62
  delete(path) {
57
63
  return this.request("DELETE", path);
58
64
  }
65
+ /**
66
+ * Sends a JSON-RPC request to the governed MCP endpoint (`POST /api/v1/mcp`).
67
+ *
68
+ * Deliberately NOT built on `request()`. That helper returns `json.data`,
69
+ * which is the REST envelope — a JSON-RPC response carries `result` / `error`
70
+ * at the top level and has no `data` key at all, so routing this through
71
+ * `request()` would quietly resolve to `undefined` for every call. The bug
72
+ * would look like "governance returned nothing" rather than "we read the wrong
73
+ * field".
74
+ *
75
+ * @throws {McpUnavailableError} when the site has not enabled `contentOs.mcp`.
76
+ * @throws {LumiBaseApiError} for transport/HTTP failures and JSON-RPC errors.
77
+ */
78
+ async jsonRpc(method, params) {
79
+ const res = await fetch(`${this.baseUrl}/mcp`, {
80
+ method: "POST",
81
+ headers: this.headers,
82
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...params ? { params } : {} })
83
+ });
84
+ const body = await res.json().catch(() => null);
85
+ if (!res.ok) {
86
+ const errors = body?.errors ?? [{ code: "UNKNOWN", message: `HTTP ${res.status}` }];
87
+ if (errors.some((e) => e.code === "MCP_DISABLED")) {
88
+ throw new McpUnavailableError(errors[0]?.message ?? "MCP endpoint disabled");
89
+ }
90
+ throw new LumiBaseApiError(res.status, errors);
91
+ }
92
+ if (body?.error) {
93
+ throw new LumiBaseApiError(res.status, [
94
+ { code: `JSONRPC_${body.error.code}`, message: body.error.message }
95
+ ]);
96
+ }
97
+ return body?.result;
98
+ }
59
99
  /**
60
100
  * GET a root-level (non-`/api/v1`) endpoint such as `/health` or `/metrics`
61
101
  * and return the raw response body as text. These endpoints are not
@@ -108,12 +148,6 @@ function configFromEnv() {
108
148
  return { url, siteId, token };
109
149
  }
110
150
 
111
- // src/tools/access.ts
112
- import { z as z3 } from "zod";
113
-
114
- // src/tools/_crud.ts
115
- import { z as z2 } from "zod";
116
-
117
151
  // src/tools/_shared.ts
118
152
  function formatError(err) {
119
153
  if (err instanceof LumiBaseApiError) {
@@ -153,6 +187,285 @@ async function run(fn) {
153
187
  }
154
188
  }
155
189
  var confirmDescription = "Must be true to confirm the destructive operation";
190
+ var DECISION_STATUSES = /* @__PURE__ */ new Set(["executed", "pending_approval", "denied"]);
191
+ function asGovernedDecision(value) {
192
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
193
+ const status = value.status;
194
+ if (typeof status !== "string" || !DECISION_STATUSES.has(status)) return void 0;
195
+ return value;
196
+ }
197
+ function renderDecision(decision, executedText) {
198
+ if (decision.status === "executed") {
199
+ return decision.data === void 0 ? okText(executedText) : ok(decision.data);
200
+ }
201
+ if (decision.status === "pending_approval") {
202
+ const id = decision.agentApprovalId ?? decision.approvalId;
203
+ const space = decision.approvalSpace ?? (decision.agentApprovalId ? "agent" : id ? "legacy_ai" : void 0);
204
+ const endpoint = space === "agent" ? "POST /api/v1/agent/approvals/{approvalId}/decide" : space === "legacy_ai" ? "POST /api/v1/ai/approvals/{approvalId}/decide" : void 0;
205
+ const lines = [
206
+ "Not executed \u2014 pending approval.",
207
+ ...id ? [`Approval id: ${id}`] : [],
208
+ ...endpoint ? [`Decide at: ${endpoint}`] : [],
209
+ ...decision.runId ? [`Run: ${decision.runId}`] : [],
210
+ ...decision.message ? [decision.message] : []
211
+ ];
212
+ return okText(lines.join("\n"));
213
+ }
214
+ const reason = decision.code ? `[${decision.code}] ` : "";
215
+ return {
216
+ content: [{ type: "text", text: `Not executed \u2014 denied. ${reason}${decision.message ?? ""}`.trim() }],
217
+ isError: true
218
+ };
219
+ }
220
+ function okAfter(response, executedText) {
221
+ const decision = asGovernedDecision(response);
222
+ return decision === void 0 ? okText(executedText) : renderDecision(decision, executedText);
223
+ }
224
+
225
+ // src/governed.ts
226
+ var GOVERNED_TOOLS = {
227
+ // ── items ────────────────────────────────────────────────────────────────
228
+ create_item: { skill: "createItem" },
229
+ update_item: { skill: "updateItem" },
230
+ delete_item: { skill: "deleteItem" },
231
+ // ── schema ───────────────────────────────────────────────────────────────
232
+ delete_collection: { skill: "deleteCollection" },
233
+ // `field_name` is this transport's name for the field; the skill reads `name`.
234
+ // Renamed explicitly rather than dropped (see repro R17).
235
+ delete_field: { skill: "deleteField", rename: { field_name: "name" } },
236
+ create_relation: { skill: "createRelation" },
237
+ delete_relation: { skill: "deleteRelation" },
238
+ // ── access ───────────────────────────────────────────────────────────────
239
+ delete_role: { skill: "deleteRole" },
240
+ delete_policy: { skill: "deletePolicy" },
241
+ // ── automation ───────────────────────────────────────────────────────────
242
+ delete_flow: { skill: "deleteFlow" },
243
+ run_flow: { skill: "runFlow" },
244
+ create_intent: { skill: "createIntent" },
245
+ delete_intent: { skill: "deleteIntent" },
246
+ // ── config ───────────────────────────────────────────────────────────────
247
+ upsert_setting: { skill: "upsertSetting" },
248
+ delete_setting: { skill: "deleteSetting" },
249
+ create_translation: { skill: "createTranslation" },
250
+ update_translation: { skill: "updateTranslation" },
251
+ delete_translation: { skill: "deleteTranslation" },
252
+ create_webhook: { skill: "createWebhook" },
253
+ update_webhook: { skill: "updateWebhook" },
254
+ delete_webhook: { skill: "deleteWebhook" },
255
+ // ── api keys / users / teams ──────────────────────────────────────────────
256
+ create_api_key: { skill: "createApiKey" },
257
+ rotate_api_key: { skill: "rotateApiKey" },
258
+ revoke_api_key: { skill: "revokeApiKey" },
259
+ invite_user: { skill: "inviteUser" },
260
+ update_user: { skill: "updateUser" },
261
+ remove_user: { skill: "removeUser" },
262
+ create_team: { skill: "createTeam" },
263
+ delete_team: { skill: "deleteTeam" },
264
+ // The team tools take the team as `id`; the skill names it `teamId` because it
265
+ // also takes a `userId` and one bare `id` would be ambiguous.
266
+ add_team_member: { skill: "addTeamMember", rename: { id: "teamId" } },
267
+ remove_team_member: { skill: "removeTeamMember", rename: { id: "teamId" } },
268
+ // ── extensions ───────────────────────────────────────────────────────────
269
+ uninstall_extension: { skill: "uninstallExtension" },
270
+ // ── cdc ──────────────────────────────────────────────────────────────────
271
+ // The change-feed skills name the subscription `subscriptionId` (as
272
+ // `getCdcSubscriptionStatus`/`replayCdcSubscription` do); the CRUD-generated
273
+ // stdio tool takes it as `id`, like every other `delete_*` tool.
274
+ // Known divergence, not a skipped gate: the harness builds `SubscriptionService`
275
+ // without `cache`/`audit`, so REST's `cdc_subscription_deleted` audit-log row
276
+ // and feed-flag cache eviction do not happen on this path. The run, tool-call
277
+ // and approval rows record the deletion instead, and the flag cache expires on
278
+ // its own TTL.
279
+ delete_cdc_subscription: { skill: "deleteCdcSubscription", rename: { id: "subscriptionId" } }
280
+ };
281
+ var UNGOVERNED_MUTATIONS = {
282
+ // Skill exists, but the canonical contract is narrower than what this tool
283
+ // advertises. Routing now would reject arguments callers legitimately send.
284
+ create_collection: "contract-narrower-than-tool: 16 advertised properties have no canonical counterpart",
285
+ create_policy: "contract-narrower-than-tool: enforceTfa/ipAllow/ipDeny/validFrom/validUntil",
286
+ create_role: "contract-narrower-than-tool: systemKey",
287
+ cdc_subscription_replay: "contract-narrower-than-tool: cursor has no canonical counterpart",
288
+ create_cdc_subscription: "contract-narrower-than-tool: payload_mode \u2014 the createCdcSubscription handler never forwards it, so a snapshot subscription would be created as reference",
289
+ // Skill and canonical contract both exist and accept the advertised arguments,
290
+ // but the skill's handler skips checks the REST route applies. Routing would
291
+ // add HITL and remove those checks, so these stay on REST until the handler
292
+ // carries them.
293
+ create_flow: "skill-weaker-than-rest: createFlow skips the active-graph validation and schedule-cron check of POST /flows and never sets nextRunAt, so an active schedule flow would never fire",
294
+ install_extension: "skill-weaker-than-rest: installExtension skips the bundle signature check, the reserved lumibase-* namespace check and the per-action extensions:* permission probes of POST /extensions",
295
+ update_extension: "skill-weaker-than-rest: updateExtension skips the per-action extensions:* permission probes, the unverified-official enable refusal, sandbox cache eviction and CDC subscription sync of PATCH /extensions/:id",
296
+ // No skill at all: nothing to route to. Listed so the set is closed.
297
+ add_policy_permission: "no-skill",
298
+ apply_access_import: "no-skill",
299
+ apply_schema: "no-skill",
300
+ approve_content: "no-skill",
301
+ assign_role_user: "no-skill",
302
+ attach_api_key_policy: "no-skill",
303
+ attach_api_key_role: "no-skill",
304
+ attach_policy_user: "no-skill",
305
+ attach_role_policy: "no-skill",
306
+ compile_intent: "no-skill",
307
+ create_preset: "no-skill",
308
+ create_release: "no-skill",
309
+ create_share: "no-skill",
310
+ delete_media: "no-skill",
311
+ delete_policy_permission: "no-skill",
312
+ delete_preset: "no-skill",
313
+ delete_release: "no-skill",
314
+ delete_tm: "no-skill",
315
+ detach_api_key_policy: "no-skill",
316
+ detach_api_key_role: "no-skill",
317
+ detach_policy_user: "no-skill",
318
+ detach_role_policy: "no-skill",
319
+ drop_materialization: "no-skill",
320
+ install_marketplace_extension: "no-skill",
321
+ publish_extension: "no-skill",
322
+ publish_release: "no-skill",
323
+ refresh_materialization: "no-skill",
324
+ reject_content: "no-skill",
325
+ remove_role_user: "no-skill",
326
+ restore_backup: "no-skill",
327
+ revoke_share: "no-skill",
328
+ run_panel: "no-skill",
329
+ submit_review: "no-skill",
330
+ translate_text: "no-skill",
331
+ update_cdc_subscription: "no-skill",
332
+ update_collection: "no-skill",
333
+ update_flow: "no-skill",
334
+ update_intent: "no-skill",
335
+ update_policy: "no-skill",
336
+ update_policy_permission: "no-skill",
337
+ update_preset: "no-skill",
338
+ update_release: "no-skill",
339
+ update_role: "no-skill",
340
+ update_team: "no-skill",
341
+ update_tm: "no-skill",
342
+ upsert_field: "no-skill",
343
+ upsert_tm: "no-skill"
344
+ };
345
+ var MUTATION_VERB = /^(create|update|delete|remove|upsert|set|add|attach|detach|revoke|rotate|invite|install|uninstall|enable|disable|publish|unpublish|promote|apply|run|trigger|restore|replay|drop|materialize|reset|assign|unassign|approve|reject|submit|claim|decide|import|veto|freeze|lift|seed|sync|purge|bump|stage|commit|schedule|cancel|retry|archive|clone|duplicate|move|rename|reorder|translate|compile|generate|refresh|configure)/;
346
+ var MUTATION_EXCEPTIONS = /* @__PURE__ */ new Set(["cdc_subscription_replay"]);
347
+ function isMutationTool(name) {
348
+ return MUTATION_EXCEPTIONS.has(name) || MUTATION_VERB.test(name);
349
+ }
350
+ var PROMPT_ONLY_ARGS = /* @__PURE__ */ new Set(["confirm"]);
351
+ var camel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
352
+ function toSkillArgs(args, binding) {
353
+ const out = {};
354
+ for (const [key, value] of Object.entries(args)) {
355
+ if (PROMPT_ONLY_ARGS.has(key)) continue;
356
+ if (value === void 0) continue;
357
+ out[binding.rename?.[key] ?? camel(key)] = value;
358
+ }
359
+ return out;
360
+ }
361
+ function governedModeFromEnv(env = process.env) {
362
+ const raw = (env["LUMIBASE_MCP_GOVERNED"] ?? "auto").toLowerCase();
363
+ if (raw === "true" || raw === "on" || raw === "1") return "on";
364
+ if (raw === "false" || raw === "off" || raw === "0") return "off";
365
+ return "auto";
366
+ }
367
+ var GovernedDispatcher = class {
368
+ constructor(client, options = {}) {
369
+ this.client = client;
370
+ this.mode = options.mode ?? governedModeFromEnv();
371
+ this.warn = options.warn ?? ((message) => console.error(message));
372
+ }
373
+ client;
374
+ mode;
375
+ warn;
376
+ available;
377
+ get enabled() {
378
+ return this.mode !== "off";
379
+ }
380
+ /**
381
+ * True when this deployment requires every mutation to go through governance.
382
+ *
383
+ * Read by the registration wrapper to refuse mutations that have no governed
384
+ * mapping. Without it, mode `on` only governed the 27 mapped tools and left the
385
+ * rest on REST — so the setting that exists to guarantee governance did not,
386
+ * and the guarantee failed silently for exactly the calls nobody had mapped
387
+ * yet.
388
+ */
389
+ get requiresGovernance() {
390
+ return this.mode === "on";
391
+ }
392
+ /**
393
+ * The refusal returned for a mutation that cannot be governed.
394
+ *
395
+ * Names the reason from {@link UNGOVERNED_MUTATIONS} when there is one, so the
396
+ * operator can tell "we know about this gap" from "nobody classified this tool".
397
+ */
398
+ refuseUngoverned(tool) {
399
+ const reason = UNGOVERNED_MUTATIONS[tool];
400
+ return {
401
+ content: [
402
+ {
403
+ type: "text",
404
+ text: `Refused: "${tool}" changes state but has no governed mapping, and LUMIBASE_MCP_GOVERNED=on requires every mutation to run through the agent harness (autonomy levels, HITL approval, kill switch, run audit).
405
+ ` + (reason ? `Known gap: ${reason}.
406
+ ` : "This tool is in neither the governed nor the declared-ungoverned table, which means it was added without a governance decision.\n") + "Set LUMIBASE_MCP_GOVERNED=auto or off to accept ungoverned REST calls for it."
407
+ }
408
+ ],
409
+ isError: true
410
+ };
411
+ }
412
+ /** True when the governed endpoint answered a probe. */
413
+ probe() {
414
+ this.available ??= (async () => {
415
+ try {
416
+ await this.client.jsonRpc("tools/list");
417
+ return true;
418
+ } catch (err) {
419
+ if (this.mode === "on") return false;
420
+ this.warn(
421
+ err instanceof McpUnavailableError ? "[lumibase-mcp] governed tool calls unavailable: this site has contentOs.mcp disabled. Falling back to direct REST calls, which skip agent governance (autonomy levels, HITL approval, kill switch, run audit). Set LUMIBASE_MCP_GOVERNED=on to refuse instead of falling back." : `[lumibase-mcp] governed endpoint probe failed (${err instanceof Error ? err.message : String(err)}); falling back to direct REST calls, which skip agent governance. Set LUMIBASE_MCP_GOVERNED=on to refuse instead of falling back.`
422
+ );
423
+ return false;
424
+ }
425
+ })();
426
+ return this.available;
427
+ }
428
+ /**
429
+ * Runs `tool` through the harness.
430
+ *
431
+ * @returns the rendered tool result, or `undefined` when the caller should run
432
+ * its own REST handler instead (mode `auto` with governance unavailable).
433
+ */
434
+ async dispatch(tool, args, binding) {
435
+ if (!this.enabled) return void 0;
436
+ if (!await this.probe()) {
437
+ if (this.mode === "on") {
438
+ return {
439
+ content: [
440
+ {
441
+ type: "text",
442
+ text: `Refused: "${tool}" must run through the governed harness, but this site has contentOs.mcp disabled. Enable it, or set LUMIBASE_MCP_GOVERNED=off to accept ungoverned REST calls.`
443
+ }
444
+ ],
445
+ isError: true
446
+ };
447
+ }
448
+ return void 0;
449
+ }
450
+ try {
451
+ const result = await this.client.jsonRpc("tools/call", { name: binding.skill, arguments: toSkillArgs(args, binding) });
452
+ const decision = asGovernedDecision(result?.structuredContent);
453
+ if (decision) return renderDecision(decision, `${tool} executed.`);
454
+ return {
455
+ content: result?.content ?? [{ type: "text", text: JSON.stringify(result ?? null, null, 2) }],
456
+ ...result?.isError === void 0 ? {} : { isError: result.isError }
457
+ };
458
+ } catch (err) {
459
+ return fail(err);
460
+ }
461
+ }
462
+ };
463
+
464
+ // src/tools/access.ts
465
+ import { z as z3 } from "zod";
466
+
467
+ // src/tools/_crud.ts
468
+ import { z as z2 } from "zod";
156
469
 
157
470
  // src/tools/path.ts
158
471
  import { z } from "zod";
@@ -239,8 +552,8 @@ function registerCrud(server, client, opts) {
239
552
  async (args) => {
240
553
  const id = String(args[idParam]);
241
554
  return run(async () => {
242
- await client.delete(`${basePath}/${encodePathSegment(id)}`);
243
- return okText(`${resource} "${id}" deleted.`);
555
+ const response = await client.delete(`${basePath}/${encodePathSegment(id)}`);
556
+ return okAfter(response, `${resource} "${id}" deleted.`);
244
557
  });
245
558
  }
246
559
  );
@@ -312,8 +625,8 @@ function registerAccessTools(server, client) {
312
625
  }
313
626
  },
314
627
  async ({ id, policyId }) => run(async () => {
315
- await client.delete(`/roles/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
316
- return okText(`Policy "${policyId}" detached from role "${id}".`);
628
+ const response = await client.delete(`/roles/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
629
+ return okAfter(response, `Policy "${policyId}" detached from role "${id}".`);
317
630
  })
318
631
  );
319
632
  server.registerTool(
@@ -335,8 +648,8 @@ function registerAccessTools(server, client) {
335
648
  }
336
649
  },
337
650
  async ({ id, userId }) => run(async () => {
338
- await client.delete(`/roles/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
339
- return okText(`User "${userId}" removed from role "${id}".`);
651
+ const response = await client.delete(`/roles/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
652
+ return okAfter(response, `User "${userId}" removed from role "${id}".`);
340
653
  })
341
654
  );
342
655
  registerCrud(server, client, {
@@ -378,8 +691,8 @@ function registerAccessTools(server, client) {
378
691
  }
379
692
  },
380
693
  async ({ id, permId }) => run(async () => {
381
- await client.delete(`/policies/${encodePathSegment(id)}/permissions/${encodePathSegment(permId)}`);
382
- return okText(`Permission "${permId}" deleted from policy "${id}".`);
694
+ const response = await client.delete(`/policies/${encodePathSegment(id)}/permissions/${encodePathSegment(permId)}`);
695
+ return okAfter(response, `Permission "${permId}" deleted from policy "${id}".`);
383
696
  })
384
697
  );
385
698
  server.registerTool(
@@ -406,8 +719,8 @@ function registerAccessTools(server, client) {
406
719
  }
407
720
  },
408
721
  async ({ id, userId }) => run(async () => {
409
- await client.delete(`/policies/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
410
- return okText(`Policy "${id}" detached from user "${userId}".`);
722
+ const response = await client.delete(`/policies/${encodePathSegment(id)}/users/${encodePathSegment(userId)}`);
723
+ return okAfter(response, `Policy "${id}" detached from user "${userId}".`);
411
724
  })
412
725
  );
413
726
  server.registerTool(
@@ -515,8 +828,8 @@ function registerAdminTools(server, client) {
515
828
  inputSchema: { id: idPathSegmentSchema, confirm: z4.literal(true).describe(confirmDescription) }
516
829
  },
517
830
  async ({ id }) => run(async () => {
518
- await client.delete(`/materialize/${encodePathSegment(id)}`);
519
- return okText(`Materialization "${id}" dropped.`);
831
+ const response = await client.delete(`/materialize/${encodePathSegment(id)}`);
832
+ return okAfter(response, `Materialization "${id}" dropped.`);
520
833
  })
521
834
  );
522
835
  }
@@ -692,8 +1005,8 @@ function registerApiKeyTools(server, client) {
692
1005
  }
693
1006
  },
694
1007
  async ({ id, roleId }) => run(async () => {
695
- await client.delete(`/api-keys/${encodePathSegment(id)}/roles/${encodePathSegment(roleId)}`);
696
- return okText(`Role "${roleId}" detached from API key "${id}".`);
1008
+ const response = await client.delete(`/api-keys/${encodePathSegment(id)}/roles/${encodePathSegment(roleId)}`);
1009
+ return okAfter(response, `Role "${roleId}" detached from API key "${id}".`);
697
1010
  })
698
1011
  );
699
1012
  server.registerTool(
@@ -720,8 +1033,8 @@ function registerApiKeyTools(server, client) {
720
1033
  }
721
1034
  },
722
1035
  async ({ id, policyId }) => run(async () => {
723
- await client.delete(`/api-keys/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
724
- return okText(`Policy "${policyId}" detached from API key "${id}".`);
1036
+ const response = await client.delete(`/api-keys/${encodePathSegment(id)}/policies/${encodePathSegment(policyId)}`);
1037
+ return okAfter(response, `Policy "${policyId}" detached from API key "${id}".`);
725
1038
  })
726
1039
  );
727
1040
  }
@@ -913,8 +1226,8 @@ function registerCollectionTools(server, client) {
913
1226
  },
914
1227
  async ({ name, confirm: _ }) => {
915
1228
  try {
916
- await client.delete(`/collections/${encodePathSegment(name)}`);
917
- return { content: [{ type: "text", text: `Collection "${name}" deleted.` }] };
1229
+ const response = await client.delete(`/collections/${encodePathSegment(name)}`);
1230
+ return okAfter(response, `Collection "${name}" deleted.`);
918
1231
  } catch (err) {
919
1232
  return { content: [{ type: "text", text: `Error: ${formatError2(err)}` }], isError: true };
920
1233
  }
@@ -1070,8 +1383,8 @@ function registerContentConfigTools(server, client) {
1070
1383
  }
1071
1384
  },
1072
1385
  async ({ key }) => run(async () => {
1073
- await client.delete(`/settings/${encodePathSegment(key)}`);
1074
- return okText(`Setting "${key}" deleted.`);
1386
+ const response = await client.delete(`/settings/${encodePathSegment(key)}`);
1387
+ return okAfter(response, `Setting "${key}" deleted.`);
1075
1388
  })
1076
1389
  );
1077
1390
  }
@@ -1234,8 +1547,8 @@ function registerExtensionTools(server, client) {
1234
1547
  inputSchema: { id: idPathSegmentSchema, confirm: z12.literal(true).describe(confirmDescription) }
1235
1548
  },
1236
1549
  async ({ id }) => run(async () => {
1237
- await client.delete(`/extensions/${encodePathSegment(id)}`);
1238
- return okText(`Extension "${id}" uninstalled.`);
1550
+ const response = await client.delete(`/extensions/${encodePathSegment(id)}`);
1551
+ return okAfter(response, `Extension "${id}" uninstalled.`);
1239
1552
  })
1240
1553
  );
1241
1554
  server.registerTool(
@@ -1391,12 +1704,10 @@ function registerFieldTools(server, client) {
1391
1704
  async ({ collection, field_name, force }) => {
1392
1705
  try {
1393
1706
  const qs = force ? "?force=true" : "";
1394
- await client.delete(
1707
+ const response = await client.delete(
1395
1708
  `/collections/${encodePathSegment(collection)}/fields/${encodePathSegment(field_name)}${qs}`
1396
1709
  );
1397
- return {
1398
- content: [{ type: "text", text: `Field "${field_name}" deleted from "${collection}".` }]
1399
- };
1710
+ return okAfter(response, `Field "${field_name}" deleted from "${collection}".`);
1400
1711
  } catch (err) {
1401
1712
  return { content: [{ type: "text", text: `Error: ${formatError3(err)}` }], isError: true };
1402
1713
  }
@@ -1563,7 +1874,7 @@ function registerItemTools(server, client) {
1563
1874
  async ({ collection, data: itemData, status }) => {
1564
1875
  try {
1565
1876
  const data = await client.post(`/items/${encodePathSegment(collection)}`, {
1566
- ...itemData,
1877
+ data: itemData,
1567
1878
  status
1568
1879
  });
1569
1880
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
@@ -1586,7 +1897,7 @@ function registerItemTools(server, client) {
1586
1897
  try {
1587
1898
  const data = await client.patch(
1588
1899
  `/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`,
1589
- itemData
1900
+ { data: itemData }
1590
1901
  );
1591
1902
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1592
1903
  } catch (err) {
@@ -1606,8 +1917,10 @@ function registerItemTools(server, client) {
1606
1917
  },
1607
1918
  async ({ collection, id }) => {
1608
1919
  try {
1609
- await client.delete(`/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`);
1610
- return { content: [{ type: "text", text: `Item "${id}" deleted from "${collection}".` }] };
1920
+ const response = await client.delete(
1921
+ `/items/${encodePathSegment(collection)}/${encodePathSegment(id)}`
1922
+ );
1923
+ return okAfter(response, `Item "${id}" deleted from "${collection}".`);
1611
1924
  } catch (err) {
1612
1925
  return { content: [{ type: "text", text: `Error: ${formatError4(err)}` }], isError: true };
1613
1926
  }
@@ -1695,8 +2008,8 @@ function registerReleaseTools(server, client) {
1695
2008
  }
1696
2009
  },
1697
2010
  async ({ id }) => run(async () => {
1698
- await client.delete(`/releases/${encodePathSegment(id)}`);
1699
- return okText(`Release "${id}" deleted.`);
2011
+ const response = await client.delete(`/releases/${encodePathSegment(id)}`);
2012
+ return okAfter(response, `Release "${id}" deleted.`);
1700
2013
  })
1701
2014
  );
1702
2015
  }
@@ -1730,8 +2043,8 @@ function registerShareTools(server, client) {
1730
2043
  }
1731
2044
  },
1732
2045
  async ({ id }) => run(async () => {
1733
- await client.post(`/shares/${encodePathSegment(id)}/revoke`, {});
1734
- return okText(`Share link "${id}" revoked.`);
2046
+ const response = await client.post(`/shares/${encodePathSegment(id)}/revoke`, {});
2047
+ return okAfter(response, `Share link "${id}" revoked.`);
1735
2048
  })
1736
2049
  );
1737
2050
  }
@@ -1840,8 +2153,8 @@ function registerRelationTools(server, client) {
1840
2153
  }
1841
2154
  },
1842
2155
  async ({ id }) => run(async () => {
1843
- await client.delete(`/relations/${encodePathSegment(id)}`);
1844
- return okText(`Relation "${id}" deleted.`);
2156
+ const response = await client.delete(`/relations/${encodePathSegment(id)}`);
2157
+ return okAfter(response, `Relation "${id}" deleted.`);
1845
2158
  })
1846
2159
  );
1847
2160
  }
@@ -1886,8 +2199,8 @@ function registerSearchMediaTools(server, client) {
1886
2199
  }
1887
2200
  },
1888
2201
  async ({ key }) => run(async () => {
1889
- await client.delete(`/media/${encodeMediaKey(key)}`);
1890
- return okText(`Media asset "${key}" deleted.`);
2202
+ const response = await client.delete(`/media/${encodeMediaKey(key)}`);
2203
+ return okAfter(response, `Media asset "${key}" deleted.`);
1891
2204
  })
1892
2205
  );
1893
2206
  server.registerTool(
@@ -1983,8 +2296,8 @@ function registerTranslationMemoryTools(server, client) {
1983
2296
  }
1984
2297
  },
1985
2298
  async ({ id }) => run(async () => {
1986
- await client.delete(`/tm/${encodePathSegment(id)}`);
1987
- return okText(`Translation-memory entry "${id}" deleted.`);
2299
+ const response = await client.delete(`/tm/${encodePathSegment(id)}`);
2300
+ return okAfter(response, `Translation-memory entry "${id}" deleted.`);
1988
2301
  })
1989
2302
  );
1990
2303
  }
@@ -2032,8 +2345,8 @@ function registerUsersTeamsTools(server, client) {
2032
2345
  inputSchema: { id: idPathSegmentSchema, confirm: z23.literal(true).describe(confirmDescription) }
2033
2346
  },
2034
2347
  async ({ id }) => run(async () => {
2035
- await client.delete(`/users/${encodePathSegment(id)}`);
2036
- return okText(`User "${id}" removed from the site.`);
2348
+ const response = await client.delete(`/users/${encodePathSegment(id)}`);
2349
+ return okAfter(response, `User "${id}" removed from the site.`);
2037
2350
  })
2038
2351
  );
2039
2352
  server.registerTool(
@@ -2073,8 +2386,8 @@ function registerUsersTeamsTools(server, client) {
2073
2386
  inputSchema: { id: idPathSegmentSchema, confirm: z23.literal(true).describe(confirmDescription) }
2074
2387
  },
2075
2388
  async ({ id }) => run(async () => {
2076
- await client.delete(`/teams/${encodePathSegment(id)}`);
2077
- return okText(`Team "${id}" deleted.`);
2389
+ const response = await client.delete(`/teams/${encodePathSegment(id)}`);
2390
+ return okAfter(response, `Team "${id}" deleted.`);
2078
2391
  })
2079
2392
  );
2080
2393
  server.registerTool(
@@ -2101,8 +2414,8 @@ function registerUsersTeamsTools(server, client) {
2101
2414
  }
2102
2415
  },
2103
2416
  async ({ id, userId }) => run(async () => {
2104
- await client.delete(`/teams/${encodePathSegment(id)}/members/${encodePathSegment(userId)}`);
2105
- return okText(`User "${userId}" removed from team "${id}".`);
2417
+ const response = await client.delete(`/teams/${encodePathSegment(id)}/members/${encodePathSegment(userId)}`);
2418
+ return okAfter(response, `User "${userId}" removed from team "${id}".`);
2106
2419
  })
2107
2420
  );
2108
2421
  }
@@ -2131,7 +2444,33 @@ function registerWebhookTools(server, client) {
2131
2444
  }
2132
2445
 
2133
2446
  // src/tools/index.ts
2134
- function registerAllTools(server, client) {
2447
+ function registerAllTools(server, client, options = {}) {
2448
+ const dispatcher = options.dispatcher === void 0 ? new GovernedDispatcher(client) : options.dispatcher;
2449
+ const target = dispatcher?.enabled ? withGovernedHandlers(server, dispatcher) : server;
2450
+ registerModules(target, client);
2451
+ }
2452
+ function withGovernedHandlers(server, dispatcher) {
2453
+ return new Proxy(server, {
2454
+ get(t, prop, receiver) {
2455
+ if (prop !== "registerTool") return Reflect.get(t, prop, receiver);
2456
+ return (name, config, handler) => {
2457
+ const binding = GOVERNED_TOOLS[name];
2458
+ let wrapped = handler;
2459
+ if (binding) {
2460
+ wrapped = async (args) => await dispatcher.dispatch(name, args, binding) ?? await handler(args);
2461
+ } else if (dispatcher.requiresGovernance && isMutationTool(name)) {
2462
+ wrapped = async () => dispatcher.refuseUngoverned(name);
2463
+ }
2464
+ return t.registerTool(
2465
+ name,
2466
+ config,
2467
+ wrapped
2468
+ );
2469
+ };
2470
+ }
2471
+ });
2472
+ }
2473
+ function registerModules(server, client) {
2135
2474
  registerCollectionTools(server, client);
2136
2475
  registerFieldTools(server, client);
2137
2476
  registerItemTools(server, client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumibase/mcp-server",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.3",
4
4
  "description": "LumiBase MCP server — lets AI assistants manage the full LumiBase Content OS: collections, fields, items, relations, RBAC (roles/policies/permissions/API keys), users & teams, intents, flows, webhooks, translations & translation memory, search, media & transform presets, read-only insights, presets, editorial workflow, content releases, share links, read-only deployments, extensions, and site administration.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,13 +27,13 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.30.0",
30
- "zod": "^4.4.3"
30
+ "zod": "^4.6.5"
31
31
  },
32
32
  "devDependencies": {
33
- "@types/node": "^26.4.0",
33
+ "@types/node": "^26.6.2",
34
34
  "tsup": "^8.5.1",
35
35
  "typescript": "^5.6.2",
36
- "vitest": "^4.1.11"
36
+ "vitest": "^5.0.1"
37
37
  },
38
38
  "files": [
39
39
  "dist"