@layers/amba-mcp 4.0.4 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -310,7 +310,13 @@ const READ_VERBS = new Set([
310
310
  "logs",
311
311
  "query",
312
312
  "aggregate",
313
- "results"
313
+ "results",
314
+ "explain",
315
+ "plan",
316
+ "diff",
317
+ "drift",
318
+ "balance",
319
+ "payouts"
314
320
  ]);
315
321
  /**
316
322
  * Destructive verbs. A tool whose name contains one of these MAY perform
@@ -367,10 +373,15 @@ const WRITE_VERBS = new Set([
367
373
  "insert",
368
374
  "invite",
369
375
  "register",
376
+ "define",
377
+ "map",
378
+ "adopt",
370
379
  "purchase",
371
380
  "signup",
372
381
  "login",
373
- "refresh"
382
+ "refresh",
383
+ "track",
384
+ "charge"
374
385
  ]);
375
386
  /**
376
387
  * Split a tool name into lowercase verb-candidate tokens, dropping the
@@ -386,7 +397,17 @@ function toolNameTokens(name) {
386
397
  * test suite asserts it doesn't, so a `null` at runtime means a new tool
387
398
  * used an unrecognised verb and needs a verb-set entry here.
388
399
  */
400
+ /**
401
+ * Per-tool classification overrides for names whose verb token is a noun
402
+ * elsewhere. `catalog` is a NOUN in catalog-management tools
403
+ * (`amba_create_catalog_item`, `amba_set_item_price`), so it can't be a global
404
+ * read verb — but `amba_events_catalog` is a pure read/discovery tool. Override
405
+ * it explicitly rather than polluting the verb sets.
406
+ */
407
+ const VERB_CLASS_OVERRIDES = { amba_events_catalog: "read" };
389
408
  function classifyToolVerb(name) {
409
+ const override = VERB_CLASS_OVERRIDES[name];
410
+ if (override) return override;
390
411
  const tokens = toolNameTokens(name);
391
412
  let sawRead = false;
392
413
  let sawWrite = false;
@@ -422,7 +443,9 @@ const NOUN_LIKE_VERBS = new Set([
422
443
  "tiers",
423
444
  "analytics",
424
445
  "schedule",
425
- "me"
446
+ "me",
447
+ "balance",
448
+ "payouts"
426
449
  ]);
427
450
  /**
428
451
  * The verbs eligible to be a title's leading word: every classification
@@ -613,7 +636,7 @@ function registerPublicTool(server, name, description, schema, handler, aliases
613
636
  }
614
637
  //#endregion
615
638
  //#region src/tools/projects.ts
616
- function registerTools$33(server, apiClient) {
639
+ function registerTools$37(server, apiClient) {
617
640
  registerTool(server, apiClient, "amba_projects_list", "List all Amba projects owned by the authenticated developer. Returns project id, name, bundle_id, platform, and environment.", {}, async (_, { client }) => {
618
641
  const result = await client.get("/projects");
619
642
  return { content: [{
@@ -631,7 +654,7 @@ function registerTools$33(server, apiClient) {
631
654
  registerTool(server, apiClient, "amba_projects_create", "Create a new Amba project. A project represents a mobile app and contains all its engagement configuration (push, segments, config, content, streaks).", {
632
655
  name: z.string().describe("Human-readable project name (e.g. \"My Fitness App\")"),
633
656
  bundle_id: z.string().optional().describe("App bundle identifier (e.g. \"com.example.myapp\"). Doubles as the audience the server expects on Apple Sign In identity tokens — set this if your app uses Sign in with Apple, or sign-in will reject with AUDIENCE_NOT_CONFIGURED."),
634
- google_oauth_client_id: z.string().optional().describe("Google OAuth 2.0 client id from Google Cloud Console. Doubles as the audience the server expects on Google Sign In id tokens — set this if your app uses Sign in with Google, or sign-in will reject with AUDIENCE_NOT_CONFIGURED. The client id is a public identifier (not a secret) — same value that ships in the app binary."),
657
+ google_oauth_client_id: z.string().optional().describe("Google OAuth 2.0 client id from your Google OAuth configuration. Doubles as the audience the server expects on Google Sign In id tokens — set this if your app uses Sign in with Google, or sign-in will reject with AUDIENCE_NOT_CONFIGURED. The client id is a public identifier (not a secret) — same value that ships in the app binary."),
635
658
  platform: z.enum([
636
659
  "ios",
637
660
  "android",
@@ -720,7 +743,7 @@ function registerTools$33(server, apiClient) {
720
743
  }
721
744
  //#endregion
722
745
  //#region src/tools/push.ts
723
- function registerTools$32(server, apiClient) {
746
+ function registerTools$36(server, apiClient) {
724
747
  registerTool(server, apiClient, "amba_push_campaigns_create", "Create a new push notification campaign for a project. The campaign starts in \"draft\" status. You can optionally target a segment and schedule delivery.", {
725
748
  project_id: z.string().describe("The project ID"),
726
749
  title: z.string().describe("Push notification title shown to the user"),
@@ -850,7 +873,7 @@ const segmentRulesSchema = z.object({
850
873
  operator: z.enum(["AND", "OR"]).describe("Logical operator combining conditions"),
851
874
  conditions: z.array(segmentConditionSchema).describe("Array of filter conditions")
852
875
  });
853
- function registerTools$31(server, apiClient) {
876
+ function registerTools$35(server, apiClient) {
854
877
  registerTool(server, apiClient, "amba_segments_list", "List user segments for a project. Segments define groups of users based on rules (e.g. \"active in last 7 days\", \"premium users\"). Returns only project-custom segments by default. Pass include_system: true to also include the built-in system segments (e.g. \"all\", \"active_7d\", \"premium\").", {
855
878
  project_id: z.string().describe("The project ID"),
856
879
  include_system: z.boolean().optional().describe("Include built-in system segments in the result. Defaults to false.")
@@ -935,7 +958,7 @@ const configConditionSchema = z.object({
935
958
  percentage: z.number().optional().describe("Percentage rollout (0-100)"),
936
959
  value: z.unknown().describe("Override value for this condition")
937
960
  });
938
- function registerTools$30(server, apiClient) {
961
+ function registerTools$34(server, apiClient) {
939
962
  registerTool(server, apiClient, "amba_remote_configs_list", "List remote-config keys for a project. Returns key, value, value_type, description, conditions, and version per active row. Remote config lets you change app behavior without deploying an update. Returns only project-created config keys by default. Pass include_system: true to also include platform-provided defaults (e.g. app_version_min, maintenance_mode).", {
940
963
  project_id: z.string().describe("The project ID"),
941
964
  include_system: z.boolean().optional().describe("Include platform-provided default config keys in the result. Defaults to false.")
@@ -1013,7 +1036,7 @@ const contentItemSchema = z.object({
1013
1036
  metadata: z.record(z.unknown()).optional().describe("Arbitrary metadata key-value pairs"),
1014
1037
  is_premium: z.boolean().optional().describe("Whether this content requires a premium entitlement")
1015
1038
  });
1016
- function registerTools$29(server, apiClient) {
1039
+ function registerTools$33(server, apiClient) {
1017
1040
  registerTool(server, apiClient, "amba_content_libraries_create", "Create a content library for a project. A content library is a collection of content items (tips, quotes, articles) that can be delivered on a schedule to keep users engaged. The library `name` IS the channel handle the in-app SDK uses: `Amba.content.library(name)` reads this library (names are unique). The response echoes it as `channel`.", {
1018
1041
  project_id: z.string().describe("The project ID"),
1019
1042
  name: z.string().describe("Library name (e.g. \"Daily Motivation\", \"Workout Tips\")"),
@@ -1069,14 +1092,16 @@ function registerTools$29(server, apiClient) {
1069
1092
  "random",
1070
1093
  "sequential"
1071
1094
  ]).describe("How content items are selected for delivery"),
1072
- config: z.record(z.unknown()).optional().describe("Schedule-specific configuration (e.g. delivery time, timezone)")
1073
- }, async ({ project_id, library_id, name, schedule_type, config }, { client }) => {
1095
+ config: z.record(z.unknown()).optional().describe("Schedule-specific configuration (e.g. delivery time, timezone)"),
1096
+ batch_size: z.number().int().min(1).max(100).optional().describe("How many items to publish per interval. Defaults to 1 (one item per run). Set to N to publish N items each interval — e.g. \"today's 3 tips\" — without hand-rolling a date-wrap rotation client-side. The selected items are delivered as an array the in-app SDK reads.")
1097
+ }, async ({ project_id, library_id, name, schedule_type, config, batch_size }, { client }) => {
1074
1098
  const payload = {
1075
1099
  library_id,
1076
1100
  name,
1077
1101
  schedule_type
1078
1102
  };
1079
1103
  if (config !== void 0) payload.config = config;
1104
+ if (batch_size !== void 0) payload.batch_size = batch_size;
1080
1105
  const result = await client.post(`/projects/${project_id}/content/schedules`, payload);
1081
1106
  return { content: [{
1082
1107
  type: "text",
@@ -1150,14 +1175,16 @@ function registerTools$29(server, apiClient) {
1150
1175
  cron: z.string().optional().describe("Cron expression (5-field) for delivery timing"),
1151
1176
  timezone: z.string().optional().describe("IANA timezone (e.g. \"America/Los_Angeles\")"),
1152
1177
  segment_id: z.string().nullable().optional().describe("Target segment, or null to broadcast to all users"),
1153
- is_active: z.boolean().optional().describe("Whether the schedule is active")
1154
- }, async ({ project_id, schedule_id, name, cron, timezone, segment_id, is_active }, { client }) => {
1178
+ is_active: z.boolean().optional().describe("Whether the schedule is active"),
1179
+ batch_size: z.number().int().min(1).max(100).optional().describe("How many items to publish per interval (1–100). Set to N to switch this schedule to publishing N items each run instead of one.")
1180
+ }, async ({ project_id, schedule_id, name, cron, timezone, segment_id, is_active, batch_size }, { client }) => {
1155
1181
  const payload = {};
1156
1182
  if (name !== void 0) payload.name = name;
1157
1183
  if (cron !== void 0) payload.cron = cron;
1158
1184
  if (timezone !== void 0) payload.timezone = timezone;
1159
1185
  if (segment_id !== void 0) payload.segment_id = segment_id;
1160
1186
  if (is_active !== void 0) payload.is_active = is_active;
1187
+ if (batch_size !== void 0) payload.batch_size = batch_size;
1161
1188
  const result = await client.patch(`/projects/${project_id}/content/schedules/${schedule_id}`, payload);
1162
1189
  return { content: [{
1163
1190
  type: "text",
@@ -1174,6 +1201,45 @@ function registerTools$29(server, apiClient) {
1174
1201
  text: JSON.stringify(result, null, 2)
1175
1202
  }] };
1176
1203
  });
1204
+ registerTool(server, apiClient, "amba_content_translations_set", "Set a per-language translation for a content item. The base item carries one title/body in the language it was authored in; this stores a localized title/body for a BCP-47 language tag (e.g. \"es-419\", \"pt-BR\"). In-app, a content read in that language (the SDK forwards the device locale) returns the translated copy, falling back to the base item when no translation exists — so a multi-locale app ships every language from one Amba content library instead of bundling per-language copies in the app binary. This is a PARTIAL update: only the fields you pass are written. Omit a field to leave a previously-stored value untouched (and to share the base item value on first create). Pass an explicit `null` for `title` or `body` to CLEAR a previously-stored translated value so that field falls back to the base item again.", {
1205
+ project_id: z.string().describe("The project ID"),
1206
+ item_id: z.string().describe("The content item ID to translate"),
1207
+ language: z.string().describe("BCP-47 language tag for this translation (e.g. \"en\", \"es-419\", \"pt-BR\")"),
1208
+ title: z.string().nullable().optional().describe("Translated title. Omit to leave the existing value untouched (shares the base item title on first create); pass null to clear a previously-stored title so it falls back to the base item."),
1209
+ body: z.string().nullable().optional().describe("Translated body. Omit to leave the existing value untouched (shares the base item body on first create); pass null to clear a previously-stored body so it falls back to the base item."),
1210
+ metadata: z.record(z.unknown()).optional().describe("Optional per-language metadata (e.g. locale-specific media URL)")
1211
+ }, async ({ project_id, item_id, language, title, body, metadata }, { client }) => {
1212
+ const payload = {};
1213
+ if (title !== void 0) payload.title = title;
1214
+ if (body !== void 0) payload.body = body;
1215
+ if (metadata !== void 0) payload.metadata = metadata;
1216
+ const result = await client.put(`/projects/${project_id}/content/items/${item_id}/translations/${encodeURIComponent(language)}`, payload);
1217
+ return { content: [{
1218
+ type: "text",
1219
+ text: JSON.stringify(result, null, 2)
1220
+ }] };
1221
+ });
1222
+ registerTool(server, apiClient, "amba_content_translations_list", "List every per-language translation for a content item (one row per language). Use this to see which locales an item is already translated into before adding more, or to audit translation coverage across a content library.", {
1223
+ project_id: z.string().describe("The project ID"),
1224
+ item_id: z.string().describe("The content item ID")
1225
+ }, async ({ project_id, item_id }, { client }) => {
1226
+ const result = await client.get(`/projects/${project_id}/content/items/${item_id}/translations`);
1227
+ return { content: [{
1228
+ type: "text",
1229
+ text: JSON.stringify(result, null, 2)
1230
+ }] };
1231
+ });
1232
+ registerTool(server, apiClient, "amba_content_translations_delete", "Delete a single per-language translation for a content item. After deletion, in-app reads in that language fall back to the base item.", {
1233
+ project_id: z.string().describe("The project ID"),
1234
+ item_id: z.string().describe("The content item ID"),
1235
+ language: z.string().describe("BCP-47 language tag of the translation to delete")
1236
+ }, async ({ project_id, item_id, language }, { client }) => {
1237
+ const result = await client.delete(`/projects/${project_id}/content/items/${item_id}/translations/${encodeURIComponent(language)}`);
1238
+ return { content: [{
1239
+ type: "text",
1240
+ text: JSON.stringify(result, null, 2)
1241
+ }] };
1242
+ });
1177
1243
  registerTool(server, apiClient, "amba_content_bulk_import", "Bulk-import multiple content items into a library in a single transactional call. Each item is upsert-on-key within the library. Useful for seed data and for migrating from external content sources.", {
1178
1244
  project_id: z.string().describe("The project ID"),
1179
1245
  library_id: z.string().describe("The content library ID"),
@@ -1188,7 +1254,7 @@ function registerTools$29(server, apiClient) {
1188
1254
  }
1189
1255
  //#endregion
1190
1256
  //#region src/tools/streaks.ts
1191
- function registerTools$28(server, apiClient) {
1257
+ function registerTools$32(server, apiClient) {
1192
1258
  registerTool(server, apiClient, "amba_streaks_create", "Create a streak definition for a project. Streaks track consecutive user engagement (e.g. daily logins, workout completions). Supports configurable periods, grace periods, and freeze mechanics.", {
1193
1259
  project_id: z.string().describe("The project ID"),
1194
1260
  key: z.string().regex(/^[a-z0-9_-]{1,64}$/).optional().describe("Stable identifier used by the SDK to qualify a streak without knowing its UUID: `Amba.streaks.qualify(\"daily_login\")`. Lowercase letters, digits, underscore, hyphen, 1-64 characters. Immutable after creation — changing it breaks live SDK calls. When omitted the server derives one from `name`."),
@@ -1280,7 +1346,7 @@ function registerTools$28(server, apiClient) {
1280
1346
  //#endregion
1281
1347
  //#region src/tools/integrations.ts
1282
1348
  const providerEnum = z.enum(INTEGRATION_PROVIDERS);
1283
- function registerTools$27(server, apiClient) {
1349
+ function registerTools$31(server, apiClient) {
1284
1350
  registerTool(server, apiClient, "amba_integrations_configure", "Configure (create or replace) a third-party integration for a project. Supported providers: \"apns\" (Apple Push), \"fcm\" (Firebase Cloud Messaging), \"revenuecat\" (subscription management), \"superwall\" (paywall management). Each provider requires specific config fields. Push credentials (.p8 / service-account JSON) are passed inline here and stored server-side automatically — no separate secrets step.", {
1285
1351
  project_id: z.string().describe("The project ID"),
1286
1352
  provider: providerEnum.describe("Integration provider name"),
@@ -1350,7 +1416,7 @@ const PERIOD_DAYS = {
1350
1416
  "30d": "30",
1351
1417
  "90d": "90"
1352
1418
  };
1353
- function registerTools$26(server, apiClient) {
1419
+ function registerTools$30(server, apiClient) {
1354
1420
  registerTool(server, apiClient, "amba_analytics_get", "Get daily analytics for a project. Returns per-day event counts and unique-user counts for the requested window, plus totals. Use this to verify that end-to-end event tracking is working after seeding and testing.", {
1355
1421
  project_id: z.string().describe("The project ID"),
1356
1422
  period: z.enum([
@@ -1372,8 +1438,58 @@ function registerTools$26(server, apiClient) {
1372
1438
  }, ["amba_get_analytics"]);
1373
1439
  }
1374
1440
  //#endregion
1441
+ //#region src/lib/tool-result.ts
1442
+ /**
1443
+ * Shared MCP tool result helpers.
1444
+ *
1445
+ * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
1446
+ * envelope. Two helpers centralize that:
1447
+ *
1448
+ * - [`jsonResult`] — wraps an arbitrary payload.
1449
+ * - [`passthroughResult`] — flattens an upstream HTTP response
1450
+ * (status + parsed body) into the same envelope. Status is written
1451
+ * LAST so a colliding top-level `status` field in the API response
1452
+ * cannot shadow the HTTP status — agents look at `parsed.status` to
1453
+ * distinguish 2xx from 4xx/5xx.
1454
+ *
1455
+ * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
1456
+ * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
1457
+ * `tools/*` files stay strictly tool registrations.
1458
+ */
1459
+ /**
1460
+ * Wire-shape every MCP tool handler returns.
1461
+ *
1462
+ * The MCP SDK's `tool()` callback signature is structurally typed and
1463
+ * carries an open index signature for `_meta` etc. Declaring our return
1464
+ * type as a plain `{ content: [...] }` interface won't satisfy that
1465
+ * structural check — so the helpers' return type is left as the actual
1466
+ * inferred shape (no explicit interface) and consumers rely on the
1467
+ * inference + the SDK's structural compatibility. If we ever want a
1468
+ * named alias, write it as a type-alias over the inferred shape rather
1469
+ * than a closed interface.
1470
+ */
1471
+ /** Wrap an arbitrary payload as a JSON-text tool result. */
1472
+ function jsonResult$1(payload) {
1473
+ return { content: [{
1474
+ type: "text",
1475
+ text: JSON.stringify(payload, null, 2)
1476
+ }] };
1477
+ }
1478
+ /**
1479
+ * Flatten an upstream HTTP response into the agent-facing tool payload.
1480
+ * Body fields are spread FIRST so a future top-level `status` key in
1481
+ * the API response cannot shadow the HTTP `status` — agents read
1482
+ * `parsed.status` to distinguish 2xx from 4xx/5xx.
1483
+ */
1484
+ function passthroughResult(result) {
1485
+ return jsonResult$1({
1486
+ ...result.body ?? {},
1487
+ status: result.status
1488
+ });
1489
+ }
1490
+ //#endregion
1375
1491
  //#region src/tools/users.ts
1376
- function registerTools$25(server, apiClient) {
1492
+ function registerTools$29(server, apiClient) {
1377
1493
  registerTool(server, apiClient, "amba_users_list", "List app users for a project. Supports pagination with offset and limit, plus optional search (matches email / external_id / display_name with ILIKE) and segment filter. Returns user profiles including external_id, email, display_name, properties, and activity timestamps.", {
1378
1494
  project_id: z.string().describe("The project ID"),
1379
1495
  limit: z.number().optional().describe("Maximum number of users to return (default 50)"),
@@ -1492,6 +1608,23 @@ function registerTools$25(server, apiClient) {
1492
1608
  text: JSON.stringify(result, null, 2)
1493
1609
  }] };
1494
1610
  });
1611
+ registerTool(server, apiClient, "amba_users_create_cohort", [
1612
+ "Mint N anonymous app_users at once, all sharing the same properties (and, optionally, the same segment memberships) in a single call.",
1613
+ "The seed-a-test-cohort primitive: use it to populate a leaderboard, dry-run a push segment, or stand up a batch of users tagged `cohort: beta`.",
1614
+ "Each user is anonymous-style (no external identity) — a shared-properties cohort has no per-user identity by definition. Pass `segment_ids` to enrol every minted user into those segments.",
1615
+ "Capped at 500 per call (split larger seeds into multiple calls). Returns `{ user_ids, created_count }`.",
1616
+ "Errors: 400 INVALID_COUNT (out of 1–500) / INVALID_SEGMENT_ID (segment does not exist), 500 COHORT_FAILED."
1617
+ ].join(" "), {
1618
+ project_id: z.string().describe("The project ID."),
1619
+ count: z.number().int().min(1).max(500).describe("How many users to mint (1–500). Larger counts return INVALID_COUNT."),
1620
+ properties: z.record(z.unknown()).optional().describe("Properties stamped identically onto every minted user (JSON object). Optional."),
1621
+ segment_ids: z.array(z.string()).optional().describe("Segment UUIDs every minted user is enrolled into. Optional.")
1622
+ }, async ({ project_id, count, properties, segment_ids }, { client }) => {
1623
+ const payload = { count };
1624
+ if (properties !== void 0) payload.properties = properties;
1625
+ if (segment_ids !== void 0) payload.segment_ids = segment_ids;
1626
+ return jsonResult$1(await client.post(`/projects/${project_id}/users/cohort`, payload));
1627
+ });
1495
1628
  registerTool(server, apiClient, "amba_users_delete", [
1496
1629
  "Hard-delete a single app_user. Pass exactly one of `user_id`",
1497
1630
  "(uuid) or `anonymous_id` (string). Returns the deleted id plus",
@@ -1562,6 +1695,31 @@ function registerTools$25(server, apiClient) {
1562
1695
  text: JSON.stringify(result, null, 2)
1563
1696
  }] };
1564
1697
  });
1698
+ registerTool(server, apiClient, "amba_users_reset_user", [
1699
+ "Reset ONE app user to a brand-new state WITHOUT deleting them. Clears",
1700
+ "that single user's gamification (XP + ledger, achievements,",
1701
+ "leaderboard entries, challenges), economy (currency balances +",
1702
+ "transactions, inventory, entitlements), streaks, engagement/telemetry",
1703
+ "events, sessions, onboarding, roles, segment memberships, push tokens",
1704
+ "and their social participation (friendships, group + conversation",
1705
+ "memberships, messages, reviews, feed activity) — but keeps the",
1706
+ "app_users row itself (email, anonymous_id, properties survive). This",
1707
+ "is the scalpel to amba_users_reset_sandbox’s sledgehammer: reset a",
1708
+ "single stuck/test user on a production project without touching a",
1709
+ "seeded cohort. Developer-authored config (currencies, achievement",
1710
+ "definitions, catalog) is never touched — the user goes to zero, the",
1711
+ "project does not change. Pass `delete:true` to ALSO remove the",
1712
+ "app_users row at the end (equivalent to amba_users_delete). Returns a",
1713
+ "per-table summary of what was cleared. 404 USER_NOT_FOUND if no row",
1714
+ "matched."
1715
+ ].join(" "), {
1716
+ project_id: z.string().describe("The project ID"),
1717
+ user_id: z.string().describe("The app user UUID to reset."),
1718
+ delete: z.boolean().optional().describe("When true, also delete the app_users row after clearing its state (full removal). Defaults to false — the user is kept and only reset.")
1719
+ }, async ({ project_id, user_id, delete: alsoDelete }, { client }) => {
1720
+ const query = alsoDelete ? { delete: "true" } : void 0;
1721
+ return jsonResult$1(await client.delete(`/projects/${project_id}/users/${user_id}/reset`, query));
1722
+ });
1565
1723
  registerTool(server, apiClient, "amba_sessions_analytics", "Get session analytics (DAU, active users, total sessions, average and median session duration) for a project across a rolling window.", {
1566
1724
  project_id: z.string().describe("The project ID"),
1567
1725
  period: z.enum([
@@ -1778,7 +1936,7 @@ final dailyLimit = await Amba.config.get<int>('daily_limit', defaultValue: 10);
1778
1936
  final streak = await Amba.streaks.get('daily_login');
1779
1937
  print('Current streak: \${streak.currentCount}');`
1780
1938
  };
1781
- function registerTools$24(server, apiClient) {
1939
+ function registerTools$28(server, apiClient) {
1782
1940
  registerTool(server, apiClient, "amba_sdk_get_setup_instructions", "Get platform-specific code snippets and setup instructions for integrating the Amba client SDK into a mobile app. Supports iOS (Swift), Android (Kotlin), React Native, Expo, and Flutter.", { platform: z.enum([
1783
1941
  "ios",
1784
1942
  "android",
@@ -1863,7 +2021,7 @@ const criteriaSchema = z.object({
1863
2021
  property_key: z.string().optional().describe("User property key (required for property_value type)"),
1864
2022
  target_value: z.number().describe("Target value to unlock the achievement")
1865
2023
  });
1866
- function registerTools$23(server, apiClient) {
2024
+ function registerTools$27(server, apiClient) {
1867
2025
  registerTool(server, apiClient, "amba_achievements_create", "Create an achievement/badge definition. Achievements unlock automatically when users meet the criteria (e.g. track 5 workouts, reach a 7-day streak, earn 5000 XP). Can optionally award bonus XP on unlock.", {
1868
2026
  project_id: z.string().describe("The project ID"),
1869
2027
  key: z.string().describe("Unique key for this achievement (e.g. \"first_workout\", \"streak_master_7\")"),
@@ -1968,7 +2126,7 @@ function registerTools$23(server, apiClient) {
1968
2126
  }
1969
2127
  //#endregion
1970
2128
  //#region src/tools/challenges.ts
1971
- function registerTools$22(server, apiClient) {
2129
+ function registerTools$26(server, apiClient) {
1972
2130
  registerTool(server, apiClient, "amba_challenges_create", "Create a time-limited challenge. Challenges are events that run between start_at and end_at, where users try to reach a goal (e.g. track 10 workouts this week, earn 500 XP in 3 days). Can reward XP and/or unlock an achievement on completion.", {
1973
2131
  project_id: z.string().describe("The project ID"),
1974
2132
  name: z.string().describe("Challenge name (e.g. \"7-Day Fitness Sprint\", \"XP Weekend Blitz\")"),
@@ -2079,7 +2237,7 @@ function registerTools$22(server, apiClient) {
2079
2237
  }
2080
2238
  //#endregion
2081
2239
  //#region src/tools/economy.ts
2082
- function registerTools$21(server, apiClient) {
2240
+ function registerTools$25(server, apiClient) {
2083
2241
  registerTool(server, apiClient, "amba_currencies_create", "Create a virtual currency for a project. Currencies can be soft (earned through gameplay) or premium (purchased with real money). Supports auto-recharge for time-gated mechanics like hearts/energy.", {
2084
2242
  project_id: z.string().describe("The project ID"),
2085
2243
  code: z.string().describe("Unique currency code (e.g. \"gold\", \"gems\", \"hearts\")"),
@@ -2327,6 +2485,137 @@ function registerTools$21(server, apiClient) {
2327
2485
  text: JSON.stringify(result, null, 2)
2328
2486
  }] };
2329
2487
  });
2488
+ registerTool(server, apiClient, "amba_entitlements_grant", [
2489
+ "Grant or refresh a subscription entitlement directly on a user — the same `user_entitlements` row mobile store webhooks (Apple/Play) write to, so a server-side grant and an in-app purchase land in one unified place your client SDK reads with `Amba.entitlements`.",
2490
+ "Covers the non-mobile cases that have no webhook: Stripe/Paddle web checkout, manual support grants, gift codes, legacy migrations. Set is_active=false to revoke (ToS violations, refunds).",
2491
+ "Upserts on (user, entitlement_id) — a second call for the same pair refreshes the existing row in place rather than duplicating. Omit a nullable field to preserve it; pass null to clear it."
2492
+ ].join(" "), {
2493
+ project_id: z.string().describe("The project ID"),
2494
+ app_user_id: z.string().describe("The app_user UUID to grant the entitlement to"),
2495
+ entitlement_id: z.string().describe("Entitlement identifier (e.g. \"premium\", \"pro_annual\"). Required."),
2496
+ is_active: z.boolean().optional().describe("Whether the entitlement is active. Defaults to true on grant; pass false to revoke."),
2497
+ product_id: z.string().nullable().optional().describe("Product identifier this grant is for (e.g. \"pro_yearly\"). When it matches a declared product (amba_products_create), the product's mapped entitlements are granted too. Pass null to clear."),
2498
+ store: z.enum([
2499
+ "app_store",
2500
+ "play_store",
2501
+ "web"
2502
+ ]).nullable().optional().describe("Originating store (\"web\" covers any non-native checkout). Pass null to clear."),
2503
+ purchase_date: z.string().nullable().optional().describe("ISO-8601 purchase timestamp. Pass null to clear."),
2504
+ expiration_date: z.string().nullable().optional().describe("ISO-8601 expiration timestamp. Pass null to clear (e.g. for a lifetime grant)."),
2505
+ period_type: z.enum([
2506
+ "trial",
2507
+ "intro",
2508
+ "normal"
2509
+ ]).nullable().optional().describe("Billing period type. Pass null to clear."),
2510
+ raw_data: z.record(z.unknown()).nullable().optional().describe("Opaque JSON blob for audit (e.g. the raw webhook payload). Pass null to reset to {}.")
2511
+ }, async ({ project_id, app_user_id, entitlement_id, is_active, product_id, store, purchase_date, expiration_date, period_type, raw_data }, { client }) => {
2512
+ const payload = { entitlement_id };
2513
+ if (is_active !== void 0) payload.is_active = is_active;
2514
+ if (product_id !== void 0) payload.product_id = product_id;
2515
+ if (store !== void 0) payload.store = store;
2516
+ if (purchase_date !== void 0) payload.purchase_date = purchase_date;
2517
+ if (expiration_date !== void 0) payload.expiration_date = expiration_date;
2518
+ if (period_type !== void 0) payload.period_type = period_type;
2519
+ if (raw_data !== void 0) payload.raw_data = raw_data;
2520
+ const result = await client.post(`/projects/${project_id}/users/${app_user_id}/entitlements`, payload);
2521
+ return { content: [{
2522
+ type: "text",
2523
+ text: JSON.stringify(result, null, 2)
2524
+ }] };
2525
+ });
2526
+ registerTool(server, apiClient, "amba_entitlements_define", ["Define a canonical entitlement your app gates features on with `Amba.entitlements.has(\"pro\")`. Declaring it lets every grant path validate against a known set instead of free-typed strings, and lets you map products to it.", "This is the durable abstraction: features check the entitlement, never a product or a store — so you can change how it is sold without touching app code."].join(" "), {
2527
+ project_id: z.string().describe("The project ID"),
2528
+ key: z.string().describe("Stable entitlement key (e.g. \"pro\", \"premium_content\"). Required."),
2529
+ display_name: z.string().optional().describe("Human-readable name for dashboards."),
2530
+ description: z.string().optional().describe("What this entitlement unlocks."),
2531
+ metadata: z.record(z.unknown()).optional().describe("Opaque metadata object.")
2532
+ }, async ({ project_id, key, display_name, description, metadata }, { client }) => {
2533
+ const payload = { key };
2534
+ if (display_name !== void 0) payload.display_name = display_name;
2535
+ if (description !== void 0) payload.description = description;
2536
+ if (metadata !== void 0) payload.metadata = metadata;
2537
+ const result = await client.post(`/projects/${project_id}/subscriptions/entitlements`, payload);
2538
+ return { content: [{
2539
+ type: "text",
2540
+ text: JSON.stringify(result, null, 2)
2541
+ }] };
2542
+ });
2543
+ registerTool(server, apiClient, "amba_products_create", ["Declare a sellable subscription product and the entitlement it grants. `store_product_refs` maps each store to its own identifier ({ \"app_store\": \"com.app.pro.yearly\", \"play_store\": \"pro_yearly\", \"web\": \"price_123\" }) so one Amba product resolves on whichever store a purchase came from.", "When `grants_entitlement_id` is set, an in-app purchase OR a server grant carrying this product unlocks that entitlement automatically — that resolution is what makes the payment engine swappable."].join(" "), {
2544
+ project_id: z.string().describe("The project ID"),
2545
+ product_id: z.string().describe("Stable, neutral product key (e.g. \"pro_yearly\"). Required."),
2546
+ grants_entitlement_id: z.string().optional().describe("The entitlement key this product unlocks (must be defined first)."),
2547
+ display_name: z.string().optional().describe("Human-readable product name."),
2548
+ description: z.string().optional().describe("Marketing description for the paywall."),
2549
+ product_type: z.enum([
2550
+ "subscription",
2551
+ "consumable",
2552
+ "non_consumable",
2553
+ "non_renewing"
2554
+ ]).optional().describe("Product type (default \"subscription\")."),
2555
+ store_product_refs: z.record(z.string()).optional().describe("Map of store id → that store's product identifier."),
2556
+ trial_period_days: z.number().int().nonnegative().optional().describe("Free-trial length in days (display hint)."),
2557
+ duration: z.string().optional().describe("ISO-8601 duration display hint (e.g. \"P1M\", \"P1Y\")."),
2558
+ metadata: z.record(z.unknown()).optional().describe("Opaque metadata object.")
2559
+ }, async ({ project_id, product_id, grants_entitlement_id, display_name, description, product_type, store_product_refs, trial_period_days, duration, metadata }, { client }) => {
2560
+ const payload = { product_id };
2561
+ if (grants_entitlement_id !== void 0) payload.grants_entitlement_id = grants_entitlement_id;
2562
+ if (display_name !== void 0) payload.display_name = display_name;
2563
+ if (description !== void 0) payload.description = description;
2564
+ if (product_type !== void 0) payload.product_type = product_type;
2565
+ if (store_product_refs !== void 0) payload.store_product_refs = store_product_refs;
2566
+ if (trial_period_days !== void 0) payload.trial_period_days = trial_period_days;
2567
+ if (duration !== void 0) payload.duration = duration;
2568
+ if (metadata !== void 0) payload.metadata = metadata;
2569
+ const result = await client.post(`/projects/${project_id}/subscriptions/products`, payload);
2570
+ return { content: [{
2571
+ type: "text",
2572
+ text: JSON.stringify(result, null, 2)
2573
+ }] };
2574
+ });
2575
+ registerTool(server, apiClient, "amba_entitlements_map_product", "Map a product to an (additional) entitlement it unlocks. Products can unlock more than one entitlement (e.g. a bundle); this adds to the set beyond the product's primary grants_entitlement_id. Both the product and the entitlement must already be declared.", {
2576
+ project_id: z.string().describe("The project ID"),
2577
+ product_id: z.string().describe("The declared product id."),
2578
+ entitlement_id: z.string().describe("The declared entitlement key to also grant.")
2579
+ }, async ({ project_id, product_id, entitlement_id }, { client }) => {
2580
+ const result = await client.post(`/projects/${project_id}/subscriptions/products/${product_id}/entitlements`, { entitlement_id });
2581
+ return { content: [{
2582
+ type: "text",
2583
+ text: JSON.stringify(result, null, 2)
2584
+ }] };
2585
+ });
2586
+ registerTool(server, apiClient, "amba_offerings_create", ["Create (or replace) a subscription offering — the named set of packages your paywall renders, fetched at runtime with `Amba.offerings()`. Each package surfaces one product at a position (e.g. $monthly, $annual).", "Set is_current=true to make this the default offering the SDK returns; exactly one offering is current at a time. The response is fully provider-neutral."].join(" "), {
2587
+ project_id: z.string().describe("The project ID"),
2588
+ offering_id: z.string().describe("Stable offering id (e.g. \"default\", \"holiday_2026\")."),
2589
+ display_name: z.string().optional().describe("Human-readable name."),
2590
+ description: z.string().optional().describe("Internal description."),
2591
+ is_current: z.boolean().optional().describe("Make this the default offering the SDK returns (default false)."),
2592
+ packages: z.array(z.object({
2593
+ package_id: z.string().describe("Package id unique within the offering (e.g. \"$annual\")."),
2594
+ product_id: z.string().optional().describe("The product this package surfaces (by product_id)."),
2595
+ position: z.number().int().nonnegative().optional().describe("Display order (lower first)."),
2596
+ metadata: z.record(z.unknown()).optional().describe("Opaque package metadata.")
2597
+ })).optional().describe("The packages in this offering (replaces the existing set)."),
2598
+ metadata: z.record(z.unknown()).optional().describe("Opaque offering metadata.")
2599
+ }, async ({ project_id, offering_id, display_name, description, is_current, packages, metadata }, { client }) => {
2600
+ const payload = { offering_id };
2601
+ if (display_name !== void 0) payload.display_name = display_name;
2602
+ if (description !== void 0) payload.description = description;
2603
+ if (is_current !== void 0) payload.is_current = is_current;
2604
+ if (packages !== void 0) payload.packages = packages;
2605
+ if (metadata !== void 0) payload.metadata = metadata;
2606
+ const result = await client.post(`/projects/${project_id}/subscriptions/offerings`, payload);
2607
+ return { content: [{
2608
+ type: "text",
2609
+ text: JSON.stringify(result, null, 2)
2610
+ }] };
2611
+ });
2612
+ registerTool(server, apiClient, "amba_offerings_list", "List the project's subscription offerings with their packages and the product behind each — the same provider-neutral shape the client SDK's `offerings()` returns.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2613
+ const result = await client.get(`/projects/${project_id}/subscriptions/offerings`);
2614
+ return { content: [{
2615
+ type: "text",
2616
+ text: JSON.stringify(result, null, 2)
2617
+ }] };
2618
+ });
2330
2619
  registerTool(server, apiClient, "amba_users_get_inventory", "View a user's inventory including all owned items and quantities.", {
2331
2620
  project_id: z.string().describe("The project ID"),
2332
2621
  app_user_id: z.string().describe("The user ID to look up")
@@ -2612,10 +2901,100 @@ function registerTools$21(server, apiClient) {
2612
2901
  text: JSON.stringify(result, null, 2)
2613
2902
  }] };
2614
2903
  }, ["amba_get_currency_transactions"]);
2904
+ registerTool(server, apiClient, "amba_currencies_get_user_balance", [
2905
+ "Read a user's current per-currency balances directly — the live value your client SDK and paywall logic see, returned in one call instead of replaying and summing the transaction ledger.",
2906
+ "Returns one row per currency the user holds (balance, lifetime_earned, lifetime_spent, last_recharged_at), or an empty array if they hold none. Pass currency_id to narrow to a single currency.",
2907
+ "Pair with amba_currencies_grant / amba_currencies_spend to verify a balance change landed, or with amba_currencies_get_transactions to reconcile the balance against its ledger history."
2908
+ ].join(" "), {
2909
+ project_id: z.string().describe("The project ID"),
2910
+ user_id: z.string().describe("The app user ID"),
2911
+ currency_id: z.string().optional().describe("Filter to one currency (definition UUID).")
2912
+ }, async ({ project_id, user_id, currency_id }, { client }) => {
2913
+ const query = {};
2914
+ if (currency_id !== void 0) query.currency_id = currency_id;
2915
+ const result = await client.get(`/projects/${project_id}/currencies/balances/${user_id}`, query);
2916
+ return { content: [{
2917
+ type: "text",
2918
+ text: JSON.stringify(result, null, 2)
2919
+ }] };
2920
+ });
2921
+ }
2922
+ //#endregion
2923
+ //#region src/tools/monetization.ts
2924
+ const objectTypeSchema = z.enum([
2925
+ "entitlement",
2926
+ "product",
2927
+ "offering",
2928
+ "package",
2929
+ "paywall"
2930
+ ]);
2931
+ function registerTools$24(server, apiClient) {
2932
+ registerTool(server, apiClient, "amba_monetization_plan", "Preview the monetization plan for a project: a three-way diff between the declared subscription config (entitlements, products, offerings, packages, paywalls), the last-adopted baseline, and the live config in the connected provider (RevenueCat). Returns the op-list an apply WOULD perform (create/update/archive/attach/detach/set-current), a store-floor preflight (referenced store product identifiers not yet present upstream), and any out-of-band drift. READ-ONLY — computes the plan; applies nothing. Use this before changing monetization so you can see exactly what differs.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2933
+ const result = await client.get(`/projects/${project_id}/monetization/plan`);
2934
+ return { content: [{
2935
+ type: "text",
2936
+ text: JSON.stringify(result, null, 2)
2937
+ }] };
2938
+ });
2939
+ registerTool(server, apiClient, "amba_monetization_drift", "Show only the out-of-band drift for a project's monetization: Amba-managed subscription objects whose live state in the provider (RevenueCat) has changed since Amba last adopted them. READ-ONLY — reports drift; reconciles nothing. Use this to detect when someone edited the subscription config directly in the provider dashboard outside the declarative flow.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2940
+ const result = await client.get(`/projects/${project_id}/monetization/drift`);
2941
+ return { content: [{
2942
+ type: "text",
2943
+ text: JSON.stringify(result, null, 2)
2944
+ }] };
2945
+ });
2946
+ registerTool(server, apiClient, "amba_monetization_export", "Snapshot a project's LIVE subscription monetization config (from the connected provider, RevenueCat) into a declarative bundle: entitlements, products, offerings, packages, and paywalls keyed by their stable identifiers. READ-ONLY. Use this to capture the current config as Infrastructure-as-Code you can review, version-control, and promote between projects.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2947
+ const result = await client.get(`/projects/${project_id}/monetization/export`);
2948
+ return { content: [{
2949
+ type: "text",
2950
+ text: JSON.stringify(result, null, 2)
2951
+ }] };
2952
+ });
2953
+ registerTool(server, apiClient, "amba_monetization_definitions_list", "List the project's current DECLARED monetization definitions (the desired-state entitlements, products, offerings, packages, and paywalls stored in Amba). READ-ONLY. This is the authored Infrastructure-as-Code that `amba_monetization_plan` diffs against the live provider config.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
2954
+ const result = await client.get(`/projects/${project_id}/monetization/definitions`);
2955
+ return { content: [{
2956
+ type: "text",
2957
+ text: JSON.stringify(result, null, 2)
2958
+ }] };
2959
+ });
2960
+ registerTool(server, apiClient, "amba_monetization_apply", "Apply the declared monetization config to the connected provider (RevenueCat). Additive ops apply automatically: CREATE entitlements/offerings/packages/paywall-DRAFTS, ATTACH products to new packages/entitlements, update display metadata. DESTRUCTIVE ops (detach a product, archive an entitlement/offering, remove a package) are GATED: they apply ONLY when you re-call with `confirm` set to the plan_hash — otherwise the apply refuses with MONETIZATION_CONFIRM_REQUIRED and lists the gated ops (nothing is written). Setting the current/default offering and publishing a paywall live are NOT API-driven — they're returned in `refused` with the exact dashboard step. Pass the `plan_hash` from a fresh `amba_monetization_plan`; apply refuses a stale plan_hash (re-plan), hard-fails on a missing store product, enforces the ordering invariant (never archives the live current offering; never detaches a product a live customer resolves unless `allow_detach_live`), reads back every write, and STOPS on the first failure without rolling back. To execute gated ops, first review the plan, then call again with confirm = plan_hash.", {
2961
+ project_id: z.string().describe("The project ID"),
2962
+ plan_hash: z.string().describe("The plan_hash returned by a fresh amba_monetization_plan. Apply refuses if the provider drifted off this hash."),
2963
+ confirm: z.string().optional().describe("Set to the SAME plan_hash to confirm and EXECUTE the gated destructive ops (detach/archive/remove). Omit for an additive-only apply — gated ops are then reported but not run."),
2964
+ allow_detach_live: z.boolean().optional().describe("Accept detaching a product a live customer currently resolves. Off by default (the apply refuses such a detach to protect live purchases)."),
2965
+ reconcile: z.enum(["amba", "adopt"]).optional().describe("Out-of-band drift mode: 'amba' (default) reverts the provider to the declared config on confirm; 'adopt' pulls the live change back into the declared config instead.")
2966
+ }, async ({ project_id, plan_hash, confirm, allow_detach_live, reconcile }, { client }) => {
2967
+ const body = { plan_hash };
2968
+ if (confirm !== void 0) body["confirm"] = confirm;
2969
+ if (allow_detach_live !== void 0) body["allow_detach_live"] = allow_detach_live;
2970
+ if (reconcile !== void 0) body["reconcile"] = reconcile;
2971
+ const result = await client.post(`/projects/${project_id}/monetization/apply`, body);
2972
+ return { content: [{
2973
+ type: "text",
2974
+ text: JSON.stringify(result, null, 2)
2975
+ }] };
2976
+ });
2977
+ registerTool(server, apiClient, "amba_monetization_adopt", "Adopt a project's live subscription config as Amba's managed baseline: writes the matching declared definitions AND records the current live state as the adopted baseline, so the NEXT plan is a clean no-op and future provider-side edits surface as drift. This writes ONLY Amba's own state — it NEVER changes the provider (RevenueCat). Idempotent. Pass adopt_all: true to adopt everything, or a specific objects list. Use this to bring an existing project's monetization under declarative management.", {
2978
+ project_id: z.string().describe("The project ID"),
2979
+ adopt_all: z.boolean().optional().describe("Adopt every live object. Mutually sufficient with `objects`."),
2980
+ objects: z.array(z.object({
2981
+ object_type: objectTypeSchema.describe("The kind of object to adopt"),
2982
+ identifier: z.string().describe("The object stable identifier (provider id)")
2983
+ })).optional().describe("Specific objects to adopt. Provide this OR adopt_all.")
2984
+ }, async ({ project_id, adopt_all, objects }, { client }) => {
2985
+ const body = {};
2986
+ if (adopt_all !== void 0) body["adopt_all"] = adopt_all;
2987
+ if (objects !== void 0) body["objects"] = objects;
2988
+ const result = await client.post(`/projects/${project_id}/monetization/adopt`, body);
2989
+ return { content: [{
2990
+ type: "text",
2991
+ text: JSON.stringify(result, null, 2)
2992
+ }] };
2993
+ });
2615
2994
  }
2616
2995
  //#endregion
2617
2996
  //#region src/tools/leaderboards.ts
2618
- function registerTools$20(server, apiClient) {
2997
+ function registerTools$23(server, apiClient) {
2619
2998
  registerTool(server, apiClient, "amba_leaderboards_create", "Create a leaderboard definition. Leaderboards rank users by XP, streak length, or a custom metric. Supports all-time, daily, weekly, and monthly periods with configurable max entries. The returned `key` (which equals `name`) is the exact value the in-app SDK passes to `Amba.leaderboards.getEntries(key)` / `getRank(key)`.", {
2620
2999
  project_id: z.string().describe("The project ID"),
2621
3000
  name: z.string().describe("Leaderboard name (e.g. \"Top XP Earners\", \"Weekly Streak Leaders\")"),
@@ -2727,10 +3106,10 @@ function registerTools$20(server, apiClient) {
2727
3106
  * cohorts that the rollover workflow reshuffles. These tools provision and
2728
3107
  * inspect leagues; the admin routes are mounted at
2729
3108
  * `/v1/admin/projects/:projectId/leagues`. Members are assigned by the weekly
2730
- * rollover (the registered Temporal schedule) and score live off `xp_awarded`
3109
+ * rollover (the scheduled weekly rollover) and score live off `xp_awarded`
2731
3110
  * engagement events.
2732
3111
  */
2733
- function registerTools$19(server, apiClient) {
3112
+ function registerTools$22(server, apiClient) {
2734
3113
  registerTool(server, apiClient, "amba_leagues_create", "Create a league tier (e.g. \"Bronze\", tier_order=1). Leagues are the promote/demote ladder: each week users are grouped into cohorts of `cohort_size`, the top `promote_count` move up a tier and the bottom `demote_count` move down. Create one league per tier (lowest tier_order = entry tier). Members are assigned by the weekly rollover, not at create time.", {
2735
3114
  project_id: z.string().describe("The project ID"),
2736
3115
  name: z.string().describe("League tier name, e.g. \"Bronze\", \"Silver\", \"Gold\""),
@@ -2793,7 +3172,7 @@ function registerTools$19(server, apiClient) {
2793
3172
  }
2794
3173
  //#endregion
2795
3174
  //#region src/tools/platform.ts
2796
- function registerTools$18(server, apiClient) {
3175
+ function registerTools$21(server, apiClient) {
2797
3176
  registerTool(server, apiClient, "amba_onboarding_create", "Create an onboarding flow for a project. Onboarding flows define step-by-step experiences for new users (e.g. welcome screens, permission prompts, feature tours).", {
2798
3177
  project_id: z.string().describe("The project ID"),
2799
3178
  name: z.string().describe("Flow name (e.g. \"Welcome Flow\", \"Premium Onboarding\")"),
@@ -3068,6 +3447,83 @@ function registerTools$18(server, apiClient) {
3068
3447
  text: JSON.stringify(result, null, 2)
3069
3448
  }] };
3070
3449
  });
3450
+ registerTool(server, apiClient, "amba_media_catalogs_create", "Create a curated media catalog — a named, slug-addressable bundle of media assets your app fetches in one call. Reach for this when an app needs a stable, ordered set of public images/assets (onboarding illustrations, a sticker pack, home-screen heroes) instead of hard-coding asset URLs client-side. Items are added separately with amba_media_catalogs_add_item. `is_public` (default true) gates whether end-user apps can read it via Amba.media.catalog(slug); set it false for drafts.", {
3451
+ project_id: z.string().describe("The project ID"),
3452
+ slug: z.string().describe("URL-safe handle your app reads the catalog by (lowercase, digits, hyphens; e.g. \"onboarding-art\"). Unique per project."),
3453
+ name: z.string().describe("Human-readable catalog name (e.g. \"Onboarding Illustrations\")"),
3454
+ description: z.string().optional().describe("Optional description of what the catalog holds"),
3455
+ is_public: z.boolean().optional().describe("Whether end-user apps can read this catalog by slug. Defaults to true."),
3456
+ metadata: z.record(z.unknown()).optional().describe("Custom metadata")
3457
+ }, async ({ project_id, slug, name, description, is_public, metadata }, { client }) => {
3458
+ const payload = {
3459
+ slug,
3460
+ name
3461
+ };
3462
+ if (description !== void 0) payload.description = description;
3463
+ if (is_public !== void 0) payload.is_public = is_public;
3464
+ if (metadata !== void 0) payload.metadata = metadata;
3465
+ return jsonResult$1(await client.post(`/projects/${project_id}/media/catalogs`, payload));
3466
+ });
3467
+ registerTool(server, apiClient, "amba_media_catalogs_list", "List media catalogs for a project, each with its item count. Use this to see which curated asset bundles exist before adding to or reading one.", {
3468
+ project_id: z.string().describe("The project ID"),
3469
+ limit: z.number().optional().describe("Max results to return (default 50)"),
3470
+ offset: z.number().optional().describe("Offset for pagination (default 0)")
3471
+ }, async ({ project_id, limit, offset }, { client }) => {
3472
+ const query = {};
3473
+ if (limit !== void 0) query.limit = String(limit);
3474
+ if (offset !== void 0) query.offset = String(offset);
3475
+ return jsonResult$1(await client.get(`/projects/${project_id}/media/catalogs`, query));
3476
+ });
3477
+ registerTool(server, apiClient, "amba_media_catalogs_get", "Get a single media catalog and its ordered items, each resolved to a stable public asset URL. Use this to inspect exactly what an app will receive when it reads the catalog by slug.", {
3478
+ project_id: z.string().describe("The project ID"),
3479
+ catalog_id: z.string().describe("The catalog ID")
3480
+ }, async ({ project_id, catalog_id }, { client }) => {
3481
+ return jsonResult$1(await client.get(`/projects/${project_id}/media/catalogs/${catalog_id}`));
3482
+ });
3483
+ registerTool(server, apiClient, "amba_media_catalogs_update", "Update a media catalog's name, slug, description, visibility, or metadata. Flip `is_public` to publish a draft catalog or pull one back to internal.", {
3484
+ project_id: z.string().describe("The project ID"),
3485
+ catalog_id: z.string().describe("The catalog ID"),
3486
+ slug: z.string().optional().describe("New URL-safe slug (lowercase, digits, hyphens)"),
3487
+ name: z.string().optional().describe("New catalog name"),
3488
+ description: z.string().optional().describe("New description"),
3489
+ is_public: z.boolean().optional().describe("Whether end-user apps can read this catalog"),
3490
+ metadata: z.record(z.unknown()).optional().describe("Replacement metadata object")
3491
+ }, async ({ project_id, catalog_id, slug, name, description, is_public, metadata }, { client }) => {
3492
+ const payload = {};
3493
+ if (slug !== void 0) payload.slug = slug;
3494
+ if (name !== void 0) payload.name = name;
3495
+ if (description !== void 0) payload.description = description;
3496
+ if (is_public !== void 0) payload.is_public = is_public;
3497
+ if (metadata !== void 0) payload.metadata = metadata;
3498
+ return jsonResult$1(await client.patch(`/projects/${project_id}/media/catalogs/${catalog_id}`, payload));
3499
+ });
3500
+ registerTool(server, apiClient, "amba_media_catalogs_delete", "Delete a media catalog. This removes the catalog and its item membership; the underlying media assets are NOT deleted (they stay in storage and any other catalog).", {
3501
+ project_id: z.string().describe("The project ID"),
3502
+ catalog_id: z.string().describe("The catalog ID")
3503
+ }, async ({ project_id, catalog_id }, { client }) => {
3504
+ return jsonResult$1(await client.delete(`/projects/${project_id}/media/catalogs/${catalog_id}`));
3505
+ });
3506
+ registerTool(server, apiClient, "amba_media_catalogs_add_item", "Add a media asset to a catalog. The asset keeps its canonical public URL — a catalog is a curated reference, not a copy. Set `position` to control where it lands in the catalog's order, and `caption` for a per-catalog label.", {
3507
+ project_id: z.string().describe("The project ID"),
3508
+ catalog_id: z.string().describe("The catalog ID"),
3509
+ media_id: z.string().describe("ID of the media asset to add (from amba_media_list)"),
3510
+ position: z.number().optional().describe("Sort position within the catalog (lower comes first). Defaults to 0."),
3511
+ caption: z.string().optional().describe("Optional per-catalog caption for this asset"),
3512
+ metadata: z.record(z.unknown()).optional().describe("Custom metadata for this catalog item")
3513
+ }, async ({ project_id, catalog_id, media_id, position, caption, metadata }, { client }) => {
3514
+ const payload = { media_id };
3515
+ if (position !== void 0) payload.position = position;
3516
+ if (caption !== void 0) payload.caption = caption;
3517
+ if (metadata !== void 0) payload.metadata = metadata;
3518
+ return jsonResult$1(await client.post(`/projects/${project_id}/media/catalogs/${catalog_id}/items`, payload));
3519
+ });
3520
+ registerTool(server, apiClient, "amba_media_catalogs_remove_item", "Remove a media asset from a catalog. The asset itself is untouched — only its membership in this catalog is removed.", {
3521
+ project_id: z.string().describe("The project ID"),
3522
+ catalog_id: z.string().describe("The catalog ID"),
3523
+ media_id: z.string().describe("ID of the media asset to remove from the catalog")
3524
+ }, async ({ project_id, catalog_id, media_id }, { client }) => {
3525
+ return jsonResult$1(await client.delete(`/projects/${project_id}/media/catalogs/${catalog_id}/items/${media_id}`));
3526
+ });
3071
3527
  registerTool(server, apiClient, "amba_moderation_queue_get", "Get the content moderation queue for a project. Shows reported content pending review.", {
3072
3528
  project_id: z.string().describe("The project ID"),
3073
3529
  status: z.enum([
@@ -3369,7 +3825,7 @@ function registerTools$18(server, apiClient) {
3369
3825
  }
3370
3826
  //#endregion
3371
3827
  //#region src/tools/social.ts
3372
- function registerTools$17(server, apiClient) {
3828
+ function registerTools$20(server, apiClient) {
3373
3829
  registerTool(server, apiClient, "amba_friendships_get_stats", "Get friendship statistics for a project. Returns total friendships, pending requests, accepted friendships, and blocked count.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
3374
3830
  const result = await client.get(`/projects/${project_id}/friends/stats`);
3375
3831
  return { content: [{
@@ -3694,7 +4150,7 @@ function registerTools$17(server, apiClient) {
3694
4150
  }
3695
4151
  //#endregion
3696
4152
  //#region src/tools/xp.ts
3697
- function registerTools$16(server, apiClient) {
4153
+ function registerTools$19(server, apiClient) {
3698
4154
  registerTool(server, apiClient, "amba_xp_rules_create", "Create an XP rule that auto-awards XP when a matching engagement event is tracked. For example, award 50 XP every time a user completes a workout, with optional daily caps and cooldowns.", {
3699
4155
  project_id: z.string().describe("The project ID"),
3700
4156
  name: z.string().describe("Rule name (e.g. \"Workout Completed\", \"Daily Login Bonus\")"),
@@ -3817,7 +4273,7 @@ function registerTools$16(server, apiClient) {
3817
4273
  }
3818
4274
  //#endregion
3819
4275
  //#region src/tools/events.ts
3820
- function registerTools$15(server, apiClient) {
4276
+ function registerTools$18(server, apiClient) {
3821
4277
  registerTool(server, apiClient, "amba_events_list", "List engagement events for a project (most recent first) with cursor pagination on (occurred_at, id). Defaults to the last 24 hours when `since` is omitted. Pass `next_cursor` from a previous page back as `cursor` to continue.", {
3822
4278
  project_id: z.string().describe("The project ID"),
3823
4279
  since: z.string().optional().describe("ISO-8601 lower bound for occurred_at. Defaults to 24h ago."),
@@ -3857,9 +4313,103 @@ function registerTools$15(server, apiClient) {
3857
4313
  text: JSON.stringify(result, null, 2)
3858
4314
  }] };
3859
4315
  });
4316
+ registerTool(server, apiClient, "amba_events_explain", [
4317
+ "Dry-run an event against the rule engine: given a candidate event (name + properties + optional user), return which rules WOULD match and what each WOULD produce — XP awards, currency grants, feed items, streak qualification, and webhook deliveries — WITHOUT firing the event or committing any side effect. Pure read-only preview.",
4318
+ "Use it to answer \"what happens if I track this?\" before you wire up the call: see the exact XP delta, currency amount, feed item, and webhook destinations a given event would trigger, so you can confirm your rules are configured the way you intend.",
4319
+ "Pass `user_id` to also preview per-user gating (daily limits, cooldowns, balance caps) — the response notes when an effect would currently be suppressed or reduced. Omit it for the base, user-independent match. Nothing is written either way.",
4320
+ "Returns `{data: {matched: [{rule_type, rule_id, name, would, note?}], evaluated_surfaces, unmatched_reason_samples?}}`. An empty `matched` with a reason sample means no rule targets this event yet."
4321
+ ].join(" "), {
4322
+ project_id: z.string().describe("The project ID"),
4323
+ event_name: z.string().describe("Candidate event name to evaluate (e.g. \"lesson_completed\")."),
4324
+ properties: z.record(z.unknown()).optional().describe("Candidate event properties (e.g. {amount: 5}). Read by property-scaled grant rules."),
4325
+ user_id: z.string().optional().describe("Optional app_user UUID. When supplied, the preview also reports per-user gating (daily limit / cooldown / balance cap) without mutating anything.")
4326
+ }, async ({ project_id, event_name, properties, user_id }, { client }) => {
4327
+ const event = { name: event_name };
4328
+ if (properties !== void 0) event.properties = properties;
4329
+ if (user_id !== void 0) event.user_id = user_id;
4330
+ return jsonResult$1(await client.post(`/projects/${project_id}/events/explain`, { event }));
4331
+ });
4332
+ registerTool(server, apiClient, "amba_events_track", [
4333
+ "Track an engagement event on behalf of a user from the server side — the one call that drives the whole reactive pipeline: streak qualification, currency grant rules, XP awards, achievements, segment membership, and any webhooks subscribed to the event all fan out from this single write.",
4334
+ "Use it to dogfood the event→XP→achievement loop without a device: emit the exact event your app will (e.g. \"mission_completed\" with {xp: 100}) and watch the downstream effects land, then read them back with amba_users_get_xp / amba_achievements_list.",
4335
+ "Pass `event_id` for exactly-once semantics — a re-POST with the same id is a silent no-op and never double-credits a streak or grant. Omit `app_user_id` for a project-scope event (no end-user attribution).",
4336
+ "Send one event, or a `batch` of up to the per-request cap, in a single round-trip. Returns `{data: {tracked, inserted, skipped}}` — `skipped` counts idempotent replays."
4337
+ ].join(" "), {
4338
+ project_id: z.string().describe("The project ID"),
4339
+ event_name: z.string().describe("Event name (e.g. \"mission_completed\", \"lesson_finished\"). Required for a single event.").optional(),
4340
+ app_user_id: z.string().nullable().optional().describe("The app_user UUID this event belongs to. Omit or pass null for a project-scope event with no end-user attribution."),
4341
+ properties: z.record(z.unknown()).optional().describe("Arbitrary event properties (e.g. {xp: 100, level: 3}). Read by grant/XP rules."),
4342
+ occurred_at: z.string().optional().describe("ISO-8601 timestamp the event occurred. Defaults to now."),
4343
+ event_id: z.string().optional().describe("Idempotency key (≤255 chars). A re-POST with the same id is a silent no-op — no double-credit."),
4344
+ batch: z.array(z.object({
4345
+ event_name: z.string().describe("Event name. Required."),
4346
+ app_user_id: z.string().nullable().optional().describe("The app_user UUID. Omit or pass null for a project-scope event."),
4347
+ properties: z.record(z.unknown()).optional().describe("Arbitrary event properties."),
4348
+ occurred_at: z.string().optional().describe("ISO-8601 occurrence timestamp."),
4349
+ event_id: z.string().optional().describe("Idempotency key for this event.")
4350
+ })).optional().describe("Track many events in one call. When provided, the top-level single-event fields are ignored.")
4351
+ }, async ({ project_id, event_name, app_user_id, properties, occurred_at, event_id, batch }, { client }) => {
4352
+ let payload;
4353
+ if (batch !== void 0) payload = { events: batch };
4354
+ else {
4355
+ const single = { event_name };
4356
+ if (app_user_id !== void 0) single.app_user_id = app_user_id;
4357
+ if (properties !== void 0) single.properties = properties;
4358
+ if (occurred_at !== void 0) single.occurred_at = occurred_at;
4359
+ if (event_id !== void 0) single.event_id = event_id;
4360
+ payload = single;
4361
+ }
4362
+ return jsonResult$1(await client.post(`/projects/${project_id}/events`, payload));
4363
+ });
3860
4364
  }
3861
4365
  //#endregion
3862
- //#region src/tools/funnels.ts
4366
+ //#region src/tools/event-catalog.ts
4367
+ function registerTools$17(server, apiClient) {
4368
+ registerTool(server, apiClient, "amba_events_catalog", "Discover every event you can subscribe a webhook to — both APP events (inside your app: users, gamification, economy, social, content) and CONTROL-plane lifecycle events (about the project itself: provisioning, deploys, domains, billing). Returns each event's type, plane, payload example, and status (`live` = emitted today, `planned` = catalogued, not yet wired). Production pattern: after standing up a project, call this, then subscribe to the lifecycle events you care about (e.g. `domain.registered`, `site.deploy.completed`) with amba_control_webhooks_create and to app events with amba_webhooks_create. Subscriptions match an exact name, a namespace wildcard (`economy.*`), or all (`*`). Pass plane to filter.", {
4369
+ project_id: z.string().describe("The project ID"),
4370
+ plane: z.enum(["app", "control"]).optional().describe("Filter to one plane. Omit for both.")
4371
+ }, async ({ project_id, plane }, { client }) => {
4372
+ const qs = plane ? `?plane=${plane}` : "";
4373
+ const result = await client.get(`/projects/${project_id}/webhooks/catalog${qs}`);
4374
+ return { content: [{
4375
+ type: "text",
4376
+ text: JSON.stringify(result, null, 2)
4377
+ }] };
4378
+ });
4379
+ registerTool(server, apiClient, "amba_control_webhooks_create", "Subscribe a webhook to CONTROL-plane lifecycle events for this project — provisioning, deploys, domains, billing. Amba sends an HMAC-signed POST to target_url whenever a matching event fires. event_name is an exact event (`project.provisioned`), a namespace wildcard (`domain.*`), or all control events (`*`). The signing secret is returned ONCE — copy it into your receiver. target_url must be https:// (http:// only for localhost). Use amba_events_catalog (plane:'control') to see what's available. For APP events use amba_webhooks_create instead.", {
4380
+ project_id: z.string().describe("The project ID"),
4381
+ event_name: z.string().describe("Control event to subscribe to: exact (`project.provisioned`), wildcard (`domain.*`), or `*`."),
4382
+ target_url: z.string().describe("HTTPS endpoint that receives the signed POST. http:// only for localhost.")
4383
+ }, async ({ project_id, event_name, target_url }, { client }) => {
4384
+ const result = await client.post(`/projects/${project_id}/control-webhooks`, {
4385
+ event_name,
4386
+ target_url
4387
+ });
4388
+ return { content: [{
4389
+ type: "text",
4390
+ text: JSON.stringify(result, null, 2)
4391
+ }] };
4392
+ });
4393
+ registerTool(server, apiClient, "amba_control_webhooks_list", "List this project's control-plane webhook subscriptions (most recent first). Secrets are never included.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
4394
+ const result = await client.get(`/projects/${project_id}/control-webhooks`);
4395
+ return { content: [{
4396
+ type: "text",
4397
+ text: JSON.stringify(result, null, 2)
4398
+ }] };
4399
+ });
4400
+ registerTool(server, apiClient, "amba_control_webhooks_delete", "Delete a control-plane webhook subscription by id.", {
4401
+ project_id: z.string().describe("The project ID"),
4402
+ id: z.string().describe("The subscription id")
4403
+ }, async ({ project_id, id }, { client }) => {
4404
+ const result = await client.delete(`/projects/${project_id}/control-webhooks/${id}`);
4405
+ return { content: [{
4406
+ type: "text",
4407
+ text: JSON.stringify(result, null, 2)
4408
+ }] };
4409
+ });
4410
+ }
4411
+ //#endregion
4412
+ //#region src/tools/funnels.ts
3863
4413
  const funnelFilterSchema = z.object({
3864
4414
  property: z.string().describe("Dot-path into the event `properties` JSON (e.g. \"source\" or \"plan.tier\")."),
3865
4415
  op: z.enum([
@@ -3884,7 +4434,7 @@ const funnelStepSchema = z.object({
3884
4434
  event: z.string().describe("Event name to match (engagement event_name)."),
3885
4435
  filters: z.array(funnelFilterSchema).optional().describe("Optional property predicates, ANDed together, that an event must satisfy.")
3886
4436
  });
3887
- function registerTools$14(server, apiClient) {
4437
+ function registerTools$16(server, apiClient) {
3888
4438
  registerTool(server, apiClient, "amba_funnels_list", "List conversion funnels for a project. A funnel is a saved, ordered sequence of steps (event + optional property filters) plus a conversion window. Returns the definitions only — use amba_funnels_query to compute per-step counts.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
3889
4439
  const result = await client.get(`/projects/${project_id}/funnels`);
3890
4440
  return { content: [{
@@ -3978,56 +4528,6 @@ function registerTools$14(server, apiClient) {
3978
4528
  });
3979
4529
  }
3980
4530
  //#endregion
3981
- //#region src/lib/tool-result.ts
3982
- /**
3983
- * Shared MCP tool result helpers.
3984
- *
3985
- * Every tool emits the same `{ content: [{ type: 'text', text: <json> }] }`
3986
- * envelope. Two helpers centralize that:
3987
- *
3988
- * - [`jsonResult`] — wraps an arbitrary payload.
3989
- * - [`passthroughResult`] — flattens an upstream HTTP response
3990
- * (status + parsed body) into the same envelope. Status is written
3991
- * LAST so a colliding top-level `status` field in the API response
3992
- * cannot shadow the HTTP status — agents look at `parsed.status` to
3993
- * distinguish 2xx from 4xx/5xx.
3994
- *
3995
- * Lives in `src/lib/` (vs. `src/tools/_helpers.ts`) to set the same
3996
- * cross-cutting-helper precedent as `src/lib/with-pat.ts` (task #36).
3997
- * `tools/*` files stay strictly tool registrations.
3998
- */
3999
- /**
4000
- * Wire-shape every MCP tool handler returns.
4001
- *
4002
- * The MCP SDK's `tool()` callback signature is structurally typed and
4003
- * carries an open index signature for `_meta` etc. Declaring our return
4004
- * type as a plain `{ content: [...] }` interface won't satisfy that
4005
- * structural check — so the helpers' return type is left as the actual
4006
- * inferred shape (no explicit interface) and consumers rely on the
4007
- * inference + the SDK's structural compatibility. If we ever want a
4008
- * named alias, write it as a type-alias over the inferred shape rather
4009
- * than a closed interface.
4010
- */
4011
- /** Wrap an arbitrary payload as a JSON-text tool result. */
4012
- function jsonResult$1(payload) {
4013
- return { content: [{
4014
- type: "text",
4015
- text: JSON.stringify(payload, null, 2)
4016
- }] };
4017
- }
4018
- /**
4019
- * Flatten an upstream HTTP response into the agent-facing tool payload.
4020
- * Body fields are spread FIRST so a future top-level `status` key in
4021
- * the API response cannot shadow the HTTP `status` — agents read
4022
- * `parsed.status` to distinguish 2xx from 4xx/5xx.
4023
- */
4024
- function passthroughResult(result) {
4025
- return jsonResult$1({
4026
- ...result.body ?? {},
4027
- status: result.status
4028
- });
4029
- }
4030
- //#endregion
4031
4531
  //#region src/tools/auth.ts
4032
4532
  async function authFetch(apiClient, options) {
4033
4533
  const url = `${apiClient.getApiRoot()}${options.path}`;
@@ -4150,7 +4650,7 @@ function enrichedAuthResult(result, agentInstructions) {
4150
4650
  status: result.status
4151
4651
  });
4152
4652
  }
4153
- function registerTools$13(server, apiClient) {
4653
+ function registerTools$15(server, apiClient) {
4154
4654
  registerPublicTool(server, "amba_developer_signup", [
4155
4655
  "Create a new Amba developer account. Returns a long-lived Personal Access Token (PAT)",
4156
4656
  "plus a real isolated Amba project (provisioning asynchronously), plus ready-to-paste",
@@ -4257,7 +4757,7 @@ function registerTools$13(server, apiClient) {
4257
4757
  }
4258
4758
  //#endregion
4259
4759
  //#region src/tools/collections.ts
4260
- async function adminFetch(apiClient, options) {
4760
+ async function adminFetch$1(apiClient, options) {
4261
4761
  let url = `${apiClient.getBaseUrl()}${options.path}`;
4262
4762
  if (options.query && Object.keys(options.query).length > 0) url += `?${new URLSearchParams(options.query).toString()}`;
4263
4763
  const headers = {
@@ -4359,10 +4859,10 @@ const sdkOrderSchema = z.array(z.object({
4359
4859
  })).describe("Ordering. `[{column: \"created_at\", direction: \"desc\"}, ...]`.");
4360
4860
  const adminFindWhereSchema = z.unknown().describe("Server WhereClause shape: column-keyed FieldOps, e.g. {tag: {eq: \"x\"}, count: {gte: 5}} plus optional `and|or|not` combinators.");
4361
4861
  const setSchema = z.record(z.unknown()).describe("Column-value map. Server-managed columns (id, created_at, etc.) are rejected.");
4362
- function registerTools$12(server, apiClient) {
4862
+ function registerTools$14(server, apiClient) {
4363
4863
  registerTool(server, apiClient, "amba_collections_create", [
4364
4864
  "Create a new collection (schema-first Postgres table) in a project.",
4365
- "The DDL is emitted server-side and applied via a Temporal saga.",
4865
+ "The schema change is applied server-side via an async workflow.",
4366
4866
  "Authenticates as the developer/agent — pass `pat` (the Personal Access Token returned by amba_developer_signup) or send it as the inbound Bearer.",
4367
4867
  "Returns the workflow id + version + status. Failure responses include `details.workflow_id` for debugging.",
4368
4868
  "Errors: 400 RESERVED_NAME / INVALID_COLLECTION_SCHEMA / COLLECTION_MIGRATION_FAILED, 401 MISSING_PAT."
@@ -4379,7 +4879,7 @@ function registerTools$12(server, apiClient) {
4379
4879
  };
4380
4880
  if (indexes !== void 0) body.indexes = indexes;
4381
4881
  if (shared !== void 0) body.shared = shared;
4382
- return passthroughResult(await adminFetch(apiClient, {
4882
+ return passthroughResult(await adminFetch$1(apiClient, {
4383
4883
  method: "POST",
4384
4884
  path: `/projects/${project_id}/collections`,
4385
4885
  body,
@@ -4398,7 +4898,7 @@ function registerTools$12(server, apiClient) {
4398
4898
  const query = {};
4399
4899
  if (limit !== void 0) query.limit = String(limit);
4400
4900
  if (offset !== void 0) query.offset = String(offset);
4401
- return passthroughResult(await adminFetch(apiClient, {
4901
+ return passthroughResult(await adminFetch$1(apiClient, {
4402
4902
  method: "GET",
4403
4903
  path: `/projects/${project_id}/collections`,
4404
4904
  query,
@@ -4413,7 +4913,7 @@ function registerTools$12(server, apiClient) {
4413
4913
  project_id: z.string().describe("The project ID."),
4414
4914
  name: z.string().describe("Collection name (customer-facing, no `coll_` prefix).")
4415
4915
  }, async ({ project_id, name }, { pat }) => {
4416
- return passthroughResult(await adminFetch(apiClient, {
4916
+ return passthroughResult(await adminFetch$1(apiClient, {
4417
4917
  method: "GET",
4418
4918
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}`,
4419
4919
  bearer: pat
@@ -4428,13 +4928,32 @@ function registerTools$12(server, apiClient) {
4428
4928
  name: z.string().describe("Collection name to drop."),
4429
4929
  confirm: z.string().describe("Must equal the collection name. Accident guard for destructive drops.")
4430
4930
  }, async ({ project_id, name, confirm }, { pat }) => {
4431
- return passthroughResult(await adminFetch(apiClient, {
4931
+ return passthroughResult(await adminFetch$1(apiClient, {
4432
4932
  method: "DELETE",
4433
4933
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}`,
4434
4934
  query: { confirm },
4435
4935
  bearer: pat
4436
4936
  }));
4437
4937
  }, ["amba_delete_collection"]);
4938
+ registerTool(server, apiClient, "amba_collections_reset_data", [
4939
+ "Reset a collection: empty ALL of its rows in one call WITHOUT dropping the collection — the schema, columns and indexes survive, only the data is cleared. Use this to wipe a fixture/test collection back to empty between runs without re-creating it (use amba_collections_delete to drop the collection itself).",
4940
+ "Hard by default — rows are permanently removed. Pass `hard:false` to soft-delete instead (sets deleted_at so rows stay recoverable).",
4941
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4942
+ "Returns the number of rows cleared. Errors: 404 COLLECTION_NOT_FOUND if the collection does not exist."
4943
+ ].join(" "), {
4944
+ project_id: z.string().describe("The project ID."),
4945
+ name: z.string().describe("Collection name whose rows to clear."),
4946
+ hard: z.boolean().optional().describe("true (default) permanently deletes every row; false soft-deletes (sets deleted_at) so rows remain recoverable.")
4947
+ }, async ({ project_id, name, hard }, { pat }) => {
4948
+ const query = {};
4949
+ if (hard === false) query.hard = "false";
4950
+ return passthroughResult(await adminFetch$1(apiClient, {
4951
+ method: "DELETE",
4952
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/data`,
4953
+ query,
4954
+ bearer: pat
4955
+ }));
4956
+ });
4438
4957
  registerTool(server, apiClient, "amba_collections_alter", [
4439
4958
  "Alter a collection: exactly one of `add_column`, `add_index`, `change_type`, `rename_column`, `drop_column`, or `relax_user_id` per call (the underlying saga is built around one SQL blob per workflow).",
4440
4959
  "Drop-column is destructive — requires `confirm` to equal the dropped column name.",
@@ -4471,7 +4990,7 @@ function registerTools$12(server, apiClient) {
4471
4990
  if (relax_user_id !== void 0) body.relax_user_id = relax_user_id;
4472
4991
  const query = {};
4473
4992
  if (drop_column !== void 0 && confirm !== void 0) query.confirm = confirm;
4474
- return passthroughResult(await adminFetch(apiClient, {
4993
+ return passthroughResult(await adminFetch$1(apiClient, {
4475
4994
  method: "PATCH",
4476
4995
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}`,
4477
4996
  body,
@@ -4482,13 +5001,15 @@ function registerTools$12(server, apiClient) {
4482
5001
  registerTool(server, apiClient, "amba_admin_insert_row", [
4483
5002
  "Insert a row directly into a collection from the developer/agent side — BYPASSES auto-RLS.",
4484
5003
  "Server-managed columns (id, created_at, updated_at, deleted_at) are stripped from the body. `user_id` is honored if present (admins can create rows attributed to any app_user).",
5004
+ "For developer-seeded GLOBAL content (question banks, lookup tables, daily content) on a shared:true collection, pass as_system=true to write a row with NO owner (user_id = NULL). Rejected with NOT_SHARED_COLLECTION on a non-shared collection — create it with shared:true (or PATCH relax_user_id:true) first.",
4485
5005
  "Atomic upsert: pass on_conflict=\"ignore\" (return the existing row, no change) or \"update\" (merge) plus conflict_target (the unique-index columns). Replaces read-then-write races.",
4486
5006
  "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
4487
- "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
5007
+ "Errors: 400 INVALID_COLUMN / INVALID_CONFLICT_TARGET / NOT_SHARED_COLLECTION, 404 COLLECTION_NOT_FOUND, 500 CREATE_FAILED."
4488
5008
  ].join(" "), {
4489
5009
  project_id: z.string().describe("The project ID."),
4490
5010
  name: z.string().describe("Collection name."),
4491
5011
  row: z.record(z.unknown()).describe("Column-value map for the new row. Server-managed columns are stripped."),
5012
+ as_system: z.boolean().optional().describe("Write a developer-owned GLOBAL row (user_id = NULL) on a shared:true collection — for content the whole app reads (question banks, lookup tables) rather than data owned by one end-user. Mutually exclusive with a row.user_id. Rejected on non-shared collections."),
4492
5013
  on_conflict: z.enum([
4493
5014
  "error",
4494
5015
  "ignore",
@@ -4496,15 +5017,19 @@ function registerTools$12(server, apiClient) {
4496
5017
  ]).optional().describe("Conflict policy. \"error\" (default) fails on a unique-constraint clash; \"ignore\" returns the existing row (HTTP 200) unchanged; \"update\" merges the provided columns. Requires conflict_target."),
4497
5018
  conflict_target: z.array(z.string()).optional().describe("Columns that form the unique index to conflict on (e.g. [\"user_id\",\"kind\"]). Required when on_conflict is \"ignore\" or \"update\"."),
4498
5019
  return_minimal: z.boolean().optional().describe("When true, the response returns only the inserted row id instead of the full row. Use this for rows with large columns (big JSON blobs) to avoid bloating the response — essential for bulk migrations where echoing full rows exhausts the token budget.")
4499
- }, async ({ project_id, name, row, on_conflict, conflict_target, return_minimal }, { pat }) => {
5020
+ }, async ({ project_id, name, row, as_system, on_conflict, conflict_target, return_minimal }, { pat }) => {
4500
5021
  const query = {};
4501
5022
  if (on_conflict !== void 0) query.on_conflict = on_conflict;
4502
5023
  if (conflict_target !== void 0) query.conflict_target = conflict_target.join(",");
4503
5024
  if (return_minimal) query.return = "minimal";
4504
- const result = await adminFetch(apiClient, {
5025
+ const requestBody = as_system ? {
5026
+ ...row,
5027
+ as_system: true
5028
+ } : row;
5029
+ const result = await adminFetch$1(apiClient, {
4505
5030
  method: "POST",
4506
5031
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4507
- body: row,
5032
+ body: requestBody,
4508
5033
  query: Object.keys(query).length > 0 ? query : void 0,
4509
5034
  bearer: pat
4510
5035
  });
@@ -4532,7 +5057,7 @@ function registerTools$12(server, apiClient) {
4532
5057
  }, async ({ project_id, name, rows, on_conflict, return_minimal }, { pat }) => {
4533
5058
  const body = { rows };
4534
5059
  if (on_conflict !== void 0) body.on_conflict = on_conflict;
4535
- return passthroughResult(await adminFetch(apiClient, {
5060
+ return passthroughResult(await adminFetch$1(apiClient, {
4536
5061
  method: "POST",
4537
5062
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows:batch`,
4538
5063
  body,
@@ -4564,7 +5089,7 @@ function registerTools$12(server, apiClient) {
4564
5089
  if (cursor !== void 0) findQuery.cursor = cursor;
4565
5090
  if (select !== void 0) findQuery.select = select;
4566
5091
  if (include_deleted !== void 0) findQuery.includeDeleted = include_deleted;
4567
- return passthroughResult(await adminFetch(apiClient, {
5092
+ return passthroughResult(await adminFetch$1(apiClient, {
4568
5093
  method: "GET",
4569
5094
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
4570
5095
  query: Object.keys(findQuery).length > 0 ? { query: JSON.stringify(findQuery) } : void 0,
@@ -4599,13 +5124,109 @@ function registerTools$12(server, apiClient) {
4599
5124
  if (group_by !== void 0) body.group_by = group_by;
4600
5125
  if (where !== void 0) body.where = where;
4601
5126
  if (include_deleted !== void 0) body.includeDeleted = include_deleted;
4602
- return passthroughResult(await adminFetch(apiClient, {
5127
+ return passthroughResult(await adminFetch$1(apiClient, {
4603
5128
  method: "POST",
4604
5129
  path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/aggregate`,
4605
5130
  body,
4606
5131
  bearer: pat
4607
5132
  }));
4608
5133
  });
5134
+ registerTool(server, apiClient, "amba_admin_update_row", [
5135
+ "Update a single row by id from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce, so it can edit ANY row regardless of which app_user owns it.",
5136
+ "Pass `set` (a column→value map of the fields to write). Server-managed columns (id, created_at, updated_at, deleted_at) are rejected with 400 PROTECTED_COLUMN.",
5137
+ "JSON object values targeting jsonb columns MERGE into the stored object (shallow, atomic merge-patch) instead of replacing it, so concurrent writers patching different keys both survive. Arrays/scalars/null replace the column value. Pass `objects: \"replace\"` to overwrite the whole stored object instead.",
5138
+ "Compare-and-set (optimistic concurrency): pass `expected` (a column→expected-value map) and the update applies ONLY if the row currently still matches those values — otherwise 409 PRECONDITION_FAILED and nothing is written. Null-safe, so `expected: {status: null}` matches a NULL column. Use this for safe read-modify-write (read the row, then update guarded by the values you read) without a transaction.",
5139
+ "For updating many rows by a filter instead of one id, use amba_admin_bulk_update.",
5140
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
5141
+ "Errors: 400 PROTECTED_COLUMN / INVALID_COLUMN / INVALID_BODY, 404 NOT_FOUND (no such row id), 409 PRECONDITION_FAILED (expected mismatch), 404 COLLECTION_NOT_FOUND."
5142
+ ].join(" "), {
5143
+ project_id: z.string().describe("The project ID."),
5144
+ name: z.string().describe("Collection name."),
5145
+ id: z.string().describe("Row UUID to update."),
5146
+ set: setSchema.describe("Column-value map to write. Server-managed columns are rejected."),
5147
+ expected: z.record(z.unknown()).optional().describe("Compare-and-set precondition: column→expected-value map. The update applies only if the row currently matches every entry; otherwise 409 PRECONDITION_FAILED and no write occurs. Null-safe (an entry of null matches a NULL column)."),
5148
+ objects: z.enum(["merge", "replace"]).optional().describe("How JSON object values apply to jsonb columns: \"merge\" (default — shallow merge-patch into the stored object) or \"replace\" (overwrite the whole stored object).")
5149
+ }, async ({ project_id, name, id, set, expected, objects }, { pat }) => {
5150
+ return passthroughResult(await adminFetch$1(apiClient, {
5151
+ method: "PATCH",
5152
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/${encodeURIComponent(id)}`,
5153
+ body: expected !== void 0 ? {
5154
+ set,
5155
+ expected
5156
+ } : { set },
5157
+ query: objects === "replace" ? { objects: "replace" } : void 0,
5158
+ bearer: pat
5159
+ }));
5160
+ });
5161
+ registerTool(server, apiClient, "amba_admin_delete_row", [
5162
+ "Delete a single row by id from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce, so it can delete ANY row regardless of which app_user owns it.",
5163
+ "Soft-delete by default (sets deleted_at = NOW() so the row stays recoverable and still appears in admin_list_rows with include_deleted=true). Pass hard=true for a permanent, irreversible DELETE that removes the row entirely.",
5164
+ "Deleting an already-soft-deleted row returns `already_deleted: true` (200), not an error.",
5165
+ "For deleting many rows by a filter instead of one id, use amba_admin_bulk_delete.",
5166
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
5167
+ "Errors: 404 NOT_FOUND (no such row id), 404 COLLECTION_NOT_FOUND, 400 INVALID_UUID."
5168
+ ].join(" "), {
5169
+ project_id: z.string().describe("The project ID."),
5170
+ name: z.string().describe("Collection name."),
5171
+ id: z.string().describe("Row UUID to delete."),
5172
+ hard: z.boolean().optional().describe("false (default) soft-deletes (sets deleted_at so the row stays recoverable); true permanently removes the row (irreversible).")
5173
+ }, async ({ project_id, name, id, hard }, { pat }) => {
5174
+ return passthroughResult(await adminFetch$1(apiClient, {
5175
+ method: "DELETE",
5176
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows/${encodeURIComponent(id)}`,
5177
+ query: hard === true ? { hard: "true" } : void 0,
5178
+ bearer: pat
5179
+ }));
5180
+ });
5181
+ registerTool(server, apiClient, "amba_admin_bulk_update", [
5182
+ "Update EVERY row matching a `where` filter in one statement, from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce (updates rows across all app_users).",
5183
+ "`where` is REQUIRED — a bulk update with no filter (which would touch the entire collection) is rejected with 400 WHERE_REQUIRED. To update a single row by id, use amba_admin_update_row instead.",
5184
+ "`set` is the column→value map applied to every matched row. Server-managed columns are rejected with 400 PROTECTED_COLUMN.",
5185
+ "JSON object values targeting jsonb columns MERGE into the stored object per row (shallow, atomic merge-patch) instead of replacing it. Arrays/scalars/null replace the column value. Pass `objects: \"replace\"` to overwrite the whole stored object instead.",
5186
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
5187
+ "Returns `{data: {updated: <n>, ids: [...]}}`. Errors: 400 WHERE_REQUIRED / PROTECTED_COLUMN / INVALID_COLUMN, 404 COLLECTION_NOT_FOUND."
5188
+ ].join(" "), {
5189
+ project_id: z.string().describe("The project ID."),
5190
+ name: z.string().describe("Collection name."),
5191
+ set: setSchema.describe("Column-value map applied to every matched row. Required."),
5192
+ where: adminFindWhereSchema.describe("Server WhereClause selecting which rows to update. REQUIRED — there is no \"update all\" shortcut; pass a filter that matches the rows you intend to change."),
5193
+ objects: z.enum(["merge", "replace"]).optional().describe("How JSON object values apply to jsonb columns: \"merge\" (default — shallow merge-patch into the stored object) or \"replace\" (overwrite the whole stored object).")
5194
+ }, async ({ project_id, name, set, where, objects }, { pat }) => {
5195
+ return passthroughResult(await adminFetch$1(apiClient, {
5196
+ method: "PATCH",
5197
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
5198
+ body: {
5199
+ set,
5200
+ where
5201
+ },
5202
+ query: objects === "replace" ? { objects: "replace" } : void 0,
5203
+ bearer: pat
5204
+ }));
5205
+ });
5206
+ registerTool(server, apiClient, "amba_admin_bulk_delete", [
5207
+ "Delete EVERY row matching a `where` filter in one statement, from the developer/agent side — BYPASSES the per-user row scoping the client tools enforce (deletes rows across all app_users).",
5208
+ "`where` is REQUIRED — a bulk delete with no filter is rejected. To delete a single row by id, use amba_admin_delete_row.",
5209
+ "Foot-gun guard: `confirm` MUST equal the exact number of rows the `where` filter currently matches. Preview that count first with amba_admin_list_rows (or the count endpoint), then pass it as `confirm`. If the live count differs (e.g. a concurrent write changed it), the delete aborts with 400 CONFIRMATION_MISMATCH and nothing is removed.",
5210
+ "Soft-delete by default (sets deleted_at). Pass hard=true for a permanent, irreversible DELETE of every matched row.",
5211
+ "Authenticates as the developer/agent — pass `pat` or send as inbound Bearer.",
5212
+ "Returns `{data: {deleted: <n>, ids: [...], mode}}`. Errors: 400 WHERE_REQUIRED / CONFIRMATION_REQUIRED / CONFIRMATION_MISMATCH, 404 COLLECTION_NOT_FOUND."
5213
+ ].join(" "), {
5214
+ project_id: z.string().describe("The project ID."),
5215
+ name: z.string().describe("Collection name."),
5216
+ where: adminFindWhereSchema.describe("Server WhereClause selecting which rows to delete. REQUIRED."),
5217
+ confirm: z.number().int().nonnegative().describe("The exact number of rows the `where` filter currently matches (preview via amba_admin_list_rows). The delete proceeds only if the live count equals this; otherwise 400 CONFIRMATION_MISMATCH and nothing is deleted."),
5218
+ hard: z.boolean().optional().describe("false (default) soft-deletes (sets deleted_at, recoverable); true permanently removes every matched row (irreversible).")
5219
+ }, async ({ project_id, name, where, confirm, hard }, { pat }) => {
5220
+ const query = { confirm: String(confirm) };
5221
+ if (hard === true) query.hard = "true";
5222
+ return passthroughResult(await adminFetch$1(apiClient, {
5223
+ method: "DELETE",
5224
+ path: `/projects/${project_id}/collections/${encodeURIComponent(name)}/rows`,
5225
+ body: { where },
5226
+ query,
5227
+ bearer: pat
5228
+ }));
5229
+ });
4609
5230
  server.tool("amba_client_insert_row", [
4610
5231
  "Insert a row into a collection as the signed-in app_user. Auto-RLS injects `user_id = <session app_user>` server-side; the body cannot override it.",
4611
5232
  "Atomic upsert: pass on_conflict=\"ignore\" (return existing row, HTTP 200) or \"update\" (merge) plus conflict_target (unique-index columns, may include user_id). Removes read-then-write races (duplicate profiles, split rooms, etc.).",
@@ -4689,6 +5310,7 @@ function registerTools$12(server, apiClient) {
4689
5310
  });
4690
5311
  server.tool("amba_client_update_row", [
4691
5312
  "Update a single row by id. Auto-RLS — only succeeds if the row belongs to the signed-in app_user.",
5313
+ "JSON object values targeting jsonb columns MERGE into the stored object (shallow, atomic merge-patch) instead of replacing it, so concurrent writers patching different keys both survive. Arrays/scalars/null replace the column value. Pass `objects: \"replace\"` to overwrite the whole stored object instead.",
4692
5314
  "Compare-and-set: pass `expected` (a column→value map) on a single-row update; the write applies only if the row still matches, else 409 PRECONDITION_FAILED. Null-safe. Optimistic concurrency without a transaction.",
4693
5315
  "For bulk updates with a where-clause, omit `id` and pass `where` + `set`.",
4694
5316
  "Server-managed columns and user_id are rejected with 400 INVALID_COLUMN.",
@@ -4700,10 +5322,12 @@ function registerTools$12(server, apiClient) {
4700
5322
  set: setSchema.describe("Column-value map to write. Required."),
4701
5323
  expected: z.record(z.unknown()).optional().describe("Compare-and-set precondition (single-row only): column→expected-value map. The update applies only if the row currently matches; otherwise 409 PRECONDITION_FAILED."),
4702
5324
  limit: z.number().optional().describe("Bulk-update cap (default 1000, max 10000). Ignored when `id` is set."),
5325
+ objects: z.enum(["merge", "replace"]).optional().describe("How JSON object values apply to jsonb columns: \"merge\" (default — shallow merge-patch into the stored object) or \"replace\" (overwrite the whole stored object)."),
4703
5326
  api_key: z.string().describe("Project client X-Api-Key."),
4704
5327
  session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
4705
- }, async ({ name, id, where, set, expected, limit, api_key, session_token }) => {
5328
+ }, async ({ name, id, where, set, expected, limit, objects, api_key, session_token }) => {
4706
5329
  if (!api_key) return MISSING_API_KEY_ERROR;
5330
+ const objectsQuery = objects === "replace" ? { objects: "replace" } : void 0;
4707
5331
  if (id !== void 0) return passthroughResult(await clientFetch(apiClient, {
4708
5332
  method: "PATCH",
4709
5333
  path: `/collections/${encodeURIComponent(name)}/${encodeURIComponent(id)}`,
@@ -4711,6 +5335,7 @@ function registerTools$12(server, apiClient) {
4711
5335
  set,
4712
5336
  expected
4713
5337
  } : { set },
5338
+ ...objectsQuery !== void 0 && { query: objectsQuery },
4714
5339
  apiKey: api_key,
4715
5340
  ...session_token !== void 0 && { sessionToken: session_token }
4716
5341
  }));
@@ -4723,6 +5348,7 @@ function registerTools$12(server, apiClient) {
4723
5348
  method: "PATCH",
4724
5349
  path: `/collections/${encodeURIComponent(name)}`,
4725
5350
  body,
5351
+ ...objectsQuery !== void 0 && { query: objectsQuery },
4726
5352
  apiKey: api_key,
4727
5353
  ...session_token !== void 0 && { sessionToken: session_token }
4728
5354
  }));
@@ -4863,6 +5489,47 @@ function registerTools$12(server, apiClient) {
4863
5489
  ...session_token !== void 0 && { sessionToken: session_token }
4864
5490
  }));
4865
5491
  });
5492
+ server.tool("amba_client_aggregate_rows", [
5493
+ "Run a group-by aggregation over a collection as the signed-in app_user — server-side, no rows shipped to the client.",
5494
+ "Supports count / sum / avg / min / max, optionally grouped by one or more columns. count without a column = COUNT(*).",
5495
+ "Auto-RLS — the aggregate covers only rows the app_user owns (plus shared/unowned rows) AND'd with the live deleted_at filter; pass include_deleted=true to include soft-deleted rows.",
5496
+ "Roll a leaderboard, a spend total, a per-category count, or a daily streak summary in one round-trip instead of paging every row. Pairs naturally with amba_client_find_rows for the drill-down.",
5497
+ "Authenticates as the end-user — pass `api_key` + `session_token`.",
5498
+ "Returns `{data: [...]}` — one row per group (or a single row when group_by is omitted), each aggregation under its alias."
5499
+ ].join(" "), {
5500
+ name: z.string().describe("Collection name."),
5501
+ select: z.array(z.object({
5502
+ fn: z.enum([
5503
+ "count",
5504
+ "sum",
5505
+ "avg",
5506
+ "min",
5507
+ "max"
5508
+ ]),
5509
+ column: z.string().optional().describe("Column to aggregate. Required for sum/avg/min/max; omit for COUNT(*)."),
5510
+ as: z.string().optional().describe("Output alias (defaults to fn or fn_column).")
5511
+ })).min(1).describe("One or more aggregations to compute."),
5512
+ group_by: z.union([z.string(), z.array(z.string())]).optional().describe("Column(s) to group by. Omit for a whole-collection aggregate."),
5513
+ filter: sdkFilterSchema.optional(),
5514
+ where: adminFindWhereSchema.optional(),
5515
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default FALSE."),
5516
+ api_key: z.string().describe("Project client X-Api-Key."),
5517
+ session_token: z.string().optional().describe("App_user session Bearer. Required by the route.")
5518
+ }, async ({ name, select, group_by, filter, where, include_deleted, api_key, session_token }) => {
5519
+ if (!api_key) return MISSING_API_KEY_ERROR;
5520
+ const body = { select };
5521
+ if (group_by !== void 0) body.group_by = group_by;
5522
+ if (filter !== void 0) body.filter = filter;
5523
+ if (where !== void 0) body.where = where;
5524
+ if (include_deleted !== void 0) body.include_deleted = include_deleted;
5525
+ return passthroughResult(await clientFetch(apiClient, {
5526
+ method: "POST",
5527
+ path: `/collections/${encodeURIComponent(name)}/aggregate`,
5528
+ body,
5529
+ apiKey: api_key,
5530
+ ...session_token !== void 0 && { sessionToken: session_token }
5531
+ }));
5532
+ });
4866
5533
  }
4867
5534
  //#endregion
4868
5535
  //#region src/tools/_pat.ts
@@ -4978,7 +5645,7 @@ function jsonResult(payload) {
4978
5645
  * of a generic upstream 400.
4979
5646
  */
4980
5647
  const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
4981
- function registerTools$11(server, apiClient) {
5648
+ function registerTools$13(server, apiClient) {
4982
5649
  registerTool(server, apiClient, "amba_functions_deploy", [
4983
5650
  "Deploy a function to an Amba project. The supplied `code` is a fully bundled JavaScript module (one entry file) — the deploy endpoint uploads it server-side and records a new active deployment row, superseding any prior active version for the same `name`.",
4984
5651
  "Function name must match /^[a-z][a-z0-9_-]*$/ and be ≤58 chars. Bundle size cap: 10 MiB (enforced client-side too — oversize uploads are rejected before the round-trip).",
@@ -5106,7 +5773,7 @@ function registerTools$11(server, apiClient) {
5106
5773
  }
5107
5774
  //#endregion
5108
5775
  //#region src/tools/sites.ts
5109
- function registerTools$10(server, apiClient) {
5776
+ function registerTools$12(server, apiClient) {
5110
5777
  registerTool(server, apiClient, "amba_sites_deploy", [
5111
5778
  "Deploy a static site to an Amba project. Creates the site if it does not yet exist, then uploads the supplied files as a new deployment.",
5112
5779
  "The deployment is immutable — its files live forever under the site's storage prefix — and the site flips to serve the new deployment atomically. Older deployments are kept for rollback via `amba_sites_list`.",
@@ -5198,7 +5865,7 @@ function registerTools$10(server, apiClient) {
5198
5865
  }
5199
5866
  //#endregion
5200
5867
  //#region src/tools/domains.ts
5201
- function registerTools$9(server, apiClient) {
5868
+ function registerTools$11(server, apiClient) {
5202
5869
  registerTool(server, apiClient, "amba_domains_search", ["Search for available domains to buy through Amba. Pass a keyword or a full domain; returns candidate domains with availability and first-year price (USD).", "Free — searching never costs anything. Use this to help a user pick a name, then `amba_domains_check` to price specific picks and `amba_domains_purchase` to buy."].join(" "), {
5203
5870
  project_id: z.string().describe("The Amba project ID."),
5204
5871
  query: z.string().describe("A keyword (e.g. \"getunbury\") or a full domain (e.g. \"unbury.com\")."),
@@ -5253,8 +5920,41 @@ function registerTools$9(server, apiClient) {
5253
5920
  });
5254
5921
  }
5255
5922
  //#endregion
5923
+ //#region src/tools/operations.ts
5924
+ function registerTools$10(server, apiClient) {
5925
+ registerTool(server, apiClient, "amba_operations_get", [
5926
+ "Poll an async operation by its operation_id to see whether it has finished.",
5927
+ "Returns the operation `status` (pending | running | succeeded | failed); on `failed`, `failed_reason` says why, and on `succeeded`, `result` holds the outcome.",
5928
+ "Use this after a tool returns an `operation_id` (e.g. amba_domains_purchase): re-call until status is succeeded or failed instead of re-running the action."
5929
+ ].join(" "), {
5930
+ project_id: z.string().describe("The Amba project ID."),
5931
+ operation_id: z.string().describe("The operation handle id returned by the tool that started the work.")
5932
+ }, async ({ project_id, operation_id }, { pat }) => {
5933
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/operations/${encodeURIComponent(operation_id)}`));
5934
+ });
5935
+ registerTool(server, apiClient, "amba_operations_list", ["List async operations for a project, newest first. Optionally filter by `kind` (e.g. \"domain_purchase\") and/or `status` (pending | running | succeeded | failed).", "Read-only — useful to find an in-flight operation or review recent ones."].join(" "), {
5936
+ project_id: z.string().describe("The Amba project ID."),
5937
+ kind: z.string().optional().describe("Filter to one operation kind, e.g. \"domain_purchase\"."),
5938
+ status: z.enum([
5939
+ "pending",
5940
+ "running",
5941
+ "succeeded",
5942
+ "failed"
5943
+ ]).optional().describe("Filter to one lifecycle status."),
5944
+ limit: z.number().int().min(1).max(200).optional().describe("Page size (1-200, default 50)."),
5945
+ offset: z.number().int().min(0).optional().describe("Skip rows (default 0).")
5946
+ }, async ({ project_id, kind, status, limit, offset }, { pat }) => {
5947
+ const query = {};
5948
+ if (kind !== void 0) query.kind = kind;
5949
+ if (status !== void 0) query.status = status;
5950
+ if (limit !== void 0) query.limit = String(limit);
5951
+ if (offset !== void 0) query.offset = String(offset);
5952
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/operations`, { query }));
5953
+ });
5954
+ }
5955
+ //#endregion
5256
5956
  //#region src/tools/secrets.ts
5257
- function registerTools$8(server, apiClient) {
5957
+ function registerTools$9(server, apiClient) {
5258
5958
  registerTool(server, apiClient, "amba_secrets_set", [
5259
5959
  "Set or rotate a secret. Omit `function` for a PROJECT-WIDE secret (one value visible to every function in the project, including ones deployed later) — set it once instead of repeating per function. Pass `function` to scope the secret to just that function.",
5260
5960
  "Writes a new version to the backing secret store and enqueues the sync. Subsequent function invocations see the new value once sync_status flips to \"synced\" (typically <30s).",
@@ -5327,17 +6027,249 @@ function registerTools$8(server, apiClient) {
5327
6027
  const PROVIDER_ENUM$1 = z.enum([
5328
6028
  "anthropic",
5329
6029
  "openai",
5330
- "mistral"
6030
+ "mistral",
6031
+ "gemini"
5331
6032
  ]);
5332
6033
  const RATE_LIMIT_SHAPE = z.object({
5333
6034
  window: z.string().optional().describe("Window length (e.g. \"60s\", \"1m\", \"1h\"). Defaults to \"60s\"."),
5334
6035
  max: z.number().int().min(1).optional().describe("Max invocations per window. Defaults to 20."),
5335
6036
  key: z.string().optional().describe("Bucketing key. Typically \"user_id\" or \"session_id\". Defaults to \"user_id\".")
5336
6037
  }).strict();
5337
- function registerTools$7(server, apiClient) {
6038
+ const DEFAULT_IMAGE_MIME = "image/png";
6039
+ /**
6040
+ * Normalize an image's `url` / `data` to non-empty values, trimming whitespace
6041
+ * and treating a blank result as absent. Returns `null` when NEITHER a real url
6042
+ * nor real data survives — such a block carries no image and is dropped by the
6043
+ * caller (instead of emitting an empty image URL or an empty base64 block).
6044
+ */
6045
+ function normalizeImage(img) {
6046
+ const url = typeof img.url === "string" && img.url.trim().length > 0 ? img.url.trim() : void 0;
6047
+ const data = typeof img.data === "string" && img.data.trim().length > 0 ? img.data.trim() : void 0;
6048
+ if (!url && !data) return null;
6049
+ return {
6050
+ url,
6051
+ data,
6052
+ mime: img.mime
6053
+ };
6054
+ }
6055
+ /**
6056
+ * Map a NORMALIZED vendor-neutral image into the provider's native
6057
+ * content-block shape. Anthropic → `{type:'image', source:{...}}`;
6058
+ * OpenAI/Mistral → `{type:'image_url', image_url:{url}}` (base64 becomes a
6059
+ * data: URL); Gemini → a `parts[]` entry (`inline_data` for base64,
6060
+ * `file_data` for a URL). The input is guaranteed to have a real `url` or
6061
+ * real `data`.
6062
+ *
6063
+ * Note: Gemini's image shape is a `contents[].parts[]` entry, NOT a message
6064
+ * `content[]` block — so the Gemini path builds `contents[]` directly via
6065
+ * `geminiContentsFromMessages` and does NOT route image mapping through
6066
+ * `attachImagesToMessages` (which speaks the OpenAI/Anthropic message-content
6067
+ * shape). This helper is reused there for the per-part mapping.
6068
+ */
6069
+ function imageBlockForProvider(img, provider) {
6070
+ const mime = img.mime ?? DEFAULT_IMAGE_MIME;
6071
+ if (provider === "anthropic") return img.url ? {
6072
+ type: "image",
6073
+ source: {
6074
+ type: "url",
6075
+ url: img.url
6076
+ }
6077
+ } : {
6078
+ type: "image",
6079
+ source: {
6080
+ type: "base64",
6081
+ media_type: mime,
6082
+ data: img.data ?? ""
6083
+ }
6084
+ };
6085
+ if (provider === "gemini") return img.url ? { file_data: {
6086
+ mime_type: mime,
6087
+ file_uri: img.url
6088
+ } } : { inline_data: {
6089
+ mime_type: mime,
6090
+ data: img.data ?? ""
6091
+ } };
6092
+ return {
6093
+ type: "image_url",
6094
+ image_url: { url: img.url ?? `data:${mime};base64,${img.data ?? ""}` }
6095
+ };
6096
+ }
6097
+ /**
6098
+ * Convert ONE content block from a message's content array into the provider's
6099
+ * native shape. A vendor-neutral `{type:'image', url|data}` block is mapped via
6100
+ * `imageBlockForProvider`; everything else (a `{type:'text'}` block, or an
6101
+ * already-native block the agent supplied) passes through unchanged. This keeps
6102
+ * the whole message in a SINGLE format so we never mix vendor-neutral and
6103
+ * pre-mapped native image blocks in one message (which would be malformed for
6104
+ * the raw `/ai/messages` passthrough).
6105
+ *
6106
+ * Returns `null` only for a vendor-neutral image block that normalizes away
6107
+ * (blank url + blank data) — the caller drops it.
6108
+ */
6109
+ function nativeContentBlock(block, provider) {
6110
+ if (typeof block === "object" && block !== null) {
6111
+ const b = block;
6112
+ if (b["type"] === "image" && ("url" in b || "data" in b) && !("source" in b)) {
6113
+ const norm = normalizeImage({
6114
+ url: typeof b["url"] === "string" ? b["url"] : void 0,
6115
+ data: typeof b["data"] === "string" ? b["data"] : void 0,
6116
+ mime: typeof b["mime"] === "string" ? b["mime"] : void 0
6117
+ });
6118
+ return norm ? imageBlockForProvider(norm, provider) : null;
6119
+ }
6120
+ }
6121
+ return block;
6122
+ }
6123
+ /** Does a message's `content` array carry any vendor-neutral image block? */
6124
+ function contentHasNeutralImage(content) {
6125
+ if (!Array.isArray(content)) return false;
6126
+ return content.some((b) => {
6127
+ if (typeof b !== "object" || b === null) return false;
6128
+ const bb = b;
6129
+ return bb["type"] === "image" && ("url" in bb || "data" in bb) && !("source" in bb);
6130
+ });
6131
+ }
6132
+ /** Does ANY message carry a vendor-neutral image block in its content array? */
6133
+ function messagesHaveNeutralImage(messages) {
6134
+ return messages.some((m) => contentHasNeutralImage(m?.content));
6135
+ }
6136
+ /**
6137
+ * Normalize ONE message's content array into a single native-shaped block list:
6138
+ * a string → a `[{type:'text', text}]` block (empty string → no block); an
6139
+ * array → its blocks mapped via `nativeContentBlock` (vendor-neutral images to
6140
+ * native, others untouched, normalize-away blocks dropped). Returns the block
6141
+ * list for the array/string cases; for any other value returns `null` so the
6142
+ * caller can leave it untouched (don't coerce a shape we don't understand).
6143
+ */
6144
+ function normalizeContentBlocks(content, provider) {
6145
+ if (typeof content === "string") return content.length > 0 ? [{
6146
+ type: "text",
6147
+ text: content
6148
+ }] : [];
6149
+ if (Array.isArray(content)) return content.map((b) => nativeContentBlock(b, provider)).filter((b) => b !== null);
6150
+ return null;
6151
+ }
6152
+ /**
6153
+ * Map a registered prompt's `messages` into a single provider-native format AND
6154
+ * (optionally) attach extra `images` to the last user message.
6155
+ *
6156
+ * Because `amba_ai_prompts_invoke` posts through the raw `/ai/messages`
6157
+ * passthrough (byte-for-byte to the provider — the gateway does NOT remap this
6158
+ * path), any VENDOR-NEUTRAL `{type:'image', url|data}` block — whether it came
6159
+ * from the `images` convenience param OR was put directly in `messages` by the
6160
+ * caller — must be mapped to the provider's native shape HERE.
6161
+ *
6162
+ * Conservative by design: a message is rewritten ONLY when it needs it — it
6163
+ * carries vendor-neutral image blocks, OR it's the last user message receiving
6164
+ * appended `images`. Plain text / already-native messages pass through
6165
+ * byte-identical (no needless string→block conversion). When no user message
6166
+ * exists but extra `images` were supplied, a fresh user message is appended.
6167
+ * Returns a NEW array — the caller's `messages` is never mutated.
6168
+ */
6169
+ function attachImagesToMessages(messages, images, provider) {
6170
+ const imageBlocks = images.map(normalizeImage).filter((img) => img !== null).map((img) => imageBlockForProvider(img, provider));
6171
+ const out = messages.map((m) => ({ ...m }));
6172
+ let lastUserIdx = -1;
6173
+ for (let i = out.length - 1; i >= 0; i--) if (out[i].role === "user") {
6174
+ lastUserIdx = i;
6175
+ break;
6176
+ }
6177
+ for (let i = 0; i < out.length; i++) {
6178
+ const isTarget = i === lastUserIdx && imageBlocks.length > 0;
6179
+ const needsImageMapping = contentHasNeutralImage(out[i].content);
6180
+ if (!isTarget && !needsImageMapping) continue;
6181
+ const blocks = normalizeContentBlocks(out[i].content, provider);
6182
+ if (blocks === null) {
6183
+ if (isTarget) out[i] = {
6184
+ ...out[i],
6185
+ content: [{
6186
+ type: "text",
6187
+ text: JSON.stringify(out[i].content ?? "")
6188
+ }, ...imageBlocks]
6189
+ };
6190
+ continue;
6191
+ }
6192
+ const withImages = isTarget ? [...blocks, ...imageBlocks] : blocks;
6193
+ if (withImages.length === 0) continue;
6194
+ out[i] = {
6195
+ ...out[i],
6196
+ content: withImages
6197
+ };
6198
+ }
6199
+ if (lastUserIdx === -1 && imageBlocks.length > 0) out.push({
6200
+ role: "user",
6201
+ content: imageBlocks
6202
+ });
6203
+ return out;
6204
+ }
6205
+ /**
6206
+ * Map ONE message's content into Gemini `parts`. A string → a single
6207
+ * `{text}` part; a non-string, non-array value → one JSON-stringified `{text}`
6208
+ * part; an array → each block mapped (vendor-neutral text → `{text}`,
6209
+ * vendor-neutral image → `inline_data`/`file_data`, a normalize-away image
6210
+ * dropped, and any UNKNOWN/alien block — e.g. an OpenAI `image_url` or
6211
+ * Anthropic `source` shape — dropped). Mirrors the gateway's
6212
+ * `mapContentGeminiParts` exactly so a server-side test invocation produces the
6213
+ * same upstream body the production `/prompts/:name/invoke` path does.
6214
+ */
6215
+ function geminiPartsForContent(content) {
6216
+ if (typeof content === "string") return [{ text: content }];
6217
+ if (!Array.isArray(content)) return [{ text: JSON.stringify(content ?? "") }];
6218
+ const parts = [];
6219
+ for (const block of content) {
6220
+ if (typeof block !== "object" || block === null) continue;
6221
+ const b = block;
6222
+ if (b["type"] === "text" && typeof b["text"] === "string") {
6223
+ parts.push({ text: b["text"] });
6224
+ continue;
6225
+ }
6226
+ if (b["type"] === "image" && ("url" in b || "data" in b)) {
6227
+ const norm = normalizeImage({
6228
+ url: typeof b["url"] === "string" ? b["url"] : void 0,
6229
+ data: typeof b["data"] === "string" ? b["data"] : void 0,
6230
+ mime: typeof b["mime"] === "string" ? b["mime"] : void 0
6231
+ });
6232
+ if (norm) parts.push(imageBlockForProvider(norm, "gemini"));
6233
+ continue;
6234
+ }
6235
+ }
6236
+ return parts;
6237
+ }
6238
+ /**
6239
+ * Build Gemini's `contents[]` from a registered prompt's `messages`, mapping
6240
+ * roles (assistant/model → "model", everything else → "user"), translating
6241
+ * content into `parts`, and appending any extra `images` to the last user turn
6242
+ * (creating one if none exists). Messages whose content maps to NO parts are
6243
+ * dropped (an empty turn is rejected upstream). Returns a NEW array — the
6244
+ * caller's `messages` is never mutated.
6245
+ */
6246
+ function geminiContentsFromMessages(messages, images) {
6247
+ const imageParts = images.map(normalizeImage).filter((img) => img !== null).map((img) => imageBlockForProvider(img, "gemini"));
6248
+ const contents = messages.map((m) => ({
6249
+ role: m.role === "assistant" || m.role === "model" ? "model" : "user",
6250
+ parts: geminiPartsForContent(m.content)
6251
+ }));
6252
+ if (imageParts.length > 0) {
6253
+ let lastUserIdx = -1;
6254
+ for (let i = contents.length - 1; i >= 0; i--) if (contents[i].role === "user") {
6255
+ lastUserIdx = i;
6256
+ break;
6257
+ }
6258
+ if (lastUserIdx === -1) contents.push({
6259
+ role: "user",
6260
+ parts: [...imageParts]
6261
+ });
6262
+ else contents[lastUserIdx] = {
6263
+ role: "user",
6264
+ parts: [...contents[lastUserIdx].parts, ...imageParts]
6265
+ };
6266
+ }
6267
+ return contents.filter((c) => c.parts.length > 0);
6268
+ }
6269
+ function registerTools$8(server, apiClient) {
5338
6270
  registerTool(server, apiClient, "amba_ai_prompts_create", [
5339
6271
  "Register a new AI prompt template on a project. Returns the persisted prompt row (name, version, provider, model, client_invokable).",
5340
- "Name must match /^[a-z][a-z0-9_-]{0,127}$/. Provider must be \"anthropic\", \"openai\", or \"mistral\" and must already be registered via `amba_ai_providers_set`. Model is the provider-native model id (e.g. \"claude-sonnet-4-5-20250929\", \"gpt-4o-2024-08-06\").",
6272
+ "Name must match /^[a-z][a-z0-9_-]{0,127}$/. Provider must be \"anthropic\", \"openai\", \"mistral\", or \"gemini\" and must already be registered via `amba_ai_providers_set`. Model is the provider-native model id (e.g. \"claude-sonnet-4-5-20250929\", \"gpt-4o-2024-08-06\", \"gemini-2.5-flash\").",
5341
6273
  "Set `client_invokable=true` to allow SDK callers (e.g. amba-web, amba-react) to invoke the prompt via `Amba.ai.run()`. Defaults false — server-side / admin invocation only.",
5342
6274
  "Re-issuing this tool with an existing `name` will bump the prompt to a new version; prefer `amba_ai_prompts_update` for that intent."
5343
6275
  ].join(" "), {
@@ -5401,8 +6333,9 @@ function registerTools$7(server, apiClient) {
5401
6333
  });
5402
6334
  }, ["amba_delete_ai_prompt"]);
5403
6335
  registerTool(server, apiClient, "amba_ai_prompts_invoke", [
5404
- "Server-side test invocation of a registered AI prompt. Looks up the prompt's provider + model + system_prompt + max_tokens, then proxies a Messages-API call through the admin AI gateway using the customer's registered provider key.",
5405
- "Provide `messages` as a provider-shaped array (e.g. Anthropic: `[{role: \"user\", content: \"...\"}]`). For testing — production traffic goes through the SDK or the function-side `ctx.ai.run()` helper.",
6336
+ "Server-side test invocation of a registered AI prompt. Looks up the prompt's provider + model + system_prompt + max_tokens, then proxies the call through the admin AI gateway using the customer's registered provider key. Works for every provider — Anthropic / OpenAI / Mistral build a Messages-style body; Gemini builds its contents[] body automatically.",
6337
+ "Provide `messages` as a simple `[{role: \"user\", content: \"...\"}]` array (roles: \"user\"/\"assistant\"/\"system\"; for Gemini, \"assistant\" maps to \"model\" and \"system\" is taken from the prompt). For testing — production traffic goes through the SDK or the function-side `ctx.ai.run()` helper.",
6338
+ "To send IMAGES to a vision-capable model, pass `images` (each `{url}` or `{data, mime}`) — they attach to the last user message, mapped to the provider's native image format for you. The prompt's model must be vision-capable.",
5406
6339
  "`extra_body` lets you pass additional provider-native parameters (temperature, top_p, response_format, …). These are merged shallow on top of the prompt-derived body — so a `model` override here wins."
5407
6340
  ].join(" "), {
5408
6341
  project_id: z.string().describe("The Amba project ID."),
@@ -5411,21 +6344,39 @@ function registerTools$7(server, apiClient) {
5411
6344
  role: z.string().describe("Anthropic / OpenAI role (e.g. \"user\", \"assistant\", \"system\")."),
5412
6345
  content: z.unknown().describe("Message content. String or provider-native array form.")
5413
6346
  })).min(1).describe("Provider-shaped messages array. At least one message required."),
6347
+ images: z.array(z.object({
6348
+ url: z.string().optional().describe("Remote image URL. Use this OR `data` (base64), not both."),
6349
+ data: z.string().optional().describe("Base64-encoded image bytes (no data: prefix)."),
6350
+ mime: z.string().optional().describe("MIME type for a base64 image (e.g. \"image/png\"). Defaults to image/png.")
6351
+ })).optional().describe("Optional vision input. Each image attaches to the last user message and is mapped to the provider's native image format. The prompt's model must be vision-capable (e.g. claude-sonnet-4-5, gpt-4o, gemini-2.5-pro)."),
5414
6352
  extra_body: z.record(z.unknown()).optional().describe("Optional extra fields merged into the upstream body. Allowlisted keys only: temperature, top_p, top_k, stop, stop_sequences, response_format, frequency_penalty, presence_penalty, seed, tools, tool_choice, metadata. Unrecognised keys are rejected to prevent provider-body smuggling (e.g. authorization, model overrides).")
5415
- }, async ({ project_id, name, messages, extra_body }, { pat }) => {
6353
+ }, async ({ project_id, name, messages, images, extra_body }, { pat }) => {
5416
6354
  const prompt = (await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/ai/prompts/${encodeURIComponent(name)}`) ?? {}).data;
5417
6355
  if (!prompt) throw new Error(`Prompt "${name}" not found.`);
5418
- const body = {
5419
- model: prompt.model,
5420
- max_tokens: prompt.max_tokens
5421
- };
5422
- if (prompt.provider === "anthropic") {
5423
- if (prompt.system_prompt) body.system = prompt.system_prompt;
5424
- body.messages = messages;
5425
- } else body.messages = prompt.system_prompt ? [{
5426
- role: "system",
5427
- content: prompt.system_prompt
5428
- }, ...messages] : messages;
6356
+ const hasImagesParam = Array.isArray(images) && images.length > 0;
6357
+ let body;
6358
+ if (prompt.provider === "gemini") {
6359
+ body = {
6360
+ model: prompt.model,
6361
+ contents: geminiContentsFromMessages(messages, images ?? []),
6362
+ generationConfig: { maxOutputTokens: prompt.max_tokens }
6363
+ };
6364
+ if (prompt.system_prompt) body.systemInstruction = { parts: [{ text: prompt.system_prompt }] };
6365
+ } else {
6366
+ const messagesHaveImageBlocks = messagesHaveNeutralImage(messages);
6367
+ const effectiveMessages = hasImagesParam || messagesHaveImageBlocks ? attachImagesToMessages(messages, images ?? [], prompt.provider) : messages;
6368
+ body = {
6369
+ model: prompt.model,
6370
+ max_tokens: prompt.max_tokens
6371
+ };
6372
+ if (prompt.provider === "anthropic") {
6373
+ if (prompt.system_prompt) body.system = prompt.system_prompt;
6374
+ body.messages = effectiveMessages;
6375
+ } else body.messages = prompt.system_prompt ? [{
6376
+ role: "system",
6377
+ content: prompt.system_prompt
6378
+ }, ...effectiveMessages] : effectiveMessages;
6379
+ }
5429
6380
  if (extra_body) {
5430
6381
  const ALLOWED_EXTRA_KEYS = new Set([
5431
6382
  "temperature",
@@ -5441,16 +6392,67 @@ function registerTools$7(server, apiClient) {
5441
6392
  "tool_choice",
5442
6393
  "metadata"
5443
6394
  ]);
5444
- for (const [k, v] of Object.entries(extra_body)) {
5445
- if (!ALLOWED_EXTRA_KEYS.has(k)) throw new Error(`extra_body key "${k}" is not allowed. Allowlist: ${Array.from(ALLOWED_EXTRA_KEYS).join(", ")}`);
5446
- body[k] = v;
5447
- }
6395
+ for (const k of Object.keys(extra_body)) if (!ALLOWED_EXTRA_KEYS.has(k)) throw new Error(`extra_body key "${k}" is not allowed. Allowlist: ${Array.from(ALLOWED_EXTRA_KEYS).join(", ")}`);
6396
+ if (prompt.provider === "gemini") {
6397
+ const gc = body.generationConfig ?? {};
6398
+ const GEMINI_KEY = {
6399
+ temperature: "temperature",
6400
+ top_p: "topP",
6401
+ top_k: "topK",
6402
+ seed: "seed",
6403
+ frequency_penalty: "frequencyPenalty",
6404
+ presence_penalty: "presencePenalty"
6405
+ };
6406
+ for (const [k, v] of Object.entries(extra_body)) if (k === "stop" || k === "stop_sequences") gc.stopSequences = Array.isArray(v) ? v : [v];
6407
+ else if (k === "response_format") {
6408
+ const rf = typeof v === "object" && v !== null ? v : null;
6409
+ const rfType = rf?.["type"];
6410
+ if (rfType === "json_object") gc.responseMimeType = "application/json";
6411
+ else if (rfType === "json_schema") {
6412
+ gc.responseMimeType = "application/json";
6413
+ const schema = rf && typeof rf["schema"] === "object" && rf["schema"] !== null ? rf["schema"] : void 0;
6414
+ if (schema !== void 0) gc.responseSchema = schema;
6415
+ }
6416
+ } else if (k in GEMINI_KEY) gc[GEMINI_KEY[k]] = v;
6417
+ else body[k] = v;
6418
+ body.generationConfig = gc;
6419
+ } else for (const [k, v] of Object.entries(extra_body)) body[k] = v;
5448
6420
  }
5449
6421
  return jsonResult(await callWithPat(apiClient, pat, "POST", `/projects/${encodeURIComponent(project_id)}/ai/messages`, { body: {
5450
6422
  provider: prompt.provider,
5451
6423
  body
5452
6424
  } }));
5453
6425
  }, ["amba_invoke_ai_prompt"]);
6426
+ registerTool(server, apiClient, "amba_ai_prompts_set_budget", [
6427
+ "Set (or clear) a per-prompt AI spend budget. Caps how much this one prompt can spend on model calls per period; once the period's spend reaches the budget, invocations are denied with a clear `ai_budget_exceeded` error until the period resets — so an exposed prompt can't burn unbounded spend.",
6428
+ "Pass `budget_usd` as a dollar amount (e.g. 25 = $25). Pass `budget_usd: null` to remove the budget (unlimited — the default). `budget_period` is the reset cadence: \"monthly\" (default), \"daily\", or \"total\" (never resets).",
6429
+ "Returns the persisted budget. For the whole-project ceiling instead, use `amba_billing_set_ceiling`."
6430
+ ].join(" "), {
6431
+ project_id: z.string().describe("The Amba project ID."),
6432
+ name: z.string().describe("Registered prompt name to budget."),
6433
+ budget_usd: z.number().min(0).max(1e6).nullable().describe("Per-period USD ceiling. Pass null to clear the budget (unlimited)."),
6434
+ budget_period: z.enum([
6435
+ "daily",
6436
+ "monthly",
6437
+ "total"
6438
+ ]).optional().describe("Reset cadence. Defaults to \"monthly\" when a budget is set.")
6439
+ }, async ({ project_id, name, budget_usd, budget_period }, { pat }) => {
6440
+ const body = { budget_usd };
6441
+ if (budget_period !== void 0) body.budget_period = budget_period;
6442
+ return jsonResult(await callWithPat(apiClient, pat, "PUT", `/projects/${encodeURIComponent(project_id)}/ai/prompts/${encodeURIComponent(name)}/budget`, { body }));
6443
+ });
6444
+ registerTool(server, apiClient, "amba_ai_prompts_get_spend", ["Read a prompt's AI spend for the current budget period, alongside its budget (if any). Returns { name, period, period_start, spend_usd, budget_usd, exceeded }.", "Spend is summed from the per-call cost of every invocation in the period. Use this to check headroom before raising a budget, or to diagnose an `ai_budget_exceeded` denial. Pass `period` to inspect a different window (defaults to the prompt's configured period, or \"monthly\")."].join(" "), {
6445
+ project_id: z.string().describe("The Amba project ID."),
6446
+ name: z.string().describe("Registered prompt name."),
6447
+ period: z.enum([
6448
+ "daily",
6449
+ "monthly",
6450
+ "total"
6451
+ ]).optional().describe("Spend window to report. Defaults to the prompt's configured budget period.")
6452
+ }, async ({ project_id, name, period }, { pat }) => {
6453
+ const query = period ? `?period=${encodeURIComponent(period)}` : "";
6454
+ return jsonResult(await callWithPat(apiClient, pat, "GET", `/projects/${encodeURIComponent(project_id)}/ai/prompts/${encodeURIComponent(name)}/spend${query}`));
6455
+ });
5454
6456
  }
5455
6457
  //#endregion
5456
6458
  //#region src/tools/ai-providers-admin.ts
@@ -5460,7 +6462,7 @@ const PROVIDER_ENUM = z.enum([
5460
6462
  "mistral",
5461
6463
  "gemini"
5462
6464
  ]);
5463
- function registerTools$6(server, apiClient) {
6465
+ function registerTools$7(server, apiClient) {
5464
6466
  registerTool(server, apiClient, "amba_ai_providers_set", [
5465
6467
  "Register or rotate the upstream AI provider API key for a project. This is the key the AI gateway uses to call the model on your behalf when you invoke a prompt or `Amba.ai.*`.",
5466
6468
  "Call this BEFORE `amba_ai_prompts_create` — a prompt references a registered provider, and invocations fail with `provider_not_configured` (424) until a key is set.",
@@ -5500,7 +6502,7 @@ const STATUS_ENUM = z.enum([
5500
6502
  "paused",
5501
6503
  "ended"
5502
6504
  ]).describe("active = assigns + exposes new users; paused = serves existing assignments but issues no new ones; ended = read-only.");
5503
- function registerTools$5(server, apiClient) {
6505
+ function registerTools$6(server, apiClient) {
5504
6506
  registerTool(server, apiClient, "amba_experiments_create", [
5505
6507
  "Create an A/B experiment: a stable `key`, a display `name`, and >= 2 weighted variants.",
5506
6508
  "End-users are bucketed into a STICKY variant the first time they request an assignment",
@@ -5603,7 +6605,7 @@ function registerTools$5(server, apiClient) {
5603
6605
  }
5604
6606
  //#endregion
5605
6607
  //#region src/tools/promotion.ts
5606
- function registerTools$4(server, apiClient) {
6608
+ function registerTools$5(server, apiClient) {
5607
6609
  registerTool(server, apiClient, "amba_project_export", [
5608
6610
  "Export a declarative bundle of a project's reusable configuration so it can be re-created in another project (e.g. promote dev → prod) in one operation.",
5609
6611
  "The bundle includes (each section only when present): remote configs, collection SCHEMAS (column defs + indexes, no rows), content libraries + their items, and the full gamification set — currencies, achievements, streaks, challenges, leaderboards, and xp rules — as DEFINITIONS only.",
@@ -5709,7 +6711,7 @@ const TIER_CATALOG = {
5709
6711
  note: "Use Amba.track(name, props, { telemetry: true }) for high-volume telemetry. Cheaper, no fan-out to segments/workflows/push."
5710
6712
  }
5711
6713
  };
5712
- function registerTools$3(server, apiClient) {
6714
+ function registerTools$4(server, apiClient) {
5713
6715
  registerTool(server, apiClient, "amba_billing_status", "Get the current billing tier, headroom on each metered axis (MAU, events, push, db_storage, media_storage), projected overage for the current period, spend ceiling, and what (if any) human action is required before the agent can continue safely. Use this BEFORE provisioning new features or running data-heavy workloads so the agent can self-throttle or escalate to the human.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
5714
6716
  const result = await client.get(`/projects/${project_id}/billing/status`);
5715
6717
  return { content: [{
@@ -5735,6 +6737,80 @@ function registerTools$3(server, apiClient) {
5735
6737
  });
5736
6738
  }
5737
6739
  //#endregion
6740
+ //#region src/tools/payments.ts
6741
+ function registerTools$3(server, apiClient) {
6742
+ registerTool(server, apiClient, "amba_payments_account_create", "Create the project's connected payments account so the app can accept payments from its users (the app is the seller; Amba takes a platform fee, Stripe carries the money). Idempotent — returns the existing account if one is already set up. After this, call amba_payments_onboarding_link to finish setup. Note: payments must be enabled on the Amba platform by the platform owner before live accounts can be created.", {
6743
+ project_id: z.string().describe("The project ID"),
6744
+ country: z.string().length(2).optional().describe("Two-letter ISO country code for the seller (e.g. \"US\")"),
6745
+ email: z.string().optional().describe("Contact email for the connected account")
6746
+ }, async ({ project_id, country, email }, { client }) => {
6747
+ const result = await client.post(`/projects/${project_id}/payments/accounts`, {
6748
+ ...country ? { country } : {},
6749
+ ...email ? { email } : {}
6750
+ });
6751
+ return { content: [{
6752
+ type: "text",
6753
+ text: JSON.stringify(result, null, 2)
6754
+ }] };
6755
+ });
6756
+ registerTool(server, apiClient, "amba_payments_create_onboarding_link", "Generate a hosted onboarding URL the seller opens to finish setting up payments (identity, bank details — handled entirely by the payment provider, Amba never sees identity documents). Surface this URL to the human as a copy box. The link is single-use and short-lived; generate a fresh one if it expires.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
6757
+ const result = await client.post(`/projects/${project_id}/payments/accounts/link`, {});
6758
+ return { content: [{
6759
+ type: "text",
6760
+ text: JSON.stringify(result, null, 2)
6761
+ }] };
6762
+ });
6763
+ registerTool(server, apiClient, "amba_payments_account_status", "Read the connected payments account status — onboarding stage, whether charges and payouts are enabled, and the configured default platform fee. Returns connected_account: null when payments are not set up for the project yet. Check this before attempting a charge.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
6764
+ const result = await client.get(`/projects/${project_id}/payments/accounts`);
6765
+ return { content: [{
6766
+ type: "text",
6767
+ text: JSON.stringify(result, null, 2)
6768
+ }] };
6769
+ });
6770
+ registerTool(server, apiClient, "amba_payments_set_fee", "Set the default platform fee taken on each payment, in basis points (e.g. 250 = 2.5%). Applied when a charge does not specify its own fee. Pass null to clear the default (each charge must then specify a fee explicitly). This is a money-affecting setting — surface it to the human before calling.", {
6771
+ project_id: z.string().describe("The project ID"),
6772
+ default_platform_fee_bps: z.number().int().min(0).max(1e4).nullable().describe("Platform fee in basis points (250 = 2.5%), or null to clear")
6773
+ }, async ({ project_id, default_platform_fee_bps }, { client }) => {
6774
+ const result = await client.put(`/projects/${project_id}/payments/config`, { default_platform_fee_bps });
6775
+ return { content: [{
6776
+ type: "text",
6777
+ text: JSON.stringify(result, null, 2)
6778
+ }] };
6779
+ });
6780
+ registerTool(server, apiClient, "amba_payments_charge", "Create a payment (destination charge) on behalf of the seller, taking the platform fee. amount is in the smallest currency unit (cents). The fee is the platform fee (application_fee_amount) — provide application_fee_amount (cents) OR platform_fee_bps (basis points), else the project default is used. Returns a client_secret the app uses to confirm the payment on-device. Money-moving action — surface to the human before calling.", {
6781
+ project_id: z.string().describe("The project ID"),
6782
+ amount: z.number().int().positive().describe("Amount in smallest currency unit (cents)"),
6783
+ currency: z.string().length(3).describe("3-letter ISO currency code (lowercase, e.g. \"usd\")"),
6784
+ application_fee_amount: z.number().int().min(0).optional().describe("Platform fee in cents (overrides bps/default)"),
6785
+ platform_fee_bps: z.number().int().min(0).max(1e4).optional().describe("Platform fee in basis points (250 = 2.5%)"),
6786
+ client_reference: z.string().optional().describe("Opaque reference (e.g. your order id) stored with the payment")
6787
+ }, async ({ project_id, ...body }, { client }) => {
6788
+ const result = await client.post(`/projects/${project_id}/payments/charges`, body);
6789
+ return { content: [{
6790
+ type: "text",
6791
+ text: JSON.stringify(result, null, 2)
6792
+ }] };
6793
+ });
6794
+ registerTool(server, apiClient, "amba_payments_balance", "Read the seller's available and pending payment balance per currency. Use this to show the developer how much they've earned that hasn't yet been paid out.", { project_id: z.string().describe("The project ID") }, async ({ project_id }, { client }) => {
6795
+ const result = await client.get(`/projects/${project_id}/payments/balance`);
6796
+ return { content: [{
6797
+ type: "text",
6798
+ text: JSON.stringify(result, null, 2)
6799
+ }] };
6800
+ });
6801
+ registerTool(server, apiClient, "amba_payments_payouts", "List the seller's recent payouts (transfers from the payment balance to their bank), with amount, currency, status, and arrival date.", {
6802
+ project_id: z.string().describe("The project ID"),
6803
+ limit: z.number().int().min(1).max(100).optional().describe("How many payouts to return (default 10)")
6804
+ }, async ({ project_id, limit }, { client }) => {
6805
+ const qs = limit ? `?limit=${limit}` : "";
6806
+ const result = await client.get(`/projects/${project_id}/payments/payouts${qs}`);
6807
+ return { content: [{
6808
+ type: "text",
6809
+ text: JSON.stringify(result, null, 2)
6810
+ }] };
6811
+ });
6812
+ }
6813
+ //#endregion
5738
6814
  //#region src/tools/invites.ts
5739
6815
  function registerTools$2(server, apiClient) {
5740
6816
  registerTool(server, apiClient, "amba_projects_invite_member", "Invite a teammate by email to collaborate on this project. Returns the invite URL the human should send to their teammate. Roles: admin (can manage members + integrations), member (can write data), viewer (read-only).", {
@@ -7846,7 +8922,9 @@ const TOOL_CATEGORY = {
7846
8922
  amba_users_delete: "identity",
7847
8923
  amba_users_export: "identity",
7848
8924
  amba_users_bulk_update: "identity",
8925
+ amba_users_create_cohort: "identity",
7849
8926
  amba_users_reset_sandbox: "identity",
8927
+ amba_users_reset_user: "identity",
7850
8928
  amba_users_events_export: "identity",
7851
8929
  amba_users_list_events: "identity",
7852
8930
  amba_api_keys_create: "identity",
@@ -7898,6 +8976,9 @@ const TOOL_CATEGORY = {
7898
8976
  amba_content_list_items: "engagement",
7899
8977
  amba_content_update_item: "engagement",
7900
8978
  amba_content_delete_item: "engagement",
8979
+ amba_content_translations_set: "engagement",
8980
+ amba_content_translations_list: "engagement",
8981
+ amba_content_translations_delete: "engagement",
7901
8982
  amba_content_bulk_import: "engagement",
7902
8983
  amba_content_schedules_create: "engagement",
7903
8984
  amba_create_content_schedule: "engagement",
@@ -7980,6 +9061,16 @@ const TOOL_CATEGORY = {
7980
9061
  amba_delete_challenge: "gamification",
7981
9062
  amba_challenges_list_participants: "gamification",
7982
9063
  amba_list_challenge_participants: "gamification",
9064
+ amba_monetization_plan: "economy",
9065
+ amba_monetization_drift: "economy",
9066
+ amba_monetization_export: "economy",
9067
+ amba_monetization_definitions_list: "economy",
9068
+ amba_monetization_adopt: "economy",
9069
+ amba_entitlements_define: "economy",
9070
+ amba_products_create: "economy",
9071
+ amba_entitlements_map_product: "economy",
9072
+ amba_offerings_create: "economy",
9073
+ amba_offerings_list: "economy",
7983
9074
  amba_currencies_create: "economy",
7984
9075
  amba_create_currency: "economy",
7985
9076
  amba_currencies_list: "economy",
@@ -7993,6 +9084,7 @@ const TOOL_CATEGORY = {
7993
9084
  amba_currencies_spend: "economy",
7994
9085
  amba_currencies_get_transactions: "economy",
7995
9086
  amba_get_currency_transactions: "economy",
9087
+ amba_currencies_get_user_balance: "economy",
7996
9088
  amba_currency_grant_rules_create: "economy",
7997
9089
  amba_currency_grant_rules_list: "economy",
7998
9090
  amba_currency_grant_rules_delete: "economy",
@@ -8026,6 +9118,7 @@ const TOOL_CATEGORY = {
8026
9118
  amba_stores_delete_listing: "economy",
8027
9119
  amba_inventory_grant_item: "economy",
8028
9120
  amba_inventory_revoke_item: "economy",
9121
+ amba_entitlements_grant: "economy",
8029
9122
  amba_grant_item: "economy",
8030
9123
  amba_users_get_inventory: "economy",
8031
9124
  amba_get_user_inventory: "economy",
@@ -8086,6 +9179,8 @@ const TOOL_CATEGORY = {
8086
9179
  amba_moderation_list_trust: "social",
8087
9180
  amba_events_list: "analytics",
8088
9181
  amba_events_count: "analytics",
9182
+ amba_events_track: "analytics",
9183
+ amba_events_explain: "analytics",
8089
9184
  amba_analytics_get: "analytics",
8090
9185
  amba_get_analytics: "analytics",
8091
9186
  amba_sessions_list: "analytics",
@@ -8128,7 +9223,12 @@ const TOOL_CATEGORY = {
8128
9223
  amba_alter_collection: "infrastructure",
8129
9224
  amba_collections_delete: "infrastructure",
8130
9225
  amba_delete_collection: "infrastructure",
9226
+ amba_collections_reset_data: "infrastructure",
8131
9227
  amba_admin_insert_row: "infrastructure",
9228
+ amba_admin_update_row: "infrastructure",
9229
+ amba_admin_delete_row: "infrastructure",
9230
+ amba_admin_bulk_update: "infrastructure",
9231
+ amba_admin_bulk_delete: "infrastructure",
8132
9232
  amba_admin_list_rows: "infrastructure",
8133
9233
  amba_admin_aggregate_rows: "infrastructure",
8134
9234
  amba_client_insert_row: "infrastructure",
@@ -8137,6 +9237,7 @@ const TOOL_CATEGORY = {
8137
9237
  amba_client_count_rows: "infrastructure",
8138
9238
  amba_client_find_rows: "infrastructure",
8139
9239
  amba_client_find_nearest_rows: "infrastructure",
9240
+ amba_client_aggregate_rows: "infrastructure",
8140
9241
  amba_client_update_row: "infrastructure",
8141
9242
  amba_client_delete_row: "infrastructure",
8142
9243
  amba_functions_list: "infrastructure",
@@ -8175,6 +9276,8 @@ const TOOL_CATEGORY = {
8175
9276
  amba_domains_check: "infrastructure",
8176
9277
  amba_domains_purchase: "infrastructure",
8177
9278
  amba_domains_list: "infrastructure",
9279
+ amba_operations_get: "infrastructure",
9280
+ amba_operations_list: "infrastructure",
8178
9281
  amba_media_upload: "infrastructure",
8179
9282
  amba_upload_media: "infrastructure",
8180
9283
  amba_media_list: "infrastructure",
@@ -8183,6 +9286,13 @@ const TOOL_CATEGORY = {
8183
9286
  amba_media_create_folder: "infrastructure",
8184
9287
  amba_media_delete_folder: "infrastructure",
8185
9288
  amba_media_list_folders: "infrastructure",
9289
+ amba_media_catalogs_create: "infrastructure",
9290
+ amba_media_catalogs_list: "infrastructure",
9291
+ amba_media_catalogs_get: "infrastructure",
9292
+ amba_media_catalogs_update: "infrastructure",
9293
+ amba_media_catalogs_delete: "infrastructure",
9294
+ amba_media_catalogs_add_item: "infrastructure",
9295
+ amba_media_catalogs_remove_item: "infrastructure",
8186
9296
  amba_secrets_set: "infrastructure",
8187
9297
  amba_set_secret: "infrastructure",
8188
9298
  amba_secrets_get: "infrastructure",
@@ -8221,11 +9331,20 @@ const TOOL_CATEGORY = {
8221
9331
  amba_delete_ai_prompt: "infrastructure",
8222
9332
  amba_ai_prompts_invoke: "infrastructure",
8223
9333
  amba_invoke_ai_prompt: "infrastructure",
9334
+ amba_ai_prompts_set_budget: "infrastructure",
9335
+ amba_ai_prompts_get_spend: "infrastructure",
8224
9336
  amba_sdk_get_setup_instructions: "infrastructure",
8225
9337
  amba_get_sdk_setup_instructions: "infrastructure",
8226
9338
  amba_billing_status: "infrastructure",
8227
9339
  amba_billing_tiers: "infrastructure",
8228
9340
  amba_billing_set_ceiling: "infrastructure",
9341
+ amba_payments_account_create: "economy",
9342
+ amba_payments_create_onboarding_link: "economy",
9343
+ amba_payments_account_status: "economy",
9344
+ amba_payments_set_fee: "economy",
9345
+ amba_payments_charge: "economy",
9346
+ amba_payments_balance: "economy",
9347
+ amba_payments_payouts: "economy",
8229
9348
  amba_projects_invite_member: "infrastructure",
8230
9349
  amba_projects_list_members: "infrastructure",
8231
9350
  amba_projects_remove_member: "infrastructure",
@@ -8240,6 +9359,10 @@ const TOOL_CATEGORY = {
8240
9359
  amba_webhooks_deliveries_list: "infrastructure",
8241
9360
  amba_webhooks_deliveries_get: "infrastructure",
8242
9361
  amba_webhooks_deliveries_replay: "infrastructure",
9362
+ amba_events_catalog: "infrastructure",
9363
+ amba_control_webhooks_create: "infrastructure",
9364
+ amba_control_webhooks_list: "infrastructure",
9365
+ amba_control_webhooks_delete: "infrastructure",
8243
9366
  amba_email_templates_create: "engagement",
8244
9367
  amba_email_templates_list: "engagement",
8245
9368
  amba_email_templates_get: "engagement",
@@ -8268,6 +9391,691 @@ function getToolCategory(toolName) {
8268
9391
  return TOOL_CATEGORY[toolName] ?? null;
8269
9392
  }
8270
9393
  //#endregion
9394
+ //#region src/auto/schema-to-zod.ts
9395
+ /**
9396
+ * Pure mapper: a collection's live schema (the JSON returned by the
9397
+ * `GET .../collections/:name` describe endpoint) → the zod raw shapes the
9398
+ * auto-generated MCP tools register.
9399
+ *
9400
+ * Three derived shapes per collection (the auto-MCP wedge §2.2):
9401
+ *
9402
+ * - `insertShape` — the typed body of an `insert` tool. Server-managed
9403
+ * columns (`id`, `user_id`, `created_at`, `updated_at`, `deleted_at`)
9404
+ * are omitted (the route stamps them); a column is REQUIRED when it is
9405
+ * NOT NULL and has no default, OPTIONAL otherwise.
9406
+ * - `whereShape` — the typed `where` of a `find` tool: one optional key
9407
+ * per non-vector column whose value is `value | { eq?, ne?, gt?, … }`
9408
+ * typed to the column. This is the single highest-value projection —
9409
+ * it makes the generated tool read like a hand-built typed client
9410
+ * rather than a free-form `where: object` blob.
9411
+ * - `updateShape` — the typed `set` of an `update` tool: every non
9412
+ * server-managed column, all optional (merge-patch semantics).
9413
+ *
9414
+ * Plus `rowTypeHints` — a column→plain-English type label map the
9415
+ * description generator (`describe.ts`) reads, and `vectorColumns` — the
9416
+ * vector columns (excluded from row input; they drive `find_nearest`).
9417
+ *
9418
+ * The mapper is PURE (no I/O, no zod-runtime coupling beyond building the
9419
+ * shape) so it unit-tests in isolation and so the same projection can run
9420
+ * in the hosted server today and in the CLI / console later.
9421
+ */
9422
+ /**
9423
+ * Columns Amba manages on every collection. They are stamped by the
9424
+ * route, never accepted as tool input, and so are omitted from the
9425
+ * insert / update shapes. They ARE filterable (`where`) and selectable.
9426
+ */
9427
+ const SERVER_MANAGED_COLUMNS = new Set([
9428
+ "id",
9429
+ "user_id",
9430
+ "created_at",
9431
+ "updated_at",
9432
+ "deleted_at"
9433
+ ]);
9434
+ /**
9435
+ * Classify a column into a coarse {@link ColumnKind}. Reads `data_type`
9436
+ * first (the only reliable array signal — `data_type === 'ARRAY'`), then
9437
+ * `udt_name`. Unknown types fall back to `unknown` (mapped to free JSON)
9438
+ * rather than throwing, so a future column type never breaks generation.
9439
+ */
9440
+ function classifyColumn(column) {
9441
+ const udt = column.udt_name.toLowerCase();
9442
+ if (column.data_type.toUpperCase() === "ARRAY" || udt.startsWith("_")) switch (udt.replace(/^_/, "")) {
9443
+ case "text":
9444
+ case "varchar":
9445
+ case "bpchar": return "text[]";
9446
+ case "int2":
9447
+ case "int4":
9448
+ case "int8": return "integer[]";
9449
+ case "numeric":
9450
+ case "float4":
9451
+ case "float8": return "number[]";
9452
+ case "bool": return "boolean[]";
9453
+ case "uuid": return "uuid[]";
9454
+ default: return "unknown";
9455
+ }
9456
+ switch (udt) {
9457
+ case "text":
9458
+ case "varchar":
9459
+ case "bpchar": return "text";
9460
+ case "int2":
9461
+ case "int4":
9462
+ case "int8": return "integer";
9463
+ case "numeric":
9464
+ case "float4":
9465
+ case "float8": return "number";
9466
+ case "bool": return "boolean";
9467
+ case "jsonb":
9468
+ case "json": return "json";
9469
+ case "uuid": return "uuid";
9470
+ case "date": return "date";
9471
+ case "timestamptz":
9472
+ case "timestamp": return "timestamp";
9473
+ case "vector": return "vector";
9474
+ default: return "unknown";
9475
+ }
9476
+ }
9477
+ /** Build the base (scalar/array) zod type for a column kind. */
9478
+ function baseTypeForKind(kind) {
9479
+ switch (kind) {
9480
+ case "text": return z.string();
9481
+ case "uuid": return z.string().describe("uuid");
9482
+ case "date": return z.string().describe("ISO-8601 date (YYYY-MM-DD)");
9483
+ case "timestamp": return z.string().describe("ISO-8601 timestamp");
9484
+ case "integer": return z.number().int();
9485
+ case "number": return z.number();
9486
+ case "boolean": return z.boolean();
9487
+ case "json": return z.unknown();
9488
+ case "text[]": return z.array(z.string());
9489
+ case "integer[]": return z.array(z.number().int());
9490
+ case "number[]": return z.array(z.number());
9491
+ case "boolean[]": return z.array(z.boolean());
9492
+ case "uuid[]": return z.array(z.string());
9493
+ case "vector": return z.array(z.number());
9494
+ default: return z.unknown();
9495
+ }
9496
+ }
9497
+ /**
9498
+ * Build the per-column `where` operator object. Every operator is
9499
+ * optional; the value type matches the column. A bare value is also
9500
+ * accepted as shorthand for `{ eq: value }` (mirrors the server, which
9501
+ * treats a non-object field value as equality).
9502
+ */
9503
+ function whereTypeForKind(kind, columnName) {
9504
+ const base = baseTypeForKind(kind);
9505
+ const isArray = kind.endsWith("[]");
9506
+ const isComparable = kind === "integer" || kind === "number" || kind === "date" || kind === "timestamp" || kind === "text";
9507
+ const isText = kind === "text";
9508
+ const ops = {
9509
+ eq: base.optional(),
9510
+ ne: base.optional(),
9511
+ in: z.array(base).optional(),
9512
+ notIn: z.array(base).optional(),
9513
+ isNull: z.boolean().optional(),
9514
+ isNotNull: z.boolean().optional()
9515
+ };
9516
+ if (isComparable) {
9517
+ ops.gt = base.optional();
9518
+ ops.gte = base.optional();
9519
+ ops.lt = base.optional();
9520
+ ops.lte = base.optional();
9521
+ }
9522
+ if (isText) {
9523
+ ops.like = z.string().optional().describe("SQL LIKE pattern (case-sensitive).");
9524
+ ops.ilike = z.string().optional().describe("SQL ILIKE pattern (case-insensitive).");
9525
+ }
9526
+ if (isArray) {
9527
+ ops.contains = base.optional().describe("Rows whose array contains all of these elements.");
9528
+ ops.containedBy = base.optional().describe("Rows whose array is contained by these elements.");
9529
+ ops.overlaps = base.optional().describe("Rows whose array shares any element with these.");
9530
+ }
9531
+ return z.union([z.object(ops), base]).optional().describe(`Filter on \`${columnName}\`. Bare value = equality; or an operator object.`);
9532
+ }
9533
+ /**
9534
+ * Whether a column is required on insert: NOT NULL and no column default.
9535
+ * A defaulted NOT NULL column is optional (the DB fills it).
9536
+ */
9537
+ function isRequiredOnInsert(column) {
9538
+ return column.is_nullable === "NO" && column.column_default === null;
9539
+ }
9540
+ /**
9541
+ * Project a collection schema into the zod raw shapes the auto-MCP tools
9542
+ * register. Pure — same input always yields the same shapes.
9543
+ */
9544
+ function mapCollectionSchema(schema, options = {}) {
9545
+ const insertShape = {};
9546
+ const whereShape = {};
9547
+ const updateShape = {};
9548
+ const rowTypeHints = {};
9549
+ const vectorColumns = [];
9550
+ for (const column of schema.columns) {
9551
+ const name = column.column_name;
9552
+ const kind = classifyColumn(column);
9553
+ rowTypeHints[name] = kind;
9554
+ if (kind === "vector") {
9555
+ vectorColumns.push(name);
9556
+ continue;
9557
+ }
9558
+ whereShape[name] = whereTypeForKind(kind, name);
9559
+ if (SERVER_MANAGED_COLUMNS.has(name)) {
9560
+ if (name === "user_id" && options.includeUserIdInInsert) insertShape[name] = baseTypeForKind(kind).optional().describe("Optional. The app user id to attribute this row to. Omit to insert without an owner (requires a shared collection) or use as_system.");
9561
+ continue;
9562
+ }
9563
+ const base = baseTypeForKind(kind);
9564
+ updateShape[name] = base.optional();
9565
+ insertShape[name] = isRequiredOnInsert(column) ? base : base.optional();
9566
+ }
9567
+ return {
9568
+ insertShape,
9569
+ whereShape,
9570
+ updateShape,
9571
+ rowTypeHints,
9572
+ vectorColumns,
9573
+ hasVector: vectorColumns.length > 0
9574
+ };
9575
+ }
9576
+ //#endregion
9577
+ //#region src/auto/describe.ts
9578
+ /**
9579
+ * SDK-grade per-tool description generator for the auto-MCP wedge.
9580
+ *
9581
+ * The wedge is only worth shipping if the generated tools read like a
9582
+ * hand-built typed client, not a thin `find_rows({collection, where})`
9583
+ * wrapper (design doc §7). These descriptions are generated from the live
9584
+ * column list + access policy so an agent never needs external docs:
9585
+ * column names, types, nullability, the access policy in plain words, and
9586
+ * a worked example per verb.
9587
+ *
9588
+ * Pure string builders — snapshot-friendly, no I/O. NO provider leakage:
9589
+ * "collection", "row", "column", "function" only — never the underlying
9590
+ * storage engine.
9591
+ */
9592
+ /** A human-friendly type label for a column kind. */
9593
+ function labelForKind(kind) {
9594
+ switch (kind) {
9595
+ case "integer": return "integer";
9596
+ case "number": return "number";
9597
+ case "boolean": return "boolean";
9598
+ case "json": return "JSON";
9599
+ case "uuid": return "uuid (string)";
9600
+ case "date": return "date string";
9601
+ case "timestamp": return "timestamp string";
9602
+ case "vector": return "vector";
9603
+ case "text[]": return "string array";
9604
+ case "integer[]": return "integer array";
9605
+ case "number[]": return "number array";
9606
+ case "boolean[]": return "boolean array";
9607
+ case "uuid[]": return "uuid array";
9608
+ case "text": return "text";
9609
+ default: return "value";
9610
+ }
9611
+ }
9612
+ /**
9613
+ * One-line summary of the collection's columns an agent can write to
9614
+ * (insert/update). Server-managed columns are excluded — the route stamps
9615
+ * them.
9616
+ */
9617
+ function writableColumnSummary(mapped) {
9618
+ const cols = Object.keys(mapped.rowTypeHints).filter((c) => !SERVER_MANAGED_COLUMNS.has(c) && mapped.rowTypeHints[c] !== "vector");
9619
+ if (cols.length === 0) return "(no writable columns)";
9620
+ return cols.map((c) => `${c} (${labelForKind(mapped.rowTypeHints[c])})`).join(", ");
9621
+ }
9622
+ /** All columns with types, for the find/list filter description. */
9623
+ function allColumnSummary(mapped) {
9624
+ const cols = Object.keys(mapped.rowTypeHints);
9625
+ if (cols.length === 0) return "(no columns)";
9626
+ return cols.map((c) => `${c} (${labelForKind(mapped.rowTypeHints[c])})`).join(", ");
9627
+ }
9628
+ /** Plain-words access-policy sentence for the given scope. */
9629
+ function policySentence(ctx) {
9630
+ if (ctx.scope === "admin") return "Developer/admin scope: sees and mutates EVERY row regardless of which app user owns it (no per-user scoping).";
9631
+ return `End-user scope: ${ctx.readPolicy === "public" ? "Reads return all rows (this collection is public-read)." : "Reads return only the signed-in user's own rows (plus shared/unowned rows)."} ${ctx.writePolicy === "authenticated" ? "Any signed-in user may write." : "Writes affect only the signed-in user's own rows."}`;
9632
+ }
9633
+ /** Build the first writable column name for worked examples. */
9634
+ function exampleWritableColumn(mapped) {
9635
+ return Object.keys(mapped.rowTypeHints).find((c) => !SERVER_MANAGED_COLUMNS.has(c) && mapped.rowTypeHints[c] !== "vector") ?? null;
9636
+ }
9637
+ /** Example value literal for a column kind (for worked examples). */
9638
+ function exampleValue(kind) {
9639
+ switch (kind) {
9640
+ case "integer": return "42";
9641
+ case "number": return "3.14";
9642
+ case "boolean": return "true";
9643
+ case "json": return "{\"key\":\"value\"}";
9644
+ case "uuid": return "\"00000000-0000-0000-0000-000000000000\"";
9645
+ case "date": return "\"2026-01-31\"";
9646
+ case "timestamp": return "\"2026-01-31T12:00:00Z\"";
9647
+ case "text[]": return "[\"a\",\"b\"]";
9648
+ case "integer[]": return "[1,2,3]";
9649
+ case "number[]": return "[1.5,2.5]";
9650
+ case "boolean[]": return "[true,false]";
9651
+ case "uuid[]": return "[\"00000000-0000-0000-0000-000000000000\"]";
9652
+ default: return "\"example\"";
9653
+ }
9654
+ }
9655
+ /**
9656
+ * Generate the description string for a single collection tool.
9657
+ */
9658
+ function describeCollectionTool(verb, ctx) {
9659
+ const { collection, mapped } = ctx;
9660
+ const policy = policySentence(ctx);
9661
+ const exampleCol = exampleWritableColumn(mapped);
9662
+ const exampleKind = exampleCol ? mapped.rowTypeHints[exampleCol] : "text";
9663
+ switch (verb) {
9664
+ case "list": return [
9665
+ `List rows in the "${collection}" collection, newest first.`,
9666
+ `Returns a page of rows plus a \`next_cursor\` — pass it back as \`cursor\` to fetch the next page.`,
9667
+ `Columns: ${allColumnSummary(mapped)}.`,
9668
+ policy,
9669
+ `Example: { "limit": 20 }.`
9670
+ ].join(" ");
9671
+ case "get": return [
9672
+ `Fetch one row from "${collection}" by its \`id\`.`,
9673
+ `Returns the full row, or a 404 if no row with that id exists (or it is outside your scope).`,
9674
+ policy,
9675
+ `Example: { "id": "00000000-0000-0000-0000-000000000000" }.`
9676
+ ].join(" ");
9677
+ case "find": return [
9678
+ `Find rows in "${collection}" with a typed, per-column filter.`,
9679
+ `Each \`where\` key is a column; its value is a bare value (equality) or an operator object`,
9680
+ `({ eq, ne, gt, gte, lt, lte, in, notIn, like, ilike, isNull, isNotNull, contains, containedBy, overlaps }).`,
9681
+ `Combine with top-level \`and\`/\`or\`/\`not\`. Supports \`order\`, \`limit\`, and \`cursor\` pagination (returns \`next_cursor\`).`,
9682
+ `Columns: ${allColumnSummary(mapped)}.`,
9683
+ policy,
9684
+ exampleCol ? `Example: { "where": { "${exampleCol}": { "eq": ${exampleValue(exampleKind)} } }, "limit": 20 }.` : `Example: { "where": {}, "limit": 20 }.`
9685
+ ].join(" ");
9686
+ case "insert": return [
9687
+ `Insert a new row into "${collection}".`,
9688
+ `Provide values for the writable columns: ${writableColumnSummary(mapped)}.`,
9689
+ `Server-managed columns (id, user_id, created_at, updated_at, deleted_at) are stamped automatically — do not send them.`,
9690
+ policy,
9691
+ exampleCol ? `Example: { "${exampleCol}": ${exampleValue(exampleKind)} }.` : `Example: {}.`
9692
+ ].join(" ");
9693
+ case "update": return [
9694
+ `Update an existing row in "${collection}" by \`id\` (merge-patch — only the fields you send change).`,
9695
+ `Writable columns: ${writableColumnSummary(mapped)}.`,
9696
+ `Server-managed columns are rejected.`,
9697
+ policy,
9698
+ exampleCol ? `Example: { "id": "…", "set": { "${exampleCol}": ${exampleValue(exampleKind)} } }.` : `Example: { "id": "…", "set": {} }.`
9699
+ ].join(" ");
9700
+ case "delete": return [
9701
+ `Delete a row from "${collection}" by \`id\`.`,
9702
+ `Soft-delete by default (recoverable); pass \`hard: true\` for a permanent removal.`,
9703
+ policy,
9704
+ `Example: { "id": "00000000-0000-0000-0000-000000000000" }.`
9705
+ ].join(" ");
9706
+ case "aggregate": return [
9707
+ `Compute aggregates over "${collection}": count / sum / avg / min / max, optionally grouped by columns.`,
9708
+ `\`count\` with no column = COUNT(*). Combine with a typed \`where\` to aggregate a subset.`,
9709
+ `Columns: ${allColumnSummary(mapped)}.`,
9710
+ policy,
9711
+ `Example: { "select": [{ "fn": "count" }] }.`
9712
+ ].join(" ");
9713
+ case "find_nearest": return [
9714
+ `Vector similarity search over "${collection}" — returns the rows whose embedding is closest to your query vector.`,
9715
+ `This tool exists because "${collection}" has a vector column (${mapped.vectorColumns.join(", ")}).`,
9716
+ policy,
9717
+ `Example: { "vector": [0.1, 0.2, …], "limit": 5 }.`
9718
+ ].join(" ");
9719
+ default: return `Operate on the "${collection}" collection.`;
9720
+ }
9721
+ }
9722
+ /** Description for the `<ns>_list_collections` meta tool. */
9723
+ function describeListCollections(namespace) {
9724
+ return [
9725
+ `List every collection exposed by this app's agent surface (namespace "${namespace}").`,
9726
+ `Each collection projects typed list/get/find/insert/update/delete/aggregate tools you can call directly —`,
9727
+ `this tool is the index. Returns each collection's name and column summary.`
9728
+ ].join(" ");
9729
+ }
9730
+ /** Description for the `<ns>_whoami` meta tool. */
9731
+ function describeWhoami(namespace) {
9732
+ return [`Report which app project this agent surface is connected to and the scope of the current credential`, `(namespace "${namespace}"). Use it to confirm you are pointed at the right app before mutating data.`].join(" ");
9733
+ }
9734
+ //#endregion
9735
+ //#region src/auto/collection-tools.ts
9736
+ /** Admin-route fetch (mirrors tools/collections.ts adminFetch). */
9737
+ async function adminFetch(apiClient, bearer, options) {
9738
+ let url = `${apiClient.getBaseUrl()}${options.path}`;
9739
+ if (options.query && Object.keys(options.query).length > 0) url += `?${new URLSearchParams(options.query).toString()}`;
9740
+ const headers = {
9741
+ Authorization: `Bearer ${bearer}`,
9742
+ Accept: "application/json"
9743
+ };
9744
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
9745
+ const res = await fetch(url, {
9746
+ method: options.method,
9747
+ headers,
9748
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
9749
+ });
9750
+ let body;
9751
+ try {
9752
+ body = await res.json();
9753
+ } catch {
9754
+ body = { error: {
9755
+ code: "INVALID_RESPONSE",
9756
+ message: res.statusText
9757
+ } };
9758
+ }
9759
+ return {
9760
+ status: res.status,
9761
+ body
9762
+ };
9763
+ }
9764
+ /**
9765
+ * Tool-name fragment regex. MCP + `tool-naming.test.ts` require lowercase
9766
+ * `_`-separated names. The namespace + collection are sanitized to that
9767
+ * shape so a slug like "My App" or a collection like "MyRecipes" never
9768
+ * produces an invalid tool name.
9769
+ */
9770
+ function sanitizeNameFragment(raw) {
9771
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
9772
+ return /^[a-z]/.test(cleaned) ? cleaned : `c_${cleaned}`;
9773
+ }
9774
+ /** Build the `<ns>_<coll>_<verb>` tool name. */
9775
+ function collectionToolName(namespace, collection, verb) {
9776
+ return `${sanitizeNameFragment(namespace)}_${sanitizeNameFragment(collection)}_${verb}`;
9777
+ }
9778
+ /**
9779
+ * Register the typed CRUD tools for a single collection on `server`,
9780
+ * proxying to the existing admin routes via `apiClient`.
9781
+ */
9782
+ function registerCollectionToolsFor(server, apiClient, options) {
9783
+ const { projectId, namespace, collection, schema, policy, scope } = options;
9784
+ const mapped = mapCollectionSchema(schema, { includeUserIdInInsert: scope === "admin" });
9785
+ const describeCtx = {
9786
+ collection,
9787
+ mapped,
9788
+ readPolicy: policy.read_policy,
9789
+ writePolicy: policy.write_policy,
9790
+ scope
9791
+ };
9792
+ const rowsPath = `/projects/${projectId}/collections/${encodeURIComponent(collection)}/rows`;
9793
+ const name = (verb) => collectionToolName(namespace, collection, verb);
9794
+ registerTool(server, apiClient, name("list"), describeCollectionTool("list", describeCtx), {
9795
+ limit: z.number().int().positive().optional().describe("Max rows (default 50)."),
9796
+ cursor: z.string().optional().describe("Opaque cursor from a prior page."),
9797
+ order: z.union([z.string(), z.array(z.string())]).optional().describe("Order spec, e.g. \"created_at desc\"."),
9798
+ select: z.array(z.string()).optional().describe("Subset of columns to return."),
9799
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default false.")
9800
+ }, async ({ limit, cursor, order, select, include_deleted }, { pat }) => {
9801
+ const findQuery = {};
9802
+ if (limit !== void 0) findQuery.limit = limit;
9803
+ if (cursor !== void 0) findQuery.cursor = cursor;
9804
+ if (order !== void 0) findQuery.order = order;
9805
+ if (select !== void 0) findQuery.select = select;
9806
+ if (include_deleted !== void 0) findQuery.includeDeleted = include_deleted;
9807
+ return passthroughResult(await adminFetch(apiClient, pat, {
9808
+ method: "GET",
9809
+ path: rowsPath,
9810
+ query: Object.keys(findQuery).length > 0 ? { query: JSON.stringify(findQuery) } : void 0
9811
+ }));
9812
+ });
9813
+ registerTool(server, apiClient, name("get"), describeCollectionTool("get", describeCtx), { id: z.string().describe("Row id (uuid).") }, async ({ id }, { pat }) => {
9814
+ return passthroughResult(await adminFetch(apiClient, pat, {
9815
+ method: "GET",
9816
+ path: `${rowsPath}/${encodeURIComponent(id)}`
9817
+ }));
9818
+ });
9819
+ registerTool(server, apiClient, name("find"), describeCollectionTool("find", describeCtx), {
9820
+ where: z.object({
9821
+ ...mapped.whereShape,
9822
+ and: z.array(z.unknown()).optional().describe("AND-combine nested where clauses."),
9823
+ or: z.array(z.unknown()).optional().describe("OR-combine nested where clauses."),
9824
+ not: z.unknown().optional().describe("Negate a nested where clause.")
9825
+ }).partial().optional().describe("Typed, per-column filter."),
9826
+ order: z.union([z.string(), z.array(z.string())]).optional().describe("Order spec, e.g. \"created_at desc\"."),
9827
+ limit: z.number().int().positive().optional().describe("Max rows (default 50)."),
9828
+ cursor: z.string().optional().describe("Opaque cursor from a prior page."),
9829
+ select: z.array(z.string()).optional().describe("Subset of columns to return."),
9830
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default false.")
9831
+ }, async ({ where, order, limit, cursor, select, include_deleted }, { pat }) => {
9832
+ const findQuery = {};
9833
+ if (where !== void 0) findQuery.where = where;
9834
+ if (order !== void 0) findQuery.order = order;
9835
+ if (limit !== void 0) findQuery.limit = limit;
9836
+ if (cursor !== void 0) findQuery.cursor = cursor;
9837
+ if (select !== void 0) findQuery.select = select;
9838
+ if (include_deleted !== void 0) findQuery.includeDeleted = include_deleted;
9839
+ return passthroughResult(await adminFetch(apiClient, pat, {
9840
+ method: "GET",
9841
+ path: rowsPath,
9842
+ query: Object.keys(findQuery).length > 0 ? { query: JSON.stringify(findQuery) } : void 0
9843
+ }));
9844
+ });
9845
+ registerTool(server, apiClient, name("insert"), describeCollectionTool("insert", describeCtx), {
9846
+ ...mapped.insertShape,
9847
+ ...scope === "admin" ? { as_system: z.boolean().optional().describe("Admin only. On a shared collection, insert a global row with no owner (user_id NULL). Mutually exclusive with user_id.") } : {}
9848
+ }, async (args, { pat }) => {
9849
+ const row = {};
9850
+ for (const [k, v] of Object.entries(args)) {
9851
+ if (k === "pat") continue;
9852
+ if (v !== void 0) row[k] = v;
9853
+ }
9854
+ return passthroughResult(await adminFetch(apiClient, pat, {
9855
+ method: "POST",
9856
+ path: rowsPath,
9857
+ body: row
9858
+ }));
9859
+ });
9860
+ registerTool(server, apiClient, name("update"), describeCollectionTool("update", describeCtx), {
9861
+ id: z.string().describe("Row id (uuid) to update."),
9862
+ set: z.object({ ...mapped.updateShape }).describe("Column→value map of the fields to change (merge-patch).")
9863
+ }, async ({ id, set }, { pat }) => {
9864
+ return passthroughResult(await adminFetch(apiClient, pat, {
9865
+ method: "PATCH",
9866
+ path: `${rowsPath}/${encodeURIComponent(id)}`,
9867
+ body: { set }
9868
+ }));
9869
+ });
9870
+ registerTool(server, apiClient, name("delete"), describeCollectionTool("delete", describeCtx), {
9871
+ id: z.string().describe("Row id (uuid) to delete."),
9872
+ hard: z.boolean().optional().describe("false (default) soft-deletes (recoverable); true permanently removes.")
9873
+ }, async ({ id, hard }, { pat }) => {
9874
+ return passthroughResult(await adminFetch(apiClient, pat, {
9875
+ method: "DELETE",
9876
+ path: `${rowsPath}/${encodeURIComponent(id)}`,
9877
+ query: hard === true ? { hard: "true" } : void 0
9878
+ }));
9879
+ });
9880
+ registerTool(server, apiClient, name("aggregate"), describeCollectionTool("aggregate", describeCtx), {
9881
+ select: z.array(z.object({
9882
+ fn: z.enum([
9883
+ "count",
9884
+ "sum",
9885
+ "avg",
9886
+ "min",
9887
+ "max"
9888
+ ]),
9889
+ column: z.string().optional().describe("Column to aggregate. Required for sum/avg/min/max; omit for count(*)."),
9890
+ as: z.string().optional().describe("Output alias.")
9891
+ })).min(1).describe("One or more aggregations to compute."),
9892
+ group_by: z.union([z.string(), z.array(z.string())]).optional().describe("Column(s) to group by. Omit for a whole-collection aggregate."),
9893
+ where: z.object({ ...mapped.whereShape }).partial().optional().describe("Typed, per-column filter to aggregate a subset."),
9894
+ include_deleted: z.boolean().optional().describe("Include soft-deleted rows. Default false.")
9895
+ }, async ({ select, group_by, where, include_deleted }, { pat }) => {
9896
+ const body = { select };
9897
+ if (group_by !== void 0) body.group_by = group_by;
9898
+ if (where !== void 0) body.where = where;
9899
+ if (include_deleted !== void 0) body.includeDeleted = include_deleted;
9900
+ return passthroughResult(await adminFetch(apiClient, pat, {
9901
+ method: "POST",
9902
+ path: `${rowsPath}/aggregate`,
9903
+ body
9904
+ }));
9905
+ });
9906
+ }
9907
+ //#endregion
9908
+ //#region src/auto/index.ts
9909
+ /** Page size for the admin collections list (the route caps at 200). */
9910
+ const COLLECTION_PAGE_SIZE = 200;
9911
+ /**
9912
+ * Hard cap on total pages fetched, so a bad `total` (or a route that never
9913
+ * advances) can't spin forever. 200 * 100 = 20k collections — far beyond
9914
+ * any real project.
9915
+ */
9916
+ const COLLECTION_MAX_PAGES = 100;
9917
+ /**
9918
+ * Fetch EVERY collection name for a project, advancing `offset` until the
9919
+ * reported `pagination.total` is reached (the admin list route caps each
9920
+ * page at 200).
9921
+ *
9922
+ * Returns `{ ok, names }`. `ok` is `false` if ANY page (first OR a later
9923
+ * one) returns a non-success status — a partial list must not masquerade
9924
+ * as complete, or the caller would silently register only some of the
9925
+ * project's collections as tools. On a mid-pagination failure the
9926
+ * already-fetched `names` are still returned alongside `ok:false` so a
9927
+ * caller can surface a diagnostic that includes what it did see.
9928
+ */
9929
+ async function listAllCollections(apiClient, projectId) {
9930
+ const names = [];
9931
+ let offset = 0;
9932
+ for (let page = 0; page < COLLECTION_MAX_PAGES; page++) {
9933
+ const res = await adminGet(apiClient, `/projects/${projectId}/collections?limit=${COLLECTION_PAGE_SIZE}&offset=${offset}`);
9934
+ if (res.status < 200 || res.status >= 300) return {
9935
+ ok: false,
9936
+ names
9937
+ };
9938
+ const pageNames = (res.body.data ?? []).map((c) => c.name).filter((n) => typeof n === "string" && n.length > 0);
9939
+ names.push(...pageNames);
9940
+ const total = res.body.pagination?.total;
9941
+ offset += COLLECTION_PAGE_SIZE;
9942
+ if (typeof total === "number") {
9943
+ if (offset >= total) break;
9944
+ } else if (pageNames.length < COLLECTION_PAGE_SIZE) break;
9945
+ }
9946
+ return {
9947
+ ok: true,
9948
+ names
9949
+ };
9950
+ }
9951
+ /** Admin GET helper bound to a Bearer (no pat-arg machinery needed here). */
9952
+ async function adminGet(apiClient, path) {
9953
+ const res = await fetch(`${apiClient.getBaseUrl()}${path}`, {
9954
+ method: "GET",
9955
+ headers: {
9956
+ Authorization: `Bearer ${await apiClientToken(apiClient)}`,
9957
+ Accept: "application/json"
9958
+ }
9959
+ });
9960
+ let body;
9961
+ try {
9962
+ body = await res.json();
9963
+ } catch {
9964
+ body = {};
9965
+ }
9966
+ return {
9967
+ status: res.status,
9968
+ body
9969
+ };
9970
+ }
9971
+ async function apiClientToken(apiClient) {
9972
+ return await apiClient.resolveTokenOrNull() ?? "";
9973
+ }
9974
+ /**
9975
+ * Register the auto-MCP tool surface for one project on `server`.
9976
+ *
9977
+ * Lists + introspects the project's collections (admin routes) and
9978
+ * registers a typed tool set per collection, plus the two meta tools.
9979
+ * Returns the names of the collections that were exposed plus `listOk`
9980
+ * (false when the collection listing was incomplete — a paginated page
9981
+ * failed — in which case typed tools are deliberately NOT registered so a
9982
+ * truncated set never masquerades as the whole project).
9983
+ */
9984
+ async function registerAutoMcpTools(server, apiClient, options) {
9985
+ const { projectId, scope } = options;
9986
+ const namespace = sanitizeNameFragment(options.namespace ?? projectId);
9987
+ registerTool(server, apiClient, `${namespace}_whoami`, describeWhoami(namespace), {}, async (_args, { pat }) => {
9988
+ const me = await adminGet(apiClient.withToken(pat), `/projects/${projectId}`);
9989
+ return jsonResult$1({
9990
+ status: me.status,
9991
+ project_id: projectId,
9992
+ scope,
9993
+ namespace,
9994
+ authenticated: me.status >= 200 && me.status < 300
9995
+ });
9996
+ });
9997
+ let collections = [];
9998
+ let listOk = false;
9999
+ try {
10000
+ const all = await listAllCollections(apiClient, projectId);
10001
+ listOk = all.ok;
10002
+ collections = all.ok ? all.names : [];
10003
+ } catch {
10004
+ collections = [];
10005
+ listOk = false;
10006
+ }
10007
+ registerTool(server, apiClient, `${namespace}_list_collections`, describeListCollections(namespace), {}, async (_args, { pat }) => {
10008
+ const boundClient = apiClient.withToken(pat);
10009
+ const all = await listAllCollections(boundClient, projectId);
10010
+ const names = all.names;
10011
+ const described = await Promise.all(names.map(async (collection) => {
10012
+ let exposed = false;
10013
+ try {
10014
+ const res = await adminGet(boundClient, `/projects/${projectId}/collections/${encodeURIComponent(collection)}`);
10015
+ exposed = res.status >= 200 && res.status < 300 && Array.isArray(res.body.data?.columns);
10016
+ } catch {
10017
+ exposed = false;
10018
+ }
10019
+ return {
10020
+ collection,
10021
+ exposed
10022
+ };
10023
+ }));
10024
+ return jsonResult$1({
10025
+ status: all.ok ? 200 : 502,
10026
+ list_complete: all.ok,
10027
+ namespace,
10028
+ project_id: projectId,
10029
+ scope,
10030
+ collections: described.map(({ collection, exposed }) => ({
10031
+ collection,
10032
+ exposed,
10033
+ tools: exposed ? [
10034
+ "list",
10035
+ "get",
10036
+ "find",
10037
+ "insert",
10038
+ "update",
10039
+ "delete",
10040
+ "aggregate"
10041
+ ].map((v) => collectionToolName(namespace, collection, v)) : []
10042
+ }))
10043
+ });
10044
+ });
10045
+ for (const collection of collections) {
10046
+ let described;
10047
+ try {
10048
+ const res = await adminGet(apiClient, `/projects/${projectId}/collections/${encodeURIComponent(collection)}`);
10049
+ if (res.status < 200 || res.status >= 300) continue;
10050
+ described = res.body;
10051
+ } catch {
10052
+ continue;
10053
+ }
10054
+ const data = described.data;
10055
+ if (!data || !Array.isArray(data.columns)) continue;
10056
+ const policy = {
10057
+ read_policy: data.read_policy ?? "owner",
10058
+ write_policy: data.write_policy ?? "owner"
10059
+ };
10060
+ registerCollectionToolsFor(server, apiClient, {
10061
+ projectId,
10062
+ namespace,
10063
+ collection,
10064
+ schema: {
10065
+ name: data.name,
10066
+ columns: data.columns
10067
+ },
10068
+ policy,
10069
+ scope
10070
+ });
10071
+ }
10072
+ return {
10073
+ namespace,
10074
+ collections,
10075
+ listOk
10076
+ };
10077
+ }
10078
+ //#endregion
8271
10079
  //#region src/index.ts
8272
10080
  /**
8273
10081
  * Register every Amba MCP tool group against the given server, using the
@@ -8275,7 +10083,11 @@ function getToolCategory(toolName) {
8275
10083
  * called against it once.
8276
10084
  */
8277
10085
  function registerAllTools(server, apiClient) {
8278
- registerTools$13(server, apiClient);
10086
+ registerTools$15(server, apiClient);
10087
+ registerTools$37(server, apiClient);
10088
+ registerTools$36(server, apiClient);
10089
+ registerTools$35(server, apiClient);
10090
+ registerTools$34(server, apiClient);
8279
10091
  registerTools$33(server, apiClient);
8280
10092
  registerTools$32(server, apiClient);
8281
10093
  registerTools$31(server, apiClient);
@@ -8294,8 +10106,8 @@ function registerAllTools(server, apiClient) {
8294
10106
  registerTools$18(server, apiClient);
8295
10107
  registerTools$17(server, apiClient);
8296
10108
  registerTools$16(server, apiClient);
8297
- registerTools$15(server, apiClient);
8298
10109
  registerTools$14(server, apiClient);
10110
+ registerTools$13(server, apiClient);
8299
10111
  registerTools$12(server, apiClient);
8300
10112
  registerTools$11(server, apiClient);
8301
10113
  registerTools$10(server, apiClient);
@@ -8329,4 +10141,4 @@ function createApiClient(options) {
8329
10141
  return new ApiClient(opts);
8330
10142
  }
8331
10143
  //#endregion
8332
- export { AMBA_SETUP_ECONOMY_MD, AMBA_SETUP_ECONOMY_MIME, AMBA_SETUP_ECONOMY_URI, AMBA_SETUP_ENGAGEMENT_MD, AMBA_SETUP_ENGAGEMENT_MIME, AMBA_SETUP_ENGAGEMENT_URI, AMBA_SETUP_GAMIFICATION_MD, AMBA_SETUP_GAMIFICATION_MIME, AMBA_SETUP_GAMIFICATION_URI, AMBA_SETUP_GUIDE_BODY_VERSION, AMBA_SETUP_GUIDE_MD, AMBA_SETUP_GUIDE_MIME, AMBA_SETUP_GUIDE_URI, AMBA_SETUP_IDENTITY_MD, AMBA_SETUP_IDENTITY_MIME, AMBA_SETUP_IDENTITY_URI, AMBA_SETUP_INFRASTRUCTURE_MD, AMBA_SETUP_INFRASTRUCTURE_MIME, AMBA_SETUP_INFRASTRUCTURE_URI, AMBA_SETUP_SOCIAL_MD, AMBA_SETUP_SOCIAL_MIME, AMBA_SETUP_SOCIAL_URI, AMBA_SETUP_SUB_RESOURCES, ApiClient, CATEGORY_META, CATEGORY_ORDER, EXPO_BUILD_PROMPT_MD, EXPO_BUILD_PROMPT_MIME, EXPO_BUILD_PROMPT_URI, TOOL_CATEGORY, createApiClient, getToolCategory, registerAllResources, registerAllTools };
10144
+ export { AMBA_SETUP_ECONOMY_MD, AMBA_SETUP_ECONOMY_MIME, AMBA_SETUP_ECONOMY_URI, AMBA_SETUP_ENGAGEMENT_MD, AMBA_SETUP_ENGAGEMENT_MIME, AMBA_SETUP_ENGAGEMENT_URI, AMBA_SETUP_GAMIFICATION_MD, AMBA_SETUP_GAMIFICATION_MIME, AMBA_SETUP_GAMIFICATION_URI, AMBA_SETUP_GUIDE_BODY_VERSION, AMBA_SETUP_GUIDE_MD, AMBA_SETUP_GUIDE_MIME, AMBA_SETUP_GUIDE_URI, AMBA_SETUP_IDENTITY_MD, AMBA_SETUP_IDENTITY_MIME, AMBA_SETUP_IDENTITY_URI, AMBA_SETUP_INFRASTRUCTURE_MD, AMBA_SETUP_INFRASTRUCTURE_MIME, AMBA_SETUP_INFRASTRUCTURE_URI, AMBA_SETUP_SOCIAL_MD, AMBA_SETUP_SOCIAL_MIME, AMBA_SETUP_SOCIAL_URI, AMBA_SETUP_SUB_RESOURCES, ApiClient, CATEGORY_META, CATEGORY_ORDER, EXPO_BUILD_PROMPT_MD, EXPO_BUILD_PROMPT_MIME, EXPO_BUILD_PROMPT_URI, SERVER_MANAGED_COLUMNS, TOOL_CATEGORY, classifyColumn, collectionToolName, createApiClient, describeCollectionTool, describeListCollections, describeWhoami, getToolCategory, mapCollectionSchema, registerAllResources, registerAllTools, registerAutoMcpTools, registerCollectionToolsFor, sanitizeNameFragment };