@firstdistro/mcp 1.0.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 ADDED
@@ -0,0 +1,165 @@
1
+ # @firstdistro/mcp
2
+
3
+ MCP server for [FirstDistro](https://firstdistro.com) — Query and manage customer health from AI assistants like Claude.
4
+
5
+ ## Quick Start
6
+
7
+ ### 1. Install and configure
8
+
9
+ **Interactive setup (recommended):**
10
+ ```bash
11
+ npx @firstdistro/mcp init
12
+ ```
13
+
14
+ **Or with API key directly:**
15
+ ```bash
16
+ npx @firstdistro/mcp init --api-key sk_live_xxxxx
17
+ ```
18
+
19
+ Get your API key from [Settings → API Keys](https://firstdistro.com/dashboard/settings/sdk-configuration) in your FirstDistro dashboard.
20
+
21
+ > **Note:** Use an **API Key** (`sk_live_...` or `sk_test_...`), not an Installation Token (`fd_...`). Installation Tokens are for the browser SDK only.
22
+
23
+ ### 2. Add to Claude Code
24
+
25
+ Add to your `~/.claude/settings.json`:
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "firstdistro": {
31
+ "command": "npx",
32
+ "args": ["@firstdistro/mcp"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ### 3. Restart Claude Code
39
+
40
+ That's it! Try asking:
41
+ - "Show me my FirstDistro experiences"
42
+ - "Who's stuck in onboarding?"
43
+ - "What's Acme Corp's health score?"
44
+ - "List my at-risk customers"
45
+
46
+ ## Available Tools
47
+
48
+ | Tool | Description |
49
+ |------|-------------|
50
+ | `list_experiences` | List all configured user journeys |
51
+ | `get_experience_stats` | Get funnel metrics for an experience |
52
+ | `get_stuck_customers` | Find customers stuck in a journey |
53
+ | `get_customer_health` | Get health score for an account |
54
+ | `list_at_risk_accounts` | List critical and at-risk customers |
55
+ | `check_events_flowing` | Verify SDK is sending events |
56
+
57
+ ### Example Usage
58
+
59
+ **Check customer health:**
60
+ ```
61
+ User: "What's the health score for Acme Corp?"
62
+
63
+ Claude: Customer: Acme Corp
64
+
65
+ Health Score: 72/100
66
+ Risk Level: at-risk
67
+ Trend: declining
68
+ Last Seen: 2 hours ago
69
+ ```
70
+
71
+ **Find stuck customers:**
72
+ ```
73
+ User: "Who's stuck in the onboarding flow?"
74
+
75
+ Claude: Experience: User Onboarding
76
+ Stuck Alert: 15 min
77
+
78
+ Found 3 stuck customer(s):
79
+
80
+ - Acme Corp: john@acme.com (stuck 45 min)
81
+ - TechStart: sarah@techstart.io (stuck 32 min)
82
+ - DataFlow: mike@dataflow.com (stuck 18 min)
83
+ ```
84
+
85
+ **List at-risk accounts:**
86
+ ```
87
+ User: "Show me customers at risk of churning"
88
+
89
+ Claude: At-Risk Accounts Summary:
90
+ • Critical: 2
91
+ • At-Risk: 5
92
+ • Healthy: 43
93
+
94
+ - Acme Corp: Score 45/100 (critical) 📉
95
+ - TechStart: Score 52/100 (at-risk) 📉
96
+ - DataFlow: Score 58/100 (at-risk)
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ Config is stored in `~/.firstdistro/config.json`:
102
+
103
+ ```json
104
+ {
105
+ "apiKey": "sk_live_xxxxx",
106
+ "baseUrl": "https://firstdistro.com"
107
+ }
108
+ ```
109
+
110
+ ### Environment Variables
111
+
112
+ You can also configure via environment variables (takes priority over config file):
113
+
114
+ | Variable | Description |
115
+ |----------|-------------|
116
+ | `FIRSTDISTRO_API_KEY` | Your API key |
117
+ | `FIRSTDISTRO_BASE_URL` | API base URL (default: https://firstdistro.com) |
118
+
119
+ > **Important:** API keys start with `sk_live_` (production) or `sk_test_` (sandbox). Installation Tokens (`fd_...`) are for the browser SDK and won't work with the MCP server.
120
+
121
+ ## Troubleshooting
122
+
123
+ ### "Not configured" error
124
+ Run `npx @firstdistro/mcp init` to set up your API key.
125
+
126
+ ### "Invalid API key" error
127
+ 1. Check you're using an API Key (`sk_live_...`), not an Installation Token (`fd_...`)
128
+ 2. Verify the key in Settings → API Keys in your dashboard
129
+ 3. Generate a new key if needed
130
+
131
+ ### Tools not appearing in Claude
132
+ 1. Ensure you've added the MCP server to `~/.claude/settings.json`
133
+ 2. Restart Claude Code completely (not just reload)
134
+ 3. Check the server runs: `npx @firstdistro/mcp`
135
+
136
+ ### "Cannot reach FirstDistro API" error
137
+ 1. Check your internet connection
138
+ 2. Verify https://firstdistro.com is accessible
139
+ 3. Check if you're behind a corporate firewall/proxy
140
+
141
+ ## Development
142
+
143
+ ```bash
144
+ # Install dependencies
145
+ npm install
146
+
147
+ # Build
148
+ npm run build
149
+
150
+ # Run locally
151
+ node bin/firstdistro-mcp.js
152
+
153
+ # Run init command
154
+ node bin/firstdistro-mcp.js init
155
+ ```
156
+
157
+ ## Support
158
+
159
+ - Documentation: https://firstdistro.com/docs
160
+ - Issues: https://github.com/firstdistro/mcp/issues
161
+ - Email: support@firstdistro.com
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import('../dist/index.js');
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,72 @@
1
+ // src/index.ts
2
+ import { existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ var CONFIG_DIR = join(homedir(), ".firstdistro");
6
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
7
+ async function main() {
8
+ const args = process.argv.slice(2);
9
+ const command = args[0];
10
+ if (command === "init") {
11
+ await runInit(args.slice(1));
12
+ } else {
13
+ await runServer();
14
+ }
15
+ }
16
+ async function runInit(args) {
17
+ const apiKeyIndex = args.indexOf("--api-key");
18
+ let apiKey;
19
+ if (apiKeyIndex !== -1 && args[apiKeyIndex + 1]) {
20
+ apiKey = args[apiKeyIndex + 1];
21
+ console.warn(
22
+ "\n\u26A0\uFE0F Warning: API key may be visible in shell history.",
23
+ "\n For CI, prefer FIRSTDISTRO_API_KEY environment variable.\n"
24
+ );
25
+ }
26
+ console.log("\n FirstDistro MCP Server Setup");
27
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
28
+ if (!apiKey) {
29
+ console.log(" This will configure the MCP server to connect to your FirstDistro account.\n");
30
+ console.log(" Get your API key from: https://firstdistro.com/dashboard/settings/sdk-configuration\n");
31
+ console.log(" Run with: npx @firstdistro/mcp init --api-key YOUR_API_KEY\n");
32
+ console.log(" Or set FIRSTDISTRO_API_KEY environment variable.\n");
33
+ process.exit(0);
34
+ }
35
+ if (!apiKey.startsWith("sk_live_") && !apiKey.startsWith("sk_test_")) {
36
+ console.error(' \u2717 Invalid API key format. Key must start with "sk_live_" or "sk_test_"\n');
37
+ console.error(" Note: Installation Tokens (fd_*) are for the browser SDK, not the MCP server.\n");
38
+ console.error(" Generate an API Key from Settings \u2192 SDK Configuration in your dashboard.\n");
39
+ process.exit(1);
40
+ }
41
+ const { mkdirSync, writeFileSync } = await import("fs");
42
+ if (!existsSync(CONFIG_DIR)) {
43
+ mkdirSync(CONFIG_DIR, { recursive: true });
44
+ }
45
+ const config = {
46
+ apiKey,
47
+ baseUrl: "https://firstdistro.com"
48
+ };
49
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
50
+ console.log(" \u2713 Config saved to ~/.firstdistro/config.json\n");
51
+ console.log(" Next steps:\n");
52
+ console.log(" 1. Add to your Claude Code settings (~/.claude/settings.json):\n");
53
+ console.log(" {");
54
+ console.log(' "mcpServers": {');
55
+ console.log(' "firstdistro": {');
56
+ console.log(' "command": "npx",');
57
+ console.log(' "args": ["@firstdistro/mcp"]');
58
+ console.log(" }");
59
+ console.log(" }");
60
+ console.log(" }\n");
61
+ console.log(" 2. Restart Claude Code\n");
62
+ console.log(' 3. Try asking: "Show me my FirstDistro experiences"\n');
63
+ console.log(" Done! \u{1F389}\n");
64
+ }
65
+ async function runServer() {
66
+ const { startServer } = await import("./server.js");
67
+ await startServer();
68
+ }
69
+ main().catch((error) => {
70
+ console.error("Error:", error.message);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @firstdistro/mcp - MCP Server
3
+ *
4
+ * Implements the Model Context Protocol server with FirstDistro tools
5
+ */
6
+ declare function startServer(): Promise<void>;
7
+
8
+ export { startServer };
package/dist/server.js ADDED
@@ -0,0 +1,499 @@
1
+ // src/server.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+
6
+ // src/config.ts
7
+ import { readFileSync, existsSync } from "fs";
8
+ import { homedir } from "os";
9
+ import { join } from "path";
10
+ var CONFIG_PATH = join(homedir(), ".firstdistro", "config.json");
11
+ var DEFAULT_BASE_URL = "https://firstdistro.com";
12
+ function loadConfig() {
13
+ const envKey = process.env.FIRSTDISTRO_API_KEY;
14
+ if (envKey) {
15
+ return {
16
+ apiKey: envKey,
17
+ baseUrl: process.env.FIRSTDISTRO_BASE_URL || DEFAULT_BASE_URL
18
+ };
19
+ }
20
+ if (!existsSync(CONFIG_PATH)) {
21
+ throw new Error(
22
+ "FirstDistro not configured.\n\nRun `npx @firstdistro/mcp init --api-key YOUR_KEY` to set up,\nor set the FIRSTDISTRO_API_KEY environment variable."
23
+ );
24
+ }
25
+ try {
26
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
27
+ const config = JSON.parse(raw);
28
+ if (!config.apiKey) {
29
+ throw new Error(
30
+ "API key missing in config file.\n\nRun `npx @firstdistro/mcp init --api-key YOUR_KEY` to reconfigure."
31
+ );
32
+ }
33
+ return {
34
+ apiKey: config.apiKey,
35
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL
36
+ };
37
+ } catch (error) {
38
+ if (error instanceof SyntaxError) {
39
+ throw new Error(
40
+ "Invalid config file format.\n\nRun `npx @firstdistro/mcp init --api-key YOUR_KEY` to reconfigure."
41
+ );
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ // src/server.ts
48
+ function formatHttpError(status, statusText, context) {
49
+ switch (status) {
50
+ case 401:
51
+ return `Authentication failed. Your API key may be invalid or expired.
52
+
53
+ To fix: Run "npx @firstdistro/mcp init" to reconfigure, or check Settings \u2192 API Keys in your dashboard.`;
54
+ case 403:
55
+ return `Access denied. Your API key doesn't have permission for this operation.
56
+
57
+ Check that you're using a valid API key (sk_live_... or sk_test_...), not an Installation Token (fd_...).`;
58
+ case 404:
59
+ return `${context} not found.`;
60
+ case 429:
61
+ return `Rate limit exceeded. Please wait a moment and try again.`;
62
+ case 500:
63
+ case 502:
64
+ case 503:
65
+ return `FirstDistro API is temporarily unavailable. Please try again in a few moments.`;
66
+ default:
67
+ return `API error (${status}): ${statusText}`;
68
+ }
69
+ }
70
+ function formatNetworkError(error) {
71
+ if (error instanceof Error) {
72
+ if (error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED")) {
73
+ return `Cannot reach FirstDistro API. Check your internet connection and try again.`;
74
+ }
75
+ if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) {
76
+ return `Request timed out. The API may be slow or unavailable. Please try again.`;
77
+ }
78
+ return `Network error: ${error.message}`;
79
+ }
80
+ return "Unknown network error occurred.";
81
+ }
82
+ async function startServer() {
83
+ const config = loadConfig();
84
+ const server = new McpServer({
85
+ name: "firstdistro",
86
+ version: "1.0.0"
87
+ });
88
+ registerTools(server, config);
89
+ const transport = new StdioServerTransport();
90
+ await server.connect(transport);
91
+ console.error("[FirstDistro MCP] Server started");
92
+ }
93
+ function registerTools(server, config) {
94
+ server.tool(
95
+ "list_experiences",
96
+ "List all configured experiences (user journeys) for your FirstDistro account",
97
+ async () => {
98
+ try {
99
+ const response = await fetch(`${config.baseUrl}/api/vendor/experiences`, {
100
+ headers: {
101
+ "X-API-Key": config.apiKey,
102
+ "Content-Type": "application/json"
103
+ }
104
+ });
105
+ if (!response.ok) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: formatHttpError(response.status, response.statusText, "Experiences")
111
+ }
112
+ ],
113
+ isError: true
114
+ };
115
+ }
116
+ const data = await response.json();
117
+ const experiences = data.experiences || data;
118
+ if (!experiences || experiences.length === 0) {
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: "No experiences configured yet. Create your first experience in the FirstDistro dashboard."
124
+ }
125
+ ]
126
+ };
127
+ }
128
+ const summary = experiences.map((exp) => `- ${exp.name} (${exp.status || "active"})`).join("\n");
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `Found ${experiences.length} experience(s):
134
+
135
+ ${summary}`
136
+ }
137
+ ]
138
+ };
139
+ } catch (error) {
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: formatNetworkError(error)
145
+ }
146
+ ],
147
+ isError: true
148
+ };
149
+ }
150
+ }
151
+ );
152
+ server.tool(
153
+ "check_events_flowing",
154
+ "Verify that your SDK is properly sending events to FirstDistro",
155
+ async () => {
156
+ try {
157
+ const response = await fetch(`${config.baseUrl}/api/mcp/events-status`, {
158
+ headers: {
159
+ "X-API-Key": config.apiKey,
160
+ "Content-Type": "application/json"
161
+ }
162
+ });
163
+ if (!response.ok) {
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text",
168
+ text: formatHttpError(response.status, response.statusText, "Event check")
169
+ }
170
+ ],
171
+ isError: true
172
+ };
173
+ }
174
+ const data = await response.json();
175
+ if (data.eventsFlowing || data.hasEvents) {
176
+ let topEventsText = "";
177
+ if (data.topEvents && data.topEvents.length > 0) {
178
+ const eventsList = data.topEvents.map((e) => ` \u2022 ${e.name}: ${e.count}`).join("\n");
179
+ topEventsText = `
180
+
181
+ Top Events (24h):
182
+ ${eventsList}`;
183
+ }
184
+ return {
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text: `\u2713 Events are flowing!
189
+
190
+ Last event: ${data.lastEventAt || "Recently"}
191
+ Events (24h): ${data.eventCountLast24h ?? "Available in dashboard"}
192
+ Unique users (24h): ${data.uniqueUsersLast24h ?? "N/A"}${topEventsText}`
193
+ }
194
+ ]
195
+ };
196
+ } else {
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: "No events detected yet. Make sure your SDK is properly installed and configured."
202
+ }
203
+ ]
204
+ };
205
+ }
206
+ } catch (error) {
207
+ return {
208
+ content: [
209
+ {
210
+ type: "text",
211
+ text: formatNetworkError(error)
212
+ }
213
+ ],
214
+ isError: true
215
+ };
216
+ }
217
+ }
218
+ );
219
+ server.tool(
220
+ "get_customer_health",
221
+ "Get health score and details for a specific customer account",
222
+ {
223
+ accountId: z.string().describe("The account ID or slug to look up")
224
+ },
225
+ async ({ accountId }) => {
226
+ try {
227
+ const response = await fetch(
228
+ `${config.baseUrl}/api/vendor/customers/${accountId}`,
229
+ {
230
+ headers: {
231
+ "X-API-Key": config.apiKey,
232
+ "Content-Type": "application/json"
233
+ }
234
+ }
235
+ );
236
+ if (!response.ok) {
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text: formatHttpError(response.status, response.statusText, `Customer "${accountId}"`)
242
+ }
243
+ ],
244
+ isError: true
245
+ };
246
+ }
247
+ const data = await response.json();
248
+ const health = data.health || data;
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: `Customer: ${data.account?.name || accountId}
254
+
255
+ Health Score: ${health.score ?? "N/A"}/100
256
+ Risk Level: ${health.riskLevel || "Unknown"}
257
+ Trend: ${health.trend || "Unknown"}
258
+ Last Seen: ${data.account?.lastSeenAt || "Unknown"}`
259
+ }
260
+ ]
261
+ };
262
+ } catch (error) {
263
+ return {
264
+ content: [
265
+ {
266
+ type: "text",
267
+ text: formatNetworkError(error)
268
+ }
269
+ ],
270
+ isError: true
271
+ };
272
+ }
273
+ }
274
+ );
275
+ server.tool(
276
+ "get_experience_stats",
277
+ "Get funnel statistics for a specific experience (conversion rates, drop-offs)",
278
+ {
279
+ experienceId: z.string().describe("The experience ID or slug"),
280
+ range: z.enum(["7d", "30d", "90d"]).optional().describe("Time range for stats (default: 7d)")
281
+ },
282
+ async ({ experienceId, range }) => {
283
+ try {
284
+ const params = new URLSearchParams();
285
+ if (range) params.set("range", range);
286
+ const url = `${config.baseUrl}/api/vendor/experiences/${experienceId}/stats${params.toString() ? "?" + params.toString() : ""}`;
287
+ const response = await fetch(url, {
288
+ headers: {
289
+ "X-API-Key": config.apiKey,
290
+ "Content-Type": "application/json"
291
+ }
292
+ });
293
+ if (!response.ok) {
294
+ return {
295
+ content: [
296
+ {
297
+ type: "text",
298
+ text: formatHttpError(response.status, response.statusText, `Experience "${experienceId}"`)
299
+ }
300
+ ],
301
+ isError: true
302
+ };
303
+ }
304
+ const data = await response.json();
305
+ const stats = data.stats || {};
306
+ const exp = data.experience || {};
307
+ let avgTimeDisplay = "N/A";
308
+ if (stats.avgCompletionTimeSeconds != null) {
309
+ const seconds = stats.avgCompletionTimeSeconds;
310
+ if (seconds < 60) {
311
+ avgTimeDisplay = `${seconds}s`;
312
+ } else if (seconds < 3600) {
313
+ avgTimeDisplay = `${Math.round(seconds / 60)}m`;
314
+ } else {
315
+ avgTimeDisplay = `${Math.round(seconds / 3600)}h`;
316
+ }
317
+ }
318
+ return {
319
+ content: [
320
+ {
321
+ type: "text",
322
+ text: `Experience: ${exp.name || experienceId}
323
+ Time Range: ${data.range || "7d"}
324
+
325
+ Started: ${stats.startedCount ?? "N/A"}
326
+ Completed: ${stats.completedCount ?? "N/A"}
327
+ Completion Rate: ${stats.completionRate != null ? `${stats.completionRate}%` : "N/A"}
328
+ Avg Time to Complete: ${avgTimeDisplay}`
329
+ }
330
+ ]
331
+ };
332
+ } catch (error) {
333
+ return {
334
+ content: [
335
+ {
336
+ type: "text",
337
+ text: formatNetworkError(error)
338
+ }
339
+ ],
340
+ isError: true
341
+ };
342
+ }
343
+ }
344
+ );
345
+ server.tool(
346
+ "get_stuck_customers",
347
+ "Find customers who are stuck in a specific experience (not progressing)",
348
+ {
349
+ experienceId: z.string().describe("The experience ID or slug"),
350
+ limit: z.number().optional().describe("Maximum number of results (default: 20, max: 100)")
351
+ },
352
+ async ({ experienceId, limit }) => {
353
+ try {
354
+ const params = new URLSearchParams();
355
+ if (limit) params.set("limit", String(limit));
356
+ const url = `${config.baseUrl}/api/vendor/experiences/${experienceId}/stuck${params.toString() ? "?" + params.toString() : ""}`;
357
+ const response = await fetch(url, {
358
+ headers: {
359
+ "X-API-Key": config.apiKey,
360
+ "Content-Type": "application/json"
361
+ }
362
+ });
363
+ if (!response.ok) {
364
+ return {
365
+ content: [
366
+ {
367
+ type: "text",
368
+ text: formatHttpError(response.status, response.statusText, `Experience "${experienceId}"`)
369
+ }
370
+ ],
371
+ isError: true
372
+ };
373
+ }
374
+ const data = await response.json();
375
+ const customers = data.stuckCustomers || [];
376
+ const exp = data.experience || {};
377
+ if (customers.length === 0) {
378
+ return {
379
+ content: [
380
+ {
381
+ type: "text",
382
+ text: `No customers stuck in "${exp.name || experienceId}". Great news!`
383
+ }
384
+ ]
385
+ };
386
+ }
387
+ const customerList = customers.slice(0, 10).map(
388
+ (c) => `- ${c.accountName || c.accountId}: ${c.userName || c.userEmail || "Unknown user"} (stuck ${c.timeElapsedMinutes ?? "?"} min)`
389
+ ).join("\n");
390
+ return {
391
+ content: [
392
+ {
393
+ type: "text",
394
+ text: `Experience: ${exp.name || experienceId}
395
+ Stuck Alert: ${exp.stuckAlertMinutes ?? "?"} min
396
+
397
+ Found ${data.totalCount || customers.length} stuck customer(s):
398
+
399
+ ${customerList}${customers.length > 10 ? `
400
+
401
+ ... and ${customers.length - 10} more` : ""}`
402
+ }
403
+ ]
404
+ };
405
+ } catch (error) {
406
+ return {
407
+ content: [
408
+ {
409
+ type: "text",
410
+ text: formatNetworkError(error)
411
+ }
412
+ ],
413
+ isError: true
414
+ };
415
+ }
416
+ }
417
+ );
418
+ server.tool(
419
+ "list_at_risk_accounts",
420
+ "List customer accounts with at-risk or critical health scores",
421
+ {
422
+ riskLevel: z.enum(["critical", "at-risk", "all"]).optional().describe("Filter by risk level (default: all = both critical and at-risk)"),
423
+ limit: z.number().optional().describe("Maximum number of results (default: 20, max: 100)"),
424
+ sortBy: z.enum(["score", "lastSeen"]).optional().describe("Sort order (default: score, lowest first)")
425
+ },
426
+ async ({ riskLevel, limit, sortBy }) => {
427
+ try {
428
+ const params = new URLSearchParams();
429
+ if (riskLevel) params.set("risk_level", riskLevel);
430
+ if (limit) params.set("limit", String(limit));
431
+ if (sortBy) params.set("sortBy", sortBy);
432
+ const url = `${config.baseUrl}/api/vendor/customers/at-risk${params.toString() ? "?" + params.toString() : ""}`;
433
+ const response = await fetch(url, {
434
+ headers: {
435
+ "X-API-Key": config.apiKey,
436
+ "Content-Type": "application/json"
437
+ }
438
+ });
439
+ if (!response.ok) {
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: formatHttpError(response.status, response.statusText, "At-risk accounts")
445
+ }
446
+ ],
447
+ isError: true
448
+ };
449
+ }
450
+ const data = await response.json();
451
+ const accounts = data.accounts || [];
452
+ const summary = data.summary || {};
453
+ if (accounts.length === 0) {
454
+ return {
455
+ content: [
456
+ {
457
+ type: "text",
458
+ text: data.message || `No at-risk accounts found. Great news!
459
+
460
+ Summary: ${summary.criticalCount ?? 0} critical, ${summary.atRiskCount ?? 0} at-risk, ${summary.healthyCount ?? 0} healthy`
461
+ }
462
+ ]
463
+ };
464
+ }
465
+ const accountList = accounts.slice(0, 15).map(
466
+ (a) => `- ${a.name || a.accountId}: Score ${a.healthScore}/100 (${a.riskLevel}) ${a.trend === "declining" ? "\u{1F4C9}" : a.trend === "improving" ? "\u{1F4C8}" : ""}`
467
+ ).join("\n");
468
+ return {
469
+ content: [
470
+ {
471
+ type: "text",
472
+ text: `At-Risk Accounts Summary:
473
+ \u2022 Critical: ${summary.criticalCount ?? 0}
474
+ \u2022 At-Risk: ${summary.atRiskCount ?? 0}
475
+ \u2022 Healthy: ${summary.healthyCount ?? 0}
476
+
477
+ ${accountList}${accounts.length > 15 ? `
478
+
479
+ ... and ${accounts.length - 15} more` : ""}`
480
+ }
481
+ ]
482
+ };
483
+ } catch (error) {
484
+ return {
485
+ content: [
486
+ {
487
+ type: "text",
488
+ text: formatNetworkError(error)
489
+ }
490
+ ],
491
+ isError: true
492
+ };
493
+ }
494
+ }
495
+ );
496
+ }
497
+ export {
498
+ startServer
499
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@firstdistro/mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for FirstDistro - query and manage customer health from AI assistants",
5
+ "type": "module",
6
+ "bin": {
7
+ "firstdistro-mcp": "./bin/firstdistro-mcp.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "bin"
20
+ ],
21
+ "scripts": {
22
+ "dev": "tsup src/index.ts src/server.ts --format esm --watch",
23
+ "build": "tsup src/index.ts src/server.ts --format esm --dts",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.26.0",
30
+ "zod": "^3.25.76"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.0.0",
34
+ "tsup": "^8.0.0",
35
+ "typescript": "^5.0.0",
36
+ "vitest": "^2.0.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "keywords": [
42
+ "mcp",
43
+ "model-context-protocol",
44
+ "firstdistro",
45
+ "customer-success",
46
+ "ai",
47
+ "claude",
48
+ "llm"
49
+ ],
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/Wonderstand-AI/first-distro"
54
+ },
55
+ "homepage": "https://firstdistro.com/docs/mcp",
56
+ "author": "FirstDistro <support@firstdistro.com>"
57
+ }