@carrierllc/mcp 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,732 @@
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
+ import { z } from "zod";
8
+ async function safeCall(client, method, params = {}) {
9
+ try {
10
+ return { data: await client.call(method, params), error: null };
11
+ }
12
+ catch (err) {
13
+ return { data: null, error: err instanceof Error ? err.message : String(err) };
14
+ }
15
+ }
16
+ function formatBytes(bytes) {
17
+ if (bytes === 0)
18
+ return "0 B";
19
+ const units = ["B", "KB", "MB", "GB", "TB"];
20
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
21
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
22
+ }
23
+ function daysUntil(dateStr) {
24
+ return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
25
+ }
26
+ function toISODate(d) {
27
+ return d.toISOString().split("T")[0];
28
+ }
29
+ function result(text, isError = false) {
30
+ return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
31
+ }
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
+ // -------------------------------------------------------------------------
46
+ server.registerTool("diagnose_subscriber", {
47
+ 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") },
50
+ annotations: { readOnlyHint: true },
51
+ }, makeIntelHandler(client, async ({ iccid }) => {
52
+ const findings = [];
53
+ const actions = [];
54
+ let severity = "healthy";
55
+ const sub = await safeCall(client, "getSingleSubscriber", { iccid });
56
+ if (sub.error)
57
+ return result(`Failed to fetch subscriber: ${sub.error}`, true);
58
+ if (!sub.data)
59
+ return result("Subscriber not found", true);
60
+ const status = String(sub.data.status ?? "").toUpperCase();
61
+ const balance = Number(sub.data.balance ?? 0);
62
+ if (status !== "ACTIVE") {
63
+ findings.push(`OCS status is ${status} (not ACTIVE)`);
64
+ actions.push("Reactivate subscriber via modify_subscriber_status");
65
+ severity = "critical";
66
+ }
67
+ if (balance <= 0) {
68
+ findings.push(`Balance is ${balance} — subscriber may be blocked from usage`);
69
+ actions.push("Top up balance via modify_subscriber_balance");
70
+ if (severity !== "critical")
71
+ severity = "warning";
72
+ }
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))
76
+ : { data: null, error: null };
77
+ if (sim.data) {
78
+ const simStatus = String(sim.data.simStatus ?? sim.data.status ?? "").toUpperCase();
79
+ if (simStatus && !["ENABLED", "ACTIVE", "ACTIVATED"].includes(simStatus)) {
80
+ findings.push(`SIM provider status is ${simStatus} — SIM may be disabled at network level`);
81
+ actions.push("Enable SIM via change_sim_status");
82
+ severity = "critical";
83
+ }
84
+ }
85
+ const pkgs = await safeCall(client, "listSubscriberPrepaidPackages", { iccid });
86
+ if (pkgs.data && Array.isArray(pkgs.data)) {
87
+ const activePkgs = pkgs.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
88
+ if (activePkgs.length === 0) {
89
+ findings.push("No active packages — subscriber has no data/voice/SMS allowance");
90
+ actions.push("Assign a package via assign_package");
91
+ severity = "critical";
92
+ }
93
+ else {
94
+ for (const pkg of activePkgs) {
95
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
96
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
97
+ if (dataLimit > 0 && dataUsed >= dataLimit) {
98
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" data depleted: ${formatBytes(dataUsed)} / ${formatBytes(dataLimit)}`);
99
+ actions.push("Assign additional package or increase limits via modify_package_limits");
100
+ if (severity !== "critical")
101
+ severity = "warning";
102
+ }
103
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
104
+ if (expiry) {
105
+ const days = daysUntil(expiry);
106
+ if (days < 0) {
107
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" expired ${Math.abs(days)} days ago`);
108
+ actions.push("Remove expired package and assign a new one");
109
+ if (severity !== "critical")
110
+ severity = "warning";
111
+ }
112
+ else if (days <= 3) {
113
+ findings.push(`Package "${pkg.name ?? pkg.packageTemplateId}" expires in ${days} day(s)`);
114
+ actions.push("Consider renewing or assigning a recurring package");
115
+ if (severity === "healthy")
116
+ severity = "info";
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ const now = new Date();
123
+ 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) } });
125
+ if (events.data && Array.isArray(events.data)) {
126
+ if (events.data.length === 0) {
127
+ findings.push("No network events in last 48 hours — device may be powered off or out of coverage");
128
+ if (severity === "healthy")
129
+ severity = "warning";
130
+ }
131
+ else {
132
+ const lastEvent = events.data[events.data.length - 1];
133
+ const lastType = String(lastEvent.eventType ?? lastEvent.type ?? "unknown");
134
+ findings.push(`Last network event: ${lastType} at ${lastEvent.timestamp ?? lastEvent.date ?? "unknown"}`);
135
+ }
136
+ }
137
+ const imsiForBitrate = typeof sub.data.imsi === "string" ? sub.data.imsi : null;
138
+ const bitrate = imsiForBitrate
139
+ ? await safeCall(client, "hlrGetBitrate", { imsi: imsiForBitrate })
140
+ : { data: null, error: null };
141
+ if (bitrate.data) {
142
+ const rate = Number(bitrate.data.bitrate ?? bitrate.data.maxBitrate ?? 0);
143
+ if (rate > 0 && rate < 1000000) {
144
+ findings.push(`HLR bitrate throttled to ${(rate / 1000).toFixed(0)} kbps`);
145
+ actions.push("Increase bitrate via hlr_set_bitrate if throttling is unintended");
146
+ if (severity === "healthy")
147
+ severity = "info";
148
+ }
149
+ }
150
+ if (findings.length === 0)
151
+ 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([
156
+ `# Subscriber Diagnosis: ${iccid}`,
157
+ ``,
158
+ `## Severity: ${severity.toUpperCase()}`,
159
+ ``,
160
+ `## Findings`,
161
+ ...findings.map((f, i) => `${i + 1}. ${f}`),
162
+ ``,
163
+ ...(actions.length > 0 ? [`## Recommended Actions`, ...actions.map((a, i) => `${i + 1}. ${a}`)] : []),
164
+ ``,
165
+ `## Raw Status`,
166
+ `- OCS Status: ${status}`,
167
+ `- Balance: ${balance}`,
168
+ `- Active Packages: ${activeCount}`,
169
+ ].join("\n"));
170
+ }));
171
+ // -------------------------------------------------------------------------
172
+ // 2. FLEET HEALTH
173
+ // -------------------------------------------------------------------------
174
+ server.registerTool("fleet_health", {
175
+ title: "Fleet Health Dashboard",
176
+ description: "Aggregates eSIM status counts, low-balance accounts, and provides a fleet-wide health summary in a single call.",
177
+ inputSchema: {
178
+ accountId: z.number().optional().describe("Filter to a specific account (omit for all)"),
179
+ },
180
+ annotations: { readOnlyHint: true },
181
+ }, makeIntelHandler(client, async ({ accountId }) => {
182
+ const [statusResult, accountsResult] = await Promise.all([
183
+ safeCall(client, "esimStatusPerAccount", accountId !== undefined ? { accountId } : {}),
184
+ safeCall(client, "listResellerAccount", {}),
185
+ ]);
186
+ const sections = ["# Fleet Health Dashboard\n"];
187
+ if (statusResult.data && Array.isArray(statusResult.data)) {
188
+ let totalActive = 0, totalSuspended = 0, totalInventory = 0, totalOther = 0;
189
+ for (const account of statusResult.data) {
190
+ totalActive += Number(account.active ?? 0);
191
+ totalSuspended += Number(account.suspended ?? 0);
192
+ totalInventory += Number(account.inventory ?? account.notActivated ?? 0);
193
+ totalOther += Number(account.other ?? account.terminated ?? 0);
194
+ }
195
+ const total = totalActive + totalSuspended + totalInventory + totalOther;
196
+ const utilization = total > 0 ? ((totalActive / total) * 100).toFixed(1) : "0";
197
+ sections.push(`## eSIM Fleet Status`);
198
+ sections.push(`| Metric | Count | % |`);
199
+ sections.push(`|--------|-------|---|`);
200
+ sections.push(`| Active | ${totalActive} | ${total > 0 ? ((totalActive / total) * 100).toFixed(1) : 0}% |`);
201
+ sections.push(`| Suspended | ${totalSuspended} | ${total > 0 ? ((totalSuspended / total) * 100).toFixed(1) : 0}% |`);
202
+ sections.push(`| Inventory | ${totalInventory} | ${total > 0 ? ((totalInventory / total) * 100).toFixed(1) : 0}% |`);
203
+ sections.push(`| Other | ${totalOther} | ${total > 0 ? ((totalOther / total) * 100).toFixed(1) : 0}% |`);
204
+ sections.push(`| **Total** | **${total}** | |`);
205
+ sections.push(`\n**Fleet Utilization: ${utilization}%**`);
206
+ if (totalSuspended > totalActive * 0.1) {
207
+ sections.push(`\nHigh suspension rate (${totalSuspended} suspended vs ${totalActive} active)`);
208
+ }
209
+ }
210
+ if (accountsResult.data && Array.isArray(accountsResult.data)) {
211
+ const lowBalance = accountsResult.data.filter((a) => Number(a.balance ?? 0) < 10);
212
+ if (lowBalance.length > 0) {
213
+ sections.push(`\n## Low Balance Accounts (< 10)`);
214
+ sections.push(`| Account | Balance |`);
215
+ sections.push(`|---------|---------|`);
216
+ for (const a of lowBalance) {
217
+ sections.push(`| ${a.name ?? a.accountId ?? "?"} | ${Number(a.balance ?? 0).toFixed(2)} |`);
218
+ }
219
+ }
220
+ sections.push(`\n## Account Summary`);
221
+ sections.push(`- Total accounts: ${accountsResult.data.length}`);
222
+ sections.push(`- Low balance (< 10): ${lowBalance.length}`);
223
+ }
224
+ return result(sections.join("\n"));
225
+ }));
226
+ // -------------------------------------------------------------------------
227
+ // 3. DETECT USAGE ANOMALIES
228
+ // -------------------------------------------------------------------------
229
+ server.registerTool("detect_usage_anomalies", {
230
+ 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") },
233
+ annotations: { readOnlyHint: true },
234
+ }, makeIntelHandler(client, async ({ iccid }) => {
235
+ const now = new Date();
236
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
237
+ const [usageResult, pkgResult] = await Promise.all([
238
+ safeCall(client, "subscriberUsageOverPeriod", { subscriber: { iccid }, period: { start: toISODate(weekAgo), end: toISODate(now) } }),
239
+ safeCall(client, "listSubscriberPrepaidPackages", { iccid }),
240
+ ]);
241
+ if (usageResult.error)
242
+ return result(`Failed to fetch usage: ${usageResult.error}`, true);
243
+ const sections = [`# Usage Anomaly Report: ${iccid}\n`];
244
+ const anomalies = [];
245
+ 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
+ }));
250
+ if (dailyData.length >= 2) {
251
+ const volumes = dailyData.map((d) => d.bytes);
252
+ const mean = volumes.reduce((a, b) => a + b, 0) / volumes.length;
253
+ const stdDev = Math.sqrt(volumes.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / volumes.length);
254
+ sections.push(`## Daily Usage (Last 7 Days)`);
255
+ sections.push(`| Date | Data | vs Average |`);
256
+ sections.push(`|------|------|-----------|`);
257
+ 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" : "";
260
+ sections.push(`| ${d.date} | ${formatBytes(d.bytes)} | ${deviation}%${flag} |`);
261
+ if (d.bytes > mean + 2 * stdDev) {
262
+ anomalies.push(`Spike on ${d.date}: ${formatBytes(d.bytes)} (${deviation}% above average)`);
263
+ }
264
+ }
265
+ sections.push(`\n**Average daily usage: ${formatBytes(mean)}**`);
266
+ sections.push(`**Std deviation: ${formatBytes(stdDev)}**`);
267
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
268
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
269
+ for (const pkg of activePkgs) {
270
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
271
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
272
+ const remaining = dataLimit - dataUsed;
273
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
274
+ if (remaining > 0 && expiry && mean > 0) {
275
+ const daysLeft = daysUntil(expiry);
276
+ const daysToExhaust = remaining / mean;
277
+ sections.push(`\n## Burn Rate: ${pkg.name ?? pkg.packageTemplateId}`);
278
+ sections.push(`- Remaining: ${formatBytes(remaining)} of ${formatBytes(dataLimit)}`);
279
+ sections.push(`- Days until expiry: ${daysLeft}`);
280
+ sections.push(`- At current rate, data exhausts in: ${daysToExhaust.toFixed(1)} days`);
281
+ 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`);
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ else {
290
+ sections.push("No usage data available for the last 7 days.");
291
+ }
292
+ if (anomalies.length > 0) {
293
+ sections.push(`\n## Anomalies Detected`);
294
+ anomalies.forEach((a, i) => sections.push(`${i + 1}. ${a}`));
295
+ }
296
+ else {
297
+ sections.push(`\n## No anomalies detected — usage appears normal.`);
298
+ }
299
+ return result(sections.join("\n"));
300
+ }));
301
+ // -------------------------------------------------------------------------
302
+ // 4. OPTIMIZE PACKAGE
303
+ // -------------------------------------------------------------------------
304
+ server.registerTool("optimize_package", {
305
+ 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") },
308
+ annotations: { readOnlyHint: true },
309
+ }, makeIntelHandler(client, async ({ iccid }) => {
310
+ const now = new Date();
311
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
312
+ 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", {}),
316
+ ]);
317
+ const sections = [`# Package Optimization: ${iccid}\n`];
318
+ let avgDailyData = 0;
319
+ if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length > 0) {
320
+ const totalData = usageResult.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
321
+ avgDailyData = totalData / usageResult.data.length;
322
+ sections.push(`## Current Usage Pattern`);
323
+ sections.push(`- Average daily data: ${formatBytes(avgDailyData)}`);
324
+ sections.push(`- Projected monthly: ${formatBytes(avgDailyData * 30)}`);
325
+ }
326
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
327
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
328
+ if (activePkgs.length > 0) {
329
+ sections.push(`\n## Current Active Packages`);
330
+ for (const pkg of activePkgs) {
331
+ const dataLimit = Number(pkg.dataLimit ?? pkg.dataAllowance ?? 0);
332
+ const dataUsed = Number(pkg.dataUsed ?? pkg.dataConsumed ?? 0);
333
+ const utilization = dataLimit > 0 ? ((dataUsed / dataLimit) * 100).toFixed(1) : "N/A";
334
+ const price = Number(pkg.price ?? pkg.cost ?? 0);
335
+ sections.push(`\n### ${pkg.name ?? pkg.packageTemplateId}`);
336
+ sections.push(`- Data: ${formatBytes(dataUsed)} / ${formatBytes(dataLimit)} (${utilization}% used)`);
337
+ if (price > 0)
338
+ sections.push(`- Price: ${price.toFixed(2)}`);
339
+ const expiry = String(pkg.expirationDate ?? pkg.endDate ?? "");
340
+ if (expiry)
341
+ sections.push(`- Expires: ${expiry} (${daysUntil(expiry)} days)`);
342
+ if (dataLimit > 0 && Number(utilization) < 30) {
343
+ sections.push(`- LOW UTILIZATION — subscriber is using less than 30% of allowance`);
344
+ }
345
+ else if (Number(utilization) > 90) {
346
+ sections.push(`- NEAR LIMIT — subscriber at risk of running out`);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ if (templatesResult.data && Array.isArray(templatesResult.data) && avgDailyData > 0) {
352
+ const scored = templatesResult.data
353
+ .map((t) => {
354
+ const limit = Number(t.dataLimit ?? t.dataAllowance ?? 0);
355
+ const validity = Number(t.validityDays ?? t.duration ?? 30);
356
+ const price = Number(t.price ?? t.cost ?? 0);
357
+ const projectedUsage = avgDailyData * validity;
358
+ 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 };
361
+ })
362
+ .filter((t) => t.fitScore > 0.3 && t.limit > 0)
363
+ .sort((a, b) => b.fitScore - a.fitScore)
364
+ .slice(0, 5);
365
+ if (scored.length > 0) {
366
+ sections.push(`\n## Recommended Packages (by usage fit)`);
367
+ sections.push(`| Template | Data | Validity | Price | Fit | Projected Use |`);
368
+ sections.push(`|----------|------|----------|-------|-----|--------------|`);
369
+ for (const t of scored) {
370
+ 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)} |`);
372
+ }
373
+ }
374
+ }
375
+ else if (avgDailyData === 0) {
376
+ sections.push(`\n*No usage data available — cannot recommend packages without usage history.*`);
377
+ }
378
+ return result(sections.join("\n"));
379
+ }));
380
+ // -------------------------------------------------------------------------
381
+ // 5. CHURN RISK
382
+ // -------------------------------------------------------------------------
383
+ server.registerTool("churn_risk", {
384
+ 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") },
387
+ annotations: { readOnlyHint: true },
388
+ }, makeIntelHandler(client, async ({ iccid }) => {
389
+ const now = new Date();
390
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
391
+ 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 }),
396
+ ]);
397
+ let riskScore = 0;
398
+ const factors = [];
399
+ if (usageResult.data && Array.isArray(usageResult.data) && usageResult.data.length >= 3) {
400
+ const volumes = usageResult.data.map((e) => Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0));
401
+ const firstHalf = volumes.slice(0, Math.floor(volumes.length / 2));
402
+ const secondHalf = volumes.slice(Math.floor(volumes.length / 2));
403
+ const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
404
+ const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
405
+ if (avgFirst > 0) {
406
+ const trend = (avgSecond - avgFirst) / avgFirst;
407
+ 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` });
410
+ }
411
+ 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)}%` });
414
+ }
415
+ }
416
+ }
417
+ else if (!usageResult.data || (Array.isArray(usageResult.data) && usageResult.data.length === 0)) {
418
+ riskScore += 25;
419
+ factors.push({ factor: "No recent usage", impact: 25, detail: "Zero data activity in last 7 days" });
420
+ }
421
+ if (pkgResult.data && Array.isArray(pkgResult.data)) {
422
+ const activePkgs = pkgResult.data.filter((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
423
+ if (activePkgs.length === 0) {
424
+ riskScore += 20;
425
+ factors.push({ factor: "No active packages", impact: 20, detail: "Subscriber has no active data packages" });
426
+ }
427
+ else {
428
+ const allExpiringSoon = activePkgs.every((p) => {
429
+ const expiry = String(p.expirationDate ?? p.endDate ?? "");
430
+ return expiry && daysUntil(expiry) <= 5;
431
+ });
432
+ if (allExpiringSoon) {
433
+ riskScore += 15;
434
+ factors.push({ factor: "All packages expiring soon", impact: 15, detail: "No package renewal in sight" });
435
+ }
436
+ const hasRecurring = activePkgs.some((p) => p.recurring === true || p.isRecurring === true);
437
+ if (!hasRecurring) {
438
+ riskScore += 10;
439
+ factors.push({ factor: "No recurring packages", impact: 10, detail: "Manual renewal required — higher churn risk" });
440
+ }
441
+ }
442
+ }
443
+ if (subResult.data) {
444
+ const balance = Number(subResult.data.balance ?? 0);
445
+ if (balance <= 0) {
446
+ riskScore += 15;
447
+ factors.push({ factor: "Zero balance", impact: 15, detail: "Cannot purchase new packages" });
448
+ }
449
+ }
450
+ if (activeResult.data) {
451
+ const firstUse = String(activeResult.data.firstUseDate ?? activeResult.data.activationDate ?? "");
452
+ if (firstUse) {
453
+ const daysSinceFirst = Math.abs(daysUntil(firstUse));
454
+ if (daysSinceFirst < 30) {
455
+ riskScore += 10;
456
+ factors.push({ factor: "New subscriber", impact: 10, detail: `Only ${daysSinceFirst} days since first use` });
457
+ }
458
+ }
459
+ }
460
+ riskScore = Math.min(riskScore, 100);
461
+ const level = riskScore >= 70 ? "HIGH" : riskScore >= 40 ? "MEDIUM" : "LOW";
462
+ const sections = [
463
+ `# Churn Risk Assessment: ${iccid}`,
464
+ ``,
465
+ `## Risk Score: ${riskScore}/100 (${level})`,
466
+ ``,
467
+ `${"#".repeat(Math.floor(riskScore / 5))}${"-".repeat(20 - Math.floor(riskScore / 5))}`,
468
+ ``,
469
+ ];
470
+ if (factors.length > 0) {
471
+ sections.push(`## Contributing Factors`);
472
+ sections.push(`| Factor | Impact | Detail |`);
473
+ sections.push(`|--------|--------|--------|`);
474
+ factors.sort((a, b) => b.impact - a.impact);
475
+ for (const f of factors) {
476
+ sections.push(`| ${f.factor} | +${f.impact} | ${f.detail} |`);
477
+ }
478
+ }
479
+ sections.push(`\n## Retention Recommendations`);
480
+ if (riskScore >= 70) {
481
+ sections.push("1. Immediate outreach — contact subscriber with special offer");
482
+ sections.push("2. Assign a complimentary small data package to re-engage");
483
+ sections.push("3. Set up a recurring package to reduce renewal friction");
484
+ }
485
+ else if (riskScore >= 40) {
486
+ sections.push("1. Monitor usage for next 7 days");
487
+ sections.push("2. Consider proactive package renewal notification (via send_sms)");
488
+ sections.push("3. Ensure package fits usage pattern (run optimize_package)");
489
+ }
490
+ else {
491
+ sections.push("1. No immediate action required");
492
+ sections.push("2. Continue monitoring via regular fleet_health checks");
493
+ }
494
+ return result(sections.join("\n"));
495
+ }));
496
+ // -------------------------------------------------------------------------
497
+ // 6. AUDIT NETWORK COVERAGE
498
+ // -------------------------------------------------------------------------
499
+ server.registerTool("audit_network_coverage", {
500
+ title: "Network Coverage Audit",
501
+ description: "Analyses which networks subscribers connect to vs steering lists; flags expensive/non-preferred network roaming.",
502
+ inputSchema: {
503
+ accountId: z.number().optional().describe("Filter to a specific account"),
504
+ limit: z.number().optional().describe("Max subscribers to sample (default 50)"),
505
+ },
506
+ annotations: { readOnlyHint: true },
507
+ }, makeIntelHandler(client, async ({ accountId, limit: sampleLimit }) => {
508
+ const maxSample = sampleLimit ?? 50;
509
+ const subParams = { limit: maxSample, status: "ACTIVE" };
510
+ if (accountId !== undefined)
511
+ subParams.accountId = accountId;
512
+ const [subsResult, steeringResult] = await Promise.all([
513
+ safeCall(client, "listSubscriber", subParams),
514
+ safeCall(client, "listSteeringList", {}),
515
+ ]);
516
+ if (subsResult.error)
517
+ return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
518
+ const sections = [`# Network Coverage Audit\n`];
519
+ if (steeringResult.data && Array.isArray(steeringResult.data)) {
520
+ sections.push(`## Steering Lists: ${steeringResult.data.length} configured`);
521
+ }
522
+ const countryStats = new Map();
523
+ if (subsResult.data && Array.isArray(subsResult.data)) {
524
+ sections.push(`## Sampling ${subsResult.data.length} active subscribers\n`);
525
+ const subs = subsResult.data.slice(0, maxSample);
526
+ const batchSize = 10;
527
+ for (let i = 0; i < subs.length; i += batchSize) {
528
+ const batch = subs.slice(i, i + batchSize);
529
+ const locations = await Promise.all(batch.map((s) => safeCall(client, "getSubscriberLocation", { iccid: String(s.iccid ?? "") })));
530
+ for (let j = 0; j < batch.length; j++) {
531
+ const loc = locations[j];
532
+ if (loc.data) {
533
+ const country = String(loc.data.country ?? loc.data.countryCode ?? "Unknown");
534
+ const network = String(loc.data.network ?? loc.data.operator ?? loc.data.mccMnc ?? "Unknown");
535
+ if (!countryStats.has(country)) {
536
+ countryStats.set(country, { count: 0, networks: new Map() });
537
+ }
538
+ const stat = countryStats.get(country);
539
+ stat.count++;
540
+ stat.networks.set(network, (stat.networks.get(network) ?? 0) + 1);
541
+ }
542
+ }
543
+ }
544
+ }
545
+ if (countryStats.size > 0) {
546
+ sections.push(`## Network Distribution by Country`);
547
+ const sorted = [...countryStats.entries()].sort((a, b) => b[1].count - a[1].count);
548
+ for (const [country, stat] of sorted) {
549
+ sections.push(`\n### ${country} (${stat.count} subscribers)`);
550
+ sections.push(`| Network | Subscribers | % |`);
551
+ sections.push(`|---------|------------|---|`);
552
+ const networksSorted = [...stat.networks.entries()].sort((a, b) => b[1] - a[1]);
553
+ for (const [network, count] of networksSorted) {
554
+ sections.push(`| ${network} | ${count} | ${((count / stat.count) * 100).toFixed(0)}% |`);
555
+ }
556
+ if (networksSorted.length > 3) {
557
+ sections.push(`\n${networksSorted.length} different networks in ${country} — possible steering fragmentation`);
558
+ }
559
+ }
560
+ }
561
+ else {
562
+ sections.push("No location data available for sampled subscribers.");
563
+ }
564
+ return result(sections.join("\n"));
565
+ }));
566
+ // -------------------------------------------------------------------------
567
+ // 7. MARKETING INTELLIGENCE
568
+ // -------------------------------------------------------------------------
569
+ server.registerTool("marketing_intelligence", {
570
+ title: "Marketing Intelligence Report",
571
+ description: "Identifies high-growth markets, underserved regions, and revenue concentration. Answers 'where should I target marketing?'",
572
+ inputSchema: {
573
+ accountId: z.number().optional().describe("Filter to a specific account"),
574
+ },
575
+ annotations: { readOnlyHint: true },
576
+ }, makeIntelHandler(client, async ({ accountId }) => {
577
+ const params = { limit: 200 };
578
+ if (accountId !== undefined)
579
+ params.accountId = accountId;
580
+ const [subsResult, templatesResult] = await Promise.all([
581
+ safeCall(client, "listSubscriber", params),
582
+ safeCall(client, "listPrepaidPackageTemplate", {}),
583
+ ]);
584
+ const sections = [`# Marketing Intelligence Report\n`];
585
+ if (subsResult.data && Array.isArray(subsResult.data)) {
586
+ const total = subsResult.data.length;
587
+ sections.push(`## Fleet Size: ${total} subscribers sampled\n`);
588
+ const sample = subsResult.data.slice(0, 100);
589
+ const countryData = new Map();
590
+ const batchSize = 10;
591
+ for (let i = 0; i < sample.length; i += batchSize) {
592
+ const batch = sample.slice(i, i + batchSize);
593
+ const locations = await Promise.all(batch.map((s) => safeCall(client, "getSubscriberLocation", { iccid: String(s.iccid ?? "") })));
594
+ for (let j = 0; j < batch.length; j++) {
595
+ 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 });
600
+ const cd = countryData.get(country);
601
+ cd.subscribers++;
602
+ cd.totalBalance += Number(subItem.balance ?? 0);
603
+ }
604
+ }
605
+ if (countryData.size > 0) {
606
+ const sorted = [...countryData.entries()].sort((a, b) => b[1].subscribers - a[1].subscribers);
607
+ sections.push(`## Subscriber Concentration by Country`);
608
+ sections.push(`| Country | Subscribers | % of Fleet | Avg Balance |`);
609
+ sections.push(`|---------|-----------|------------|-------------|`);
610
+ for (const [country, data] of sorted) {
611
+ const pct = ((data.subscribers / sample.length) * 100).toFixed(1);
612
+ const avgBal = (data.totalBalance / data.subscribers).toFixed(2);
613
+ sections.push(`| ${country} | ${data.subscribers} | ${pct}% | ${avgBal} |`);
614
+ }
615
+ sections.push(`\n## Market Insights`);
616
+ const topMarket = sorted[0];
617
+ if (topMarket) {
618
+ sections.push(`- Strongest market: ${topMarket[0]} (${topMarket[1].subscribers} subscribers)`);
619
+ if (topMarket[1].subscribers / sample.length > 0.5) {
620
+ sections.push(` Revenue concentration risk — >50% of fleet in one market`);
621
+ }
622
+ }
623
+ const emerging = sorted.filter(([, d]) => d.subscribers >= 1 && d.subscribers <= 3);
624
+ if (emerging.length > 0) {
625
+ sections.push(`\n### Emerging Markets (1-3 subscribers)`);
626
+ for (const [country, data] of emerging) {
627
+ sections.push(`- ${country}: ${data.subscribers} subscriber(s)`);
628
+ }
629
+ }
630
+ }
631
+ }
632
+ 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);
635
+ if (prices.length > 0) {
636
+ 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)})`);
638
+ }
639
+ }
640
+ sections.push(`\n## Recommended Actions`);
641
+ sections.push(`1. Run \`audit_network_coverage\` to verify network quality in top markets`);
642
+ sections.push(`2. Run \`high_cost_subscribers\` to identify margin pressure`);
643
+ sections.push(`3. Consider creating regional package templates for emerging markets`);
644
+ return result(sections.join("\n"));
645
+ }));
646
+ // -------------------------------------------------------------------------
647
+ // 8. HIGH COST SUBSCRIBERS
648
+ // -------------------------------------------------------------------------
649
+ server.registerTool("high_cost_subscribers", {
650
+ title: "High Cost Subscriber Report",
651
+ description: "Identifies subscribers with disproportionately high data consumption relative to package value.",
652
+ inputSchema: {
653
+ accountId: z.number().optional().describe("Filter to a specific account"),
654
+ limit: z.number().optional().describe("Max subscribers to analyse (default 100)"),
655
+ thresholdPct: z.number().optional().describe("Usage % threshold to flag (default 80)"),
656
+ },
657
+ annotations: { readOnlyHint: true },
658
+ }, makeIntelHandler(client, async ({ accountId, limit: maxLimit, thresholdPct }) => {
659
+ const sampleSize = maxLimit ?? 100;
660
+ const threshold = thresholdPct ?? 80;
661
+ const subParams = { limit: sampleSize, status: "ACTIVE" };
662
+ if (accountId !== undefined)
663
+ subParams.accountId = accountId;
664
+ const subsResult = await safeCall(client, "listSubscriber", subParams);
665
+ if (subsResult.error)
666
+ return result(`Failed to fetch subscribers: ${subsResult.error}`, true);
667
+ if (!subsResult.data || !Array.isArray(subsResult.data))
668
+ return result("No subscribers found", true);
669
+ const sections = [`# High Cost Subscriber Report\n`];
670
+ const now = new Date();
671
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
672
+ const highCostSubs = [];
673
+ const batchSize = 5;
674
+ const subs = subsResult.data.slice(0, sampleSize);
675
+ for (let i = 0; i < subs.length; i += batchSize) {
676
+ const batch = subs.slice(i, i + batchSize);
677
+ await Promise.all(batch.map(async (sub) => {
678
+ const iccid = String(sub.iccid ?? "");
679
+ 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 }),
683
+ ]);
684
+ let dailyAvgBytes = 0;
685
+ if (usage.data && Array.isArray(usage.data) && usage.data.length > 0) {
686
+ const totalBytes = usage.data.reduce((sum, e) => sum + Number(e.dataBytes ?? e.dataVolume ?? e.totalData ?? 0), 0);
687
+ dailyAvgBytes = totalBytes / usage.data.length;
688
+ }
689
+ if (pkgs.data && Array.isArray(pkgs.data)) {
690
+ const activePkg = pkgs.data.find((p) => String(p.status ?? "").toUpperCase() === "ACTIVE");
691
+ if (activePkg && dailyAvgBytes > 0) {
692
+ const dataLimit = Number(activePkg.dataLimit ?? activePkg.dataAllowance ?? 0);
693
+ const dataUsed = Number(activePkg.dataUsed ?? activePkg.dataConsumed ?? 0);
694
+ const price = Number(activePkg.price ?? activePkg.cost ?? 0);
695
+ const utilizationPct = dataLimit > 0 ? (dataUsed / dataLimit) * 100 : 0;
696
+ const remaining = dataLimit - dataUsed;
697
+ 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 ?? "?") : "?";
700
+ if (utilizationPct >= threshold || daysToExhaust < 3) {
701
+ highCostSubs.push({ iccid, dailyAvgBytes, packageDataLimit: dataLimit, packagePrice: price, utilizationPct, costPerGB, daysToExhaust, country });
702
+ }
703
+ }
704
+ }
705
+ }));
706
+ }
707
+ highCostSubs.sort((a, b) => b.utilizationPct - a.utilizationPct);
708
+ sections.push(`Analysed ${subs.length} active subscribers (threshold: ${threshold}% usage)\n`);
709
+ if (highCostSubs.length === 0) {
710
+ sections.push(`No subscribers above ${threshold}% package utilization. Fleet margins look healthy.`);
711
+ }
712
+ else {
713
+ sections.push(`## ${highCostSubs.length} High-Cost Subscribers Found\n`);
714
+ sections.push(`| ICCID | Country | Daily Avg | Usage % | Days Left | Cost/GB |`);
715
+ sections.push(`|-------|---------|-----------|---------|-----------|---------|`);
716
+ let totalDailyBytes = 0;
717
+ for (const s of highCostSubs) {
718
+ 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) : "?"} |`);
720
+ }
721
+ sections.push(`\n## Summary`);
722
+ sections.push(`- High-cost subscribers: ${highCostSubs.length} / ${subs.length} (${((highCostSubs.length / subs.length) * 100).toFixed(1)}%)`);
723
+ sections.push(`- Combined daily data burn: ${formatBytes(totalDailyBytes)}`);
724
+ sections.push(`\n## Recommended Actions`);
725
+ sections.push(`1. Review tariff rates for top countries via \`get_tariff\``);
726
+ sections.push(`2. Consider throttling heavy users via \`hlr_set_bitrate\``);
727
+ sections.push(`3. Run \`optimize_package\` on flagged ICCIDs to find better-fit plans`);
728
+ }
729
+ return result(sections.join("\n"));
730
+ }));
731
+ }
732
+ //# sourceMappingURL=intelligence.js.map