@carrierllc/mcp 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,25 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CARRIER_VERSION,
4
+ OCS_MAX_USAGE_WINDOW_DAYS,
4
5
  ROUTER_RULES,
5
6
  TARGET_IDS,
6
7
  acquireEndpointSlot,
8
+ applyParamRenames,
7
9
  applySubscriberFilters,
8
10
  buildListSubscriberParams,
9
11
  buildRouterCatalog,
10
12
  buildSite,
11
13
  clerkCliDeps,
12
14
  configureClerkInstance,
15
+ daysUntil,
13
16
  deploySite,
14
17
  esimStatusPerAccountParams,
18
+ extractDailyUsage,
15
19
  extractEsimStatusCounts,
20
+ filterActiveSubscribers,
16
21
  fleetScreen,
22
+ formatBytes,
17
23
  formatProbes,
18
24
  generateStorefrontLogo,
19
25
  getLimitForEndpoint,
20
26
  getRateLimitWindowCounts,
21
27
  imsiFromSubscriberRecord,
22
28
  isTargetId,
29
+ lastNDaysPeriod,
23
30
  loadStorefrontBrand,
24
31
  locationParams,
25
32
  mergeEnvLocal,
@@ -33,11 +40,14 @@ import {
33
40
  recurringPackageParams,
34
41
  renderHtml,
35
42
  repairPlanFor,
43
+ runWithBudget,
36
44
  storefrontClerkUrls,
37
45
  subscriberIdParams,
46
+ subscriberRows,
38
47
  usageOverPeriodParams,
39
- verifyStorefront
40
- } from "./chunk-V4CYEMLJ.js";
48
+ verifyStorefront,
49
+ withOcsListSummary
50
+ } from "./chunk-KHNJNJX3.js";
41
51
  import "./chunk-SHKKVIIA.js";
42
52
 
43
53
  // src/index.ts
@@ -69,32 +79,35 @@ var OcsClient = class {
69
79
  async call(method, params = {}) {
70
80
  await acquireEndpointSlot(this.token, method, "interactive");
71
81
  const url = `${this.baseUrl}/v1?token=${this.token}`;
72
- const body2 = JSON.stringify({ [method]: params });
73
- const res = await fetch(url, {
74
- method: "POST",
75
- headers: { "Content-Type": "application/json" },
76
- body: body2
77
- });
78
- if (!res.ok) {
79
- throw new OcsApiError(res.status, `HTTP ${res.status} ${res.statusText}`, method);
80
- }
81
- const json = await res.json();
82
- if (json.status?.code !== 0) {
83
- throw new OcsApiError(json.status?.code ?? -1, json.status?.msg ?? "Unknown error", method);
84
- }
85
- if (method === "getCustomerTariff" && json["listTariffRule"] !== void 0) {
86
- return json["listTariffRule"];
87
- }
88
- if (method === "getSubscriberLocationByCellId") {
89
- const byMethod = json[method];
90
- if (byMethod !== void 0) {
91
- return byMethod;
82
+ const body2 = JSON.stringify({ [method]: applyParamRenames(method, params) });
83
+ return runWithBudget(method, async (signal) => {
84
+ const res = await fetch(url, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: body2,
88
+ signal
89
+ });
90
+ if (!res.ok) {
91
+ throw new OcsApiError(res.status, `HTTP ${res.status} ${res.statusText}`, method);
92
92
  }
93
- if (json["subscriberLocation"] !== void 0) {
94
- return json["subscriberLocation"];
93
+ const json = await res.json();
94
+ if (json.status?.code !== 0) {
95
+ throw new OcsApiError(json.status?.code ?? -1, json.status?.msg ?? "Unknown error", method);
95
96
  }
96
- }
97
- return json[method] ?? json;
97
+ if (method === "getCustomerTariff" && json["listTariffRule"] !== void 0) {
98
+ return json["listTariffRule"];
99
+ }
100
+ if (method === "getSubscriberLocationByCellId") {
101
+ const byMethod = json[method];
102
+ if (byMethod !== void 0) {
103
+ return byMethod;
104
+ }
105
+ if (json["subscriberLocation"] !== void 0) {
106
+ return json["subscriberLocation"];
107
+ }
108
+ }
109
+ return json[method] ?? json;
110
+ });
98
111
  }
99
112
  };
100
113
 
@@ -189,6 +202,9 @@ function estimateCostUsd(u, rates = HAIKU_RATES) {
189
202
  var DEFAULT_BEDROCK_REGION = "us-east-1";
190
203
  var DEFAULT_MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
191
204
  var BEDROCK_TIMEOUT_MS = 3e4;
205
+ var MAX_ATTEMPTS = 4;
206
+ var INITIAL_RETRY_MS = 100;
207
+ var MIN_ATTEMPT_BUDGET_MS = 250;
192
208
  var BedrockRateLimitError = class extends Error {
193
209
  isRateLimit = true;
194
210
  /**
@@ -218,7 +234,11 @@ function client(creds) {
218
234
  secretAccessKey: creds.secretAccessKey,
219
235
  ...creds.sessionToken ? { sessionToken: creds.sessionToken } : {},
220
236
  region,
221
- service: "bedrock"
237
+ service: "bedrock",
238
+ // See MAX_ATTEMPTS. aws4fetch's retry loop cannot be cancelled, so this
239
+ // layer owns the policy instead. Changing this back to the default 10
240
+ // silently restores the ~50s stall that timeoutMs is supposed to prevent.
241
+ retries: 0
222
242
  }),
223
243
  region,
224
244
  modelId: creds.modelId ?? DEFAULT_MODEL_ID
@@ -234,12 +254,12 @@ function body(req) {
234
254
  ...req.tools ? { tools: req.tools, tool_choice: { type: "any" } } : {}
235
255
  };
236
256
  }
237
- function headersFor(req, accept) {
257
+ function headersFor(attribution, accept) {
238
258
  const headers = {
239
259
  "Content-Type": "application/json",
240
260
  Accept: accept
241
261
  };
242
- const meta = req.attribution ? requestMetadata(req.attribution) : null;
262
+ const meta = attribution ? requestMetadata(attribution) : null;
243
263
  if (meta) headers[REQUEST_METADATA_HEADER] = JSON.stringify(meta);
244
264
  return headers;
245
265
  }
@@ -260,28 +280,81 @@ async function fail(resp) {
260
280
  }
261
281
  throw new BedrockError(text || `Bedrock returned HTTP ${resp.status}`, resp.status);
262
282
  }
283
+ function timedOut() {
284
+ const err7 = new Error("Bedrock call timed out before a usable response");
285
+ err7.name = "TimeoutError";
286
+ return err7;
287
+ }
288
+ function sleep(ms, signal) {
289
+ if (signal.aborted) return Promise.resolve();
290
+ return new Promise((resolve) => {
291
+ const done = () => {
292
+ clearTimeout(timer);
293
+ signal.removeEventListener("abort", done);
294
+ resolve();
295
+ };
296
+ const timer = setTimeout(done, ms);
297
+ signal.addEventListener("abort", done, { once: true });
298
+ });
299
+ }
300
+ function retryable(status) {
301
+ return status === 429 || status >= 500;
302
+ }
303
+ async function sendWithRetry(aws, url, init, signal, deadlineAt) {
304
+ let throttled = null;
305
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
306
+ if (signal.aborted) break;
307
+ let resp;
308
+ try {
309
+ resp = await aws.fetch(url, init);
310
+ } catch (err7) {
311
+ if (signal.aborted && throttled) return throttled;
312
+ throw err7;
313
+ }
314
+ if (!retryable(resp.status)) return resp;
315
+ throttled = resp;
316
+ if (attempt === MAX_ATTEMPTS - 1) break;
317
+ const backoff = Math.random() * INITIAL_RETRY_MS * 2 ** attempt;
318
+ if (Date.now() + backoff + MIN_ATTEMPT_BUDGET_MS > deadlineAt) break;
319
+ await sleep(backoff, signal);
320
+ }
321
+ if (throttled) return throttled;
322
+ throw timedOut();
323
+ }
263
324
  async function invokeTool(creds, req, signal) {
325
+ const json = await invokeModelRaw(
326
+ creds,
327
+ body(req),
328
+ { attribution: req.attribution, timeoutMs: req.timeoutMs },
329
+ signal
330
+ );
331
+ reportUsage(req, extractUsage(json));
332
+ const block = json.content?.find((b) => b.type === "tool_use");
333
+ return block?.name ? { name: block.name, input: block.input ?? {} } : null;
334
+ }
335
+ async function invokeModelRaw(creds, payload, opts = {}, signal) {
264
336
  const { aws, region, modelId } = client(creds);
337
+ const timeoutMs = opts.timeoutMs ?? BEDROCK_TIMEOUT_MS;
338
+ const deadlineAt = Date.now() + timeoutMs;
265
339
  const controller = new AbortController();
266
- const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? BEDROCK_TIMEOUT_MS);
340
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
267
341
  const onAbort = () => controller.abort();
268
342
  signal?.addEventListener("abort", onAbort, { once: true });
269
343
  try {
270
- const headers = headersFor(req, "application/json");
271
- const resp = await aws.fetch(
344
+ const resp = await sendWithRetry(
345
+ aws,
272
346
  `https://bedrock-runtime.${region}.amazonaws.com/model/${encodeURIComponent(modelId)}/invoke`,
273
347
  {
274
348
  method: "POST",
275
- headers,
276
- body: JSON.stringify(body(req)),
349
+ headers: headersFor(opts.attribution, "application/json"),
350
+ body: JSON.stringify(payload),
277
351
  signal: controller.signal
278
- }
352
+ },
353
+ controller.signal,
354
+ deadlineAt
279
355
  );
280
356
  if (!resp.ok) await fail(resp);
281
- const json = await resp.json();
282
- reportUsage(req, extractUsage(json));
283
- const block = json.content?.find((b) => b.type === "tool_use");
284
- return block?.name ? { name: block.name, input: block.input ?? {} } : null;
357
+ return await resp.json();
285
358
  } finally {
286
359
  clearTimeout(timer);
287
360
  signal?.removeEventListener("abort", onAbort);
@@ -762,11 +835,12 @@ function wrapHandler(toolName, ocsMethod, requiredScope, ctx, handler) {
762
835
  return result2;
763
836
  };
764
837
  }
765
- async function ocsCall(env, token2, method, params = {}) {
838
+ async function ocsCall(env, token2, method, params = {}, listKind) {
766
839
  const client2 = new OcsClient(env.CARRIER_OCS_BASE_URL, token2);
767
840
  const result2 = await client2.call(method, params);
841
+ const payload = listKind ? withOcsListSummary(result2, listKind) : result2;
768
842
  return {
769
- content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
843
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
770
844
  };
771
845
  }
772
846
  async function resolveSubscriberByIccid(env, token2, iccid, cache) {
@@ -796,7 +870,7 @@ function registerAllTools(server2, ctx) {
796
870
  "list_reseller_accounts",
797
871
  {
798
872
  title: "List Reseller Accounts",
799
- description: "Use this to enumerate the accounts (sub-resellers or customer accounts) BELOW a reseller. Params: `resellerId` (integer, optional \u2014 omit to list accounts under the token owner's reseller). Returns: `{ reseller: [ { id, name, account: [ { id, name, balance, packageOnly } ] } ] }` \u2014 the accounts are NESTED under each reseller, not a flat top-level array. Do NOT use this to fetch a single subscriber's details \u2014 use `get_subscriber` instead. Do NOT use this to check eSIM activation counts \u2014 use `esim_status_per_account` for that. Do NOT use this to find a reseller's PARENT, balance or charging plans \u2014 those are above or on the reseller itself, not in this list; use `get_reseller_info`.",
873
+ description: "Use this to enumerate the accounts (sub-resellers or customer accounts) BELOW a reseller. Params: `resellerId` (integer, optional \u2014 omit to list accounts under the token owner's reseller). Returns: `{ summary, reseller: [ { id, name, account: [ { id, name, balance, packageOnly } ] } ] }` \u2014 the accounts are NESTED under each reseller, not a flat top-level array. `summary.total` therefore counts RESELLERS; the account count is `summary.nested.accounts`. For any question about accounts use that, never `total`. Do NOT use this to fetch a single subscriber's details \u2014 use `get_subscriber` instead. Do NOT use this to check eSIM activation counts \u2014 use `esim_status_per_account` for that. Do NOT use this to find a reseller's PARENT, balance or charging plans \u2014 those are above or on the reseller itself, not in this list; use `get_reseller_info`.",
800
874
  inputSchema: {
801
875
  resellerId: z.number().optional().describe("Filter to a specific reseller by ID (omit for token owner's reseller)")
802
876
  },
@@ -810,7 +884,7 @@ function registerAllTools(server2, ctx) {
810
884
  async ({ resellerId }, token2) => {
811
885
  const params = {};
812
886
  if (resellerId !== void 0) params.resellerId = resellerId;
813
- return ocsCall(ctx.env, token2, "listResellerAccount", params);
887
+ return ocsCall(ctx.env, token2, "listResellerAccount", params, "resellers");
814
888
  }
815
889
  )
816
890
  );
@@ -958,7 +1032,7 @@ function registerAllTools(server2, ctx) {
958
1032
  "list_subscribers",
959
1033
  {
960
1034
  title: "List Subscribers",
961
- description: "Use this to list subscribers with optional filters and pagination. Good for fleet enumeration, bulk status checks, and finding subscribers by account or status. OCS REQUIRES at least one search key \u2014 provide exactly one of: `imsi`, `iccid`, `activationCode`, `accountId`, or `msisdn`. Calling with no key will be rejected before reaching OCS. Params: `imsi` (string), `iccid` (string), `activationCode` (string), `accountId` (integer), `msisdn` (string), `status` (string, e.g. 'ACTIVE'/'SUSPENDED'), `offset` (integer, pagination \u2014 default 0), `limit` (integer, max results \u2014 always set to avoid unbounded fetches; recommended max 100 per call). Returns: array of subscriber summary records with ICCID, status, and account. Do NOT use this to fetch full details for a specific subscriber \u2014 use `get_subscriber` for that.",
1035
+ description: "Use this to list subscribers with optional filters and pagination. Good for fleet enumeration, bulk status checks, and finding subscribers by account or status. OCS REQUIRES at least one search key \u2014 provide exactly one of: `imsi`, `iccid`, `activationCode`, `accountId`, or `msisdn`. Calling with no key will be rejected before reaching OCS. Params: `imsi` (string), `iccid` (string), `activationCode` (string), `accountId` (integer), `msisdn` (string), `status` (string, e.g. 'ACTIVE'/'SUSPENDED'), `offset` (integer, pagination \u2014 default 0), `limit` (integer, max results \u2014 always set to avoid unbounded fetches; recommended max 100 per call). Returns: `{ summary, subscriberList: [...] }` \u2014 subscriber summary records with ICCID, status and account, behind a server-counted `summary.total` (rows returned) and `summary.breakdown.status`. Answer 'how many' from `summary`, not by counting rows \u2014 but when `summary.moreAvailable` is set this is one page, so report `summary.upstreamTotal` for the fleet-wide figure, never `total`. Do NOT use this to fetch full details for a specific subscriber \u2014 use `get_subscriber` for that.",
962
1036
  inputSchema: z.object({
963
1037
  imsi: z.string().optional().describe("Filter by IMSI"),
964
1038
  iccid: z.string().optional().describe("Filter by ICCID"),
@@ -987,7 +1061,12 @@ function registerAllTools(server2, ctx) {
987
1061
  const raw = await client2.call("listSubscriber", params);
988
1062
  const payload = applySubscriberFilters(raw, args);
989
1063
  return {
990
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
1064
+ content: [
1065
+ {
1066
+ type: "text",
1067
+ text: JSON.stringify(withOcsListSummary(payload, "subscribers"), null, 2)
1068
+ }
1069
+ ]
991
1070
  };
992
1071
  }
993
1072
  )
@@ -1314,7 +1393,7 @@ function registerAllTools(server2, ctx) {
1314
1393
  "list_subscriber_packages",
1315
1394
  {
1316
1395
  title: "List Subscriber Packages",
1317
- description: "Use this to retrieve all prepaid packages currently assigned to a subscriber. Returns each package's allowance (data/voice/SMS), consumed usage, expiry date, status, and packageId. Always call this before any package modification tool (`modify_package_limits`, `modify_package_expiry`, `modify_package_status`, `delete_subscriber_package`) to confirm the correct packageId and current state. Params: `iccid` (subscriber identifier). Returns: array of package records with `packageId`, `name`, `status`, `dataLimit`, `dataUsed`, `expirationDate`, `recurring` flag. Do NOT use this to browse the product catalog \u2014 use `list_package_templates` for that.",
1396
+ description: "Use this to retrieve all prepaid packages currently assigned to a subscriber. Returns each package's allowance (data/voice/SMS), consumed usage, expiry date, status, and packageId. Always call this before any package modification tool (`modify_package_limits`, `modify_package_expiry`, `modify_package_status`, `delete_subscriber_package`) to confirm the correct packageId and current state. Params: `iccid` (subscriber identifier). Returns: `{ summary, prepaidPackage: [...] }` \u2014 package records with `packageId`, `name`, `status`, `dataLimit`, `dataUsed`, `expirationDate` and a `recurring` flag, behind a server-counted `summary.total` and a `summary.breakdown` over status and recurring. Answer 'how many' from `summary`, not by counting rows. Do NOT use this to browse the product catalog \u2014 use `list_package_templates` for that.",
1318
1397
  inputSchema: { iccid: z.string().describe("The subscriber ICCID") },
1319
1398
  annotations: { readOnlyHint: true }
1320
1399
  },
@@ -1323,7 +1402,7 @@ function registerAllTools(server2, ctx) {
1323
1402
  "listSubscriberPrepaidPackages",
1324
1403
  TOOL_SCOPES["list_subscriber_packages"],
1325
1404
  ctx,
1326
- async ({ iccid }, token2) => ocsCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid })
1405
+ async ({ iccid }, token2) => ocsCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid }, "packages")
1327
1406
  )
1328
1407
  );
1329
1408
  server2.registerTool(
@@ -1556,10 +1635,11 @@ function registerAllTools(server2, ctx) {
1556
1635
  "list_package_templates",
1557
1636
  {
1558
1637
  title: "List Package Templates",
1559
- description: "Use this to browse the product catalog of prepaid package templates available for assignment. Returns each template's name, data/voice/SMS limits, pricing, validity period, location zone, and recurring configuration. Call this before `assign_package` or `assign_recurring_package` to obtain valid `packageTemplateId` values. Params: `accountId` (integer, optional \u2014 filter templates visible to a specific account). Returns: array of template records with `templateId`, `name`, `dataLimit`, `price`, `validityDays`, `locationZoneId`, `recurring`. Do NOT use this to list packages assigned to a specific subscriber \u2014 use `list_subscriber_packages`.",
1560
- inputSchema: {
1561
- accountId: z.number().optional().describe("Filter templates by account ID")
1562
- },
1638
+ description: "Use this to browse the product catalog of prepaid package templates available for assignment. Returns each template's name, data/voice/SMS limits, pricing, validity period, location zone, and recurring configuration. Call this before `assign_package` or `assign_recurring_package` to obtain valid `packageTemplateId` values. Params: none \u2014 returns the whole catalog for the token owner's reseller. Returns: `{ summary, template: [...] }`. `summary.total` is the number of templates, counted by the server over the complete catalog, and `summary.breakdown` counts the templates per `recurring` value and per location zone. Answer any 'how many' question from `summary` \u2014 it is authoritative, and counting the rows yourself gets it wrong. Each row carries `prepaidpackagetemplateid`, `prepaidpackagetemplatename`, `databyte` (bytes), `cost`, `perioddays`, `locationzoneid`, `rdbLocationZones.locationzonename` and `recurring`. Do NOT use this to list packages assigned to a specific subscriber \u2014 use `list_subscriber_packages`.",
1639
+ // No `accountId` — see the matching note in apps/mcp-server/src/tools.ts.
1640
+ // OCS rejected every call that carried it, and it is not a rename of any
1641
+ // of the five properties OCS does accept.
1642
+ inputSchema: {},
1563
1643
  annotations: { readOnlyHint: true }
1564
1644
  },
1565
1645
  wrapHandler(
@@ -1567,11 +1647,7 @@ function registerAllTools(server2, ctx) {
1567
1647
  "listPrepaidPackageTemplate",
1568
1648
  TOOL_SCOPES["list_package_templates"],
1569
1649
  ctx,
1570
- async ({ accountId }, token2) => {
1571
- const params = {};
1572
- if (accountId !== void 0) params.accountId = accountId;
1573
- return ocsCall(ctx.env, token2, "listPrepaidPackageTemplate", params);
1574
- }
1650
+ async (_args, token2) => ocsCall(ctx.env, token2, "listPrepaidPackageTemplate", {}, "templates")
1575
1651
  )
1576
1652
  );
1577
1653
  server2.registerTool(
@@ -1824,7 +1900,7 @@ function registerAllTools(server2, ctx) {
1824
1900
  "get_tariff",
1825
1901
  {
1826
1902
  title: "Get Customer Tariff",
1827
- 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.`,
1903
+ 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\`. This is the slowest tool on the platform: the OCS table is ~3.4 MB and takes 3 s in a fast window and up to ~2 minutes in a slow one, so the call carries a 180 s budget. Expect to wait, and do not call it in a loop. 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.`,
1828
1904
  inputSchema: {
1829
1905
  resellerId: z.number().optional().describe("Reseller ID (omit to use token owner's reseller)"),
1830
1906
  country: z.string().optional().describe(
@@ -1950,8 +2026,9 @@ var ocs_methods_default = {
1950
2026
  scope: "read",
1951
2027
  description: "Retrieve reseller details",
1952
2028
  params: {
1953
- resellerId: { type: "number", required: false }
2029
+ resellerId: { type: "number", required: false, ocs_field: "id" }
1954
2030
  },
2031
+ params_note: "`resellerId` is the MCP-facing name. OCS getResellerInfo accepts exactly one property, `id`; @carrier/ocs-client translates it at the serialisation chokepoint (OCS_PARAM_RENAMES).",
1955
2032
  response: {},
1956
2033
  annotations: "readOnlyHint",
1957
2034
  verified_against_server: true,
@@ -2356,9 +2433,8 @@ var ocs_methods_default = {
2356
2433
  category: "templates",
2357
2434
  scope: "read",
2358
2435
  description: "List all prepaid package templates",
2359
- params: {
2360
- accountId: { type: "number", required: false }
2361
- },
2436
+ params: {},
2437
+ params_note: "No account filter. OCS listPrepaidPackageTemplate accepts only locationZoneId, templateId, destinationListId, resellerId, sponsorId. The former `accountId` param was rejected by OCS on every call and is not a rename of any of those five: accounts are a different id space from resellers and sponsors.",
2362
2438
  response: {},
2363
2439
  annotations: "readOnlyHint",
2364
2440
  verified_against_server: true,
@@ -3492,26 +3568,9 @@ async function fetchActiveSubscribers(env, token2, accountId, resellerId) {
3492
3568
  { accountId: acctId }
3493
3569
  );
3494
3570
  if (subResult.error) return { data: null, error: subResult.error };
3495
- const raw = subResult.data;
3496
- const list = Array.isArray(raw) ? raw : raw?.subscriberList ?? [];
3497
- aggregated.push(...list);
3571
+ aggregated.push(...subscriberRows(subResult.data));
3498
3572
  }
3499
- const active = aggregated.filter((s) => String(s.status ?? "").toUpperCase() === "ACTIVE");
3500
- return { data: active, error: null };
3501
- }
3502
- function formatBytes(bytes) {
3503
- if (bytes === 0) return "0 B";
3504
- const units = ["B", "KB", "MB", "GB", "TB"];
3505
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
3506
- return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
3507
- }
3508
- function daysUntil(dateStr) {
3509
- const now = /* @__PURE__ */ new Date();
3510
- const target = new Date(dateStr);
3511
- return Math.ceil((target.getTime() - now.getTime()) / (1e3 * 60 * 60 * 24));
3512
- }
3513
- function toISODate(d) {
3514
- return d.toISOString().split("T")[0];
3573
+ return { data: filterActiveSubscribers(aggregated), error: null };
3515
3574
  }
3516
3575
  function result(text, isError = false) {
3517
3576
  return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
@@ -3591,10 +3650,10 @@ function registerIntelligenceTools(server2, ctx) {
3591
3650
  }
3592
3651
  }
3593
3652
  const now = /* @__PURE__ */ new Date();
3594
- const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1e3);
3653
+ const eventWindow = lastNDaysPeriod(3, now);
3595
3654
  const events = await safeCall(ctx.env, token2, "subscriberNetworkEventsOverPeriod", {
3596
3655
  subscriber: { iccid },
3597
- period: { start: toISODate(twoDaysAgo), end: toISODate(now) }
3656
+ period: eventWindow
3598
3657
  });
3599
3658
  if (events.data && Array.isArray(events.data)) {
3600
3659
  if (events.data.length === 0) {
@@ -3771,11 +3830,11 @@ Package-only accounts at 0 balance require no action.`);
3771
3830
  }, async ({ iccid }) => {
3772
3831
  const token2 = await ctx.getUserToken(ctx.props.sub);
3773
3832
  const now = /* @__PURE__ */ new Date();
3774
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3833
+ const usagePeriod = lastNDaysPeriod(OCS_MAX_USAGE_WINDOW_DAYS, now);
3775
3834
  const [usageResult, pkgResult] = await Promise.all([
3776
3835
  safeCall(ctx.env, token2, "subscriberUsageOverPeriod", {
3777
3836
  subscriber: { iccid },
3778
- period: { start: toISODate(weekAgo), end: toISODate(now) }
3837
+ period: usagePeriod
3779
3838
  }),
3780
3839
  safeCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid })
3781
3840
  ]);
@@ -3783,13 +3842,8 @@ Package-only accounts at 0 balance require no action.`);
3783
3842
  const sections = [`# Usage Anomaly Report: ${iccid}
3784
3843
  `];
3785
3844
  const anomalies = [];
3786
- if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
3787
- const dailyData = [];
3788
- for (const entry of usageResult.data) {
3789
- const bytes = Number(entry.dataBytes ?? entry.dataVolume ?? entry.totalData ?? 0);
3790
- const date = String(entry.date ?? entry.day ?? "?");
3791
- dailyData.push({ date, bytes });
3792
- }
3845
+ const dailyData = extractDailyUsage(usageResult.data);
3846
+ if (dailyData.length > 0) {
3793
3847
  if (dailyData.length >= 2) {
3794
3848
  const volumes = dailyData.map((d) => d.bytes);
3795
3849
  const mean = volumes.reduce((a, b) => a + b, 0) / volumes.length;
@@ -3857,24 +3911,21 @@ Package-only accounts at 0 balance require no action.`);
3857
3911
  }, async ({ iccid }) => {
3858
3912
  const token2 = await ctx.getUserToken(ctx.props.sub);
3859
3913
  const now = /* @__PURE__ */ new Date();
3860
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3914
+ const usagePeriod = lastNDaysPeriod(OCS_MAX_USAGE_WINDOW_DAYS, now);
3861
3915
  const [usageResult, pkgResult, templatesResult] = await Promise.all([
3862
3916
  safeCall(ctx.env, token2, "subscriberUsageOverPeriod", {
3863
3917
  subscriber: { iccid },
3864
- period: { start: toISODate(weekAgo), end: toISODate(now) }
3918
+ period: usagePeriod
3865
3919
  }),
3866
3920
  safeCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid }),
3867
3921
  safeCall(ctx.env, token2, "listPrepaidPackageTemplate", {})
3868
3922
  ]);
3869
3923
  const sections = [`# Package Optimization: ${iccid}
3870
3924
  `];
3925
+ const optimizeRows = extractDailyUsage(usageResult.data);
3871
3926
  let avgDailyData = 0;
3872
- if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
3873
- const totalData = usageResult.data.reduce(
3874
- (sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0),
3875
- 0
3876
- );
3877
- avgDailyData = totalData / usageResult.data.length;
3927
+ if (optimizeRows.length > 0) {
3928
+ avgDailyData = optimizeRows.reduce((sum, r) => sum + r.bytes, 0) / optimizeRows.length;
3878
3929
  sections.push(`## Current Usage Pattern`);
3879
3930
  sections.push(`- Average daily data: ${formatBytes(avgDailyData)}`);
3880
3931
  sections.push(`- Projected monthly: ${formatBytes(avgDailyData * 30)}`);
@@ -3945,22 +3996,21 @@ Package-only accounts at 0 balance require no action.`);
3945
3996
  }, async ({ iccid }) => {
3946
3997
  const token2 = await ctx.getUserToken(ctx.props.sub);
3947
3998
  const now = /* @__PURE__ */ new Date();
3948
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
3999
+ const usagePeriod = lastNDaysPeriod(OCS_MAX_USAGE_WINDOW_DAYS, now);
3949
4000
  const [subResult, usageResult, pkgResult, activeResult] = await Promise.all([
3950
4001
  safeCall(ctx.env, token2, "getSingleSubscriber", { iccid }),
3951
4002
  safeCall(ctx.env, token2, "subscriberUsageOverPeriod", {
3952
4003
  subscriber: { iccid },
3953
- period: { start: toISODate(weekAgo), end: toISODate(now) }
4004
+ period: usagePeriod
3954
4005
  }),
3955
4006
  safeCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid }),
3956
4007
  safeCall(ctx.env, token2, "getSubscriberActivePeriod", { iccid })
3957
4008
  ]);
3958
4009
  let riskScore = 0;
3959
4010
  const factors = [];
3960
- if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length >= 3) {
3961
- const volumes = usageResult.data.map(
3962
- (e) => Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0)
3963
- );
4011
+ const churnRows = extractDailyUsage(usageResult.data);
4012
+ if (churnRows.length >= 3) {
4013
+ const volumes = churnRows.map((r) => r.bytes);
3964
4014
  const firstHalf = volumes.slice(0, Math.floor(volumes.length / 2));
3965
4015
  const secondHalf = volumes.slice(Math.floor(volumes.length / 2));
3966
4016
  const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
@@ -3977,7 +4027,7 @@ Package-only accounts at 0 balance require no action.`);
3977
4027
  factors.push({ factor: "Moderately declining usage", impact, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}%` });
3978
4028
  }
3979
4029
  }
3980
- } else if (!usageResult.data || Array.isArray(usageResult.data) && usageResult.data.length === 0) {
4030
+ } else if (churnRows.length === 0) {
3981
4031
  riskScore += 25;
3982
4032
  factors.push({ factor: "No recent usage", impact: 25, detail: "Zero data activity in last 7 days" });
3983
4033
  }
@@ -4272,7 +4322,7 @@ ${networksSorted.length} different networks in ${country} \u2014 possible steeri
4272
4322
  const sections = [`# High Cost Subscriber Report
4273
4323
  `];
4274
4324
  const now = /* @__PURE__ */ new Date();
4275
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
4325
+ const usagePeriod = lastNDaysPeriod(OCS_MAX_USAGE_WINDOW_DAYS, now);
4276
4326
  const highCostSubs = [];
4277
4327
  const batchSize = 5;
4278
4328
  const subs = subsResult.data.slice(0, sampleSize);
@@ -4284,19 +4334,13 @@ ${networksSorted.length} different networks in ${country} \u2014 possible steeri
4284
4334
  const [usage, pkgs, loc] = await Promise.all([
4285
4335
  safeCall(ctx.env, token2, "subscriberUsageOverPeriod", {
4286
4336
  subscriber: { iccid },
4287
- period: { start: toISODate(weekAgo), end: toISODate(now) }
4337
+ period: usagePeriod
4288
4338
  }),
4289
4339
  safeCall(ctx.env, token2, "listSubscriberPrepaidPackages", { iccid }),
4290
4340
  safeCall(ctx.env, token2, "getSubscriberLocation", { iccid })
4291
4341
  ]);
4292
- let dailyAvgBytes = 0;
4293
- if (usage.data && Array.isArray(usage.data) && usage.data.length > 0) {
4294
- const totalBytes = usage.data.reduce(
4295
- (sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0),
4296
- 0
4297
- );
4298
- dailyAvgBytes = totalBytes / usage.data.length;
4299
- }
4342
+ const dailyRows = extractDailyUsage(usage.data);
4343
+ const dailyAvgBytes = dailyRows.length > 0 ? dailyRows.reduce((sum, r) => sum + r.bytes, 0) / dailyRows.length : 0;
4300
4344
  if (pkgs.data && Array.isArray(pkgs.data)) {
4301
4345
  const activePkg = pkgs.data.find(
4302
4346
  (p) => String(p.status ?? "").toUpperCase() === "ACTIVE"
@@ -4371,7 +4415,7 @@ ${networksSorted.length} different networks in ${country} \u2014 possible steeri
4371
4415
  });
4372
4416
  server2.registerTool("detect_country_entry", {
4373
4417
  title: "Detect Country Entry",
4374
- description: "Detects when a subscriber has entered a new country by reading networkInfo.lastMcc from getSingleSubscriber (one cheap OCS call \u2014 avoids the per-call cost of getSubscriberLocationByCellId). Resolves MCC \u2192 ISO 3166-1 alpha-2 and optionally diffs against a caller-supplied expectedCountry to return countryChanged. Designed for downstream country-entry upsell workflows (e.g. mango.talk SMS/push offers). COST NOTE: This tool makes exactly one OCS call per invocation. Consumers running polling crons MUST enforce their own rate floor \u2014 this layer provides no throttle.",
4418
+ description: "Detects when a subscriber has entered a new country by reading networkInfo.lastMcc from getSingleSubscriber (one cheap OCS call \u2014 avoids the per-call cost of getSubscriberLocationByCellId). Resolves MCC \u2192 ISO 3166-1 alpha-2 and optionally diffs against a caller-supplied expectedCountry to return countryChanged. Designed for downstream country-entry upsell workflows (e.g. mango.talk SMS/push offers). NOT AN AI TOOL: this is a deterministic MCC lookup and returns no model-written analysis, unlike the eight intelligence composites. COST NOTE: This tool makes exactly one OCS call per invocation. Consumers running polling crons MUST enforce their own rate floor \u2014 this layer provides no throttle.",
4375
4419
  inputSchema: {
4376
4420
  subscriber: z2.union([
4377
4421
  z2.object({ subscriberId: z2.number() }).describe("Internal subscriber ID"),
@@ -5528,6 +5572,21 @@ function computeExpiresAt(askedAt) {
5528
5572
  if (isNaN(asked)) return "";
5529
5573
  return new Date(asked + PENDING_ASK_TTL_SECONDS * 1e3).toISOString();
5530
5574
  }
5575
+ async function callerOwnsTask(ctx, taskId) {
5576
+ let raw;
5577
+ try {
5578
+ raw = await ctx.env.CARRIER_USERS.get(`steel_task:${taskId}`);
5579
+ } catch {
5580
+ return false;
5581
+ }
5582
+ if (!raw) return false;
5583
+ try {
5584
+ const task = JSON.parse(raw);
5585
+ return task.owner_sub !== void 0 && task.owner_sub === ctx.props.sub;
5586
+ } catch {
5587
+ return false;
5588
+ }
5589
+ }
5531
5590
  function registerUiAgentAskTools(server2, ctx) {
5532
5591
  server2.registerTool(
5533
5592
  "ui_agent_reply",
@@ -5577,7 +5636,7 @@ function registerUiAgentAskTools(server2, ctx) {
5577
5636
  }
5578
5637
  const pendingKey = `${PENDING_ASK_PREFIX}${task_id}`;
5579
5638
  const pendingRaw = await ctx.env.CARRIER_USERS.get(pendingKey);
5580
- if (pendingRaw === null) {
5639
+ if (pendingRaw === null || !await callerOwnsTask(ctx, task_id)) {
5581
5640
  return {
5582
5641
  isError: true,
5583
5642
  content: [
@@ -5719,6 +5778,8 @@ function registerUiAgentAskTools(server2, ctx) {
5719
5778
  if (!raw) return null;
5720
5779
  try {
5721
5780
  const parsed = JSON.parse(raw);
5781
+ const taskId = parsed.task_id ?? name.slice(PENDING_ASK_PREFIX.length);
5782
+ if (!await callerOwnsTask(ctx, taskId)) return null;
5722
5783
  return {
5723
5784
  ...parsed,
5724
5785
  expires_at: computeExpiresAt(parsed.asked_at)
@@ -6696,6 +6757,33 @@ async function resolveBillingSub(env, orgId) {
6696
6757
  return pointer ?? orgId;
6697
6758
  }
6698
6759
 
6760
+ // src/scope-guard.ts
6761
+ function denyUnlessScoped(props2, toolName, scopes) {
6762
+ const required = scopes[toolName];
6763
+ if (required === void 0) {
6764
+ return {
6765
+ isError: true,
6766
+ content: [
6767
+ {
6768
+ type: "text",
6769
+ text: `Scope denied: tool '${toolName}' declares no scope, so it cannot be authorised. This is a server-side omission \u2014 add it to the tool's scope table.`
6770
+ }
6771
+ ]
6772
+ };
6773
+ }
6774
+ const held = props2.scope ?? [];
6775
+ if (held.includes(required)) return null;
6776
+ return {
6777
+ isError: true,
6778
+ content: [
6779
+ {
6780
+ type: "text",
6781
+ text: `Scope denied: tool '${toolName}' requires '${required}' scope. Your token has: [${held.join(", ")}].`
6782
+ }
6783
+ ]
6784
+ };
6785
+ }
6786
+
6699
6787
  // src/wallet-tools.ts
6700
6788
  var PACKS = [
6701
6789
  { id: "pack_500", eurCents: 5e4, bonusPct: 0 },
@@ -6874,8 +6962,11 @@ var WALLET_TOOL_SCOPES = {
6874
6962
  wallet_topup_checkout: "write",
6875
6963
  wallet_auto_topup: "write"
6876
6964
  };
6877
- function ok(text) {
6878
- return { content: [{ type: "text", text }] };
6965
+ function okStructured(payload) {
6966
+ return {
6967
+ content: [{ type: "text", text: JSON.stringify(payload) }],
6968
+ structuredContent: payload
6969
+ };
6879
6970
  }
6880
6971
  function err(text) {
6881
6972
  return { isError: true, content: [{ type: "text", text }] };
@@ -6889,9 +6980,22 @@ function registerWalletTools(server2, ctx) {
6889
6980
  title: "Wallet Balance",
6890
6981
  description: "Read the caller organisation's Carrier prepaid wallet: current balance and auto-top-up settings. The wallet is what Carrier bills your own eSIM spend against \u2014 it is not an OCS balance and not your MCP plan credits. Params: none (the organisation is taken from the session). Returns: { org_id, balance_eur_cents, auto_topup_enabled, auto_topup_threshold_cents, auto_topup_pack_cents } \u2014 all amounts in EUR cents, not euros. Do NOT use this for a reseller account balance in OCS \u2014 use `get_reseller_info` or `list_reseller_accounts`. Do NOT use this for a subscriber's OCS balance \u2014 use `get_subscriber`. Do NOT use this for MCP plan credits \u2014 use `credit_balance`. Do NOT use this for money held at Stripe \u2014 use `stripe_connect_balance`.",
6891
6982
  inputSchema: {},
6983
+ // Derived from the object literal this handler builds below, not from the
6984
+ // description. Every field is unconditional on the success path and comes
6985
+ // straight off the Wallet record (wallet-client.ts:31-34), so all five are
6986
+ // required rather than optional.
6987
+ outputSchema: {
6988
+ org_id: z7.string().describe("Carrier organisation the wallet belongs to"),
6989
+ balance_eur_cents: z7.number().int().describe("Current balance in EUR cents"),
6990
+ auto_topup_enabled: z7.boolean(),
6991
+ auto_topup_threshold_cents: z7.number().int().describe("Balance at or below which an auto top-up fires, EUR cents"),
6992
+ auto_topup_pack_cents: z7.number().int().describe("Amount an auto top-up charges, EUR cents")
6993
+ },
6892
6994
  annotations: annotationsFor("wallet_balance", "read")
6893
6995
  },
6894
6996
  async () => {
6997
+ const denied = denyUnlessScoped(props2, "wallet_balance", WALLET_TOOL_SCOPES);
6998
+ if (denied) return denied;
6895
6999
  const orgId = defaultOrgId;
6896
7000
  const start = Date.now();
6897
7001
  try {
@@ -6905,15 +7009,13 @@ function registerWalletTools(server2, ctx) {
6905
7009
  sub: props2.sub,
6906
7010
  reseller_id: props2.reseller_id
6907
7011
  });
6908
- return ok(
6909
- JSON.stringify({
6910
- org_id: orgId,
6911
- balance_eur_cents: wallet.balance_eur_cents,
6912
- auto_topup_enabled: wallet.auto_topup_enabled,
6913
- auto_topup_threshold_cents: wallet.auto_topup_threshold_cents,
6914
- auto_topup_pack_cents: wallet.auto_topup_pack_cents
6915
- })
6916
- );
7012
+ return okStructured({
7013
+ org_id: orgId,
7014
+ balance_eur_cents: wallet.balance_eur_cents,
7015
+ auto_topup_enabled: wallet.auto_topup_enabled,
7016
+ auto_topup_threshold_cents: wallet.auto_topup_threshold_cents,
7017
+ auto_topup_pack_cents: wallet.auto_topup_pack_cents
7018
+ });
6917
7019
  } catch (e) {
6918
7020
  if (e instanceof WalletClientError) return err(`Wallet error: ${e.message}`);
6919
7021
  Sentry2.captureException(e);
@@ -6931,23 +7033,48 @@ function registerWalletTools(server2, ctx) {
6931
7033
  "Pack id (pack_500/pack_1000/pack_2500/pack_5000) or exact EUR-cents amount. Omit to list packs."
6932
7034
  )
6933
7035
  },
7036
+ /**
7037
+ * This tool really does return two different shapes, so the schema says
7038
+ * so rather than pretending otherwise. `pack` omitted returns the
7039
+ * catalogue arm (`packs`); `pack` supplied returns the session arm. An
7040
+ * `outputSchema` is an object shape, not a union type, so the two arms
7041
+ * are expressed as mutually exclusive optional groups and the pairing is
7042
+ * documented here. Marking either arm required would break the other.
7043
+ */
7044
+ outputSchema: {
7045
+ packs: z7.array(
7046
+ z7.object({
7047
+ id: z7.string(),
7048
+ eur_cents: z7.number().int(),
7049
+ bonus_pct: z7.number(),
7050
+ bonus_eur_cents: z7.number().int(),
7051
+ credited_eur_cents: z7.number().int()
7052
+ })
7053
+ ).optional().describe("Catalogue arm \u2014 present only when `pack` was omitted."),
7054
+ org_id: z7.string().optional().describe("Session arm."),
7055
+ pack_id: z7.string().optional().describe("Session arm."),
7056
+ pack_eur_cents: z7.number().int().optional().describe("Session arm."),
7057
+ bonus_eur_cents: z7.number().int().optional().describe("Session arm."),
7058
+ checkout_session_id: z7.string().optional().describe("Session arm."),
7059
+ checkout_url: z7.string().nullable().optional().describe("Session arm. Stripe may return null for a session URL.")
7060
+ },
6934
7061
  annotations: annotationsFor("wallet_topup_checkout", "write")
6935
7062
  },
6936
7063
  async ({ pack }) => {
7064
+ const denied = denyUnlessScoped(props2, "wallet_topup_checkout", WALLET_TOOL_SCOPES);
7065
+ if (denied) return denied;
6937
7066
  const orgId = defaultOrgId;
6938
7067
  const start = Date.now();
6939
7068
  if (!pack) {
6940
- return ok(
6941
- JSON.stringify({
6942
- packs: PACKS.map((p) => ({
6943
- id: p.id,
6944
- eur_cents: p.eurCents,
6945
- bonus_pct: p.bonusPct,
6946
- bonus_eur_cents: bonusCentsFor(p),
6947
- credited_eur_cents: p.eurCents + bonusCentsFor(p)
6948
- }))
6949
- })
6950
- );
7069
+ return okStructured({
7070
+ packs: PACKS.map((p) => ({
7071
+ id: p.id,
7072
+ eur_cents: p.eurCents,
7073
+ bonus_pct: p.bonusPct,
7074
+ bonus_eur_cents: bonusCentsFor(p),
7075
+ credited_eur_cents: p.eurCents + bonusCentsFor(p)
7076
+ }))
7077
+ });
6951
7078
  }
6952
7079
  const selected = resolvePack(pack);
6953
7080
  if (!selected) {
@@ -6966,16 +7093,14 @@ function registerWalletTools(server2, ctx) {
6966
7093
  sub: props2.sub,
6967
7094
  reseller_id: props2.reseller_id
6968
7095
  });
6969
- return ok(
6970
- JSON.stringify({
6971
- org_id: orgId,
6972
- pack_id: selected.id,
6973
- pack_eur_cents: selected.eurCents,
6974
- bonus_eur_cents: bonusCentsFor(selected),
6975
- checkout_session_id: session.id,
6976
- checkout_url: session.url
6977
- })
6978
- );
7096
+ return okStructured({
7097
+ org_id: orgId,
7098
+ pack_id: selected.id,
7099
+ pack_eur_cents: selected.eurCents,
7100
+ bonus_eur_cents: bonusCentsFor(selected),
7101
+ checkout_session_id: session.id,
7102
+ checkout_url: session.url
7103
+ });
6979
7104
  } catch (e) {
6980
7105
  Sentry2.captureException(e);
6981
7106
  return err(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -6990,9 +7115,31 @@ function registerWalletTools(server2, ctx) {
6990
7115
  inputSchema: {
6991
7116
  pack_cents: z7.number().int().positive().optional().describe("Override pack amount in EUR cents (must match a catalog pack).")
6992
7117
  },
7118
+ /**
7119
+ * Only the charged arm is described here — `charged: false` returns
7120
+ * through `err()`, and the SDK skips output validation on an `isError`
7121
+ * result.
7122
+ *
7123
+ * `balance_after_cents` is optional and the description above is wrong to
7124
+ * promise it. runAutoTopup has a second success arm (wallet-tools.ts:337)
7125
+ * where the card was charged but crediting Atlas failed: it carries
7126
+ * `paymentIntentId` and a `reason`, and no balance. Requiring the field
7127
+ * would turn that arm — a real charge the caller must be told about —
7128
+ * into a validation error.
7129
+ */
7130
+ outputSchema: {
7131
+ org_id: z7.string(),
7132
+ charged: z7.literal(true),
7133
+ credited_eur_cents: z7.number().int(),
7134
+ balance_after_cents: z7.number().int().optional().describe("Absent when the charge succeeded but crediting is pending."),
7135
+ payment_intent_id: z7.string().optional(),
7136
+ note: z7.string().optional().describe("Present when the charge landed but the credit is still pending.")
7137
+ },
6993
7138
  annotations: annotationsFor("wallet_auto_topup", "write")
6994
7139
  },
6995
7140
  async ({ pack_cents }) => {
7141
+ const denied = denyUnlessScoped(props2, "wallet_auto_topup", WALLET_TOOL_SCOPES);
7142
+ if (denied) return denied;
6996
7143
  const orgId = defaultOrgId;
6997
7144
  const start = Date.now();
6998
7145
  try {
@@ -7011,16 +7158,18 @@ function registerWalletTools(server2, ctx) {
7011
7158
  JSON.stringify({ org_id: orgId, charged: false, reason: result2.reason })
7012
7159
  );
7013
7160
  }
7014
- return ok(
7015
- JSON.stringify({
7016
- org_id: orgId,
7017
- charged: true,
7018
- credited_eur_cents: result2.creditedCents,
7019
- balance_after_cents: result2.balanceAfterCents,
7020
- payment_intent_id: result2.paymentIntentId,
7021
- ...result2.reason ? { note: result2.reason } : {}
7022
- })
7023
- );
7161
+ return okStructured({
7162
+ org_id: orgId,
7163
+ charged: true,
7164
+ credited_eur_cents: result2.creditedCents,
7165
+ // Spread rather than assign, so the key is absent instead of set to
7166
+ // `undefined`. JSON.stringify drops an undefined value anyway, so
7167
+ // assigning it would make `content` and `structuredContent` disagree
7168
+ // about whether the field is there.
7169
+ ...result2.balanceAfterCents !== void 0 ? { balance_after_cents: result2.balanceAfterCents } : {},
7170
+ ...result2.paymentIntentId !== void 0 ? { payment_intent_id: result2.paymentIntentId } : {},
7171
+ ...result2.reason ? { note: result2.reason } : {}
7172
+ });
7024
7173
  } catch (e) {
7025
7174
  Sentry2.captureException(e);
7026
7175
  return err(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7046,25 +7195,30 @@ async function issueConfirmToken(env, sub, toolName) {
7046
7195
  await env.OAUTH_KV.put(key, token2, { expirationTtl: 300 });
7047
7196
  return token2;
7048
7197
  }
7049
- function ok2(text) {
7198
+ function ok(text) {
7050
7199
  return { content: [{ type: "text", text }] };
7051
7200
  }
7052
7201
  function err2(text) {
7053
7202
  return { isError: true, content: [{ type: "text", text }] };
7054
7203
  }
7055
7204
  async function stripeGet(stripeKey, path, connectedAccountId) {
7056
- const headers = { Authorization: `Bearer ${stripeKey}` };
7205
+ const headers = {
7206
+ Authorization: `Bearer ${stripeKey}`,
7207
+ "Stripe-Version": "2025-08-27.basil"
7208
+ };
7057
7209
  if (connectedAccountId) headers["Stripe-Account"] = connectedAccountId;
7058
7210
  const res = await fetch(`https://api.stripe.com${path}`, { headers });
7059
7211
  const data = await res.json();
7060
7212
  return { ok: res.ok, status: res.status, data };
7061
7213
  }
7062
- async function stripePost(stripeKey, path, params, connectedAccountId) {
7214
+ async function stripePost(stripeKey, path, params, connectedAccountId, idempotencyKey) {
7063
7215
  const headers = {
7064
7216
  Authorization: `Bearer ${stripeKey}`,
7217
+ "Stripe-Version": "2025-08-27.basil",
7065
7218
  "Content-Type": "application/x-www-form-urlencoded"
7066
7219
  };
7067
7220
  if (connectedAccountId) headers["Stripe-Account"] = connectedAccountId;
7221
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
7068
7222
  const res = await fetch(`https://api.stripe.com${path}`, {
7069
7223
  method: "POST",
7070
7224
  headers,
@@ -7073,6 +7227,9 @@ async function stripePost(stripeKey, path, params, connectedAccountId) {
7073
7227
  const data = await res.json();
7074
7228
  return { ok: res.ok, status: res.status, data };
7075
7229
  }
7230
+ function refundIdempotencyKey(sub, charge, amount, reason) {
7231
+ return `carrier-refund:${sub}:${charge}:${amount ?? "full"}:${reason ?? "none"}`;
7232
+ }
7076
7233
  var STRIPE_CONNECT_TOOL_SCOPES = {
7077
7234
  stripe_connect_status: "read",
7078
7235
  stripe_connect_payouts: "read",
@@ -7103,6 +7260,8 @@ function registerStripeConnectTools(server2, ctx) {
7103
7260
  annotations: annotationsFor("stripe_connect_status", "read")
7104
7261
  },
7105
7262
  async ({ operator_id }) => {
7263
+ const denied = denyUnlessScoped(props2, "stripe_connect_status", STRIPE_CONNECT_TOOL_SCOPES);
7264
+ if (denied) return denied;
7106
7265
  if (operator_id !== void 0 && operator_id !== operatorId) {
7107
7266
  if (!isPlatformOperator(env, props2.org_id)) {
7108
7267
  return err2(
@@ -7118,7 +7277,7 @@ function registerStripeConnectTools(server2, ctx) {
7118
7277
  if (!stripeKey) return err2("Stripe not configured");
7119
7278
  const accountId = await env.CARRIER_USERS.get(opKvKey);
7120
7279
  if (!accountId) {
7121
- return ok2(JSON.stringify({ status: "not_connected", operator_id: opId }));
7280
+ return ok(JSON.stringify({ status: "not_connected", operator_id: opId }));
7122
7281
  }
7123
7282
  const result2 = await stripeGet(stripeKey, `/v1/accounts/${accountId}`);
7124
7283
  if (!result2.ok) return err2(`Stripe error: ${JSON.stringify(result2.data)}`);
@@ -7133,7 +7292,7 @@ function registerStripeConnectTools(server2, ctx) {
7133
7292
  sub: props2.sub,
7134
7293
  reseller_id: props2.reseller_id
7135
7294
  });
7136
- return ok2(JSON.stringify({
7295
+ return ok(JSON.stringify({
7137
7296
  status,
7138
7297
  operator_id: opId,
7139
7298
  account_id: accountId,
@@ -7163,6 +7322,8 @@ function registerStripeConnectTools(server2, ctx) {
7163
7322
  annotations: annotationsFor("stripe_connect_payouts", "read")
7164
7323
  },
7165
7324
  async ({ limit, status }) => {
7325
+ const denied = denyUnlessScoped(props2, "stripe_connect_payouts", STRIPE_CONNECT_TOOL_SCOPES);
7326
+ if (denied) return denied;
7166
7327
  const start = Date.now();
7167
7328
  try {
7168
7329
  const stripeKey = env.STRIPE_SECRET_KEY;
@@ -7182,7 +7343,7 @@ function registerStripeConnectTools(server2, ctx) {
7182
7343
  sub: props2.sub,
7183
7344
  reseller_id: props2.reseller_id
7184
7345
  });
7185
- return ok2(JSON.stringify({ payouts: result2.data.data, has_more: result2.data.has_more }));
7346
+ return ok(JSON.stringify({ payouts: result2.data.data, has_more: result2.data.has_more }));
7186
7347
  } catch (e) {
7187
7348
  Sentry3.captureException(e);
7188
7349
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7198,6 +7359,8 @@ function registerStripeConnectTools(server2, ctx) {
7198
7359
  annotations: annotationsFor("stripe_connect_balance", "read")
7199
7360
  },
7200
7361
  async () => {
7362
+ const denied = denyUnlessScoped(props2, "stripe_connect_balance", STRIPE_CONNECT_TOOL_SCOPES);
7363
+ if (denied) return denied;
7201
7364
  const start = Date.now();
7202
7365
  try {
7203
7366
  const stripeKey = env.STRIPE_SECRET_KEY;
@@ -7215,7 +7378,7 @@ function registerStripeConnectTools(server2, ctx) {
7215
7378
  sub: props2.sub,
7216
7379
  reseller_id: props2.reseller_id
7217
7380
  });
7218
- return ok2(JSON.stringify({
7381
+ return ok(JSON.stringify({
7219
7382
  account_id: accountId,
7220
7383
  available: result2.data.available ?? [],
7221
7384
  pending: result2.data.pending ?? []
@@ -7240,11 +7403,13 @@ function registerStripeConnectTools(server2, ctx) {
7240
7403
  annotations: annotationsFor("stripe_connect_refund", "write")
7241
7404
  },
7242
7405
  async ({ charge_id, amount_cents, reason, confirm_token }) => {
7406
+ const denied = denyUnlessScoped(props2, "stripe_connect_refund", STRIPE_CONNECT_TOOL_SCOPES);
7407
+ if (denied) return denied;
7243
7408
  const start = Date.now();
7244
7409
  const toolName = "stripe_connect_refund";
7245
7410
  if (!confirm_token) {
7246
7411
  const token2 = await issueConfirmToken(env, props2.sub, toolName);
7247
- return ok2(
7412
+ return ok(
7248
7413
  `HARD_BLOCK: Refund ${amount_cents ? `${amount_cents} cents on` : "(full) on"} charge ${charge_id} requires confirmation.
7249
7414
  confirm_token: ${token2}
7250
7415
  Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes.`
@@ -7262,7 +7427,13 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7262
7427
  const params = { charge: charge_id };
7263
7428
  if (amount_cents) params.amount = String(amount_cents);
7264
7429
  if (reason) params.reason = reason;
7265
- const result2 = await stripePost(stripeKey, "/v1/refunds", params, accountId);
7430
+ const result2 = await stripePost(
7431
+ stripeKey,
7432
+ "/v1/refunds",
7433
+ params,
7434
+ accountId,
7435
+ refundIdempotencyKey(props2.sub, charge_id, amount_cents, reason)
7436
+ );
7266
7437
  if (!result2.ok) return err2(`Stripe error: ${JSON.stringify(result2.data)}`);
7267
7438
  writeAudit(env, {
7268
7439
  tool_name: toolName,
@@ -7273,7 +7444,7 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7273
7444
  sub: props2.sub,
7274
7445
  reseller_id: props2.reseller_id
7275
7446
  });
7276
- return ok2(`Refund issued: ${result2.data.id} \u2014 status: ${result2.data.status}`);
7447
+ return ok(`Refund issued: ${result2.data.id} \u2014 status: ${result2.data.status}`);
7277
7448
  } catch (e) {
7278
7449
  Sentry3.captureException(e);
7279
7450
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7292,6 +7463,8 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7292
7463
  annotations: annotationsFor("stripe_connect_dispute_list", "read")
7293
7464
  },
7294
7465
  async ({ limit, status }) => {
7466
+ const denied = denyUnlessScoped(props2, "stripe_connect_dispute_list", STRIPE_CONNECT_TOOL_SCOPES);
7467
+ if (denied) return denied;
7295
7468
  const start = Date.now();
7296
7469
  try {
7297
7470
  const stripeKey = env.STRIPE_SECRET_KEY;
@@ -7311,7 +7484,7 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7311
7484
  sub: props2.sub,
7312
7485
  reseller_id: props2.reseller_id
7313
7486
  });
7314
- return ok2(JSON.stringify({ disputes: result2.data.data, has_more: result2.data.has_more }));
7487
+ return ok(JSON.stringify({ disputes: result2.data.data, has_more: result2.data.has_more }));
7315
7488
  } catch (e) {
7316
7489
  Sentry3.captureException(e);
7317
7490
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7330,6 +7503,13 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7330
7503
  annotations: annotationsFor("radar_review_list", "read")
7331
7504
  },
7332
7505
  async ({ open_only, limit }) => {
7506
+ const denied = denyUnlessScoped(props2, "radar_review_list", STRIPE_CONNECT_TOOL_SCOPES);
7507
+ if (denied) return denied;
7508
+ if (!isPlatformOperator(env, props2.org_id)) {
7509
+ return err2(
7510
+ "This tool acts on Carrier's platform Stripe account and is restricted to Carrier's own organization."
7511
+ );
7512
+ }
7333
7513
  const start = Date.now();
7334
7514
  try {
7335
7515
  const stripeKey = env.STRIPE_SECRET_KEY;
@@ -7347,7 +7527,7 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7347
7527
  sub: props2.sub,
7348
7528
  reseller_id: props2.reseller_id
7349
7529
  });
7350
- return ok2(JSON.stringify({ reviews: result2.data.data, has_more: result2.data.has_more }));
7530
+ return ok(JSON.stringify({ reviews: result2.data.data, has_more: result2.data.has_more }));
7351
7531
  } catch (e) {
7352
7532
  Sentry3.captureException(e);
7353
7533
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7366,11 +7546,18 @@ Call again with confirm_token="${token2}" to execute. Token expires in 5 minutes
7366
7546
  annotations: annotationsFor("radar_review_approve", "admin")
7367
7547
  },
7368
7548
  async ({ review_id, confirm_token }) => {
7549
+ const denied = denyUnlessScoped(props2, "radar_review_approve", STRIPE_CONNECT_TOOL_SCOPES);
7550
+ if (denied) return denied;
7551
+ if (!isPlatformOperator(env, props2.org_id)) {
7552
+ return err2(
7553
+ "This tool acts on Carrier's platform Stripe account and is restricted to Carrier's own organization."
7554
+ );
7555
+ }
7369
7556
  const toolName = "radar_review_approve";
7370
7557
  const start = Date.now();
7371
7558
  if (!confirm_token) {
7372
7559
  const token2 = await issueConfirmToken(env, props2.sub, toolName);
7373
- return ok2(
7560
+ return ok(
7374
7561
  `HARD_BLOCK: Approving review ${review_id} allows the charge to proceed.
7375
7562
  confirm_token: ${token2}
7376
7563
  Call again with confirm_token="${token2}" to execute. Expires in 5 minutes.`
@@ -7396,7 +7583,7 @@ Call again with confirm_token="${token2}" to execute. Expires in 5 minutes.`
7396
7583
  sub: props2.sub,
7397
7584
  reseller_id: props2.reseller_id
7398
7585
  });
7399
- return ok2(`Review ${review_id} approved. Charge will proceed.`);
7586
+ return ok(`Review ${review_id} approved. Charge will proceed.`);
7400
7587
  } catch (e) {
7401
7588
  Sentry3.captureException(e);
7402
7589
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7415,11 +7602,18 @@ Call again with confirm_token="${token2}" to execute. Expires in 5 minutes.`
7415
7602
  annotations: annotationsFor("radar_review_decline", "admin")
7416
7603
  },
7417
7604
  async ({ review_id, confirm_token }) => {
7605
+ const denied = denyUnlessScoped(props2, "radar_review_decline", STRIPE_CONNECT_TOOL_SCOPES);
7606
+ if (denied) return denied;
7607
+ if (!isPlatformOperator(env, props2.org_id)) {
7608
+ return err2(
7609
+ "This tool acts on Carrier's platform Stripe account and is restricted to Carrier's own organization."
7610
+ );
7611
+ }
7418
7612
  const toolName = "radar_review_decline";
7419
7613
  const start = Date.now();
7420
7614
  if (!confirm_token) {
7421
7615
  const token2 = await issueConfirmToken(env, props2.sub, toolName);
7422
- return ok2(
7616
+ return ok(
7423
7617
  `HARD_BLOCK: Declining review ${review_id} will close/block the charge.
7424
7618
  confirm_token: ${token2}
7425
7619
  Expires in 5 minutes.`
@@ -7445,7 +7639,7 @@ Expires in 5 minutes.`
7445
7639
  sub: props2.sub,
7446
7640
  reseller_id: props2.reseller_id
7447
7641
  });
7448
- return ok2(`Review ${review_id} declined.`);
7642
+ return ok(`Review ${review_id} declined.`);
7449
7643
  } catch (e) {
7450
7644
  Sentry3.captureException(e);
7451
7645
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7465,11 +7659,18 @@ Expires in 5 minutes.`
7465
7659
  annotations: annotationsFor("radar_value_list_add", "admin")
7466
7660
  },
7467
7661
  async ({ value_list_id, value, confirm_token }) => {
7662
+ const denied = denyUnlessScoped(props2, "radar_value_list_add", STRIPE_CONNECT_TOOL_SCOPES);
7663
+ if (denied) return denied;
7664
+ if (!isPlatformOperator(env, props2.org_id)) {
7665
+ return err2(
7666
+ "This tool acts on Carrier's platform Stripe account and is restricted to Carrier's own organization."
7667
+ );
7668
+ }
7468
7669
  const toolName = "radar_value_list_add";
7469
7670
  const start = Date.now();
7470
7671
  if (!confirm_token) {
7471
7672
  const token2 = await issueConfirmToken(env, props2.sub, toolName);
7472
- return ok2(
7673
+ return ok(
7473
7674
  `HARD_BLOCK: Adding "${value}" to list ${value_list_id} will affect future charge decisions.
7474
7675
  confirm_token: ${token2}
7475
7676
  Expires in 5 minutes.`
@@ -7495,7 +7696,7 @@ Expires in 5 minutes.`
7495
7696
  sub: props2.sub,
7496
7697
  reseller_id: props2.reseller_id
7497
7698
  });
7498
- return ok2(`Added "${value}" to Radar list ${value_list_id}.`);
7699
+ return ok(`Added "${value}" to Radar list ${value_list_id}.`);
7499
7700
  } catch (e) {
7500
7701
  Sentry3.captureException(e);
7501
7702
  return err2(`Error: ${e instanceof Error ? e.message : "unknown"}`);
@@ -7514,7 +7715,14 @@ Expires in 5 minutes.`
7514
7715
  annotations: annotationsFor("radar_rule_toggle", "admin")
7515
7716
  },
7516
7717
  async ({ rule_id, enabled }) => {
7517
- return ok2(
7718
+ const denied = denyUnlessScoped(props2, "radar_rule_toggle", STRIPE_CONNECT_TOOL_SCOPES);
7719
+ if (denied) return denied;
7720
+ if (!isPlatformOperator(env, props2.org_id)) {
7721
+ return err2(
7722
+ "This tool acts on Carrier's platform Stripe account and is restricted to Carrier's own organization."
7723
+ );
7724
+ }
7725
+ return ok(
7518
7726
  `Stripe Radar does not expose rule enable/disable via the public API.
7519
7727
  To ${enabled ? "enable" : "disable"} rule ${rule_id}:
7520
7728
  1. Open https://dashboard.stripe.com/radar/rules
@@ -7531,9 +7739,9 @@ import { z as z9 } from "zod";
7531
7739
  var STOREFRONT_LOGO_TOOL_SCOPES = {
7532
7740
  generate_storefront_logo: "read"
7533
7741
  };
7534
- var ok3 = (text) => ({ content: [{ type: "text", text }] });
7742
+ var ok2 = (text) => ({ content: [{ type: "text", text }] });
7535
7743
  var err3 = (text) => ({ isError: true, content: [{ type: "text", text }] });
7536
- function registerStorefrontLogoTools(server2, env = {}) {
7744
+ function registerStorefrontLogoTools(server2, env = {}, props2) {
7537
7745
  server2.registerTool(
7538
7746
  "generate_storefront_logo",
7539
7747
  {
@@ -7547,6 +7755,8 @@ function registerStorefrontLogoTools(server2, env = {}) {
7547
7755
  annotations: annotationsFor("generate_storefront_logo", "read")
7548
7756
  },
7549
7757
  async ({ name, accent, tagline }) => {
7758
+ const denied = denyUnlessScoped(props2, "generate_storefront_logo", STOREFRONT_LOGO_TOOL_SCOPES);
7759
+ if (denied) return denied;
7550
7760
  try {
7551
7761
  const hex = accent ? accent.startsWith("#") ? accent : `#${accent}` : void 0;
7552
7762
  const logo = await generateStorefrontLogo({
@@ -7555,7 +7765,7 @@ function registerStorefrontLogoTools(server2, env = {}) {
7555
7765
  tagline,
7556
7766
  env: { ...env, ...process.env }
7557
7767
  });
7558
- return ok3(
7768
+ return ok2(
7559
7769
  JSON.stringify({
7560
7770
  brand: name,
7561
7771
  source: logo.source,
@@ -7580,11 +7790,11 @@ var STOREFRONT_DEPLOY_TOOL_SCOPES = {
7580
7790
  deploy_storefront: "write",
7581
7791
  provision_storefront_clerk: "write"
7582
7792
  };
7583
- var ok4 = (value) => ({
7793
+ var ok3 = (value) => ({
7584
7794
  content: [{ type: "text", text: JSON.stringify(value) }]
7585
7795
  });
7586
7796
  var err4 = (text) => ({ isError: true, content: [{ type: "text", text }] });
7587
- function registerStorefrontDeployTools(server2) {
7797
+ function registerStorefrontDeployTools(server2, props2) {
7588
7798
  server2.registerTool(
7589
7799
  "list_deploy_targets",
7590
7800
  {
@@ -7596,10 +7806,12 @@ function registerStorefrontDeployTools(server2) {
7596
7806
  annotations: annotationsFor("list_deploy_targets", "read")
7597
7807
  },
7598
7808
  async ({ dir }) => {
7809
+ const denied = denyUnlessScoped(props2, "list_deploy_targets", STOREFRONT_DEPLOY_TOOL_SCOPES);
7810
+ if (denied) return denied;
7599
7811
  try {
7600
7812
  const statuses = await probeAll(dir);
7601
7813
  const ranked = rankTargets(statuses);
7602
- return ok4({
7814
+ return ok3({
7603
7815
  targets: statuses,
7604
7816
  default: ranked[0]?.id ?? null,
7605
7817
  reason: ranked.length ? void 0 : "No host is installed and logged in."
@@ -7626,6 +7838,8 @@ function registerStorefrontDeployTools(server2) {
7626
7838
  annotations: annotationsFor("deploy_storefront", "write")
7627
7839
  },
7628
7840
  async ({ dir, target, name, skip_build, push_secrets, verify, domain }) => {
7841
+ const denied = denyUnlessScoped(props2, "deploy_storefront", STOREFRONT_DEPLOY_TOOL_SCOPES);
7842
+ if (denied) return denied;
7629
7843
  try {
7630
7844
  if (target && !isTargetId(target)) return err4(`Unknown target "${target}".`);
7631
7845
  const brand = await loadStorefrontBrand(dir, name ? { name } : void 0);
@@ -7645,7 +7859,7 @@ function registerStorefrontDeployTools(server2) {
7645
7859
  return err4(result2.reason ?? "Deploy failed.");
7646
7860
  }
7647
7861
  const verification = verify !== false && result2.url ? await verifyStorefront(result2.url, dir, process.env) : void 0;
7648
- return ok4({
7862
+ return ok3({
7649
7863
  deployed: true,
7650
7864
  target: result2.target,
7651
7865
  project: result2.projectName,
@@ -7678,6 +7892,12 @@ function registerStorefrontDeployTools(server2) {
7678
7892
  annotations: annotationsFor("provision_storefront_clerk", "write")
7679
7893
  },
7680
7894
  async ({ dir, name, production, no_create, url }) => {
7895
+ const denied = denyUnlessScoped(
7896
+ props2,
7897
+ "provision_storefront_clerk",
7898
+ STOREFRONT_DEPLOY_TOOL_SCOPES
7899
+ );
7900
+ if (denied) return denied;
7681
7901
  try {
7682
7902
  const brand = await loadStorefrontBrand(dir, name ? { name } : void 0);
7683
7903
  const result2 = await provisionClerk({
@@ -7690,7 +7910,7 @@ function registerStorefrontDeployTools(server2) {
7690
7910
  cli: clerkCliDeps()
7691
7911
  });
7692
7912
  if (!result2.ok || !result2.credentials) {
7693
- return ok4({
7913
+ return ok3({
7694
7914
  provisioned: false,
7695
7915
  tier: result2.tier,
7696
7916
  reason: result2.reason,
@@ -7706,7 +7926,7 @@ function registerStorefrontDeployTools(server2) {
7706
7926
  allowedOrigins,
7707
7927
  redirectUrls
7708
7928
  });
7709
- return ok4({
7929
+ return ok3({
7710
7930
  provisioned: true,
7711
7931
  tier: result2.tier,
7712
7932
  application_id: result2.credentials.applicationId ?? null,
@@ -10014,7 +10234,82 @@ function buildExamples(toolName) {
10014
10234
  // src/list-recent-ocs-events.ts
10015
10235
  import { z as z12 } from "zod";
10016
10236
 
10237
+ // src/ocs-scoping.ts
10238
+ function managedOwnAccountId(auth) {
10239
+ if (!auth.managed || auth.accountId === void 0) return void 0;
10240
+ const n = Number(auth.accountId);
10241
+ return Number.isFinite(n) ? n : void 0;
10242
+ }
10243
+ function subscriberAccountId(data) {
10244
+ if (!data || typeof data !== "object") return void 0;
10245
+ const root = data;
10246
+ const sub = root.subscriber && typeof root.subscriber === "object" ? root.subscriber : root;
10247
+ const raw = sub.accountId ?? sub.account_id ?? sub.account;
10248
+ if (raw === void 0 || raw === null) return void 0;
10249
+ const n = Number(raw);
10250
+ return Number.isFinite(n) ? n : void 0;
10251
+ }
10252
+ function isManagedSubscriberDenied(auth, subscriberData) {
10253
+ const ownId = managedOwnAccountId(auth);
10254
+ if (ownId === void 0) return false;
10255
+ const subAccountId = subscriberAccountId(subscriberData);
10256
+ if (subAccountId === void 0) return true;
10257
+ return subAccountId !== ownId;
10258
+ }
10259
+ async function assertManagedIccidAccess(baseUrl2, auth, iccid) {
10260
+ if (managedOwnAccountId(auth) === void 0) return { ok: true };
10261
+ try {
10262
+ const client2 = new OcsClient(baseUrl2, auth.token);
10263
+ const data = await client2.call("getSingleSubscriber", { iccid });
10264
+ if (isManagedSubscriberDenied(auth, data)) {
10265
+ return { ok: false, error: "Subscriber not found" };
10266
+ }
10267
+ return { ok: true };
10268
+ } catch (err7) {
10269
+ return {
10270
+ ok: false,
10271
+ error: err7 instanceof Error ? err7.message : String(err7)
10272
+ };
10273
+ }
10274
+ }
10275
+
10017
10276
  // ../../packages/ocs-spec/src/ocs-event-buffer-read.ts
10277
+ function ocsEventRingBufferIccidFromDigitsReference(raw) {
10278
+ const digits = raw.replace(/\D/g, "");
10279
+ if (digits.length === 0) {
10280
+ throw new Error("reference must contain at least one digit");
10281
+ }
10282
+ if (digits.length > 20) {
10283
+ return digits.slice(-20);
10284
+ }
10285
+ if (digits.length >= 19) {
10286
+ return digits;
10287
+ }
10288
+ return digits.padStart(19, "0");
10289
+ }
10290
+ function msisdnFieldFromSubscriberPayload(data) {
10291
+ if (!data || typeof data !== "object") return null;
10292
+ const r = data;
10293
+ const v = r["msisdn"] ?? r["MSISDN"];
10294
+ if (v === void 0 || v === null) return null;
10295
+ if (typeof v === "string") return /\d/.test(v) ? v : null;
10296
+ if (typeof v === "number") return String(v);
10297
+ return null;
10298
+ }
10299
+ function mergeOcsEventOutputsNewestFirst(parts, limit) {
10300
+ const seen = /* @__PURE__ */ new Set();
10301
+ const out = [];
10302
+ const combined = [...parts].flat().sort(
10303
+ (x, y) => x.timestamp < y.timestamp ? 1 : x.timestamp > y.timestamp ? -1 : x.event_id.localeCompare(y.event_id)
10304
+ );
10305
+ for (const e of combined) {
10306
+ if (seen.has(e.event_id)) continue;
10307
+ seen.add(e.event_id);
10308
+ out.push(e);
10309
+ if (out.length >= limit) break;
10310
+ }
10311
+ return out;
10312
+ }
10018
10313
  async function listRecentOcsEvents(iccid, limit, eventTypes, since, kv) {
10019
10314
  const routingRaw = await kv.get(`iccid:${iccid}`, "json");
10020
10315
  const resellerId = routingRaw?.reseller_id ?? 0;
@@ -10097,7 +10392,8 @@ var listRecentOcsEventsSchema = {
10097
10392
  async function listRecentOcsEvents2(iccid, limit, eventTypes, since, kv) {
10098
10393
  return listRecentOcsEvents(iccid, limit, eventTypes, since, kv);
10099
10394
  }
10100
- function registerListRecentOcsEventsTool(server2, env) {
10395
+ function registerListRecentOcsEventsTool(server2, ctx) {
10396
+ const env = ctx.env;
10101
10397
  server2.registerTool(
10102
10398
  "list_recent_ocs_events",
10103
10399
  {
@@ -10113,6 +10409,19 @@ function registerListRecentOcsEventsTool(server2, env) {
10113
10409
  async (args) => {
10114
10410
  const { iccid, limit, event_types, since } = args;
10115
10411
  try {
10412
+ if (managedOwnAccountId(ctx.managedAuth) !== void 0) {
10413
+ const access = await assertManagedIccidAccess(
10414
+ env.CARRIER_OCS_BASE_URL,
10415
+ { token: await ctx.getUserToken(ctx.props.sub), ...ctx.managedAuth },
10416
+ iccid
10417
+ );
10418
+ if (!access.ok) {
10419
+ return {
10420
+ isError: true,
10421
+ content: [{ type: "text", text: access.error }]
10422
+ };
10423
+ }
10424
+ }
10116
10425
  const result2 = await listRecentOcsEvents2(
10117
10426
  iccid,
10118
10427
  limit,
@@ -10224,12 +10533,26 @@ function registerRateLimitStatusTool(server2, ctx) {
10224
10533
 
10225
10534
  // src/tools-depletion-events.ts
10226
10535
  import { z as z14 } from "zod";
10227
- function ok5(payload) {
10536
+ function ok4(payload) {
10228
10537
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
10229
10538
  }
10230
10539
  function err5(message) {
10231
10540
  return { isError: true, content: [{ type: "text", text: message }] };
10232
10541
  }
10542
+ async function ownershipToken(ctx) {
10543
+ if (managedOwnAccountId(ctx.managedAuth) === void 0) return null;
10544
+ return ctx.getUserToken(ctx.props.sub);
10545
+ }
10546
+ async function ownsEvent(ctx, token2, iccid) {
10547
+ if (token2 === null) return true;
10548
+ if (!iccid) return false;
10549
+ const access = await assertManagedIccidAccess(
10550
+ ctx.env.CARRIER_OCS_BASE_URL,
10551
+ { token: token2, ...ctx.managedAuth },
10552
+ iccid
10553
+ );
10554
+ return access.ok;
10555
+ }
10233
10556
  function registerDepletionEventsTool(server2, ctx) {
10234
10557
  server2.registerTool(
10235
10558
  "subscriber_depletion_events",
@@ -10259,10 +10582,18 @@ function registerDepletionEventsTool(server2, ctx) {
10259
10582
  );
10260
10583
  }
10261
10584
  const { subscriberId, since } = args;
10585
+ let scopeToken;
10586
+ try {
10587
+ scopeToken = await ownershipToken(ctx);
10588
+ } catch (e) {
10589
+ return err5(
10590
+ `Could not resolve the OCS credential needed to scope this read: ${e instanceof Error ? e.message : String(e)}`
10591
+ );
10592
+ }
10262
10593
  if (subscriberId) {
10263
10594
  const raw = await kv.get(`bundle-depleted:${subscriberId}`, "text");
10264
10595
  if (!raw) {
10265
- return ok5({
10596
+ return ok4({
10266
10597
  subscriber_id: subscriberId,
10267
10598
  depleted: false,
10268
10599
  event: null,
@@ -10273,22 +10604,30 @@ function registerDepletionEventsTool(server2, ctx) {
10273
10604
  try {
10274
10605
  event = JSON.parse(raw);
10275
10606
  } catch {
10276
- return ok5({
10607
+ return ok4({
10277
10608
  subscriber_id: subscriberId,
10278
10609
  depleted: false,
10279
10610
  event: null,
10280
10611
  note: "Stored depletion entry was invalid JSON and was ignored."
10281
10612
  });
10282
10613
  }
10614
+ if (!await ownsEvent(ctx, scopeToken, event.iccid)) {
10615
+ return ok4({
10616
+ subscriber_id: subscriberId,
10617
+ depleted: false,
10618
+ event: null,
10619
+ note: "No bundle depletion event found in the last 7 days."
10620
+ });
10621
+ }
10283
10622
  if (since && event.depletedAt < since) {
10284
- return ok5({
10623
+ return ok4({
10285
10624
  subscriber_id: subscriberId,
10286
10625
  depleted: false,
10287
10626
  event: null,
10288
10627
  note: `No bundle depletion after ${since}.`
10289
10628
  });
10290
10629
  }
10291
- return ok5({
10630
+ return ok4({
10292
10631
  subscriber_id: subscriberId,
10293
10632
  depleted: true,
10294
10633
  event: {
@@ -10302,7 +10641,7 @@ function registerDepletionEventsTool(server2, ctx) {
10302
10641
  }
10303
10642
  const listResult = await kv.list({ prefix: "bundle-depleted:", limit: 20 });
10304
10643
  if (listResult.keys.length === 0) {
10305
- return ok5({
10644
+ return ok4({
10306
10645
  depletions: [],
10307
10646
  total: 0,
10308
10647
  note: "No bundle depletion events in the last 7 days."
@@ -10316,6 +10655,7 @@ function registerDepletionEventsTool(server2, ctx) {
10316
10655
  try {
10317
10656
  const ev = JSON.parse(raw);
10318
10657
  if (since && ev.depletedAt < since) return;
10658
+ if (!await ownsEvent(ctx, scopeToken, ev.iccid)) return;
10319
10659
  events.push({
10320
10660
  subscriber_id: ev.subscriberId,
10321
10661
  iccid: ev.iccid,
@@ -10328,7 +10668,7 @@ function registerDepletionEventsTool(server2, ctx) {
10328
10668
  })
10329
10669
  );
10330
10670
  events.sort((a, b) => b.depleted_at.localeCompare(a.depleted_at));
10331
- return ok5({
10671
+ return ok4({
10332
10672
  depletions: events,
10333
10673
  total: events.length,
10334
10674
  list_truncated: !listResult.list_complete
@@ -10353,19 +10693,46 @@ function registerCountryHistoryTool(server2, ctx) {
10353
10693
  },
10354
10694
  wrapHandler(
10355
10695
  "subscriber_country_history",
10356
- "[country-history:kv]",
10696
+ "getSingleSubscriber",
10357
10697
  "read",
10358
10698
  ctx,
10359
- async ({ subscriberId, limit }) => {
10699
+ async ({ subscriberId, limit }, token2) => {
10360
10700
  const effectiveLimit = limit ?? 20;
10361
- const result2 = await listRecentOcsEvents2(
10701
+ const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
10702
+ const subscriberPayload = await client2.call("getSingleSubscriber", {
10703
+ iccid: subscriberId
10704
+ });
10705
+ let syntheticRingIccid = null;
10706
+ const msisdnRaw = msisdnFieldFromSubscriberPayload(subscriberPayload);
10707
+ if (msisdnRaw) {
10708
+ try {
10709
+ const k = ocsEventRingBufferIccidFromDigitsReference(msisdnRaw);
10710
+ if (k !== subscriberId) syntheticRingIccid = k;
10711
+ } catch {
10712
+ syntheticRingIccid = null;
10713
+ }
10714
+ }
10715
+ const primary = await listRecentOcsEvents(
10362
10716
  subscriberId,
10363
10717
  effectiveLimit,
10364
10718
  ["country.entered"],
10365
10719
  void 0,
10366
10720
  ctx.env.OCS_EVENT_ROUTING
10367
10721
  );
10368
- const countryEvents = result2.events;
10722
+ let countryEvents = primary.events;
10723
+ if (syntheticRingIccid) {
10724
+ const secondary = await listRecentOcsEvents(
10725
+ syntheticRingIccid,
10726
+ effectiveLimit,
10727
+ ["country.entered"],
10728
+ void 0,
10729
+ ctx.env.OCS_EVENT_ROUTING
10730
+ );
10731
+ countryEvents = mergeOcsEventOutputsNewestFirst(
10732
+ [primary.events, secondary.events],
10733
+ effectiveLimit
10734
+ );
10735
+ }
10369
10736
  return {
10370
10737
  content: [
10371
10738
  {
@@ -10383,7 +10750,7 @@ function registerCountryHistoryTool(server2, ctx) {
10383
10750
  source: e.data["source"] ?? "relay-lu"
10384
10751
  })),
10385
10752
  total_country_events: countryEvents.length,
10386
- total_in_buffer: result2.total_in_buffer,
10753
+ total_in_buffer: primary.total_in_buffer,
10387
10754
  note: countryEvents.length === 0 ? "No cross-border events recorded in the 24h ring-buffer for this subscriber." : void 0
10388
10755
  },
10389
10756
  null,
@@ -11677,7 +12044,7 @@ function resolvePortalBaseUrl(env) {
11677
12044
  function getGreenzoneKv(env) {
11678
12045
  return env.GREENZONE_STATE_KV ?? null;
11679
12046
  }
11680
- function ok6(payload) {
12047
+ function ok5(payload) {
11681
12048
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
11682
12049
  }
11683
12050
  function err6(message) {
@@ -11713,7 +12080,7 @@ function registerGreenzoneTools(server2, ctx) {
11713
12080
  ...ip ? buildAddIpSteps(portalBaseUrl, ip) : []
11714
12081
  ];
11715
12082
  if (dry_run) {
11716
- return ok6({
12083
+ return ok5({
11717
12084
  dry_run: true,
11718
12085
  message: "Would execute the following Kapture steps against the OCS portal:",
11719
12086
  kapture_steps: steps,
@@ -11736,7 +12103,7 @@ function registerGreenzoneTools(server2, ctx) {
11736
12103
  } else {
11737
12104
  kvWarning = "GREENZONE_STATE_KV not bound in stdio mode \u2014 state not persisted.";
11738
12105
  }
11739
- return ok6({
12106
+ return ok5({
11740
12107
  requires_kapture: true,
11741
12108
  message: "Execute the Kapture steps below to apply the change in the OCS portal. " + KAPTURE_UNAVAILABLE_MSG,
11742
12109
  kapture_steps: steps,
@@ -11774,7 +12141,7 @@ function registerGreenzoneTools(server2, ctx) {
11774
12141
  ...ip ? buildRemoveIpSteps(portalBaseUrl, ip) : []
11775
12142
  ];
11776
12143
  if (dry_run) {
11777
- return ok6({
12144
+ return ok5({
11778
12145
  dry_run: true,
11779
12146
  message: "Would execute the following Kapture steps to remove from OCS portal:",
11780
12147
  kapture_steps: steps,
@@ -11797,7 +12164,7 @@ function registerGreenzoneTools(server2, ctx) {
11797
12164
  } else {
11798
12165
  kvWarning = "GREENZONE_STATE_KV not bound in stdio mode \u2014 state not persisted.";
11799
12166
  }
11800
- return ok6({
12167
+ return ok5({
11801
12168
  requires_kapture: true,
11802
12169
  message: "Execute the Kapture steps below to apply the removal in the OCS portal. " + KAPTURE_UNAVAILABLE_MSG,
11803
12170
  kapture_steps: steps,
@@ -11828,7 +12195,7 @@ function registerGreenzoneTools(server2, ctx) {
11828
12195
  const { from_portal } = args;
11829
12196
  if (from_portal) {
11830
12197
  const portalBaseUrl = resolvePortalBaseUrl(ctx.env);
11831
- return ok6({
12198
+ return ok5({
11832
12199
  requires_kapture: true,
11833
12200
  message: "Execute the Kapture steps below to read live Greenzone whitelist state from the OCS portal.",
11834
12201
  kapture_steps: buildListSteps(portalBaseUrl),
@@ -11837,7 +12204,7 @@ function registerGreenzoneTools(server2, ctx) {
11837
12204
  }
11838
12205
  const kv = getGreenzoneKv(ctx.env);
11839
12206
  if (!kv) {
11840
- return ok6({
12207
+ return ok5({
11841
12208
  source: "kv_cache",
11842
12209
  hosts: [],
11843
12210
  ips: [],
@@ -11849,7 +12216,7 @@ function registerGreenzoneTools(server2, ctx) {
11849
12216
  }
11850
12217
  try {
11851
12218
  const state = await readState(kv);
11852
- return ok6({
12219
+ return ok5({
11853
12220
  source: "kv_cache",
11854
12221
  hosts: state.hosts,
11855
12222
  ips: state.ips,
@@ -11949,7 +12316,13 @@ var props = {
11949
12316
  var audit = (_row) => {
11950
12317
  };
11951
12318
  var getUserToken = async (_sub) => token;
11952
- var toolCtx = { env: stdioEnv, props, audit, getUserToken };
12319
+ var toolCtx = {
12320
+ env: stdioEnv,
12321
+ props,
12322
+ managedAuth: {},
12323
+ audit,
12324
+ getUserToken
12325
+ };
11953
12326
  var server = new McpServer(
11954
12327
  { name: "carrier-mcp", version: CARRIER_VERSION },
11955
12328
  {
@@ -11961,7 +12334,7 @@ registerAllTools(server, toolCtx);
11961
12334
  registerIntelligenceTools(server, toolCtx);
11962
12335
  registerAllBacklogTools(server, toolCtx);
11963
12336
  registerAllCarrierAskTools(server, toolCtx);
11964
- registerListRecentOcsEventsTool(server, stdioEnv);
12337
+ registerListRecentOcsEventsTool(server, toolCtx);
11965
12338
  registerRateLimitStatusTool(server, toolCtx);
11966
12339
  registerCountryHistoryTool(server, toolCtx);
11967
12340
  registerDepletionEventsTool(server, toolCtx);
@@ -11974,8 +12347,12 @@ registerStripeConnectTools(server, { env: stdioEnv, props });
11974
12347
  registerWalletTools(server, { env: stdioEnv, props });
11975
12348
  registerGreenzoneTools(server, toolCtx);
11976
12349
  registerUiAgentGenericTools(server, toolCtx);
11977
- registerStorefrontLogoTools(server, stdioEnv);
11978
- registerStorefrontDeployTools(server);
12350
+ registerStorefrontLogoTools(
12351
+ server,
12352
+ stdioEnv,
12353
+ props
12354
+ );
12355
+ registerStorefrontDeployTools(server, props);
11979
12356
  registerAllPrompts(server);
11980
12357
  var transport = new StdioServerTransport();
11981
12358
  await server.connect(transport);