@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.
- package/README.md +548 -0
- package/access/auth.js +289 -0
- package/access/oauth.js +1059 -0
- package/access/resource-metadata.js +167 -0
- package/access/tiers.js +422 -0
- package/analytics.js +634 -0
- package/api-bridge.js +499 -0
- package/governance/money.js +141 -0
- package/output-formatter.js +589 -0
- package/package.json +68 -0
- package/resources.js +246 -0
- package/security.js +690 -0
- package/server.js +2139 -0
- package/server.json +55 -0
- package/skills/backlog-triage/SKILL.md +115 -0
- package/skills/board-review/SKILL.md +96 -0
- package/skills/incident-to-card/SKILL.md +126 -0
- package/skills/log-outreach.md +62 -0
- package/skills/ship-the-card/SKILL.md +155 -0
- package/tool-annotations.js +269 -0
- package/tools/billing.js +149 -0
- package/tools/email-tools.js +652 -0
- package/tools/enterprise-tools.js +651 -0
- package/tools/free-search.js +160 -0
- package/tools/memory.js +440 -0
- package/tools/morning-brief.js +551 -0
- package/tools/paper-tools.js +563 -0
- package/tools/scheduling.js +322 -0
- package/tools/work-board-tools.js +758 -0
- package/toolsets/communications.js +276 -0
- package/toolsets/crm.js +495 -0
- package/toolsets/extensibility.js +1131 -0
- package/toolsets/infrastructure.js +757 -0
- package/toolsets/intelligence.js +232 -0
- package/toolsets/knowledge.js +154 -0
- package/toolsets/matrix.js +217 -0
- package/toolsets/outreach.js +432 -0
- package/toolsets/prospecting.js +314 -0
- package/toolsets/revenue/always-loaded.js +341 -0
- package/toolsets/revenue/sloan-tools.js +81 -0
- 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
|
+
}
|