@carrierllc/mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CARRIER_VERSION,
4
+ TARGET_IDS,
4
5
  acquireEndpointSlot,
6
+ buildSite,
7
+ clerkCliDeps,
8
+ configureClerkInstance,
9
+ deploySite,
10
+ esimStatusPerAccountParams,
5
11
  extractEsimStatusCounts,
12
+ fleetScreen,
13
+ formatProbes,
6
14
  generateStorefrontLogo,
7
15
  getLimitForEndpoint,
8
16
  getRateLimitWindowCounts,
17
+ imsiFromSubscriberRecord,
18
+ isTargetId,
19
+ loadStorefrontBrand,
9
20
  locationParams,
21
+ mergeEnvLocal,
10
22
  networkEventsOverPeriodParams,
23
+ normalizePackageTemplate,
24
+ normalizePackageTemplateChanges,
25
+ probeAll,
26
+ provisionClerk,
27
+ rankTargets,
28
+ renderHtml,
29
+ repairPlanFor,
30
+ storefrontClerkUrls,
11
31
  subscriberIdParams,
12
- usageOverPeriodParams
13
- } from "./chunk-TABEJ7SM.js";
32
+ usageOverPeriodParams,
33
+ verifyStorefront
34
+ } from "./chunk-RBG4MKMW.js";
35
+ import "./chunk-SHKKVIIA.js";
14
36
 
15
37
  // src/index.ts
16
38
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -158,6 +180,212 @@ function firstDayNextMonth() {
158
180
  return new Date(Date.UTC(y, m, 1)).toISOString();
159
181
  }
160
182
 
183
+ // src/bounded-results.ts
184
+ var TARIFF_MAX_RULES = 3e3;
185
+ var TARIFF_VERBOSE_MAX_RULES = 250;
186
+ var ZONES_MAX_ZONES = 100;
187
+ var ZONES_MAX_OPERATORS = 2e3;
188
+ var ZONES_VERBOSE_MAX_OPERATORS = 400;
189
+ function extractTariffRules(raw) {
190
+ if (Array.isArray(raw)) return raw;
191
+ if (raw && typeof raw === "object") {
192
+ const obj = raw;
193
+ for (const key of ["rule", "listTariffRule", "rules"]) {
194
+ const v = obj[key];
195
+ if (Array.isArray(v)) return v;
196
+ if (v && typeof v === "object" && Array.isArray(v.rule)) {
197
+ return v.rule;
198
+ }
199
+ }
200
+ }
201
+ return [];
202
+ }
203
+ function extractZones(raw) {
204
+ if (Array.isArray(raw)) return raw;
205
+ if (raw && typeof raw === "object") {
206
+ const obj = raw;
207
+ for (const key of ["listDetailedLocationZone", "locationZone", "zone", "zones"]) {
208
+ const v = obj[key];
209
+ if (Array.isArray(v)) return v;
210
+ }
211
+ }
212
+ return [];
213
+ }
214
+ function matchesCountry(op, country) {
215
+ const want = country.trim().toLowerCase();
216
+ if (!want) return true;
217
+ const iso = (op?.countryIso2 ?? "").trim().toLowerCase();
218
+ const name = (op?.countryName ?? "").trim().toLowerCase();
219
+ return iso === want || name === want;
220
+ }
221
+ function matchesTrafficType(rule, trafficType) {
222
+ const gt0 = (v) => typeof v === "number" && v > 0;
223
+ switch (trafficType) {
224
+ case "data":
225
+ return gt0(rule.datarate);
226
+ case "voice":
227
+ return gt0(rule.mocallrate) || gt0(rule.mtcallrate);
228
+ case "sms":
229
+ return gt0(rule.mosmsrate) || gt0(rule.mtsmsrate);
230
+ }
231
+ }
232
+ var RATE_COLUMNS = {
233
+ data: [["datarate", "data"]],
234
+ voice: [
235
+ ["mocallrate", "moCall"],
236
+ ["mtcallrate", "mtCall"]
237
+ ],
238
+ sms: [
239
+ ["mosmsrate", "moSms"],
240
+ ["mtsmsrate", "mtSms"]
241
+ ]
242
+ };
243
+ var ALL_RATE_COLUMNS = [
244
+ ["datarate", "data"],
245
+ ["mocallrate", "moCall"],
246
+ ["mtcallrate", "mtCall"],
247
+ ["mosmsrate", "moSms"],
248
+ ["mtsmsrate", "mtSms"]
249
+ ];
250
+ var FIELD_LEGEND = {
251
+ iso: "ISO-3166 alpha-2 country code",
252
+ op: "operator / network name",
253
+ data: "data rate per MB",
254
+ moCall: "mobile-originated call rate per minute",
255
+ mtCall: "mobile-terminated call rate per minute",
256
+ moSms: "mobile-originated SMS rate",
257
+ mtSms: "mobile-terminated SMS rate",
258
+ active: "present only when the rule is active; absent means inactive"
259
+ };
260
+ function truncationNote(kind, returned, matched) {
261
+ if (kind === "tariff") {
262
+ return `TRUNCATED: showing ${returned.toLocaleString("en-US")} of ${matched.toLocaleString("en-US")} matching tariff rules (cap ${TARIFF_MAX_RULES.toLocaleString("en-US")} rows, imposed so the result fits the MCP transport \u2014 see issue #663). Narrow the request to see the rest: pass 'country' (ISO2 such as "nl" or a full name such as "Netherlands") and/or 'trafficType' ("data" | "voice" | "sms"). These filters are applied server-side to the complete OCS table, so a filtered call sees every matching rule, not just the rules in this sample.`;
263
+ }
264
+ return `TRUNCATED: showing ${returned.toLocaleString("en-US")} of ${matched.toLocaleString("en-US")} operator entries across the returned zones (cap ${ZONES_MAX_OPERATORS.toLocaleString("en-US")} entries, imposed so the result fits the MCP transport \u2014 see issue #663). Pass 'locationZoneId' to retrieve a single zone in full.`;
265
+ }
266
+ function boundTariffResult(raw, opts = {}) {
267
+ const all = extractTariffRules(raw);
268
+ const { country, trafficType, verbose = false, resellerId } = opts;
269
+ const matched = all.filter(
270
+ (r) => (country === void 0 || matchesCountry(r.operator, country)) && (trafficType === void 0 || matchesTrafficType(r, trafficType))
271
+ );
272
+ const cap = opts.maxRules ?? (verbose ? TARIFF_VERBOSE_MAX_RULES : TARIFF_MAX_RULES);
273
+ const kept = matched.slice(0, cap);
274
+ const truncated = kept.length < matched.length;
275
+ const result2 = {
276
+ resellerId,
277
+ totalRules: all.length,
278
+ matchedRules: matched.length,
279
+ returnedRules: kept.length,
280
+ truncated,
281
+ projection: verbose ? "verbose" : "compact",
282
+ rules: []
283
+ };
284
+ if (country !== void 0 || trafficType !== void 0) {
285
+ result2.filters = {};
286
+ if (country !== void 0) result2.filters.country = country;
287
+ if (trafficType !== void 0) result2.filters.trafficType = trafficType;
288
+ }
289
+ if (verbose) {
290
+ result2.rules = kept;
291
+ if (truncated) result2.note = truncationNote("tariff", kept.length, matched.length);
292
+ return result2;
293
+ }
294
+ const currencies = new Set(
295
+ kept.map((r) => r.currency?.currencycode).filter((c) => typeof c === "string")
296
+ );
297
+ const hoistCurrency = currencies.size === 1;
298
+ if (hoistCurrency) result2.currency = [...currencies][0];
299
+ const columns = trafficType ? RATE_COLUMNS[trafficType] : ALL_RATE_COLUMNS;
300
+ result2.rules = kept.map((r) => {
301
+ const op = r.operator ?? {};
302
+ const row = {};
303
+ if (op.countryIso2 !== void 0) row.iso = op.countryIso2;
304
+ if (op.operatorName !== void 0) row.op = op.operatorName;
305
+ for (const [src, dst] of columns) {
306
+ const v = r[src];
307
+ if (typeof v === "number" && v !== 0) row[dst] = v;
308
+ }
309
+ if (!hoistCurrency && typeof r.currency?.currencycode === "string") {
310
+ row.cur = r.currency.currencycode;
311
+ }
312
+ if (r.active === true) row.active = true;
313
+ return row;
314
+ });
315
+ const legend = { ...FIELD_LEGEND };
316
+ if (trafficType) {
317
+ for (const key of ["data", "moCall", "mtCall", "moSms", "mtSms"]) {
318
+ if (!columns.some(([, dst]) => dst === key)) delete legend[key];
319
+ }
320
+ }
321
+ if (!hoistCurrency) legend.cur = "ISO-4217 currency code";
322
+ result2.fields = legend;
323
+ const notes = [];
324
+ if (truncated) notes.push(truncationNote("tariff", kept.length, matched.length));
325
+ notes.push(
326
+ `Projected result: nested operator detail (mccMncs, tadigs, continent, countryCode, utcOffset), the sponsor object, plan ids (roamingplanruleid, roamingplanid, sponsoridx) and the validity/discount flags (startdate, enddate, isDiscounted, hidden, dailyCap) are omitted. Pass verbose=true for the full untouched rule shape (capped at ${TARIFF_VERBOSE_MAX_RULES} rows \u2014 use 'country'/'trafficType' with it).`
327
+ );
328
+ result2.note = notes.join(" ");
329
+ return result2;
330
+ }
331
+ var ZONE_FIELD_LEGEND = {
332
+ zoneId: "location zone id \u2014 pass to package-template tools",
333
+ zoneName: "human-readable zone name",
334
+ operatorCount: "total operator entries in this zone upstream",
335
+ operators: "projected operator entries: net (networkId), iso (country ISO2), op (operator name)"
336
+ };
337
+ function boundZonesResult(raw, opts = {}) {
338
+ const all = extractZones(raw);
339
+ const { verbose = false, resellerId } = opts;
340
+ const maxZones = opts.maxZones ?? ZONES_MAX_ZONES;
341
+ const maxOperators = opts.maxOperators ?? (verbose ? ZONES_VERBOSE_MAX_OPERATORS : ZONES_MAX_OPERATORS);
342
+ const totalOperators = all.reduce((n, z23) => n + (z23.operators?.length ?? 0), 0);
343
+ const keptZones = all.slice(0, maxZones);
344
+ let budget = maxOperators;
345
+ let returnedOperators = 0;
346
+ const zones = keptZones.map((z23) => {
347
+ const ops = z23.operators ?? [];
348
+ const take = Math.max(0, Math.min(ops.length, budget));
349
+ budget -= take;
350
+ returnedOperators += take;
351
+ const sliced = ops.slice(0, take);
352
+ if (verbose) {
353
+ return { ...z23, operators: sliced, operatorCount: ops.length };
354
+ }
355
+ return {
356
+ zoneId: z23.zoneId,
357
+ zoneName: z23.zoneName,
358
+ operatorCount: ops.length,
359
+ operators: sliced.map((o) => ({
360
+ net: o.networkId,
361
+ iso: o.countryIso2,
362
+ op: o.operatorName
363
+ }))
364
+ };
365
+ });
366
+ const truncated = keptZones.length < all.length || returnedOperators < totalOperators;
367
+ const result2 = {
368
+ resellerId,
369
+ totalZones: all.length,
370
+ returnedZones: keptZones.length,
371
+ totalOperators,
372
+ returnedOperators,
373
+ truncated,
374
+ projection: verbose ? "verbose" : "compact",
375
+ zones
376
+ };
377
+ const notes = [];
378
+ if (truncated) notes.push(truncationNote("zones", returnedOperators, totalOperators));
379
+ if (!verbose) {
380
+ result2.fields = ZONE_FIELD_LEGEND;
381
+ notes.push(
382
+ `Projected result: per-operator mccMncs, tadigs, continent, countryCode, countryName and utcOffset are omitted, as is the repeated reseller object. Pass verbose=true for the full untouched zone shape (capped at ${ZONES_VERBOSE_MAX_OPERATORS} operator entries).`
383
+ );
384
+ }
385
+ if (notes.length) result2.note = notes.join(" ");
386
+ return result2;
387
+ }
388
+
161
389
  // src/tools.ts
162
390
  var TOOL_SCOPES = {
163
391
  // --- read ---
@@ -203,7 +431,12 @@ var TOOL_SCOPES = {
203
431
  modify_account_balance: "admin",
204
432
  // corrected per ocs-spec PR #6
205
433
  change_sim_status: "admin",
206
- delete_subscriber_package: "admin",
434
+ // Removing ONE package from ONE subscriber is a write, not an admin
435
+ // action. Admin-gating it made package lifecycle write-once for any
436
+ // operator on a read/write token: they could assign a package but had no
437
+ // way to remove one, including one assigned in error. clean_all_packages
438
+ // stays admin — wiping every package is a different blast radius.
439
+ delete_subscriber_package: "write",
207
440
  clean_all_packages: "admin",
208
441
  modify_template_core: "admin",
209
442
  modify_template_recurring: "admin",
@@ -310,9 +543,9 @@ function wrapHandler(toolName, ocsMethod, requiredScope, ctx, handler) {
310
543
  try {
311
544
  const token2 = await ctx.getUserToken(ctx.props.sub);
312
545
  result2 = await handler(args, token2);
313
- } catch (err6) {
546
+ } catch (err7) {
314
547
  try {
315
- Sentry.captureException(err6, {
548
+ Sentry.captureException(err7, {
316
549
  tags: {
317
550
  tool: toolName,
318
551
  feature: "mcp",
@@ -321,8 +554,8 @@ function wrapHandler(toolName, ocsMethod, requiredScope, ctx, handler) {
321
554
  });
322
555
  } catch {
323
556
  }
324
- const message = err6 instanceof Error ? err6.message : String(err6);
325
- const ocsCode = err6 instanceof OcsApiError ? err6.code : void 0;
557
+ const message = err7 instanceof Error ? err7.message : String(err7);
558
+ const ocsCode = err7 instanceof OcsApiError ? err7.code : void 0;
326
559
  ctx.audit({
327
560
  tool_name: toolName,
328
561
  ocs_method: ocsMethod,
@@ -464,12 +697,9 @@ function registerAllTools(server2, ctx) {
464
697
  TOOL_SCOPES["esim_status_per_account"],
465
698
  ctx,
466
699
  async ({ accountId, resellerId }, token2) => {
467
- const params = {};
468
- if (accountId !== void 0) {
469
- params.accountId = accountId;
470
- } else {
471
- params.resellerId = resellerId ?? await getDefaultResellerId(ctx.env, token2);
472
- }
700
+ const params = esimStatusPerAccountParams(
701
+ accountId !== void 0 ? { accountId } : { resellerId: resellerId ?? await getDefaultResellerId(ctx.env, token2) }
702
+ );
473
703
  return ocsCall(ctx.env, token2, "esimStatusPerAccount", params);
474
704
  }
475
705
  )
@@ -869,7 +1099,7 @@ function registerAllTools(server2, ctx) {
869
1099
  async ({ iccid, bitrate, bitrate_string }, token2) => {
870
1100
  const cache = /* @__PURE__ */ new Map();
871
1101
  const sub = await resolveSubscriberByIccid(ctx.env, token2, iccid, cache);
872
- const imsi = sub.imsi;
1102
+ const imsi = imsiFromSubscriberRecord(sub);
873
1103
  if (typeof imsi !== "string" || imsi.length === 0) {
874
1104
  return {
875
1105
  isError: true,
@@ -897,7 +1127,7 @@ function registerAllTools(server2, ctx) {
897
1127
  async ({ iccid }, token2) => {
898
1128
  const cache = /* @__PURE__ */ new Map();
899
1129
  const sub = await resolveSubscriberByIccid(ctx.env, token2, iccid, cache);
900
- const imsi = sub.imsi;
1130
+ const imsi = imsiFromSubscriberRecord(sub);
901
1131
  if (typeof imsi !== "string" || imsi.length === 0) {
902
1132
  return {
903
1133
  isError: true,
@@ -1124,8 +1354,8 @@ function registerAllTools(server2, ctx) {
1124
1354
  title: "Delete Subscriber Package",
1125
1355
  description: "Use this to permanently remove a single prepaid package from a subscriber. This is irreversible \u2014 the package record and any unused allowance are deleted. Always call `list_subscriber_packages` first to confirm the correct packageId and snapshot the current state. Use `dry_run=true` on the first call. Params: `iccid` (subscriber identifier), `packageId` (integer from `list_subscriber_packages`). Returns: OCS confirmation of deletion. Do NOT use this to remove ALL packages at once \u2014 use `clean_all_packages` for that (requires separate confirm). Do NOT use this to pause a package \u2014 use `modify_package_status` to deactivate it instead.",
1126
1356
  inputSchema: {
1127
- iccid: z.string().describe("The subscriber ICCID"),
1128
- packageId: z.number().describe("The package ID to delete"),
1357
+ iccid: z.string().describe("The subscriber ICCID (for audit logging; OCS itself does not take it \u2014 see below)"),
1358
+ packageId: z.number().describe("The package ID to delete (OCS's sole parameter \u2014 a bare integer)"),
1129
1359
  ...DRY_RUN_FIELD
1130
1360
  },
1131
1361
  annotations: { destructiveHint: true }
@@ -1135,7 +1365,11 @@ function registerAllTools(server2, ctx) {
1135
1365
  "deleteSubscriberPackage",
1136
1366
  TOOL_SCOPES["delete_subscriber_package"],
1137
1367
  ctx,
1138
- async ({ iccid, packageId }, token2) => ocsCall(ctx.env, token2, "deleteSubscriberPackage", { iccid, packageId })
1368
+ // OCS deleteSubscriberPackage takes a BARE INTEGER package id
1369
+ // (endpoints-full.md 3.8, "Numeric value = package ID") — not an
1370
+ // { iccid, packageId } object. iccid is kept in the tool's input schema
1371
+ // for audit/description purposes only; it is not an OCS parameter.
1372
+ async ({ packageId }, token2) => ocsCall(ctx.env, token2, "deleteSubscriberPackage", packageId)
1139
1373
  )
1140
1374
  );
1141
1375
  server2.registerTool(
@@ -1183,7 +1417,7 @@ function registerAllTools(server2, ctx) {
1183
1417
  "create_package_template",
1184
1418
  {
1185
1419
  title: "Create Package Template",
1186
- description: "Use this to create a new prepaid package template in the product catalog. Templates define allowances, pricing, location zones, validity, and throttling thresholds that are reused each time the template is assigned to a subscriber. Params: `template` (full template configuration as a JSON string \u2014 fields include `name`, `dataLimit` in bytes, `price`, `validityDays`, `locationZoneId`, `recurring`, `throttlingActive`). Returns: created template record with the new `templateId`. Do NOT use this to modify an existing template \u2014 use `modify_template_core`. After creation, call `list_package_templates` to confirm the template is visible.",
1420
+ description: 'Use this to create a new prepaid package template in the product catalog. Templates define allowances, pricing, location zones, validity, and throttling thresholds that are reused each time the template is assigned to a subscriber. Params: `template` (full template configuration as a JSON string \u2014 fields include `name`, `dataLimit` in bytes, `price`, `validityDays`, `locationZoneId`, `recurring`, `throttlingActive`). NEVER express an unlimited plan as a zero data allowance: OCS reads 0 as a literal zero, and because `throttlingThreshold1Perc` is a percentage OF THE BUNDLE, a zero bundle leaves the throttle trigger uncomputable and every storefront renders "0 MB". Describe the plan as it is sold and the OCS fields are derived for you: `unlimited: true`, `throttleAfterMb` (the full-speed window, e.g. 500 for "500 MB/day"), `throttleKbps` (the speed AFTER that window) and `perioddays` (1 for a daily bucket). The bundle ceiling is sized so the window is a whole trigger percentage and the bundle cannot be drained even at the throttled rate for the whole period, because a plan sold as unlimited must not run out. `throttleKbps` is a SPEED and must be an OCS bitrate (128, 256, 384, 512, 1024, 3072, 5120, 7680, 10240, 20480, 51200, 102400); passing megabytes here is rejected. Returns: created template record with the new `templateId`. Do NOT use this to modify an existing template \u2014 use `modify_template_core`. After creation, call `list_package_templates` to confirm the template is visible.',
1187
1421
  inputSchema: {
1188
1422
  template: z.string().describe("Full template configuration as JSON string"),
1189
1423
  ...DRY_RUN_FIELD
@@ -1199,7 +1433,7 @@ function registerAllTools(server2, ctx) {
1199
1433
  ctx.env,
1200
1434
  token2,
1201
1435
  "createPrepaidPackageTemplate",
1202
- JSON.parse(template)
1436
+ normalizePackageTemplate(JSON.parse(template))
1203
1437
  )
1204
1438
  )
1205
1439
  );
@@ -1222,7 +1456,7 @@ function registerAllTools(server2, ctx) {
1222
1456
  ctx,
1223
1457
  async ({ templateId, changes }, token2) => ocsCall(ctx.env, token2, "modifyPPTCore", {
1224
1458
  templateId,
1225
- ...JSON.parse(changes)
1459
+ ...normalizePackageTemplateChanges(JSON.parse(changes))
1226
1460
  })
1227
1461
  )
1228
1462
  );
@@ -1276,9 +1510,9 @@ function registerAllTools(server2, ctx) {
1276
1510
  "list_location_zones",
1277
1511
  {
1278
1512
  title: "List Location Zone Elements",
1279
- description: "Use this to list countries and networks within a specific location zone. WARNING: this method has a known Jackson deserialization bug in the upstream OCS API that may return malformed responses. Prefer `list_detailed_location_zones` for reliable results. Params: `locationZoneId` (integer, optional \u2014 filter to a specific zone). Returns: array of zone element records with country and operator entries. Do NOT use this for reliable zone data \u2014 use `list_detailed_location_zones` instead. Do NOT use this to create zones \u2014 use `create_location_zone`.",
1513
+ description: "Use this to list countries and networks within a specific location zone. WARNING: this method has a known Jackson deserialization bug in the upstream OCS API that may return malformed responses. Prefer `list_detailed_location_zones` for reliable results. Params: `locationZoneId` (integer, REQUIRED \u2014 OCS takes a bare zone id, not an object; there is no unfiltered call). Returns: array of zone element records with country and operator entries. Do NOT use this for reliable zone data \u2014 use `list_detailed_location_zones` instead. Do NOT use this to create zones \u2014 use `create_location_zone`.",
1280
1514
  inputSchema: {
1281
- locationZoneId: z.number().optional().describe("Filter by zone ID")
1515
+ locationZoneId: z.number().describe("Zone ID (required \u2014 OCS has no unfiltered call for this method)")
1282
1516
  },
1283
1517
  annotations: { readOnlyHint: true }
1284
1518
  },
@@ -1288,9 +1522,7 @@ function registerAllTools(server2, ctx) {
1288
1522
  TOOL_SCOPES["list_location_zones"],
1289
1523
  ctx,
1290
1524
  async ({ locationZoneId }, token2) => {
1291
- const params = {};
1292
- if (locationZoneId !== void 0) params.locationZoneId = locationZoneId;
1293
- return ocsCall(ctx.env, token2, "listLocationZoneElement", params);
1525
+ return ocsCall(ctx.env, token2, "listLocationZoneElement", locationZoneId);
1294
1526
  }
1295
1527
  )
1296
1528
  );
@@ -1298,9 +1530,12 @@ function registerAllTools(server2, ctx) {
1298
1530
  "list_detailed_location_zones",
1299
1531
  {
1300
1532
  title: "List Detailed Location Zones",
1301
- description: "Use this as the preferred way to list location zones with full detail: included countries, operator networks, zone IDs, and names. This is the working alternative to `list_location_zones` which has a known upstream deserialization bug. Use `locationZoneId` values from this response when creating or editing package templates. Params: `resellerId` (integer, optional \u2014 omit to use the token owner's reseller). Returns: array of zone objects each containing `locationZoneId`, `name`, `countries`, and `operators`. Do NOT use `list_location_zones` when you need reliable data \u2014 always use this tool instead.",
1533
+ description: `Use this as the preferred way to list location zones with full detail: included countries, operator networks, zone IDs, and names. This is the working alternative to \`list_location_zones\` which has a known upstream deserialization bug. Use \`locationZoneId\` values from this response when creating or editing package templates. Params: \`resellerId\` (integer, optional \u2014 omit to use the token owner's reseller); \`verbose\` (optional boolean, default false \u2014 returns the full untouched zone shape with a smaller operator-entry budget). Returns BY DEFAULT a projected result: \`totalZones\`, \`returnedZones\`, \`totalOperators\`, \`returnedOperators\`, \`truncated\`, a \`fields\` legend, and \`zones[]\` each with \`zoneId\`, \`zoneName\`, \`operatorCount\` and short-keyed \`operators[]\` (\`net\`, \`iso\`, \`op\`). Per-operator mccMncs, tadigs, continent, countryCode, countryName, utcOffset and the repeated reseller object are dropped unless \`verbose\` is true. Operator entries are capped at ${ZONES_MAX_OPERATORS} so the result fits the MCP transport (issue #663); when the cap bites, \`truncated\` is true and \`note\` says so \u2014 never silently. Do NOT use \`list_location_zones\` when you need reliable data \u2014 always use this tool instead.`,
1302
1534
  inputSchema: {
1303
- resellerId: z.number().optional().describe("Reseller ID (omit to use token owner's reseller)")
1535
+ resellerId: z.number().optional().describe("Reseller ID (omit to use token owner's reseller)"),
1536
+ verbose: z.boolean().optional().describe(
1537
+ "Return the full untouched OCS zone shape instead of the projected zones (smaller operator budget)"
1538
+ )
1304
1539
  },
1305
1540
  annotations: { readOnlyHint: true }
1306
1541
  },
@@ -1309,9 +1544,16 @@ function registerAllTools(server2, ctx) {
1309
1544
  "listDetailedLocationZone",
1310
1545
  TOOL_SCOPES["list_detailed_location_zones"],
1311
1546
  ctx,
1312
- async ({ resellerId }, token2) => {
1547
+ async ({ resellerId, verbose }, token2) => {
1313
1548
  const id = resellerId ?? await getDefaultResellerId(ctx.env, token2);
1314
- return ocsCall(ctx.env, token2, "listDetailedLocationZone", id);
1549
+ const raw = await new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2).call(
1550
+ "listDetailedLocationZone",
1551
+ id
1552
+ );
1553
+ const bounded = boundZonesResult(raw, { resellerId: id, verbose });
1554
+ return {
1555
+ content: [{ type: "text", text: JSON.stringify(bounded) }]
1556
+ };
1315
1557
  }
1316
1558
  )
1317
1559
  );
@@ -1319,9 +1561,9 @@ function registerAllTools(server2, ctx) {
1319
1561
  "list_destination_prefixes",
1320
1562
  {
1321
1563
  title: "List Destination List Prefixes",
1322
- description: "Use this to list the phone number prefixes (country dialling codes) within a specific named destination list. Destination lists control which countries a subscriber may call on voice/SMS packages. You must already know the `destinationListId` to use this tool. Params: `destinationListId` (integer, optional \u2014 omit to list all known prefixes). Returns: array of prefix records with country code and E.164 prefix. Do NOT use this to discover the destination list catalog \u2014 use `list_destination_lists` for that. For data-only eSIM products without MOC voice, destination lists are irrelevant.",
1564
+ description: "Use this to list the phone number prefixes (country dialling codes) within a specific named destination list. Destination lists control which countries a subscriber may call on voice/SMS packages. You must already know the `destinationListId` to use this tool. Params: `destinationListId` (integer, REQUIRED \u2014 OCS takes a bare list id, not an object; there is no unfiltered call). Returns: array of prefix records with country code and E.164 prefix. Do NOT use this to discover the destination list catalog \u2014 use `list_destination_lists` for that. For data-only eSIM products without MOC voice, destination lists are irrelevant.",
1323
1565
  inputSchema: {
1324
- destinationListId: z.number().optional().describe("Filter by destination list ID")
1566
+ destinationListId: z.number().describe("Destination list ID (required \u2014 OCS has no unfiltered call for this method)")
1325
1567
  },
1326
1568
  annotations: { readOnlyHint: true }
1327
1569
  },
@@ -1331,10 +1573,7 @@ function registerAllTools(server2, ctx) {
1331
1573
  TOOL_SCOPES["list_destination_prefixes"],
1332
1574
  ctx,
1333
1575
  async ({ destinationListId }, token2) => {
1334
- const params = {};
1335
- if (destinationListId !== void 0)
1336
- params.destinationListId = destinationListId;
1337
- return ocsCall(ctx.env, token2, "listDestinationListPrefix", params);
1576
+ return ocsCall(ctx.env, token2, "listDestinationListPrefix", destinationListId);
1338
1577
  }
1339
1578
  )
1340
1579
  );
@@ -1424,9 +1663,18 @@ function registerAllTools(server2, ctx) {
1424
1663
  "get_tariff",
1425
1664
  {
1426
1665
  title: "Get Customer Tariff",
1427
- description: "Use this to retrieve the complete tariff table for a reseller: per-country, per-traffic-type (data/voice/SMS) wholesale rates. Useful for cost analysis, margin calculations, and identifying expensive roaming countries before steering decisions. Params: `resellerId` (integer, optional \u2014 omit to use the token owner's reseller). Returns: array of tariff rules, each with `country`, `trafficType`, `rate`, and `currency`. Response key in OCS is `listTariffRule`. Do NOT use this to assign a pricing plan to a subscriber \u2014 use `modify_subscriber_mobile_plan`. This shows the RESELLER's wholesale cost, not what end-users are charged.",
1666
+ description: `Use this to retrieve the tariff table for a reseller: per-country, per-operator wholesale data/voice/SMS rates. Useful for cost analysis, margin calculations, and identifying expensive roaming countries before steering decisions. Params: \`resellerId\` (integer, optional \u2014 omit to use the token owner's reseller); \`country\` (optional, ISO-3166 alpha-2 such as "nl" OR a full country name such as "Netherlands", case-insensitive); \`trafficType\` (optional, "data" | "voice" | "sms" \u2014 keeps only rules whose corresponding rate is greater than zero); \`verbose\` (optional boolean, default false \u2014 returns the full untouched OCS rule shape, capped at ${TARIFF_VERBOSE_MAX_RULES} rows, so combine it with the filters). Returns BY DEFAULT a projected result: \`totalRules\`, \`matchedRules\`, \`returnedRules\`, \`truncated\`, a hoisted \`currency\`, a \`fields\` legend and \`rules[]\` of short-keyed rows (\`iso\`, \`op\`, \`data\`, \`moCall\`, \`mtCall\`, \`moSms\`, \`mtSms\`, \`active\`). Zero rates and inactive flags are omitted per row. Nested operator detail (mccMncs, tadigs, continent, countryCode, utcOffset), the sponsor object, plan ids and the validity/discount flags are dropped unless \`verbose\` is true. Rows are capped at ${TARIFF_MAX_RULES}; when the cap bites, \`truncated\` is true and \`note\` says so explicitly and names the filter params \u2014 the result is never silently shortened. Filtering happens server-side on the complete OCS table, so a filtered call sees every matching rule. Response key in OCS is \`listTariffRule\`. Do NOT use this to assign a pricing plan to a subscriber \u2014 use \`modify_subscriber_mobile_plan\`. This shows the RESELLER's wholesale cost, not what end-users are charged.`,
1428
1667
  inputSchema: {
1429
- resellerId: z.number().optional().describe("Reseller ID (omit to use token owner's reseller)")
1668
+ resellerId: z.number().optional().describe("Reseller ID (omit to use token owner's reseller)"),
1669
+ country: z.string().optional().describe(
1670
+ 'Filter by country \u2014 ISO-3166 alpha-2 ("nl") or full name ("Netherlands"), case-insensitive'
1671
+ ),
1672
+ trafficType: z.enum(["data", "voice", "sms"]).optional().describe(
1673
+ 'Filter to rules whose rate for this traffic type is greater than zero ("data" | "voice" | "sms")'
1674
+ ),
1675
+ verbose: z.boolean().optional().describe(
1676
+ `Return the full untouched OCS rule shape instead of the projected rows (capped at ${TARIFF_VERBOSE_MAX_RULES} rows)`
1677
+ )
1430
1678
  },
1431
1679
  annotations: { readOnlyHint: true }
1432
1680
  },
@@ -1435,9 +1683,21 @@ function registerAllTools(server2, ctx) {
1435
1683
  "getCustomerTariff",
1436
1684
  TOOL_SCOPES["get_tariff"],
1437
1685
  ctx,
1438
- async ({ resellerId }, token2) => {
1686
+ async ({ resellerId, country, trafficType, verbose }, token2) => {
1439
1687
  const id = resellerId ?? await getDefaultResellerId(ctx.env, token2);
1440
- return ocsCall(ctx.env, token2, "getCustomerTariff", id);
1688
+ const raw = await new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2).call(
1689
+ "getCustomerTariff",
1690
+ id
1691
+ );
1692
+ const bounded = boundTariffResult(raw, {
1693
+ resellerId: id,
1694
+ country,
1695
+ trafficType,
1696
+ verbose
1697
+ });
1698
+ return {
1699
+ content: [{ type: "text", text: JSON.stringify(bounded) }]
1700
+ };
1441
1701
  }
1442
1702
  )
1443
1703
  );
@@ -1463,7 +1723,7 @@ function registerAllTools(server2, ctx) {
1463
1723
  async ({ iccid, msisdn, message, sender }, token2) => {
1464
1724
  const cache = /* @__PURE__ */ new Map();
1465
1725
  const sub = await resolveSubscriberByIccid(ctx.env, token2, iccid, cache);
1466
- const imsi = sub.imsi;
1726
+ const imsi = imsiFromSubscriberRecord(sub);
1467
1727
  if (typeof imsi !== "string" || imsi.length === 0) {
1468
1728
  return {
1469
1729
  isError: true,
@@ -3042,8 +3302,8 @@ async function safeCall(env, token2, method, params = {}) {
3042
3302
  const client = new OcsClient(env.CARRIER_OCS_BASE_URL, token2);
3043
3303
  const data = await client.call(method, params);
3044
3304
  return { data, error: null };
3045
- } catch (err6) {
3046
- return { data: null, error: err6 instanceof Error ? err6.message : String(err6) };
3305
+ } catch (err7) {
3306
+ return { data: null, error: err7 instanceof Error ? err7.message : String(err7) };
3047
3307
  }
3048
3308
  }
3049
3309
  async function fetchActiveSubscribers(env, token2, accountId, resellerId) {
@@ -4925,10 +5185,10 @@ async function callBedrock(env, payload) {
4925
5185
  const body = await resp.text();
4926
5186
  if (resp.status === 429) {
4927
5187
  const retryAfter = resp.headers.get("retry-after");
4928
- const err6 = new Error(`Bedrock rate limit: ${body}`);
4929
- err6.isRateLimit = true;
4930
- err6.retryAfter = retryAfter;
4931
- throw err6;
5188
+ const err7 = new Error(`Bedrock rate limit: ${body}`);
5189
+ err7.isRateLimit = true;
5190
+ err7.retryAfter = retryAfter;
5191
+ throw err7;
4932
5192
  }
4933
5193
  throw new Error(`Bedrock invoke failed: ${resp.status} ${body}`);
4934
5194
  }
@@ -4966,9 +5226,9 @@ Pre-resolved context: ${JSON.stringify(definedContext)}` : "";
4966
5226
  AWS_REGION: env.AWS_REGION,
4967
5227
  BEDROCK_MODEL_ID: env.BEDROCK_MODEL_ID
4968
5228
  }, payload);
4969
- } catch (err6) {
4970
- if (err6 instanceof Error && err6.isRateLimit) {
4971
- const retryAfter = err6.retryAfter;
5229
+ } catch (err7) {
5230
+ if (err7 instanceof Error && err7.isRateLimit) {
5231
+ const retryAfter = err7.retryAfter;
4972
5232
  const parsedSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
4973
5233
  return {
4974
5234
  match: "rate_limited",
@@ -4976,7 +5236,7 @@ Pre-resolved context: ${JSON.stringify(definedContext)}` : "";
4976
5236
  suggestion: "Bedrock rate limit reached. Please retry after the indicated delay."
4977
5237
  };
4978
5238
  }
4979
- throw err6;
5239
+ throw err7;
4980
5240
  }
4981
5241
  const toolUseBlock = response.content.find(
4982
5242
  (block) => block.type === "tool_use"
@@ -5150,7 +5410,7 @@ function registerAllCarrierAskTools(server2, ctx) {
5150
5410
  let route;
5151
5411
  try {
5152
5412
  route = await _routeIntent(intent, context, ctx.env);
5153
- } catch (err6) {
5413
+ } catch (err7) {
5154
5414
  writeCarrierAskAudit(ctx.env, {
5155
5415
  intent_hash: intentHash,
5156
5416
  match: "error",
@@ -5167,7 +5427,7 @@ function registerAllCarrierAskTools(server2, ctx) {
5167
5427
  type: "text",
5168
5428
  text: JSON.stringify({
5169
5429
  error: "routing_error",
5170
- message: err6 instanceof Error ? err6.message : "An unexpected error occurred during routing."
5430
+ message: err7 instanceof Error ? err7.message : "An unexpected error occurred during routing."
5171
5431
  })
5172
5432
  }
5173
5433
  ],
@@ -5419,8 +5679,8 @@ function registerListRecentOcsEventsTool(server2, env) {
5419
5679
  return {
5420
5680
  content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
5421
5681
  };
5422
- } catch (err6) {
5423
- const message = err6 instanceof Error ? err6.message : String(err6);
5682
+ } catch (err7) {
5683
+ const message = err7 instanceof Error ? err7.message : String(err7);
5424
5684
  return {
5425
5685
  isError: true,
5426
5686
  content: [{ type: "text", text: `Error reading OCS event buffer: ${message}` }]
@@ -5700,47 +5960,100 @@ import {
5700
5960
  registerAppResource,
5701
5961
  RESOURCE_MIME_TYPE
5702
5962
  } from "@modelcontextprotocol/ext-apps/server";
5703
- async function safeCallWithToken(client, _token, method, params = {}) {
5963
+
5964
+ // src/apps/fleet-data.ts
5965
+ function extractAccounts(json) {
5966
+ const raw = json;
5967
+ if (Array.isArray(raw)) return raw;
5968
+ return (raw?.reseller ?? []).flatMap((r) => r.account ?? []);
5969
+ }
5970
+ function toFleetInput(raw) {
5971
+ const counts = extractEsimStatusCounts(raw.status);
5972
+ const accountRecords = extractAccounts(raw.accounts);
5973
+ const accounts = accountRecords.map((a) => ({
5974
+ name: String(a.name ?? a.accountId ?? "?"),
5975
+ balance: Number(a.balance ?? 0),
5976
+ // Per-account status breakdown is not in listResellerAccount; the fleet
5977
+ // totals come from the status call. Showing 0 here would read as "this
5978
+ // account has no eSIMs", so the per-account columns stay at the balance
5979
+ // and flags this call actually carries.
5980
+ active: 0,
5981
+ suspended: 0,
5982
+ inventory: 0,
5983
+ other: 0,
5984
+ packageOnly: Boolean(a.packageOnly)
5985
+ }));
5986
+ const unavailable = [];
5987
+ if (raw.statusError) unavailable.push(`esimStatusPerAccount: ${raw.statusError}`);
5988
+ if (raw.accountsError) unavailable.push(`listResellerAccount: ${raw.accountsError}`);
5989
+ return {
5990
+ active: counts.active,
5991
+ suspended: counts.suspended,
5992
+ inventory: counts.inventory,
5993
+ other: counts.other,
5994
+ accounts,
5995
+ unavailable: unavailable.length ? unavailable : void 0
5996
+ };
5997
+ }
5998
+
5999
+ // src/apps/fleet-health-app.ts
6000
+ var FLEET_APP_RESOURCE_URI = "ui://fleet-health-dashboard";
6001
+ async function safeCall2(client, method, params) {
5704
6002
  try {
5705
6003
  return { data: await client.call(method, params), error: null };
5706
- } catch (err6) {
5707
- return {
5708
- data: null,
5709
- error: err6 instanceof Error ? err6.message : String(err6)
5710
- };
6004
+ } catch (err7) {
6005
+ return { data: null, error: err7 instanceof Error ? err7.message : String(err7) };
5711
6006
  }
5712
6007
  }
6008
+ async function loadFleet(ctx, accountId) {
6009
+ const token2 = await ctx.getUserToken(ctx.props.sub);
6010
+ const client = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
6011
+ const resellerId = await getDefaultResellerId(ctx.env, token2).catch(() => void 0);
6012
+ const statusParams = accountId !== void 0 ? { accountId } : resellerId !== void 0 ? { resellerId } : {};
6013
+ const accountParams = resellerId !== void 0 ? { resellerId } : {};
6014
+ const [status, accounts] = await Promise.all([
6015
+ safeCall2(client, "esimStatusPerAccount", statusParams),
6016
+ safeCall2(client, "listResellerAccount", accountParams)
6017
+ ]);
6018
+ return toFleetInput({
6019
+ status: status.data,
6020
+ accounts: accounts.data,
6021
+ statusError: status.error,
6022
+ accountsError: accounts.error
6023
+ });
6024
+ }
5713
6025
  function registerFleetHealthApp(server2, ctx) {
5714
6026
  registerAppResource(
5715
6027
  server2,
5716
6028
  "Fleet Health Dashboard",
5717
- "ui://fleet-health-dashboard",
6029
+ FLEET_APP_RESOURCE_URI,
5718
6030
  {
5719
- description: "Interactive Fleet Health Dashboard \u2014 eSIM status charts, account breakdown, low-balance alerts."
6031
+ description: "Fleet overview panel \u2014 eSIM status, per-account breakdown and low-balance alerts."
5720
6032
  },
5721
6033
  async () => {
5722
6034
  let html;
5723
6035
  try {
5724
- const resp = await ctx.env.ASSETS.fetch(
5725
- new Request("https://internal/views/fleet-health/index.html")
5726
- );
5727
- html = await resp.text();
5728
- } catch {
5729
- html = "<html><body><p>Dashboard unavailable.</p></body></html>";
6036
+ html = renderHtml(fleetScreen(await loadFleet(ctx)));
6037
+ } catch (err7) {
6038
+ html = renderHtml({
6039
+ id: "fleet-health",
6040
+ title: "Fleet health",
6041
+ sections: [
6042
+ {
6043
+ kind: "note",
6044
+ tone: "critical",
6045
+ text: `Could not load fleet data: ${err7 instanceof Error ? err7.message : String(err7)}`
6046
+ }
6047
+ ]
6048
+ });
5730
6049
  }
5731
6050
  return {
5732
6051
  contents: [
5733
6052
  {
5734
- uri: "ui://fleet-health-dashboard",
6053
+ uri: FLEET_APP_RESOURCE_URI,
5735
6054
  mimeType: RESOURCE_MIME_TYPE,
5736
- text: html,
5737
- _meta: {
5738
- ui: {
5739
- csp: {
5740
- resourceDomains: ["https://cdn.jsdelivr.net"]
5741
- }
5742
- }
5743
- }
6055
+ text: html
6056
+ // Self-contained: no external origins, so no CSP exceptions.
5744
6057
  }
5745
6058
  ]
5746
6059
  };
@@ -5751,100 +6064,45 @@ function registerFleetHealthApp(server2, ctx) {
5751
6064
  "fleet_health_app",
5752
6065
  {
5753
6066
  title: "Fleet Health Dashboard",
5754
- description: "Renders an interactive Fleet Health Dashboard with eSIM status charts, top-10 account breakdown, and low-balance alerts. Returns structuredContent for the chart panel.",
6067
+ description: "Draw the fleet overview as an interactive panel: eSIM status, per-account breakdown and low-balance alerts. Returns structuredContent for the panel, so it only helps a client that can render MCP UI resources. Do NOT use this when the answer has to be readable text, or in any headless or scripted context \u2014 `fleet_health` returns the same data as prose.",
5755
6068
  inputSchema: {
5756
6069
  accountId: z9.number().optional().describe("Filter to a specific account (omit for all)")
5757
6070
  },
5758
6071
  annotations: { readOnlyHint: true },
5759
6072
  _meta: {
5760
6073
  ui: {
5761
- resourceUri: "ui://fleet-health-dashboard",
6074
+ resourceUri: FLEET_APP_RESOURCE_URI,
5762
6075
  visibility: ["model", "app"]
5763
6076
  }
5764
6077
  }
5765
6078
  },
5766
6079
  async ({ accountId }) => {
5767
- const token2 = await ctx.getUserToken(ctx.props.sub);
5768
- const client = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5769
- const [statusResult, accountsResult] = await Promise.all([
5770
- safeCallWithToken(
5771
- client,
5772
- token2,
5773
- "esimStatusPerAccount",
5774
- accountId !== void 0 ? { accountId } : {}
5775
- ),
5776
- safeCallWithToken(
5777
- client,
5778
- token2,
5779
- "listResellerAccount",
5780
- {}
5781
- )
5782
- ]);
5783
- let totalActive = 0;
5784
- let totalSuspended = 0;
5785
- let totalInventory = 0;
5786
- let totalOther = 0;
5787
- const accountList = [];
5788
- if (statusResult.data && Array.isArray(statusResult.data)) {
5789
- for (const account of statusResult.data) {
5790
- const active = Number(account["active"] ?? 0);
5791
- const suspended = Number(account["suspended"] ?? 0);
5792
- const inventory = Number(
5793
- account["inventory"] ?? account["notActivated"] ?? 0
5794
- );
5795
- const other = Number(
5796
- account["other"] ?? account["terminated"] ?? 0
5797
- );
5798
- totalActive += active;
5799
- totalSuspended += suspended;
5800
- totalInventory += inventory;
5801
- totalOther += other;
5802
- accountList.push({
5803
- name: String(account["name"] ?? account["accountId"] ?? "?"),
5804
- balance: 0,
5805
- active,
5806
- suspended,
5807
- inventory,
5808
- other
5809
- });
5810
- }
5811
- }
5812
- if (accountsResult.data && Array.isArray(accountsResult.data)) {
5813
- for (const a of accountsResult.data) {
5814
- const aName = String(a["name"] ?? a["accountId"] ?? "?");
5815
- const entry = accountList.find((e) => e.name === aName);
5816
- if (entry) {
5817
- entry.balance = Number(a["balance"] ?? 0);
6080
+ const input = await loadFleet(ctx, accountId);
6081
+ const screen = fleetScreen(input);
6082
+ const total = input.active + input.suspended + input.inventory + input.other;
6083
+ const lowBalanceCount = input.accounts.filter((a) => a.balance < 10).length;
6084
+ return {
6085
+ content: [
6086
+ {
6087
+ type: "text",
6088
+ text: total === 0 && input.accounts.length === 0 ? "Fleet view returned no eSIMs and no accounts \u2014 check the account scope and credentials." : `${total} eSIMs across ${input.accounts.length} account(s); ${input.active} active. Fleet Utilization: ${total > 0 ? (input.active / total * 100).toFixed(1) : "0"}%`
5818
6089
  }
6090
+ ],
6091
+ structuredContent: {
6092
+ // `screen` is the shared model — the panel and `carrier dash` both
6093
+ // render this exact object, so the two cannot disagree.
6094
+ screen,
6095
+ // Flat fields are the original published contract. Kept so anything
6096
+ // already reading them keeps working; the fix was the data, not the shape.
6097
+ utilization: total > 0 ? Math.round(input.active / total * 1e3) / 10 : 0,
6098
+ totalActive: input.active,
6099
+ totalSuspended: input.suspended,
6100
+ totalInventory: input.inventory,
6101
+ totalOther: input.other,
6102
+ totalAccounts: input.accounts.length,
6103
+ lowBalanceCount,
6104
+ accountList: input.accounts
5819
6105
  }
5820
- }
5821
- const total = totalActive + totalSuspended + totalInventory + totalOther;
5822
- const utilization = total > 0 ? Math.round(totalActive / total * 1e3) / 10 : 0;
5823
- const totalAccounts = accountsResult.data && Array.isArray(accountsResult.data) ? accountsResult.data.length : accountList.length;
5824
- const lowBalanceCount = accountsResult.data && Array.isArray(accountsResult.data) ? accountsResult.data.filter((a) => Number(a["balance"] ?? 0) < 10).length : 0;
5825
- const sortedAccounts = [...accountList].sort(
5826
- (a, b) => b.active + b.suspended + b.inventory + b.other - (a.active + a.suspended + a.inventory + a.other)
5827
- ).slice(0, 10);
5828
- const errors = [statusResult.error, accountsResult.error].filter(Boolean).join("; ");
5829
- const summaryLines = [
5830
- `Fleet Utilization: ${utilization}%`,
5831
- `Active: ${totalActive} | Suspended: ${totalSuspended} | Inventory: ${totalInventory} | Other: ${totalOther}`,
5832
- `Total Accounts: ${totalAccounts} | Low Balance (<10): ${lowBalanceCount}`,
5833
- ...errors ? [`Errors: ${errors}`] : []
5834
- ];
5835
- const structuredContent = {
5836
- utilization,
5837
- totalActive,
5838
- totalSuspended,
5839
- totalInventory,
5840
- totalOther,
5841
- totalAccounts,
5842
- lowBalanceCount,
5843
- accountList: sortedAccounts
5844
- };
5845
- return {
5846
- content: [{ type: "text", text: summaryLines.join("\n") }],
5847
- structuredContent
5848
6106
  };
5849
6107
  }
5850
6108
  );
@@ -5901,13 +6159,13 @@ async function deleteWizardSession(env, sub, wizardId) {
5901
6159
  }
5902
6160
 
5903
6161
  // src/apps/provisioning-wizard.ts
5904
- async function safeCallWithToken2(client, _token, method, params = {}) {
6162
+ async function safeCallWithToken(client, _token, method, params = {}) {
5905
6163
  try {
5906
6164
  return { data: await client.call(method, params), error: null };
5907
- } catch (err6) {
6165
+ } catch (err7) {
5908
6166
  return {
5909
6167
  data: null,
5910
- error: err6 instanceof Error ? err6.message : String(err6)
6168
+ error: err7 instanceof Error ? err7.message : String(err7)
5911
6169
  };
5912
6170
  }
5913
6171
  }
@@ -5973,7 +6231,7 @@ function registerProvisioningWizard(server2, ctx) {
5973
6231
  const client = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5974
6232
  if (step === "init") {
5975
6233
  const newWizardId = generateWizardId();
5976
- const subscribersResult = await safeCallWithToken2(
6234
+ const subscribersResult = await safeCallWithToken(
5977
6235
  client,
5978
6236
  token2,
5979
6237
  "listResellerAccount",
@@ -6051,7 +6309,7 @@ function registerProvisioningWizard(server2, ctx) {
6051
6309
  };
6052
6310
  }
6053
6311
  const resellerIdForTemplates = await getDefaultResellerId(ctx.env, token2);
6054
- const packagesResult = await safeCallWithToken2(
6312
+ const packagesResult = await safeCallWithToken(
6055
6313
  client,
6056
6314
  token2,
6057
6315
  "listPrepaidPackageTemplate",
@@ -6118,7 +6376,7 @@ function registerProvisioningWizard(server2, ctx) {
6118
6376
  isError: true
6119
6377
  };
6120
6378
  }
6121
- const previewResult = await safeCallWithToken2(
6379
+ const previewResult = await safeCallWithToken(
6122
6380
  client,
6123
6381
  token2,
6124
6382
  "listPrepaidPackageTemplate",
@@ -6190,7 +6448,7 @@ function registerProvisioningWizard(server2, ctx) {
6190
6448
  isError: true
6191
6449
  };
6192
6450
  }
6193
- const subRecord = await safeCallWithToken2(
6451
+ const subRecord = await safeCallWithToken(
6194
6452
  client,
6195
6453
  token2,
6196
6454
  "getSingleSubscriber",
@@ -6219,7 +6477,7 @@ function registerProvisioningWizard(server2, ctx) {
6219
6477
  isError: true
6220
6478
  };
6221
6479
  }
6222
- const result2 = await safeCallWithToken2(
6480
+ const result2 = await safeCallWithToken(
6223
6481
  client,
6224
6482
  token2,
6225
6483
  "affectPackageToSubscriber",
@@ -6264,13 +6522,13 @@ import {
6264
6522
  registerAppResource as registerAppResource3,
6265
6523
  RESOURCE_MIME_TYPE as RESOURCE_MIME_TYPE3
6266
6524
  } from "@modelcontextprotocol/ext-apps/server";
6267
- async function safeCallWithToken3(client, _token, method, params = {}) {
6525
+ async function safeCallWithToken2(client, _token, method, params = {}) {
6268
6526
  try {
6269
6527
  return { data: await client.call(method, params), error: null };
6270
- } catch (err6) {
6528
+ } catch (err7) {
6271
6529
  return {
6272
6530
  data: null,
6273
- error: err6 instanceof Error ? err6.message : String(err6)
6531
+ error: err7 instanceof Error ? err7.message : String(err7)
6274
6532
  };
6275
6533
  }
6276
6534
  }
@@ -6332,7 +6590,7 @@ function registerBalanceTopupApp(server2, ctx) {
6332
6590
  const token2 = await ctx.getUserToken(ctx.props.sub);
6333
6591
  const client = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
6334
6592
  if (preview === true) {
6335
- const result2 = await safeCallWithToken3(
6593
+ const result2 = await safeCallWithToken2(
6336
6594
  client,
6337
6595
  token2,
6338
6596
  "getSingleSubscriber",
@@ -6357,7 +6615,7 @@ function registerBalanceTopupApp(server2, ctx) {
6357
6615
  structuredContent: structured2
6358
6616
  };
6359
6617
  }
6360
- const execResult = await safeCallWithToken3(
6618
+ const execResult = await safeCallWithToken2(
6361
6619
  client,
6362
6620
  token2,
6363
6621
  // CAR-78: subscriber top-up uses modifySubscriberBalance with { subscriber, amount }
@@ -7931,8 +8189,8 @@ function buildPricingHandler(tool, ctx) {
7931
8189
  }
7932
8190
  ]
7933
8191
  };
7934
- } catch (err6) {
7935
- const message = err6 instanceof Error ? err6.message : String(err6);
8192
+ } catch (err7) {
8193
+ const message = err7 instanceof Error ? err7.message : String(err7);
7936
8194
  ctx.audit({
7937
8195
  tool_name: tool.name,
7938
8196
  ocs_method: "billing",
@@ -8252,8 +8510,8 @@ function writeUsageThresholdAudit(env, severity, remainingCredits, month, taskCo
8252
8510
  doubles: [remainingCredits],
8253
8511
  indexes: ["manus_usage"]
8254
8512
  });
8255
- } catch (err6) {
8256
- console.error(`[manus-usage] threshold audit write failed: ${err6.message}`);
8513
+ } catch (err7) {
8514
+ console.error(`[manus-usage] threshold audit write failed: ${err7.message}`);
8257
8515
  }
8258
8516
  }
8259
8517
 
@@ -8363,7 +8621,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8363
8621
  }
8364
8622
  ]
8365
8623
  };
8366
- } catch (err6) {
8624
+ } catch (err7) {
8367
8625
  ctx.audit({
8368
8626
  tool_name: "ui_agent_schedule_create",
8369
8627
  ocs_method: "[manus:schedule.create]",
@@ -8372,7 +8630,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8372
8630
  duration_ms: Date.now() - start,
8373
8631
  event_type: "ui_agent_dispatch"
8374
8632
  });
8375
- const msg = err6 instanceof ManusScheduleError ? `Manus API error (HTTP ${err6.statusCode}): ${err6.message}` : err6 instanceof Error ? err6.message : String(err6);
8633
+ const msg = err7 instanceof ManusScheduleError ? `Manus API error (HTTP ${err7.statusCode}): ${err7.message}` : err7 instanceof Error ? err7.message : String(err7);
8376
8634
  return { isError: true, content: [{ type: "text", text: msg }] };
8377
8635
  }
8378
8636
  }
@@ -8430,7 +8688,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8430
8688
  }
8431
8689
  ]
8432
8690
  };
8433
- } catch (err6) {
8691
+ } catch (err7) {
8434
8692
  ctx.audit({
8435
8693
  tool_name: "ui_agent_schedule_list",
8436
8694
  ocs_method: "[manus:schedule.list]",
@@ -8438,7 +8696,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8438
8696
  dry_run: false,
8439
8697
  duration_ms: Date.now() - start
8440
8698
  });
8441
- const msg = err6 instanceof ManusScheduleError ? `Manus API error (HTTP ${err6.statusCode}): ${err6.message}` : err6 instanceof Error ? err6.message : String(err6);
8699
+ const msg = err7 instanceof ManusScheduleError ? `Manus API error (HTTP ${err7.statusCode}): ${err7.message}` : err7 instanceof Error ? err7.message : String(err7);
8442
8700
  return { isError: true, content: [{ type: "text", text: msg }] };
8443
8701
  }
8444
8702
  }
@@ -8499,7 +8757,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8499
8757
  }
8500
8758
  ]
8501
8759
  };
8502
- } catch (err6) {
8760
+ } catch (err7) {
8503
8761
  ctx.audit({
8504
8762
  tool_name: "ui_agent_schedule_delete",
8505
8763
  ocs_method: "[manus:schedule.delete]",
@@ -8508,7 +8766,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8508
8766
  duration_ms: Date.now() - start,
8509
8767
  event_type: "ui_agent_dispatch"
8510
8768
  });
8511
- const msg = err6 instanceof ManusScheduleError ? `Manus API error (HTTP ${err6.statusCode}): ${err6.message}` : err6 instanceof Error ? err6.message : String(err6);
8769
+ const msg = err7 instanceof ManusScheduleError ? `Manus API error (HTTP ${err7.statusCode}): ${err7.message}` : err7 instanceof Error ? err7.message : String(err7);
8512
8770
  return { isError: true, content: [{ type: "text", text: msg }] };
8513
8771
  }
8514
8772
  }
@@ -8569,7 +8827,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8569
8827
  }
8570
8828
  ]
8571
8829
  };
8572
- } catch (err6) {
8830
+ } catch (err7) {
8573
8831
  ctx.audit({
8574
8832
  tool_name: "ui_agent_schedule_pause",
8575
8833
  ocs_method: "[manus:schedule.pause]",
@@ -8578,7 +8836,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8578
8836
  duration_ms: Date.now() - start,
8579
8837
  event_type: "ui_agent_dispatch"
8580
8838
  });
8581
- const msg = err6 instanceof ManusScheduleError ? `Manus API error (HTTP ${err6.statusCode}): ${err6.message}` : err6 instanceof Error ? err6.message : String(err6);
8839
+ const msg = err7 instanceof ManusScheduleError ? `Manus API error (HTTP ${err7.statusCode}): ${err7.message}` : err7 instanceof Error ? err7.message : String(err7);
8582
8840
  return { isError: true, content: [{ type: "text", text: msg }] };
8583
8841
  }
8584
8842
  }
@@ -8639,7 +8897,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8639
8897
  }
8640
8898
  ]
8641
8899
  };
8642
- } catch (err6) {
8900
+ } catch (err7) {
8643
8901
  ctx.audit({
8644
8902
  tool_name: "ui_agent_schedule_resume",
8645
8903
  ocs_method: "[manus:schedule.resume]",
@@ -8648,7 +8906,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8648
8906
  duration_ms: Date.now() - start,
8649
8907
  event_type: "ui_agent_dispatch"
8650
8908
  });
8651
- const msg = err6 instanceof ManusScheduleError ? `Manus API error (HTTP ${err6.statusCode}): ${err6.message}` : err6 instanceof Error ? err6.message : String(err6);
8909
+ const msg = err7 instanceof ManusScheduleError ? `Manus API error (HTTP ${err7.statusCode}): ${err7.message}` : err7 instanceof Error ? err7.message : String(err7);
8652
8910
  return { isError: true, content: [{ type: "text", text: msg }] };
8653
8911
  }
8654
8912
  }
@@ -8693,7 +8951,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8693
8951
  }
8694
8952
  ]
8695
8953
  };
8696
- } catch (err6) {
8954
+ } catch (err7) {
8697
8955
  ctx.audit({
8698
8956
  tool_name: "ui_agent_usage",
8699
8957
  ocs_method: "[manus:usage.get]",
@@ -8701,7 +8959,7 @@ function registerScheduleAndUsageTools(server2, ctx) {
8701
8959
  dry_run: false,
8702
8960
  duration_ms: Date.now() - start
8703
8961
  });
8704
- const msg = err6 instanceof Error ? err6.message : String(err6);
8962
+ const msg = err7 instanceof Error ? err7.message : String(err7);
8705
8963
  return { isError: true, content: [{ type: "text", text: msg }] };
8706
8964
  }
8707
8965
  }
@@ -8944,7 +9202,7 @@ Report success or failure with a clear summary.`;
8944
9202
  }
8945
9203
  ]
8946
9204
  };
8947
- } catch (err6) {
9205
+ } catch (err7) {
8948
9206
  ctx.audit({
8949
9207
  tool_name: toolName,
8950
9208
  ocs_method: `[ui-agent:${gapId}]`,
@@ -8958,7 +9216,7 @@ Report success or failure with a clear summary.`;
8958
9216
  content: [
8959
9217
  {
8960
9218
  type: "text",
8961
- text: `Error dispatching UI agent: ${err6 instanceof Error ? err6.message : String(err6)}`
9219
+ text: `Error dispatching UI agent: ${err7 instanceof Error ? err7.message : String(err7)}`
8962
9220
  }
8963
9221
  ]
8964
9222
  };
@@ -9382,7 +9640,7 @@ function registerUiAgentAskTools(server2, ctx) {
9382
9640
  try {
9383
9641
  const replyOutcome = await askReply(keys, task_id, reply);
9384
9642
  result2 = replyOutcome.data;
9385
- } catch (err6) {
9643
+ } catch (err7) {
9386
9644
  ctx.audit({
9387
9645
  tool_name: "ui_agent_reply",
9388
9646
  ocs_method: "[ui-agent:ask-reply]",
@@ -9397,7 +9655,7 @@ function registerUiAgentAskTools(server2, ctx) {
9397
9655
  content: [
9398
9656
  {
9399
9657
  type: "text",
9400
- text: `Error calling Manus task.reply: ${err6 instanceof Error ? err6.message : String(err6)}`
9658
+ text: `Error calling Manus task.reply: ${err7 instanceof Error ? err7.message : String(err7)}`
9401
9659
  }
9402
9660
  ]
9403
9661
  };
@@ -9475,13 +9733,13 @@ function registerUiAgentAskTools(server2, ctx) {
9475
9733
  try {
9476
9734
  const listing = await ctx.env.CARRIER_USERS.list({ prefix: PENDING_ASK_PREFIX });
9477
9735
  keys = listing.keys;
9478
- } catch (err6) {
9736
+ } catch (err7) {
9479
9737
  return {
9480
9738
  isError: true,
9481
9739
  content: [
9482
9740
  {
9483
9741
  type: "text",
9484
- text: `Error listing pending tasks: ${err6 instanceof Error ? err6.message : String(err6)}`
9742
+ text: `Error listing pending tasks: ${err7 instanceof Error ? err7.message : String(err7)}`
9485
9743
  }
9486
9744
  ]
9487
9745
  };
@@ -10935,6 +11193,138 @@ function registerStorefrontLogoTools(server2, env = {}) {
10935
11193
  );
10936
11194
  }
10937
11195
 
11196
+ // src/tools-storefront-deploy.ts
11197
+ import { z as z22 } from "zod";
11198
+ var ok6 = (value) => ({
11199
+ content: [{ type: "text", text: JSON.stringify(value) }]
11200
+ });
11201
+ var err6 = (text) => ({ isError: true, content: [{ type: "text", text }] });
11202
+ function registerStorefrontDeployTools(server2) {
11203
+ server2.tool(
11204
+ "list_deploy_targets",
11205
+ "Report which storefront deploy hosts (Cloudflare, Vercel, Netlify, Fly) are installed, logged in, and ready on this machine, and which one a deploy would pick.",
11206
+ {
11207
+ dir: z22.string().min(1).describe("Path to the scaffolded storefront directory")
11208
+ },
11209
+ async ({ dir }) => {
11210
+ try {
11211
+ const statuses = await probeAll(dir);
11212
+ const ranked = rankTargets(statuses);
11213
+ return ok6({
11214
+ targets: statuses,
11215
+ default: ranked[0]?.id ?? null,
11216
+ reason: ranked.length ? void 0 : "No host is installed and logged in."
11217
+ });
11218
+ } catch (e) {
11219
+ return err6(e instanceof Error ? e.message : "target probe failed");
11220
+ }
11221
+ }
11222
+ );
11223
+ server2.tool(
11224
+ "deploy_storefront",
11225
+ "Build and deploy a scaffolded storefront to whichever host is available on this machine, then push its runtime secrets (CARRIER_API_KEY, CLERK_SECRET_KEY) to that host.",
11226
+ {
11227
+ dir: z22.string().min(1).describe("Path to the scaffolded storefront directory"),
11228
+ target: z22.enum(TARGET_IDS).optional().describe("Force a host instead of auto-detecting"),
11229
+ name: z22.string().optional().describe("Project/worker name (defaults from brand)"),
11230
+ skip_build: z22.boolean().optional().describe("Deploy the existing build without rebuilding"),
11231
+ push_secrets: z22.boolean().optional().describe("Stage runtime secrets on the host before deploy (default true)"),
11232
+ verify: z22.boolean().optional().describe("Probe the deployed URL and report whether it actually serves (default true)"),
11233
+ domain: z22.boolean().optional().describe("Route the brand domain to the deployment (Cloudflare provisions DNS + cert)")
11234
+ },
11235
+ async ({ dir, target, name, skip_build, push_secrets, verify, domain }) => {
11236
+ try {
11237
+ if (target && !isTargetId(target)) return err6(`Unknown target "${target}".`);
11238
+ const brand = await loadStorefrontBrand(dir, name ? { name } : void 0);
11239
+ const preferred = target;
11240
+ if (!skip_build) {
11241
+ const ranked = rankTargets(await probeAll(dir));
11242
+ const buildFor = preferred ?? ranked[0]?.id ?? "cloudflare";
11243
+ const built = await buildSite(dir, buildFor);
11244
+ if (!built) return err6(`Build failed for ${buildFor}. Run the build locally to see why.`);
11245
+ }
11246
+ const result2 = await deploySite(dir, brand, {
11247
+ preferred,
11248
+ pushSecrets: push_secrets !== false,
11249
+ customDomain: domain === true
11250
+ });
11251
+ if (!result2.ok) {
11252
+ return err6(result2.reason ?? "Deploy failed.");
11253
+ }
11254
+ const verification = verify !== false && result2.url ? await verifyStorefront(result2.url, dir, process.env) : void 0;
11255
+ return ok6({
11256
+ deployed: true,
11257
+ target: result2.target,
11258
+ project: result2.projectName,
11259
+ url: result2.url ?? null,
11260
+ secrets_staged: result2.secrets?.pushed ?? [],
11261
+ secrets_failed: result2.secrets?.failed ?? [],
11262
+ verified: verification ? verification.ok : null,
11263
+ diagnosis: verification?.ok === false ? verification.diagnosis : void 0,
11264
+ probes: verification ? formatProbes(verification.probes) : void 0,
11265
+ repair_hint: verification && !verification.ok ? repairPlanFor(verification.diagnosis)?.note ?? verification.summary : void 0,
11266
+ warning: result2.secrets?.pushed.includes("CARRIER_API_KEY") ? void 0 : "CARRIER_API_KEY was not staged \u2014 / and /shop will return 500 until it is set."
11267
+ });
11268
+ } catch (e) {
11269
+ return err6(e instanceof Error ? e.message : "deploy failed");
11270
+ }
11271
+ }
11272
+ );
11273
+ server2.tool(
11274
+ "provision_storefront_clerk",
11275
+ "Create a Clerk application (Platform API) or reuse Clerk keys already on this machine, write them into the storefront .env.local, and configure allowed origins and redirect URLs on the instance.",
11276
+ {
11277
+ dir: z22.string().min(1).describe("Path to the scaffolded storefront directory"),
11278
+ name: z22.string().optional().describe("Clerk application name (defaults from brand)"),
11279
+ production: z22.boolean().optional().describe("Also create a production instance (Platform API only)"),
11280
+ no_create: z22.boolean().optional().describe("Never create a new application; only reuse existing keys"),
11281
+ url: z22.string().optional().describe("Deployed URL to whitelist as origin and redirect")
11282
+ },
11283
+ async ({ dir, name, production, no_create, url }) => {
11284
+ try {
11285
+ const brand = await loadStorefrontBrand(dir, name ? { name } : void 0);
11286
+ const result2 = await provisionClerk({
11287
+ name: name ?? brand.name,
11288
+ domain: brand.domain,
11289
+ production: production === true,
11290
+ noCreate: no_create === true,
11291
+ env: process.env,
11292
+ storefront: dir,
11293
+ cli: clerkCliDeps()
11294
+ });
11295
+ if (!result2.ok || !result2.credentials) {
11296
+ return ok6({
11297
+ provisioned: false,
11298
+ tier: result2.tier,
11299
+ reason: result2.reason,
11300
+ guidance: result2.guidance ?? []
11301
+ });
11302
+ }
11303
+ const written = await mergeEnvLocal(dir, {
11304
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: result2.credentials.publishableKey,
11305
+ CLERK_SECRET_KEY: result2.credentials.secretKey
11306
+ });
11307
+ const { allowedOrigins, redirectUrls } = storefrontClerkUrls(url, brand.domain);
11308
+ const configured = await configureClerkInstance(result2.credentials.secretKey, {
11309
+ allowedOrigins,
11310
+ redirectUrls
11311
+ });
11312
+ return ok6({
11313
+ provisioned: true,
11314
+ tier: result2.tier,
11315
+ application_id: result2.credentials.applicationId ?? null,
11316
+ instance_id: result2.credentials.instanceId ?? null,
11317
+ env_written: written,
11318
+ configured: configured.applied,
11319
+ configure_failures: configured.failed
11320
+ });
11321
+ } catch (e) {
11322
+ return err6(e instanceof Error ? e.message : "clerk provisioning failed");
11323
+ }
11324
+ }
11325
+ );
11326
+ }
11327
+
10938
11328
  // src/index.ts
10939
11329
  var baseUrl = process.env.CARRIER_OCS_BASE_URL ?? process.env.ESIMVAULT_BASE_URL ?? "https://ocs.esimvault.cloud";
10940
11330
  var token = process.env.CARRIER_OCS_API_TOKEN ?? process.env.ESIMVAULT_API_TOKEN;
@@ -11003,6 +11393,7 @@ registerWalletTools(server, { env: stdioEnv, props });
11003
11393
  registerGreenzoneTools(server, toolCtx);
11004
11394
  registerUiAgentGenericTools(server, toolCtx);
11005
11395
  registerStorefrontLogoTools(server, stdioEnv);
11396
+ registerStorefrontDeployTools(server);
11006
11397
  registerAllPrompts(server);
11007
11398
  var transport = new StdioServerTransport();
11008
11399
  await server.connect(transport);