mcpcat
Version:
Analytics tool for MCP (Model Context Protocol) servers - tracks tool usage patterns and provides insights
1 lines • 207 kB
Source Map (JSON)
{"version":3,"sources":["../src/modules/logging.ts","../src/modules/compatibility.ts","../src/modules/tools.ts","../src/modules/internal.ts","../src/modules/eventQueue.ts","../src/thirdparty/ksuid/index.js","../src/thirdparty/ksuid/base-convert-int-array.js","../src/thirdparty/ksuid/base62.js","../package.json","../src/modules/session.ts","../src/modules/constants.ts","../src/modules/redaction.ts","../src/modules/exceptions.ts","../src/modules/context-parameters.ts","../src/modules/tracing.ts","../src/modules/tracingV2.ts","../src/modules/mcp-sdk-compat.ts","../src/modules/exporters/trace-context.ts","../src/modules/exporters/otlp.ts","../src/modules/exporters/datadog.ts","../src/modules/exporters/sentry.ts","../src/modules/telemetry.ts","../src/index.ts"],"sourcesContent":["import { createRequire } from \"module\";\n\n// Lazy-loaded module references for Node.js file logging\n// These are loaded dynamically to support edge environments (Cloudflare Workers, etc.)\nlet fsModule: typeof import(\"fs\") | null = null;\nlet logFilePath: string | null = null;\nlet initAttempted = false;\nlet useConsoleFallback = false;\n\n/**\n * Attempts to initialize Node.js file logging.\n * Falls back to console.log in edge environments where fs/os modules are unavailable.\n */\nfunction tryInitSync(): void {\n if (initAttempted) return;\n initAttempted = true;\n\n try {\n // Use createRequire for ESM compatibility\n // Works in Node.js ESM/CJS, fails gracefully in Workers/edge environments\n const require = createRequire(import.meta.url);\n const fs = require(\"fs\");\n const os = require(\"os\");\n const path = require(\"path\");\n\n const home = os.homedir?.();\n if (home) {\n fsModule = fs;\n logFilePath = path.join(home, \"mcpcat.log\");\n } else {\n // homedir() returned null/undefined - use console fallback\n useConsoleFallback = true;\n }\n } catch {\n // Module not available or homedir() not implemented - use console fallback\n useConsoleFallback = true;\n fsModule = null;\n logFilePath = null;\n }\n}\n\nexport function writeToLog(message: string): void {\n tryInitSync();\n\n const timestamp = new Date().toISOString();\n const logEntry = `[${timestamp}] ${message}`;\n\n if (useConsoleFallback) {\n console.log(`[mcpcat] ${logEntry}`);\n return;\n }\n\n // Node.js environment: write to file\n if (!logFilePath || !fsModule) {\n return;\n }\n\n try {\n if (!fsModule.existsSync(logFilePath)) {\n fsModule.writeFileSync(logFilePath, logEntry + \"\\n\");\n } else {\n fsModule.appendFileSync(logFilePath, logEntry + \"\\n\");\n }\n } catch {\n // Silently fail to avoid breaking the server\n }\n}\n","import { HighLevelMCPServerLike, MCPServerLike } from \"../types.js\";\nimport { writeToLog } from \"./logging.js\";\n\n/**\n * MCPCat Compatibility Module\n *\n * This module ensures compatibility with Model Context Protocol TypeScript SDK.\n * MCPCat only supports MCP SDK version 1.11 and above.\n *\n * Version 1.11+ is required because it introduced stable APIs for:\n * - Tool registration and handling\n * - Request handler access patterns\n * - Client version detection\n * - Server info structure\n */\n\n// Function to log compatibility information\nexport function logCompatibilityWarning(): void {\n writeToLog(\n \"MCPCat SDK Compatibility: This version only supports Model Context Protocol TypeScript SDK v1.11 and above. Please upgrade if using an older version.\",\n );\n}\n\n// Check if server has high-level structure (wrapper with .server property)\nexport function isHighLevelServer(server: any): boolean {\n return (\n server &&\n typeof server === \"object\" &&\n server.server &&\n typeof server.server === \"object\"\n );\n}\n\n// Check if server has low-level structure (no .server property)\nexport function isLowLevelServer(server: any): boolean {\n return server && typeof server === \"object\" && !server.server;\n}\n\n// Type guard function that validates server compatibility and returns typed server\nexport function isCompatibleServerType(\n server: any,\n): MCPServerLike | HighLevelMCPServerLike {\n if (!server || typeof server !== \"object\") {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server must be an object. Ensure you're using MCP SDK v1.11 or higher.\",\n );\n }\n\n if (isHighLevelServer(server)) {\n // Validate high-level server requirements\n if (\n !server._registeredTools ||\n typeof server._registeredTools !== \"object\"\n ) {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: High-level server must have _registeredTools object. This requires MCP SDK v1.11 or higher.\",\n );\n }\n if (typeof server.tool !== \"function\") {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: High-level server must have tool() method. This requires MCP SDK v1.11 or higher.\",\n );\n }\n\n // Validate the underlying low-level server\n const targetServer = server.server;\n validateLowLevelServer(targetServer);\n\n return server as HighLevelMCPServerLike;\n } else {\n // Direct low-level server validation\n validateLowLevelServer(server);\n return server as MCPServerLike;\n }\n}\n\n// Helper function to validate low-level server requirements\nfunction validateLowLevelServer(server: any): void {\n if (typeof server.setRequestHandler !== \"function\") {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server must have a setRequestHandler method. This requires MCP SDK v1.11 or higher.\",\n );\n }\n\n if (!server._requestHandlers || !(server._requestHandlers instanceof Map)) {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server._requestHandlers is not accessible. This requires MCP SDK v1.11 or higher.\",\n );\n }\n\n // Validate that _requestHandlers contains functions with compatible signatures\n if (typeof server._requestHandlers.get !== \"function\") {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server._requestHandlers must be a Map with a get method. This requires MCP SDK v1.11 or higher.\",\n );\n }\n\n if (typeof server.getClientVersion !== \"function\") {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server.getClientVersion must be a function. This requires MCP SDK v1.11 or higher.\",\n );\n }\n\n if (\n !server._serverInfo ||\n typeof server._serverInfo !== \"object\" ||\n !server._serverInfo.name\n ) {\n logCompatibilityWarning();\n throw new Error(\n \"MCPCat SDK compatibility error: Server._serverInfo is not accessible or missing name. This requires MCP SDK v1.11 or higher.\",\n );\n }\n}\n\nexport function getMCPCompatibleErrorMessage(error: unknown): string {\n if (error instanceof Error) {\n try {\n return JSON.stringify(error, Object.getOwnPropertyNames(error));\n } catch {\n return \"Unknown error\";\n }\n } else if (typeof error === \"string\") {\n return error;\n } else if (typeof error === \"object\" && error !== null) {\n return JSON.stringify(error);\n }\n return \"Unknown error\";\n}\n","import {\n ListToolsRequestSchema,\n ListToolsResult,\n} from \"@modelcontextprotocol/sdk/types.js\";\nimport { MCPServerLike, UnredactedEvent } from \"../types.js\";\nimport { writeToLog } from \"./logging.js\";\nimport { getServerTrackingData } from \"./internal.js\";\nimport { addContextParameterToTools } from \"./context-parameters.js\";\nimport { publishEvent } from \"./eventQueue.js\";\nimport { getServerSessionId } from \"./session.js\";\nimport { PublishEventRequestEventTypeEnum } from \"mcpcat-api\";\nimport { getMCPCompatibleErrorMessage } from \"./compatibility.js\";\n\nexport const GET_MORE_TOOLS_NAME = \"get_more_tools\" as const;\n\nexport function getReportMissingToolDescriptor() {\n return {\n name: GET_MORE_TOOLS_NAME,\n description:\n \"Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.\",\n inputSchema: {\n type: \"object\",\n properties: {\n context: {\n type: \"string\",\n description:\n \"A description of your goal and what kind of tool would help accomplish it.\",\n },\n },\n required: [\"context\"],\n },\n } as const;\n}\n\nexport function handleReportMissing(args: { context: string }) {\n writeToLog(`Missing tool reported: ${JSON.stringify(args)}`);\n\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.`,\n },\n ],\n };\n}\n\nexport function setupMCPCatTools(server: MCPServerLike): void {\n // Store reference to original handlers - need to use the method name, not the schema\n const handlers = server._requestHandlers;\n\n const originalListToolsHandler = handlers.get(\"tools/list\");\n const originalCallToolHandler = handlers.get(\"tools/call\");\n\n if (!originalListToolsHandler || !originalCallToolHandler) {\n writeToLog(\n \"Warning: Original tool handlers not found. Your tools may not be setup before MCPCat .track().\",\n );\n return;\n }\n\n // Override tools list to include get_more_tools and add context parameter\n try {\n server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {\n let tools: any[] = [];\n const data = getServerTrackingData(server);\n let event: UnredactedEvent = {\n sessionId: getServerSessionId(server, extra),\n parameters: {\n request: request,\n extra: extra,\n },\n eventType: PublishEventRequestEventTypeEnum.mcpToolsList,\n timestamp: new Date(),\n redactionFn: data?.options.redactSensitiveInformation,\n };\n try {\n const originalResponse = (await originalListToolsHandler(\n request,\n extra,\n )) as ListToolsResult;\n tools = originalResponse.tools || [];\n } catch (error) {\n // If original handler fails, start with empty tools\n writeToLog(\n `Warning: Original list tools handler failed, this suggests an error MCPCat did not cause - ${error}`,\n );\n event.error = { message: getMCPCompatibleErrorMessage(error) };\n event.isError = true;\n event.duration =\n (event.timestamp &&\n new Date().getTime() - event.timestamp.getTime()) ||\n 0;\n publishEvent(server, event);\n throw error;\n }\n\n if (!data) {\n writeToLog(\n \"Warning: MCPCat is unable to find server tracking data. Please ensure you have called track(server, options) before using tool calls.\",\n );\n return { tools };\n }\n\n if (tools.length === 0) {\n writeToLog(\n \"Warning: No tools found in the original list. This is likely due to the tools not being registered before MCPCat.track().\",\n );\n event.error = { message: \"No tools were sent to MCP client.\" };\n event.isError = true;\n event.duration =\n (event.timestamp &&\n new Date().getTime() - event.timestamp.getTime()) ||\n 0;\n publishEvent(server, event);\n return { tools };\n }\n\n // Add context parameter to all existing tools if enableToolCallContext is true\n if (data.options.enableToolCallContext) {\n tools = addContextParameterToTools(\n tools,\n data.options.customContextDescription,\n );\n }\n\n // Add report_missing tool if enabled\n if (data.options.enableReportMissing) {\n const alreadyPresent = tools.some(\n (t: any) => t?.name === GET_MORE_TOOLS_NAME,\n );\n if (!alreadyPresent) {\n tools.push(getReportMissingToolDescriptor());\n }\n }\n\n event.response = { tools };\n event.isError = false;\n event.duration =\n (event.timestamp && new Date().getTime() - event.timestamp.getTime()) ||\n 0;\n publishEvent(server, event);\n return { tools };\n });\n } catch (error) {\n writeToLog(`Warning: Failed to override list tools handler - ${error}`);\n }\n}\n","import {\n MCPCatData,\n MCPServerLike,\n UserIdentity,\n CompatibleRequestHandlerExtra,\n UnredactedEvent,\n} from \"../types.js\";\nimport { PublishEventRequestEventTypeEnum } from \"mcpcat-api\";\nimport { publishEvent } from \"./eventQueue.js\";\nimport { writeToLog } from \"./logging.js\";\nimport { captureException } from \"./exceptions.js\";\n\n/**\n * Simple LRU cache for session identities.\n * Prevents memory leaks by capping at maxSize entries.\n * This cache persists across server instance restarts.\n */\nclass IdentityCache {\n private cache: Map<string, { identity: UserIdentity; timestamp: number }>;\n private maxSize: number;\n\n constructor(maxSize: number = 1000) {\n this.cache = new Map();\n this.maxSize = maxSize;\n }\n\n get(sessionId: string): UserIdentity | undefined {\n const entry = this.cache.get(sessionId);\n if (entry) {\n // Update timestamp on access (LRU behavior)\n entry.timestamp = Date.now();\n // Move to end (most recently used)\n this.cache.delete(sessionId);\n this.cache.set(sessionId, entry);\n return entry.identity;\n }\n return undefined;\n }\n\n set(sessionId: string, identity: UserIdentity): void {\n // Remove if already exists (to re-add at end)\n this.cache.delete(sessionId);\n\n // Evict oldest if at capacity\n if (this.cache.size >= this.maxSize) {\n const oldestKey = this.cache.keys().next().value;\n if (oldestKey !== undefined) {\n this.cache.delete(oldestKey);\n }\n }\n\n this.cache.set(sessionId, { identity, timestamp: Date.now() });\n }\n\n has(sessionId: string): boolean {\n return this.cache.has(sessionId);\n }\n\n size(): number {\n return this.cache.size;\n }\n}\n\n// Global identity cache shared across all server instances\n// This prevents duplicate identify events when server objects are recreated\nconst _globalIdentityCache = new IdentityCache(1000);\n\n// Internal tracking storage\nconst _serverTracking = new WeakMap<MCPServerLike, MCPCatData>();\n\nexport function getServerTrackingData(\n server: MCPServerLike,\n): MCPCatData | undefined {\n return _serverTracking.get(server);\n}\n\nexport function setServerTrackingData(\n server: MCPServerLike,\n data: MCPCatData,\n): void {\n _serverTracking.set(server, data);\n}\n\n/**\n * Deep comparison of two UserIdentity objects\n */\nexport function areIdentitiesEqual(a: UserIdentity, b: UserIdentity): boolean {\n if (a.userId !== b.userId) return false;\n if (a.userName !== b.userName) return false;\n\n // Deep compare userData objects\n const aData = a.userData || {};\n const bData = b.userData || {};\n\n const aKeys = Object.keys(aData);\n const bKeys = Object.keys(bData);\n\n if (aKeys.length !== bKeys.length) return false;\n\n for (const key of aKeys) {\n if (!(key in bData)) return false;\n if (JSON.stringify(aData[key]) !== JSON.stringify(bData[key])) return false;\n }\n\n return true;\n}\n\n/**\n * Merges two UserIdentity objects, overwriting userId and userName,\n * but merging userData fields\n */\nexport function mergeIdentities(\n previous: UserIdentity | undefined,\n next: UserIdentity,\n): UserIdentity {\n if (!previous) {\n return next;\n }\n\n return {\n userId: next.userId,\n userName: next.userName,\n userData: {\n ...(previous.userData || {}),\n ...(next.userData || {}),\n },\n };\n}\n\n/**\n * Handles user identification for a request.\n * Calls the identify function if configured, compares with previous identity,\n * and publishes an identify event only if the identity has changed.\n *\n * @param server - The MCP server instance\n * @param data - The server tracking data\n * @param request - The request object to pass to identify function\n * @param extra - Optional extra parameters containing headers, sessionId, etc.\n */\nexport async function handleIdentify(\n server: MCPServerLike,\n data: MCPCatData,\n request: any,\n extra?: CompatibleRequestHandlerExtra,\n): Promise<void> {\n if (!data.options.identify) {\n return;\n }\n\n const sessionId = data.sessionId;\n let identifyEvent: UnredactedEvent = {\n sessionId: sessionId,\n resourceName: request.params?.name || \"Unknown\",\n eventType: PublishEventRequestEventTypeEnum.mcpcatIdentify,\n parameters: {\n request: request,\n extra: extra,\n },\n timestamp: new Date(),\n redactionFn: data.options.redactSensitiveInformation,\n };\n\n try {\n const identityResult = await data.options.identify(request, extra);\n if (identityResult) {\n // Now use the (possibly updated) sessionId for all subsequent operations\n const currentSessionId = data.sessionId;\n\n // Check global cache first (works across server instance restarts)\n const previousIdentity = _globalIdentityCache.get(currentSessionId);\n\n // Merge identities (overwrite userId/userName, merge userData)\n const mergedIdentity = mergeIdentities(previousIdentity, identityResult);\n\n // Only publish if identity has changed\n const hasChanged =\n !previousIdentity ||\n !areIdentitiesEqual(previousIdentity, mergedIdentity);\n\n // Update BOTH caches to keep them in sync\n // Global cache: persists across server instances\n _globalIdentityCache.set(currentSessionId, mergedIdentity);\n // Per-server cache: used by getSessionInfo() for fast local access\n data.identifiedSessions.set(data.sessionId, mergedIdentity);\n\n if (hasChanged) {\n writeToLog(\n `Identified session ${currentSessionId} with identity: ${JSON.stringify(mergedIdentity)}`,\n );\n publishEvent(server, identifyEvent);\n }\n } else {\n writeToLog(\n `Warning: Supplied identify function returned null for session ${sessionId}`,\n );\n }\n } catch (error) {\n writeToLog(\n `Warning: Supplied identify function threw an error while identifying session ${sessionId} - ${error}`,\n );\n identifyEvent.duration =\n (identifyEvent.timestamp &&\n new Date().getTime() - identifyEvent.timestamp.getTime()) ||\n undefined;\n identifyEvent.isError = true;\n identifyEvent.error = captureException(error);\n publishEvent(server, identifyEvent);\n }\n}\n","import {\n Configuration,\n EventsApi,\n PublishEventRequest,\n PublishEventRequestEventTypeEnum,\n} from \"mcpcat-api\";\nimport { Event, UnredactedEvent, MCPServerLike } from \"../types.js\";\nimport { writeToLog } from \"./logging.js\";\nimport { getServerTrackingData } from \"./internal.js\";\nimport { getSessionInfo } from \"./session.js\";\nimport { redactEvent } from \"./redaction.js\";\nimport KSUID from \"../thirdparty/ksuid/index.js\";\nimport { getMCPCompatibleErrorMessage } from \"./compatibility.js\";\nimport { TelemetryManager } from \"./telemetry.js\";\n\nclass EventQueue {\n private queue: UnredactedEvent[] = [];\n private processing = false;\n private maxRetries = 3;\n private maxQueueSize = 10000; // Prevent unbounded growth\n private concurrency = 5; // Max parallel requests\n private activeRequests = 0;\n private apiClient: EventsApi;\n private telemetryManager?: TelemetryManager;\n\n constructor() {\n const config = new Configuration({ basePath: \"https://api.mcpcat.io\" });\n this.apiClient = new EventsApi(config);\n }\n\n setTelemetryManager(telemetryManager: TelemetryManager): void {\n this.telemetryManager = telemetryManager;\n }\n\n add(event: UnredactedEvent): void {\n // Drop oldest events if queue is full (or implement your preferred strategy)\n if (this.queue.length >= this.maxQueueSize) {\n writeToLog(\"Event queue full, dropping oldest event\");\n this.queue.shift();\n }\n\n this.queue.push(event);\n this.process();\n }\n\n private async process(): Promise<void> {\n if (this.processing) return;\n\n this.processing = true;\n\n while (this.queue.length > 0 && this.activeRequests < this.concurrency) {\n const event = this.queue.shift();\n if (event?.redactionFn) {\n // Redact sensitive information if a redaction function is provided\n try {\n const redactedEvent = await redactEvent(event, event.redactionFn);\n event.redactionFn = undefined; // Clear the function to avoid reprocessing\n Object.assign(event, redactedEvent);\n } catch (error) {\n writeToLog(`Failed to redact event: ${error}`);\n continue; // Skip this event if redaction fails\n }\n }\n\n if (event) {\n event.id = event.id || (await KSUID.withPrefix(\"evt\").random());\n this.activeRequests++;\n this.sendEvent(event as Event).finally(() => {\n this.activeRequests--;\n // Try to process more events\n this.process();\n });\n }\n }\n\n this.processing = false;\n }\n\n private toPublishEventRequest(event: Event): PublishEventRequest {\n return {\n // Core fields\n id: event.id,\n projectId: event.projectId,\n sessionId: event.sessionId,\n timestamp: event.timestamp,\n duration: event.duration,\n\n // Event data\n eventType: event.eventType as PublishEventRequestEventTypeEnum,\n resourceName: event.resourceName,\n parameters: event.parameters,\n response: event.response,\n userIntent: event.userIntent,\n isError: event.isError,\n error: event.error,\n\n // Actor fields\n identifyActorGivenId: event.identifyActorGivenId,\n identifyActorName: event.identifyActorName,\n identifyData: event.identifyActorData,\n\n // Session info\n ipAddress: event.ipAddress,\n sdkLanguage: event.sdkLanguage,\n mcpcatVersion: event.mcpcatVersion,\n serverName: event.serverName,\n serverVersion: event.serverVersion,\n clientName: event.clientName,\n clientVersion: event.clientVersion,\n\n // Legacy fields\n actorId: event.actorId || event.identifyActorGivenId,\n eventId: event.eventId,\n };\n }\n\n private async sendEvent(event: Event, retries = 0): Promise<void> {\n // Export to telemetry if configured (fire-and-forget)\n if (this.telemetryManager) {\n this.telemetryManager.export(event).catch((error) => {\n writeToLog(\n `Telemetry export error: ${getMCPCompatibleErrorMessage(error)}`,\n );\n });\n }\n\n // Send to MCPCat API if projectId is provided\n if (event.projectId) {\n try {\n const publishRequest = this.toPublishEventRequest(event);\n await this.apiClient.publishEvent({\n publishEventRequest: publishRequest,\n });\n writeToLog(\n `Successfully sent event ${event.id} | ${event.eventType} | ${event.projectId} | ${event.duration} ms | ${event.identifyActorGivenId || \"anonymous\"}`,\n );\n writeToLog(`Event details: ${JSON.stringify(event)}`);\n } catch (error) {\n writeToLog(\n `Failed to send event ${event.id}, retrying... [Error: ${getMCPCompatibleErrorMessage(error)}]`,\n );\n if (retries < this.maxRetries) {\n // Exponential backoff: 1s, 2s, 4s\n await this.delay(Math.pow(2, retries) * 1000);\n return this.sendEvent(event, retries + 1);\n }\n throw error;\n }\n }\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n\n // Get queue stats for monitoring\n getStats() {\n return {\n queueLength: this.queue.length,\n activeRequests: this.activeRequests,\n isProcessing: this.processing,\n };\n }\n\n // Graceful shutdown - wait for active requests\n async destroy(): Promise<void> {\n // Stop accepting new events\n this.add = () => {\n writeToLog(\"Queue is shutting down, event dropped\");\n };\n\n // Wait for queue to drain (with timeout)\n const timeout = 5000; // 5 seconds\n const start = Date.now();\n\n while (\n (this.queue.length > 0 || this.activeRequests > 0) &&\n Date.now() - start < timeout\n ) {\n await this.delay(100);\n }\n\n if (this.queue.length > 0) {\n writeToLog(\n `Shutting down with ${this.queue.length} events still in queue`,\n );\n }\n }\n}\n\nexport const eventQueue = new EventQueue();\n\n// Register graceful shutdown handlers if available (Node.js only)\n// Edge environments (Cloudflare Workers, etc.) don't have process signals\ntry {\n if (typeof process !== \"undefined\" && typeof process.once === \"function\") {\n process.once(\"SIGINT\", () => eventQueue.destroy());\n process.once(\"SIGTERM\", () => eventQueue.destroy());\n process.once(\"beforeExit\", () => eventQueue.destroy());\n }\n} catch {\n // process.once not available in this environment - graceful shutdown handlers not registered\n}\n\nexport function setTelemetryManager(telemetryManager: TelemetryManager): void {\n eventQueue.setTelemetryManager(telemetryManager);\n}\n\nexport function publishEvent(\n server: MCPServerLike,\n eventInput: UnredactedEvent,\n): void {\n const data = getServerTrackingData(server);\n if (!data) {\n writeToLog(\n \"Warning: Server tracking data not found. Event will not be published.\",\n );\n return;\n }\n\n const sessionInfo = getSessionInfo(server, data);\n\n // Calculate duration if not provided\n const duration =\n eventInput.duration ||\n (eventInput.timestamp\n ? new Date().getTime() - eventInput.timestamp.getTime()\n : undefined);\n\n // Build complete Event object with all fields explicit\n const fullEvent: UnredactedEvent = {\n // Core fields (id will be generated later in the queue)\n id: eventInput.id || \"\",\n sessionId: eventInput.sessionId || data.sessionId,\n projectId: data.projectId,\n\n // Event metadata\n eventType: eventInput.eventType || \"\",\n timestamp: eventInput.timestamp || new Date(),\n duration: duration,\n\n // Session context from sessionInfo\n ipAddress: sessionInfo.ipAddress,\n sdkLanguage: sessionInfo.sdkLanguage,\n mcpcatVersion: sessionInfo.mcpcatVersion,\n serverName: sessionInfo.serverName,\n serverVersion: sessionInfo.serverVersion,\n clientName: sessionInfo.clientName,\n clientVersion: sessionInfo.clientVersion,\n\n // Actor information from sessionInfo\n identifyActorGivenId: sessionInfo.identifyActorGivenId,\n identifyActorName: sessionInfo.identifyActorName,\n identifyActorData: sessionInfo.identifyActorData,\n\n // Event-specific data from input\n resourceName: eventInput.resourceName,\n parameters: eventInput.parameters,\n response: eventInput.response,\n userIntent: eventInput.userIntent,\n isError: eventInput.isError,\n error: eventInput.error,\n\n // Preserve redaction function\n redactionFn: eventInput.redactionFn,\n };\n\n eventQueue.add(fullEvent);\n}\n","\"use strict\";\nimport { randomBytes } from \"node:crypto\";\nimport { inspect } from \"node:util\";\nimport { promisify } from \"node:util\";\nimport * as base62 from \"./base62.js\";\n\nconst customInspectSymbol = inspect.custom;\n\nconst asyncRandomBytes = promisify(randomBytes);\n\n// KSUID's epoch starts more recently so that the 32-bit number space gives a\n// significantly higher useful lifetime of around 136 years from March 2014.\n// This number (14e11) was picked to be easy to remember.\nconst EPOCH_IN_MS = 14e11;\n\nconst MAX_TIME_IN_MS = 1e3 * (2 ** 32 - 1) + EPOCH_IN_MS;\n\n// Timestamp is a uint32\nconst TIMESTAMP_BYTE_LENGTH = 4;\n\n// Payload is 16-bytes\nconst PAYLOAD_BYTE_LENGTH = 16;\n\n// KSUIDs are 20 bytes when binary encoded\nconst BYTE_LENGTH = TIMESTAMP_BYTE_LENGTH + PAYLOAD_BYTE_LENGTH;\n\n// The length of a KSUID when string (base62) encoded\nconst STRING_ENCODED_LENGTH = 27;\n\nconst TIME_IN_MS_ASSERTION =\n `Valid KSUID timestamps must be in milliseconds since ${new Date(0).toISOString()},\n no earlier than ${new Date(EPOCH_IN_MS).toISOString()} and no later than ${new Date(MAX_TIME_IN_MS).toISOString()}\n`\n .trim()\n .replace(/(\\n|\\s)+/g, \" \")\n .replace(/\\.000Z/g, \"Z\");\n\nconst VALID_ENCODING_ASSERTION = `Valid encoded KSUIDs are ${STRING_ENCODED_LENGTH} characters`;\n\nconst VALID_BUFFER_ASSERTION = `Valid KSUID buffers are ${BYTE_LENGTH} bytes`;\n\nconst VALID_PAYLOAD_ASSERTION = `Valid KSUID payloads are ${PAYLOAD_BYTE_LENGTH} bytes`;\n\nfunction fromParts(timeInMs, payload) {\n const timestamp = Math.floor((timeInMs - EPOCH_IN_MS) / 1e3);\n const timestampBuffer = Buffer.allocUnsafe(TIMESTAMP_BYTE_LENGTH);\n timestampBuffer.writeUInt32BE(timestamp, 0);\n\n return Buffer.concat([timestampBuffer, payload], BYTE_LENGTH);\n}\n\nconst bufferLookup = new WeakMap();\n\nclass KSUID {\n constructor(buffer) {\n if (!KSUID.isValid(buffer)) {\n throw new TypeError(VALID_BUFFER_ASSERTION);\n }\n\n bufferLookup.set(this, buffer);\n Object.defineProperty(this, \"buffer\", {\n enumerable: true,\n get() {\n return Buffer.from(buffer);\n },\n });\n }\n\n get raw() {\n return Buffer.from(bufferLookup.get(this).slice(0));\n }\n\n get date() {\n return new Date(1e3 * this.timestamp + EPOCH_IN_MS);\n }\n\n get timestamp() {\n return bufferLookup.get(this).readUInt32BE(0);\n }\n\n get payload() {\n const payload = bufferLookup\n .get(this)\n .slice(TIMESTAMP_BYTE_LENGTH, BYTE_LENGTH);\n return Buffer.from(payload);\n }\n\n get string() {\n const encoded = base62.encode(\n bufferLookup.get(this),\n STRING_ENCODED_LENGTH,\n );\n return encoded.padStart(STRING_ENCODED_LENGTH, \"0\");\n }\n\n compare(other) {\n if (!bufferLookup.has(other)) {\n return 0;\n }\n\n return bufferLookup\n .get(this)\n .compare(bufferLookup.get(other), 0, BYTE_LENGTH);\n }\n\n equals(other) {\n return (\n this === other || (bufferLookup.has(other) && this.compare(other) === 0)\n );\n }\n\n toString() {\n return `${this[Symbol.toStringTag]} { ${this.string} }`;\n }\n\n toJSON() {\n return this.string;\n }\n\n [customInspectSymbol]() {\n return this.toString();\n }\n\n static async random(time = Date.now()) {\n const payload = await asyncRandomBytes(PAYLOAD_BYTE_LENGTH);\n return new KSUID(fromParts(Number(time), payload));\n }\n\n static randomSync(time = Date.now()) {\n const payload = randomBytes(PAYLOAD_BYTE_LENGTH);\n return new KSUID(fromParts(Number(time), payload));\n }\n\n static fromParts(timeInMs, payload) {\n if (\n !Number.isInteger(timeInMs) ||\n timeInMs < EPOCH_IN_MS ||\n timeInMs > MAX_TIME_IN_MS\n ) {\n throw new TypeError(TIME_IN_MS_ASSERTION);\n }\n if (\n !Buffer.isBuffer(payload) ||\n payload.byteLength !== PAYLOAD_BYTE_LENGTH\n ) {\n throw new TypeError(VALID_PAYLOAD_ASSERTION);\n }\n\n return new KSUID(fromParts(timeInMs, payload));\n }\n\n static isValid(buffer) {\n return Buffer.isBuffer(buffer) && buffer.byteLength === BYTE_LENGTH;\n }\n\n static parse(string) {\n if (string.length !== STRING_ENCODED_LENGTH) {\n throw new TypeError(VALID_ENCODING_ASSERTION);\n }\n\n const decoded = base62.decode(string, BYTE_LENGTH);\n if (decoded.byteLength === BYTE_LENGTH) {\n return new KSUID(decoded);\n }\n\n const buffer = Buffer.allocUnsafe(BYTE_LENGTH);\n const padEnd = BYTE_LENGTH - decoded.byteLength;\n buffer.fill(0, 0, padEnd);\n decoded.copy(buffer, padEnd);\n return new KSUID(buffer);\n }\n}\nObject.defineProperty(KSUID.prototype, Symbol.toStringTag, { value: \"KSUID\" });\n// A string-encoded maximum value for a KSUID\nObject.defineProperty(KSUID, \"MAX_STRING_ENCODED\", {\n value: \"aWgEPTl1tmebfsQzFP4bxwgy80V\",\n});\n// A string-encoded minimum value for a KSUID\nObject.defineProperty(KSUID, \"MIN_STRING_ENCODED\", {\n value: \"000000000000000000000000000\",\n});\n\n// Add prefix functionality\nKSUID.withPrefix = function (prefix) {\n return {\n random: async (time = Date.now()) => {\n const ksuid = await KSUID.random(time);\n return `${prefix}_${ksuid.string}`;\n },\n randomSync: (time = Date.now()) => {\n const ksuid = KSUID.randomSync(time);\n return `${prefix}_${ksuid.string}`;\n },\n fromParts: (timeInMs, payload) => {\n const ksuid = KSUID.fromParts(timeInMs, payload);\n return `${prefix}_${ksuid.string}`;\n },\n };\n};\n\nexport default KSUID;\n","\"use strict\";\n\nconst maxLength = (array, from, to) =>\n Math.ceil((array.length * Math.log2(from)) / Math.log2(to));\n\nfunction baseConvertIntArray(array, { from, to, fixedLength = null }) {\n const length =\n fixedLength === null ? maxLength(array, from, to) : fixedLength;\n const result = new Array(length);\n\n // Each iteration prepends the resulting value, so start the offset at the end.\n let offset = length;\n let input = array;\n while (input.length > 0) {\n if (offset === 0) {\n throw new RangeError(\n `Fixed length of ${fixedLength} is too small, expected at least ${maxLength(array, from, to)}`,\n );\n }\n\n const quotients = [];\n let remainder = 0;\n\n for (const digit of input) {\n const acc = digit + remainder * from;\n const q = Math.floor(acc / to);\n remainder = acc % to;\n\n if (quotients.length > 0 || q > 0) {\n quotients.push(q);\n }\n }\n\n result[--offset] = remainder;\n input = quotients;\n }\n\n // Trim leading padding, unless length is fixed.\n if (fixedLength === null) {\n return offset > 0 ? result.slice(offset) : result;\n }\n\n // Fill in any holes in the result array.\n while (offset > 0) {\n result[--offset] = 0;\n }\n return result;\n}\nexport default baseConvertIntArray;\n","\"use strict\";\nimport baseConvertIntArray from \"./base-convert-int-array.js\";\n\nconst CHARS = \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\";\n\nfunction encode(buffer, fixedLength) {\n return baseConvertIntArray(buffer, { from: 256, to: 62, fixedLength })\n .map((value) => CHARS[value])\n .join(\"\");\n}\n\nfunction decode(string, fixedLength) {\n // Optimization from https://github.com/andrew/base62.js/pull/31.\n const input = Array.from(string, (char) => {\n const charCode = char.charCodeAt(0);\n if (charCode < 58) return charCode - 48;\n if (charCode < 91) return charCode - 55;\n return charCode - 61;\n });\n return Buffer.from(\n baseConvertIntArray(input, { from: 62, to: 256, fixedLength }),\n );\n}\nexport { encode, decode };\n","{\n \"name\": \"mcpcat\",\n \"version\": \"0.1.11\",\n \"description\": \"Analytics tool for MCP (Model Context Protocol) servers - tracks tool usage patterns and provides insights\",\n \"type\": \"module\",\n \"main\": \"dist/index.js\",\n \"module\": \"dist/index.mjs\",\n \"types\": \"dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.mjs\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest\",\n \"test:compatibility\": \"vitest run src/tests/mcp-version-compatibility.test.ts\",\n \"lint\": \"eslint src/\",\n \"typecheck\": \"tsc --noEmit\",\n \"prepare\": \"husky\",\n \"prepublishOnly\": \"pnpm run build && pnpm run test && pnpm run lint && pnpm run typecheck\"\n },\n \"keywords\": [\n \"ai\",\n \"authentication\",\n \"mcp\",\n \"observability\",\n \"ai-agents\",\n \"ai-platform\",\n \"ai-agent\",\n \"mcps\",\n \"aiagents\",\n \"ai-agent-tools\",\n \"mcp-servers\",\n \"mcp-server\",\n \"mcp-tools\",\n \"agent-runtime\",\n \"mcp-framework\",\n \"mcp-analytics\"\n ],\n \"author\": \"MCPcat\",\n \"license\": \"MIT\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/MCPCat/mcpcat-typescript-sdk.git\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/MCPCat/mcpcat-typescript-sdk/issues\"\n },\n \"homepage\": \"https://github.com/MCPCat/mcpcat-typescript-sdk#readme\",\n \"packageManager\": \"pnpm@10.11.0\",\n \"devDependencies\": {\n \"@changesets/cli\": \"^2.29.8\",\n \"@modelcontextprotocol/sdk\": \"~1.24.2\",\n \"@types/node\": \"^22.15.21\",\n \"@typescript-eslint/eslint-plugin\": \"^8.32.1\",\n \"@typescript-eslint/parser\": \"^8.32.1\",\n \"@vitest/coverage-v8\": \"^4.0.14\",\n \"@vitest/ui\": \"^4.0.14\",\n \"eslint\": \"^9.39.1\",\n \"husky\": \"^9.1.7\",\n \"lint-staged\": \"^16.1.0\",\n \"prettier\": \"^3.5.3\",\n \"tsup\": \"^8.5.0\",\n \"typescript\": \"^5.8.3\",\n \"vitest\": \"^4.0.14\",\n \"zod\": \"^3.25 || ^4.0\"\n },\n \"peerDependencies\": {\n \"@modelcontextprotocol/sdk\": \">=1.11\"\n },\n \"dependencies\": {\n \"@opentelemetry/otlp-transformer\": \"^0.203.0\",\n \"mcpcat-api\": \"0.1.6\"\n },\n \"lint-staged\": {\n \"*.{ts,js}\": [\n \"eslint --fix\",\n \"prettier --write\"\n ],\n \"*.{json,md,yml,yaml}\": [\n \"prettier --write\"\n ]\n },\n \"pnpm\": {\n \"overrides\": {\n \"js-yaml\": \">=4.1.1\",\n \"tmp\": \">=0.2.4\",\n \"vite\": \">=6.4.1\",\n \"body-parser\": \">=2.2.1\",\n \"brace-expansion\": \"2.0.2\"\n },\n \"overridesComments\": {\n \"js-yaml\": \"Fixes GHSA-mh29-5h37-fv8m (prototype pollution in merge) - via @changesets/cli\",\n \"tmp\": \"Fixes GHSA-52f5-9888-hmc6 (symlink attack) - via @changesets/cli\",\n \"vite\": \"Fixes GHSA-93m4-6634-74q7 and other vite security issues - via vitest\",\n \"body-parser\": \"Fixes GHSA-wqch-xfxh-vrr4 (DoS via url encoding) - via @modelcontextprotocol/sdk\",\n \"brace-expansion\": \"Fixes GHSA-v6h2-p8h4-qcjw (ReDoS vulnerability) - via eslint\"\n }\n }\n}\n","import {\n MCPCatData,\n MCPServerLike,\n ServerClientInfoLike,\n SessionInfo,\n CompatibleRequestHandlerExtra,\n} from \"../types.js\";\nimport { getServerTrackingData, setServerTrackingData } from \"./internal.js\";\nimport KSUID from \"../thirdparty/ksuid/index.js\";\nimport packageJson from \"../../package.json\" with { type: \"json\" };\nimport { createHash } from \"crypto\";\n\nimport { INACTIVITY_TIMEOUT_IN_MINUTES } from \"./constants.js\";\n\nexport function newSessionId(): string {\n return KSUID.withPrefix(\"ses\").randomSync();\n}\n\n/**\n * Creates a deterministic KSUID session ID from an MCP sessionId and optional projectId.\n * The same inputs will always produce the same session ID, enabling correlation across server restarts.\n *\n * @param mcpSessionId - The session ID from the MCP protocol\n * @param projectId - Optional MCPCat project ID to include in the hash\n * @returns A KSUID with \"ses\" prefix derived deterministically from the inputs\n */\nexport function deriveSessionIdFromMCPSession(\n mcpSessionId: string,\n projectId?: string,\n): string {\n // Create input string for hashing\n const input = projectId ? `${mcpSessionId}:${projectId}` : mcpSessionId;\n\n // Hash the input with SHA-256\n const hash = createHash(\"sha256\").update(input).digest();\n\n // Extract timestamp from first 4 bytes of hash (for deterministic but reasonable timestamp)\n // We'll use a fixed epoch (2024-01-01) plus the hash value to get a deterministic but valid timestamp\n const EPOCH_2024 = new Date(\"2024-01-01T00:00:00Z\").getTime();\n const timestampOffset = hash.readUInt32BE(0) % (365 * 24 * 60 * 60 * 1000); // Max 1 year offset\n const timestamp = EPOCH_2024 + timestampOffset;\n\n // Use the remaining 16 bytes of hash as the KSUID payload\n const payload = hash.subarray(4, 20);\n\n // Create deterministic KSUID with prefix\n return KSUID.withPrefix(\"ses\").fromParts(timestamp, payload);\n}\n\n/**\n * Gets or generates a session ID for the server.\n * Prioritizes MCP protocol sessionId over MCPCat-generated sessionId.\n *\n * @param server - The MCP server instance\n * @param extra - Optional extra data containing MCP sessionId\n * @returns The session ID to use for events\n */\nexport function getServerSessionId(\n server: MCPServerLike,\n extra?: CompatibleRequestHandlerExtra,\n): string {\n const data = getServerTrackingData(server);\n\n if (!data) {\n throw new Error(\"Server tracking data not found\");\n }\n\n const mcpSessionId = extra?.sessionId;\n\n // If MCP sessionId is provided\n if (mcpSessionId) {\n // Derive deterministic KSUID from MCP sessionId\n data.sessionId = deriveSessionIdFromMCPSession(\n mcpSessionId,\n data.projectId || undefined,\n );\n data.lastMcpSessionId = mcpSessionId;\n data.sessionSource = \"mcp\";\n setServerTrackingData(server, data);\n // If MCP sessionId hasn't changed, continue using the existing derived KSUID\n setLastActivity(server);\n return data.sessionId;\n }\n\n // No MCP sessionId provided - handle MCPCat-generated sessions\n // If we had an MCP sessionId before but it disappeared, keep using the last derived ID\n if (data.sessionSource === \"mcp\" && data.lastMcpSessionId) {\n setLastActivity(server);\n return data.sessionId;\n }\n\n // For MCPCat-generated sessions, apply timeout logic\n const now = Date.now();\n const timeoutMs = INACTIVITY_TIMEOUT_IN_MINUTES * 60 * 1000;\n // If last activity timed out\n if (now - data.lastActivity.getTime() > timeoutMs) {\n data.sessionId = newSessionId();\n data.sessionSource = \"mcpcat\";\n setServerTrackingData(server, data);\n }\n setLastActivity(server);\n\n return data.sessionId;\n}\n\nexport function setLastActivity(server: MCPServerLike): void {\n const data = getServerTrackingData(server);\n\n if (!data) {\n throw new Error(\"Server tracking data not found\");\n }\n\n data.lastActivity = new Date();\n setServerTrackingData(server, data);\n}\n\nexport function getSessionInfo(\n server: MCPServerLike,\n data: MCPCatData | undefined,\n): SessionInfo {\n let clientInfo: ServerClientInfoLike | undefined = {\n name: undefined,\n version: undefined,\n };\n if (!data?.sessionInfo.clientName) {\n clientInfo = server.getClientVersion();\n }\n const actorInfo = data?.identifiedSessions.get(data.sessionId);\n\n const sessionInfo: SessionInfo = {\n ipAddress: undefined, // grab from django\n sdkLanguage: \"TypeScript\", // hardcoded for now\n mcpcatVersion: packageJson.version,\n serverName: server._serverInfo?.name,\n serverVersion: server._serverInfo?.version,\n clientName: clientInfo?.name,\n clientVersion: clientInfo?.version,\n identifyActorGivenId: actorInfo?.userId,\n identifyActorName: actorInfo?.userName,\n identifyActorData: actorInfo?.userData || {},\n };\n\n if (!data) {\n return sessionInfo;\n }\n\n data.sessionInfo = sessionInfo;\n setServerTrackingData(server, data);\n return data.sessionInfo;\n}\n","// MCPCat Settings\nexport const INACTIVITY_TIMEOUT_IN_MINUTES = 30;\nexport const DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = `Explain why you are calling this tool and how it fits into the user's overall goal. This parameter is used for analytics and user intent tracking. YOU MUST provide 15-25 words (count carefully). NEVER use first person ('I', 'we', 'you') - maintain third-person perspective. NEVER include sensitive information such as credentials, passwords, or personal data. Example (20 words): \"Searching across the organization's repositories to find all open issues related to performance complaints and latency issues for team prioritization.\"`;\nexport const MCPCAT_CUSTOM_EVENT_TYPE = \"mcpcat:custom\";\n","import { Event, RedactFunction, UnredactedEvent } from \"../types.js\";\n\n/**\n * Set of field names that should be protected from redaction.\n * These fields contain system-level identifiers and metadata that\n * need to be preserved for analytics tracking.\n */\nconst PROTECTED_FIELDS = new Set([\n \"sessionId\",\n \"id\",\n \"projectId\",\n \"server\",\n \"identifyActorGivenId\",\n \"identifyActorName\",\n \"identifyData\",\n \"resourceName\",\n \"eventType\",\n \"actorId\",\n]);\n\n/**\n * Recursively applies a redaction function to all string values in an object.\n * This ensures that sensitive information is removed from all string fields\n * before events are sent to the analytics service.\n *\n * @param obj - The object to redact strings from\n * @param redactFn - The redaction function to apply to each string\n * @param path - The current path in the object tree (used to check protected fields)\n * @param isProtected - Whether the current object/value is within a protected field\n * @returns A new object with all strings redacted\n */\nasync function redactStringsInObject(\n obj: any,\n redactFn: RedactFunction,\n path: string = \"\",\n isProtected: boolean = false,\n): Promise<any> {\n if (obj === null || obj === undefined) {\n return obj;\n }\n\n // Handle strings\n if (typeof obj === \"string\") {\n // Don't redact if this field or any parent field is protected\n if (isProtected) {\n return obj;\n }\n return await redactFn(obj);\n }\n\n // Handle arrays\n if (Array.isArray(obj)) {\n return Promise.all(\n obj.map((item, index) =>\n redactStringsInObject(item, redactFn, `${path}[${index}]`, isProtected),\n ),\n );\n }\n\n // Handle dates (don't redact)\n if (obj instanceof Date) {\n return obj;\n }\n\n // Handle objects\n if (typeof obj === \"object\") {\n const redactedObj: any = {};\n\n for (const [key, value] of Object.entries(obj)) {\n // Skip functions and undefined values\n if (typeof value === \"function\" || value === undefined) {\n continue;\n }\n\n // Build the path for nested fields\n const fieldPath = path ? `${path}.${key}` : key;\n // Check if this field is protected (only check at top level)\n const isFieldProtected =\n isProtected || (path === \"\" && PROTECTED_FIELDS.has(key));\n redactedObj[key] = await redactStringsInObject(\n value,\n redactFn,\n fieldPath,\n isFieldProtected,\n );\n }\n\n return redactedObj;\n }\n\n // For all other types (numbers, booleans, etc.), return as-is\n return obj;\n}\n\n/**\n * Applies the customer's redaction function to all string fields in an Event object.\n * This is the main entry point for redacting sensitive information from events\n * before they are sent to the analytics service.\n *\n * @param event - The event to redact\n * @param redactFn - The customer's redaction function\n * @returns A new event object with all strings redacted\n */\nexport async function redactEvent(\n event: UnredactedEvent,\n redactFn: RedactFunction,\n): Promise<Event> {\n return redactStringsInObject(event, redactFn, \"\", false) as Promise<Event>;\n}\n","import { createRequire } from \"module\";\nimport { ErrorData, StackFrame, ChainedErrorData } from \"../types.js\";\n\n// Lazy-loaded fs module for context_line extraction (Node.js only)\n// Edge environments don't have filesystem access\nlet fsModule: typeof import(\"fs\") | null = null;\nlet fsInitAttempted = false;\n\nfunction getFsSync(): typeof import(\"fs\") | null {\n if (!fsInitAttempted) {\n fsInitAttempted = true;\n try {\n // Use createRequire for ESM compatibility\n // Works in Node.js ESM/CJS, fails gracefully in Workers/edge environments\n const require = createRequire(import.meta.url);\n fsModule = require(\"fs\");\n } catch {\n fsModule = null;\n }\n }\n return fsModule;\n}\n\n// Maximum number of exceptions to capture in a cause chain\nconst MAX_EXCEPTION_CHAIN_DEPTH = 10;\n\n// Maximum number of stack frames to capture per exception\nconst MAX_STACK_FRAMES = 50;\n\n/**\n * Captures detailed exception information including stack traces and cause chains.\n *\n * This function extracts error metadata (type, message, stack trace) and recursively\n * unwraps Error.cause chains. It parses V8 stack traces into structured frames and\n * detects whether each frame is user code (in_app: true) or library code (in_app: false).\n *\n * @param error - The error to capture (can be Error, string, object, or any value)\n * @param contextStack - Optional Error object to use for stack context (for validation errors)\n * @returns ErrorData object with structured error information\n */\nexport function captureException(\n error: unknown,\n contextStack?: Error,\n): ErrorData {\n // Handle CallToolResult objects (SDK 1.21.0+ converts errors to these)\n if (isCallToolResult(error)) {\n return captureCallToolResultError(error, contextStack);\n }\n\n // Handle non-Error objects\n if (!(error instanceof Error)) {\n return {\n message: stringifyNonError(error),\n type: undefined,\n platform: \"javascript\",\n };\n }\n\n const errorData: ErrorData = {\n message: error.message || \"\",\n type: error.name || error.constructor?.name || undefined,\n platform: \"javascript\",\n };\n\n // Capt