@posthog/mcp 0.0.8 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +307 -2
  2. package/README.md +186 -93
  3. package/dist/extensions/capture.d.ts +13 -0
  4. package/dist/extensions/capture.d.ts.map +1 -0
  5. package/dist/extensions/capture.js +80 -0
  6. package/dist/extensions/capture.mjs +46 -0
  7. package/dist/extensions/compatibility.d.ts +23 -0
  8. package/dist/extensions/compatibility.d.ts.map +1 -0
  9. package/dist/extensions/compatibility.js +113 -0
  10. package/dist/extensions/compatibility.mjs +67 -0
  11. package/dist/extensions/constants.d.ts +39 -0
  12. package/dist/extensions/constants.d.ts.map +1 -0
  13. package/dist/extensions/constants.js +86 -0
  14. package/dist/extensions/constants.mjs +37 -0
  15. package/dist/extensions/context-parameters.d.ts +31 -0
  16. package/dist/extensions/context-parameters.d.ts.map +1 -0
  17. package/dist/extensions/context-parameters.js +93 -0
  18. package/dist/extensions/context-parameters.mjs +50 -0
  19. package/dist/extensions/conversation-id.d.ts +52 -0
  20. package/dist/extensions/conversation-id.d.ts.map +1 -0
  21. package/dist/extensions/conversation-id.js +164 -0
  22. package/dist/extensions/conversation-id.mjs +103 -0
  23. package/dist/extensions/event-types.d.ts +21 -0
  24. package/dist/extensions/event-types.d.ts.map +1 -0
  25. package/dist/extensions/event-types.js +47 -0
  26. package/dist/extensions/event-types.mjs +13 -0
  27. package/dist/extensions/exceptions.d.ts +13 -0
  28. package/dist/extensions/exceptions.d.ts.map +1 -0
  29. package/dist/extensions/exceptions.js +58 -0
  30. package/dist/extensions/exceptions.mjs +24 -0
  31. package/dist/extensions/ids.d.ts +13 -0
  32. package/dist/extensions/ids.d.ts.map +1 -0
  33. package/dist/extensions/ids.js +55 -0
  34. package/dist/extensions/ids.mjs +18 -0
  35. package/dist/extensions/instrument-highlevel.d.ts +3 -0
  36. package/dist/extensions/instrument-highlevel.d.ts.map +1 -0
  37. package/dist/extensions/instrument-highlevel.js +229 -0
  38. package/dist/extensions/instrument-highlevel.mjs +195 -0
  39. package/dist/extensions/instrument-lowlevel.d.ts +8 -0
  40. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -0
  41. package/dist/extensions/instrument-lowlevel.js +85 -0
  42. package/dist/extensions/instrument-lowlevel.mjs +51 -0
  43. package/dist/extensions/instrumentation.d.ts +59 -0
  44. package/dist/extensions/instrumentation.d.ts.map +1 -0
  45. package/dist/extensions/instrumentation.js +237 -0
  46. package/dist/extensions/instrumentation.mjs +194 -0
  47. package/dist/extensions/intent.d.ts +10 -0
  48. package/dist/extensions/intent.d.ts.map +1 -0
  49. package/dist/extensions/intent.js +84 -0
  50. package/dist/extensions/intent.mjs +44 -0
  51. package/dist/extensions/internal.d.ts +32 -0
  52. package/dist/extensions/internal.d.ts.map +1 -0
  53. package/dist/extensions/internal.js +158 -0
  54. package/dist/extensions/internal.mjs +106 -0
  55. package/dist/extensions/logger.d.ts +14 -0
  56. package/dist/extensions/logger.d.ts.map +1 -0
  57. package/dist/extensions/logger.js +47 -0
  58. package/dist/extensions/logger.mjs +10 -0
  59. package/dist/extensions/mcp-payloads.d.ts +5 -0
  60. package/dist/extensions/mcp-payloads.d.ts.map +1 -0
  61. package/dist/extensions/mcp-payloads.js +91 -0
  62. package/dist/extensions/mcp-payloads.mjs +54 -0
  63. package/dist/extensions/mcp-sdk-compat.d.ts +33 -0
  64. package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -0
  65. package/dist/extensions/mcp-sdk-compat.js +112 -0
  66. package/dist/extensions/mcp-sdk-compat.mjs +60 -0
  67. package/dist/extensions/posthog-events.d.ts +14 -0
  68. package/dist/extensions/posthog-events.d.ts.map +1 -0
  69. package/dist/extensions/posthog-events.js +143 -0
  70. package/dist/extensions/posthog-events.mjs +109 -0
  71. package/dist/extensions/sanitization.d.ts +10 -0
  72. package/dist/extensions/sanitization.d.ts.map +1 -0
  73. package/dist/extensions/sanitization.js +96 -0
  74. package/dist/extensions/sanitization.mjs +62 -0
  75. package/dist/extensions/session.d.ts +20 -0
  76. package/dist/extensions/session.d.ts.map +1 -0
  77. package/dist/extensions/session.js +113 -0
  78. package/dist/extensions/session.mjs +67 -0
  79. package/dist/extensions/sink.d.ts +44 -0
  80. package/dist/extensions/sink.d.ts.map +1 -0
  81. package/dist/extensions/sink.js +102 -0
  82. package/dist/extensions/sink.mjs +65 -0
  83. package/dist/extensions/tools.d.ts +9 -0
  84. package/dist/extensions/tools.d.ts.map +1 -0
  85. package/dist/extensions/tools.js +79 -0
  86. package/dist/extensions/tools.mjs +39 -0
  87. package/dist/extensions/tracing-helpers.d.ts +29 -0
  88. package/dist/extensions/tracing-helpers.d.ts.map +1 -0
  89. package/dist/extensions/tracing-helpers.js +59 -0
  90. package/dist/extensions/tracing-helpers.mjs +16 -0
  91. package/dist/extensions/truncation.d.ts +25 -0
  92. package/dist/extensions/truncation.d.ts.map +1 -0
  93. package/dist/extensions/truncation.js +277 -0
  94. package/dist/extensions/truncation.mjs +228 -0
  95. package/dist/index.d.ts +36 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +132 -0
  98. package/dist/index.mjs +83 -2928
  99. package/dist/types.d.ts +220 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +18 -0
  102. package/dist/types.mjs +0 -0
  103. package/dist/version.d.ts +2 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +36 -0
  106. package/dist/version.mjs +2 -0
  107. package/package.json +55 -87
  108. package/src/extensions/capture.ts +68 -0
  109. package/src/extensions/compatibility.ts +138 -0
  110. package/src/extensions/constants.ts +48 -0
  111. package/src/extensions/context-parameters.ts +119 -0
  112. package/src/extensions/conversation-id.ts +177 -0
  113. package/src/extensions/event-types.ts +21 -0
  114. package/src/extensions/exceptions.ts +83 -0
  115. package/src/extensions/ids.ts +32 -0
  116. package/src/extensions/instrument-highlevel.ts +297 -0
  117. package/src/extensions/instrument-lowlevel.ts +83 -0
  118. package/src/extensions/instrumentation.ts +415 -0
  119. package/src/extensions/intent.ts +78 -0
  120. package/src/extensions/internal.ts +193 -0
  121. package/src/extensions/logger.ts +27 -0
  122. package/src/extensions/mcp-payloads.ts +98 -0
  123. package/src/extensions/mcp-sdk-compat.ts +160 -0
  124. package/src/extensions/posthog-events.ts +212 -0
  125. package/src/extensions/sanitization.ts +121 -0
  126. package/src/extensions/session.ts +117 -0
  127. package/src/extensions/sink.ts +130 -0
  128. package/src/extensions/tools.ts +50 -0
  129. package/src/extensions/tracing-helpers.ts +51 -0
  130. package/src/extensions/truncation.ts +453 -0
  131. package/src/index.ts +174 -0
  132. package/src/types.ts +244 -0
  133. package/src/version.ts +1 -0
  134. package/dist/index.cjs +0 -2936
  135. package/dist/index.cjs.map +0 -1
  136. package/dist/index.d.cts +0 -264
  137. package/dist/index.d.mts +0 -263
  138. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,415 @@
1
+ import {
2
+ InitializeRequestSchema,
3
+ ListToolsRequestSchema,
4
+ type ListToolsResult,
5
+ } from '@modelcontextprotocol/sdk/types.js'
6
+ import type { CompatibleRequestHandlerExtra, MCPAnalyticsData, MCPRequestLike, MCPServerLike, McpEvent } from '../types'
7
+ import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
8
+ import {
9
+ addConversationIdToTools,
10
+ type ConversationIdResolution,
11
+ canInjectConversationIdPromptBack,
12
+ cloneRequestWithoutConversationId,
13
+ injectConversationIdPromptBack,
14
+ resolveConversationId,
15
+ } from './conversation-id'
16
+ import { captureEvent } from './capture'
17
+ import { MCPAnalyticsEventType } from './event-types'
18
+ import { captureException } from './exceptions'
19
+ import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from './intent'
20
+ import { getServerTrackingData, handleIdentify } from './internal'
21
+ import { log } from './logger'
22
+ import { buildCapturedMcpParameters } from './mcp-payloads'
23
+ import { getServerSessionId } from './session'
24
+ import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
25
+ import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
26
+
27
+ /**
28
+ * Single instrumentation core shared by the low-level (`Server`) and high-level
29
+ * (`McpServer`) wrappers. The two entrypoints differ only in how they reach the
30
+ * underlying tool — they both funnel the tool-call lifecycle through
31
+ * {@link captureToolCall}, so error handling, conversation-id minting, session
32
+ * attribution, and event capture are defined exactly once.
33
+ */
34
+
35
+ type MCPRequestHandler = (request: MCPRequestLike, extra?: CompatibleRequestHandlerExtra) => Promise<unknown>
36
+
37
+ /**
38
+ * Runs the underlying tool. Receives the request with the SDK-injected
39
+ * `conversation_id` stripped; an adapter is free to ignore it (the high-level
40
+ * path strips arguments inside the wrapped callback instead).
41
+ */
42
+ type ToolExecutor = (downstreamRequest: MCPRequestLike) => Promise<unknown>
43
+
44
+ interface TraceToolCallParams {
45
+ server: MCPServerLike
46
+ data: MCPAnalyticsData
47
+ request: MCPRequestLike
48
+ extra?: CompatibleRequestHandlerExtra
49
+ execute: ToolExecutor
50
+ /**
51
+ * Event type to capture. Defaults to a tool call; the `get_more_tools` virtual
52
+ * tool passes `mcpMissingCapability` so it records a capability gap rather than
53
+ * a tool invocation.
54
+ */
55
+ eventType?: MCPAnalyticsEventType
56
+ /**
57
+ * When set, used verbatim as the captured intent (source `context_parameter`)
58
+ * instead of running `resolveToolCallIntent`. Used by the `get_more_tools`
59
+ * virtual tool, which carries its intent in the `context` argument.
60
+ */
61
+ explicitContextIntent?: string
62
+ /**
63
+ * Optional accessor for an error the executor captured out-of-band. The
64
+ * high-level SDK turns thrown tool errors into `isError: true` results before
65
+ * they reach us, so the wrapped callback stashes the original error and we
66
+ * read it here to capture the real stack rather than the result envelope.
67
+ */
68
+ takeCapturedError?: () => unknown
69
+ }
70
+
71
+ /**
72
+ * The shared tool-call lifecycle: resolve conversation id, build + enrich the
73
+ * analytics event, run the tool, then capture success/failure.
74
+ *
75
+ * Analytics is isolated from the tool path on both sides — a failure while
76
+ * preparing or publishing the event can never change what the tool returns or
77
+ * throws, and the tool's own errors are always re-thrown to the caller.
78
+ */
79
+ export async function captureToolCall(params: TraceToolCallParams): Promise<unknown> {
80
+ const { server, data, request, extra, execute, eventType, explicitContextIntent, takeCapturedError } = params
81
+
82
+ const conversation = resolveConversationId(
83
+ data.options.enableConversationId ?? false,
84
+ request.params?.arguments,
85
+ request.params?.name
86
+ )
87
+ const downstreamRequest = conversation.conversationId ? cloneRequestWithoutConversationId(request) : request
88
+
89
+ // Prepare the event in isolation: if identity/metadata/intent resolution
90
+ // throws, we drop instrumentation for this call but still run the tool.
91
+ const startTime = new Date()
92
+ const event = await prepareToolCallEvent(
93
+ server,
94
+ data,
95
+ request,
96
+ downstreamRequest,
97
+ extra,
98
+ startTime,
99
+ conversation,
100
+ eventType ?? MCPAnalyticsEventType.mcpToolsCall
101
+ )
102
+ if (event && explicitContextIntent) {
103
+ setExplicitContextIntent(event, explicitContextIntent)
104
+ }
105
+
106
+ let result: unknown
107
+ try {
108
+ result = await execute(downstreamRequest)
109
+ } catch (error) {
110
+ publishFailedToolEvent(server, event, error, startTime, conversation)
111
+ throw error
112
+ }
113
+
114
+ const finalResult = applyConversationPromptBack(event, result, conversation)
115
+ publishSuccessfulToolEvent(server, event, finalResult, startTime, takeCapturedError)
116
+ return finalResult
117
+ }
118
+
119
+ async function prepareToolCallEvent(
120
+ server: MCPServerLike,
121
+ data: MCPAnalyticsData,
122
+ request: MCPRequestLike,
123
+ downstreamRequest: MCPRequestLike,
124
+ extra: CompatibleRequestHandlerExtra | undefined,
125
+ startTime: Date,
126
+ conversation: ConversationIdResolution,
127
+ eventType: MCPAnalyticsEventType
128
+ ): Promise<McpEvent | null> {
129
+ try {
130
+ const sessionId = getServerSessionId(server, extra)
131
+ await handleIdentify(server, data, sessionId, request, extra)
132
+
133
+ const toolName = request.params?.name
134
+ const event: McpEvent = {
135
+ sessionId,
136
+ conversationId: conversation.conversationId,
137
+ resourceName: toolName || 'Unknown Tool Name',
138
+ parameters: buildCapturedMcpParameters(downstreamRequest),
139
+ eventType,
140
+ timestamp: startTime,
141
+ toolDescription: toolName ? data.toolDescriptions.get(toolName) : undefined,
142
+ }
143
+
144
+ await applyResolvedMetadata(event, data, request, extra)
145
+ setEventIntent(event, await resolveToolCallIntent(data, request, extra))
146
+ return event
147
+ } catch (error) {
148
+ log(
149
+ `Warning: PostHog MCP analytics instrumentation failed for tool ${request.params?.name}, the tool will still run - ${error}`
150
+ )
151
+ return null
152
+ }
153
+ }
154
+
155
+ /**
156
+ * When we minted a conversation id, append the prompt-back so the agent echoes
157
+ * it on subsequent calls. If the result can't carry it, clear the id off the
158
+ * event so analytics doesn't show an orphan the agent never received.
159
+ */
160
+ function applyConversationPromptBack(
161
+ event: McpEvent | null,
162
+ result: unknown,
163
+ conversation: ConversationIdResolution
164
+ ): unknown {
165
+ if (!conversation.minted) {
166
+ return result
167
+ }
168
+ if (canInjectConversationIdPromptBack(result)) {
169
+ return injectConversationIdPromptBack(result, conversation.conversationId)
170
+ }
171
+ if (event) {
172
+ event.conversationId = undefined
173
+ }
174
+ return result
175
+ }
176
+
177
+ function publishSuccessfulToolEvent(
178
+ server: MCPServerLike,
179
+ event: McpEvent | null,
180
+ result: unknown,
181
+ startTime: Date,
182
+ takeCapturedError?: () => unknown
183
+ ): void {
184
+ if (!event) {
185
+ return
186
+ }
187
+ try {
188
+ if (isToolResultError(result)) {
189
+ event.isError = true
190
+ const capturedError = takeCapturedError?.()
191
+ event.error = captureException(capturedError ?? result)
192
+ } else {
193
+ event.isError = false
194
+ }
195
+ event.response = result
196
+ event.duration = Date.now() - startTime.getTime()
197
+ captureEvent(server, event)
198
+ } catch (error) {
199
+ log(`Warning: PostHog MCP analytics failed to publish tool event - ${error}`)
200
+ }
201
+ }
202
+
203
+ function publishFailedToolEvent(
204
+ server: MCPServerLike,
205
+ event: McpEvent | null,
206
+ error: unknown,
207
+ startTime: Date,
208
+ conversation: ConversationIdResolution
209
+ ): void {
210
+ if (!event) {
211
+ return
212
+ }
213
+ try {
214
+ if (conversation.minted) {
215
+ event.conversationId = undefined
216
+ }
217
+ event.isError = true
218
+ event.error = captureException(error)
219
+ event.duration = Date.now() - startTime.getTime()
220
+ captureEvent(server, event)
221
+ } catch (publishError) {
222
+ log(`Warning: PostHog MCP analytics failed to publish failed tool event - ${publishError}`)
223
+ }
224
+ }
225
+
226
+ // --- tools/list -----------------------------------------------------------
227
+
228
+ type MCPServerWithCapabilities = MCPServerLike & {
229
+ _capabilities?: {
230
+ tools?: unknown
231
+ }
232
+ }
233
+
234
+ const listToolsTracingSetup = new WeakMap<MCPServerLike, boolean>()
235
+
236
+ /**
237
+ * Wraps the server's `tools/list` handler so each listing is captured and the
238
+ * SDK-managed tools (context parameter, conversation id, `get_more_tools`) are
239
+ * injected. Idempotent per server. Works for both low-level and high-level
240
+ * servers — the high-level wrapper passes its underlying `server`.
241
+ */
242
+ export function instrumentToolsListHandler(server: MCPServerLike): void {
243
+ if (!(server as MCPServerWithCapabilities)._capabilities?.tools) {
244
+ return
245
+ }
246
+ if (listToolsTracingSetup.get(server)) {
247
+ return
248
+ }
249
+
250
+ const originalListToolsHandler = server._requestHandlers.get('tools/list')
251
+ if (!originalListToolsHandler) {
252
+ return
253
+ }
254
+
255
+ try {
256
+ server.setRequestHandler(
257
+ ListToolsRequestSchema,
258
+ async (request, extra) => await handleListToolsRequest(server, originalListToolsHandler, request, extra)
259
+ )
260
+ listToolsTracingSetup.set(server, true)
261
+ } catch (error) {
262
+ log(`Warning: Failed to override list tools handler - ${error}`)
263
+ }
264
+ }
265
+
266
+ async function handleListToolsRequest(
267
+ server: MCPServerLike,
268
+ originalListToolsHandler: MCPRequestHandler,
269
+ request: MCPRequestLike,
270
+ extra?: CompatibleRequestHandlerExtra
271
+ ): Promise<{ tools: ListToolsResult['tools'] }> {
272
+ const data = getServerTrackingData(server)
273
+ const startTime = new Date()
274
+ const event: McpEvent = {
275
+ sessionId: getServerSessionId(server, extra),
276
+ parameters: buildCapturedMcpParameters(request),
277
+ eventType: MCPAnalyticsEventType.mcpToolsList,
278
+ timestamp: startTime,
279
+ }
280
+
281
+ if (data) {
282
+ await applyResolvedMetadata(event, data, request, extra)
283
+ }
284
+
285
+ const tools = await getTracedToolsList(server, originalListToolsHandler, request, extra, event)
286
+
287
+ if (!data) {
288
+ log(
289
+ 'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using tool calls.'
290
+ )
291
+ return { tools }
292
+ }
293
+
294
+ if (tools.length === 0) {
295
+ log(
296
+ 'Warning: No tools found in the original list. This is likely due to the tools not being registered before PostHog MCP analytics.instrument().'
297
+ )
298
+ event.error = captureException('No tools were sent to MCP client.')
299
+ event.isError = true
300
+ event.duration = Date.now() - startTime.getTime()
301
+ captureEvent(server, event)
302
+ return { tools }
303
+ }
304
+
305
+ event.response = { tools }
306
+ event.listedToolNames = collectListedToolNames(tools)
307
+ event.isError = false
308
+ event.duration = Date.now() - startTime.getTime()
309
+ captureEvent(server, event)
310
+ return { tools }
311
+ }
312
+
313
+ function collectListedToolNames(tools: ListToolsResult['tools'] | undefined): string[] | undefined {
314
+ if (!tools || tools.length === 0) {
315
+ return
316
+ }
317
+ const names = tools.map((tool) => tool?.name).filter((name): name is string => typeof name === 'string')
318
+ return names.length > 0 ? names : undefined
319
+ }
320
+
321
+ async function getTracedToolsList(
322
+ server: MCPServerLike,
323
+ originalListToolsHandler: MCPRequestHandler,
324
+ request: MCPRequestLike,
325
+ extra: CompatibleRequestHandlerExtra | undefined,
326
+ event: McpEvent
327
+ ): Promise<ListToolsResult['tools']> {
328
+ try {
329
+ const data = getServerTrackingData(server)
330
+ const originalResponse = (await originalListToolsHandler(request, extra)) as ListToolsResult
331
+ let tools = originalResponse.tools || []
332
+
333
+ if (data && isContextEnabled(data.options.context)) {
334
+ tools = addContextParameterToTools(tools, getContextDescription(data.options.context))
335
+ }
336
+
337
+ if (data?.options.enableConversationId) {
338
+ tools = addConversationIdToTools(tools)
339
+ }
340
+
341
+ if (data?.options.reportMissing) {
342
+ const alreadyPresent = tools.some((tool) => tool?.name === GET_MORE_TOOLS_NAME)
343
+ if (!alreadyPresent) {
344
+ tools.push(getReportMissingToolDescriptor())
345
+ }
346
+ }
347
+
348
+ if (data) {
349
+ cacheToolDescriptions(data.toolDescriptions, tools)
350
+ }
351
+
352
+ return tools
353
+ } catch (error) {
354
+ log(
355
+ `Warning: Original list tools handler failed, this suggests an error PostHog MCP analytics did not cause - ${error}`
356
+ )
357
+ event.error = captureException(error)
358
+ event.isError = true
359
+ event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0
360
+ captureEvent(server, event)
361
+ throw error
362
+ }
363
+ }
364
+
365
+ export function cacheToolDescriptions(cache: Map<string, string>, tools: ListToolsResult['tools'] | undefined): void {
366
+ if (!tools) {
367
+ return
368
+ }
369
+ for (const tool of tools) {
370
+ if (tool?.name && typeof tool.description === 'string') {
371
+ cache.set(tool.name, tool.description)
372
+ }
373
+ }
374
+ }
375
+
376
+ // --- initialize -----------------------------------------------------------
377
+
378
+ /**
379
+ * Wraps the server's `initialize` handler so the connection handshake is
380
+ * captured (and identity resolved) before the original handler runs.
381
+ */
382
+ export function instrumentInitializeHandler(server: MCPServerLike): void {
383
+ const originalInitializeHandler = server._requestHandlers.get('initialize')
384
+ if (!originalInitializeHandler) {
385
+ return
386
+ }
387
+
388
+ server.setRequestHandler(InitializeRequestSchema, async (request, extra) => {
389
+ const data = getServerTrackingData(server)
390
+ if (!data) {
391
+ log(
392
+ 'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using tool calls.'
393
+ )
394
+ return await originalInitializeHandler(request, extra)
395
+ }
396
+
397
+ const sessionId = getServerSessionId(server, extra)
398
+ await handleIdentify(server, data, sessionId, request, extra)
399
+
400
+ const event: McpEvent = {
401
+ sessionId,
402
+ resourceName: request.params?.name || 'Unknown Tool Name',
403
+ eventType: MCPAnalyticsEventType.mcpInitialize,
404
+ parameters: buildCapturedMcpParameters(request),
405
+ timestamp: new Date(),
406
+ }
407
+
408
+ await applyResolvedMetadata(event, data, request, extra)
409
+
410
+ const result = await originalInitializeHandler(request, extra)
411
+ event.response = result
412
+ captureEvent(server, event)
413
+ return result
414
+ })
415
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ CompatibleRequestHandlerExtra,
3
+ MCPAnalyticsData,
4
+ MCPAnalyticsIntentSource,
5
+ MCPRequestLike,
6
+ McpEvent,
7
+ } from '../types'
8
+ import { isContextEnabled } from './context-parameters'
9
+ import { log } from './logger'
10
+
11
+ interface ResolvedIntent {
12
+ intent: string
13
+ source: MCPAnalyticsIntentSource
14
+ }
15
+
16
+ function getContextArgument(request: MCPRequestLike): string | undefined {
17
+ const context = request.params?.arguments?.context
18
+ return typeof context === 'string' && context.trim() ? context : undefined
19
+ }
20
+
21
+ function normalizeIntent(intent: string | null | undefined): string | null {
22
+ if (typeof intent !== 'string') {
23
+ return null
24
+ }
25
+
26
+ const trimmed = intent.trim()
27
+ return trimmed ? trimmed : null
28
+ }
29
+
30
+ async function runIntentFallback(
31
+ data: MCPAnalyticsData,
32
+ request: MCPRequestLike,
33
+ extra?: CompatibleRequestHandlerExtra
34
+ ): Promise<ResolvedIntent | null> {
35
+ if (!data.options.intentFallback) {
36
+ return null
37
+ }
38
+
39
+ try {
40
+ const intent = normalizeIntent(await data.options.intentFallback(request, extra))
41
+ return intent ? { intent, source: 'inferred' } : null
42
+ } catch (error) {
43
+ log(`intentFallback callback error: ${error}`)
44
+ return null
45
+ }
46
+ }
47
+
48
+ export async function resolveToolCallIntent(
49
+ data: MCPAnalyticsData,
50
+ request: MCPRequestLike,
51
+ extra?: CompatibleRequestHandlerExtra
52
+ ): Promise<ResolvedIntent | null> {
53
+ const contextArgument = getContextArgument(request)
54
+ if (isContextEnabled(data.options.context) && request.params?.name !== 'get_more_tools' && contextArgument) {
55
+ return { intent: contextArgument, source: 'context_parameter' }
56
+ }
57
+
58
+ return await runIntentFallback(data, request, extra)
59
+ }
60
+
61
+ export function setEventIntent(event: McpEvent, resolvedIntent: ResolvedIntent | null): void {
62
+ if (!resolvedIntent) {
63
+ return
64
+ }
65
+
66
+ event.userIntent = resolvedIntent.intent
67
+ event.userIntentSource = resolvedIntent.source
68
+ }
69
+
70
+ export function setExplicitContextIntent(event: McpEvent, context: string): void {
71
+ const intent = normalizeIntent(context)
72
+ if (!intent) {
73
+ return
74
+ }
75
+
76
+ event.userIntent = intent
77
+ event.userIntentSource = 'context_parameter'
78
+ }
@@ -0,0 +1,193 @@
1
+ import type {
2
+ CompatibleRequestHandlerExtra,
3
+ MCPAnalyticsData,
4
+ MCPRequestLike,
5
+ MCPServerLike,
6
+ McpEvent,
7
+ UserIdentity,
8
+ } from '../types'
9
+ import { MCPAnalyticsEventType } from './event-types'
10
+ import { log } from './logger'
11
+ import { captureEvent } from './capture'
12
+
13
+ /**
14
+ * Bounded LRU cache for session identities, capped at `maxSize` entries so a
15
+ * long-lived server can't accumulate identities for unboundedly many sessions.
16
+ * One instance lives on each server's tracking data — it is NOT shared across
17
+ * server instances, so identities never bleed between servers.
18
+ */
19
+ export class IdentityCache {
20
+ private readonly _cache = new Map<string, UserIdentity>()
21
+ private readonly _maxSize: number
22
+
23
+ constructor(maxSize = 1000) {
24
+ this._maxSize = maxSize
25
+ }
26
+
27
+ get(sessionId: string): UserIdentity | undefined {
28
+ const identity = this._cache.get(sessionId)
29
+ if (identity === undefined) {
30
+ return
31
+ }
32
+ // Touch: re-insert so it counts as most-recently-used.
33
+ this._cache.delete(sessionId)
34
+ this._cache.set(sessionId, identity)
35
+ return identity
36
+ }
37
+
38
+ set(sessionId: string, identity: UserIdentity): void {
39
+ this._cache.delete(sessionId)
40
+
41
+ if (this._cache.size >= this._maxSize) {
42
+ const oldestKey = this._cache.keys().next().value
43
+ if (oldestKey !== undefined) {
44
+ this._cache.delete(oldestKey)
45
+ }
46
+ }
47
+
48
+ this._cache.set(sessionId, identity)
49
+ }
50
+
51
+ has(sessionId: string): boolean {
52
+ return this._cache.has(sessionId)
53
+ }
54
+
55
+ size(): number {
56
+ return this._cache.size
57
+ }
58
+ }
59
+
60
+ const _serverTracking = new WeakMap<MCPServerLike, MCPAnalyticsData>()
61
+
62
+ export function getServerTrackingData(server: MCPServerLike): MCPAnalyticsData | undefined {
63
+ return _serverTracking.get(server)
64
+ }
65
+
66
+ export function setServerTrackingData(server: MCPServerLike, data: MCPAnalyticsData): void {
67
+ _serverTracking.set(server, data)
68
+ }
69
+
70
+ export function areIdentitiesEqual(a: UserIdentity, b: UserIdentity): boolean {
71
+ if (a.distinctId !== b.distinctId) {
72
+ return false
73
+ }
74
+ if (JSON.stringify(a.groups ?? {}) !== JSON.stringify(b.groups ?? {})) {
75
+ return false
76
+ }
77
+
78
+ const aProps = a.properties || {}
79
+ const bProps = b.properties || {}
80
+
81
+ const aKeys = Object.keys(aProps)
82
+ const bKeys = Object.keys(bProps)
83
+
84
+ if (aKeys.length !== bKeys.length) {
85
+ return false
86
+ }
87
+
88
+ for (const key of aKeys) {
89
+ if (!(key in bProps)) {
90
+ return false
91
+ }
92
+ if (JSON.stringify(aProps[key]) !== JSON.stringify(bProps[key])) {
93
+ return false
94
+ }
95
+ }
96
+
97
+ return true
98
+ }
99
+
100
+ export function mergeIdentities(previous: UserIdentity | undefined, next: UserIdentity): UserIdentity {
101
+ if (!previous) {
102
+ return next
103
+ }
104
+
105
+ return {
106
+ distinctId: next.distinctId,
107
+ properties: {
108
+ ...(previous.properties || {}),
109
+ ...(next.properties || {}),
110
+ },
111
+ groups: next.groups ?? previous.groups,
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Resolves the optional `identify` callback, dedupes against the server's identity
117
+ * cache, and publishes an `$identify` event only when the identity has materially changed.
118
+ */
119
+ export async function handleIdentify(
120
+ server: MCPServerLike,
121
+ data: MCPAnalyticsData,
122
+ sessionId: string,
123
+ request: MCPRequestLike,
124
+ extra?: CompatibleRequestHandlerExtra
125
+ ): Promise<void> {
126
+ if (!data.options.identify) {
127
+ return
128
+ }
129
+
130
+ const identifyEvent: McpEvent = {
131
+ sessionId,
132
+ resourceName: getRequestResourceName(request),
133
+ eventType: MCPAnalyticsEventType.identify,
134
+ parameters: { request, extra },
135
+ timestamp: new Date(),
136
+ }
137
+
138
+ try {
139
+ const identityResult =
140
+ typeof data.options.identify === 'function' ? await data.options.identify(request, extra) : data.options.identify
141
+
142
+ if (identityResult) {
143
+ const previousIdentity = data.identifiedSessions.get(sessionId)
144
+ const mergedIdentity = mergeIdentities(previousIdentity, identityResult)
145
+ const hasChanged = !(previousIdentity && areIdentitiesEqual(previousIdentity, mergedIdentity))
146
+
147
+ data.identifiedSessions.set(sessionId, mergedIdentity)
148
+
149
+ if (hasChanged) {
150
+ log(`Identified session ${sessionId} with identity: ${JSON.stringify(mergedIdentity)}`)
151
+ captureEvent(server, identifyEvent)
152
+ }
153
+ } else {
154
+ log(`Warning: Supplied identify function returned null for session ${sessionId}`)
155
+ }
156
+ } catch (error) {
157
+ log(`Error: User supplied identify function threw an error while identifying session ${sessionId} - ${error}`)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolves the `eventProperties` callback. Returns null when no callback is
163
+ * configured, or when it returns nullish or throws — a throw never propagates
164
+ * into the tool path.
165
+ */
166
+ export async function resolveEventProperties(
167
+ data: MCPAnalyticsData,
168
+ request: MCPRequestLike,
169
+ extra?: CompatibleRequestHandlerExtra
170
+ ): Promise<Record<string, unknown> | null> {
171
+ if (!data.options.eventProperties) {
172
+ return null
173
+ }
174
+ try {
175
+ return (await data.options.eventProperties(request, extra)) ?? null
176
+ } catch (e) {
177
+ log(`eventProperties callback error: ${e}`)
178
+ return null
179
+ }
180
+ }
181
+
182
+ function getRequestResourceName(request: unknown): string {
183
+ if (!request || typeof request !== 'object' || !('params' in request)) {
184
+ return 'Unknown'
185
+ }
186
+
187
+ const params = request.params
188
+ if (!params || typeof params !== 'object' || !('name' in params)) {
189
+ return 'Unknown'
190
+ }
191
+
192
+ return typeof params.name === 'string' ? params.name : 'Unknown'
193
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * STDIO-safe logger.
3
+ *
4
+ * MCP servers running over the STDIO transport use stdout/stderr to exchange
5
+ * protocol messages, so we cannot use the default `console.log`. We accept a
6
+ * `logger` option on the public API; when omitted, log calls are silently
7
+ * dropped. Errors that affect tracking should still be observable in apps that
8
+ * want them, so the consumer can plug in any function (e.g. `console.error`
9
+ * for non-STDIO transports, a file logger, etc.).
10
+ */
11
+ export type LoggerFn = (message: string) => void
12
+
13
+ let activeLogger: LoggerFn | undefined
14
+
15
+ export function setLogger(logger: LoggerFn | undefined): void {
16
+ activeLogger = logger
17
+ }
18
+
19
+ export function log(message: string): void {
20
+ if (activeLogger) {
21
+ try {
22
+ activeLogger(message)
23
+ } catch {
24
+ // never let logging blow up the tracking pipeline
25
+ }
26
+ }
27
+ }