@posthog/mcp 0.10.6 → 0.10.8

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 (46) hide show
  1. package/dist/extensions/analytics-parameters.d.ts +7 -2
  2. package/dist/extensions/analytics-parameters.d.ts.map +1 -1
  3. package/dist/extensions/analytics-parameters.js +4 -2
  4. package/dist/extensions/analytics-parameters.mjs +4 -2
  5. package/dist/extensions/capture.d.ts +2 -2
  6. package/dist/extensions/capture.d.ts.map +1 -1
  7. package/dist/extensions/capture.js +2 -2
  8. package/dist/extensions/capture.mjs +2 -2
  9. package/dist/extensions/conversation-id.d.ts +37 -8
  10. package/dist/extensions/conversation-id.d.ts.map +1 -1
  11. package/dist/extensions/conversation-id.js +8 -13
  12. package/dist/extensions/conversation-id.mjs +8 -13
  13. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  14. package/dist/extensions/instrument-highlevel.js +1 -1
  15. package/dist/extensions/instrument-highlevel.mjs +1 -1
  16. package/dist/extensions/instrumentation.d.ts.map +1 -1
  17. package/dist/extensions/instrumentation.js +86 -42
  18. package/dist/extensions/instrumentation.mjs +88 -44
  19. package/dist/extensions/internal.d.ts +10 -2
  20. package/dist/extensions/internal.d.ts.map +1 -1
  21. package/dist/extensions/internal.js +25 -9
  22. package/dist/extensions/internal.mjs +19 -6
  23. package/dist/extensions/output-instructions.d.ts +70 -0
  24. package/dist/extensions/output-instructions.d.ts.map +1 -0
  25. package/dist/extensions/output-instructions.js +131 -0
  26. package/dist/extensions/output-instructions.mjs +82 -0
  27. package/dist/extensions/session.d.ts +26 -5
  28. package/dist/extensions/session.d.ts.map +1 -1
  29. package/dist/extensions/session.js +35 -15
  30. package/dist/extensions/session.mjs +33 -16
  31. package/dist/types.d.ts +23 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.mjs +1 -1
  36. package/package.json +3 -3
  37. package/src/extensions/analytics-parameters.ts +15 -2
  38. package/src/extensions/capture.ts +13 -6
  39. package/src/extensions/conversation-id.ts +51 -29
  40. package/src/extensions/instrument-highlevel.ts +3 -1
  41. package/src/extensions/instrumentation.ts +151 -60
  42. package/src/extensions/internal.ts +30 -7
  43. package/src/extensions/output-instructions.ts +231 -0
  44. package/src/extensions/session.ts +128 -29
  45. package/src/types.ts +23 -1
  46. package/src/version.ts +1 -1
@@ -11,7 +11,6 @@ import {
11
11
  } from './analytics-parameters'
12
12
  import { DEFAULT_CONVERSATION_ID_DESCRIPTION } from './constants'
13
13
  import { log, type LoggerFn } from './logger'
14
- import { GET_MORE_TOOLS_NAME } from './tools'
15
14
 
16
15
  export const CONVERSATION_ID_PARAM_NAME = 'conversation_id'
17
16
 
@@ -70,60 +69,83 @@ export function addConversationIdToTool<TTool extends ConversationIdInjectableTo
70
69
  return modifiedTool
71
70
  }
72
71
 
72
+ /**
73
+ * Injects `conversation_id` across a tool listing, including the virtual
74
+ * `get_more_tools` tool. Its calls publish `$mcp_missing_capability`, and a
75
+ * capability gap is only meaningful next to the work that hit it — so it belongs
76
+ * in the same session as the surrounding tool calls.
77
+ */
73
78
  export function addConversationIdToTools<TTool extends ConversationIdInjectableTool>(
74
79
  tools: TTool[],
75
- missingCapabilityToolName: string = GET_MORE_TOOLS_NAME,
76
- skipMissingCapabilityTool = true,
77
80
  logger: LoggerFn = log
78
81
  ): TTool[] {
79
- return tools.map((tool) => {
80
- if (skipMissingCapabilityTool && tool.name === missingCapabilityToolName) {
81
- return tool
82
- }
83
- return addConversationIdToTool(tool, logger)
84
- })
82
+ return tools.map((tool) => addConversationIdToTool(tool, logger))
85
83
  }
86
84
 
85
+ /**
86
+ * The shape of every id we mint: a uuidv7. Used to tell an echo of our own handle
87
+ * from a value the agent made up.
88
+ */
89
+ const MINTED_CONVERSATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
90
+
87
91
  export type ConversationIdResolution =
88
92
  | { minted: false; conversationId: string | undefined }
89
93
  | { minted: true; conversationId: string }
90
94
 
91
95
  /**
92
96
  * Decides which conversation_id to use for a tool call:
93
- * - disabled or get_more_tools → none
94
- * - agent supplied a value → use it
95
- * - agent omitted → mint a UUID
97
+ * - disabled → none
98
+ * - agent echoed a handle we could have minted → use it
99
+ * - anything else → mint a fresh one
100
+ *
101
+ * The shape check matters because this value becomes `$session_id`, and the
102
+ * derivation is deterministic so that two pods agree — which also means two
103
+ * *callers* sending the same string land in the same session. The strings agents
104
+ * invent are not random (`conv-1`, `1`, `session`), so trusting them verbatim
105
+ * would silently merge unrelated conversations, potentially across users.
106
+ *
107
+ * A compliant agent echoes the uuidv7 we minted and is unaffected. Anything else
108
+ * is treated exactly as if the handle were absent: mint, and prompt back. Shape
109
+ * rather than a registry of issued ids, because a per-request server has no
110
+ * memory of what it minted.
111
+ *
112
+ * This narrows the problem, it does not prove provenance: a caller supplying a
113
+ * well-formed uuidv7 is trusted, so two that pick the *same* one still merge. That
114
+ * residue is far smaller than the case it replaces — an agent ignoring the
115
+ * parameter description reaches for `conv-1` or `1`, not a conforming uuidv7 — and
116
+ * closing it needs a signed handle, i.e. a secret shared by every pod. Worth doing
117
+ * if this ever anchors something security-bearing; `$session_id` is an analytics
118
+ * grouping key, so it does not today.
96
119
  */
97
- export function resolveConversationId(
98
- enabled: boolean,
99
- args: unknown,
100
- toolName: string | undefined,
101
- missingCapabilityToolName: string = GET_MORE_TOOLS_NAME
102
- ): ConversationIdResolution {
103
- if (!enabled || toolName === missingCapabilityToolName) {
120
+ export function resolveConversationId(enabled: boolean, args: unknown): ConversationIdResolution {
121
+ if (!enabled) {
104
122
  return { minted: false, conversationId: undefined }
105
123
  }
106
124
  const supplied = extractConversationId(args)
107
- if (supplied) {
108
- return { minted: false, conversationId: supplied }
125
+ if (supplied && MINTED_CONVERSATION_ID.test(supplied)) {
126
+ // Lowercased because the shape test is case-insensitive but the hash behind
127
+ // `$session_id` is not. Some hosts normalise uuids to uppercase, and an
128
+ // uppercased echo of our own handle would otherwise clear the gate and then
129
+ // land in a different session than the call that minted it.
130
+ return { minted: false, conversationId: supplied.toLowerCase() }
109
131
  }
110
132
  return { minted: true, conversationId: uuidv7() }
111
133
  }
112
134
 
113
135
  /**
114
- * Predicate matching the same eligibility checks injectConversationIdPromptBack uses,
115
- * exposed so callers can pre-decide whether the prompt-back will actually land
116
- * (and clear event.conversationId if not, to avoid orphan ids in analytics).
136
+ * Whether the prompt-back can ride this result's `content`. The only requirement
137
+ * is an array to append to.
138
+ *
139
+ * Errored results included on purpose. A tool that fails on the first call of a
140
+ * conversation is exactly when the agent needs the session handle: without it the
141
+ * retry starts a fresh conversation, so the failure and its fix land in different
142
+ * sessions.
117
143
  */
118
144
  export function canInjectConversationIdPromptBack(result: unknown): boolean {
119
145
  if (!(result && typeof result === 'object')) {
120
146
  return false
121
147
  }
122
- const resultObj = result as { content?: unknown; isError?: unknown }
123
- if (resultObj.isError === true) {
124
- return false
125
- }
126
- return Array.isArray(resultObj.content)
148
+ return Array.isArray((result as { content?: unknown }).content)
127
149
  }
128
150
 
129
151
  export function extractConversationId(args: unknown): string | undefined {
@@ -262,7 +262,9 @@ async function handleToolCallRequest(
262
262
  request,
263
263
  extra,
264
264
  execute: (downstreamRequest) => originalCallToolHandler(downstreamRequest as MCPRequest, extra),
265
- parameterOwnership: registeredTool ? getAnalyticsParameterOwnership(registeredTool.inputSchema) : undefined,
265
+ parameterOwnership: registeredTool
266
+ ? getAnalyticsParameterOwnership(registeredTool.inputSchema, registeredTool.outputSchema)
267
+ : undefined,
266
268
  takeCapturedError: () => {
267
269
  const captured = extra?.__mcp_analytics_error
268
270
  if (extra) {
@@ -12,6 +12,7 @@ import type {
12
12
  MCPServerLike,
13
13
  McpEvent,
14
14
  ServerClientInfoLike,
15
+ SessionInfo,
15
16
  } from '../types'
16
17
  import { getAnalyticsParameterOwnership, stripOwnedAnalyticsArguments } from './analytics-parameters'
17
18
  import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
@@ -23,15 +24,16 @@ import {
23
24
  resolveConversationId,
24
25
  } from './conversation-id'
25
26
  import { stampMetaClientInfo } from './client-identity'
27
+ import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
26
28
  import { captureEvent } from './capture'
27
29
  import { MCPAnalyticsEventType } from './event-types'
28
30
  import { captureException } from './exceptions'
29
31
  import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from './intent'
30
- import { getServerTrackingData, handleIdentify, setServerTrackingData } from './internal'
32
+ import { getServerTrackingData, handleIdentify, setServerTrackingData, withIdentity } from './internal'
31
33
  import type { LoggerFn } from './logger'
32
34
  import { buildCapturedMcpParameters } from './mcp-payloads'
33
35
  import { getLiteralValue, getObjectShape } from './mcp-sdk-compat'
34
- import { getSessionId, newSessionId } from './session'
36
+ import { getSessionId, getSessionInfo, newSessionId } from './session'
35
37
  import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
36
38
  import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
37
39
  import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
@@ -105,20 +107,13 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
105
107
  parameterOwnership,
106
108
  resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
107
109
  )
108
- const conversation = resolveConversationId(
109
- ownership.conversationId,
110
- request.params?.arguments,
111
- request.params?.name,
112
- resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
113
- ? resolveMissingCapabilityToolName(data.options)
114
- : ''
115
- )
110
+ const conversation = resolveConversationId(ownership.conversationId, request.params?.arguments)
116
111
  const downstreamRequest = cloneRequestWithoutOwnedAnalyticsArguments(request, ownership)
117
112
 
118
113
  // Prepare the event in isolation: if identity/metadata/intent resolution
119
114
  // throws, we drop instrumentation for this call but still run the tool.
120
115
  const startTime = new Date()
121
- const event = await prepareToolCallEvent(
116
+ const preparedEvent = await prepareToolCallEvent(
122
117
  server,
123
118
  data,
124
119
  request,
@@ -129,34 +124,56 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
129
124
  ownership,
130
125
  resolvedEventType
131
126
  )
132
- if (event && explicitContextIntent) {
133
- setExplicitContextIntent(event, explicitContextIntent)
127
+ if (preparedEvent && explicitContextIntent) {
128
+ setExplicitContextIntent(preparedEvent.event, explicitContextIntent)
134
129
  }
135
130
 
136
131
  let result: unknown
137
132
  try {
138
133
  result = await execute(downstreamRequest)
139
134
  } catch (error) {
140
- publishFailedToolEvent(server, event, error, startTime, conversation, data.logger)
135
+ publishFailedToolEvent(server, preparedEvent, error, startTime, conversation, data.logger)
141
136
  throw error
142
137
  }
143
138
 
144
- const finalResult = applyConversationPromptBack(event, result, conversation)
145
- publishSuccessfulToolEvent(server, event, finalResult, startTime, data.logger, takeCapturedError)
139
+ const finalResult = applyConversationInstructions(preparedEvent?.event ?? null, result, conversation, ownership)
140
+ publishSuccessfulToolEvent(server, preparedEvent, finalResult, startTime, data.logger, takeCapturedError)
146
141
  return finalResult
147
142
  }
148
143
 
144
+ interface PreparedToolEvent {
145
+ event: McpEvent
146
+ requestAttribution: SessionInfo
147
+ }
148
+
149
149
  function getActiveAnalyticsParameterOwnership(
150
150
  data: MCPAnalyticsData,
151
151
  toolName: string | undefined,
152
152
  override: AnalyticsParameterOwnership | undefined,
153
153
  isMissingCapabilityTool: boolean
154
154
  ): AnalyticsParameterOwnership {
155
- const ownership = override ?? (toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined)
155
+ const listed = toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined
156
+ const ownership = override ?? listed
156
157
  return {
157
158
  context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
158
- conversationId:
159
- !isMissingCapabilityTool && data.options.enableConversationId === true && ownership?.conversationId === true,
159
+ conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
160
+ // Deliberately read off `listed`, never the override. This asks whether
161
+ // `tools/list` actually declared `_mcp_instructions`, and only the advertised
162
+ // JSON Schema can answer it — an override is built from the live registry,
163
+ // which on the high-level path holds Zod.
164
+ //
165
+ // The cache is per-instance, so this is not merely "before the first
166
+ // `tools/list`": an instance that never serves a listing never writes the
167
+ // mirror at all. That is the per-request server pattern — `tools/list` lands
168
+ // on one instance, `tools/call` on a cold one — where the handle falls back
169
+ // to the `content` block and a structuredContent-only client misses it.
170
+ //
171
+ // Failing closed is deliberate. Writing a key the advertised schema did not
172
+ // declare fails the *entire* tool result under `additionalProperties: false`,
173
+ // so guessing costs the caller their result, while not guessing costs us one
174
+ // delivery channel. The fix is a process-scoped ownership cache, so a listing
175
+ // served by any instance answers for the rest — not a per-call guess.
176
+ outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
160
177
  }
161
178
  }
162
179
 
@@ -188,10 +205,22 @@ async function prepareToolCallEvent(
188
205
  conversation: ConversationIdResolution,
189
206
  ownership: AnalyticsParameterOwnership,
190
207
  eventType: MCPAnalyticsEventType
191
- ): Promise<McpEvent | null> {
208
+ ): Promise<PreparedToolEvent | null> {
192
209
  try {
193
- const sessionId = getSessionId(server, extra)
194
- await handleIdentify(server, data, sessionId, request, extra)
210
+ const sessionId = getSessionId(server, extra, conversation.conversationId)
211
+ // Snapshot token/client/protocol metadata synchronously, before identify or
212
+ // metadata callbacks can yield and let another request replace shared state.
213
+ const sessionInfo = getSessionInfo(server, data, sessionId)
214
+ const identity = await handleIdentify(
215
+ server,
216
+ data,
217
+ sessionId,
218
+ request,
219
+ sessionInfo,
220
+ extra,
221
+ !!conversation.conversationId
222
+ )
223
+ const requestAttribution = withIdentity(sessionInfo, identity)
195
224
 
196
225
  const toolName = request.params?.name
197
226
  const event: McpEvent = {
@@ -211,7 +240,7 @@ async function prepareToolCallEvent(
211
240
 
212
241
  await applyResolvedMetadata(event, data, request, extra)
213
242
  setEventIntent(event, await resolveToolCallIntent(data, request, ownership.context, extra))
214
- return event
243
+ return { event, requestAttribution }
215
244
  } catch (error) {
216
245
  data.logger(
217
246
  `Warning: PostHog MCP analytics instrumentation failed for tool ${request.params?.name}, the tool will still run - ${error}`
@@ -221,38 +250,65 @@ async function prepareToolCallEvent(
221
250
  }
222
251
 
223
252
  /**
224
- * When we minted a conversation id, append the prompt-back so the agent echoes
225
- * it on subsequent calls. If the result can't carry it, clear the id off the
226
- * event so analytics doesn't show an orphan the agent never received.
253
+ * Delivers the conversation session handle back to the agent, over both channels a tool
254
+ * result has:
255
+ *
256
+ * - `structuredContent`, on *every* response for a tool whose output schema we
257
+ * declared the key on. Clients that read structured results never see the text
258
+ * block, and re-sending it each time lets an agent that dropped the session handle read
259
+ * it back.
260
+ * - `content`, as a text block, only on the response that minted the session handle.
261
+ * Repeating it every call would put a `[SERVER]:` line in front of the user on
262
+ * every single tool result.
263
+ *
264
+ * If neither channel could carry a session handle we minted, the agent never received it,
265
+ * so clear it off the event rather than showing analytics an id nobody has.
227
266
  */
228
- function applyConversationPromptBack(
267
+ function applyConversationInstructions(
229
268
  event: McpEvent | null,
230
269
  result: unknown,
231
- conversation: ConversationIdResolution
270
+ conversation: ConversationIdResolution,
271
+ ownership: AnalyticsParameterOwnership
232
272
  ): unknown {
233
- if (!conversation.minted) {
273
+ const conversationId = conversation.conversationId
274
+ if (!conversationId) {
234
275
  return result
235
276
  }
236
- if (canInjectConversationIdPromptBack(result)) {
237
- return injectConversationIdPromptBack(result, conversation.conversationId)
277
+
278
+ let updated = result
279
+ let delivered = false
280
+
281
+ if (ownership.outputInstructions) {
282
+ const mirrored = mirrorInstructionsIntoStructuredContent(updated, conversationId)
283
+ delivered = mirrored !== updated
284
+ updated = mirrored
285
+ }
286
+
287
+ if (conversation.minted && canInjectConversationIdPromptBack(updated)) {
288
+ updated = injectConversationIdPromptBack(updated, conversationId)
289
+ delivered = true
238
290
  }
239
- if (event) {
291
+
292
+ // Only a minted session handle can be lost this way — one the agent supplied, it has.
293
+ if (!delivered && conversation.minted && event) {
240
294
  event.conversationId = undefined
241
295
  }
242
- return result
296
+
297
+ return updated
243
298
  }
244
299
 
245
300
  function publishSuccessfulToolEvent(
246
301
  server: MCPServerLike,
247
- event: McpEvent | null,
302
+ preparedEvent: PreparedToolEvent | null,
248
303
  result: unknown,
249
304
  startTime: Date,
250
305
  logger: LoggerFn,
251
306
  takeCapturedError?: () => unknown
252
307
  ): void {
253
- if (!event) {
308
+ if (!preparedEvent) {
254
309
  return
255
310
  }
311
+ const { event, requestAttribution } = preparedEvent
256
312
  try {
257
313
  if (isToolResultError(result)) {
258
314
  event.isError = true
@@ -263,7 +319,7 @@ function publishSuccessfulToolEvent(
263
319
  }
264
320
  event.response = result
265
321
  event.duration = Date.now() - startTime.getTime()
266
- captureEvent(server, event, logger)
322
+ captureEvent(server, event, logger, requestAttribution)
267
323
  } catch (error) {
268
324
  logger(`Warning: PostHog MCP analytics failed to publish tool event - ${error}`)
269
325
  }
@@ -271,15 +327,16 @@ function publishSuccessfulToolEvent(
271
327
 
272
328
  function publishFailedToolEvent(
273
329
  server: MCPServerLike,
274
- event: McpEvent | null,
330
+ preparedEvent: PreparedToolEvent | null,
275
331
  error: unknown,
276
332
  startTime: Date,
277
333
  conversation: ConversationIdResolution,
278
334
  logger: LoggerFn
279
335
  ): void {
280
- if (!event) {
336
+ if (!preparedEvent) {
281
337
  return
282
338
  }
339
+ const { event, requestAttribution } = preparedEvent
283
340
  try {
284
341
  if (conversation.minted) {
285
342
  event.conversationId = undefined
@@ -287,7 +344,7 @@ function publishFailedToolEvent(
287
344
  event.isError = true
288
345
  event.error = captureException(error)
289
346
  event.duration = Date.now() - startTime.getTime()
290
- captureEvent(server, event, logger)
347
+ captureEvent(server, event, logger, requestAttribution)
291
348
  } catch (publishError) {
292
349
  logger(`Warning: PostHog MCP analytics failed to publish failed tool event - ${publishError}`)
293
350
  }
@@ -407,8 +464,12 @@ export async function handleListToolsRequest(
407
464
  ): Promise<{ tools: ListToolsResult['tools'] }> {
408
465
  const data = getServerTrackingData(server)
409
466
  const startTime = new Date()
467
+ const sessionId = getSessionId(server, extra)
468
+ // Snapshot before metadata resolution or the list handler can yield to a
469
+ // concurrent request using the same instrumented server.
470
+ const requestAttribution = getSessionInfo(server, data, sessionId)
410
471
  const event: McpEvent = {
411
- sessionId: getSessionId(server, extra),
472
+ sessionId,
412
473
  parameters: buildCapturedMcpParameters(request),
413
474
  eventType: MCPAnalyticsEventType.mcpToolsList,
414
475
  timestamp: startTime,
@@ -419,7 +480,15 @@ export async function handleListToolsRequest(
419
480
  await applyResolvedMetadata(event, data, request, extra)
420
481
  }
421
482
 
422
- const tools = await getTracedToolsList(server, originalListToolsHandler, request, extra, event, logger)
483
+ const tools = await getTracedToolsList(
484
+ server,
485
+ originalListToolsHandler,
486
+ request,
487
+ extra,
488
+ event,
489
+ logger,
490
+ requestAttribution
491
+ )
423
492
 
424
493
  if (!data) {
425
494
  logger(
@@ -435,7 +504,7 @@ export async function handleListToolsRequest(
435
504
  event.error = captureException('No tools were sent to MCP client.')
436
505
  event.isError = true
437
506
  event.duration = Date.now() - startTime.getTime()
438
- captureEvent(server, event, data.logger)
507
+ captureEvent(server, event, data.logger, requestAttribution)
439
508
  return { tools }
440
509
  }
441
510
 
@@ -443,7 +512,7 @@ export async function handleListToolsRequest(
443
512
  event.listedToolNames = collectListedToolNames(tools)
444
513
  event.isError = false
445
514
  event.duration = Date.now() - startTime.getTime()
446
- captureEvent(server, event, data.logger)
515
+ captureEvent(server, event, data.logger, requestAttribution)
447
516
  return { tools }
448
517
  }
449
518
 
@@ -454,7 +523,7 @@ function cacheToolAnalyticsParameterOwnership(
454
523
  // Merge pages and concurrent enumerations; repeated tool names overwrite stale schemas.
455
524
  for (const tool of tools) {
456
525
  if (tool?.name) {
457
- cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema))
526
+ cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema, tool.outputSchema))
458
527
  }
459
528
  }
460
529
  }
@@ -473,7 +542,8 @@ async function getTracedToolsList(
473
542
  request: MCPRequestLike,
474
543
  extra: CompatibleRequestHandlerExtra | undefined,
475
544
  event: McpEvent,
476
- logger: LoggerFn
545
+ logger: LoggerFn,
546
+ requestAttribution: SessionInfo
477
547
  ): Promise<ListToolsResult['tools']> {
478
548
  try {
479
549
  const data = getServerTrackingData(server)
@@ -490,7 +560,6 @@ async function getTracedToolsList(
490
560
 
491
561
  if (data) {
492
562
  const missingToolName = resolveMissingCapabilityToolName(data.options)
493
- let injectedMissingCapabilityTool = false
494
563
  if (data.options.reportMissing) {
495
564
  const alreadyPresent = tools.some((tool) => tool?.name === missingToolName)
496
565
  if (alreadyPresent) {
@@ -498,13 +567,17 @@ async function getTracedToolsList(
498
567
  `Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted.`
499
568
  )
500
569
  } else {
501
- tools.push(getReportMissingToolDescriptor(missingToolName))
502
- injectedMissingCapabilityTool = true
570
+ const virtualTool = getReportMissingToolDescriptor(missingToolName)
571
+ tools.push(virtualTool)
572
+ // Cached separately because the virtual tool is added after the listing
573
+ // was cached, and its calls need ownership like any other tool's.
574
+ cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool])
503
575
  }
504
576
  }
505
577
 
506
578
  if (data.options.enableConversationId) {
507
- tools = addConversationIdToTools(tools, missingToolName, injectedMissingCapabilityTool, data.logger)
579
+ tools = addConversationIdToTools(tools, data.logger)
580
+ tools = addInstructionsToOutputSchemas(tools, data.logger)
508
581
  }
509
582
  }
510
583
 
@@ -521,7 +594,7 @@ async function getTracedToolsList(
521
594
  event.error = captureException(error)
522
595
  event.isError = true
523
596
  event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0
524
- captureEvent(server, event, logger)
597
+ captureEvent(server, event, logger, requestAttribution)
525
598
  throw error
526
599
  }
527
600
  }
@@ -630,9 +703,10 @@ function mintStatelessSessionOnInitialize(
630
703
  */
631
704
  function upgradeMintedTokenToNegotiated(
632
705
  server: MCPServerLike,
633
- data: MCPAnalyticsData,
634
706
  mintedSessionId: string,
635
- negotiatedProtocolVersion: string | undefined
707
+ sessionInfo: SessionInfo,
708
+ negotiatedProtocolVersion: string | undefined,
709
+ logger: LoggerFn
636
710
  ): void {
637
711
  try {
638
712
  const transport = server.transport
@@ -641,13 +715,13 @@ function upgradeMintedTokenToNegotiated(
641
715
  }
642
716
  const token = encodeSessionId({
643
717
  sessionId: mintedSessionId,
644
- clientName: data.sessionInfo.clientName,
645
- clientVersion: data.sessionInfo.clientVersion,
718
+ clientName: sessionInfo.clientName,
719
+ clientVersion: sessionInfo.clientVersion,
646
720
  protocolVersion: negotiatedProtocolVersion,
647
721
  })
648
722
  writeSessionIdToTransport(transport, token)
649
723
  } catch (error) {
650
- data.logger(`Warning: PostHog MCP analytics failed to upgrade the stateless session token - ${error}`)
724
+ logger(`Warning: PostHog MCP analytics failed to upgrade the stateless session token - ${error}`)
651
725
  }
652
726
  }
653
727
 
@@ -689,7 +763,17 @@ export async function handleInitializeRequest(
689
763
  // Mint first so the `$mcp_initialize` event below already carries the minted id.
690
764
  const mintedSessionId = mintStatelessSessionOnInitialize(server, data, request, extra)
691
765
  const sessionId = getSessionId(server, extra)
692
- await handleIdentify(server, data, sessionId, request, extra)
766
+ // Snapshot before identify, metadata, or the initialize handler can yield to
767
+ // another request using the same instrumented server.
768
+ const sessionInfo = getSessionInfo(server, data, sessionId)
769
+ const initializeClientInfo = readInitializeClientInfo(request)
770
+ const requestSessionInfo: SessionInfo = {
771
+ ...sessionInfo,
772
+ clientName: initializeClientInfo?.name ?? sessionInfo.clientName,
773
+ clientVersion: initializeClientInfo?.version ?? sessionInfo.clientVersion,
774
+ }
775
+ const identity = await handleIdentify(server, data, sessionId, request, requestSessionInfo, extra)
776
+ const requestAttribution = withIdentity(requestSessionInfo, identity)
693
777
 
694
778
  const event: McpEvent = {
695
779
  sessionId,
@@ -712,12 +796,19 @@ export async function handleInitializeRequest(
712
796
  // re-mint the token so pods replaying it report the negotiated version too.
713
797
  const negotiatedProtocolVersion = readProtocolVersion(result, request)
714
798
  event.protocolVersion = negotiatedProtocolVersion
715
- data.sessionInfo.protocolVersion = negotiatedProtocolVersion
716
- setServerTrackingData(server, data)
799
+ // Do not let a delayed initialize overwrite whichever session became current
800
+ // while its callbacks or the original handler were awaiting.
801
+ if (data.sessionId === sessionId) {
802
+ data.sessionInfo = { ...data.sessionInfo, protocolVersion: negotiatedProtocolVersion }
803
+ setServerTrackingData(server, data)
804
+ }
717
805
  if (mintedSessionId) {
718
- upgradeMintedTokenToNegotiated(server, data, mintedSessionId, negotiatedProtocolVersion)
806
+ upgradeMintedTokenToNegotiated(server, mintedSessionId, requestSessionInfo, negotiatedProtocolVersion, data.logger)
719
807
  }
720
- captureEvent(server, event, data.logger)
808
+ captureEvent(server, event, data.logger, {
809
+ ...requestAttribution,
810
+ protocolVersion: negotiatedProtocolVersion,
811
+ })
721
812
  return result
722
813
  }
723
814
 
@@ -9,6 +9,7 @@ import type {
9
9
  MCPRequestLike,
10
10
  MCPServerLike,
11
11
  McpEvent,
12
+ SessionInfo,
12
13
  UserIdentity,
13
14
  } from '../types'
14
15
  import { MCPAnalyticsEventType } from './event-types'
@@ -128,10 +129,20 @@ export async function handleIdentify(
128
129
  data: MCPAnalyticsData,
129
130
  sessionId: string,
130
131
  request: MCPRequestLike,
131
- extra?: CompatibleRequestHandlerExtra
132
- ): Promise<void> {
132
+ requestAttribution: SessionInfo,
133
+ extra?: CompatibleRequestHandlerExtra,
134
+ /**
135
+ * True when this session id came from an agent-carried `conversation_id`
136
+ * rather than the transport. Such a session is brand new and was never
137
+ * announced at `initialize`, whatever `data.sessionSource` still says about
138
+ * the connection.
139
+ */
140
+ sessionFromConversation = false
141
+ ): Promise<UserIdentity | undefined> {
142
+ const identityBeforeRequest = data.identifiedSessions.get(sessionId)
143
+ const sessionSourceBeforeIdentify = data.sessionSource
133
144
  if (!data.options.identify) {
134
- return
145
+ return identityBeforeRequest
135
146
  }
136
147
 
137
148
  const identifyEvent: McpEvent = {
@@ -162,23 +173,35 @@ export async function handleIdentify(
162
173
  // announced); revisit with the stateless-by-default rework.
163
174
  const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
164
175
  const firstSeen = previousIdentity === undefined
165
- const announcedAtInitialize = data.sessionSource === 'token' && request.method !== 'initialize'
176
+ const announcedAtInitialize =
177
+ !sessionFromConversation && sessionSourceBeforeIdentify === 'token' && request.method !== 'initialize'
166
178
  const shouldPublish = changed || (firstSeen && !announcedAtInitialize)
167
179
 
168
180
  data.identifiedSessions.set(sessionId, mergedIdentity)
169
181
 
170
182
  if (shouldPublish) {
171
183
  data.logger(`Identified session ${sessionId}`)
172
- captureEvent(server, identifyEvent, data.logger)
184
+ captureEvent(server, identifyEvent, data.logger, withIdentity(requestAttribution, mergedIdentity))
173
185
  }
174
- } else {
175
- data.logger(`Warning: Supplied identify function returned null for session ${sessionId}`)
186
+ return mergedIdentity
176
187
  }
188
+
189
+ data.logger(`Warning: Supplied identify function returned null for session ${sessionId}`)
177
190
  } catch (error) {
178
191
  data.logger(
179
192
  `Error: User supplied identify function threw an error while identifying session ${sessionId} - ${error}`
180
193
  )
181
194
  }
195
+ return identityBeforeRequest
196
+ }
197
+
198
+ export function withIdentity(sessionInfo: SessionInfo, identity: UserIdentity | undefined): SessionInfo {
199
+ return {
200
+ ...sessionInfo,
201
+ identifyActorGivenId: identity?.distinctId,
202
+ identifyActorData: identity?.properties || {},
203
+ identifyActorGroups: identity?.groups,
204
+ }
182
205
  }
183
206
 
184
207
  /**