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