@carrierllc/mcp 0.10.0 → 0.10.1

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
@@ -2,10 +2,11 @@
2
2
  import {
3
3
  CARRIER_VERSION,
4
4
  OCS_MAX_USAGE_WINDOW_DAYS,
5
+ OcsApiError,
6
+ OcsClient,
5
7
  ROUTER_RULES,
6
8
  TARGET_IDS,
7
- acquireEndpointSlot,
8
- applyParamRenames,
9
+ activePeriodFromPackages,
9
10
  applySubscriberFilters,
10
11
  buildListSubscriberParams,
11
12
  buildRouterCatalog,
@@ -33,21 +34,24 @@ import {
33
34
  networkEventsOverPeriodParams,
34
35
  normalizePackageTemplate,
35
36
  normalizePackageTemplateChanges,
37
+ ocsLocalDateTime,
38
+ packageActivePeriodParams,
39
+ prepaidPackageLimits,
36
40
  probeAll,
37
41
  provisionClerk,
38
42
  rankTargets,
39
43
  recordToolDescription,
44
+ recurringIdForPackage,
40
45
  recurringPackageParams,
41
46
  renderHtml,
42
47
  repairPlanFor,
43
- runWithBudget,
44
48
  storefrontClerkUrls,
45
49
  subscriberIdParams,
46
50
  subscriberRows,
47
51
  usageOverPeriodParams,
48
52
  verifyStorefront,
49
53
  withOcsListSummary
50
- } from "./chunk-DUAENMJE.js";
54
+ } from "./chunk-DP4ICKYF.js";
51
55
  import "./chunk-SHKKVIIA.js";
52
56
 
53
57
  // src/index.ts
@@ -58,59 +62,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
58
62
  import { z } from "zod";
59
63
  import * as Sentry from "@sentry/cloudflare";
60
64
 
61
- // src/client.ts
62
- var OcsApiError = class extends Error {
63
- constructor(code, message, method) {
64
- super(`[${method}] OCS error ${code}: ${message}`);
65
- this.code = code;
66
- this.method = method;
67
- this.name = "OcsApiError";
68
- }
69
- code;
70
- method;
71
- };
72
- var OcsClient = class {
73
- baseUrl;
74
- token;
75
- constructor(baseUrl2, token2) {
76
- this.baseUrl = baseUrl2.replace(/\/+$/, "");
77
- this.token = token2;
78
- }
79
- async call(method, params = {}) {
80
- await acquireEndpointSlot(this.token, method, "interactive");
81
- const url = `${this.baseUrl}/v1?token=${this.token}`;
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
- }
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);
96
- }
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
- });
111
- }
112
- };
113
-
114
65
  // ../../packages/carrier-ai/dist/index.js
115
66
  import { AwsClient } from "aws4fetch";
116
67
  var AI_TIER_POLICY = {
@@ -1370,7 +1321,12 @@ function registerAllTools(server2, ctx) {
1370
1321
  TOOL_SCOPES["modify_account_balance"],
1371
1322
  ctx,
1372
1323
  async ({ accountId, amount, mode }, token2) => {
1373
- const params = { accountId, amount, mode };
1324
+ const params = { accountId };
1325
+ if (mode === "set") {
1326
+ params.setBalance = amount;
1327
+ } else {
1328
+ params.amount = amount;
1329
+ }
1374
1330
  return ocsCall(ctx.env, token2, "modifyAccountBalance", params);
1375
1331
  }
1376
1332
  )
@@ -1751,11 +1707,13 @@ function registerAllTools(server2, ctx) {
1751
1707
  "move_subscriber_range_to_account",
1752
1708
  {
1753
1709
  title: "Move Subscribers to Account",
1754
- description: "Use this to move a contiguous ICCID range of subscribers to a different account. Useful for bulk subscriber migrations between accounts or during account restructuring. Params: `iccidFrom` (start ICCID of range, inclusive), `iccidTo` (end ICCID of range, inclusive), `accountId` (target account ID from `list_reseller_accounts`). Returns: OCS confirmation of the range move with affected subscriber count. Do NOT use this for a single subscriber move \u2014 provide identical iccidFrom and iccidTo. Always call `list_subscribers` on the range first to verify the correct subscribers are included.",
1710
+ description: "BREAKING CHANGE (2026-08-28): this tool's contract was rebuilt around account-ID ranges to match what OCS actually accepts \u2014 ICCID-pair targeting (`iccidFrom`/`iccidTo` + a single `accountId`) is gone. Zero field names were ever shared with OCS's real `moveSubscriberRangeToAccount` shape (verified live 2026-08-24 \u2014 see packages/ocs-spec/ocs-accepted-params.json), so every prior call to this tool failed outright; nothing that used to work stops working. Use this to move a contiguous range of subscribers, identified by IMSI or ICCID, from one account to another. Cross-reseller moves lose the subscribers' packages. Params: `srcAccountId` (source account ID from `list_reseller_accounts`), `destAccount` (destination account ID), `rangeType` ('IMSI' | 'ICCID' \u2014 which identifier `rangeStart`/`rangeEnd` are expressed in), `rangeStart` (inclusive start of the range), `rangeEnd` (inclusive end of the range). Returns: OCS confirmation with the count of subscribers moved. Do NOT use this for a single subscriber move \u2014 provide identical rangeStart and rangeEnd. Always call `list_subscribers` on the range first to verify the correct subscribers are included.",
1755
1711
  inputSchema: {
1756
- iccidFrom: z.string().describe("Start ICCID of range"),
1757
- iccidTo: z.string().describe("End ICCID of range"),
1758
- accountId: z.number().describe("Target account ID"),
1712
+ srcAccountId: z.number().describe("Source account ID to move subscribers FROM"),
1713
+ destAccount: z.number().describe("Destination account ID to move subscribers TO"),
1714
+ rangeType: z.enum(["IMSI", "ICCID"]).describe("Whether rangeStart/rangeEnd identify subscribers by IMSI or ICCID"),
1715
+ rangeStart: z.string().describe("Inclusive start of the identifier range"),
1716
+ rangeEnd: z.string().describe("Inclusive end of the identifier range"),
1759
1717
  ...DRY_RUN_FIELD
1760
1718
  },
1761
1719
  annotations: { destructiveHint: true }
@@ -1765,10 +1723,12 @@ function registerAllTools(server2, ctx) {
1765
1723
  "moveSubscriberRangeToAccount",
1766
1724
  TOOL_SCOPES["move_subscriber_range_to_account"],
1767
1725
  ctx,
1768
- async ({ iccidFrom, iccidTo, accountId }, token2) => ocsCall(ctx.env, token2, "moveSubscriberRangeToAccount", {
1769
- iccidFrom,
1770
- iccidTo,
1771
- accountId
1726
+ async ({ srcAccountId, destAccount, rangeType, rangeStart, rangeEnd }, token2) => ocsCall(ctx.env, token2, "moveSubscriberRangeToAccount", {
1727
+ srcAccountId,
1728
+ destAccount,
1729
+ rangeType,
1730
+ rangeStart,
1731
+ rangeEnd
1772
1732
  })
1773
1733
  )
1774
1734
  );
@@ -1957,11 +1917,17 @@ function registerAllTools(server2, ctx) {
1957
1917
  "modifySubscriberPrepaidPackageLimits",
1958
1918
  TOOL_SCOPES["modify_package_limits"],
1959
1919
  ctx,
1960
- async ({ iccid, packageId, limits }, token2) => ocsCall(ctx.env, token2, "modifySubscriberPrepaidPackageLimits", {
1961
- iccid,
1962
- packageId,
1963
- ...JSON.parse(limits)
1964
- })
1920
+ async ({ packageId, limits }, token2) => (
1921
+ // OCS accepts only packageId/newLimits/comment here, so `iccid` never
1922
+ // reaches the wire, and the limits nest under `newLimits`
1923
+ // (dataByte/mocSecond/mtcSecond/moSms/mtSms) rather than sitting flat.
1924
+ // mcp-stdio is a BYO-token surface, so unlike mcp-server there is no
1925
+ // ownership-evidence use for the ICCID here.
1926
+ ocsCall(ctx.env, token2, "modifySubscriberPrepaidPackageLimits", {
1927
+ packageId,
1928
+ newLimits: prepaidPackageLimits(JSON.parse(limits))
1929
+ })
1930
+ )
1965
1931
  )
1966
1932
  );
1967
1933
  server2.registerTool(
@@ -1993,9 +1959,11 @@ function registerAllTools(server2, ctx) {
1993
1959
  "modifySubscriberPrepaidPackageExpDate",
1994
1960
  TOOL_SCOPES["modify_package_expiry"],
1995
1961
  ctx,
1996
- async ({ iccid, packageId, expirationDate, validity_days }, token2) => {
1997
- const params = { iccid, packageId };
1998
- if (expirationDate !== void 0) params.expirationDate = expirationDate;
1962
+ async ({ packageId, expirationDate, validity_days }, token2) => {
1963
+ const params = { packageId };
1964
+ if (expirationDate !== void 0) {
1965
+ params.newExpirationDate = ocsLocalDateTime(expirationDate);
1966
+ }
1999
1967
  if (validity_days !== void 0) params.newValidityDuration = validity_days;
2000
1968
  return ocsCall(ctx.env, token2, "modifySubscriberPrepaidPackageExpDate", params);
2001
1969
  }
@@ -2019,11 +1987,24 @@ function registerAllTools(server2, ctx) {
2019
1987
  "modifySubscriberPrepaidPackageStatus",
2020
1988
  TOOL_SCOPES["modify_package_status"],
2021
1989
  ctx,
2022
- async ({ iccid, packageId, status }, token2) => ocsCall(ctx.env, token2, "modifySubscriberPrepaidPackageStatus", {
2023
- iccid,
2024
- packageId,
2025
- status
2026
- })
1990
+ async ({ packageId, status }, token2) => {
1991
+ const normalised = String(status).trim().toUpperCase();
1992
+ if (normalised !== "ACTIVE" && normalised !== "INACTIVE") {
1993
+ return {
1994
+ isError: true,
1995
+ content: [
1996
+ {
1997
+ type: "text",
1998
+ text: `Invalid status "${status}". Use "ACTIVE" or "INACTIVE".`
1999
+ }
2000
+ ]
2001
+ };
2002
+ }
2003
+ return ocsCall(ctx.env, token2, "modifySubscriberPrepaidPackageStatus", {
2004
+ subsPrepaidPackageId: packageId,
2005
+ active: normalised === "ACTIVE"
2006
+ });
2007
+ }
2027
2008
  )
2028
2009
  );
2029
2010
  server2.registerTool(
@@ -2044,11 +2025,29 @@ function registerAllTools(server2, ctx) {
2044
2025
  "stopResumeSubsRecurringPackage",
2045
2026
  TOOL_SCOPES["stop_resume_recurring_package"],
2046
2027
  ctx,
2047
- async ({ iccid, packageId, action }, token2) => ocsCall(ctx.env, token2, "stopResumeSubsRecurringPackage", {
2048
- iccid,
2049
- packageId,
2050
- action
2051
- })
2028
+ async ({ iccid, packageId, action }, token2) => {
2029
+ const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
2030
+ const listed = await client2.call(
2031
+ "listSubscriberPrepaidPackages",
2032
+ { iccid }
2033
+ );
2034
+ const recurringId = recurringIdForPackage(listed, packageId);
2035
+ if (recurringId === void 0) {
2036
+ return {
2037
+ isError: true,
2038
+ content: [
2039
+ {
2040
+ type: "text",
2041
+ text: `Package ${packageId} is not a recurring package on ICCID ${iccid}. stop_resume_recurring_package needs a package that came from a recurring template \u2014 call list_subscriber_packages and pick one that reports a recurring subscription.`
2042
+ }
2043
+ ]
2044
+ };
2045
+ }
2046
+ return ocsCall(ctx.env, token2, "stopResumeSubsRecurringPackage", {
2047
+ recurringId,
2048
+ active: action === "resume"
2049
+ });
2050
+ }
2052
2051
  )
2053
2052
  );
2054
2053
  server2.registerTool(
@@ -4171,7 +4170,7 @@ function registerIntelligenceTools(server2, ctx) {
4171
4170
  }, async ({ accountId }) => {
4172
4171
  const token2 = await ctx.getUserToken(ctx.props.sub);
4173
4172
  const resellerId = await getDefaultResellerId(ctx.env, token2).catch(() => void 0);
4174
- const [statusResult, accountsResult] = await Promise.all([
4173
+ const [statusResult, accountsResult, resellerInfoResult] = await Promise.all([
4175
4174
  safeCall(
4176
4175
  ctx.env,
4177
4176
  token2,
@@ -4183,7 +4182,17 @@ function registerIntelligenceTools(server2, ctx) {
4183
4182
  token2,
4184
4183
  "listResellerAccount",
4185
4184
  resellerId !== void 0 ? { resellerId } : {}
4186
- )
4185
+ ),
4186
+ // The reseller's OWN balance. Sub-account balances cannot show that the
4187
+ // parent is out of credit, and that is the one state which silently
4188
+ // zeroes every rating group (2026-08-28: reseller 1170 at 0.00 while
4189
+ // fleet_health reported "require no action").
4190
+ resellerId !== void 0 ? safeCall(
4191
+ ctx.env,
4192
+ token2,
4193
+ "getResellerInfo",
4194
+ { id: resellerId }
4195
+ ) : Promise.resolve({ data: null, error: null })
4187
4196
  ]);
4188
4197
  const sections = ["# Fleet Health Dashboard\n"];
4189
4198
  const counts = extractEsimStatusCounts(statusResult.data);
@@ -4232,11 +4241,20 @@ High suspension rate (${totalSuspended} suspended vs ${totalActive} active)`);
4232
4241
  const criticalZero = accounts.filter(
4233
4242
  (a) => a.packageOnly === false && Number(a.balance ?? 0) <= 0
4234
4243
  );
4244
+ const resellerRaw = resellerInfoResult?.data ?? null;
4245
+ const rawBalance = resellerRaw?.getResellerInfo?.balance;
4246
+ const resellerBalance = rawBalance === void 0 || rawBalance === null ? null : Number(rawBalance);
4247
+ const resellerOutOfCredit = resellerBalance !== null && resellerBalance <= 0;
4235
4248
  sections.push(`
4236
4249
  ## Account Summary`);
4237
4250
  sections.push(`- Total accounts: ${accounts.length}`);
4238
4251
  sections.push(`- Low balance (< 10): ${lowBalance.length}`);
4239
4252
  sections.push(`- Package-only with 0 balance: ${packageOnlyZero.length}`);
4253
+ if (resellerBalance !== null) {
4254
+ sections.push(
4255
+ `- Reseller balance: ${resellerBalance.toFixed(2)}` + (resellerOutOfCredit ? ` (OUT OF CREDIT - nothing can be rated)` : ``)
4256
+ );
4257
+ }
4240
4258
  if (lowBalance.length > 0) {
4241
4259
  sections.push(`
4242
4260
  ### Low Balance Accounts (< 10)`);
@@ -4266,8 +4284,15 @@ Action: top up non-package-only accounts at 0 balance to restore rated traffic.`
4266
4284
  sections.push(`
4267
4285
  All accounts healthy.`);
4268
4286
  } else if (informational > 0 && warning <= 0) {
4269
- sections.push(`
4287
+ if (resellerOutOfCredit) {
4288
+ sections.push(
4289
+ `
4290
+ Action: reseller balance is ${resellerBalance.toFixed(2)}. Package-only accounts draw on their packages, but a reseller at 0 cannot rate any traffic - sessions are accepted and granted 0 bytes. Top up the reseller.`
4291
+ );
4292
+ } else {
4293
+ sections.push(`
4270
4294
  Package-only accounts at 0 balance require no action.`);
4295
+ }
4271
4296
  }
4272
4297
  } else {
4273
4298
  sections.push(`
@@ -5088,7 +5113,7 @@ function registerAllBacklogTools(server2, ctx) {
5088
5113
  const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5089
5114
  const result2 = await client2.call(
5090
5115
  "modifySubscriberMobilePlan",
5091
- { subscriber: iccid, mobilePlanId: mobile_plan_id }
5116
+ { subscriberId: { iccid }, mobilePlanId: mobile_plan_id }
5092
5117
  );
5093
5118
  return {
5094
5119
  content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
@@ -5106,6 +5131,9 @@ function registerAllBacklogTools(server2, ctx) {
5106
5131
  package_id: z3.number().describe("The subscriber package ID (from list_subscriber_packages)"),
5107
5132
  start_date: z3.string().optional().describe("New start date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss)"),
5108
5133
  end_date: z3.string().optional().describe("New end date in ISO 8601 format (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss)"),
5134
+ comment: z3.string().optional().describe(
5135
+ "Audit note stored on the OCS package record. OCS requires this field, so a default is sent when it is omitted."
5136
+ ),
5109
5137
  ...DRY_RUN_FIELD2
5110
5138
  },
5111
5139
  annotations: { destructiveHint: true }
@@ -5115,11 +5143,22 @@ function registerAllBacklogTools(server2, ctx) {
5115
5143
  "modifySubscriberPrepaidPackageActivePeriod",
5116
5144
  BACKLOG_TOOL_SCOPES["modify_subscriber_package_active_period"],
5117
5145
  ctx,
5118
- async ({ iccid, package_id, start_date, end_date }, token2) => {
5119
- const params = { subscriber: iccid, packageId: package_id };
5120
- if (start_date !== void 0) params.startDate = start_date;
5121
- if (end_date !== void 0) params.endDate = end_date;
5146
+ async ({ iccid, package_id, start_date, end_date, comment }, token2) => {
5122
5147
  const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5148
+ let current;
5149
+ if (start_date === void 0 || end_date === void 0) {
5150
+ const listed = await client2.call("listSubscriberPrepaidPackages", { iccid });
5151
+ current = activePeriodFromPackages(listed, package_id);
5152
+ if (!current) {
5153
+ throw new Error(`Package ${package_id} is not on subscriber ${iccid}`);
5154
+ }
5155
+ }
5156
+ const params = packageActivePeriodParams(package_id, {
5157
+ startDate: start_date,
5158
+ endDate: end_date,
5159
+ comment,
5160
+ current
5161
+ });
5123
5162
  const result2 = await client2.call(
5124
5163
  "modifySubscriberPrepaidPackageActivePeriod",
5125
5164
  params
@@ -5151,7 +5190,7 @@ function registerAllBacklogTools(server2, ctx) {
5151
5190
  const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5152
5191
  const result2 = await client2.call(
5153
5192
  "modifySubscriberVoipPlan",
5154
- { subscriber: iccid, voipPlanId: voip_plan_id }
5193
+ { subscriberId: { iccid }, voipPlanId: voip_plan_id }
5155
5194
  );
5156
5195
  return {
5157
5196
  content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
@@ -5176,13 +5215,10 @@ function registerAllBacklogTools(server2, ctx) {
5176
5215
  BACKLOG_TOOL_SCOPES["push_steering_to_subscriber"],
5177
5216
  ctx,
5178
5217
  async ({ iccid }, token2) => {
5179
- const cache = /* @__PURE__ */ new Map();
5180
- const sub = await resolveSubscriberByIccid(ctx.env, token2, iccid, cache);
5181
- const subscriberId = sub.id ?? sub.subscriberId ?? iccid;
5182
5218
  const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5183
5219
  const result2 = await client2.call(
5184
5220
  "pushSteeringToSubs",
5185
- { subscriber: subscriberId }
5221
+ { iccid }
5186
5222
  );
5187
5223
  return {
5188
5224
  content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
@@ -5208,7 +5244,7 @@ function registerAllBacklogTools(server2, ctx) {
5208
5244
  ctx,
5209
5245
  async ({ iccid }, token2) => {
5210
5246
  const client2 = new OcsClient(ctx.env.CARRIER_OCS_BASE_URL, token2);
5211
- const result2 = await client2.call("resetSubsGzCounter", { subscriber: iccid });
5247
+ const result2 = await client2.call("resetSubsGzCounter", { iccid });
5212
5248
  return {
5213
5249
  content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
5214
5250
  };
@@ -6683,6 +6719,11 @@ async function createTopupCheckout(env, args) {
6683
6719
  const successUrl = args.successUrl ?? env.WALLET_CHECKOUT_SUCCESS_URL ?? DEFAULT_SUCCESS_URL;
6684
6720
  const cancelUrl = args.cancelUrl ?? env.WALLET_CHECKOUT_CANCEL_URL ?? DEFAULT_CANCEL_URL;
6685
6721
  const bonusLabel = bonusCents > 0 ? ` (+\u20AC${(bonusCents / 100).toFixed(2)} bonus)` : "";
6722
+ if (!env.CARRIER_USERS) {
6723
+ throw new Error(
6724
+ "Wallet top-up is not available in local stdio mode: the prepaid wallet is credited by the hosted Carrier service after payment, and this process cannot reach the customer record that crediting depends on. Creating the session here would bill the card against a duplicate Stripe customer and the wallet would never be credited. Top up from the console at https://app.carrier.llc/billing, or connect to the hosted MCP at https://mcp.carrier.llc/mcp, which has the binding. `wallet_balance` still works here if ATLAS_BASE_URL and CARRIER_INTERNAL_API_KEY are set."
6725
+ );
6726
+ }
6686
6727
  const billingSub = await resolveBillingSub(env, args.orgId);
6687
6728
  const existingCustomer = await env.CARRIER_USERS.get(`stripe_customer_id:${billingSub}`).catch(() => null);
6688
6729
  const params = new URLSearchParams({
@@ -6732,7 +6773,7 @@ async function runAutoTopup(env, args) {
6732
6773
  let packCents = args.packCents;
6733
6774
  try {
6734
6775
  const wallet = await getWallet(env, args.orgId);
6735
- if (packCents === void 0) packCents = wallet.auto_topup_pack_cents;
6776
+ if (packCents === void 0) packCents = wallet.auto_topup_pack_cents ?? void 0;
6736
6777
  } catch (e) {
6737
6778
  if (e instanceof WalletClientError) {
6738
6779
  return { charged: false, creditedCents: 0, reason: `wallet_unreachable: ${e.message}` };
@@ -6838,15 +6879,23 @@ function registerWalletTools(server2, ctx) {
6838
6879
  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`.",
6839
6880
  inputSchema: {},
6840
6881
  // Derived from the object literal this handler builds below, not from the
6841
- // description. Every field is unconditional on the success path and comes
6842
- // straight off the Wallet record (wallet-client.ts:31-34), so all five are
6843
- // required rather than optional.
6882
+ // description. All five keys are always present on the success path, so
6883
+ // none is optional — but the two auto-top-up amounts are nullable, not
6884
+ // just unset. ATLAS types them `number | null` and synthesises an
6885
+ // all-null row for an org with no wallet yet (its toWalletView), so an
6886
+ // org that has never configured auto-top-up — every newly onboarded one
6887
+ // — returns null here. Requiring a number turned that, the most common
6888
+ // state a new operator is in, into an output validation error.
6844
6889
  outputSchema: {
6845
6890
  org_id: z7.string().describe("Carrier organisation the wallet belongs to"),
6846
6891
  balance_eur_cents: z7.number().int().describe("Current balance in EUR cents"),
6847
6892
  auto_topup_enabled: z7.boolean(),
6848
- auto_topup_threshold_cents: z7.number().int().describe("Balance at or below which an auto top-up fires, EUR cents"),
6849
- auto_topup_pack_cents: z7.number().int().describe("Amount an auto top-up charges, EUR cents")
6893
+ auto_topup_threshold_cents: z7.number().int().nullable().describe(
6894
+ "Balance at or below which an auto top-up fires, EUR cents. Null when auto-top-up is unconfigured."
6895
+ ),
6896
+ auto_topup_pack_cents: z7.number().int().nullable().describe(
6897
+ "Amount an auto top-up charges, EUR cents. Null when auto-top-up is unconfigured."
6898
+ )
6850
6899
  },
6851
6900
  annotations: annotationsFor("wallet_balance", "read")
6852
6901
  },
@@ -7038,7 +7087,39 @@ function registerWalletTools(server2, ctx) {
7038
7087
  // src/stripe-connect-tools.ts
7039
7088
  import { z as z8 } from "zod";
7040
7089
  import * as Sentry3 from "@sentry/cloudflare";
7090
+
7091
+ // src/binding-unavailable.ts
7092
+ var HOSTED_MCP_URL = "https://mcp.carrier.llc/mcp";
7093
+ var BindingUnavailableError = class extends Error {
7094
+ constructor(tool, binding, message) {
7095
+ super(message);
7096
+ this.tool = tool;
7097
+ this.binding = binding;
7098
+ this.name = "BindingUnavailableError";
7099
+ }
7100
+ tool;
7101
+ binding;
7102
+ };
7103
+ function requireUserStore(env, tool, subject) {
7104
+ requireBinding(env.CARRIER_USERS, tool, "CARRIER_USERS", subject);
7105
+ }
7106
+ function requireBinding(binding, tool, name, subject) {
7107
+ if (binding) return;
7108
+ throw new BindingUnavailableError(
7109
+ tool,
7110
+ name,
7111
+ `${tool} cannot answer here: ${subject} is held by the hosted Carrier service, and this local MCP server has no connection to it. There is nothing to set in your environment \u2014 the data does not exist on this machine. Connect to the hosted MCP at ${HOSTED_MCP_URL}, or open the console at https://app.carrier.llc, to use this tool. Every OCS tool in this server works against your own token and is unaffected.`
7112
+ );
7113
+ }
7114
+
7115
+ // src/stripe-connect-tools.ts
7041
7116
  async function verifyConfirmToken(env, sub, toolName, token2) {
7117
+ requireBinding(
7118
+ env.OAUTH_KV,
7119
+ toolName,
7120
+ "OAUTH_KV",
7121
+ "the short-lived confirmation token this operation requires"
7122
+ );
7042
7123
  const key = `confirm:${sub}:${toolName}`;
7043
7124
  const stored = await env.OAUTH_KV.get(key);
7044
7125
  if (!stored || stored !== token2) return false;
@@ -7046,6 +7127,12 @@ async function verifyConfirmToken(env, sub, toolName, token2) {
7046
7127
  return true;
7047
7128
  }
7048
7129
  async function issueConfirmToken(env, sub, toolName) {
7130
+ requireBinding(
7131
+ env.OAUTH_KV,
7132
+ toolName,
7133
+ "OAUTH_KV",
7134
+ "the short-lived confirmation token this operation requires"
7135
+ );
7049
7136
  const bytes = crypto.getRandomValues(new Uint8Array(16));
7050
7137
  const token2 = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
7051
7138
  const key = `confirm:${sub}:${toolName}`;
@@ -8173,6 +8260,7 @@ var PRICING_TOOLS = [
8173
8260
  required: []
8174
8261
  },
8175
8262
  handler: async (env, props2) => {
8263
+ requireUserStore(env, "credit_balance", "your MCP plan credit ledger");
8176
8264
  const tier = props2.tier;
8177
8265
  const summary = await getCreditSummary(env, props2.sub, tier);
8178
8266
  const creditCheck = await checkCredits(env, props2.sub, tier, "read");
@@ -8254,6 +8342,7 @@ var PRICING_TOOLS = [
8254
8342
  required: []
8255
8343
  },
8256
8344
  handler: async (env, props2, args) => {
8345
+ requireUserStore(env, "configure_billing", "your billing configuration");
8257
8346
  const tier = props2.tier;
8258
8347
  if (tier === "free") {
8259
8348
  return {
@@ -8322,6 +8411,11 @@ var PRICING_TOOLS = [
8322
8411
  required: []
8323
8412
  },
8324
8413
  handler: async (env, props2, args) => {
8414
+ requireUserStore(
8415
+ env,
8416
+ "usage_projection",
8417
+ "the consumption history a projection is drawn from"
8418
+ );
8325
8419
  const tier = props2.tier;
8326
8420
  const summary = await getCreditSummary(env, props2.sub, tier);
8327
8421
  const now = /* @__PURE__ */ new Date();
@@ -8470,6 +8564,7 @@ var PRICING_TOOLS = [
8470
8564
  required: []
8471
8565
  },
8472
8566
  handler: async (env, props2, args) => {
8567
+ requireUserStore(env, "billing_events", "your billing event history");
8473
8568
  const limit = Math.min(
8474
8569
  typeof args.limit === "number" ? args.limit : 20,
8475
8570
  50
@@ -8780,6 +8875,11 @@ var PROJECTS_TOOLS = [
8780
8875
  required: ["new_token", "confirm"]
8781
8876
  },
8782
8877
  handler: async (env, props2, args) => {
8878
+ requireUserStore(
8879
+ env,
8880
+ "rotate_credentials",
8881
+ "the encrypted token vault this writes to"
8882
+ );
8783
8883
  if (!args.confirm) {
8784
8884
  return {
8785
8885
  status: "cancelled",
@@ -8794,7 +8894,9 @@ var PROJECTS_TOOLS = [
8794
8894
  }
8795
8895
  const encryptionKey = env.CARRIER_TOKEN_ENCRYPTION_KEY;
8796
8896
  if (!encryptionKey) {
8797
- return { error: "Encryption key not configured \u2014 contact support" };
8897
+ throw new Error(
8898
+ "rotate_credentials cannot run here: CARRIER_TOKEN_ENCRYPTION_KEY is a secret of the hosted Carrier service and is not present in a local install, so there is no way to encrypt a token for the vault. Rotate from the console at https://app.carrier.llc, or connect to the hosted MCP at https://mcp.carrier.llc/mcp. In local stdio mode the OCS token comes from CARRIER_OCS_API_TOKEN in your own environment \u2014 change it there and restart, no rotation call needed."
8899
+ );
8798
8900
  }
8799
8901
  const encrypted = await encryptToken(newToken, encryptionKey);
8800
8902
  const orgId = props2.org_id;
@@ -8943,13 +9045,17 @@ var PROJECTS_TOOLS = [
8943
9045
  handler: async (env, props2) => {
8944
9046
  const sub = props2.sub;
8945
9047
  const orgId = props2.org_id;
9048
+ const hasStore = !!env.CARRIER_USERS;
8946
9049
  let orgDetails = null;
8947
- if (orgId) {
9050
+ if (orgId && hasStore) {
8948
9051
  orgDetails = await env.CARRIER_USERS.get(`org:${orgId}`, "json").catch(() => null);
8949
9052
  }
8950
- const hasStripe = !!await env.CARRIER_USERS.get(`stripe_customer_id:${sub}`);
8951
- const hasWebhook = !!await env.CARRIER_USERS.get(`webhook_url:${sub}`);
9053
+ const hasStripe = hasStore ? !!await env.CARRIER_USERS.get(`stripe_customer_id:${sub}`) : null;
9054
+ const hasWebhook = hasStore ? !!await env.CARRIER_USERS.get(`webhook_url:${sub}`) : null;
8952
9055
  return {
9056
+ ...hasStore ? {} : {
9057
+ note: "Running as a local stdio server. Fields reported as null are held by the hosted Carrier service and cannot be read from here; connect to https://mcp.carrier.llc/mcp to see them. Everything else below is this session's own configuration and is accurate."
9058
+ },
8953
9059
  environment: {
8954
9060
  user_sub: sub,
8955
9061
  org_id: orgId ?? null,
@@ -9374,8 +9480,8 @@ var CURATED_ROUTER_TOOLS = [
9374
9480
  },
9375
9481
  {
9376
9482
  name: "move_subscriber_range_to_account",
9377
- description: "Move a range of subscribers to a different account. Intent: 'move subscribers', 'transfer SIMs to account'.",
9378
- input_schema: { type: "object", properties: { accountId: { type: "number" } } }
9483
+ description: "Move a range of subscribers (by IMSI or ICCID) from one account to another. Intent: 'move subscribers', 'transfer SIMs to account'.",
9484
+ input_schema: { type: "object", properties: { srcAccountId: { type: "number" }, destAccount: { type: "number" }, rangeType: { type: "string" } } }
9379
9485
  },
9380
9486
  {
9381
9487
  name: "hlr_get_bitrate",
@@ -9658,15 +9764,28 @@ var ROUTER_SYSTEM_PROMPT = `You are a carrier fleet operations router. Your job
9658
9764
 
9659
9765
  ${ROUTER_RULES}`;
9660
9766
  var ROUTER_TIMEOUT_MS = 12e3;
9767
+ var AskRouterNotConfiguredError = class extends Error {
9768
+ constructor(missing) {
9769
+ super(routerRemediation(missing));
9770
+ this.missing = missing;
9771
+ this.name = "AskRouterNotConfiguredError";
9772
+ }
9773
+ missing;
9774
+ };
9775
+ function missingRouterCredentials(env) {
9776
+ const missing = [];
9777
+ if (!env.AWS_ACCESS_KEY_ID) missing.push("AWS_ACCESS_KEY_ID");
9778
+ if (!env.AWS_SECRET_ACCESS_KEY) missing.push("AWS_SECRET_ACCESS_KEY");
9779
+ if (env.CARRIER_ASK_ENABLED !== "true") missing.push("CARRIER_ASK_ENABLED=true");
9780
+ return missing;
9781
+ }
9782
+ function routerRemediation(missing) {
9783
+ return `carrier_ask cannot route: ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not set. Set them in the environment of the process running this MCP server (for Claude Desktop / Cursor / Windsurf, the \`env\` block of the server entry in your MCP config), then restart it. The router calls Amazon Bedrock, so the credentials must belong to an IAM identity with bedrock:InvokeModel. Every other Carrier tool works without this \u2014 carrier_ask only picks which one to call, so you can call the tool you want directly in the meantime.`;
9784
+ }
9661
9785
  async function _routeIntent(intent, context, env, opts = {}) {
9662
9786
  const { attribution, onUsage } = opts;
9663
9787
  if (env.CARRIER_ASK_ENABLED !== "true" || !env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
9664
- return {
9665
- match: "routing_pending",
9666
- intent_received: intent,
9667
- note: "carrier_ask routing engine is not yet activated. Add AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY to Doppler carrier/dev+stg+prd and set CARRIER_ASK_ENABLED=true.",
9668
- scaffold_version: "v2.0-bedrock"
9669
- };
9788
+ throw new AskRouterNotConfiguredError(missingRouterCredentials(env));
9670
9789
  }
9671
9790
  const definedContext = Object.fromEntries(
9672
9791
  Object.entries(context).filter(([, value]) => value !== void 0)
@@ -9922,6 +10041,34 @@ function registerAllCarrierAskTools(server2, ctx) {
9922
10041
  }
9923
10042
  });
9924
10043
  } catch (err7) {
10044
+ if (err7 instanceof AskRouterNotConfiguredError) {
10045
+ writeCarrierAskAudit(ctx.env, {
10046
+ intent_hash: intentHash,
10047
+ match: "error",
10048
+ resolved_tool: "none",
10049
+ confirm_token_state: "none",
10050
+ status: "error",
10051
+ latency_ms: Date.now() - start,
10052
+ sub: ctx.props.sub,
10053
+ reseller_id: ctx.props.reseller_id,
10054
+ model_id: "none",
10055
+ tier: ctx.props.tier,
10056
+ usage: ZERO_USAGE
10057
+ });
10058
+ return {
10059
+ content: [
10060
+ {
10061
+ type: "text",
10062
+ text: JSON.stringify({
10063
+ error: "router_not_configured",
10064
+ missing: err7.missing,
10065
+ message: err7.message
10066
+ })
10067
+ }
10068
+ ],
10069
+ isError: true
10070
+ };
10071
+ }
9925
10072
  writeCarrierAskAudit(ctx.env, {
9926
10073
  intent_hash: intentHash,
9927
10074
  match: "error",
@@ -10279,6 +10426,12 @@ function registerListRecentOcsEventsTool(server2, ctx) {
10279
10426
  };
10280
10427
  }
10281
10428
  }
10429
+ requireBinding(
10430
+ env.OCS_EVENT_ROUTING,
10431
+ "list_recent_ocs_events",
10432
+ "OCS_EVENT_ROUTING",
10433
+ "the OCS event ring buffer"
10434
+ );
10282
10435
  const result2 = await listRecentOcsEvents2(
10283
10436
  iccid,
10284
10437
  limit,
@@ -10435,7 +10588,7 @@ function registerDepletionEventsTool(server2, ctx) {
10435
10588
  const kv = ctx.env.RATE_LIMIT_KV;
10436
10589
  if (!kv) {
10437
10590
  return err5(
10438
- "RATE_LIMIT_KV binding is not available in this environment. Add the binding to wrangler.jsonc and redeploy."
10591
+ "subscriber_depletion_events cannot answer here. Depletion events are written by the Carrier webhook receiver as they arrive from the network and kept for 7 days in hosted storage; a local stdio server receives no webhooks and has nowhere to keep them, so there is no history on this machine to read. There is nothing to set in your environment. Use the hosted MCP at https://mcp.carrier.llc/mcp for this tool. To see a subscriber's current bundle state instead of its depletion history, `get_subscriber` and `list_subscriber_packages` query OCS directly and work here."
10439
10592
  );
10440
10593
  }
10441
10594
  const { subscriberId, since } = args;
@@ -12353,7 +12506,23 @@ var stdioEnv = {
12353
12506
  ATLAS_BASE_URL: process.env.ATLAS_BASE_URL,
12354
12507
  CARRIER_INTERNAL_API_KEY: process.env.CARRIER_INTERNAL_API_KEY,
12355
12508
  WALLET_CHECKOUT_SUCCESS_URL: process.env.WALLET_CHECKOUT_SUCCESS_URL,
12356
- WALLET_CHECKOUT_CANCEL_URL: process.env.WALLET_CHECKOUT_CANCEL_URL
12509
+ WALLET_CHECKOUT_CANCEL_URL: process.env.WALLET_CHECKOUT_CANCEL_URL,
12510
+ // Bedrock credentials for carrier_ask, the natural-language router. None of
12511
+ // these appeared in this shim at all until now, so every read saw `undefined`
12512
+ // and the routing gate in tools-carrier-ask.ts could never open: the flagship
12513
+ // router had routed nothing from npm since it shipped. Same treatment as
12514
+ // STRIPE_SECRET_KEY and the wallet values above — read the environment, and
12515
+ // when absent let the tool refuse out loud rather than pretend it answered.
12516
+ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
12517
+ AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
12518
+ AWS_REGION: process.env.AWS_REGION,
12519
+ BEDROCK_MODEL_ID: process.env.BEDROCK_MODEL_ID,
12520
+ // Defaults ON here, unlike the Worker. The flag is the Worker's staged
12521
+ // rollout switch; a local operator who has put AWS credentials on the process
12522
+ // has already opted in, and making them set a second variable they have never
12523
+ // heard of is just the same dead gate one step further along. Setting it to
12524
+ // anything other than "true" still turns the router off.
12525
+ CARRIER_ASK_ENABLED: process.env.CARRIER_ASK_ENABLED ?? "true"
12357
12526
  };
12358
12527
  var props = {
12359
12528
  sub: "stdio@local",