lcontext-mcp 1.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.js ADDED
@@ -0,0 +1,1084 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lcontext MCP Server
4
+ *
5
+ * Provides page and element analytics context for Claude Code.
6
+ * Requires authentication via LCONTEXT_API_KEY environment variable only.
7
+ *
8
+ * Tools:
9
+ * - get_page_context: Get page analytics, stats, and related elements for a given path and time range
10
+ * - list_pages: List all tracked pages for the authenticated website
11
+ * - get_element_context: Get detailed analytics for a specific element
12
+ * - get_app_context: Get application-wide analytics including sessions, visitors, and AI insights
13
+ * - get_visitors: Get visitors list with AI-generated profiles and segment assignments
14
+ * - get_visitor_detail: Get detailed profile and sessions for a specific visitor
15
+ * - get_sessions: Get sessions list with AI summaries and sentiment analysis
16
+ * - get_session_detail: Get detailed session info including events and visitor context
17
+ */
18
+ // Handle CLI flags before importing heavy dependencies
19
+ const args = process.argv.slice(2);
20
+ if (args.includes('--version') || args.includes('-v')) {
21
+ console.log('1.1.0');
22
+ process.exit(0);
23
+ }
24
+ if (args.includes('--help') || args.includes('-h')) {
25
+ console.log(`lcontext v1.1.0
26
+
27
+ MCP server for lcontext page analytics.
28
+ Provides page and element context for Claude Code.
29
+
30
+ Usage:
31
+ lcontext [options]
32
+
33
+ Options:
34
+ -v, --version Show version number
35
+ -h, --help Show this help message
36
+ --update Update to the latest version
37
+
38
+ Environment Variables:
39
+ LCONTEXT_API_KEY Your lcontext API key (required)
40
+ LCONTEXT_API_URL API base URL (default: https://lcontext.com)
41
+
42
+ Documentation: https://lcontext.com/docs/mcp
43
+ `);
44
+ process.exit(0);
45
+ }
46
+ // Self-update command - runs async then exits
47
+ if (args.includes('--update')) {
48
+ import('fs').then(fs => import('os').then(os => {
49
+ const CURRENT_VERSION = '1.1.0';
50
+ const GITHUB_REPO = 'evan-kyr/lcontext';
51
+ const platform = os.platform();
52
+ const arch = os.arch();
53
+ let binaryName;
54
+ if (platform === 'darwin') {
55
+ binaryName = arch === 'arm64' ? 'lcontext-macos-arm64' : 'lcontext-macos-x64';
56
+ }
57
+ else if (platform === 'win32') {
58
+ binaryName = 'lcontext-windows-x64.exe';
59
+ }
60
+ else {
61
+ binaryName = arch === 'arm64' ? 'lcontext-linux-arm64' : 'lcontext-linux-x64';
62
+ }
63
+ console.log(`Current version: ${CURRENT_VERSION}`);
64
+ console.log(`Platform: ${platform} ${arch}`);
65
+ console.log('Checking for updates...');
66
+ fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
67
+ headers: { 'User-Agent': 'lcontext-mcp' }
68
+ })
69
+ .then(res => res.json())
70
+ .then((release) => {
71
+ const latestVersion = release.tag_name.replace(/^v/, '');
72
+ console.log(`Latest version: ${latestVersion}`);
73
+ if (latestVersion === CURRENT_VERSION) {
74
+ console.log('Already up to date!');
75
+ process.exit(0);
76
+ }
77
+ const asset = release.assets.find((a) => a.name === binaryName);
78
+ if (!asset) {
79
+ console.error(`No binary found for ${binaryName}`);
80
+ process.exit(1);
81
+ }
82
+ console.log(`Downloading ${binaryName}...`);
83
+ return fetch(asset.browser_download_url, {
84
+ headers: { 'User-Agent': 'lcontext-mcp' }
85
+ }).then(res => res.arrayBuffer()).then(data => {
86
+ const binaryData = Buffer.from(data);
87
+ const execPath = process.execPath;
88
+ const tempPath = execPath + '.new';
89
+ const backupPath = execPath + '.backup';
90
+ console.log(`Installing to ${execPath}...`);
91
+ fs.writeFileSync(tempPath, binaryData);
92
+ fs.chmodSync(tempPath, 0o755);
93
+ if (fs.existsSync(backupPath))
94
+ fs.unlinkSync(backupPath);
95
+ fs.renameSync(execPath, backupPath);
96
+ fs.renameSync(tempPath, execPath);
97
+ fs.unlinkSync(backupPath);
98
+ console.log(`Successfully updated to ${latestVersion}!`);
99
+ process.exit(0);
100
+ });
101
+ })
102
+ .catch((err) => {
103
+ console.error('Update failed:', err.message);
104
+ process.exit(1);
105
+ });
106
+ }));
107
+ // Keep process alive while update runs
108
+ setInterval(() => { }, 1000);
109
+ }
110
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
111
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
112
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
113
+ import { z } from "zod";
114
+ // Configuration
115
+ const CURRENT_VERSION = "1.1.0";
116
+ const GITHUB_REPO = "evan-kyr/lcontext";
117
+ const API_BASE_URL = process.env.LCONTEXT_API_URL || "https://lcontext.com";
118
+ const API_KEY = process.env.LCONTEXT_API_KEY;
119
+ // Check for updates (non-blocking, outputs to stderr)
120
+ async function checkForUpdates() {
121
+ try {
122
+ const controller = new AbortController();
123
+ const timeout = setTimeout(() => controller.abort(), 3000);
124
+ const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
125
+ signal: controller.signal,
126
+ headers: { 'User-Agent': 'lcontext-mcp' }
127
+ });
128
+ clearTimeout(timeout);
129
+ if (!response.ok)
130
+ return;
131
+ const release = await response.json();
132
+ const latestVersion = release.tag_name.replace(/^v/, '');
133
+ if (latestVersion !== CURRENT_VERSION && isNewerVersion(latestVersion, CURRENT_VERSION)) {
134
+ console.error(`\n[lcontext] Update available: ${CURRENT_VERSION} → ${latestVersion}`);
135
+ console.error(`[lcontext] Download: https://github.com/${GITHUB_REPO}/releases/latest\n`);
136
+ }
137
+ }
138
+ catch {
139
+ // Silently ignore - don't block startup for update checks
140
+ }
141
+ }
142
+ function isNewerVersion(latest, current) {
143
+ const latestParts = latest.split('.').map(Number);
144
+ const currentParts = current.split('.').map(Number);
145
+ for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) {
146
+ const l = latestParts[i] || 0;
147
+ const c = currentParts[i] || 0;
148
+ if (l > c)
149
+ return true;
150
+ if (l < c)
151
+ return false;
152
+ }
153
+ return false;
154
+ }
155
+ // Run update check in background
156
+ checkForUpdates();
157
+ // Validation schemas
158
+ const getPageContextSchema = z.object({
159
+ path: z.string().describe("The page path to get context for (e.g., '/products', '/checkout')"),
160
+ startDate: z.string().optional().describe("Start date for stats (ISO format, e.g., '2025-01-01')"),
161
+ endDate: z.string().optional().describe("End date for stats (ISO format, e.g., '2025-01-13')"),
162
+ periodType: z.enum(["day", "week"]).optional().default("day").describe("Period type for stats aggregation")
163
+ });
164
+ const listPagesSchema = z.object({
165
+ limit: z.number().optional().default(50).describe("Maximum number of pages to return (max: 200)"),
166
+ search: z.string().optional().describe("Search filter for page paths")
167
+ });
168
+ const getElementContextSchema = z.object({
169
+ elementLabel: z.string().optional().describe("The element's label text or aria-label to search for"),
170
+ elementId: z.string().optional().describe("The element's HTML ID to search for"),
171
+ pagePath: z.string().optional().describe("Optional page path to filter elements")
172
+ });
173
+ const getAppContextSchema = z.object({
174
+ periodType: z.enum(["day", "week"]).optional().default("day").describe("Period type for stats aggregation"),
175
+ limit: z.number().optional().default(7).describe("Number of periods to return (default: 7, max: 30)")
176
+ });
177
+ const getVisitorsSchema = z.object({
178
+ limit: z.number().optional().default(20).describe("Maximum number of visitors to return (default: 20, max: 100)"),
179
+ offset: z.number().optional().default(0).describe("Offset for pagination"),
180
+ segmentId: z.number().optional().describe("Filter by segment ID"),
181
+ search: z.string().optional().describe("Search in visitor ID, title, summary, interests, goals, action, evidence"),
182
+ firstVisitAfter: z.string().optional().describe("Filter visitors who first visited after this date (ISO format)"),
183
+ firstVisitBefore: z.string().optional().describe("Filter visitors who first visited before this date (ISO format)"),
184
+ lastVisitAfter: z.string().optional().describe("Filter visitors who last visited after this date (ISO format)"),
185
+ lastVisitBefore: z.string().optional().describe("Filter visitors who last visited before this date (ISO format)"),
186
+ engagementTrend: z.enum(["increasing", "stable", "decreasing"]).optional().describe("Filter by engagement trend"),
187
+ overallSentiment: z.enum(["positive", "negative", "neutral", "mixed"]).optional().describe("Filter by overall sentiment")
188
+ });
189
+ const getVisitorDetailSchema = z.object({
190
+ visitorId: z.string().describe("The visitor's unique identifier")
191
+ });
192
+ const getSessionsSchema = z.object({
193
+ limit: z.number().optional().default(20).describe("Maximum number of sessions to return (default: 20, max: 100)"),
194
+ offset: z.number().optional().default(0).describe("Offset for pagination"),
195
+ visitorId: z.string().optional().describe("Filter sessions by visitor ID"),
196
+ sentiment: z.enum(["positive", "negative", "neutral"]).optional().describe("Filter by session sentiment"),
197
+ startDate: z.string().optional().describe("Start date for filtering (ISO format)"),
198
+ endDate: z.string().optional().describe("End date for filtering (ISO format)"),
199
+ search: z.string().optional().describe("Search in session title and description"),
200
+ minDuration: z.number().optional().describe("Filter sessions with duration >= this value (seconds)"),
201
+ maxDuration: z.number().optional().describe("Filter sessions with duration <= this value (seconds)"),
202
+ minEventsCount: z.number().optional().describe("Filter sessions with events count >= this value"),
203
+ maxEventsCount: z.number().optional().describe("Filter sessions with events count <= this value")
204
+ });
205
+ const getSessionDetailSchema = z.object({
206
+ sessionId: z.number().describe("The session's numeric ID")
207
+ });
208
+ // HTTP client helper
209
+ async function apiRequest(endpoint, options = {}) {
210
+ if (!API_KEY) {
211
+ throw new Error("LCONTEXT_API_KEY environment variable is required");
212
+ }
213
+ const url = `${API_BASE_URL}${endpoint}`;
214
+ const headers = {
215
+ 'X-API-Key': API_KEY,
216
+ 'Content-Type': 'application/json',
217
+ ...options.headers
218
+ };
219
+ const response = await fetch(url, {
220
+ ...options,
221
+ headers
222
+ });
223
+ if (!response.ok) {
224
+ const errorBody = await response.text();
225
+ throw new Error(`API request failed (${response.status}): ${errorBody}`);
226
+ }
227
+ const text = await response.text();
228
+ try {
229
+ return JSON.parse(text);
230
+ }
231
+ catch (e) {
232
+ throw new Error(`Invalid JSON response from API: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
233
+ }
234
+ }
235
+ // Helper function to format page context
236
+ function formatPageContext(data) {
237
+ const { page, stats, elements } = data;
238
+ let output = `
239
+ ## Page Analytics: ${page.path}
240
+ ${page.title ? `**Title:** ${page.title}` : ''}
241
+ **First Seen:** ${new Date(page.firstSeenAt).toLocaleDateString()}
242
+ **Last Seen:** ${new Date(page.lastSeenAt).toLocaleDateString()}
243
+
244
+ ### Page Statistics
245
+ `;
246
+ if (stats.length === 0) {
247
+ output += "No statistics available for the selected time range.\n";
248
+ }
249
+ else {
250
+ // Aggregate totals
251
+ const totals = stats.reduce((acc, stat) => ({
252
+ views: acc.views + (stat.viewCount || 0),
253
+ uniqueVisitors: acc.uniqueVisitors + (stat.uniqueVisitors || 0),
254
+ bounces: acc.bounces + (stat.bounceCount || 0),
255
+ entries: acc.entries + (stat.entryCount || 0),
256
+ exits: acc.exits + (stat.exitCount || 0)
257
+ }), { views: 0, uniqueVisitors: 0, bounces: 0, entries: 0, exits: 0 });
258
+ output += `
259
+ **Summary (${stats.length} ${stats[0]?.periodType || 'day'}s)**
260
+ - Total Views: ${totals.views}
261
+ - Total Unique Visitors: ${totals.uniqueVisitors}
262
+ - Total Bounces: ${totals.bounces}
263
+ - Entry Rate: ${totals.views > 0 ? ((totals.entries / totals.views) * 100).toFixed(1) : 0}%
264
+ - Exit Rate: ${totals.views > 0 ? ((totals.exits / totals.views) * 100).toFixed(1) : 0}%
265
+
266
+ **Recent Daily Breakdown:**
267
+ `;
268
+ for (const stat of stats.slice(0, 7)) {
269
+ const date = new Date(stat.periodStart).toLocaleDateString();
270
+ output += `| ${date} | Views: ${stat.viewCount} | Visitors: ${stat.uniqueVisitors} | Avg Duration: ${stat.avgDuration}s | Scroll: ${stat.avgScrollDepth}% |\n`;
271
+ }
272
+ // Add AI summaries if available
273
+ const summaries = stats.filter((stat) => stat.aiSummary);
274
+ if (summaries.length > 0) {
275
+ output += `\n### AI Insights\n`;
276
+ for (const stat of summaries) {
277
+ const date = new Date(stat.periodStart).toLocaleDateString();
278
+ const updatedAt = stat.aiSummaryUpdatedAt ? new Date(stat.aiSummaryUpdatedAt).toLocaleDateString() : 'Unknown';
279
+ output += `\n**${stat.periodType === 'week' ? 'Week of' : ''} ${date}** (updated: ${updatedAt})\n${stat.aiSummary}\n`;
280
+ }
281
+ }
282
+ }
283
+ output += `
284
+ ### Interactive Elements (${elements.length} tracked)
285
+ `;
286
+ if (elements.length === 0) {
287
+ output += "No interactive elements tracked on this page.\n";
288
+ }
289
+ else {
290
+ // Calculate total interactions for each element
291
+ const elementsWithTotals = elements.map((element) => {
292
+ const totalInteractions = element.stats.reduce((sum, s) => sum + (s.interactionCount || 0), 0);
293
+ return { ...element, totalInteractions };
294
+ }).sort((a, b) => b.totalInteractions - a.totalInteractions);
295
+ for (const element of elementsWithTotals.slice(0, 20)) {
296
+ const label = element.label || element.ariaLabel || element.elementId || element.tagName || 'Unknown';
297
+ const category = element.category?.toUpperCase() || 'OTHER';
298
+ output += `
299
+ **${category}: ${label}**
300
+ - Interactions: ${element.totalInteractions}
301
+ - Tag: \`<${element.tagName || 'unknown'}>\`${element.elementId ? ` id="${element.elementId}"` : ''}
302
+ ${element.destinationUrl ? `- Links to: ${element.destinationUrl}` : ''}
303
+ `;
304
+ }
305
+ if (elementsWithTotals.length > 20) {
306
+ output += `\n*...and ${elementsWithTotals.length - 20} more elements*\n`;
307
+ }
308
+ }
309
+ return output.trim();
310
+ }
311
+ // Helper function to format app context
312
+ function formatAppContext(data) {
313
+ const { stats, topPages, topEntryPages, topExitPages, recentInsights } = data;
314
+ let output = `## Application Analytics Overview\n`;
315
+ if (stats.length === 0) {
316
+ output += "\nNo statistics available for the selected time range.\n";
317
+ }
318
+ else {
319
+ // Aggregate totals
320
+ const totals = stats.reduce((acc, stat) => ({
321
+ sessions: acc.sessions + (stat.totalSessions || 0),
322
+ uniqueVisitors: acc.uniqueVisitors + (stat.uniqueVisitors || 0),
323
+ newVisitors: acc.newVisitors + (stat.newVisitors || 0),
324
+ pageViews: acc.pageViews + (stat.totalPageViews || 0),
325
+ totalDuration: acc.totalDuration + (stat.totalDuration || 0),
326
+ totalEvents: acc.totalEvents + (stat.totalEvents || 0),
327
+ bounces: acc.bounces + (stat.bounceCount || 0),
328
+ positiveSessions: acc.positiveSessions + (stat.positiveSessions || 0),
329
+ negativeSessions: acc.negativeSessions + (stat.negativeSessions || 0),
330
+ neutralSessions: acc.neutralSessions + (stat.neutralSessions || 0)
331
+ }), { sessions: 0, uniqueVisitors: 0, newVisitors: 0, pageViews: 0, totalDuration: 0, totalEvents: 0, bounces: 0, positiveSessions: 0, negativeSessions: 0, neutralSessions: 0 });
332
+ const avgSessionDuration = totals.sessions > 0 ? Math.round(totals.totalDuration / totals.sessions) : 0;
333
+ const bounceRate = totals.sessions > 0 ? ((totals.bounces / totals.sessions) * 100).toFixed(1) : '0';
334
+ output += `
335
+ ### Summary (${stats.length} ${stats[0]?.periodType || 'day'}s)
336
+ - **Total Sessions:** ${totals.sessions.toLocaleString()}
337
+ - **Unique Visitors:** ${totals.uniqueVisitors.toLocaleString()}
338
+ - **New Visitors:** ${totals.newVisitors.toLocaleString()}
339
+ - **Total Page Views:** ${totals.pageViews.toLocaleString()}
340
+ - **Avg Session Duration:** ${avgSessionDuration}s
341
+ - **Total Events:** ${totals.totalEvents.toLocaleString()}
342
+ - **Bounce Rate:** ${bounceRate}%
343
+
344
+ ### Session Sentiment
345
+ - Positive: ${totals.positiveSessions} | Neutral: ${totals.neutralSessions} | Negative: ${totals.negativeSessions}
346
+
347
+ ### Daily Breakdown
348
+ `;
349
+ for (const stat of stats.slice(0, 7)) {
350
+ const date = new Date(stat.periodStart).toLocaleDateString();
351
+ output += `| ${date} | Sessions: ${stat.totalSessions} | Visitors: ${stat.uniqueVisitors} | Page Views: ${stat.totalPageViews} | Bounce: ${stat.bounceRate}% |\n`;
352
+ }
353
+ // Add AI summaries if available
354
+ const summaries = stats.filter((stat) => stat.aiSummary);
355
+ if (summaries.length > 0) {
356
+ output += `\n### AI Insights\n`;
357
+ for (const stat of summaries.slice(0, 3)) {
358
+ const date = new Date(stat.periodStart).toLocaleDateString();
359
+ output += `\n**${stat.periodType === 'week' ? 'Week of' : ''} ${date}**\n${stat.aiSummary}\n`;
360
+ }
361
+ }
362
+ }
363
+ // Top pages
364
+ if (topPages && topPages.length > 0) {
365
+ output += `\n### Top Pages by Views\n`;
366
+ for (const page of topPages.slice(0, 5)) {
367
+ output += `- ${page.path}: ${page.viewCount} views\n`;
368
+ }
369
+ }
370
+ // Top entry pages
371
+ if (topEntryPages && topEntryPages.length > 0) {
372
+ output += `\n### Top Entry Pages\n`;
373
+ for (const page of topEntryPages.slice(0, 5)) {
374
+ output += `- ${page.path}: ${page.count} entries\n`;
375
+ }
376
+ }
377
+ // Top exit pages
378
+ if (topExitPages && topExitPages.length > 0) {
379
+ output += `\n### Top Exit Pages\n`;
380
+ for (const page of topExitPages.slice(0, 5)) {
381
+ output += `- ${page.path}: ${page.count} exits\n`;
382
+ }
383
+ }
384
+ // Recent insights
385
+ if (recentInsights && recentInsights.length > 0) {
386
+ output += `\n### Recent Insights\n`;
387
+ for (const insight of recentInsights.slice(0, 3)) {
388
+ const date = new Date(insight.createdAt).toLocaleDateString();
389
+ output += `\n**${insight.title}** (${date})\n${insight.content}\n`;
390
+ }
391
+ }
392
+ return output.trim();
393
+ }
394
+ // Helper function to format visitors list
395
+ function formatVisitors(data) {
396
+ const { visitors, total, limit, offset } = data;
397
+ let output = `## Visitors\n\n`;
398
+ output += `Showing ${visitors.length} of ${total} visitors (offset: ${offset}):\n`;
399
+ if (visitors.length === 0) {
400
+ output += "\nNo visitors found matching the criteria.\n";
401
+ return output.trim();
402
+ }
403
+ for (const visitor of visitors) {
404
+ const sentiment = visitor.overallSentiment ? ` | ${visitor.overallSentiment}` : '';
405
+ const trend = visitor.engagementTrend ? ` | Trend: ${visitor.engagementTrend}` : '';
406
+ const segment = visitor.segmentName ? ` | Segment: ${visitor.segmentName}` : '';
407
+ output += `
408
+ ### ${visitor.profileTitle || visitor.visitorId}
409
+ - **Visitor ID:** ${visitor.visitorId}
410
+ - **Sessions:** ${visitor.sessionCount}${sentiment}${trend}${segment}
411
+ - **First Visit:** ${new Date(visitor.firstVisitAt).toLocaleDateString()}
412
+ - **Last Visit:** ${new Date(visitor.lastVisitAt).toLocaleDateString()}
413
+ `;
414
+ if (visitor.profileSummary) {
415
+ output += `- **Profile:** ${visitor.profileSummary}\n`;
416
+ }
417
+ if (visitor.primaryInterests && visitor.primaryInterests.length > 0) {
418
+ output += `- **Interests:** ${visitor.primaryInterests.join(', ')}\n`;
419
+ }
420
+ if (visitor.recommendedAction) {
421
+ output += `- **Recommended Action:** ${visitor.recommendedAction}\n`;
422
+ }
423
+ }
424
+ return output.trim();
425
+ }
426
+ // Helper function to format visitor detail
427
+ function formatVisitorDetail(data) {
428
+ const { visitor, recentSessions } = data;
429
+ let output = `## Visitor Profile: ${visitor.profileTitle || visitor.visitorId}\n`;
430
+ output += `
431
+ **Visitor ID:** ${visitor.visitorId}
432
+ **First Visit:** ${new Date(visitor.firstVisitAt).toLocaleDateString()}
433
+ **Last Visit:** ${new Date(visitor.lastVisitAt).toLocaleDateString()}
434
+ `;
435
+ if (visitor.segmentName) {
436
+ output += `**Segment:** ${visitor.segmentName}\n`;
437
+ }
438
+ if (visitor.overallSentiment) {
439
+ output += `**Overall Sentiment:** ${visitor.overallSentiment}\n`;
440
+ }
441
+ if (visitor.engagementTrend) {
442
+ output += `**Engagement Trend:** ${visitor.engagementTrend}\n`;
443
+ }
444
+ if (visitor.profileSummary) {
445
+ output += `\n### Profile Summary\n${visitor.profileSummary}\n`;
446
+ }
447
+ if (visitor.primaryInterests && visitor.primaryInterests.length > 0) {
448
+ output += `\n### Primary Interests\n`;
449
+ for (const interest of visitor.primaryInterests) {
450
+ output += `- ${interest}\n`;
451
+ }
452
+ }
453
+ if (visitor.goalsInferred && visitor.goalsInferred.length > 0) {
454
+ output += `\n### Inferred Goals\n`;
455
+ for (const goal of visitor.goalsInferred) {
456
+ output += `- ${goal}\n`;
457
+ }
458
+ }
459
+ if (visitor.recommendedAction) {
460
+ output += `\n### Recommended Action\n${visitor.recommendedAction}\n`;
461
+ }
462
+ if (visitor.evidence && visitor.evidence.length > 0) {
463
+ output += `\n### Supporting Evidence\n`;
464
+ for (const e of visitor.evidence.slice(0, 5)) {
465
+ output += `- ${e}\n`;
466
+ }
467
+ }
468
+ if (recentSessions && recentSessions.length > 0) {
469
+ output += `\n### Recent Sessions (${recentSessions.length})\n`;
470
+ for (const session of recentSessions) {
471
+ const date = new Date(session.startTime).toLocaleDateString();
472
+ const sentiment = session.sentiment ? ` [${session.sentiment}]` : '';
473
+ output += `\n**Session ${session.id}** - ${date}${sentiment}\n`;
474
+ if (session.title) {
475
+ output += `Title: ${session.title}\n`;
476
+ }
477
+ if (session.description) {
478
+ output += `${session.description}\n`;
479
+ }
480
+ output += `Duration: ${session.duration || 0}s | Events: ${session.eventsCount || 0}\n`;
481
+ }
482
+ }
483
+ return output.trim();
484
+ }
485
+ // Helper function to format sessions list
486
+ function formatSessions(data) {
487
+ const { sessions, total, limit, offset } = data;
488
+ let output = `## Sessions\n\n`;
489
+ output += `Showing ${sessions.length} of ${total} sessions (offset: ${offset}):\n`;
490
+ if (sessions.length === 0) {
491
+ output += "\nNo sessions found matching the criteria.\n";
492
+ return output.trim();
493
+ }
494
+ for (const session of sessions) {
495
+ const date = new Date(session.startTime).toLocaleDateString();
496
+ const time = new Date(session.startTime).toLocaleTimeString();
497
+ const sentiment = session.sentiment ? ` [${session.sentiment}]` : '';
498
+ output += `
499
+ ### Session ${session.id} - ${date} ${time}${sentiment}
500
+ - **Visitor:** ${session.visitorId}
501
+ - **Duration:** ${session.duration || 0}s
502
+ - **Events:** ${session.eventsCount || 0}
503
+ `;
504
+ if (session.title) {
505
+ output += `- **Title:** ${session.title}\n`;
506
+ }
507
+ if (session.description) {
508
+ output += `- **Description:** ${session.description}\n`;
509
+ }
510
+ if (session.summary) {
511
+ output += `- **Summary:** ${session.summary}\n`;
512
+ }
513
+ }
514
+ return output.trim();
515
+ }
516
+ // Helper function to format session detail
517
+ function formatSessionDetail(data) {
518
+ const { session, visitor } = data;
519
+ const date = new Date(session.startTime).toLocaleDateString();
520
+ const time = new Date(session.startTime).toLocaleTimeString();
521
+ const sentiment = session.sentiment ? ` [${session.sentiment}]` : '';
522
+ let output = `## Session ${session.id}${sentiment}\n`;
523
+ output += `**Date:** ${date} ${time}\n`;
524
+ output += `**Duration:** ${session.duration || 0}s\n`;
525
+ output += `**Events:** ${session.eventsCount || 0}\n`;
526
+ if (session.title) {
527
+ output += `**Title:** ${session.title}\n`;
528
+ }
529
+ if (visitor) {
530
+ output += `\n### Visitor Context\n`;
531
+ output += `**Visitor ID:** ${visitor.visitorId}\n`;
532
+ if (visitor.profileTitle) {
533
+ output += `**Profile:** ${visitor.profileTitle}\n`;
534
+ }
535
+ if (visitor.profileSummary) {
536
+ output += `**Summary:** ${visitor.profileSummary}\n`;
537
+ }
538
+ if (visitor.overallSentiment) {
539
+ output += `**Overall Sentiment:** ${visitor.overallSentiment}\n`;
540
+ }
541
+ if (visitor.segmentName) {
542
+ output += `**Segment:** ${visitor.segmentName}\n`;
543
+ }
544
+ }
545
+ if (session.description) {
546
+ output += `\n### Session Description\n${session.description}\n`;
547
+ }
548
+ if (session.summary) {
549
+ output += `\n### AI Summary\n${session.summary}\n`;
550
+ }
551
+ if (session.events && Array.isArray(session.events) && session.events.length > 0) {
552
+ output += `\n### Events Timeline (${session.events.length} events)\n`;
553
+ // Group events by type for a summary
554
+ const eventTypes = {};
555
+ for (const event of session.events) {
556
+ const type = event.type || 'unknown';
557
+ eventTypes[type] = (eventTypes[type] || 0) + 1;
558
+ }
559
+ output += `**Event Summary:** `;
560
+ output += Object.entries(eventTypes)
561
+ .map(([type, count]) => `${type}: ${count}`)
562
+ .join(', ');
563
+ output += '\n\n';
564
+ // Show first 20 events with details
565
+ const eventsToShow = session.events.slice(0, 20);
566
+ for (const event of eventsToShow) {
567
+ const eventTime = event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : '';
568
+ output += `- **${event.type}**${eventTime ? ` (${eventTime})` : ''}`;
569
+ if (event.data) {
570
+ if (event.data.path)
571
+ output += ` - ${event.data.path}`;
572
+ if (event.data.elementId)
573
+ output += ` [#${event.data.elementId}]`;
574
+ if (event.data.label)
575
+ output += ` "${event.data.label}"`;
576
+ }
577
+ output += '\n';
578
+ }
579
+ if (session.events.length > 20) {
580
+ output += `\n*... and ${session.events.length - 20} more events*\n`;
581
+ }
582
+ }
583
+ return output.trim();
584
+ }
585
+ // Initialize MCP server
586
+ const server = new Server({
587
+ name: "lcontext-mcp",
588
+ version: "1.0.0"
589
+ }, {
590
+ capabilities: {
591
+ tools: {}
592
+ }
593
+ });
594
+ // List available tools
595
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
596
+ return {
597
+ tools: [
598
+ {
599
+ name: "get_page_context",
600
+ description: "Get comprehensive analytics context for a page including stats, visitor metrics, and all interactive elements with their engagement data. Use this when analyzing user behavior on a specific page.",
601
+ inputSchema: {
602
+ type: "object",
603
+ properties: {
604
+ path: {
605
+ type: "string",
606
+ description: "The page path to get context for (e.g., '/products', '/checkout')"
607
+ },
608
+ startDate: {
609
+ type: "string",
610
+ description: "Start date for stats (ISO format, e.g., '2025-01-01')"
611
+ },
612
+ endDate: {
613
+ type: "string",
614
+ description: "End date for stats (ISO format, e.g., '2025-01-13')"
615
+ },
616
+ periodType: {
617
+ type: "string",
618
+ enum: ["day", "week"],
619
+ description: "Period type for stats aggregation (default: 'day')"
620
+ }
621
+ },
622
+ required: ["path"]
623
+ }
624
+ },
625
+ {
626
+ name: "list_pages",
627
+ description: "List all tracked pages for your website. Use this to discover available pages before getting detailed context.",
628
+ inputSchema: {
629
+ type: "object",
630
+ properties: {
631
+ limit: {
632
+ type: "number",
633
+ description: "Maximum number of pages to return (default: 50, max: 200)"
634
+ },
635
+ search: {
636
+ type: "string",
637
+ description: "Search filter for page paths (e.g., '/product' to find all product pages)"
638
+ }
639
+ }
640
+ }
641
+ },
642
+ {
643
+ name: "get_element_context",
644
+ description: "Get detailed analytics for a specific interactive element by its label or ID. Use this to understand how users interact with buttons, links, or forms.",
645
+ inputSchema: {
646
+ type: "object",
647
+ properties: {
648
+ elementLabel: {
649
+ type: "string",
650
+ description: "The element's label text or aria-label to search for"
651
+ },
652
+ elementId: {
653
+ type: "string",
654
+ description: "The element's HTML ID to search for"
655
+ },
656
+ pagePath: {
657
+ type: "string",
658
+ description: "Optional page path to filter elements"
659
+ }
660
+ }
661
+ }
662
+ },
663
+ {
664
+ name: "get_app_context",
665
+ description: "Get application-wide analytics context including total sessions, visitors, page views, engagement metrics, and AI-generated insights. Use this to understand overall app performance and trends.",
666
+ inputSchema: {
667
+ type: "object",
668
+ properties: {
669
+ periodType: {
670
+ type: "string",
671
+ enum: ["day", "week"],
672
+ description: "Period type for stats aggregation (default: 'day')"
673
+ },
674
+ limit: {
675
+ type: "number",
676
+ description: "Number of periods to return (default: 7, max: 30)"
677
+ }
678
+ }
679
+ }
680
+ },
681
+ {
682
+ name: "get_visitors",
683
+ description: "Get a list of visitors with their AI-generated profiles, interests, engagement trends, and segment assignments. Use this to understand who is using your application.",
684
+ inputSchema: {
685
+ type: "object",
686
+ properties: {
687
+ limit: {
688
+ type: "number",
689
+ description: "Maximum number of visitors to return (default: 20, max: 100)"
690
+ },
691
+ offset: {
692
+ type: "number",
693
+ description: "Offset for pagination (default: 0)"
694
+ },
695
+ segmentId: {
696
+ type: "number",
697
+ description: "Filter by segment ID"
698
+ },
699
+ search: {
700
+ type: "string",
701
+ description: "Search in visitor ID, title, summary, interests, goals, action, evidence"
702
+ },
703
+ firstVisitAfter: {
704
+ type: "string",
705
+ description: "Filter visitors who first visited after this date (ISO format)"
706
+ },
707
+ firstVisitBefore: {
708
+ type: "string",
709
+ description: "Filter visitors who first visited before this date (ISO format)"
710
+ },
711
+ lastVisitAfter: {
712
+ type: "string",
713
+ description: "Filter visitors who last visited after this date (ISO format)"
714
+ },
715
+ lastVisitBefore: {
716
+ type: "string",
717
+ description: "Filter visitors who last visited before this date (ISO format)"
718
+ },
719
+ engagementTrend: {
720
+ type: "string",
721
+ enum: ["increasing", "stable", "decreasing"],
722
+ description: "Filter by engagement trend"
723
+ },
724
+ overallSentiment: {
725
+ type: "string",
726
+ enum: ["positive", "negative", "neutral", "mixed"],
727
+ description: "Filter by overall sentiment"
728
+ }
729
+ }
730
+ }
731
+ },
732
+ {
733
+ name: "get_visitor_detail",
734
+ description: "Get detailed profile and recent sessions for a specific visitor. Use this to understand individual user behavior and journey.",
735
+ inputSchema: {
736
+ type: "object",
737
+ properties: {
738
+ visitorId: {
739
+ type: "string",
740
+ description: "The visitor's unique identifier"
741
+ }
742
+ },
743
+ required: ["visitorId"]
744
+ }
745
+ },
746
+ {
747
+ name: "get_sessions",
748
+ description: "Get a list of user sessions with AI-generated summaries, titles, and sentiment analysis. Use this to understand user activity patterns.",
749
+ inputSchema: {
750
+ type: "object",
751
+ properties: {
752
+ limit: {
753
+ type: "number",
754
+ description: "Maximum number of sessions to return (default: 20, max: 100)"
755
+ },
756
+ offset: {
757
+ type: "number",
758
+ description: "Offset for pagination (default: 0)"
759
+ },
760
+ visitorId: {
761
+ type: "string",
762
+ description: "Filter sessions by visitor ID"
763
+ },
764
+ sentiment: {
765
+ type: "string",
766
+ enum: ["positive", "negative", "neutral"],
767
+ description: "Filter by session sentiment"
768
+ },
769
+ startDate: {
770
+ type: "string",
771
+ description: "Start date for filtering (ISO format, e.g., '2025-01-01')"
772
+ },
773
+ endDate: {
774
+ type: "string",
775
+ description: "End date for filtering (ISO format, e.g., '2025-01-15')"
776
+ },
777
+ search: {
778
+ type: "string",
779
+ description: "Search in session title and description"
780
+ },
781
+ minDuration: {
782
+ type: "number",
783
+ description: "Filter sessions with duration >= this value (seconds)"
784
+ },
785
+ maxDuration: {
786
+ type: "number",
787
+ description: "Filter sessions with duration <= this value (seconds)"
788
+ },
789
+ minEventsCount: {
790
+ type: "number",
791
+ description: "Filter sessions with events count >= this value"
792
+ },
793
+ maxEventsCount: {
794
+ type: "number",
795
+ description: "Filter sessions with events count <= this value"
796
+ }
797
+ }
798
+ }
799
+ },
800
+ {
801
+ name: "get_session_detail",
802
+ description: "Get detailed information about a specific session including full event data and visitor context. Use this to investigate specific user interactions.",
803
+ inputSchema: {
804
+ type: "object",
805
+ properties: {
806
+ sessionId: {
807
+ type: "number",
808
+ description: "The session's numeric ID"
809
+ }
810
+ },
811
+ required: ["sessionId"]
812
+ }
813
+ }
814
+ ]
815
+ };
816
+ });
817
+ // Handle tool calls
818
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
819
+ try {
820
+ // GET_PAGE_CONTEXT tool
821
+ if (request.params.name === "get_page_context") {
822
+ const args = getPageContextSchema.parse(request.params.arguments);
823
+ // Build query parameters
824
+ const params = new URLSearchParams();
825
+ if (args.startDate)
826
+ params.append('startDate', args.startDate);
827
+ if (args.endDate)
828
+ params.append('endDate', args.endDate);
829
+ if (args.periodType)
830
+ params.append('periodType', args.periodType);
831
+ // URL-encode the path to handle "/" and special characters correctly
832
+ // The path is encoded so "/" becomes "%2F" allowing the API to distinguish paths
833
+ const encodedPath = encodeURIComponent(args.path);
834
+ const queryString = params.toString();
835
+ const endpoint = `/api/mcp/pages/${encodedPath}${queryString ? `?${queryString}` : ''}`;
836
+ const data = await apiRequest(endpoint);
837
+ const contextOutput = formatPageContext(data);
838
+ return {
839
+ content: [{
840
+ type: "text",
841
+ text: contextOutput
842
+ }]
843
+ };
844
+ }
845
+ // LIST_PAGES tool
846
+ if (request.params.name === "list_pages") {
847
+ const args = listPagesSchema.parse(request.params.arguments || {});
848
+ const params = new URLSearchParams();
849
+ if (args.limit)
850
+ params.append('limit', args.limit.toString());
851
+ if (args.search)
852
+ params.append('search', args.search);
853
+ const queryString = params.toString();
854
+ const endpoint = `/api/mcp/pages${queryString ? `?${queryString}` : ''}`;
855
+ const data = await apiRequest(endpoint);
856
+ if (data.pages.length === 0) {
857
+ return {
858
+ content: [{
859
+ type: "text",
860
+ text: `No pages found. Make sure tracking is set up and data has been collected.`
861
+ }]
862
+ };
863
+ }
864
+ let output = `## Tracked Pages\n\n`;
865
+ output += `Found ${data.pages.length} pages${data.total > data.pages.length ? ` (showing first ${data.pages.length} of ${data.total})` : ''}:\n\n`;
866
+ for (const page of data.pages) {
867
+ const lastSeen = new Date(page.lastSeenAt).toLocaleDateString();
868
+ output += `- **${page.path}**${page.title ? ` - ${page.title}` : ''} (last seen: ${lastSeen})\n`;
869
+ }
870
+ return {
871
+ content: [{
872
+ type: "text",
873
+ text: output.trim()
874
+ }]
875
+ };
876
+ }
877
+ // GET_ELEMENT_CONTEXT tool
878
+ if (request.params.name === "get_element_context") {
879
+ const args = getElementContextSchema.parse(request.params.arguments || {});
880
+ if (!args.elementLabel && !args.elementId) {
881
+ return {
882
+ isError: true,
883
+ content: [{
884
+ type: "text",
885
+ text: "Either elementLabel or elementId is required"
886
+ }]
887
+ };
888
+ }
889
+ const params = new URLSearchParams();
890
+ if (args.elementLabel)
891
+ params.append('elementLabel', args.elementLabel);
892
+ if (args.elementId)
893
+ params.append('elementId', args.elementId);
894
+ if (args.pagePath)
895
+ params.append('pagePath', args.pagePath);
896
+ const queryString = params.toString();
897
+ const endpoint = `/api/mcp/elements?${queryString}`;
898
+ const data = await apiRequest(endpoint);
899
+ if (data.elements.length === 0) {
900
+ return {
901
+ content: [{
902
+ type: "text",
903
+ text: `No elements found matching the criteria.`
904
+ }]
905
+ };
906
+ }
907
+ let output = `## Elements Found\n\nFound ${data.elements.length} matching element(s):\n`;
908
+ for (const element of data.elements) {
909
+ output += `
910
+ ### ${element.category?.toUpperCase() || 'ELEMENT'}: ${element.label || element.elementId || 'Unknown'}
911
+ - **Page:** ${element.pagePath}
912
+ - **Tag:** \`<${element.tagName || 'unknown'}>\`
913
+ - **HTML ID:** ${element.elementId || 'N/A'}
914
+ - **Name:** ${element.elementName || 'N/A'}
915
+ - **ARIA Label:** ${element.ariaLabel || 'N/A'}
916
+ - **Category:** ${element.category || 'N/A'}
917
+ ${element.destinationUrl ? `- **Links to:** ${element.destinationUrl}` : ''}
918
+ - **Total Interactions:** ${element.totalInteractions}
919
+ - **Unique Visitors:** ${element.uniqueVisitors}
920
+ - **First Seen:** ${new Date(element.firstSeenAt).toLocaleDateString()}
921
+ - **Last Seen:** ${new Date(element.lastSeenAt).toLocaleDateString()}
922
+ `;
923
+ }
924
+ return {
925
+ content: [{
926
+ type: "text",
927
+ text: output.trim()
928
+ }]
929
+ };
930
+ }
931
+ // GET_APP_CONTEXT tool
932
+ if (request.params.name === "get_app_context") {
933
+ const args = getAppContextSchema.parse(request.params.arguments || {});
934
+ const params = new URLSearchParams();
935
+ if (args.periodType)
936
+ params.append('periodType', args.periodType);
937
+ if (args.limit)
938
+ params.append('limit', args.limit.toString());
939
+ const queryString = params.toString();
940
+ const endpoint = `/api/mcp/app-context${queryString ? `?${queryString}` : ''}`;
941
+ const data = await apiRequest(endpoint);
942
+ const contextOutput = formatAppContext(data);
943
+ return {
944
+ content: [{
945
+ type: "text",
946
+ text: contextOutput
947
+ }]
948
+ };
949
+ }
950
+ // GET_VISITORS tool
951
+ if (request.params.name === "get_visitors") {
952
+ const args = getVisitorsSchema.parse(request.params.arguments || {});
953
+ const params = new URLSearchParams();
954
+ if (args.limit)
955
+ params.append('limit', args.limit.toString());
956
+ if (args.offset)
957
+ params.append('offset', args.offset.toString());
958
+ if (args.segmentId)
959
+ params.append('segmentId', args.segmentId.toString());
960
+ if (args.search)
961
+ params.append('search', args.search);
962
+ if (args.firstVisitAfter)
963
+ params.append('firstVisitAfter', args.firstVisitAfter);
964
+ if (args.firstVisitBefore)
965
+ params.append('firstVisitBefore', args.firstVisitBefore);
966
+ if (args.lastVisitAfter)
967
+ params.append('lastVisitAfter', args.lastVisitAfter);
968
+ if (args.lastVisitBefore)
969
+ params.append('lastVisitBefore', args.lastVisitBefore);
970
+ if (args.engagementTrend)
971
+ params.append('engagementTrend', args.engagementTrend);
972
+ if (args.overallSentiment)
973
+ params.append('overallSentiment', args.overallSentiment);
974
+ const queryString = params.toString();
975
+ const endpoint = `/api/mcp/visitors${queryString ? `?${queryString}` : ''}`;
976
+ const data = await apiRequest(endpoint);
977
+ const contextOutput = formatVisitors(data);
978
+ return {
979
+ content: [{
980
+ type: "text",
981
+ text: contextOutput
982
+ }]
983
+ };
984
+ }
985
+ // GET_VISITOR_DETAIL tool
986
+ if (request.params.name === "get_visitor_detail") {
987
+ const args = getVisitorDetailSchema.parse(request.params.arguments || {});
988
+ const endpoint = `/api/mcp/visitors/${encodeURIComponent(args.visitorId)}`;
989
+ const data = await apiRequest(endpoint);
990
+ const contextOutput = formatVisitorDetail(data);
991
+ return {
992
+ content: [{
993
+ type: "text",
994
+ text: contextOutput
995
+ }]
996
+ };
997
+ }
998
+ // GET_SESSIONS tool
999
+ if (request.params.name === "get_sessions") {
1000
+ const args = getSessionsSchema.parse(request.params.arguments || {});
1001
+ const params = new URLSearchParams();
1002
+ if (args.limit)
1003
+ params.append('limit', args.limit.toString());
1004
+ if (args.offset)
1005
+ params.append('offset', args.offset.toString());
1006
+ if (args.visitorId)
1007
+ params.append('visitorId', args.visitorId);
1008
+ if (args.sentiment)
1009
+ params.append('sentiment', args.sentiment);
1010
+ if (args.startDate)
1011
+ params.append('startDate', args.startDate);
1012
+ if (args.endDate)
1013
+ params.append('endDate', args.endDate);
1014
+ if (args.search)
1015
+ params.append('search', args.search);
1016
+ if (args.minDuration !== undefined)
1017
+ params.append('minDuration', args.minDuration.toString());
1018
+ if (args.maxDuration !== undefined)
1019
+ params.append('maxDuration', args.maxDuration.toString());
1020
+ if (args.minEventsCount !== undefined)
1021
+ params.append('minEventsCount', args.minEventsCount.toString());
1022
+ if (args.maxEventsCount !== undefined)
1023
+ params.append('maxEventsCount', args.maxEventsCount.toString());
1024
+ const queryString = params.toString();
1025
+ const endpoint = `/api/mcp/sessions${queryString ? `?${queryString}` : ''}`;
1026
+ const data = await apiRequest(endpoint);
1027
+ const contextOutput = formatSessions(data);
1028
+ return {
1029
+ content: [{
1030
+ type: "text",
1031
+ text: contextOutput
1032
+ }]
1033
+ };
1034
+ }
1035
+ // GET_SESSION_DETAIL tool
1036
+ if (request.params.name === "get_session_detail") {
1037
+ const args = getSessionDetailSchema.parse(request.params.arguments || {});
1038
+ const endpoint = `/api/mcp/sessions/${args.sessionId}`;
1039
+ const data = await apiRequest(endpoint);
1040
+ const contextOutput = formatSessionDetail(data);
1041
+ return {
1042
+ content: [{
1043
+ type: "text",
1044
+ text: contextOutput
1045
+ }]
1046
+ };
1047
+ }
1048
+ return {
1049
+ isError: true,
1050
+ content: [{
1051
+ type: "text",
1052
+ text: `Unknown tool: ${request.params.name}`
1053
+ }]
1054
+ };
1055
+ }
1056
+ catch (error) {
1057
+ return {
1058
+ isError: true,
1059
+ content: [{
1060
+ type: "text",
1061
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
1062
+ }]
1063
+ };
1064
+ }
1065
+ });
1066
+ // Start server
1067
+ async function main() {
1068
+ if (!API_KEY) {
1069
+ console.error("LCONTEXT_API_KEY environment variable is required");
1070
+ console.error("Get your API key from: https://lcontext.com/settings");
1071
+ process.exit(1);
1072
+ }
1073
+ const transport = new StdioServerTransport();
1074
+ await server.connect(transport);
1075
+ console.error("lcontext MCP server running on stdio");
1076
+ console.error(`Connected to: ${API_BASE_URL}`);
1077
+ }
1078
+ // Don't start server if running --update
1079
+ if (!args.includes('--update')) {
1080
+ main().catch(err => {
1081
+ console.error("Fatal error:", err);
1082
+ process.exit(1);
1083
+ });
1084
+ }