@adrata/adrata-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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
package/analytics.js ADDED
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Analytics and telemetry for Adrata MCP Server.
3
+ *
4
+ * Tracks:
5
+ * - Tool invocations (tool name, tier, latency, success/error)
6
+ * - Search queries with classification (industry/role/location)
7
+ * - Tier gate impressions (upgrade CTA shown)
8
+ * - Conversion events (free->signup, signup->pro, pro->enterprise)
9
+ * - Session lifecycle (start, end, tools used, duration)
10
+ *
11
+ * Privacy:
12
+ * - All PII is hashed (SHA-256) before storage
13
+ * - Only metadata is tracked, never tool result content
14
+ * - ADRATA_TELEMETRY=off disables all tracking
15
+ * - Aggregation by default for dashboard queries
16
+ *
17
+ * Builds on top of the existing event logging in memory.js.
18
+ * All analytics calls are non-blocking (fire-and-forget).
19
+ */
20
+
21
+ import crypto from 'node:crypto';
22
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Configuration
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const TELEMETRY_ENABLED = process.env.ADRATA_TELEMETRY !== 'off';
31
+ const ADRATA_DIR = join(homedir(), '.adrata');
32
+ const ANALYTICS_FILE = join(ADRATA_DIR, 'analytics.json');
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // PII hashing
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Hash PII values with SHA-256. Returns a hex digest prefix (16 chars)
40
+ * so values can be grouped/counted without storing raw PII.
41
+ *
42
+ * @param {string} value - Raw PII value (email, name, etc.)
43
+ * @returns {string} Hashed value prefix
44
+ */
45
+ export function hashPII(value) {
46
+ if (!value || typeof value !== 'string') return '';
47
+ return crypto.createHash('sha256').update(value.toLowerCase().trim()).digest('hex').slice(0, 16);
48
+ }
49
+
50
+ /**
51
+ * Sanitize an args object by hashing any PII fields.
52
+ * Recognized PII fields: email, name, firstName, lastName, personName, personEmail
53
+ *
54
+ * @param {object} args - Tool arguments
55
+ * @returns {object} Sanitized copy with PII hashed
56
+ */
57
+ function sanitizeArgs(args) {
58
+ if (!args || typeof args !== 'object') return {};
59
+ const PII_FIELDS = ['email', 'name', 'firstName', 'lastName', 'personName', 'personEmail', 'phone'];
60
+ const sanitized = {};
61
+ for (const [k, v] of Object.entries(args)) {
62
+ if (v === undefined || v === null) continue;
63
+ if (PII_FIELDS.includes(k) && typeof v === 'string') {
64
+ sanitized[k] = hashPII(v);
65
+ } else {
66
+ sanitized[k] = v;
67
+ }
68
+ }
69
+ return sanitized;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Search query classification
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const INDUSTRY_KEYWORDS = [
77
+ 'fintech', 'healthcare', 'saas', 'ecommerce', 'banking', 'insurance',
78
+ 'manufacturing', 'retail', 'technology', 'energy', 'telecom', 'media',
79
+ 'pharma', 'biotech', 'logistics', 'real estate', 'education', 'automotive',
80
+ ];
81
+
82
+ const ROLE_KEYWORDS = [
83
+ 'ceo', 'cto', 'cfo', 'coo', 'vp', 'director', 'manager', 'head of',
84
+ 'engineer', 'developer', 'sales', 'marketing', 'product', 'design',
85
+ 'founder', 'president', 'chief', 'executive', 'analyst', 'consultant',
86
+ ];
87
+
88
+ const LOCATION_KEYWORDS = [
89
+ 'new york', 'san francisco', 'london', 'berlin', 'tokyo', 'singapore',
90
+ 'seattle', 'austin', 'boston', 'chicago', 'los angeles', 'toronto',
91
+ 'us', 'uk', 'eu', 'apac', 'emea', 'remote', 'california', 'texas',
92
+ ];
93
+
94
+ /**
95
+ * Classify a search query into categories.
96
+ *
97
+ * @param {string} query - The search query text
98
+ * @returns {{ industry: string|null, role: string|null, location: string|null }}
99
+ */
100
+ export function classifyQuery(query) {
101
+ if (!query || typeof query !== 'string') return { industry: null, role: null, location: null };
102
+ const lower = query.toLowerCase();
103
+
104
+ const industry = INDUSTRY_KEYWORDS.find(kw => lower.includes(kw)) || null;
105
+ const role = ROLE_KEYWORDS.find(kw => lower.includes(kw)) || null;
106
+ const location = LOCATION_KEYWORDS.find(kw => lower.includes(kw)) || null;
107
+
108
+ return { industry, role, location };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Local analytics storage (fallback for free tier / offline)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function ensureDir() {
116
+ if (!existsSync(ADRATA_DIR)) {
117
+ mkdirSync(ADRATA_DIR, { recursive: true });
118
+ }
119
+ }
120
+
121
+ function readLocalAnalytics() {
122
+ ensureDir();
123
+ if (!existsSync(ANALYTICS_FILE)) {
124
+ return { events: [], sessions: [] };
125
+ }
126
+ try {
127
+ return JSON.parse(readFileSync(ANALYTICS_FILE, 'utf-8'));
128
+ } catch {
129
+ return { events: [], sessions: [] };
130
+ }
131
+ }
132
+
133
+ function writeLocalAnalytics(data) {
134
+ ensureDir();
135
+ // Keep only last 10,000 events to prevent unbounded growth
136
+ if (data.events && data.events.length > 10000) {
137
+ data.events = data.events.slice(-10000);
138
+ }
139
+ writeFileSync(ANALYTICS_FILE, JSON.stringify(data, null, 2));
140
+ }
141
+
142
+ function appendLocalEvent(event) {
143
+ try {
144
+ const data = readLocalAnalytics();
145
+ data.events.push(event);
146
+ writeLocalAnalytics(data);
147
+ } catch {
148
+ // Swallow errors — analytics must never break the tool
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Event tracking (fire-and-forget)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Track a tool invocation event.
158
+ *
159
+ * @param {Function} apiFn - The api() helper
160
+ * @param {object} auth - AUTH context
161
+ * @param {string} toolName
162
+ * @param {string} tier - Tool's required tier
163
+ * @param {object} args - Tool arguments (will be sanitized)
164
+ * @param {number} latencyMs
165
+ * @param {boolean} success
166
+ * @param {string|null} error - Error message if failed
167
+ */
168
+ export function trackToolInvocation(apiFn, auth, toolName, tier, args, latencyMs, success, error) {
169
+ if (!TELEMETRY_ENABLED) return;
170
+
171
+ const event = {
172
+ event_type: 'tool_invocation',
173
+ tool_name: toolName,
174
+ tier,
175
+ args_summary: summarizeForAnalytics(sanitizeArgs(args)),
176
+ latency_ms: latencyMs,
177
+ success,
178
+ error: error ? error.slice(0, 200) : undefined,
179
+ timestamp: new Date().toISOString(),
180
+ };
181
+
182
+ if (auth.authenticated) {
183
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
184
+ } else {
185
+ appendLocalEvent(event);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Track a search query with classification.
191
+ *
192
+ * @param {Function} apiFn
193
+ * @param {object} auth
194
+ * @param {string} query - The search text
195
+ * @param {string} entityType - 'company', 'person', 'lead', etc.
196
+ * @param {number} resultsCount
197
+ * @param {string} tier
198
+ */
199
+ export function trackSearchQuery(apiFn, auth, query, entityType, resultsCount, tier) {
200
+ if (!TELEMETRY_ENABLED) return;
201
+
202
+ const classification = classifyQuery(query);
203
+ const event = {
204
+ event_type: 'search_query',
205
+ query_hash: hashPII(query),
206
+ entity_type: entityType,
207
+ results_count: resultsCount,
208
+ tier,
209
+ classification,
210
+ timestamp: new Date().toISOString(),
211
+ };
212
+
213
+ if (auth.authenticated) {
214
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
215
+ } else {
216
+ appendLocalEvent(event);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Track a tier gate impression (when user hits an upgrade wall).
222
+ *
223
+ * @param {Function} apiFn
224
+ * @param {object} auth
225
+ * @param {string} toolAttempted
226
+ * @param {string} currentTier
227
+ * @param {boolean} upgradeShown
228
+ */
229
+ export function trackTierGate(apiFn, auth, toolAttempted, currentTier, upgradeShown) {
230
+ if (!TELEMETRY_ENABLED) return;
231
+
232
+ const event = {
233
+ event_type: 'tier_gate',
234
+ tool_attempted: toolAttempted,
235
+ current_tier: currentTier,
236
+ upgrade_shown: upgradeShown,
237
+ timestamp: new Date().toISOString(),
238
+ };
239
+
240
+ if (auth.authenticated) {
241
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
242
+ } else {
243
+ appendLocalEvent(event);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Track a conversion event (tier upgrade).
249
+ *
250
+ * @param {Function} apiFn
251
+ * @param {object} auth
252
+ * @param {string} fromTier
253
+ * @param {string} toTier
254
+ * @param {string} trigger - What caused the conversion (e.g., 'connect_workspace', 'upgrade_account')
255
+ */
256
+ export function trackConversion(apiFn, auth, fromTier, toTier, trigger) {
257
+ if (!TELEMETRY_ENABLED) return;
258
+
259
+ const event = {
260
+ event_type: 'conversion',
261
+ from_tier: fromTier,
262
+ to_tier: toTier,
263
+ trigger,
264
+ timestamp: new Date().toISOString(),
265
+ };
266
+
267
+ if (auth.authenticated) {
268
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
269
+ } else {
270
+ appendLocalEvent(event);
271
+ }
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Session tracking
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /** @type {{ startTime: number, toolsUsed: Set<string>, invocationCount: number } | null} */
279
+ let currentSession = null;
280
+
281
+ /**
282
+ * Start a new analytics session.
283
+ *
284
+ * @param {Function} apiFn
285
+ * @param {object} auth
286
+ */
287
+ export function startSession(apiFn, auth) {
288
+ if (!TELEMETRY_ENABLED) return;
289
+
290
+ currentSession = {
291
+ startTime: Date.now(),
292
+ toolsUsed: new Set(),
293
+ invocationCount: 0,
294
+ };
295
+
296
+ const event = {
297
+ event_type: 'session',
298
+ action: 'start',
299
+ tier: auth.tier,
300
+ timestamp: new Date().toISOString(),
301
+ };
302
+
303
+ if (auth.authenticated) {
304
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
305
+ } else {
306
+ appendLocalEvent(event);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * End the current analytics session.
312
+ *
313
+ * @param {Function} apiFn
314
+ * @param {object} auth
315
+ */
316
+ export function endSession(apiFn, auth) {
317
+ if (!TELEMETRY_ENABLED || !currentSession) return;
318
+
319
+ const event = {
320
+ event_type: 'session',
321
+ action: 'end',
322
+ tier: auth.tier,
323
+ duration_ms: Date.now() - currentSession.startTime,
324
+ tools_used_count: currentSession.toolsUsed.size,
325
+ invocation_count: currentSession.invocationCount,
326
+ timestamp: new Date().toISOString(),
327
+ };
328
+
329
+ if (auth.authenticated) {
330
+ apiFn('POST', '/api/v1/mcp/events', { body: event }).catch(() => {});
331
+ } else {
332
+ appendLocalEvent(event);
333
+ }
334
+
335
+ currentSession = null;
336
+ }
337
+
338
+ /**
339
+ * Record that a tool was used in the current session.
340
+ *
341
+ * @param {string} toolName
342
+ */
343
+ function recordSessionTool(toolName) {
344
+ if (currentSession) {
345
+ currentSession.toolsUsed.add(toolName);
346
+ currentSession.invocationCount++;
347
+ }
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Analytics summary helpers
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function summarizeForAnalytics(args) {
355
+ if (!args || typeof args !== 'object') return undefined;
356
+ const parts = [];
357
+ for (const [k, v] of Object.entries(args)) {
358
+ if (v === undefined || v === null) continue;
359
+ const str = typeof v === 'string' ? v : JSON.stringify(v);
360
+ parts.push(`${k}=${str.length > 40 ? str.slice(0, 37) + '...' : str}`);
361
+ }
362
+ const summary = parts.join(', ');
363
+ return summary.length > 150 ? summary.slice(0, 147) + '...' : summary;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Search tools that need query tracking
368
+ // ---------------------------------------------------------------------------
369
+
370
+ const SEARCH_TOOLS = {
371
+ search_companies: 'company',
372
+ search_people: 'person',
373
+ search_leads: 'lead',
374
+ search_emails: 'email',
375
+ search_opportunities: 'opportunity',
376
+ find_company: 'company',
377
+ find_person: 'person',
378
+ find_or_create_person: 'person',
379
+ find_or_create_company: 'company',
380
+ };
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Analytics wrapper for server.tool
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /**
387
+ * Wrap the MCP server's tool method to add analytics tracking on every
388
+ * invocation. This builds on top of the existing event logging in memory.js
389
+ * by adding richer analytics (search classification, tier gates, sessions).
390
+ *
391
+ * Must be applied AFTER the event logging wrapper from memory.js.
392
+ *
393
+ * @param {object} server - McpServer instance
394
+ * @param {Function} apiFn - The api() helper
395
+ * @param {object} auth - AUTH context
396
+ * @param {Function} checkToolAccessFn - checkToolAccess from auth.js
397
+ */
398
+ export function wrapWithAnalytics(server, apiFn, auth, checkToolAccessFn) {
399
+ if (!TELEMETRY_ENABLED) return;
400
+
401
+ const _analyticsWrappedTool = server.tool.bind(server);
402
+
403
+ server.tool = function analyticsTool(name, ...rest) {
404
+ const handler = rest[rest.length - 1];
405
+ rest[rest.length - 1] = async function analyticsHandler(...handlerArgs) {
406
+ const start = Date.now();
407
+ let success = true;
408
+ let errorMsg = null;
409
+
410
+ // Track tier gate if access was denied (check without modifying flow)
411
+ const access = checkToolAccessFn(name, auth);
412
+ if (!access.allowed) {
413
+ trackTierGate(apiFn, auth, name, auth.tier, true);
414
+ }
415
+
416
+ try {
417
+ const result = await handler(...handlerArgs);
418
+
419
+ // Track search queries for search tools
420
+ const args = handlerArgs[0] || {};
421
+ const entityType = SEARCH_TOOLS[name];
422
+ if (entityType) {
423
+ const query = args.query || args.name || args.search || '';
424
+ if (query) {
425
+ trackSearchQuery(apiFn, auth, query, entityType, 0, auth.tier);
426
+ }
427
+ }
428
+
429
+ return result;
430
+ } catch (err) {
431
+ success = false;
432
+ errorMsg = err?.message || String(err);
433
+ throw err;
434
+ } finally {
435
+ const latency = Date.now() - start;
436
+ const args = handlerArgs[0] || {};
437
+ trackToolInvocation(apiFn, auth, name, auth.tier, args, latency, success, errorMsg);
438
+ recordSessionTool(name);
439
+ }
440
+ };
441
+ return _analyticsWrappedTool(name, ...rest);
442
+ };
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Dashboard data aggregation (local analytics)
447
+ // ---------------------------------------------------------------------------
448
+
449
+ /**
450
+ * Aggregate local analytics into dashboard-ready data.
451
+ * Used when the Rust API endpoint is not available (free tier).
452
+ *
453
+ * @param {string} period - '1d', '7d', '30d', '90d'
454
+ * @returns {object} Dashboard data
455
+ */
456
+ export function getLocalDashboard(period = '7d') {
457
+ const data = readLocalAnalytics();
458
+ const events = data.events || [];
459
+
460
+ // Parse period into milliseconds
461
+ const periodMs = parsePeriod(period);
462
+ const cutoff = new Date(Date.now() - periodMs).toISOString();
463
+ const filtered = events.filter(e => e.timestamp >= cutoff);
464
+
465
+ // Tool invocation stats
466
+ const toolEvents = filtered.filter(e => e.event_type === 'tool_invocation');
467
+ const toolCounts = {};
468
+ let totalLatency = 0;
469
+ let successCount = 0;
470
+ let errorCount = 0;
471
+
472
+ for (const e of toolEvents) {
473
+ toolCounts[e.tool_name] = (toolCounts[e.tool_name] || 0) + 1;
474
+ totalLatency += e.latency_ms || 0;
475
+ if (e.success) successCount++;
476
+ else errorCount++;
477
+ }
478
+
479
+ const topTools = Object.entries(toolCounts)
480
+ .sort(([, a], [, b]) => b - a)
481
+ .slice(0, 10)
482
+ .map(([name, count]) => ({ name, count }));
483
+
484
+ // Search query stats
485
+ const searchEvents = filtered.filter(e => e.event_type === 'search_query');
486
+ const queryCounts = {};
487
+ const classificationCounts = { industry: {}, role: {}, location: {} };
488
+
489
+ for (const e of searchEvents) {
490
+ const key = e.query_hash || 'unknown';
491
+ queryCounts[key] = (queryCounts[key] || 0) + 1;
492
+ if (e.classification) {
493
+ for (const dim of ['industry', 'role', 'location']) {
494
+ const val = e.classification[dim];
495
+ if (val) classificationCounts[dim][val] = (classificationCounts[dim][val] || 0) + 1;
496
+ }
497
+ }
498
+ }
499
+
500
+ const topQueries = Object.entries(queryCounts)
501
+ .sort(([, a], [, b]) => b - a)
502
+ .slice(0, 10)
503
+ .map(([hash, count]) => ({ query_hash: hash, count }));
504
+
505
+ // Tier gate stats
506
+ const tierGateEvents = filtered.filter(e => e.event_type === 'tier_gate');
507
+ const tierGateCounts = {};
508
+ for (const e of tierGateEvents) {
509
+ tierGateCounts[e.tool_attempted] = (tierGateCounts[e.tool_attempted] || 0) + 1;
510
+ }
511
+
512
+ // Conversion funnel
513
+ const conversionEvents = filtered.filter(e => e.event_type === 'conversion');
514
+ const conversions = {};
515
+ for (const e of conversionEvents) {
516
+ const key = `${e.from_tier}->${e.to_tier}`;
517
+ conversions[key] = (conversions[key] || 0) + 1;
518
+ }
519
+
520
+ // Session stats
521
+ const sessionEnds = filtered.filter(e => e.event_type === 'session' && e.action === 'end');
522
+ const avgDuration = sessionEnds.length > 0
523
+ ? sessionEnds.reduce((sum, e) => sum + (e.duration_ms || 0), 0) / sessionEnds.length
524
+ : 0;
525
+
526
+ return {
527
+ period,
528
+ total_events: filtered.length,
529
+ tool_invocations: {
530
+ total: toolEvents.length,
531
+ success: successCount,
532
+ errors: errorCount,
533
+ avg_latency_ms: toolEvents.length > 0 ? Math.round(totalLatency / toolEvents.length) : 0,
534
+ top_tools: topTools,
535
+ },
536
+ search_queries: {
537
+ total: searchEvents.length,
538
+ top_queries: topQueries,
539
+ classification_distribution: classificationCounts,
540
+ },
541
+ tier_gates: {
542
+ total: tierGateEvents.length,
543
+ by_tool: tierGateCounts,
544
+ },
545
+ conversion_funnel: conversions,
546
+ sessions: {
547
+ total: sessionEnds.length,
548
+ avg_duration_ms: Math.round(avgDuration),
549
+ },
550
+ };
551
+ }
552
+
553
+ function parsePeriod(period) {
554
+ const match = period.match(/^(\d+)(d|h|m)$/);
555
+ if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7d
556
+ const [, num, unit] = match;
557
+ const n = parseInt(num, 10);
558
+ switch (unit) {
559
+ case 'd': return n * 24 * 60 * 60 * 1000;
560
+ case 'h': return n * 60 * 60 * 1000;
561
+ case 'm': return n * 60 * 1000;
562
+ default: return 7 * 24 * 60 * 60 * 1000;
563
+ }
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Register analytics tools on the MCP server
568
+ // ---------------------------------------------------------------------------
569
+
570
+ /**
571
+ * Register the analytics dashboard tool and wire up the analytics wrapper.
572
+ *
573
+ * @param {object} server - McpServer instance
574
+ * @param {object} opts - { z, api, auth, ok, checkToolAccess }
575
+ */
576
+ export function registerAnalytics(server, { z, api, auth, ok, checkToolAccess }) {
577
+ // Wire up the analytics wrapper on all future tool registrations
578
+ wrapWithAnalytics(server, api, auth, checkToolAccess);
579
+
580
+ // Start a session
581
+ startSession(api, auth);
582
+
583
+ // Graceful shutdown: end session on process exit
584
+ process.on('beforeExit', () => endSession(api, auth));
585
+ process.on('SIGINT', () => { endSession(api, auth); process.exit(0); });
586
+ process.on('SIGTERM', () => { endSession(api, auth); process.exit(0); });
587
+
588
+ // ----- get_mcp_analytics -----
589
+ server.tool('get_mcp_analytics',
590
+ 'Get MCP usage analytics dashboard: top tools, search queries, conversion funnel, tier distribution. Admin only for server-side data; free tier shows local analytics.',
591
+ {
592
+ period: z.string().optional().describe('Time period: 1d, 7d, 30d, 90d (default: 7d)'),
593
+ },
594
+ async (args) => {
595
+ const period = args.period || '7d';
596
+
597
+ if (auth.authenticated) {
598
+ // The MCP surface exposes no dedicated analytics route. The
599
+ // subscription endpoint carries the server-side usage rollup
600
+ // (usage.tool_invocations_30d) alongside plan/trial state.
601
+ try {
602
+ const data = await api('GET', '/api/v1/mcp/subscription');
603
+ const payload = data?.data || data || {};
604
+ return ok({
605
+ period,
606
+ server: payload,
607
+ usage: payload.usage || null,
608
+ local: getLocalDashboard(period),
609
+ });
610
+ } catch {
611
+ // Fallback to local if server endpoint not available
612
+ return ok(getLocalDashboard(period));
613
+ }
614
+ }
615
+
616
+ // Free tier: local analytics only
617
+ return ok(getLocalDashboard(period));
618
+ }
619
+ );
620
+
621
+ // ----- track_conversion (internal use, exposed for billing integration) -----
622
+ server.tool('track_conversion',
623
+ 'Track a tier conversion event (internal). Used by billing to record upgrades: free->pro, pro->enterprise.',
624
+ {
625
+ from_tier: z.enum(['free', 'pro', 'enterprise']).describe('Previous tier'),
626
+ to_tier: z.enum(['free', 'pro', 'enterprise']).describe('New tier'),
627
+ trigger: z.string().optional().describe('What triggered the conversion'),
628
+ },
629
+ async (args) => {
630
+ trackConversion(api, auth, args.from_tier, args.to_tier, args.trigger || 'manual');
631
+ return ok({ tracked: true, from: args.from_tier, to: args.to_tier });
632
+ }
633
+ );
634
+ }