@carrierllc/mcp 0.1.0 → 0.2.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.
Files changed (130) hide show
  1. package/dist/.metadata_never_index +0 -0
  2. package/dist/apps/app-state.d.ts +19 -0
  3. package/dist/apps/app-state.d.ts.map +1 -0
  4. package/dist/apps/app-state.js +28 -0
  5. package/dist/apps/app-state.js.map +1 -0
  6. package/dist/apps/balance-topup.d.ts +19 -0
  7. package/dist/apps/balance-topup.d.ts.map +1 -0
  8. package/dist/apps/balance-topup.js +121 -0
  9. package/dist/apps/balance-topup.js.map +1 -0
  10. package/dist/apps/fleet-health-app.d.ts +29 -0
  11. package/dist/apps/fleet-health-app.d.ts.map +1 -0
  12. package/dist/apps/fleet-health-app.js +152 -0
  13. package/dist/apps/fleet-health-app.js.map +1 -0
  14. package/dist/apps/index.d.ts +37 -0
  15. package/dist/apps/index.d.ts.map +1 -0
  16. package/dist/apps/index.js +42 -0
  17. package/dist/apps/index.js.map +1 -0
  18. package/dist/apps/provisioning-wizard.d.ts +11 -0
  19. package/dist/apps/provisioning-wizard.d.ts.map +1 -0
  20. package/dist/apps/provisioning-wizard.js +345 -0
  21. package/dist/apps/provisioning-wizard.js.map +1 -0
  22. package/dist/audit.d.ts +31 -0
  23. package/dist/audit.d.ts.map +1 -0
  24. package/dist/audit.js +42 -0
  25. package/dist/audit.js.map +1 -0
  26. package/dist/billing-thresholds.d.ts +71 -0
  27. package/dist/billing-thresholds.d.ts.map +1 -0
  28. package/dist/billing-thresholds.js +238 -0
  29. package/dist/billing-thresholds.js.map +1 -0
  30. package/dist/billing.d.ts +95 -0
  31. package/dist/billing.d.ts.map +1 -0
  32. package/dist/billing.js +239 -0
  33. package/dist/billing.js.map +1 -0
  34. package/dist/clerk.d.ts +125 -0
  35. package/dist/clerk.d.ts.map +1 -0
  36. package/dist/clerk.js +389 -0
  37. package/dist/clerk.js.map +1 -0
  38. package/dist/client.d.ts +12 -15
  39. package/dist/client.d.ts.map +1 -1
  40. package/dist/client.js +56 -53
  41. package/dist/client.js.map +1 -1
  42. package/dist/credits.d.ts +165 -0
  43. package/dist/credits.d.ts.map +1 -0
  44. package/dist/credits.js +483 -0
  45. package/dist/credits.js.map +1 -0
  46. package/dist/index.d.ts +6 -11
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +74 -23
  49. package/dist/index.js.map +1 -1
  50. package/dist/intelligence.d.ts +2 -8
  51. package/dist/intelligence.d.ts.map +1 -1
  52. package/dist/intelligence.js +399 -154
  53. package/dist/intelligence.js.map +1 -1
  54. package/dist/list-recent-ocs-events.d.ts +48 -0
  55. package/dist/list-recent-ocs-events.d.ts.map +1 -0
  56. package/dist/list-recent-ocs-events.js +160 -0
  57. package/dist/list-recent-ocs-events.js.map +1 -0
  58. package/dist/manus-client.d.ts +429 -0
  59. package/dist/manus-client.d.ts.map +1 -0
  60. package/dist/manus-client.js +401 -0
  61. package/dist/manus-client.js.map +1 -0
  62. package/dist/manus-common.d.ts +5 -0
  63. package/dist/manus-common.d.ts.map +1 -0
  64. package/dist/manus-common.js +5 -0
  65. package/dist/manus-common.js.map +1 -0
  66. package/dist/manus-schedule.d.ts +74 -0
  67. package/dist/manus-schedule.d.ts.map +1 -0
  68. package/dist/manus-schedule.js +213 -0
  69. package/dist/manus-schedule.js.map +1 -0
  70. package/dist/manus-usage.d.ts +32 -0
  71. package/dist/manus-usage.d.ts.map +1 -0
  72. package/dist/manus-usage.js +120 -0
  73. package/dist/manus-usage.js.map +1 -0
  74. package/dist/manus-webhook.d.ts +60 -0
  75. package/dist/manus-webhook.d.ts.map +1 -0
  76. package/dist/manus-webhook.js +361 -0
  77. package/dist/manus-webhook.js.map +1 -0
  78. package/dist/pricing-tools.d.ts +26 -0
  79. package/dist/pricing-tools.d.ts.map +1 -0
  80. package/dist/pricing-tools.js +428 -0
  81. package/dist/pricing-tools.js.map +1 -0
  82. package/dist/projects-tools.d.ts +39 -0
  83. package/dist/projects-tools.d.ts.map +1 -0
  84. package/dist/projects-tools.js +545 -0
  85. package/dist/projects-tools.js.map +1 -0
  86. package/dist/prompts.d.ts +1 -0
  87. package/dist/prompts.d.ts.map +1 -1
  88. package/dist/prompts.js +1 -0
  89. package/dist/prompts.js.map +1 -1
  90. package/dist/stripe-connect-tools.d.ts +33 -0
  91. package/dist/stripe-connect-tools.d.ts.map +1 -0
  92. package/dist/stripe-connect-tools.js +504 -0
  93. package/dist/stripe-connect-tools.js.map +1 -0
  94. package/dist/timing-safe-equal.d.ts +5 -0
  95. package/dist/timing-safe-equal.d.ts.map +1 -0
  96. package/dist/timing-safe-equal.js +13 -0
  97. package/dist/timing-safe-equal.js.map +1 -0
  98. package/dist/tools-backlog.d.ts +37 -0
  99. package/dist/tools-backlog.d.ts.map +1 -0
  100. package/dist/tools-backlog.js +410 -0
  101. package/dist/tools-backlog.js.map +1 -0
  102. package/dist/tools-carrier-ask.d.ts +138 -0
  103. package/dist/tools-carrier-ask.d.ts.map +1 -0
  104. package/dist/tools-carrier-ask.js +913 -0
  105. package/dist/tools-carrier-ask.js.map +1 -0
  106. package/dist/tools-pricing.d.ts +18 -0
  107. package/dist/tools-pricing.d.ts.map +1 -0
  108. package/dist/tools-pricing.js +172 -0
  109. package/dist/tools-pricing.js.map +1 -0
  110. package/dist/tools-ui-agent-ask.d.ts +29 -0
  111. package/dist/tools-ui-agent-ask.d.ts.map +1 -0
  112. package/dist/tools-ui-agent-ask.js +272 -0
  113. package/dist/tools-ui-agent-ask.js.map +1 -0
  114. package/dist/tools-ui-agent-schedule.d.ts +29 -0
  115. package/dist/tools-ui-agent-schedule.d.ts.map +1 -0
  116. package/dist/tools-ui-agent-schedule.js +517 -0
  117. package/dist/tools-ui-agent-schedule.js.map +1 -0
  118. package/dist/tools-ui-agent.d.ts +40 -0
  119. package/dist/tools-ui-agent.d.ts.map +1 -0
  120. package/dist/tools-ui-agent.js +508 -0
  121. package/dist/tools-ui-agent.js.map +1 -0
  122. package/dist/tools.d.ts +44 -13
  123. package/dist/tools.d.ts.map +1 -1
  124. package/dist/tools.js +892 -210
  125. package/dist/tools.js.map +1 -1
  126. package/dist/types.d.ts +298 -0
  127. package/dist/types.d.ts.map +1 -0
  128. package/dist/types.js +5 -0
  129. package/dist/types.js.map +1 -0
  130. package/package.json +15 -8
@@ -1,13 +1,15 @@
1
- /**
2
- * Carrier MCP stdio — Intelligence tools (8 composites).
3
- *
4
- * These tools chain multiple OCS read methods to produce structured insights.
5
- * Stdio variant: no scope enforcement, no audit hook, no Sentry.
6
- */
7
1
  import { z } from "zod";
8
- async function safeCall(client, method, params = {}) {
2
+ import { mccToIso } from "@carrier/ocs-spec/mcc-iso";
3
+ import { OcsClient } from "./client.js";
4
+ import { getDefaultResellerId } from "./tools.js";
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+ async function safeCall(env, token, method, params = {}) {
9
9
  try {
10
- return { data: await client.call(method, params), error: null };
10
+ const client = new OcsClient(env.CARRIER_OCS_BASE_URL, token);
11
+ const data = await client.call(method, params);
12
+ return { data, error: null };
11
13
  }
12
14
  catch (err) {
13
15
  return { data: null, error: err instanceof Error ? err.message : String(err) };
@@ -21,7 +23,9 @@ function formatBytes(bytes) {
21
23
  return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
22
24
  }
23
25
  function daysUntil(dateStr) {
24
- return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
26
+ const now = new Date();
27
+ const target = new Date(dateStr);
28
+ return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
25
29
  }
26
30
  function toISODate(d) {
27
31
  return d.toISOString().split("T")[0];
@@ -29,60 +33,60 @@ function toISODate(d) {
29
33
  function result(text, isError = false) {
30
34
  return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
31
35
  }
32
- function makeIntelHandler(_client, fn) {
33
- return async (args) => {
34
- try {
35
- return await fn(args);
36
- }
37
- catch (err) {
38
- return result(`Error: ${err instanceof Error ? err.message : String(err)}`, true);
39
- }
40
- };
41
- }
42
- export function registerAllIntelligenceTools(server, client) {
43
- // -------------------------------------------------------------------------
44
- // 1. DIAGNOSE SUBSCRIBER
45
- // -------------------------------------------------------------------------
36
+ // ---------------------------------------------------------------------------
37
+ // 1. DIAGNOSE SUBSCRIBER — "why is this subscriber offline?"
38
+ // ---------------------------------------------------------------------------
39
+ export function registerIntelligenceTools(server, ctx) {
46
40
  server.registerTool("diagnose_subscriber", {
47
41
  title: "Diagnose Subscriber Issues",
48
- description: "Smart diagnostic that chains multiple API calls to analyse why a subscriber may be offline, throttled, or having connectivity issues.",
49
- inputSchema: { iccid: z.string().describe("The subscriber ICCID to diagnose") },
42
+ description: "Smart diagnostic that chains multiple API calls to analyze why a subscriber " +
43
+ "may be offline, throttled, or having connectivity issues. Returns a structured " +
44
+ "diagnosis with root cause analysis and recommended actions.",
45
+ inputSchema: {
46
+ iccid: z.string().describe("The subscriber ICCID to diagnose"),
47
+ },
50
48
  annotations: { readOnlyHint: true },
51
- }, makeIntelHandler(client, async ({ iccid }) => {
49
+ }, async ({ iccid }) => {
50
+ const token = await ctx.getUserToken(ctx.props.sub);
52
51
  const findings = [];
53
52
  const actions = [];
54
53
  let severity = "healthy";
55
- const sub = await safeCall(client, "getSingleSubscriber", { iccid });
54
+ // 1. Get subscriber details
55
+ const sub = await safeCall(ctx.env, token, "getSingleSubscriber", { iccid });
56
56
  if (sub.error)
57
57
  return result(`Failed to fetch subscriber: ${sub.error}`, true);
58
58
  if (!sub.data)
59
59
  return result("Subscriber not found", true);
60
60
  const status = String(sub.data.status ?? "").toUpperCase();
61
61
  const balance = Number(sub.data.balance ?? 0);
62
+ // Check OCS status
62
63
  if (status !== "ACTIVE") {
63
64
  findings.push(`OCS status is ${status} (not ACTIVE)`);
64
- actions.push("Reactivate subscriber via modify_subscriber_status");
65
+ actions.push(`Reactivate subscriber via modify_subscriber_status`);
65
66
  severity = "critical";
66
67
  }
68
+ // Check balance
67
69
  if (balance <= 0) {
68
70
  findings.push(`Balance is ${balance} — subscriber may be blocked from usage`);
69
- actions.push("Top up balance via modify_subscriber_balance");
71
+ actions.push(`Top up balance via modify_subscriber_balance`);
70
72
  if (severity !== "critical")
71
73
  severity = "warning";
72
74
  }
73
- const simIdForStatus = sub.data.simId ?? sub.data.sim_id ?? sub.data.id;
74
- const sim = simIdForStatus !== undefined
75
- ? await safeCall(client, "getSimProviderStatus", Number(simIdForStatus))
75
+ // 2. Check SIM provider status — OCS expects bare simId (Long), resolve from subscriber record
76
+ const simId = sub.data.simId ?? sub.data.sim_id ?? sub.data.id;
77
+ const sim = simId !== undefined
78
+ ? await safeCall(ctx.env, token, "getSimProviderStatus", Number(simId))
76
79
  : { data: null, error: null };
77
80
  if (sim.data) {
78
81
  const simStatus = String(sim.data.simStatus ?? sim.data.status ?? "").toUpperCase();
79
82
  if (simStatus && !["ENABLED", "ACTIVE", "ACTIVATED"].includes(simStatus)) {
80
83
  findings.push(`SIM provider status is ${simStatus} — SIM may be disabled at network level`);
81
- actions.push("Enable SIM via change_sim_status");
84
+ actions.push(`Enable SIM via change_sim_status`);
82
85
  severity = "critical";
83
86
  }
84
87
  }
85
- const pkgs = await safeCall(client, "listSubscriberPrepaidPackages", { iccid });
88
+ // 3. Check packages
89
+ const pkgs = await safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid });
86
90
  if (pkgs.data && Array.isArray(pkgs.data)) {
87
91
  const activePkgs = pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
88
92
  if (activePkgs.length === 0) {
@@ -91,15 +95,18 @@ export function registerAllIntelligenceTools(server, client) {
91
95
  severity = "critical";
92
96
  }
93
97
  else {
98
+ // Check for depleted packages
94
99
  for (const pkg of activePkgs) {
95
100
  const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
96
101
  const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
97
102
  if (dataLimit > 0 && dataUsed >= dataLimit) {
98
- findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" data depleted: ${formatBytes(dataUsed)} / ${formatBytes(dataLimit)}`);
103
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" data depleted: ` +
104
+ `${formatBytes(dataUsed)} / ${formatBytes(dataLimit)}`);
99
105
  actions.push("Assign additional package or increase limits via modify_package_limits");
100
106
  if (severity !== "critical")
101
107
  severity = "warning";
102
108
  }
109
+ // Check expiry
103
110
  const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
104
111
  if (expiry) {
105
112
  const days = daysUntil(expiry);
@@ -119,9 +126,13 @@ export function registerAllIntelligenceTools(server, client) {
119
126
  }
120
127
  }
121
128
  }
129
+ // 4. Check recent network events (last 2 days)
122
130
  const now = new Date();
123
131
  const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
124
- const events = await safeCall(client, "subscriberNetworkEventsOverPeriod", { subscriber: { iccid }, period: { start: toISODate(twoDaysAgo), end: toISODate(now) } });
132
+ const events = await safeCall(ctx.env, token, "subscriberNetworkEventsOverPeriod", {
133
+ subscriber: { iccid },
134
+ period: { start: toISODate(twoDaysAgo), end: toISODate(now) },
135
+ });
125
136
  if (events.data && Array.isArray(events.data)) {
126
137
  if (events.data.length === 0) {
127
138
  findings.push("No network events in last 48 hours — device may be powered off or out of coverage");
@@ -134,9 +145,10 @@ export function registerAllIntelligenceTools(server, client) {
134
145
  findings.push(`Last network event: ${lastType} at ${lastEvent.timestamp ?? lastEvent.date ?? "unknown"}`);
135
146
  }
136
147
  }
148
+ // 5. Check HLR bitrate — OCS expects { imsi }, resolve from subscriber record
137
149
  const imsiForBitrate = typeof sub.data.imsi === "string" ? sub.data.imsi : null;
138
150
  const bitrate = imsiForBitrate
139
- ? await safeCall(client, "hlrGetBitrate", { imsi: imsiForBitrate })
151
+ ? await safeCall(ctx.env, token, "hlrGetBitrate", { imsi: imsiForBitrate })
140
152
  : { data: null, error: null };
141
153
  if (bitrate.data) {
142
154
  const rate = Number(bitrate.data.bitrate ?? bitrate.data.maxBitrate ?? 0);
@@ -147,12 +159,11 @@ export function registerAllIntelligenceTools(server, client) {
147
159
  severity = "info";
148
160
  }
149
161
  }
150
- if (findings.length === 0)
162
+ // Build report
163
+ if (findings.length === 0) {
151
164
  findings.push("No issues detected — subscriber appears healthy");
152
- const activeCount = pkgs.data && Array.isArray(pkgs.data)
153
- ? pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE").length
154
- : "unknown";
155
- return result([
165
+ }
166
+ const report = [
156
167
  `# Subscriber Diagnosis: ${iccid}`,
157
168
  ``,
158
169
  `## Severity: ${severity.toUpperCase()}`,
@@ -160,30 +171,39 @@ export function registerAllIntelligenceTools(server, client) {
160
171
  `## Findings`,
161
172
  ...findings.map((f, i) => `${i + 1}. ${f}`),
162
173
  ``,
163
- ...(actions.length > 0 ? [`## Recommended Actions`, ...actions.map((a, i) => `${i + 1}. ${a}`)] : []),
174
+ ...(actions.length > 0 ? [
175
+ `## Recommended Actions`,
176
+ ...actions.map((a, i) => `${i + 1}. ${a}`),
177
+ ] : []),
164
178
  ``,
165
179
  `## Raw Status`,
166
180
  `- OCS Status: ${status}`,
167
181
  `- Balance: ${balance}`,
168
- `- Active Packages: ${activeCount}`,
169
- ].join("\n"));
170
- }));
171
- // -------------------------------------------------------------------------
172
- // 2. FLEET HEALTH
173
- // -------------------------------------------------------------------------
182
+ `- Active Packages: ${pkgs.data && Array.isArray(pkgs.data) ? pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE").length : "unknown"}`,
183
+ ].join("\n");
184
+ return result(report);
185
+ });
186
+ // ---------------------------------------------------------------------------
187
+ // 2. FLEET HEALTH — single-call fleet overview
188
+ // ---------------------------------------------------------------------------
174
189
  server.registerTool("fleet_health", {
175
190
  title: "Fleet Health Dashboard",
176
- description: "Aggregates eSIM status counts, low-balance accounts, and provides a fleet-wide health summary in a single call.",
191
+ description: "Aggregates eSIM status counts, low-balance accounts, and provides a " +
192
+ "fleet-wide health summary in a single call. Identifies accounts that " +
193
+ "need attention.",
177
194
  inputSchema: {
178
195
  accountId: z.number().optional().describe("Filter to a specific account (omit for all)"),
179
196
  },
180
197
  annotations: { readOnlyHint: true },
181
- }, makeIntelHandler(client, async ({ accountId }) => {
198
+ }, async ({ accountId }) => {
199
+ const token = await ctx.getUserToken(ctx.props.sub);
200
+ // Parallel fetch
182
201
  const [statusResult, accountsResult] = await Promise.all([
183
- safeCall(client, "esimStatusPerAccount", accountId !== undefined ? { accountId } : {}),
184
- safeCall(client, "listResellerAccount", {}),
202
+ safeCall(ctx.env, token, "esimStatusPerAccount", accountId !== undefined ? { accountId } : {}),
203
+ safeCall(ctx.env, token, "listResellerAccount", {}),
185
204
  ]);
186
205
  const sections = ["# Fleet Health Dashboard\n"];
206
+ // eSIM status breakdown
187
207
  if (statusResult.data && Array.isArray(statusResult.data)) {
188
208
  let totalActive = 0, totalSuspended = 0, totalInventory = 0, totalOther = 0;
189
209
  for (const account of statusResult.data) {
@@ -204,9 +224,10 @@ export function registerAllIntelligenceTools(server, client) {
204
224
  sections.push(`| **Total** | **${total}** | |`);
205
225
  sections.push(`\n**Fleet Utilization: ${utilization}%**`);
206
226
  if (totalSuspended > totalActive * 0.1) {
207
- sections.push(`\nHigh suspension rate (${totalSuspended} suspended vs ${totalActive} active)`);
227
+ sections.push(`\n⚠ High suspension rate (${totalSuspended} suspended vs ${totalActive} active)`);
208
228
  }
209
229
  }
230
+ // Account balances
210
231
  if (accountsResult.data && Array.isArray(accountsResult.data)) {
211
232
  const lowBalance = accountsResult.data.filter((a) => Number(a.balance ?? 0) < 10);
212
233
  if (lowBalance.length > 0) {
@@ -222,41 +243,54 @@ export function registerAllIntelligenceTools(server, client) {
222
243
  sections.push(`- Low balance (< 10): ${lowBalance.length}`);
223
244
  }
224
245
  return result(sections.join("\n"));
225
- }));
226
- // -------------------------------------------------------------------------
227
- // 3. DETECT USAGE ANOMALIES
228
- // -------------------------------------------------------------------------
246
+ });
247
+ // ---------------------------------------------------------------------------
248
+ // 3. USAGE ANOMALY DETECTION
249
+ // ---------------------------------------------------------------------------
229
250
  server.registerTool("detect_usage_anomalies", {
230
251
  title: "Detect Usage Anomalies",
231
- description: "Analyses recent usage to detect anomalies: spikes, off-hours activity, or consumption exhausting the package before expiry.",
232
- inputSchema: { iccid: z.string().describe("The subscriber ICCID to analyse") },
252
+ description: "Analyzes a subscriber's recent usage patterns over the last 7 days to detect " +
253
+ "anomalies: sudden spikes, unusual off-hours activity, or consumption rates that " +
254
+ "would exhaust the package before expiry.",
255
+ inputSchema: {
256
+ iccid: z.string().describe("The subscriber ICCID to analyze"),
257
+ },
233
258
  annotations: { readOnlyHint: true },
234
- }, makeIntelHandler(client, async ({ iccid }) => {
259
+ }, async ({ iccid }) => {
260
+ const token = await ctx.getUserToken(ctx.props.sub);
235
261
  const now = new Date();
236
262
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
237
263
  const [usageResult, pkgResult] = await Promise.all([
238
- safeCall(client, "subscriberUsageOverPeriod", { subscriber: { iccid }, period: { start: toISODate(weekAgo), end: toISODate(now) } }),
239
- safeCall(client, "listSubscriberPrepaidPackages", { iccid }),
264
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
265
+ subscriber: { iccid },
266
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
267
+ }),
268
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
240
269
  ]);
241
270
  if (usageResult.error)
242
271
  return result(`Failed to fetch usage: ${usageResult.error}`, true);
243
272
  const sections = [`# Usage Anomaly Report: ${iccid}\n`];
244
273
  const anomalies = [];
245
274
  if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
246
- const dailyData = usageResult.data.map((entry) => ({
247
- date: String(entry.date ?? entry.day ?? "?"),
248
- bytes: Number(entry.dataBytes ?? entry.dataVolume ?? entry.totalData ?? 0),
249
- }));
275
+ // Extract daily data volumes
276
+ const dailyData = [];
277
+ for (const entry of usageResult.data) {
278
+ const bytes = Number(entry.dataBytes ?? entry.dataVolume ?? entry.totalData ?? 0);
279
+ const date = String(entry.date ?? entry.day ?? "?");
280
+ dailyData.push({ date, bytes });
281
+ }
250
282
  if (dailyData.length >= 2) {
251
- const volumes = dailyData.map((d) => d.bytes);
283
+ // Calculate stats
284
+ const volumes = dailyData.map(d => d.bytes);
252
285
  const mean = volumes.reduce((a, b) => a + b, 0) / volumes.length;
253
286
  const stdDev = Math.sqrt(volumes.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / volumes.length);
254
287
  sections.push(`## Daily Usage (Last 7 Days)`);
255
288
  sections.push(`| Date | Data | vs Average |`);
256
289
  sections.push(`|------|------|-----------|`);
257
290
  for (const d of dailyData) {
258
- const deviation = mean > 0 ? (((d.bytes - mean) / mean) * 100).toFixed(0) : "0";
259
- const flag = d.bytes > mean + 2 * stdDev ? " SPIKE" : d.bytes > mean + stdDev ? " HIGH" : "";
291
+ const deviation = mean > 0 ? ((d.bytes - mean) / mean * 100).toFixed(0) : "0";
292
+ const flag = d.bytes > mean + 2 * stdDev ? " SPIKE" :
293
+ d.bytes > mean + stdDev ? " HIGH" : "";
260
294
  sections.push(`| ${d.date} | ${formatBytes(d.bytes)} | ${deviation}%${flag} |`);
261
295
  if (d.bytes > mean + 2 * stdDev) {
262
296
  anomalies.push(`Spike on ${d.date}: ${formatBytes(d.bytes)} (${deviation}% above average)`);
@@ -264,6 +298,7 @@ export function registerAllIntelligenceTools(server, client) {
264
298
  }
265
299
  sections.push(`\n**Average daily usage: ${formatBytes(mean)}**`);
266
300
  sections.push(`**Std deviation: ${formatBytes(stdDev)}**`);
301
+ // Burn rate analysis against active packages
267
302
  if (pkgResult.data && Array.isArray(pkgResult.data)) {
268
303
  const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
269
304
  for (const pkg of activePkgs) {
@@ -279,7 +314,8 @@ export function registerAllIntelligenceTools(server, client) {
279
314
  sections.push(`- Days until expiry: ${daysLeft}`);
280
315
  sections.push(`- At current rate, data exhausts in: ${daysToExhaust.toFixed(1)} days`);
281
316
  if (daysToExhaust < daysLeft * 0.5) {
282
- anomalies.push(`Package "${pkg.name ?? pkg.packageTemplateId}" will run out ${(daysLeft - daysToExhaust).toFixed(0)} days before expiry at current consumption`);
317
+ anomalies.push(`Package "${pkg.name ?? pkg.packageTemplateId}" will run out ` +
318
+ `${(daysLeft - daysToExhaust).toFixed(0)} days before expiry at current consumption`);
283
319
  }
284
320
  }
285
321
  }
@@ -297,24 +333,33 @@ export function registerAllIntelligenceTools(server, client) {
297
333
  sections.push(`\n## No anomalies detected — usage appears normal.`);
298
334
  }
299
335
  return result(sections.join("\n"));
300
- }));
301
- // -------------------------------------------------------------------------
302
- // 4. OPTIMIZE PACKAGE
303
- // -------------------------------------------------------------------------
336
+ });
337
+ // ---------------------------------------------------------------------------
338
+ // 4. PACKAGE OPTIMIZER — recommend better-fit packages
339
+ // ---------------------------------------------------------------------------
304
340
  server.registerTool("optimize_package", {
305
341
  title: "Package Optimization Advisor",
306
- description: "Compares a subscriber's usage against their current package and all available templates; recommends better-fit packages.",
307
- inputSchema: { iccid: z.string().describe("The subscriber ICCID to optimise") },
342
+ description: "Compares a subscriber's actual usage against their current package and all " +
343
+ "available templates. Recommends better-fit packages to reduce waste or prevent " +
344
+ "overages. Calculates potential savings.",
345
+ inputSchema: {
346
+ iccid: z.string().describe("The subscriber ICCID to optimize"),
347
+ },
308
348
  annotations: { readOnlyHint: true },
309
- }, makeIntelHandler(client, async ({ iccid }) => {
349
+ }, async ({ iccid }) => {
350
+ const token = await ctx.getUserToken(ctx.props.sub);
310
351
  const now = new Date();
311
352
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
312
353
  const [usageResult, pkgResult, templatesResult] = await Promise.all([
313
- safeCall(client, "subscriberUsageOverPeriod", { subscriber: { iccid }, period: { start: toISODate(weekAgo), end: toISODate(now) } }),
314
- safeCall(client, "listSubscriberPrepaidPackages", { iccid }),
315
- safeCall(client, "listPrepaidPackageTemplate", {}),
354
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
355
+ subscriber: { iccid },
356
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
357
+ }),
358
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
359
+ safeCall(ctx.env, token, "listPrepaidPackageTemplate", {}),
316
360
  ]);
317
361
  const sections = [`# Package Optimization: ${iccid}\n`];
362
+ // Calculate average daily usage
318
363
  let avgDailyData = 0;
319
364
  if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
320
365
  const totalData = usageResult.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
@@ -323,6 +368,7 @@ export function registerAllIntelligenceTools(server, client) {
323
368
  sections.push(`- Average daily data: ${formatBytes(avgDailyData)}`);
324
369
  sections.push(`- Projected monthly: ${formatBytes(avgDailyData * 30)}`);
325
370
  }
371
+ // Current packages
326
372
  if (pkgResult.data && Array.isArray(pkgResult.data)) {
327
373
  const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
328
374
  if (activePkgs.length > 0) {
@@ -339,6 +385,7 @@ export function registerAllIntelligenceTools(server, client) {
339
385
  const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
340
386
  if (expiry)
341
387
  sections.push(`- Expires: ${expiry} (${daysUntil(expiry)} days)`);
388
+ // Flag waste
342
389
  if (dataLimit > 0 && Number(utilization) < 30) {
343
390
  sections.push(`- LOW UTILIZATION — subscriber is using less than 30% of allowance`);
344
391
  }
@@ -348,16 +395,21 @@ export function registerAllIntelligenceTools(server, client) {
348
395
  }
349
396
  }
350
397
  }
398
+ // Recommend templates
351
399
  if (templatesResult.data && Array.isArray(templatesResult.data) && avgDailyData > 0) {
400
+ // Score templates by fit
352
401
  const scored = templatesResult.data
353
402
  .map((t) => {
354
403
  const limit = Number(t.dataLimit ?? t.dataAllowance ?? 0);
355
404
  const validity = Number(t.validityDays ?? t.duration ?? 30);
356
405
  const price = Number(t.price ?? t.cost ?? 0);
357
406
  const projectedUsage = avgDailyData * validity;
407
+ // Fit score: penalize both waste (too much data) and shortage (too little)
358
408
  const ratio = limit > 0 ? projectedUsage / limit : 0;
359
- const fitScore = 1 - Math.abs(1 - ratio);
360
- return { name: String(t.name ?? t.templateId ?? "?"), limit, validity, price, projectedUsage, fitScore };
409
+ const fitScore = 1 - Math.abs(1 - ratio); // 1.0 = perfect fit, 0 = terrible
410
+ const costPerGB = limit > 0 && price > 0 ? price / (limit / (1024 * 1024 * 1024)) : Infinity;
411
+ const name = String(t.name ?? t.templateId ?? "?");
412
+ return { name, limit, validity, price, projectedUsage, fitScore, costPerGB, ratio };
361
413
  })
362
414
  .filter((t) => t.fitScore > 0.3 && t.limit > 0)
363
415
  .sort((a, b) => b.fitScore - a.fitScore)
@@ -368,7 +420,9 @@ export function registerAllIntelligenceTools(server, client) {
368
420
  sections.push(`|----------|------|----------|-------|-----|--------------|`);
369
421
  for (const t of scored) {
370
422
  const fitLabel = t.fitScore > 0.8 ? "GREAT" : t.fitScore > 0.6 ? "GOOD" : "OK";
371
- sections.push(`| ${t.name} | ${formatBytes(t.limit)} | ${t.validity}d | ${t.price > 0 ? t.price.toFixed(2) : "?"} | ${fitLabel} (${(t.fitScore * 100).toFixed(0)}%) | ${formatBytes(t.projectedUsage)} |`);
423
+ sections.push(`| ${t.name} | ${formatBytes(t.limit)} | ` +
424
+ `${t.validity}d | ${t.price > 0 ? t.price.toFixed(2) : "?"} | ` +
425
+ `${fitLabel} (${(t.fitScore * 100).toFixed(0)}%) | ${formatBytes(t.projectedUsage)} |`);
372
426
  }
373
427
  }
374
428
  }
@@ -376,26 +430,35 @@ export function registerAllIntelligenceTools(server, client) {
376
430
  sections.push(`\n*No usage data available — cannot recommend packages without usage history.*`);
377
431
  }
378
432
  return result(sections.join("\n"));
379
- }));
380
- // -------------------------------------------------------------------------
381
- // 5. CHURN RISK
382
- // -------------------------------------------------------------------------
433
+ });
434
+ // ---------------------------------------------------------------------------
435
+ // 5. CHURN RISK SCORING
436
+ // ---------------------------------------------------------------------------
383
437
  server.registerTool("churn_risk", {
384
438
  title: "Churn Risk Assessment",
385
- description: "Analyses usage trends, package status, balance, and activity to produce a churn risk score (0-100) with retention recommendations.",
386
- inputSchema: { iccid: z.string().describe("The subscriber ICCID to assess") },
439
+ description: "Analyzes a subscriber's usage trends, package status, balance, and activity " +
440
+ "to produce a churn risk score (0-100) with contributing factors and retention " +
441
+ "recommendations.",
442
+ inputSchema: {
443
+ iccid: z.string().describe("The subscriber ICCID to assess"),
444
+ },
387
445
  annotations: { readOnlyHint: true },
388
- }, makeIntelHandler(client, async ({ iccid }) => {
446
+ }, async ({ iccid }) => {
447
+ const token = await ctx.getUserToken(ctx.props.sub);
389
448
  const now = new Date();
390
449
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
391
450
  const [subResult, usageResult, pkgResult, activeResult] = await Promise.all([
392
- safeCall(client, "getSingleSubscriber", { iccid }),
393
- safeCall(client, "subscriberUsageOverPeriod", { subscriber: { iccid }, period: { start: toISODate(weekAgo), end: toISODate(now) } }),
394
- safeCall(client, "listSubscriberPrepaidPackages", { iccid }),
395
- safeCall(client, "getSubscriberActivePeriod", { iccid }),
451
+ safeCall(ctx.env, token, "getSingleSubscriber", { iccid }),
452
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
453
+ subscriber: { iccid },
454
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
455
+ }),
456
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
457
+ safeCall(ctx.env, token, "getSubscriberActivePeriod", { iccid }),
396
458
  ]);
397
459
  let riskScore = 0;
398
460
  const factors = [];
461
+ // Factor 1: Usage trend (declining usage = higher risk)
399
462
  if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length >= 3) {
400
463
  const volumes = usageResult.data.map((e) => Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0));
401
464
  const firstHalf = volumes.slice(0, Math.floor(volumes.length / 2));
@@ -405,12 +468,14 @@ export function registerAllIntelligenceTools(server, client) {
405
468
  if (avgFirst > 0) {
406
469
  const trend = (avgSecond - avgFirst) / avgFirst;
407
470
  if (trend < -0.5) {
408
- riskScore += 30;
409
- factors.push({ factor: "Declining usage", impact: 30, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}% week-over-week` });
471
+ const impact = 30;
472
+ riskScore += impact;
473
+ factors.push({ factor: "Declining usage", impact, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}% week-over-week` });
410
474
  }
411
475
  else if (trend < -0.2) {
412
- riskScore += 15;
413
- factors.push({ factor: "Moderately declining usage", impact: 15, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}%` });
476
+ const impact = 15;
477
+ riskScore += impact;
478
+ factors.push({ factor: "Moderately declining usage", impact, detail: `Usage dropped ${Math.abs(trend * 100).toFixed(0)}%` });
414
479
  }
415
480
  }
416
481
  }
@@ -418,6 +483,7 @@ export function registerAllIntelligenceTools(server, client) {
418
483
  riskScore += 25;
419
484
  factors.push({ factor: "No recent usage", impact: 25, detail: "Zero data activity in last 7 days" });
420
485
  }
486
+ // Factor 2: Package status
421
487
  if (pkgResult.data && Array.isArray(pkgResult.data)) {
422
488
  const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
423
489
  if (activePkgs.length === 0) {
@@ -425,6 +491,7 @@ export function registerAllIntelligenceTools(server, client) {
425
491
  factors.push({ factor: "No active packages", impact: 20, detail: "Subscriber has no active data packages" });
426
492
  }
427
493
  else {
494
+ // Check if all packages are near expiry
428
495
  const allExpiringSoon = activePkgs.every((p) => {
429
496
  const expiry = String(p.expirationDate ?? p.endDate ?? "");
430
497
  return expiry && daysUntil(expiry) <= 5;
@@ -433,6 +500,7 @@ export function registerAllIntelligenceTools(server, client) {
433
500
  riskScore += 15;
434
501
  factors.push({ factor: "All packages expiring soon", impact: 15, detail: "No package renewal in sight" });
435
502
  }
503
+ // Check if no recurring packages
436
504
  const hasRecurring = activePkgs.some((p) => p.recurring === true || p.isRecurring === true);
437
505
  if (!hasRecurring) {
438
506
  riskScore += 10;
@@ -440,6 +508,7 @@ export function registerAllIntelligenceTools(server, client) {
440
508
  }
441
509
  }
442
510
  }
511
+ // Factor 3: Balance
443
512
  if (subResult.data) {
444
513
  const balance = Number(subResult.data.balance ?? 0);
445
514
  if (balance <= 0) {
@@ -447,6 +516,7 @@ export function registerAllIntelligenceTools(server, client) {
447
516
  factors.push({ factor: "Zero balance", impact: 15, detail: "Cannot purchase new packages" });
448
517
  }
449
518
  }
519
+ // Factor 4: Subscriber age (newer = higher risk)
450
520
  if (activeResult.data) {
451
521
  const firstUse = String(activeResult.data.firstUseDate ?? activeResult.data.activationDate ?? "");
452
522
  if (firstUse) {
@@ -457,14 +527,17 @@ export function registerAllIntelligenceTools(server, client) {
457
527
  }
458
528
  }
459
529
  }
530
+ // Cap at 100
460
531
  riskScore = Math.min(riskScore, 100);
532
+ // Risk level
461
533
  const level = riskScore >= 70 ? "HIGH" : riskScore >= 40 ? "MEDIUM" : "LOW";
534
+ // Build report
462
535
  const sections = [
463
536
  `# Churn Risk Assessment: ${iccid}`,
464
537
  ``,
465
538
  `## Risk Score: ${riskScore}/100 (${level})`,
466
539
  ``,
467
- `${"#".repeat(Math.floor(riskScore / 5))}${"-".repeat(20 - Math.floor(riskScore / 5))}`,
540
+ `${"".repeat(Math.floor(riskScore / 5))}${"".repeat(20 - Math.floor(riskScore / 5))}`,
468
541
  ``,
469
542
  ];
470
543
  if (factors.length > 0) {
@@ -476,9 +549,10 @@ export function registerAllIntelligenceTools(server, client) {
476
549
  sections.push(`| ${f.factor} | +${f.impact} | ${f.detail} |`);
477
550
  }
478
551
  }
552
+ // Retention recommendations
479
553
  sections.push(`\n## Retention Recommendations`);
480
554
  if (riskScore >= 70) {
481
- sections.push("1. Immediate outreach — contact subscriber with special offer");
555
+ sections.push("1. **Immediate outreach** — contact subscriber with special offer");
482
556
  sections.push("2. Assign a complimentary small data package to re-engage");
483
557
  sections.push("3. Set up a recurring package to reduce renewal friction");
484
558
  }
@@ -492,56 +566,74 @@ export function registerAllIntelligenceTools(server, client) {
492
566
  sections.push("2. Continue monitoring via regular fleet_health checks");
493
567
  }
494
568
  return result(sections.join("\n"));
495
- }));
496
- // -------------------------------------------------------------------------
497
- // 6. AUDIT NETWORK COVERAGE
498
- // -------------------------------------------------------------------------
569
+ });
570
+ // ---------------------------------------------------------------------------
571
+ // 6. NETWORK COVERAGE AUDIT "am I on the right networks?"
572
+ // ---------------------------------------------------------------------------
499
573
  server.registerTool("audit_network_coverage", {
500
574
  title: "Network Coverage Audit",
501
- description: "Analyses which networks subscribers connect to vs steering lists; flags expensive/non-preferred network roaming.",
575
+ description: "Analyzes which networks your subscribers are actually connecting to in a given " +
576
+ "country or across all countries. Compares against your steering lists to identify " +
577
+ "mismatches — subscribers roaming on expensive or non-preferred networks. " +
578
+ "Use this to answer: 'Am I using the right networks in country X?'",
502
579
  inputSchema: {
503
580
  accountId: z.number().optional().describe("Filter to a specific account"),
504
581
  limit: z.number().optional().describe("Max subscribers to sample (default 50)"),
505
582
  },
506
583
  annotations: { readOnlyHint: true },
507
- }, makeIntelHandler(client, async ({ accountId, limit: sampleLimit }) => {
584
+ }, async ({ accountId, limit: sampleLimit }) => {
585
+ const token = await ctx.getUserToken(ctx.props.sub);
508
586
  const maxSample = sampleLimit ?? 50;
509
- const subParams = { limit: maxSample, status: "ACTIVE" };
587
+ // Fetch subscribers and steering lists in parallel
588
+ const subParams = { status: "ACTIVE" };
510
589
  if (accountId !== undefined)
511
590
  subParams.accountId = accountId;
512
- const [subsResult, steeringResult] = await Promise.all([
513
- safeCall(client, "listSubscriber", subParams),
514
- safeCall(client, "listSteeringList", {}),
591
+ const [subsResult, resellerId] = await Promise.all([
592
+ safeCall(ctx.env, token, "listSubscriber", subParams),
593
+ getDefaultResellerId(ctx.env, token),
515
594
  ]);
595
+ const steeringResult = await safeCall(ctx.env, token, "listSteeringList", resellerId);
516
596
  if (subsResult.error)
517
597
  return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
518
598
  const sections = [`# Network Coverage Audit\n`];
599
+ // Build steering list lookup
600
+ const steeringMap = new Map();
519
601
  if (steeringResult.data && Array.isArray(steeringResult.data)) {
520
- sections.push(`## Steering Lists: ${steeringResult.data.length} configured`);
602
+ for (const sl of steeringResult.data) {
603
+ steeringMap.set(Number(sl.steeringListId ?? sl.id), sl);
604
+ }
605
+ sections.push(`## Steering Lists: ${steeringMap.size} configured`);
521
606
  }
607
+ // Sample subscriber locations and networks
522
608
  const countryStats = new Map();
523
609
  if (subsResult.data && Array.isArray(subsResult.data)) {
524
- sections.push(`## Sampling ${subsResult.data.length} active subscribers\n`);
525
610
  const subs = subsResult.data.slice(0, maxSample);
611
+ sections.push(`## Sampling ${subs.length} active subscribers\n`);
612
+ // Fetch locations in batches of 10
526
613
  const batchSize = 10;
527
614
  for (let i = 0; i < subs.length; i += batchSize) {
528
615
  const batch = subs.slice(i, i + batchSize);
529
- const locations = await Promise.all(batch.map((s) => safeCall(client, "getSubscriberLocation", { iccid: String(s.iccid ?? "") })));
616
+ const locations = await Promise.all(batch.map((s) => safeCall(ctx.env, token, "getSubscriberLocation", {
617
+ iccid: String(s.iccid ?? ""),
618
+ })));
530
619
  for (let j = 0; j < batch.length; j++) {
531
620
  const loc = locations[j];
532
621
  if (loc.data) {
533
622
  const country = String(loc.data.country ?? loc.data.countryCode ?? "Unknown");
534
623
  const network = String(loc.data.network ?? loc.data.operator ?? loc.data.mccMnc ?? "Unknown");
624
+ const iccid = String(batch[j].iccid ?? "");
535
625
  if (!countryStats.has(country)) {
536
- countryStats.set(country, { count: 0, networks: new Map() });
626
+ countryStats.set(country, { count: 0, networks: new Map(), subscribers: [] });
537
627
  }
538
628
  const stat = countryStats.get(country);
539
629
  stat.count++;
540
630
  stat.networks.set(network, (stat.networks.get(network) ?? 0) + 1);
631
+ stat.subscribers.push(iccid);
541
632
  }
542
633
  }
543
634
  }
544
635
  }
636
+ // Report by country
545
637
  if (countryStats.size > 0) {
546
638
  sections.push(`## Network Distribution by Country`);
547
639
  const sorted = [...countryStats.entries()].sort((a, b) => b[1].count - a[1].count);
@@ -562,44 +654,56 @@ export function registerAllIntelligenceTools(server, client) {
562
654
  sections.push("No location data available for sampled subscribers.");
563
655
  }
564
656
  return result(sections.join("\n"));
565
- }));
566
- // -------------------------------------------------------------------------
567
- // 7. MARKETING INTELLIGENCE
568
- // -------------------------------------------------------------------------
657
+ });
658
+ // ---------------------------------------------------------------------------
659
+ // 7. MARKETING INTELLIGENCE — "which countries should I target?"
660
+ // ---------------------------------------------------------------------------
569
661
  server.registerTool("marketing_intelligence", {
570
662
  title: "Marketing Intelligence Report",
571
- description: "Identifies high-growth markets, underserved regions, and revenue concentration. Answers 'where should I target marketing?'",
663
+ description: "Analyzes your subscriber base to identify high-growth markets, underserved regions, " +
664
+ "and revenue concentration. Answers: 'Which countries should I target with marketing?' " +
665
+ "and 'Where are my most valuable subscribers?'",
572
666
  inputSchema: {
573
667
  accountId: z.number().optional().describe("Filter to a specific account"),
574
668
  },
575
669
  annotations: { readOnlyHint: true },
576
- }, makeIntelHandler(client, async ({ accountId }) => {
577
- const params = { limit: 200 };
670
+ }, async ({ accountId }) => {
671
+ const token = await ctx.getUserToken(ctx.props.sub);
672
+ const params = {};
578
673
  if (accountId !== undefined)
579
674
  params.accountId = accountId;
580
- const [subsResult, templatesResult] = await Promise.all([
581
- safeCall(client, "listSubscriber", params),
582
- safeCall(client, "listPrepaidPackageTemplate", {}),
675
+ const [subsResult, templatesResult, resellerId] = await Promise.all([
676
+ safeCall(ctx.env, token, "listSubscriber", params),
677
+ safeCall(ctx.env, token, "listPrepaidPackageTemplate", {}),
678
+ getDefaultResellerId(ctx.env, token),
583
679
  ]);
680
+ const zonesResult = await safeCall(ctx.env, token, "listDetailedLocationZone", resellerId);
584
681
  const sections = [`# Marketing Intelligence Report\n`];
682
+ // Subscriber distribution by location
585
683
  if (subsResult.data && Array.isArray(subsResult.data)) {
586
684
  const total = subsResult.data.length;
587
685
  sections.push(`## Fleet Size: ${total} subscribers sampled\n`);
686
+ // Get locations for a sample
588
687
  const sample = subsResult.data.slice(0, 100);
589
688
  const countryData = new Map();
590
689
  const batchSize = 10;
591
690
  for (let i = 0; i < sample.length; i += batchSize) {
592
691
  const batch = sample.slice(i, i + batchSize);
593
- const locations = await Promise.all(batch.map((s) => safeCall(client, "getSubscriberLocation", { iccid: String(s.iccid ?? "") })));
692
+ const locations = await Promise.all(batch.map((s) => safeCall(ctx.env, token, "getSubscriberLocation", {
693
+ iccid: String(s.iccid ?? ""),
694
+ })));
594
695
  for (let j = 0; j < batch.length; j++) {
595
696
  const loc = locations[j];
596
- const subItem = batch[j];
597
- const country = loc.data ? String(loc.data.country ?? loc.data.countryCode ?? "Unknown") : "Unknown";
598
- if (!countryData.has(country))
599
- countryData.set(country, { subscribers: 0, totalBalance: 0 });
697
+ const sub = batch[j];
698
+ const country = loc.data
699
+ ? String(loc.data.country ?? loc.data.countryCode ?? "Unknown")
700
+ : "Unknown";
701
+ if (!countryData.has(country)) {
702
+ countryData.set(country, { subscribers: 0, activePackages: 0, totalBalance: 0 });
703
+ }
600
704
  const cd = countryData.get(country);
601
705
  cd.subscribers++;
602
- cd.totalBalance += Number(subItem.balance ?? 0);
706
+ cd.totalBalance += Number(sub.balance ?? 0);
603
707
  }
604
708
  }
605
709
  if (countryData.size > 0) {
@@ -612,29 +716,55 @@ export function registerAllIntelligenceTools(server, client) {
612
716
  const avgBal = (data.totalBalance / data.subscribers).toFixed(2);
613
717
  sections.push(`| ${country} | ${data.subscribers} | ${pct}% | ${avgBal} |`);
614
718
  }
719
+ // Insights
615
720
  sections.push(`\n## Market Insights`);
721
+ // Top market
616
722
  const topMarket = sorted[0];
617
723
  if (topMarket) {
618
- sections.push(`- Strongest market: ${topMarket[0]} (${topMarket[1].subscribers} subscribers)`);
724
+ sections.push(`- **Strongest market**: ${topMarket[0]} (${topMarket[1].subscribers} subscribers)`);
619
725
  if (topMarket[1].subscribers / sample.length > 0.5) {
620
726
  sections.push(` Revenue concentration risk — >50% of fleet in one market`);
621
727
  }
622
728
  }
729
+ // High-value markets (high avg balance)
730
+ const highValue = sorted
731
+ .filter(([, d]) => d.subscribers >= 3)
732
+ .sort((a, b) => (b[1].totalBalance / b[1].subscribers) - (a[1].totalBalance / a[1].subscribers))
733
+ .slice(0, 3);
734
+ if (highValue.length > 0) {
735
+ sections.push(`\n### High-Value Markets (by avg balance)`);
736
+ for (const [country, data] of highValue) {
737
+ sections.push(`- **${country}**: avg balance ${(data.totalBalance / data.subscribers).toFixed(2)} (${data.subscribers} subs)`);
738
+ }
739
+ }
740
+ // Underserved (1-2 subscribers — early traction)
623
741
  const emerging = sorted.filter(([, d]) => d.subscribers >= 1 && d.subscribers <= 3);
624
742
  if (emerging.length > 0) {
625
- sections.push(`\n### Emerging Markets (1-3 subscribers)`);
743
+ sections.push(`\n### Emerging Markets (early traction, 1-3 subscribers)`);
744
+ sections.push(`These markets show initial demand — consider targeted campaigns:`);
626
745
  for (const [country, data] of emerging) {
627
746
  sections.push(`- ${country}: ${data.subscribers} subscriber(s)`);
628
747
  }
629
748
  }
630
749
  }
631
750
  }
751
+ // Available coverage vs actual usage
752
+ if (zonesResult.data && Array.isArray(zonesResult.data)) {
753
+ sections.push(`\n## Coverage Catalog`);
754
+ sections.push(`- Location zones available: ${zonesResult.data.length}`);
755
+ }
632
756
  if (templatesResult.data && Array.isArray(templatesResult.data)) {
633
- sections.push(`\n## Package Templates Available: ${templatesResult.data.length}`);
634
- const prices = templatesResult.data.map((t) => Number(t.price ?? t.cost ?? 0)).filter((p) => p > 0);
757
+ sections.push(`- Package templates available: ${templatesResult.data.length}`);
758
+ // Price analysis
759
+ const prices = templatesResult.data
760
+ .map((t) => Number(t.price ?? t.cost ?? 0))
761
+ .filter((p) => p > 0);
635
762
  if (prices.length > 0) {
636
763
  const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
637
- sections.push(`- Pricing range: ${Math.min(...prices).toFixed(2)} – ${Math.max(...prices).toFixed(2)} (avg ${avgPrice.toFixed(2)})`);
764
+ const minPrice = Math.min(...prices);
765
+ const maxPrice = Math.max(...prices);
766
+ sections.push(`\n### Pricing Range`);
767
+ sections.push(`- Min: ${minPrice.toFixed(2)} | Avg: ${avgPrice.toFixed(2)} | Max: ${maxPrice.toFixed(2)}`);
638
768
  }
639
769
  }
640
770
  sections.push(`\n## Recommended Actions`);
@@ -642,26 +772,29 @@ export function registerAllIntelligenceTools(server, client) {
642
772
  sections.push(`2. Run \`high_cost_subscribers\` to identify margin pressure`);
643
773
  sections.push(`3. Consider creating regional package templates for emerging markets`);
644
774
  return result(sections.join("\n"));
645
- }));
646
- // -------------------------------------------------------------------------
647
- // 8. HIGH COST SUBSCRIBERS
648
- // -------------------------------------------------------------------------
775
+ });
776
+ // ---------------------------------------------------------------------------
777
+ // 8. HIGH COST SUBSCRIBERS — "who's costing me money?"
778
+ // ---------------------------------------------------------------------------
649
779
  server.registerTool("high_cost_subscribers", {
650
780
  title: "High Cost Subscriber Report",
651
- description: "Identifies subscribers with disproportionately high data consumption relative to package value.",
781
+ description: "Identifies subscribers with disproportionately high data consumption relative " +
782
+ "to their package value. Finds subscribers burning through data at rates that " +
783
+ "erode margins. Answers: 'Which subscribers are costing me money?'",
652
784
  inputSchema: {
653
785
  accountId: z.number().optional().describe("Filter to a specific account"),
654
- limit: z.number().optional().describe("Max subscribers to analyse (default 100)"),
786
+ limit: z.number().optional().describe("Max subscribers to analyze (default 100)"),
655
787
  thresholdPct: z.number().optional().describe("Usage % threshold to flag (default 80)"),
656
788
  },
657
789
  annotations: { readOnlyHint: true },
658
- }, makeIntelHandler(client, async ({ accountId, limit: maxLimit, thresholdPct }) => {
790
+ }, async ({ accountId, limit: maxLimit, thresholdPct }) => {
791
+ const token = await ctx.getUserToken(ctx.props.sub);
659
792
  const sampleSize = maxLimit ?? 100;
660
793
  const threshold = thresholdPct ?? 80;
661
- const subParams = { limit: sampleSize, status: "ACTIVE" };
794
+ const subParams = { status: "ACTIVE" };
662
795
  if (accountId !== undefined)
663
796
  subParams.accountId = accountId;
664
- const subsResult = await safeCall(client, "listSubscriber", subParams);
797
+ const subsResult = await safeCall(ctx.env, token, "listSubscriber", subParams);
665
798
  if (subsResult.error)
666
799
  return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
667
800
  if (!subsResult.data || !Array.isArray(subsResult.data))
@@ -670,6 +803,7 @@ export function registerAllIntelligenceTools(server, client) {
670
803
  const now = new Date();
671
804
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
672
805
  const highCostSubs = [];
806
+ // Analyze in batches
673
807
  const batchSize = 5;
674
808
  const subs = subsResult.data.slice(0, sampleSize);
675
809
  for (let i = 0; i < subs.length; i += batchSize) {
@@ -677,15 +811,20 @@ export function registerAllIntelligenceTools(server, client) {
677
811
  await Promise.all(batch.map(async (sub) => {
678
812
  const iccid = String(sub.iccid ?? "");
679
813
  const [usage, pkgs, loc] = await Promise.all([
680
- safeCall(client, "subscriberUsageOverPeriod", { subscriber: { iccid }, period: { start: toISODate(weekAgo), end: toISODate(now) } }),
681
- safeCall(client, "listSubscriberPrepaidPackages", { iccid }),
682
- safeCall(client, "getSubscriberLocation", { iccid }),
814
+ safeCall(ctx.env, token, "subscriberUsageOverPeriod", {
815
+ subscriber: { iccid },
816
+ period: { start: toISODate(weekAgo), end: toISODate(now) },
817
+ }),
818
+ safeCall(ctx.env, token, "listSubscriberPrepaidPackages", { iccid }),
819
+ safeCall(ctx.env, token, "getSubscriberLocation", { iccid }),
683
820
  ]);
821
+ // Calculate daily avg
684
822
  let dailyAvgBytes = 0;
685
823
  if (usage.data && Array.isArray(usage.data) && usage.data.length > 0) {
686
824
  const totalBytes = usage.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
687
825
  dailyAvgBytes = totalBytes / usage.data.length;
688
826
  }
827
+ // Get active package info
689
828
  if (pkgs.data && Array.isArray(pkgs.data)) {
690
829
  const activePkg = pkgs.data.find((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
691
830
  if (activePkg && dailyAvgBytes > 0) {
@@ -695,17 +834,31 @@ export function registerAllIntelligenceTools(server, client) {
695
834
  const utilizationPct = dataLimit > 0 ? (dataUsed / dataLimit) * 100 : 0;
696
835
  const remaining = dataLimit - dataUsed;
697
836
  const daysToExhaust = dailyAvgBytes > 0 ? remaining / dailyAvgBytes : Infinity;
698
- const costPerGB = price > 0 && dataUsed > 0 ? price / (dataUsed / (1024 * 1024 * 1024)) : 0;
699
- const country = loc.data ? String(loc.data.country ?? loc.data.countryCode ?? "?") : "?";
837
+ const costPerGB = price > 0 && dataUsed > 0
838
+ ? price / (dataUsed / (1024 * 1024 * 1024))
839
+ : 0;
840
+ const country = loc.data
841
+ ? String(loc.data.country ?? loc.data.countryCode ?? "?")
842
+ : "?";
700
843
  if (utilizationPct >= threshold || daysToExhaust < 3) {
701
- highCostSubs.push({ iccid, dailyAvgBytes, packageDataLimit: dataLimit, packagePrice: price, utilizationPct, costPerGB, daysToExhaust, country });
844
+ highCostSubs.push({
845
+ iccid,
846
+ dailyAvgBytes,
847
+ packageDataLimit: dataLimit,
848
+ packagePrice: price,
849
+ utilizationPct,
850
+ costPerGB,
851
+ daysToExhaust,
852
+ country,
853
+ });
702
854
  }
703
855
  }
704
856
  }
705
857
  }));
706
858
  }
859
+ // Sort by utilization (highest first)
707
860
  highCostSubs.sort((a, b) => b.utilizationPct - a.utilizationPct);
708
- sections.push(`Analysed ${subs.length} active subscribers (threshold: ${threshold}% usage)\n`);
861
+ sections.push(`Analyzed ${subs.length} active subscribers (threshold: ${threshold}% usage)\n`);
709
862
  if (highCostSubs.length === 0) {
710
863
  sections.push(`No subscribers above ${threshold}% package utilization. Fleet margins look healthy.`);
711
864
  }
@@ -716,17 +869,109 @@ export function registerAllIntelligenceTools(server, client) {
716
869
  let totalDailyBytes = 0;
717
870
  for (const s of highCostSubs) {
718
871
  totalDailyBytes += s.dailyAvgBytes;
719
- sections.push(`| ${s.iccid.slice(-8)}... | ${s.country} | ${formatBytes(s.dailyAvgBytes)} | ${s.utilizationPct.toFixed(0)}% | ${s.daysToExhaust === Infinity ? "inf" : s.daysToExhaust.toFixed(1)} | ${s.costPerGB > 0 ? s.costPerGB.toFixed(2) : "?"} |`);
872
+ sections.push(`| ${s.iccid.slice(-8)}... | ${s.country} | ${formatBytes(s.dailyAvgBytes)} | ` +
873
+ `${s.utilizationPct.toFixed(0)}% | ${s.daysToExhaust === Infinity ? "inf" : s.daysToExhaust.toFixed(1)} | ` +
874
+ `${s.costPerGB > 0 ? s.costPerGB.toFixed(2) : "?"} |`);
720
875
  }
721
876
  sections.push(`\n## Summary`);
722
877
  sections.push(`- High-cost subscribers: ${highCostSubs.length} / ${subs.length} (${((highCostSubs.length / subs.length) * 100).toFixed(1)}%)`);
723
878
  sections.push(`- Combined daily data burn: ${formatBytes(totalDailyBytes)}`);
879
+ // Country breakdown
880
+ const byCountry = new Map();
881
+ for (const s of highCostSubs) {
882
+ byCountry.set(s.country, (byCountry.get(s.country) ?? 0) + 1);
883
+ }
884
+ const countrySorted = [...byCountry.entries()].sort((a, b) => b[1] - a[1]);
885
+ if (countrySorted.length > 0) {
886
+ sections.push(`\n### By Country`);
887
+ for (const [country, count] of countrySorted) {
888
+ sections.push(`- ${country}: ${count} high-cost subscriber(s)`);
889
+ }
890
+ }
724
891
  sections.push(`\n## Recommended Actions`);
725
892
  sections.push(`1. Review tariff rates for top countries via \`get_tariff\``);
726
893
  sections.push(`2. Consider throttling heavy users via \`hlr_set_bitrate\``);
727
894
  sections.push(`3. Run \`optimize_package\` on flagged ICCIDs to find better-fit plans`);
895
+ sections.push(`4. Negotiate better wholesale rates for high-volume countries`);
728
896
  }
729
897
  return result(sections.join("\n"));
730
- }));
898
+ });
899
+ // ---------------------------------------------------------------------------
900
+ // 9. DETECT COUNTRY ENTRY — cheap MCC-based location change detection
901
+ // ---------------------------------------------------------------------------
902
+ server.registerTool("detect_country_entry", {
903
+ title: "Detect Country Entry",
904
+ description: "Detects when a subscriber has entered a new country by reading " +
905
+ "networkInfo.lastMcc from getSingleSubscriber (one cheap OCS call — " +
906
+ "avoids the per-call cost of getSubscriberLocationByCellId). Resolves " +
907
+ "MCC → ISO 3166-1 alpha-2 and optionally diffs against a caller-supplied " +
908
+ "expectedCountry to return countryChanged. Designed for downstream " +
909
+ "country-entry upsell workflows (e.g. mango.talk SMS/push offers). " +
910
+ "COST NOTE: This tool makes exactly one OCS call per invocation. " +
911
+ "Consumers running polling crons MUST enforce their own rate floor — " +
912
+ "this layer provides no throttle.",
913
+ inputSchema: {
914
+ subscriber: z
915
+ .union([
916
+ z.object({ subscriberId: z.number() }).describe("Internal subscriber ID"),
917
+ z.object({ imsi: z.string() }).describe("IMSI"),
918
+ z.object({ iccid: z.string() }).describe("ICCID"),
919
+ z.object({ msisdn: z.string() }).describe("MSISDN / phone number"),
920
+ z.object({ multiImsi: z.string() }).describe("Multi-IMSI identifier"),
921
+ z.object({ activationCode: z.string() }).describe("eSIM activation code"),
922
+ ])
923
+ .describe("Subscriber identifier (use exactly one field)"),
924
+ expectedCountry: z
925
+ .string()
926
+ .length(2)
927
+ .transform((code) => code.toUpperCase())
928
+ .optional()
929
+ .describe("Caller\u2019s last-known ISO 3166-1 alpha-2 country for this subscriber " +
930
+ "(e.g. \"RU\"). When provided, countryChanged is included in the response."),
931
+ },
932
+ annotations: { readOnlyHint: true },
933
+ }, async ({ subscriber, expectedCountry }) => {
934
+ const token = await ctx.getUserToken(ctx.props.sub);
935
+ // getSingleSubscriber accepts any of subscriberId | imsi | iccid | msisdn |
936
+ // multiImsi | activationCode — pass the discriminated union value directly.
937
+ const sub = await safeCall(ctx.env, token, "getSingleSubscriber", subscriber);
938
+ if (sub.error)
939
+ return result(`Failed to fetch subscriber: ${sub.error}`, true);
940
+ if (!sub.data)
941
+ return result("Subscriber not found", true);
942
+ const subscriberId = sub.data.subscriberId ??
943
+ sub.data.id ??
944
+ ("subscriberId" in subscriber
945
+ ? subscriber.subscriberId
946
+ : undefined);
947
+ const networkInfo = sub.data.networkInfo;
948
+ if (networkInfo === null || networkInfo === undefined || typeof networkInfo !== "object") {
949
+ return result(JSON.stringify({ status: "no_location_data", subscriberId: subscriberId ?? null }));
950
+ }
951
+ const lastMcc = networkInfo.lastMcc != null ? Number(networkInfo.lastMcc) : null;
952
+ const lastMnc = networkInfo.lastMnc != null ? Number(networkInfo.lastMnc) : null;
953
+ const lastSeenAtUtc = networkInfo.time != null ? String(networkInfo.time) : null;
954
+ if (lastMcc === null || isNaN(lastMcc)) {
955
+ return result(JSON.stringify({ status: "no_location_data", subscriberId: subscriberId ?? null }));
956
+ }
957
+ const currentCountry = mccToIso(lastMcc);
958
+ const mccUnresolved = currentCountry === null;
959
+ const response = {
960
+ subscriberId: subscriberId ?? null,
961
+ currentCountry,
962
+ currentMcc: lastMcc,
963
+ currentMnc: lastMnc,
964
+ lastSeenAtUtc,
965
+ ...(mccUnresolved ? { mccUnresolved: true } : {}),
966
+ };
967
+ if (expectedCountry !== undefined) {
968
+ response.expectedCountry = expectedCountry;
969
+ // null = cannot determine (MCC unresolved); true/false = definitive diff
970
+ response.countryChanged = currentCountry !== null
971
+ ? currentCountry !== expectedCountry
972
+ : null;
973
+ }
974
+ return result(JSON.stringify(response, null, 2));
975
+ });
731
976
  }
732
977
  //# sourceMappingURL=intelligence.js.map