@easyfunnel/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +718 -0
- package/package.json +24 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
var import_server = require("@modelcontextprotocol/sdk/server/index.js");
|
|
6
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
|
+
var import_types = require("@modelcontextprotocol/sdk/types.js");
|
|
8
|
+
|
|
9
|
+
// src/api-client.ts
|
|
10
|
+
var DEFAULT_BASE_URL = "https://easyfunnel.so";
|
|
11
|
+
var ApiClient = class {
|
|
12
|
+
constructor(apiKey2, baseUrl2) {
|
|
13
|
+
this.apiKey = apiKey2;
|
|
14
|
+
this.baseUrl = baseUrl2 || DEFAULT_BASE_URL;
|
|
15
|
+
}
|
|
16
|
+
async request(path, options = {}) {
|
|
17
|
+
const url = `${this.baseUrl}/api/mcp${path}`;
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
...options,
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
23
|
+
...options.headers
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
29
|
+
}
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
async listProjects() {
|
|
33
|
+
return this.request("/projects");
|
|
34
|
+
}
|
|
35
|
+
async createProject(name, domainWhitelist) {
|
|
36
|
+
return this.request("/projects", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: JSON.stringify({ name, domain_whitelist: domainWhitelist })
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async createFunnel(projectId, name, steps) {
|
|
42
|
+
return this.request(`/projects/${projectId}/funnels`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: JSON.stringify({ name, steps })
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async getFunnelHealth(projectId, funnelId, timeRange) {
|
|
48
|
+
const params = new URLSearchParams();
|
|
49
|
+
if (funnelId) params.set("funnel_id", funnelId);
|
|
50
|
+
if (timeRange) params.set("time_range", timeRange);
|
|
51
|
+
return this.request(
|
|
52
|
+
`/projects/${projectId}/funnels/${funnelId}?${params.toString()}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
async queryEvents(projectId, params) {
|
|
56
|
+
const searchParams = new URLSearchParams();
|
|
57
|
+
searchParams.set("query_type", params.query_type);
|
|
58
|
+
if (params.event_name) searchParams.set("event_name", params.event_name);
|
|
59
|
+
if (params.time_range) searchParams.set("time_range", params.time_range);
|
|
60
|
+
if (params.group_by) searchParams.set("group_by", params.group_by);
|
|
61
|
+
if (params.limit) searchParams.set("limit", params.limit.toString());
|
|
62
|
+
return this.request(
|
|
63
|
+
`/projects/${projectId}/events?${searchParams.toString()}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/tools/list-projects.ts
|
|
69
|
+
var listProjectsDefinition = {
|
|
70
|
+
name: "list_projects",
|
|
71
|
+
description: "List all easyfunnel projects for this account",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
async function listProjects(client2) {
|
|
78
|
+
const projects = await client2.listProjects();
|
|
79
|
+
return { content: [{ type: "text", text: JSON.stringify(projects, null, 2) }] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/tools/create-project.ts
|
|
83
|
+
var createProjectDefinition = {
|
|
84
|
+
name: "create_project",
|
|
85
|
+
description: "Create a new easyfunnel project",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
name: { type: "string", description: "Project name" },
|
|
90
|
+
domain_whitelist: {
|
|
91
|
+
type: "array",
|
|
92
|
+
items: { type: "string" },
|
|
93
|
+
description: "Optional list of allowed domains"
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
required: ["name"]
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
async function createProject(client2, args) {
|
|
100
|
+
const project = await client2.createProject(args.name, args.domain_whitelist);
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: `Project created successfully!
|
|
106
|
+
|
|
107
|
+
Name: ${project.name}
|
|
108
|
+
ID: ${project.id}
|
|
109
|
+
API Key: ${project.api_key}
|
|
110
|
+
|
|
111
|
+
Use this API key in the SDK to start tracking events.`
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/tools/setup-sdk.ts
|
|
118
|
+
var import_fs = require("fs");
|
|
119
|
+
var import_path = require("path");
|
|
120
|
+
var import_child_process = require("child_process");
|
|
121
|
+
var setupSdkDefinition = {
|
|
122
|
+
name: "setup_sdk",
|
|
123
|
+
description: "Install the easyfunnel SDK and add the provider to a Next.js/React project",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
project_api_key: {
|
|
128
|
+
type: "string",
|
|
129
|
+
description: "The project API key (ef_...)"
|
|
130
|
+
},
|
|
131
|
+
project_root: {
|
|
132
|
+
type: "string",
|
|
133
|
+
description: "Absolute path to the project root directory"
|
|
134
|
+
},
|
|
135
|
+
framework: {
|
|
136
|
+
type: "string",
|
|
137
|
+
enum: ["nextjs", "react", "html"],
|
|
138
|
+
description: "The framework used in the project"
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
required: ["project_api_key", "project_root", "framework"]
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
function detectPackageManager(projectRoot) {
|
|
145
|
+
if ((0, import_fs.existsSync)((0, import_path.join)(projectRoot, "bun.lockb"))) return "bun";
|
|
146
|
+
if ((0, import_fs.existsSync)((0, import_path.join)(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
147
|
+
if ((0, import_fs.existsSync)((0, import_path.join)(projectRoot, "yarn.lock"))) return "yarn";
|
|
148
|
+
return "npm";
|
|
149
|
+
}
|
|
150
|
+
async function setupSdk(args) {
|
|
151
|
+
const { project_api_key, project_root, framework } = args;
|
|
152
|
+
const filesModified = [];
|
|
153
|
+
if (framework === "html") {
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: `For HTML projects, add this script tag to your <head>:
|
|
159
|
+
|
|
160
|
+
<script defer data-api-key="${project_api_key}" src="https://easyfunnel.so/sdk.js"></script>
|
|
161
|
+
|
|
162
|
+
This will automatically track page views and clicks on elements with data-ef-track attributes.`
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const pm = detectPackageManager(project_root);
|
|
168
|
+
const installCmd = pm === "npm" ? "npm install @easyfunnel/sdk @easyfunnel/react" : `${pm} add @easyfunnel/sdk @easyfunnel/react`;
|
|
169
|
+
try {
|
|
170
|
+
(0, import_child_process.execSync)(installCmd, { cwd: project_root, stdio: "pipe" });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
}
|
|
173
|
+
filesModified.push("package.json");
|
|
174
|
+
const envPath = (0, import_path.join)(project_root, ".env.local");
|
|
175
|
+
const envLine = `NEXT_PUBLIC_EASYFUNNEL_KEY=${project_api_key}`;
|
|
176
|
+
if ((0, import_fs.existsSync)(envPath)) {
|
|
177
|
+
const content = (0, import_fs.readFileSync)(envPath, "utf-8");
|
|
178
|
+
if (!content.includes("NEXT_PUBLIC_EASYFUNNEL_KEY")) {
|
|
179
|
+
(0, import_fs.writeFileSync)(envPath, content + "\n" + envLine + "\n");
|
|
180
|
+
filesModified.push(".env.local");
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
(0, import_fs.writeFileSync)(envPath, envLine + "\n");
|
|
184
|
+
filesModified.push(".env.local");
|
|
185
|
+
}
|
|
186
|
+
if (framework === "nextjs") {
|
|
187
|
+
const layoutPaths = [
|
|
188
|
+
(0, import_path.join)(project_root, "app", "layout.tsx"),
|
|
189
|
+
(0, import_path.join)(project_root, "app", "layout.jsx"),
|
|
190
|
+
(0, import_path.join)(project_root, "src", "app", "layout.tsx"),
|
|
191
|
+
(0, import_path.join)(project_root, "src", "app", "layout.jsx")
|
|
192
|
+
];
|
|
193
|
+
for (const layoutPath of layoutPaths) {
|
|
194
|
+
if ((0, import_fs.existsSync)(layoutPath)) {
|
|
195
|
+
let content = (0, import_fs.readFileSync)(layoutPath, "utf-8");
|
|
196
|
+
if (!content.includes("EasyFunnelProvider")) {
|
|
197
|
+
const importLine = `import { EasyFunnelProvider } from '@easyfunnel/react'
|
|
198
|
+
`;
|
|
199
|
+
content = importLine + content;
|
|
200
|
+
content = content.replace(
|
|
201
|
+
/(\{children\})/,
|
|
202
|
+
`<EasyFunnelProvider apiKey={process.env.NEXT_PUBLIC_EASYFUNNEL_KEY!}>
|
|
203
|
+
$1
|
|
204
|
+
</EasyFunnelProvider>`
|
|
205
|
+
);
|
|
206
|
+
(0, import_fs.writeFileSync)(layoutPath, content);
|
|
207
|
+
filesModified.push(layoutPath.replace(project_root + "/", ""));
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else if (framework === "react") {
|
|
213
|
+
const appPaths = [
|
|
214
|
+
(0, import_path.join)(project_root, "src", "App.tsx"),
|
|
215
|
+
(0, import_path.join)(project_root, "src", "App.jsx"),
|
|
216
|
+
(0, import_path.join)(project_root, "App.tsx"),
|
|
217
|
+
(0, import_path.join)(project_root, "App.jsx")
|
|
218
|
+
];
|
|
219
|
+
for (const appPath of appPaths) {
|
|
220
|
+
if ((0, import_fs.existsSync)(appPath)) {
|
|
221
|
+
let content = (0, import_fs.readFileSync)(appPath, "utf-8");
|
|
222
|
+
if (!content.includes("EasyFunnelProvider")) {
|
|
223
|
+
const importLine = `import { EasyFunnelProvider } from '@easyfunnel/react'
|
|
224
|
+
`;
|
|
225
|
+
content = importLine + content;
|
|
226
|
+
(0, import_fs.writeFileSync)(appPath, content);
|
|
227
|
+
filesModified.push(appPath.replace(project_root + "/", ""));
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: "text",
|
|
237
|
+
text: `SDK setup complete!
|
|
238
|
+
|
|
239
|
+
Files modified:
|
|
240
|
+
${filesModified.map((f) => ` - ${f}`).join("\n")}
|
|
241
|
+
|
|
242
|
+
The EasyFunnel provider is now wrapping your app. Page views and click tracking are automatic.
|
|
243
|
+
|
|
244
|
+
Next: I can scan your codebase for interactive elements to add tracking to.`
|
|
245
|
+
}
|
|
246
|
+
]
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/tools/scan-for-actions.ts
|
|
251
|
+
var import_fs2 = require("fs");
|
|
252
|
+
var import_path2 = require("path");
|
|
253
|
+
var scanForActionsDefinition = {
|
|
254
|
+
name: "scan_for_actions",
|
|
255
|
+
description: "Scan a codebase for interactive elements worth tracking (buttons, forms, links, CTAs)",
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: {
|
|
259
|
+
directory: {
|
|
260
|
+
type: "string",
|
|
261
|
+
description: "Absolute path to the directory to scan"
|
|
262
|
+
},
|
|
263
|
+
file_patterns: {
|
|
264
|
+
type: "array",
|
|
265
|
+
items: { type: "string" },
|
|
266
|
+
description: "File extensions to scan (default: .tsx, .jsx, .html)"
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
required: ["directory"]
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
function scanFile(filePath, baseDir) {
|
|
273
|
+
const results = [];
|
|
274
|
+
const content = (0, import_fs2.readFileSync)(filePath, "utf-8");
|
|
275
|
+
const lines = content.split("\n");
|
|
276
|
+
const relativePath = (0, import_path2.relative)(baseDir, filePath);
|
|
277
|
+
for (let i = 0; i < lines.length; i++) {
|
|
278
|
+
const line = lines[i];
|
|
279
|
+
const lineNum = i + 1;
|
|
280
|
+
const hasTracking = line.includes("data-ef-track");
|
|
281
|
+
const buttonMatch = line.match(/<(?:button|Button)[\s>]/);
|
|
282
|
+
if (buttonMatch) {
|
|
283
|
+
const nearby = lines.slice(i, Math.min(i + 3, lines.length)).join(" ");
|
|
284
|
+
const textMatch = nearby.match(/>([^<]+)</);
|
|
285
|
+
const text = textMatch ? textMatch[1].trim() : "button";
|
|
286
|
+
const suggested = `click_${text.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30)}`;
|
|
287
|
+
results.push({
|
|
288
|
+
file: relativePath,
|
|
289
|
+
line: lineNum,
|
|
290
|
+
element_type: "button",
|
|
291
|
+
context: `Button "${text}"`,
|
|
292
|
+
suggested_event_name: suggested,
|
|
293
|
+
already_tracked: hasTracking
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const linkMatch = line.match(/<(?:a|Link)[\s]/);
|
|
297
|
+
if (linkMatch && !line.includes("<label")) {
|
|
298
|
+
const hrefMatch = line.match(/href=["']([^"']+)["']/);
|
|
299
|
+
const textMatch = lines.slice(i, Math.min(i + 2, lines.length)).join(" ").match(/>([^<]+)</);
|
|
300
|
+
const text = textMatch ? textMatch[1].trim() : hrefMatch?.[1] || "link";
|
|
301
|
+
const suggested = `click_${text.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30)}`;
|
|
302
|
+
results.push({
|
|
303
|
+
file: relativePath,
|
|
304
|
+
line: lineNum,
|
|
305
|
+
element_type: "link",
|
|
306
|
+
context: `Link "${text}"${hrefMatch ? ` \u2192 ${hrefMatch[1]}` : ""}`,
|
|
307
|
+
suggested_event_name: suggested,
|
|
308
|
+
already_tracked: hasTracking
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (line.includes("onSubmit") || line.includes("handleSubmit") || line.match(/action=["']/)) {
|
|
312
|
+
const formName = line.match(/(?:function|const)\s+(\w+)/)?.[1] || "form";
|
|
313
|
+
const suggested = `submit_${formName.toLowerCase().replace(/handle|submit/gi, "").replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "form"}`;
|
|
314
|
+
results.push({
|
|
315
|
+
file: relativePath,
|
|
316
|
+
line: lineNum,
|
|
317
|
+
element_type: "form",
|
|
318
|
+
context: `Form submission handler`,
|
|
319
|
+
suggested_event_name: suggested,
|
|
320
|
+
already_tracked: line.includes("ef.track") || line.includes("useTrack")
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return results;
|
|
325
|
+
}
|
|
326
|
+
function walkDir(dir, extensions, results = []) {
|
|
327
|
+
try {
|
|
328
|
+
const entries = (0, import_fs2.readdirSync)(dir);
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "dist" || entry === ".next") {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const fullPath = (0, import_path2.join)(dir, entry);
|
|
334
|
+
const stat = (0, import_fs2.statSync)(fullPath);
|
|
335
|
+
if (stat.isDirectory()) {
|
|
336
|
+
walkDir(fullPath, extensions, results);
|
|
337
|
+
} else if (extensions.some((ext) => entry.endsWith(ext))) {
|
|
338
|
+
results.push(fullPath);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
return results;
|
|
344
|
+
}
|
|
345
|
+
async function scanForActions(args) {
|
|
346
|
+
const extensions = args.file_patterns || [".tsx", ".jsx", ".html"];
|
|
347
|
+
const files = walkDir(args.directory, extensions);
|
|
348
|
+
const allResults = [];
|
|
349
|
+
for (const file of files) {
|
|
350
|
+
const results = scanFile(file, args.directory);
|
|
351
|
+
allResults.push(...results);
|
|
352
|
+
}
|
|
353
|
+
if (allResults.length === 0) {
|
|
354
|
+
return {
|
|
355
|
+
content: [
|
|
356
|
+
{
|
|
357
|
+
type: "text",
|
|
358
|
+
text: "No trackable interactions found in the scanned directory."
|
|
359
|
+
}
|
|
360
|
+
]
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const tracked = allResults.filter((r) => r.already_tracked).length;
|
|
364
|
+
const untracked = allResults.length - tracked;
|
|
365
|
+
let output = `Found ${allResults.length} trackable interactions (${untracked} untracked, ${tracked} already tracked):
|
|
366
|
+
|
|
367
|
+
`;
|
|
368
|
+
allResults.forEach((r, i) => {
|
|
369
|
+
const status = r.already_tracked ? "[TRACKED]" : "[NOT TRACKED]";
|
|
370
|
+
output += `${i + 1}. ${status} ${r.file}:${r.line} \u2014 ${r.context} \u2192 suggested: ${r.suggested_event_name}
|
|
371
|
+
`;
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: "text", text: output }]
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/tools/instrument-code.ts
|
|
379
|
+
var import_fs3 = require("fs");
|
|
380
|
+
var instrumentCodeDefinition = {
|
|
381
|
+
name: "instrument_code",
|
|
382
|
+
description: "Add easyfunnel tracking to a specific interactive element in the codebase. Returns a diff preview \u2014 does NOT auto-save.",
|
|
383
|
+
inputSchema: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
file: {
|
|
387
|
+
type: "string",
|
|
388
|
+
description: "Absolute path to the file to instrument"
|
|
389
|
+
},
|
|
390
|
+
line: {
|
|
391
|
+
type: "number",
|
|
392
|
+
description: "Line number of the element to instrument"
|
|
393
|
+
},
|
|
394
|
+
event_name: {
|
|
395
|
+
type: "string",
|
|
396
|
+
description: "The event name to track (e.g., click_signup)"
|
|
397
|
+
},
|
|
398
|
+
method: {
|
|
399
|
+
type: "string",
|
|
400
|
+
enum: ["attribute", "track_call"],
|
|
401
|
+
description: "attribute: add data-ef-track attribute. track_call: add ef.track() call inside handler."
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
required: ["file", "line", "event_name", "method"]
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
async function instrumentCode(args) {
|
|
408
|
+
const { file, line, event_name, method } = args;
|
|
409
|
+
const content = (0, import_fs3.readFileSync)(file, "utf-8");
|
|
410
|
+
const lines = content.split("\n");
|
|
411
|
+
const targetLine = lines[line - 1];
|
|
412
|
+
if (!targetLine) {
|
|
413
|
+
return {
|
|
414
|
+
content: [
|
|
415
|
+
{
|
|
416
|
+
type: "text",
|
|
417
|
+
text: `Error: Line ${line} not found in ${file} (file has ${lines.length} lines).`
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
let newLine;
|
|
423
|
+
let changeDescription;
|
|
424
|
+
if (method === "attribute") {
|
|
425
|
+
if (targetLine.includes("data-ef-track")) {
|
|
426
|
+
return {
|
|
427
|
+
content: [
|
|
428
|
+
{
|
|
429
|
+
type: "text",
|
|
430
|
+
text: `Line ${line} already has a data-ef-track attribute.`
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
newLine = targetLine.replace(
|
|
436
|
+
/(<\w+)(\s)/,
|
|
437
|
+
`$1 data-ef-track="${event_name}"$2`
|
|
438
|
+
);
|
|
439
|
+
if (newLine === targetLine) {
|
|
440
|
+
newLine = targetLine.replace(/>/, ` data-ef-track="${event_name}">`);
|
|
441
|
+
}
|
|
442
|
+
changeDescription = `Added data-ef-track="${event_name}" attribute`;
|
|
443
|
+
} else {
|
|
444
|
+
newLine = targetLine;
|
|
445
|
+
if (targetLine.includes("onClick") || targetLine.includes("onSubmit")) {
|
|
446
|
+
changeDescription = `Add track('${event_name}') call. Suggested modification:
|
|
447
|
+
|
|
448
|
+
1. Add import: import { useTrack } from '@easyfunnel/react'
|
|
449
|
+
2. Add hook: const track = useTrack()
|
|
450
|
+
3. Add track call: track('${event_name}') at the start of the handler function`;
|
|
451
|
+
return {
|
|
452
|
+
content: [
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
text: `Instrumentation suggestion for ${file}:${line}
|
|
456
|
+
|
|
457
|
+
${changeDescription}
|
|
458
|
+
|
|
459
|
+
This requires modifying the component. Please apply these changes using your code editing capabilities.`
|
|
460
|
+
}
|
|
461
|
+
]
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
changeDescription = `Suggested: add track('${event_name}') call in the handler near line ${line}`;
|
|
465
|
+
}
|
|
466
|
+
const diff = `--- ${file}
|
|
467
|
+
+++ ${file}
|
|
468
|
+
@@ -${line},1 +${line},1 @@
|
|
469
|
+
-${targetLine}
|
|
470
|
+
+${newLine}`;
|
|
471
|
+
return {
|
|
472
|
+
content: [
|
|
473
|
+
{
|
|
474
|
+
type: "text",
|
|
475
|
+
text: `${changeDescription}
|
|
476
|
+
|
|
477
|
+
Diff preview:
|
|
478
|
+
\`\`\`diff
|
|
479
|
+
${diff}
|
|
480
|
+
\`\`\`
|
|
481
|
+
|
|
482
|
+
Apply this change to ${file} at line ${line}.`
|
|
483
|
+
}
|
|
484
|
+
]
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/tools/create-funnel.ts
|
|
489
|
+
var createFunnelDefinition = {
|
|
490
|
+
name: "create_funnel",
|
|
491
|
+
description: "Create a conversion funnel from a sequence of events",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
project_id: { type: "string", description: "Project ID" },
|
|
496
|
+
name: { type: "string", description: "Funnel name" },
|
|
497
|
+
steps: {
|
|
498
|
+
type: "array",
|
|
499
|
+
items: {
|
|
500
|
+
type: "object",
|
|
501
|
+
properties: {
|
|
502
|
+
event_name: { type: "string" },
|
|
503
|
+
label: { type: "string" }
|
|
504
|
+
},
|
|
505
|
+
required: ["event_name", "label"]
|
|
506
|
+
},
|
|
507
|
+
description: "Ordered funnel steps"
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
required: ["project_id", "name", "steps"]
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
async function createFunnel(client2, args) {
|
|
514
|
+
const funnel = await client2.createFunnel(
|
|
515
|
+
args.project_id,
|
|
516
|
+
args.name,
|
|
517
|
+
args.steps
|
|
518
|
+
);
|
|
519
|
+
const stepsDisplay = args.steps.map((s, i) => ` ${i + 1}. ${s.label} (${s.event_name})`).join("\n");
|
|
520
|
+
return {
|
|
521
|
+
content: [
|
|
522
|
+
{
|
|
523
|
+
type: "text",
|
|
524
|
+
text: `Funnel "${funnel.name}" created!
|
|
525
|
+
|
|
526
|
+
ID: ${funnel.id}
|
|
527
|
+
Steps:
|
|
528
|
+
${stepsDisplay}
|
|
529
|
+
|
|
530
|
+
You can now query this funnel's health to see conversion data.`
|
|
531
|
+
}
|
|
532
|
+
]
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/tools/get-funnel-health.ts
|
|
537
|
+
var getFunnelHealthDefinition = {
|
|
538
|
+
name: "get_funnel_health",
|
|
539
|
+
description: "Get the conversion and drop-off data for a specific funnel or the worst-performing funnel",
|
|
540
|
+
inputSchema: {
|
|
541
|
+
type: "object",
|
|
542
|
+
properties: {
|
|
543
|
+
project_id: { type: "string", description: "Project ID" },
|
|
544
|
+
funnel_id: {
|
|
545
|
+
type: "string",
|
|
546
|
+
description: "Funnel ID (optional \u2014 if omitted, returns the worst-performing funnel)"
|
|
547
|
+
},
|
|
548
|
+
time_range: {
|
|
549
|
+
type: "string",
|
|
550
|
+
enum: ["24h", "7d", "30d"],
|
|
551
|
+
description: "Time range (default: 7d)"
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
required: ["project_id"]
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
async function getFunnelHealth(client2, args) {
|
|
558
|
+
const data = await client2.getFunnelHealth(
|
|
559
|
+
args.project_id,
|
|
560
|
+
args.funnel_id || "",
|
|
561
|
+
args.time_range || "7d"
|
|
562
|
+
);
|
|
563
|
+
let output = `Funnel: ${data.funnel_name}
|
|
564
|
+
Time range: ${data.time_range}
|
|
565
|
+
|
|
566
|
+
`;
|
|
567
|
+
for (const step of data.steps) {
|
|
568
|
+
const bar = "\u2588".repeat(Math.max(1, Math.round(step.count / (data.steps[0]?.count || 1) * 20)));
|
|
569
|
+
output += `${step.label}: ${step.count} ${bar}`;
|
|
570
|
+
if (step.drop_off_pct !== null) {
|
|
571
|
+
output += ` (\u2193 ${step.drop_off_pct}% drop-off)`;
|
|
572
|
+
}
|
|
573
|
+
output += "\n";
|
|
574
|
+
}
|
|
575
|
+
output += `
|
|
576
|
+
Overall conversion: ${data.overall_conversion}%`;
|
|
577
|
+
if (data.median_time_to_convert) {
|
|
578
|
+
output += `
|
|
579
|
+
Median time to convert: ${data.median_time_to_convert}`;
|
|
580
|
+
}
|
|
581
|
+
if (data.suggestion) {
|
|
582
|
+
output += `
|
|
583
|
+
|
|
584
|
+
\u{1F4A1} ${data.suggestion}`;
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
content: [{ type: "text", text: output }]
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/tools/query-events.ts
|
|
592
|
+
var queryEventsDefinition = {
|
|
593
|
+
name: "query_events",
|
|
594
|
+
description: "Query raw event data for a project \u2014 counts, recent events, or filtered lists",
|
|
595
|
+
inputSchema: {
|
|
596
|
+
type: "object",
|
|
597
|
+
properties: {
|
|
598
|
+
project_id: { type: "string", description: "Project ID" },
|
|
599
|
+
query_type: {
|
|
600
|
+
type: "string",
|
|
601
|
+
enum: ["count", "recent", "breakdown"],
|
|
602
|
+
description: "Type of query"
|
|
603
|
+
},
|
|
604
|
+
event_name: {
|
|
605
|
+
type: "string",
|
|
606
|
+
description: "Filter by event name (optional)"
|
|
607
|
+
},
|
|
608
|
+
time_range: {
|
|
609
|
+
type: "string",
|
|
610
|
+
enum: ["24h", "7d", "30d"],
|
|
611
|
+
description: "Time range (default: 7d)"
|
|
612
|
+
},
|
|
613
|
+
group_by: {
|
|
614
|
+
type: "string",
|
|
615
|
+
enum: ["event_name", "url", "browser", "os"],
|
|
616
|
+
description: "Group by field (for breakdown query)"
|
|
617
|
+
},
|
|
618
|
+
limit: {
|
|
619
|
+
type: "number",
|
|
620
|
+
description: "Max results to return (default: 50)"
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
required: ["project_id", "query_type"]
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
async function queryEvents(client2, args) {
|
|
627
|
+
const data = await client2.queryEvents(args.project_id, args);
|
|
628
|
+
let output;
|
|
629
|
+
if (args.query_type === "count") {
|
|
630
|
+
output = `Event count (${args.time_range || "7d"}):
|
|
631
|
+
`;
|
|
632
|
+
output += ` Total events: ${data.total_events}
|
|
633
|
+
`;
|
|
634
|
+
output += ` Unique sessions: ${data.unique_sessions}
|
|
635
|
+
`;
|
|
636
|
+
output += ` Unique users: ${data.unique_users}
|
|
637
|
+
`;
|
|
638
|
+
} else if (args.query_type === "recent") {
|
|
639
|
+
output = `Recent events:
|
|
640
|
+
|
|
641
|
+
`;
|
|
642
|
+
for (const event of data.events || []) {
|
|
643
|
+
output += ` [${event.created_at}] ${event.event_name}`;
|
|
644
|
+
if (event.properties?.url) output += ` \u2014 ${event.properties.url}`;
|
|
645
|
+
output += "\n";
|
|
646
|
+
}
|
|
647
|
+
if (!data.events?.length) output += " No events found.\n";
|
|
648
|
+
} else {
|
|
649
|
+
output = `Event breakdown (${args.time_range || "7d"}):
|
|
650
|
+
|
|
651
|
+
`;
|
|
652
|
+
for (const [key, count] of Object.entries(data.breakdown || {})) {
|
|
653
|
+
output += ` ${key}: ${count}
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
if (!data.breakdown || Object.keys(data.breakdown).length === 0) {
|
|
657
|
+
output += " No events found.\n";
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return {
|
|
661
|
+
content: [{ type: "text", text: output }]
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/index.ts
|
|
666
|
+
var apiKey = process.env.EASYFUNNEL_API_KEY;
|
|
667
|
+
if (!apiKey) {
|
|
668
|
+
console.error(
|
|
669
|
+
"EASYFUNNEL_API_KEY environment variable is required. Get your account API key from https://easyfunnel.so/dashboard/settings"
|
|
670
|
+
);
|
|
671
|
+
process.exit(1);
|
|
672
|
+
}
|
|
673
|
+
var baseUrl = process.env.EASYFUNNEL_API_URL || void 0;
|
|
674
|
+
var client = new ApiClient(apiKey, baseUrl);
|
|
675
|
+
var server = new import_server.Server(
|
|
676
|
+
{ name: "easyfunnel", version: "0.1.0" },
|
|
677
|
+
{ capabilities: { tools: {} } }
|
|
678
|
+
);
|
|
679
|
+
server.setRequestHandler(import_types.ListToolsRequestSchema, async () => ({
|
|
680
|
+
tools: [
|
|
681
|
+
listProjectsDefinition,
|
|
682
|
+
createProjectDefinition,
|
|
683
|
+
setupSdkDefinition,
|
|
684
|
+
scanForActionsDefinition,
|
|
685
|
+
instrumentCodeDefinition,
|
|
686
|
+
createFunnelDefinition,
|
|
687
|
+
getFunnelHealthDefinition,
|
|
688
|
+
queryEventsDefinition
|
|
689
|
+
]
|
|
690
|
+
}));
|
|
691
|
+
server.setRequestHandler(import_types.CallToolRequestSchema, async (request) => {
|
|
692
|
+
const { name, arguments: args } = request.params;
|
|
693
|
+
switch (name) {
|
|
694
|
+
case "list_projects":
|
|
695
|
+
return listProjects(client);
|
|
696
|
+
case "create_project":
|
|
697
|
+
return createProject(client, args);
|
|
698
|
+
case "setup_sdk":
|
|
699
|
+
return setupSdk(args);
|
|
700
|
+
case "scan_for_actions":
|
|
701
|
+
return scanForActions(args);
|
|
702
|
+
case "instrument_code":
|
|
703
|
+
return instrumentCode(args);
|
|
704
|
+
case "create_funnel":
|
|
705
|
+
return createFunnel(client, args);
|
|
706
|
+
case "get_funnel_health":
|
|
707
|
+
return getFunnelHealth(client, args);
|
|
708
|
+
case "query_events":
|
|
709
|
+
return queryEvents(client, args);
|
|
710
|
+
default:
|
|
711
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
async function main() {
|
|
715
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
716
|
+
await server.connect(transport);
|
|
717
|
+
}
|
|
718
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@easyfunnel/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for easyfunnel.so — AI-powered analytics tools for Claude/Cursor",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"easyfunnel-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["dist"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format cjs --dts",
|
|
12
|
+
"dev": "tsup src/index.ts --format cjs --dts --watch"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"tsup": "^8.0.0",
|
|
20
|
+
"typescript": "^5.0.0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": ["mcp", "analytics", "funnel", "claude", "cursor", "easyfunnel"],
|
|
23
|
+
"license": "MIT"
|
|
24
|
+
}
|