@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 +165 -0
- package/bin/firstdistro-mcp.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +72 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +499 -0
- package/package.json +57 -0
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
|
package/dist/index.d.ts
ADDED
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
|
+
});
|
package/dist/server.d.ts
ADDED
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
|
+
}
|