trackly-cli 0.14.0 → 0.14.1

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.
@@ -0,0 +1,903 @@
1
+ 'use strict';
2
+
3
+ const { createHash, randomUUID } = require('node:crypto');
4
+ const { z } = require('zod');
5
+ const { version: PACKAGE_VERSION } = require('../package.json');
6
+ const {
7
+ ensureSecureUrl,
8
+ getRequestHeaders,
9
+ hasAuth,
10
+ loadConfig,
11
+ normalizeEndpoint,
12
+ saveConfig,
13
+ } = require('../lib/client');
14
+
15
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 2_000;
16
+ const RELAY_TIMEOUT_MS = 2_000;
17
+ const DEFAULT_MAX_PENDING_DELIVERIES = 32;
18
+ const MAX_CAPTURED_ITEMS = 50;
19
+ const MAX_CONTENT_LENGTH = 100_000;
20
+ const MAX_SANITIZE_DEPTH = 8;
21
+ const MAX_SANITIZE_ITEMS = 500;
22
+ const MAX_SANITIZE_STRING_LENGTH = 4_096;
23
+ const AUTHENTICATED_RELAY_PATH = '/api/jobscout/mcp-analytics';
24
+ const ANONYMOUS_RELAY_PATH = '/api/jobscout/mcp-analytics/anonymous';
25
+ const ANALYTICS_PREFERENCE_PATH = '/api/jobscout/analytics-preference';
26
+ const ANALYTICS_OPT_OUT_CONFIG_KEY = 'mcpAnalyticsOptOut';
27
+ const MCP_ANALYTICS_USER_AGENT = `trackly-mcp-analytics/${PACKAGE_VERSION}`;
28
+ const REDACTED = '[redacted]';
29
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on', 'enabled']);
30
+ const CONTEXT_DESCRIPTION =
31
+ 'Briefly explain why this tool helps the current job-search goal. Never include resume text, profile answers, demographic or work-authorization answers, application notes, credentials, or secrets.';
32
+ const CONTEXT_INTENT_MARKER = 'trackly_context_parameter:';
33
+ const ANONYMOUS_RELAY_EVENTS = new Set([
34
+ '$exception',
35
+ '$mcp_initialize',
36
+ '$mcp_missing_capability',
37
+ '$mcp_tools_list',
38
+ ]);
39
+
40
+ // Rich payload capture is deliberately allowlisted. Every other Trackly tool
41
+ // remains observable by name, duration, outcome, client, and session without
42
+ // duplicating profile, Apply, contact, or account payloads into PostHog.
43
+ const RICH_PAYLOAD_TOOLS = new Set([
44
+ 'trackly_search_jobs',
45
+ 'trackly_get_job',
46
+ 'trackly_search_companies',
47
+ 'trackly_list_companies',
48
+ 'trackly_ask',
49
+ 'get_more_tools',
50
+ ]);
51
+
52
+ const MISSING_CAPABILITY_VALUES = {
53
+ requestedCapability: [
54
+ 'search', 'inspect', 'compare', 'rank', 'recommend', 'track', 'update', 'apply',
55
+ 'submit', 'export', 'notify', 'schedule', 'integrate', 'authenticate', 'debug', 'other',
56
+ ],
57
+ requestedAction: [
58
+ 'find', 'read', 'compare', 'rank', 'create', 'update', 'delete', 'submit',
59
+ 'export', 'notify', 'schedule', 'connect', 'debug', 'other',
60
+ ],
61
+ requestedResource: [
62
+ 'job', 'company', 'application', 'contact', 'profile', 'resume', 'preference',
63
+ 'analytics', 'account', 'tool', 'other',
64
+ ],
65
+ requestedDestination: [
66
+ 'trackly', 'external_site', 'file', 'email', 'calendar', 'crm', 'browser', 'other',
67
+ ],
68
+ };
69
+
70
+ const SAFE_RESPONSE_SHAPES = {
71
+ trackly_search_jobs: {
72
+ containers: ['jobs', 'data', 'results', 'pagination', 'meta'],
73
+ textLengths: ['title', 'company', 'companyName', 'location', 'description'],
74
+ },
75
+ trackly_get_job: {
76
+ containers: ['job', 'data', 'result', 'company'],
77
+ textLengths: ['title', 'company', 'companyName', 'location', 'description'],
78
+ },
79
+ trackly_search_companies: {
80
+ containers: ['companies', 'data', 'results', 'pagination', 'meta'],
81
+ textLengths: ['name', 'companyName', 'domain', 'description'],
82
+ },
83
+ trackly_list_companies: {
84
+ containers: ['companies', 'data', 'results', 'pagination', 'meta'],
85
+ textLengths: ['name', 'companyName', 'domain', 'description'],
86
+ },
87
+ trackly_ask: {
88
+ containers: ['jobs', 'data', 'results', 'filters', 'parsedFilters', 'pagination', 'meta'],
89
+ textLengths: ['title', 'company', 'companyName', 'location', 'description', 'query'],
90
+ },
91
+ get_more_tools: {
92
+ containers: [],
93
+ textLengths: [],
94
+ },
95
+ };
96
+
97
+ const SAFE_ID_KEYS = new Set([
98
+ 'id', 'jobid', 'postingid', 'companyid', 'trackerid', 'requestid',
99
+ ]);
100
+ const SAFE_COUNT_KEYS = new Set([
101
+ 'total', 'count', 'limit', 'offset', 'page', 'pages', 'pagesize', 'remaining',
102
+ 'activejobcount', 'jobcount', 'resultcount', 'returnedcount',
103
+ ]);
104
+ const SAFE_BOOLEAN_KEYS = new Set([
105
+ 'success', 'iserror', 'hasmore', 'remote', 'active', 'tracked', 'applied', 'saved',
106
+ 'dismissed', 'jobsurlrefused', 'reported',
107
+ ]);
108
+ const SAFE_ENUM_VALUES = new Set([
109
+ 'new', 'applied', 'applied_confirmed', 'check_later', 'not_interested', 'all',
110
+ 'backlog', 'discarded', 'full_time', 'internship', 'remote', 'hybrid', 'in_person',
111
+ 'unspecified', 'us', 'non_us', 'europe', 'latam', 'middle_east', 'asia', 'africa',
112
+ 'canada', 'oceania', 'unknown', 'product', 'engineering', 'design', 'data',
113
+ 'marketing', 'sales', 'partnerships', 'finance', 'strategy', 'operations', 'people',
114
+ 'legal', 'support', 'other', 'newest', 'match',
115
+ ]);
116
+ const SAFE_ENUM_KEYS = new Set([
117
+ 'status', 'stage', 'function', 'jobfunction', 'jobmodality', 'workarrangement',
118
+ 'region', 'regiontag', 'locationfilter', 'sort',
119
+ ]);
120
+ const SDK_FAILURE_WARNING = /Warning: (?:Failed to instrument server|Failed to setup tool call instrumentation|No PostHog client passed)/i;
121
+
122
+ const ALLOWED_EVENT_PROPERTY_KEYS = new Set([
123
+ '$exception_list', '$lib', '$lib_version', '$mcp_client_name',
124
+ '$mcp_client_user_agent', '$mcp_client_version', '$mcp_duration_ms',
125
+ '$mcp_error_message', '$mcp_error_type', '$mcp_intent', '$mcp_intent_source',
126
+ '$mcp_is_error', '$mcp_listed_tool_names', '$mcp_parameters',
127
+ '$mcp_protocol_version', '$mcp_resource_name', '$mcp_response',
128
+ '$mcp_server_name', '$mcp_server_version', '$mcp_source', '$mcp_tool_category',
129
+ '$mcp_tool_description', '$mcp_tool_name', '$mcp_vendor_client',
130
+ '$process_person_profile', '$session_id', 'app_version', 'build', 'channel',
131
+ 'contract_version', 'cpu_architecture', 'environment', 'node_version',
132
+ 'operating_system', 'trackly_mcp_analytics_contract', 'trackly_package_version',
133
+ ]);
134
+
135
+ const FORBIDDEN_KEYS = new Set([
136
+ // Credentials and authentication material.
137
+ 'authorization',
138
+ 'cookie',
139
+ 'setcookie',
140
+ 'apikey',
141
+ 'apitoken',
142
+ 'accesstoken',
143
+ 'refreshtoken',
144
+ 'token',
145
+ 'password',
146
+ 'secret',
147
+ 'clientsecret',
148
+ 'privatekey',
149
+ // User-defined sensitive analytics boundary.
150
+ 'resume',
151
+ 'resumetext',
152
+ 'resumecontent',
153
+ 'resumebytes',
154
+ 'resumefile',
155
+ 'defaultresume',
156
+ 'profile',
157
+ 'profileanswer',
158
+ 'profileanswers',
159
+ 'demographic',
160
+ 'demographics',
161
+ 'workauthorization',
162
+ 'workauthorizationanswer',
163
+ 'authorizedtowork',
164
+ 'applicationnote',
165
+ 'applicationnotes',
166
+ 'applicationtext',
167
+ 'coverletter',
168
+ ]);
169
+
170
+ const analyticsState = new WeakMap();
171
+
172
+ function normalizedKey(key) {
173
+ return String(key).replace(/[^a-z0-9]/gi, '').toLowerCase();
174
+ }
175
+
176
+ function isForbiddenKey(key) {
177
+ const normalized = normalizedKey(key);
178
+ if (FORBIDDEN_KEYS.has(normalized)) return true;
179
+ return normalized.includes('resumetext')
180
+ || normalized.includes('resumecontent')
181
+ || normalized.includes('resumebytes')
182
+ || normalized.includes('resumefile')
183
+ || normalized.includes('profileanswer')
184
+ || normalized.includes('demographic')
185
+ || normalized.includes('workauthorization')
186
+ || normalized.startsWith('authorizedtowork')
187
+ || normalized.includes('applicationnote')
188
+ || normalized.includes('coverletter')
189
+ || normalized.endsWith('token')
190
+ || normalized.endsWith('password')
191
+ || normalized.endsWith('secret');
192
+ }
193
+
194
+ function truthy(value) {
195
+ return TRUE_VALUES.has(String(value || '').trim().toLowerCase());
196
+ }
197
+
198
+ function isMcpAnalyticsEnabled(env = process.env) {
199
+ if (truthy(env.TRACKLY_MCP_ANALYTICS_DISABLED)) return false;
200
+ if (env.TRACKLY_MCP_ANALYTICS_ENABLED !== undefined
201
+ && !truthy(env.TRACKLY_MCP_ANALYTICS_ENABLED)) {
202
+ return false;
203
+ }
204
+ return true;
205
+ }
206
+
207
+ function scrubString(value) {
208
+ if (typeof value !== 'string') return '';
209
+ let sanitized = value
210
+ .replace(/\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [redacted]')
211
+ .replace(/\btrk_[A-Za-z0-9_-]+\b/g, REDACTED)
212
+ .replace(/\bph[a-z]_[A-Za-z0-9_-]{12,}\b/gi, REDACTED)
213
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED)
214
+ .replace(/\/Users\/[^/\s"'`]+(?:\/[^\s"'`]*)?/g, '<local-path>')
215
+ .replace(/\/home\/[^/\s"'`]+(?:\/[^\s"'`]*)?/g, '<local-path>')
216
+ .replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+(?:\\[^\s"'`]*)?/g, '<local-path>');
217
+ const localPrefixes = new Set([
218
+ process.env.HOME,
219
+ process.env.USERPROFILE,
220
+ process.env.TRACKLY_CONFIG_DIR,
221
+ process.cwd(),
222
+ ].filter((candidate) => typeof candidate === 'string' && candidate.length > 1));
223
+ for (const prefix of localPrefixes) {
224
+ sanitized = sanitized.split(prefix).join('<local-path>');
225
+ }
226
+ return sanitized;
227
+ }
228
+
229
+ function contentLength(value) {
230
+ return { length: Math.min(String(value).length, MAX_CONTENT_LENGTH) };
231
+ }
232
+
233
+ function classifyIntent(toolName, value) {
234
+ const text = typeof value === 'string' ? value.toLowerCase() : '';
235
+ if (/\b(?:compare|versus|difference|choose|decision)\b/.test(text)) return 'compare_options';
236
+ if (/\b(?:debug|error|failed|failure|broken|troubleshoot)\b/.test(text)) return 'troubleshoot';
237
+ if (/\b(?:apply|application|submit)\b/.test(text)) return 'application_workflow';
238
+ if (/\b(?:explain|why|details?|describe)\b/.test(text)) return 'inspect_details';
239
+ if (/\b(?:recommend|best|rank|prioritize)\b/.test(text)) return 'decision_support';
240
+ const defaults = {
241
+ trackly_search_jobs: 'job_discovery',
242
+ trackly_get_job: 'inspect_job',
243
+ trackly_search_companies: 'company_discovery',
244
+ trackly_list_companies: 'browse_companies',
245
+ trackly_ask: 'natural_language_job_search',
246
+ get_more_tools: 'missing_capability',
247
+ };
248
+ return defaults[toolName] || 'other';
249
+ }
250
+
251
+ function classifyError(value) {
252
+ const text = typeof value === 'string' ? value.toLowerCase() : '';
253
+ if (/\b(?:401|unauthenticated|authentication|invalid token|expired token)\b/.test(text)) {
254
+ return 'authentication';
255
+ }
256
+ if (/\b(?:403|forbidden|permission|not authorized)\b/.test(text)) return 'authorization';
257
+ if (/\b(?:429|rate limit|too many requests)\b/.test(text)) return 'rate_limit';
258
+ if (/\b(?:timeout|timed out|deadline|abort)\b/.test(text)) return 'timeout';
259
+ if (/\b(?:404|not found|missing)\b/.test(text)) return 'not_found';
260
+ if (/\b(?:400|invalid|validation|schema|bad request)\b/.test(text)) return 'validation';
261
+ if (/\b(?:409|conflict|stale)\b/.test(text)) return 'conflict';
262
+ if (/\b(?:fetch|network|socket|connection|dns|econn)\b/.test(text)) return 'network';
263
+ if (/\b(?:500|502|503|504|internal|upstream|unavailable)\b/.test(text)) return 'server';
264
+ return 'unknown';
265
+ }
266
+
267
+ function safeFrameName(value, maxLength = 120) {
268
+ if (typeof value !== 'string' || value === '<local-path>') return undefined;
269
+ const basename = value.replace(/\\/g, '/').split('/').pop() || '';
270
+ const withoutExtension = basename.replace(/\.(?:c?js|mjs|ts|tsx)$/i, '');
271
+ return /^[A-Za-z0-9_.$@<>+-]+$/.test(withoutExtension)
272
+ ? withoutExtension.slice(0, maxLength)
273
+ : undefined;
274
+ }
275
+
276
+ function safeFrameNumber(value) {
277
+ const number = typeof value === 'number' ? value : Number(value);
278
+ return Number.isSafeInteger(number) && number >= 0 && number <= 10_000_000
279
+ ? number
280
+ : undefined;
281
+ }
282
+
283
+ function projectExceptionList(value, errorValue) {
284
+ if (!Array.isArray(value)) return [];
285
+ return value.slice(0, 10).map((candidate) => {
286
+ const exception = candidate && typeof candidate === 'object' ? candidate : {};
287
+ const stacktrace = exception.stacktrace && typeof exception.stacktrace === 'object'
288
+ ? exception.stacktrace
289
+ : {};
290
+ const frames = Array.isArray(stacktrace.frames) ? stacktrace.frames : [];
291
+ const projectedFrames = frames.slice(0, 50).map((candidateFrame) => {
292
+ const frame = candidateFrame && typeof candidateFrame === 'object' ? candidateFrame : {};
293
+ const projected = {};
294
+ const moduleName = safeFrameName(
295
+ frame.module_name ?? frame.module ?? frame.filename ?? frame.abs_path,
296
+ );
297
+ const functionName = safeFrameName(
298
+ frame.function_name ?? frame.function ?? frame.functionName,
299
+ );
300
+ const lineNumber = safeFrameNumber(
301
+ frame.line_number ?? frame.lineno ?? frame.lineNumber,
302
+ );
303
+ const columnNumber = safeFrameNumber(
304
+ frame.column_number ?? frame.colno ?? frame.columnNumber,
305
+ );
306
+ if (moduleName) projected.module_name = moduleName;
307
+ if (functionName) projected.function_name = functionName;
308
+ if (lineNumber !== undefined) projected.line_number = lineNumber;
309
+ if (columnNumber !== undefined) projected.column_number = columnNumber;
310
+ return projected;
311
+ }).filter((frame) => Object.keys(frame).length > 0);
312
+ return {
313
+ type: safeFrameName(exception.type, 80) || 'Error',
314
+ category: classifyError(exception.value ?? errorValue),
315
+ stacktrace: { frames: projectedFrames },
316
+ };
317
+ });
318
+ }
319
+
320
+ function redactValue(
321
+ value,
322
+ depth = 0,
323
+ budget = { remaining: MAX_SANITIZE_ITEMS },
324
+ seen = new WeakSet(),
325
+ ) {
326
+ if (typeof value === 'string') {
327
+ return scrubString(value.slice(0, MAX_SANITIZE_STRING_LENGTH));
328
+ }
329
+ if (!value || typeof value !== 'object') return value;
330
+ if (depth >= MAX_SANITIZE_DEPTH || budget.remaining <= 0 || seen.has(value)) {
331
+ return undefined;
332
+ }
333
+
334
+ seen.add(value);
335
+ budget.remaining -= 1;
336
+ if (Array.isArray(value)) {
337
+ const projected = [];
338
+ for (const nested of value.slice(0, MAX_CAPTURED_ITEMS)) {
339
+ if (budget.remaining <= 0) break;
340
+ const redacted = redactValue(nested, depth + 1, budget, seen);
341
+ if (redacted !== undefined) projected.push(redacted);
342
+ }
343
+ return projected;
344
+ }
345
+
346
+ const result = {};
347
+ let capturedItems = 0;
348
+ for (const key in value) {
349
+ capturedItems += 1;
350
+ if (capturedItems > MAX_CAPTURED_ITEMS) break;
351
+ if (!Object.hasOwn(value, key)) continue;
352
+ if (budget.remaining <= 0) break;
353
+ const nested = value[key];
354
+ const redacted = isForbiddenKey(key)
355
+ ? REDACTED
356
+ : redactValue(nested, depth + 1, budget, seen);
357
+ if (redacted !== undefined) result[key] = redacted;
358
+ }
359
+ return result;
360
+ }
361
+
362
+ function safeId(value) {
363
+ if (Number.isSafeInteger(value) && value >= 0) return value;
364
+ if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)) return value;
365
+ return undefined;
366
+ }
367
+
368
+ function safeCount(value) {
369
+ return Number.isSafeInteger(value) && value >= 0 && value <= MAX_CONTENT_LENGTH
370
+ ? value
371
+ : undefined;
372
+ }
373
+
374
+ function projectRichObject(toolName, value, depth = 0, budget = { remaining: 200 }) {
375
+ if (!value || typeof value !== 'object' || depth > 5 || budget.remaining <= 0) return {};
376
+ budget.remaining -= 1;
377
+ const shape = SAFE_RESPONSE_SHAPES[toolName];
378
+ if (!shape) return {};
379
+ const containers = new Set(shape.containers.map(normalizedKey));
380
+ const textLengths = new Set(shape.textLengths.map(normalizedKey));
381
+ const result = {};
382
+ for (const [key, nested] of Object.entries(value).slice(0, MAX_CAPTURED_ITEMS)) {
383
+ const normalized = normalizedKey(key);
384
+ if (SAFE_ID_KEYS.has(normalized)) {
385
+ const projected = safeId(nested);
386
+ if (projected !== undefined) result[key] = projected;
387
+ continue;
388
+ }
389
+ if (SAFE_COUNT_KEYS.has(normalized)) {
390
+ const projected = safeCount(nested);
391
+ if (projected !== undefined) result[key] = projected;
392
+ continue;
393
+ }
394
+ if (SAFE_BOOLEAN_KEYS.has(normalized) && typeof nested === 'boolean') {
395
+ result[key] = nested;
396
+ continue;
397
+ }
398
+ if (SAFE_ENUM_KEYS.has(normalized) && typeof nested === 'string'
399
+ && SAFE_ENUM_VALUES.has(nested)) {
400
+ result[key] = nested;
401
+ continue;
402
+ }
403
+ if (normalized === 'workarrangements' && Array.isArray(nested)) {
404
+ result[key] = nested.slice(0, 4).filter((candidate) => SAFE_ENUM_VALUES.has(candidate));
405
+ continue;
406
+ }
407
+ if (textLengths.has(normalized) && typeof nested === 'string') {
408
+ result[`${key}_length`] = contentLength(nested).length;
409
+ continue;
410
+ }
411
+ if (!containers.has(normalized)) continue;
412
+ if (Array.isArray(nested)) {
413
+ result[key] = nested.slice(0, MAX_CAPTURED_ITEMS)
414
+ .map((candidate) => projectRichObject(toolName, candidate, depth + 1, budget));
415
+ } else if (nested && typeof nested === 'object') {
416
+ result[key] = projectRichObject(toolName, nested, depth + 1, budget);
417
+ }
418
+ }
419
+ return result;
420
+ }
421
+
422
+ function projectRichJsonText(toolName, value) {
423
+ if (typeof value !== 'string') return projectRichObject(toolName, value);
424
+ try {
425
+ return JSON.stringify(projectRichObject(toolName, JSON.parse(value)));
426
+ } catch {
427
+ return JSON.stringify({
428
+ unparsed: true,
429
+ ...contentLength(value),
430
+ truncated: value.length > MAX_CONTENT_LENGTH,
431
+ });
432
+ }
433
+ }
434
+
435
+ function redactMcpResponse(toolName, response) {
436
+ if (!response || typeof response !== 'object') return {};
437
+ const projected = {};
438
+ if (typeof response.isError === 'boolean') projected.isError = response.isError;
439
+ if (!Array.isArray(response.content)) {
440
+ return { ...projected, ...projectRichObject(toolName, response) };
441
+ }
442
+
443
+ return {
444
+ ...projected,
445
+ content: response.content.slice(0, 10).map((block) => {
446
+ if (!block || typeof block !== 'object') return { type: 'unknown' };
447
+ if (block.type === 'text') {
448
+ return { type: 'text', text: projectRichJsonText(toolName, block.text) };
449
+ }
450
+ const type = ['image', 'audio', 'resource', 'resource_link'].includes(block.type)
451
+ ? block.type
452
+ : 'unknown';
453
+ const encoded = block.data ?? block.blob ?? block.resource?.blob;
454
+ return {
455
+ type,
456
+ ...(typeof encoded === 'string' ? { length: contentLength(encoded).length } : {}),
457
+ };
458
+ }),
459
+ };
460
+ }
461
+
462
+ function findArguments(parameters) {
463
+ if (!parameters || typeof parameters !== 'object') return null;
464
+ return parameters.request?.params?.arguments
465
+ ?? parameters.params?.arguments
466
+ ?? parameters.arguments
467
+ ?? null;
468
+ }
469
+
470
+ function projectMcpParameters(toolName, parameters) {
471
+ const args = findArguments(parameters);
472
+ if (!args || typeof args !== 'object') return {};
473
+ const projected = {};
474
+ const allowed = {
475
+ trackly_search_jobs: new Set([
476
+ 'function', 'companyId', 'locationFilter', 'jobModality', 'workArrangements',
477
+ 'remote', 'status', 'sort', 'limit', 'offset', 'keywords',
478
+ ]),
479
+ trackly_get_job: new Set(['id']),
480
+ trackly_search_companies: new Set(['query', 'limit']),
481
+ trackly_list_companies: new Set(['limit', 'offset']),
482
+ trackly_ask: new Set(['query']),
483
+ get_more_tools: new Set(Object.keys(MISSING_CAPABILITY_VALUES)),
484
+ }[toolName] || new Set();
485
+
486
+ for (const [key, value] of Object.entries(args).slice(0, MAX_CAPTURED_ITEMS)) {
487
+ if (!allowed.has(key)) continue;
488
+ if ((key === 'keywords' || key === 'query') && typeof value === 'string') {
489
+ projected[key] = contentLength(value);
490
+ } else if (key === 'companyId' || key === 'id') {
491
+ const id = safeId(value);
492
+ if (id !== undefined) projected[key] = id;
493
+ } else if (key === 'limit' || key === 'offset') {
494
+ const count = safeCount(value);
495
+ if (count !== undefined) projected[key] = count;
496
+ } else if (key === 'remote' && typeof value === 'boolean') {
497
+ projected[key] = value;
498
+ } else if (key === 'workArrangements' && Array.isArray(value)) {
499
+ projected[key] = value.slice(0, 4).filter((candidate) => SAFE_ENUM_VALUES.has(candidate));
500
+ } else if (Object.hasOwn(MISSING_CAPABILITY_VALUES, key)) {
501
+ if (MISSING_CAPABILITY_VALUES[key].includes(value)) projected[key] = value;
502
+ } else if (typeof value === 'string' && SAFE_ENUM_VALUES.has(value)) {
503
+ projected[key] = value;
504
+ } else if (key === 'locationFilter' && Array.isArray(value)) {
505
+ projected[key] = value.slice(0, 10).filter((candidate) => SAFE_ENUM_VALUES.has(candidate));
506
+ }
507
+ }
508
+ return { request: { params: { arguments: projected } } };
509
+ }
510
+
511
+ function sanitizeMcpAnalyticsEvent(event) {
512
+ if (!event || typeof event !== 'object') return null;
513
+ const sanitized = redactValue(event);
514
+ if (!sanitized || typeof sanitized !== 'object') return null;
515
+ const sourceProperties = sanitized.properties;
516
+ if (sourceProperties && typeof sourceProperties === 'object') {
517
+ sanitized.properties = Object.fromEntries(
518
+ Object.entries(sourceProperties)
519
+ .filter(([key]) => ALLOWED_EVENT_PROPERTY_KEYS.has(key)),
520
+ );
521
+ }
522
+ const properties = sanitized.properties;
523
+ if (!properties || typeof properties !== 'object') return sanitized;
524
+
525
+ // Identity is backend-owned. The CLI may carry only its ephemeral anonymous
526
+ // session identifier; verified user/person properties never cross this relay.
527
+ delete properties.$set;
528
+ delete properties.$set_once;
529
+ delete properties.$unset;
530
+ delete properties.email;
531
+ delete properties.name;
532
+
533
+ // The stdio server must not fingerprint a user's machine or forward a local
534
+ // path even if a future SDK begins attaching either automatically.
535
+ delete properties.$ip;
536
+ delete properties.ip;
537
+ delete properties.hostname;
538
+ delete properties.username;
539
+ delete properties.$mcp_conversation_id;
540
+
541
+ if (typeof properties.$session_id === 'string') {
542
+ const sdkSession = /^ses_([0-9a-f-]{36})$/i.exec(properties.$session_id);
543
+ if (sdkSession) properties.$session_id = sdkSession[1];
544
+ }
545
+
546
+ const toolName = properties.$mcp_tool_name ?? properties.$mcp_resource_name;
547
+ const sourceArguments = findArguments(properties.$mcp_parameters);
548
+ const sourceIntent = properties.$mcp_intent;
549
+ const markedContextIntent = typeof sourceIntent === 'string'
550
+ && sourceIntent.startsWith(CONTEXT_INTENT_MARKER);
551
+ const hasContext = markedContextIntent || (typeof sourceArguments?.context === 'string'
552
+ && sourceArguments.context.trim().length > 0);
553
+ if (markedContextIntent) {
554
+ properties.$mcp_intent = sourceIntent.slice(CONTEXT_INTENT_MARKER.length);
555
+ }
556
+ if (typeof toolName !== 'string' || !RICH_PAYLOAD_TOOLS.has(toolName)) {
557
+ delete properties.$mcp_parameters;
558
+ delete properties.$mcp_response;
559
+ } else {
560
+ if (properties.$mcp_parameters !== undefined) {
561
+ properties.$mcp_parameters = projectMcpParameters(toolName, properties.$mcp_parameters);
562
+ }
563
+ if (properties.$mcp_response !== undefined) {
564
+ properties.$mcp_response = redactMcpResponse(toolName, properties.$mcp_response);
565
+ }
566
+ }
567
+ if (typeof toolName === 'string' && toolName.length > 0) {
568
+ if (toolName === 'get_more_tools') {
569
+ properties.$mcp_resource_name = toolName;
570
+ delete properties.$mcp_tool_name;
571
+ } else {
572
+ properties.$mcp_tool_name = toolName;
573
+ delete properties.$mcp_resource_name;
574
+ }
575
+ properties.$mcp_intent = classifyIntent(toolName, properties.$mcp_intent);
576
+ properties.$mcp_intent_source = hasContext ? 'context_parameter' : 'inferred';
577
+ if (toolName === 'get_more_tools' && event.event === '$mcp_tool_call') {
578
+ sanitized.event = '$mcp_missing_capability';
579
+ }
580
+ } else {
581
+ delete properties.$mcp_intent;
582
+ delete properties.$mcp_intent_source;
583
+ }
584
+
585
+ const sourceErrorMessage = properties.$mcp_error_message;
586
+ if (typeof sourceErrorMessage === 'string') {
587
+ properties.$mcp_error_message = classifyError(sourceErrorMessage);
588
+ }
589
+ if (properties.$exception_list !== undefined) {
590
+ properties.$exception_list = projectExceptionList(
591
+ properties.$exception_list,
592
+ sourceErrorMessage,
593
+ );
594
+ }
595
+ return sanitized;
596
+ }
597
+
598
+ function safelySanitizeMcpAnalyticsEvent(event) {
599
+ try {
600
+ return sanitizeMcpAnalyticsEvent(event);
601
+ } catch {
602
+ return null;
603
+ }
604
+ }
605
+
606
+ function addOptionalContextToTools(server) {
607
+ const tools = server?._registeredTools;
608
+ if (!tools || typeof tools !== 'object') {
609
+ throw new Error('unsupported_mcp_sdk_tool_registry');
610
+ }
611
+
612
+ for (const [toolName, tool] of Object.entries(tools)) {
613
+ const shape = tool?.inputSchema?.shape;
614
+ if (!shape || typeof shape !== 'object') continue;
615
+ if (Object.hasOwn(shape, 'context')) continue;
616
+
617
+ const originalHandler = tool.handler;
618
+ tool.update({
619
+ paramsSchema: {
620
+ ...shape,
621
+ context: z.string().optional().describe(CONTEXT_DESCRIPTION),
622
+ },
623
+ callback: (args, extra) => {
624
+ if (!args || typeof args !== 'object' || !Object.hasOwn(args, 'context')) {
625
+ return originalHandler(args, extra);
626
+ }
627
+ const { context: _context, ...handlerArgs } = args;
628
+ return originalHandler(handlerArgs, extra);
629
+ },
630
+ });
631
+ }
632
+ }
633
+
634
+ function runtimeEventProperties(env = process.env) {
635
+ return {
636
+ channel: 'mcp',
637
+ contract_version: 3,
638
+ environment: env.TRACKLY_MCP_ANALYTICS_ENVIRONMENT || 'production',
639
+ app_version: PACKAGE_VERSION,
640
+ build: env.TRACKLY_MCP_BUILD || PACKAGE_VERSION,
641
+ trackly_package_version: PACKAGE_VERSION,
642
+ trackly_mcp_analytics_contract: 'production-v1',
643
+ node_version: process.versions.node,
644
+ operating_system: process.platform,
645
+ cpu_architecture: process.arch,
646
+ };
647
+ }
648
+
649
+ function createBackendRelay(options = {}) {
650
+ const fetchImpl = options.fetch || globalThis.fetch;
651
+ const loadConfigImpl = options.loadConfig || loadConfig;
652
+ const saveConfigImpl = options.saveConfig || saveConfig;
653
+ const pending = new Set();
654
+ const maxPendingDeliveries = Number.isSafeInteger(options.maxPendingDeliveries)
655
+ && options.maxPendingDeliveries > 0
656
+ ? options.maxPendingDeliveries
657
+ : DEFAULT_MAX_PENDING_DELIVERIES;
658
+ let cachedOptOut = readCachedAnalyticsOptOut();
659
+ let preferenceLookup = null;
660
+
661
+ function readCachedAnalyticsOptOut() {
662
+ try {
663
+ return loadConfigImpl()[ANALYTICS_OPT_OUT_CONFIG_KEY] === true;
664
+ } catch {
665
+ return true;
666
+ }
667
+ }
668
+
669
+ function persistCachedAnalyticsOptOut(optedOut) {
670
+ cachedOptOut = optedOut;
671
+ try {
672
+ const config = loadConfigImpl();
673
+ if (optedOut && config[ANALYTICS_OPT_OUT_CONFIG_KEY] === true) return;
674
+ if (optedOut) config[ANALYTICS_OPT_OUT_CONFIG_KEY] = true;
675
+ else if (Object.hasOwn(config, ANALYTICS_OPT_OUT_CONFIG_KEY)) {
676
+ delete config[ANALYTICS_OPT_OUT_CONFIG_KEY];
677
+ } else {
678
+ return;
679
+ }
680
+ saveConfigImpl(config);
681
+ } catch {
682
+ // The in-memory privacy gate remains authoritative for this process.
683
+ }
684
+ }
685
+
686
+ function authenticatedRequestContext() {
687
+ const headers = getRequestHeaders(false, MCP_ANALYTICS_USER_AGENT);
688
+ const authorization = headers.Authorization;
689
+ if (typeof authorization !== 'string' || authorization.length === 0) return null;
690
+ return {
691
+ headers,
692
+ key: createHash('sha256').update(authorization).digest('hex'),
693
+ };
694
+ }
695
+
696
+ function isCurrentAuthenticatedContext(expectedKey) {
697
+ try {
698
+ return authenticatedRequestContext()?.key === expectedKey;
699
+ } catch {
700
+ return false;
701
+ }
702
+ }
703
+
704
+ async function resolveAuthenticatedPreference(authContext) {
705
+ if (preferenceLookup?.key === authContext.key) return preferenceLookup.promise;
706
+
707
+ const lookup = { key: authContext.key, promise: null };
708
+ lookup.promise = (async () => {
709
+ const url = normalizeEndpoint(ANALYTICS_PREFERENCE_PATH);
710
+ ensureSecureUrl(url, 'MCP analytics preference');
711
+ const response = await fetchImpl(url, {
712
+ method: 'GET',
713
+ headers: authContext.headers,
714
+ signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
715
+ });
716
+ if (!response?.ok || typeof response.json !== 'function') {
717
+ if (response?.body && typeof response.body.cancel === 'function') {
718
+ await response.body.cancel().catch(() => {});
719
+ }
720
+ throw new Error('analytics_preference_unavailable');
721
+ }
722
+ const preference = await response.json();
723
+ if (!preference || typeof preference.shareUsageAnalytics !== 'boolean') {
724
+ throw new Error('analytics_preference_invalid');
725
+ }
726
+ if (!isCurrentAuthenticatedContext(authContext.key)) return false;
727
+ persistCachedAnalyticsOptOut(!preference.shareUsageAnalytics);
728
+ return preference.shareUsageAnalytics;
729
+ })().catch(() => false).finally(() => {
730
+ if (preferenceLookup === lookup) preferenceLookup = null;
731
+ });
732
+ preferenceLookup = lookup;
733
+ return lookup.promise;
734
+ }
735
+
736
+ function capture(event) {
737
+ try {
738
+ if (!event || typeof event !== 'object' || event.event === '$identify') return;
739
+ const authenticated = hasAuth();
740
+ const authContext = authenticated ? authenticatedRequestContext() : null;
741
+ if (authenticated && !authContext) return;
742
+ if (!authenticated && !ANONYMOUS_RELAY_EVENTS.has(event.event)) return;
743
+ // Another MCP process can persist an opt-out before this process loses
744
+ // authentication. Refresh the anonymous gate from disk before every
745
+ // unauthenticated delivery, while preserving a known in-memory opt-out
746
+ // if its earlier persistence attempt failed.
747
+ if (!authenticated) cachedOptOut ||= readCachedAnalyticsOptOut();
748
+ if (!authenticated && cachedOptOut) return;
749
+ if (pending.size >= maxPendingDeliveries) return;
750
+
751
+ const endpoint = authenticated ? AUTHENTICATED_RELAY_PATH : ANONYMOUS_RELAY_PATH;
752
+ const delivery = (async () => {
753
+ if (authContext && !(await resolveAuthenticatedPreference(authContext))) return;
754
+ if (authContext && !isCurrentAuthenticatedContext(authContext.key)) return;
755
+ const url = normalizeEndpoint(endpoint);
756
+ ensureSecureUrl(url, 'MCP analytics');
757
+ const response = await fetchImpl(url, {
758
+ method: 'POST',
759
+ headers: authContext?.headers
760
+ || getRequestHeaders(true, MCP_ANALYTICS_USER_AGENT),
761
+ body: JSON.stringify(event),
762
+ signal: AbortSignal.timeout(RELAY_TIMEOUT_MS),
763
+ });
764
+ // A capture response is analytics-only. Cancel the unread body so
765
+ // keep-alive connections are not retained until garbage collection.
766
+ if (response?.body && typeof response.body.cancel === 'function') {
767
+ await response.body.cancel().catch(() => {});
768
+ }
769
+ })().catch(() => {}).finally(() => pending.delete(delivery));
770
+ pending.add(delivery);
771
+ } catch {
772
+ // Config reads, URL parsing, serialization, and fetch setup all fail open.
773
+ }
774
+ }
775
+
776
+ async function flush() {
777
+ await Promise.allSettled([...pending]);
778
+ }
779
+
780
+ return {
781
+ capture,
782
+ flush,
783
+ _shutdown: flush,
784
+ };
785
+ }
786
+
787
+ function configureMcpAnalytics(server, options = {}) {
788
+ const env = options.env || process.env;
789
+ if (!isMcpAnalyticsEnabled(env)) {
790
+ return { enabled: false, reason: 'disabled' };
791
+ }
792
+
793
+ let relay;
794
+ let diagnostic = 'instrumentation_exception';
795
+ try {
796
+ const sdk = (options.loadSdk || (() => require('@posthog/mcp')))();
797
+ const anonymousDistinctId = `mcp-anon-${randomUUID()}`;
798
+ relay = (options.createRelay || createBackendRelay)({ fetch: options.fetch });
799
+ const eventProperties = runtimeEventProperties(env);
800
+ addOptionalContextToTools(server);
801
+ const sdkFailureWarnings = [];
802
+ const analytics = sdk.instrument(server, relay, {
803
+ reportMissing: false,
804
+ enableConversationId: false,
805
+ enableExceptionAutocapture: true,
806
+ context: false,
807
+ intentFallback: (request) => {
808
+ const context = request?.params?.arguments?.context;
809
+ return typeof context === 'string' && context.trim()
810
+ ? `${CONTEXT_INTENT_MARKER}${context}`
811
+ : null;
812
+ },
813
+ identify: { distinctId: anonymousDistinctId },
814
+ beforeSend: (event) => safelySanitizeMcpAnalyticsEvent({
815
+ ...event,
816
+ properties: {
817
+ ...event?.properties,
818
+ ...eventProperties,
819
+ },
820
+ }),
821
+ eventProperties: () => eventProperties,
822
+ logger: (message) => {
823
+ if (SDK_FAILURE_WARNING.test(String(message))) {
824
+ sdkFailureWarnings.push('sdk_setup_warning');
825
+ }
826
+ },
827
+ });
828
+ if (sdkFailureWarnings.length > 0) {
829
+ diagnostic = sdkFailureWarnings[0];
830
+ throw new Error('posthog_mcp_instrumentation_warning');
831
+ }
832
+ if (!analytics || typeof analytics.capture !== 'function') {
833
+ diagnostic = 'sdk_noop_handle';
834
+ throw new Error('posthog_mcp_noop_handle');
835
+ }
836
+
837
+ analyticsState.set(server, { analytics, relay });
838
+ return { enabled: true, analytics };
839
+ } catch {
840
+ if (relay && typeof relay._shutdown === 'function') {
841
+ try {
842
+ Promise.resolve(relay._shutdown()).catch(() => {});
843
+ } catch {
844
+ // A synchronous SDK shutdown failure is still an analytics-only failure.
845
+ }
846
+ }
847
+ try {
848
+ const warn = options.onWarning
849
+ || ((message) => process.stderr.write(`[trackly-mcp] ${message}\n`));
850
+ warn(`Usage analytics are unavailable (${diagnostic}); MCP tools will continue without telemetry.`);
851
+ } catch {
852
+ // Warning delivery is analytics-only and must also fail open.
853
+ }
854
+ return { enabled: false, reason: 'instrumentation_failed', diagnostic };
855
+ }
856
+ }
857
+
858
+ async function withTimeout(promise, timeoutMs) {
859
+ let timer;
860
+ try {
861
+ await Promise.race([
862
+ promise,
863
+ new Promise((resolve) => {
864
+ timer = setTimeout(resolve, timeoutMs);
865
+ timer.unref?.();
866
+ }),
867
+ ]);
868
+ } finally {
869
+ if (timer) clearTimeout(timer);
870
+ }
871
+ }
872
+
873
+ async function shutdownMcpAnalytics(server, timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
874
+ const state = analyticsState.get(server);
875
+ if (!state?.relay) return;
876
+ if (state.shutdownPromise) return state.shutdownPromise;
877
+
878
+ state.shutdownPromise = (async () => {
879
+ try {
880
+ if (typeof state.relay._shutdown === 'function') {
881
+ await withTimeout(Promise.resolve(state.relay._shutdown()), timeoutMs);
882
+ return;
883
+ }
884
+ if (typeof state.relay.flush === 'function') {
885
+ await withTimeout(Promise.resolve(state.relay.flush()), timeoutMs);
886
+ }
887
+ } catch {
888
+ // Fail open on shutdown too; stdio clients must not hang on telemetry.
889
+ }
890
+ })();
891
+ await state.shutdownPromise;
892
+ if (analyticsState.get(server) === state) analyticsState.delete(server);
893
+ }
894
+
895
+ module.exports = {
896
+ RICH_PAYLOAD_TOOLS,
897
+ createBackendRelay,
898
+ configureMcpAnalytics,
899
+ isMcpAnalyticsEnabled,
900
+ runtimeEventProperties,
901
+ sanitizeMcpAnalyticsEvent,
902
+ shutdownMcpAnalytics,
903
+ };