@posthog/mcp 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { PostHog, type PostHogOptions } from 'posthog-node'
2
2
 
3
3
  import type {
4
4
  FeedbackCaptureData,
5
+ AnalyticsParameterOwnership,
5
6
  CollectFeedbackOptions,
6
7
  CollectFeedbackConfig,
7
8
  InitializeCaptureData,
@@ -11,6 +12,7 @@ import type {
11
12
  McpEvent,
12
13
  MissingCapabilityCaptureData,
13
14
  PreparedToolCall,
15
+ PreparedToolResult,
14
16
  PrepareToolCallOptions,
15
17
  PrepareToolListOptions,
16
18
  ToolCallCaptureData,
@@ -24,7 +26,13 @@ import {
24
26
  resolveCollectFeedbackOptions,
25
27
  SEND_FEEDBACK_TOOL_NAME,
26
28
  } from './feedback'
27
- import { analyticsOwnsParameter, stripOwnedAnalyticsArguments } from './analytics-parameters'
29
+ import { getAnalyticsParameterOwnership, stripOwnedAnalyticsArguments } from './analytics-parameters'
30
+ import {
31
+ addConversationIdToTools,
32
+ canInjectConversationIdPromptBack,
33
+ injectConversationIdPromptBack,
34
+ resolveConversationId,
35
+ } from './conversation-id'
28
36
  import {
29
37
  addContextParameterToTools,
30
38
  getContextDescription,
@@ -44,6 +52,8 @@ import {
44
52
  setEventModel,
45
53
  } from './model-parameters'
46
54
  import { McpEventSink } from './sink'
55
+ import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
56
+ import { deriveSessionIdFromConversation } from './session'
47
57
  import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
48
58
 
49
59
  /**
@@ -74,6 +84,23 @@ export interface PostHogMCPOptions extends PostHogOptions {
74
84
  * `llm_model` argument as fallback. On by default; `false` disables it.
75
85
  */
76
86
  captureModel?: MCPAnalyticsOptions['captureModel']
87
+ /**
88
+ * Correlate calls with an agent-carried `conversation_id` and a derived
89
+ * PostHog session id. On by default; `false` leaves schemas, arguments, tool
90
+ * results, and capture data unchanged.
91
+ */
92
+ enableConversationId?: boolean
93
+ }
94
+
95
+ interface PreparedConversationState {
96
+ minted: boolean
97
+ outputInstructions: boolean
98
+ }
99
+
100
+ const PREPARED_CONVERSATION_STATE_KEY = '__posthogMcpConversationState'
101
+
102
+ type PreparedToolCallWithConversationState = PreparedToolCall & {
103
+ [PREPARED_CONVERSATION_STATE_KEY]?: PreparedConversationState
77
104
  }
78
105
 
79
106
  /**
@@ -123,7 +150,8 @@ export class PostHogMCP extends PostHog {
123
150
  // real tool by that name, and flagging it would shadow the real handler.
124
151
  readonly #feedbackOptions: CollectFeedbackOptions | undefined
125
152
  readonly #captureModel: MCPAnalyticsOptions['captureModel']
126
- readonly #modelParameterOwnership = new Map<string, boolean>()
153
+ readonly #enableConversationId: boolean
154
+ readonly #analyticsParameterOwnership = new Map<string, AnalyticsParameterOwnership>()
127
155
 
128
156
  constructor(apiKey: string, options: PostHogMCPOptions = {}) {
129
157
  super(apiKey, options)
@@ -138,6 +166,7 @@ export class PostHogMCP extends PostHog {
138
166
  )
139
167
  }
140
168
  this.#captureModel = options.captureModel
169
+ this.#enableConversationId = options.enableConversationId ?? true
141
170
  applyMcpLibIdentity(this)
142
171
  }
143
172
 
@@ -199,9 +228,10 @@ export class PostHogMCP extends PostHog {
199
228
  * Decorate your `tools/list` response with PostHog's analytics affordances:
200
229
  * injects the `context` argument into every tool (so agents state their intent,
201
230
  * captured as `$mcp_intent`), injects `llm_model` when the constructor's
202
- * `captureModel` option is enabled, appends `get_more_tools` when
203
- * `reportMissing` is on, and appends `send_feedback` when `collectFeedback` is
204
- * on. Returns a new array; your tools are untouched.
231
+ * `captureModel` option is enabled, and injects the optional `conversation_id`
232
+ * handle by default. It appends `get_more_tools` when `reportMissing` is on and
233
+ * `send_feedback` when `collectFeedback` is on. Returns a new array; your tools
234
+ * are untouched.
205
235
  *
206
236
  * The appended `get_more_tools` descriptor carries only the base MCP tool fields
207
237
  * (name, description, input schema) — not any framework-specific fields your
@@ -209,8 +239,9 @@ export class PostHogMCP extends PostHog {
209
239
  * {@link prepareToolCall}'s `isMissingCapability`, not dispatched through a handler.
210
240
  *
211
241
  * Call this when there is no `Server` to wrap — it does for a custom dispatcher
212
- * what `instrument()` does for a `Server`. Pair it with {@link prepareToolCall}
213
- * on the inbound side.
242
+ * what `instrument()` does for a `Server`. Pair it with
243
+ * {@link prepareToolCall} on the inbound side and {@link prepareToolResult} on
244
+ * the outbound side.
214
245
  *
215
246
  * @example
216
247
  * ```ts
@@ -223,9 +254,12 @@ export class PostHogMCP extends PostHog {
223
254
  let prepared = isContextEnabled(contextOption)
224
255
  ? addContextParameterToTools(tools, getContextDescription(contextOption))
225
256
  : [...tools]
257
+ const ownershipSources: TTool[] = [...tools]
226
258
 
227
259
  if (options.reportMissing && !prepared.some((tool) => tool?.name === this.#missingCapabilityToolName)) {
228
- prepared = [...prepared, getReportMissingToolDescriptor(this.#missingCapabilityToolName) as TTool]
260
+ const virtualTool = getReportMissingToolDescriptor(this.#missingCapabilityToolName) as TTool
261
+ prepared = [...prepared, virtualTool]
262
+ ownershipSources.push(virtualTool)
229
263
  }
230
264
 
231
265
  if (
@@ -233,29 +267,38 @@ export class PostHogMCP extends PostHog {
233
267
  this.#feedbackOptions !== undefined &&
234
268
  !prepared.some((tool) => tool?.name === this.#feedbackToolName)
235
269
  ) {
236
- prepared = [...prepared, getFeedbackToolDescriptor(this.#feedbackOptions) as TTool]
270
+ const virtualTool = getFeedbackToolDescriptor(this.#feedbackOptions) as TTool
271
+ prepared = [...prepared, virtualTool]
272
+ ownershipSources.push(virtualTool)
273
+ }
274
+
275
+ this.#analyticsParameterOwnership.clear()
276
+ const ownershipByName = collectAnalyticsParameterOwnership(ownershipSources)
277
+ for (const [toolName, ownership] of ownershipByName) {
278
+ this.#analyticsParameterOwnership.set(toolName, ownership)
237
279
  }
238
280
 
239
281
  if (isCaptureModelEnabled(this.#captureModel)) {
240
- this.#modelParameterOwnership.clear()
241
- const ownershipByName = new Map<string, boolean>()
242
- for (const tool of prepared) {
243
- if (typeof tool.name !== 'string') {
244
- continue
245
- }
246
- const ownsModel = analyticsOwnsParameter(tool.inputSchema, 'llm_model')
247
- ownershipByName.set(tool.name, (ownershipByName.get(tool.name) ?? true) && ownsModel)
248
- }
249
- for (const [toolName, ownsModel] of ownershipByName) {
250
- this.#modelParameterOwnership.set(toolName, ownsModel)
251
- }
252
282
  const modelDescription = getModelDescription(this.#captureModel)
253
283
  prepared = prepared.map((tool) =>
254
- typeof tool.name === 'string' && ownershipByName.get(tool.name) === false
284
+ typeof tool.name === 'string' && ownershipByName.get(tool.name)?.llmModel === false
255
285
  ? tool
256
286
  : addModelParameterToTool(tool, modelDescription)
257
287
  )
258
288
  }
289
+
290
+ if (this.#enableConversationId) {
291
+ prepared = prepared.map((tool) =>
292
+ typeof tool.name === 'string' && ownershipByName.get(tool.name)?.conversationId === false
293
+ ? tool
294
+ : addConversationIdToTools([tool])[0]
295
+ )
296
+ prepared = prepared.map((tool) =>
297
+ typeof tool.name === 'string' && ownershipByName.get(tool.name)?.outputInstructions === false
298
+ ? tool
299
+ : addInstructionsToOutputSchemas([tool])[0]
300
+ )
301
+ }
259
302
  return prepared
260
303
  }
261
304
 
@@ -264,8 +307,10 @@ export class PostHogMCP extends PostHog {
264
307
  * from SDK-owned arguments, strips those arguments before validation, and flags
265
308
  * whether the call targeted the `get_more_tools` virtual tool.
266
309
  *
267
- * Pass the returned intent and model fields to {@link captureToolCall}, and
268
- * dispatch the returned `args` to your tool.
310
+ * Dispatch the returned `args` to your tool. Then pass the tool result and
311
+ * this prepared call to {@link prepareToolResult}. Use its result as the MCP
312
+ * response and its session and conversation values with
313
+ * {@link captureToolCall}.
269
314
  *
270
315
  * On stateless or multi-replica servers, pass the original tool descriptor
271
316
  * so ownership does not depend on which process served `tools/list`.
@@ -279,17 +324,30 @@ export class PostHogMCP extends PostHog {
279
324
  * @example
280
325
  * ```ts
281
326
  * const originalTool = myTools.find((tool) => tool.name === name)
282
- * const { intent, intentSource, llmModel, llmModelSource, args, isMissingCapability } =
283
- * posthog.prepareToolCall(name, rawArgs, {
327
+ * const preparedCall = posthog.prepareToolCall(name, rawArgs, {
284
328
  * originalTool,
285
329
  * requestMeta: request.params?._meta,
286
330
  * })
287
- * if (isMissingCapability) {
288
- * posthog.captureMissingCapability({ context: intent, llmModel, llmModelSource, ...identity })
289
- * return getMoreToolsResult()
331
+ * if (preparedCall.isMissingCapability) {
332
+ * const preparedResult = posthog.prepareToolResult(getMoreToolsResult(), preparedCall)
333
+ * posthog.captureMissingCapability({
334
+ * context: preparedCall.intent,
335
+ * sessionId: preparedResult.sessionId,
336
+ * conversationId: preparedResult.conversationId,
337
+ * ...identity,
338
+ * })
339
+ * return preparedResult.result
290
340
  * }
291
- * const result = await runTool(name, args)
292
- * posthog.captureToolCall({ toolName: name, intent, intentSource, ...identity })
341
+ * const toolResult = await runTool(name, preparedCall.args)
342
+ * const preparedResult = posthog.prepareToolResult(toolResult, preparedCall)
343
+ * posthog.captureToolCall({
344
+ * toolName: name,
345
+ * intent: preparedCall.intent,
346
+ * sessionId: preparedResult.sessionId,
347
+ * conversationId: preparedResult.conversationId,
348
+ * ...identity,
349
+ * })
350
+ * return preparedResult.result
293
351
  * ```
294
352
  */
295
353
  prepareToolCall(
@@ -299,15 +357,22 @@ export class PostHogMCP extends PostHog {
299
357
  ): PreparedToolCall {
300
358
  const rawContext = args?.context
301
359
  const intent = typeof rawContext === 'string' && rawContext.trim() ? rawContext.trim() : undefined
302
- const ownsModel =
303
- isCaptureModelEnabled(this.#captureModel) &&
304
- (options.originalTool
305
- ? analyticsOwnsParameter(options.originalTool.inputSchema, 'llm_model')
306
- : this.#modelParameterOwnership.get(name) === true)
360
+ const ownership = options.originalTool
361
+ ? getAnalyticsParameterOwnership(options.originalTool.inputSchema, options.originalTool.outputSchema)
362
+ : this.#analyticsParameterOwnership.get(name)
363
+ const ownsModel = isCaptureModelEnabled(this.#captureModel) && ownership?.llmModel === true
307
364
  const resolvedModel = isCaptureModelEnabled(this.#captureModel)
308
365
  ? resolveModel({ params: { arguments: args, _meta: options.requestMeta } }, ownsModel)
309
366
  : undefined
310
367
  const strippedArgs = stripContext(args)
368
+ const canReadConversationId = this.#enableConversationId && (ownership?.conversationId ?? true)
369
+ const resolvedConversation = resolveConversationId(canReadConversationId, args)
370
+ const conversation =
371
+ resolvedConversation.minted && options.sessionId
372
+ ? ({ minted: false, conversationId: undefined } as const)
373
+ : resolvedConversation
374
+ const conversationId = conversation.conversationId
375
+ const sessionId = conversationId ? deriveSessionIdFromConversation(conversationId) : options.sessionId
311
376
  // A supplied `originalTool` is a real application tool by this name (it
312
377
  // comes from the host's own list, which never holds the virtual tool), so
313
378
  // the real tool wins — the stateless twin of instrument()'s listing-derived
@@ -315,28 +380,75 @@ export class PostHogMCP extends PostHog {
315
380
  // remedy for a collision is configuring a non-colliding `toolName`.
316
381
  const isFeedback =
317
382
  this.#feedbackOptions !== undefined && name === this.#feedbackToolName && options.originalTool == null
318
- return {
383
+ const preparedCall: PreparedToolCallWithConversationState = {
319
384
  intent,
320
385
  intentSource: intent ? 'context_parameter' : undefined,
321
386
  llmModel: resolvedModel?.model,
322
387
  llmModelSource: resolvedModel?.source,
323
- args: ownsModel
324
- ? (stripOwnedAnalyticsArguments(strippedArgs, {
325
- context: false,
326
- conversationId: false,
327
- llmModel: true,
328
- }) as Record<string, unknown> | undefined)
329
- : strippedArgs,
388
+ args: stripOwnedAnalyticsArguments(strippedArgs, {
389
+ context: false,
390
+ conversationId: this.#enableConversationId && ownership?.conversationId === true,
391
+ llmModel: ownsModel,
392
+ }) as Record<string, unknown> | undefined,
393
+ sessionId,
394
+ conversationId,
330
395
  isMissingCapability: name === this.#missingCapabilityToolName,
331
396
  isFeedback,
332
397
  feedbackReport: isFeedback ? parseFeedbackReport(args, this.#feedbackOptions) : undefined,
398
+ // Keep the internal state serializable. Custom dispatchers can pass this
399
+ // value through workers or clone it before preparing the result.
400
+ [PREPARED_CONVERSATION_STATE_KEY]: {
401
+ minted: conversation.minted,
402
+ outputInstructions: this.#enableConversationId && ownership?.outputInstructions === true,
403
+ },
404
+ }
405
+ return preparedCall
406
+ }
407
+
408
+ /**
409
+ * Add the conversation handle to a tool result without changing the original
410
+ * value. A new handle is appended to text content once. When the advertised
411
+ * output schema supports it, the handle is also mirrored into
412
+ * `structuredContent`.
413
+ *
414
+ * Use the returned session and conversation values for capture. If a new
415
+ * handle could not reach the client, the conversation value is omitted while
416
+ * the derived session value is kept.
417
+ */
418
+ prepareToolResult<TResult>(result: TResult, preparedCall: PreparedToolCall): PreparedToolResult<TResult> {
419
+ const state = (preparedCall as PreparedToolCallWithConversationState)[PREPARED_CONVERSATION_STATE_KEY]
420
+ const conversationId = preparedCall.conversationId
421
+ if (!conversationId) {
422
+ return { result, sessionId: preparedCall.sessionId, conversationId }
423
+ }
424
+ if (!state) {
425
+ return { result, sessionId: preparedCall.sessionId, conversationId: undefined }
426
+ }
427
+
428
+ let preparedResult: unknown = result
429
+ let delivered = false
430
+ if (state.outputInstructions) {
431
+ const mirrored = mirrorInstructionsIntoStructuredContent(preparedResult, conversationId)
432
+ delivered = mirrored !== preparedResult
433
+ preparedResult = mirrored
434
+ }
435
+ if (state.minted && canInjectConversationIdPromptBack(preparedResult)) {
436
+ preparedResult = injectConversationIdPromptBack(preparedResult, conversationId)
437
+ delivered = true
438
+ }
439
+
440
+ return {
441
+ result: preparedResult as TResult,
442
+ sessionId: preparedCall.sessionId,
443
+ conversationId: state.minted && !delivered ? undefined : conversationId,
333
444
  }
334
445
  }
335
446
 
336
447
  /**
337
448
  * Capture a `get_more_tools` call as a missing-capability report. Emits
338
449
  * `$mcp_missing_capability` with the agent's description as `$mcp_intent`. Reply
339
- * to the agent with `getMoreToolsResult()`.
450
+ * to the agent with `getMoreToolsResult()` after passing it through
451
+ * {@link prepareToolResult}.
340
452
  */
341
453
  captureMissingCapability(data: MissingCapabilityCaptureData): void {
342
454
  const event = baseEvent(MCPAnalyticsEventType.mcpMissingCapability, data)
@@ -352,7 +464,7 @@ export class PostHogMCP extends PostHog {
352
464
  * `$mcp_feedback` with the report's `$mcp_feedback_*` properties and its
353
465
  * summary/details as `$mcp_intent`. Reply to the agent with
354
466
  * `sendFeedbackResult()` (or a custom text) after routing the report to your
355
- * own feedback backend.
467
+ * own feedback backend and passing the reply through {@link prepareToolResult}.
356
468
  */
357
469
  captureFeedback(data: FeedbackCaptureData): void {
358
470
  const event = baseEvent(MCPAnalyticsEventType.mcpFeedback, data)
@@ -388,6 +500,7 @@ function baseEvent(eventType: MCPAnalyticsEventType, common: McpCaptureCommon):
388
500
  const event: McpEvent = {
389
501
  eventType,
390
502
  sessionId: common.sessionId,
503
+ conversationId: common.conversationId,
391
504
  protocolVersion: common.protocolVersion,
392
505
  // There is no `extra` on this path, so the host reads the request headers and
393
506
  // passes them per capture — the SDK has no transport to read them from. Normalized
@@ -408,6 +521,31 @@ function baseEvent(eventType: MCPAnalyticsEventType, common: McpCaptureCommon):
408
521
  return event
409
522
  }
410
523
 
524
+ function collectAnalyticsParameterOwnership<TTool extends ContextInjectableTool>(
525
+ tools: TTool[]
526
+ ): Map<string, AnalyticsParameterOwnership> {
527
+ const ownershipByName = new Map<string, AnalyticsParameterOwnership>()
528
+ for (const tool of tools) {
529
+ if (typeof tool.name !== 'string') {
530
+ continue
531
+ }
532
+ const next = getAnalyticsParameterOwnership(tool.inputSchema, tool.outputSchema)
533
+ const current = ownershipByName.get(tool.name)
534
+ ownershipByName.set(
535
+ tool.name,
536
+ current
537
+ ? {
538
+ context: current.context && next.context,
539
+ conversationId: current.conversationId && next.conversationId,
540
+ llmModel: current.llmModel && next.llmModel,
541
+ outputInstructions: current.outputInstructions && next.outputInstructions,
542
+ }
543
+ : next
544
+ )
545
+ }
546
+ return ownershipByName
547
+ }
548
+
411
549
  /**
412
550
  * Set the agent intent on an event → `$mcp_intent` / `$mcp_intent_source`. No-op
413
551
  * for blank intents, so a missing `context` argument simply leaves them off.
package/src/index.ts CHANGED
@@ -258,6 +258,7 @@ export type {
258
258
  MCPAnalyticsOptions,
259
259
  MissingCapabilityCaptureData,
260
260
  PreparedToolCall,
261
+ PreparedToolResult,
261
262
  PrepareToolCallOptions,
262
263
  PrepareToolListOptions,
263
264
  RequestHeaderBag,
package/src/types.ts CHANGED
@@ -541,6 +541,12 @@ export interface McpCaptureCommon {
541
541
  distinctId?: string
542
542
  /** Session id → `$session_id`. Omitted from the event entirely when not provided. */
543
543
  sessionId?: string
544
+ /**
545
+ * Conversation handle → `$mcp_conversation_id`. For custom dispatchers,
546
+ * use the value returned by {@link PostHogMCP.prepareToolResult} so a newly
547
+ * minted handle is captured only when it reached the client.
548
+ */
549
+ conversationId?: string
544
550
  /**
545
551
  * Negotiated MCP protocol (spec) version → `$mcp_protocol_version`. Pass it on
546
552
  * every capture for the session (like `sessionId`) so later events carry it too,
@@ -695,9 +701,15 @@ export interface PrepareToolCallOptions {
695
701
  * Passing it also disambiguates a feedback-tool name collision: a real tool
696
702
  * by that name is dispatched normally instead of being flagged as feedback.
697
703
  */
698
- originalTool?: { inputSchema?: unknown }
704
+ originalTool?: { inputSchema?: unknown; outputSchema?: unknown }
699
705
  /** The incoming `tools/call` request's `_meta`, used for recognized client model metadata. */
700
706
  requestMeta?: JsonRecord
707
+ /**
708
+ * A session id carried by the request or transport. A valid echoed
709
+ * `conversation_id` takes precedence. Otherwise this value prevents the SDK
710
+ * from minting a second session handle.
711
+ */
712
+ sessionId?: string
701
713
  }
702
714
 
703
715
  /**
@@ -714,8 +726,16 @@ export interface PreparedToolCall {
714
726
  llmModel?: string
715
727
  /** How the model id was obtained. */
716
728
  llmModelSource?: MCPAnalyticsModelSource
717
- /** The call arguments with SDK-owned `context` and `llm_model` keys removed. */
729
+ /** The call arguments with SDK-owned analytics keys removed. */
718
730
  args?: Record<string, unknown>
731
+ /** The resolved session id to use when capturing this call. */
732
+ sessionId?: string
733
+ /**
734
+ * The resolved conversation handle. Use the value from
735
+ * {@link PostHogMCP.prepareToolResult} for capture because result delivery can
736
+ * remove a newly minted handle from analytics.
737
+ */
738
+ conversationId?: string
719
739
  /** True when `name` is the `get_more_tools` virtual tool. */
720
740
  isMissingCapability: boolean
721
741
  /**
@@ -734,6 +754,16 @@ export interface PreparedToolCall {
734
754
  feedbackReport?: FeedbackReport
735
755
  }
736
756
 
757
+ /** Result of {@link PostHogMCP.prepareToolResult}. */
758
+ export interface PreparedToolResult<TResult = unknown> {
759
+ /** The result to return to the MCP client. */
760
+ result: TResult
761
+ /** The resolved session id to use when capturing this call. */
762
+ sessionId?: string
763
+ /** The conversation handle to capture, if it reached the client. */
764
+ conversationId?: string
765
+ }
766
+
737
767
  /** Payload for {@link PostHogMCP.captureMissingCapability}. Emits `$mcp_missing_capability`. */
738
768
  export interface MissingCapabilityCaptureData extends McpCaptureCommon {
739
769
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.17.0'
1
+ export const version = '0.18.0'