@posthog/mcp 0.14.1 → 0.16.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.
Files changed (65) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +64 -0
  3. package/dist/extensions/constants.d.ts +9 -0
  4. package/dist/extensions/constants.d.ts.map +1 -1
  5. package/dist/extensions/constants.js +9 -0
  6. package/dist/extensions/constants.mjs +9 -0
  7. package/dist/extensions/event-types.d.ts +1 -0
  8. package/dist/extensions/event-types.d.ts.map +1 -1
  9. package/dist/extensions/event-types.js +1 -0
  10. package/dist/extensions/event-types.mjs +1 -0
  11. package/dist/extensions/feedback.d.ts +41 -0
  12. package/dist/extensions/feedback.d.ts.map +1 -0
  13. package/dist/extensions/feedback.js +259 -0
  14. package/dist/extensions/feedback.mjs +200 -0
  15. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  16. package/dist/extensions/instrument-highlevel.js +23 -2
  17. package/dist/extensions/instrument-highlevel.mjs +25 -4
  18. package/dist/extensions/instrument-lowlevel.d.ts +4 -3
  19. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  20. package/dist/extensions/instrument-lowlevel.js +23 -2
  21. package/dist/extensions/instrument-lowlevel.mjs +25 -4
  22. package/dist/extensions/instrumentation.d.ts +33 -13
  23. package/dist/extensions/instrumentation.d.ts.map +1 -1
  24. package/dist/extensions/instrumentation.js +103 -9
  25. package/dist/extensions/instrumentation.mjs +99 -8
  26. package/dist/extensions/internal.js +9 -2
  27. package/dist/extensions/internal.mjs +9 -2
  28. package/dist/extensions/mcp-payloads.d.ts +19 -0
  29. package/dist/extensions/mcp-payloads.d.ts.map +1 -1
  30. package/dist/extensions/mcp-payloads.js +217 -7
  31. package/dist/extensions/mcp-payloads.mjs +210 -6
  32. package/dist/extensions/posthog-events.d.ts.map +1 -1
  33. package/dist/extensions/posthog-events.js +1 -0
  34. package/dist/extensions/posthog-events.mjs +1 -0
  35. package/dist/extensions/posthog-mcp.d.ts +23 -3
  36. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  37. package/dist/extensions/posthog-mcp.js +27 -1
  38. package/dist/extensions/posthog-mcp.mjs +27 -1
  39. package/dist/extensions/sanitization.d.ts.map +1 -1
  40. package/dist/extensions/sanitization.js +2 -1
  41. package/dist/extensions/sanitization.mjs +3 -2
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +10 -0
  45. package/dist/index.mjs +5 -1
  46. package/dist/types.d.ts +114 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/dist/version.mjs +1 -1
  51. package/package.json +4 -4
  52. package/src/extensions/constants.ts +9 -0
  53. package/src/extensions/event-types.ts +1 -0
  54. package/src/extensions/feedback.ts +349 -0
  55. package/src/extensions/instrument-highlevel.ts +45 -2
  56. package/src/extensions/instrument-lowlevel.ts +49 -5
  57. package/src/extensions/instrumentation.ts +170 -17
  58. package/src/extensions/internal.ts +13 -2
  59. package/src/extensions/mcp-payloads.ts +443 -7
  60. package/src/extensions/posthog-events.ts +1 -0
  61. package/src/extensions/posthog-mcp.ts +74 -3
  62. package/src/extensions/sanitization.ts +14 -6
  63. package/src/index.ts +24 -3
  64. package/src/types.ts +120 -0
  65. package/src/version.ts +1 -1
@@ -7,6 +7,7 @@ import type {
7
7
  AnalyticsParameterOwnership,
8
8
  CompatibleRequestHandlerExtra,
9
9
  CompatibleToolsListLike,
10
+ JsonRecord,
10
11
  MCPAnalyticsData,
11
12
  MCPRequestLike,
12
13
  MCPServerLike,
@@ -44,6 +45,7 @@ import { readRequestHandlerMethod } from './mcp-sdk-compat'
44
45
  import { getRequestHeaders } from './request-headers'
45
46
  import { getSessionId, getSessionInfo, isModernEraRequest, newSessionId } from './session'
46
47
  import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
48
+ import { getFeedbackToolDescriptor, resolveCollectFeedbackOptions, SEND_FEEDBACK_TOOL_NAME } from './feedback'
47
49
  import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
48
50
  import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
49
51
 
@@ -70,16 +72,29 @@ interface TraceToolCallParams {
70
72
  parameterOwnership?: AnalyticsParameterOwnership
71
73
  /**
72
74
  * Event type to capture. Defaults to a tool call; the `get_more_tools` virtual
73
- * tool passes `mcpMissingCapability` so it records a capability gap rather than
74
- * a tool invocation.
75
+ * tool passes `mcpMissingCapability` and `send_feedback` passes
76
+ * `mcpFeedback`, so they record a capability gap / a feedback report
77
+ * rather than a tool invocation.
75
78
  */
76
79
  eventType?: MCPAnalyticsEventType
77
80
  /**
78
81
  * When set, used verbatim as the captured intent (source `context_parameter`)
79
- * instead of running `resolveToolCallIntent`. Used by the `get_more_tools`
80
- * virtual tool, which carries its intent in the `context` argument.
82
+ * instead of running `resolveToolCallIntent`. Used by the virtual tools, which
83
+ * carry their intent in their own arguments.
81
84
  */
82
85
  explicitContextIntent?: string
86
+ /**
87
+ * Extra event properties spread onto the captured event, after the host's
88
+ * `eventProperties`. Used by `send_feedback` for its `$mcp_feedback_*` fields.
89
+ */
90
+ extraEventProperties?: JsonRecord
91
+ /**
92
+ * Drop the generically captured `$mcp_parameters` from the event. Used by
93
+ * `send_feedback`: its arguments are agent-narrated free text, so the
94
+ * PII-redacted `$mcp_feedback_*` properties are the captured surface — the
95
+ * raw arguments would bypass that redaction and record undeclared fields.
96
+ */
97
+ omitCapturedParameters?: boolean
83
98
  /**
84
99
  * Optional accessor for an error the executor captured out-of-band. The
85
100
  * high-level SDK turns thrown tool errors into `isError: true` results before
@@ -107,21 +122,28 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
107
122
  parameterOwnership,
108
123
  eventType,
109
124
  explicitContextIntent,
125
+ extraEventProperties,
126
+ omitCapturedParameters,
110
127
  takeCapturedError,
111
128
  } = params
112
129
  const resolvedEventType = eventType ?? MCPAnalyticsEventType.mcpToolsCall
130
+ // The SDK's own virtual tools carry their intent in their own arguments, so
131
+ // the injected `context` parameter neither exists on them nor gets read.
132
+ const isVirtualAnalyticsTool =
133
+ resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability ||
134
+ resolvedEventType === MCPAnalyticsEventType.mcpFeedback
113
135
  const ownership = getActiveAnalyticsParameterOwnership(
114
136
  data,
115
137
  request.params?.name,
116
138
  parameterOwnership,
117
- resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
139
+ isVirtualAnalyticsTool
118
140
  )
119
141
  // Reading the argument and removing it are separate questions: deleting one the
120
142
  // application declared costs the customer their call, so the strip below still
121
143
  // requires positive ownership, while reading it when ownership is unresolved
122
144
  // costs at worst a mislabelled property. ADR-0011.
123
145
  const canCaptureContextIntent =
124
- resolvedEventType !== MCPAnalyticsEventType.mcpMissingCapability &&
146
+ !isVirtualAnalyticsTool &&
125
147
  isContextEnabled(data.options.context) &&
126
148
  (ownership.context || !ownership.contextOwnershipKnown)
127
149
  const conversation = resolveConversationId(ownership.conversationId, request.params?.arguments)
@@ -145,6 +167,12 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
145
167
  if (preparedEvent && explicitContextIntent) {
146
168
  setExplicitContextIntent(preparedEvent.event, explicitContextIntent)
147
169
  }
170
+ if (preparedEvent && extraEventProperties) {
171
+ preparedEvent.event.properties = { ...preparedEvent.event.properties, ...extraEventProperties }
172
+ }
173
+ if (preparedEvent && omitCapturedParameters) {
174
+ preparedEvent.event.parameters = undefined
175
+ }
148
176
 
149
177
  let result: unknown
150
178
  try {
@@ -178,13 +206,13 @@ function getActiveAnalyticsParameterOwnership(
178
206
  data: MCPAnalyticsData,
179
207
  toolName: string | undefined,
180
208
  override: AnalyticsParameterOwnership | undefined,
181
- isMissingCapabilityTool: boolean
209
+ isVirtualAnalyticsTool: boolean
182
210
  ): ActiveAnalyticsParameterOwnership {
183
211
  const listed = toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined
184
212
  const ownership = override ?? listed
185
213
  return {
186
214
  contextOwnershipKnown: ownership !== undefined,
187
- context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
215
+ context: !isVirtualAnalyticsTool && isContextEnabled(data.options.context) && ownership?.context === true,
188
216
  conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
189
217
  llmModel: isCaptureModelEnabled(data.options.captureModel) && ownership?.llmModel === true,
190
218
  // Deliberately read off `listed`, never the override: only the advertised
@@ -395,6 +423,111 @@ function publishFailedToolEvent(
395
423
  }
396
424
  }
397
425
 
426
+ type ResourceEventType = typeof MCPAnalyticsEventType.mcpResourcesList | typeof MCPAnalyticsEventType.mcpResourcesRead
427
+
428
+ interface TraceRequestParams {
429
+ server: MCPServerLike
430
+ originalHandler: MCPRequestHandler
431
+ request: MCPRequestLike
432
+ extra: CompatibleRequestHandlerExtra | undefined
433
+ eventType: ResourceEventType
434
+ logger: LoggerFn
435
+ }
436
+
437
+ /** One resource request's outcome: either the handler threw, or it returned. */
438
+ type ResourceOutcome = { error: unknown } | { result: unknown }
439
+
440
+ /**
441
+ * Stamps a resource request's outcome onto its prepared event and publishes it.
442
+ *
443
+ * A listing's result is captured as the event response; a read's is not. What
444
+ * `resources/list` and `resources/templates/list` return is discovery metadata —
445
+ * names, uris, mime types, the next cursor — which answers "what did this client
446
+ * actually see?", while a read returns the resource body itself, which analytics
447
+ * has no business holding.
448
+ */
449
+ function publishResourceEvent(
450
+ server: MCPServerLike,
451
+ preparedEvent: PreparedToolEvent | null,
452
+ startTime: Date,
453
+ params: TraceRequestParams,
454
+ outcome: ResourceOutcome
455
+ ): void {
456
+ if (!preparedEvent) {
457
+ return
458
+ }
459
+ const { event, requestAttribution } = preparedEvent
460
+ // Stamping is inside the `try` with the publish: `captureException` reads the
461
+ // thrown value's own `stack`, which an application error is free to define as
462
+ // a throwing getter. Outside, that would replace the resource error the caller
463
+ // is waiting on with ours.
464
+ try {
465
+ if ('error' in outcome) {
466
+ event.isError = true
467
+ event.error = captureException(outcome.error)
468
+ } else {
469
+ event.isError = false
470
+ if (params.eventType === MCPAnalyticsEventType.mcpResourcesList) {
471
+ event.response = outcome.result
472
+ }
473
+ }
474
+ event.duration = Date.now() - startTime.getTime()
475
+ captureEvent(server, event, params.logger, requestAttribution)
476
+ } catch (error) {
477
+ params.logger(`Warning: PostHog MCP analytics failed to publish ${params.request.method} analytics - ${error}`)
478
+ }
479
+ }
480
+
481
+ /** Builds the handler patch that captures one resource method, for either adapter. */
482
+ export function traceResourceRequest(eventType: ResourceEventType, logger: LoggerFn): HandlerPatch {
483
+ return (server, originalHandler, request, extra) =>
484
+ captureResourceRequest({ server, originalHandler, request, extra, eventType, logger })
485
+ }
486
+
487
+ /** Captures a non-tool MCP request without changing its result or error semantics. */
488
+ async function captureResourceRequest(params: TraceRequestParams): Promise<unknown> {
489
+ const { server, originalHandler, request, extra, eventType, logger } = params
490
+ const data = getServerTrackingData(server)
491
+ if (!data) {
492
+ logger(
493
+ 'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using resources.'
494
+ )
495
+ return await originalHandler(request, extra)
496
+ }
497
+
498
+ const startTime = new Date()
499
+ let preparedEvent: PreparedToolEvent | null = null
500
+ try {
501
+ const sessionId = getSessionId(server, extra)
502
+ const sessionInfo = getSessionInfo(server, data, sessionId)
503
+ const event: McpEvent = {
504
+ sessionId,
505
+ eventType,
506
+ parameters: buildCapturedMcpParameters(request),
507
+ resourceName: eventType === MCPAnalyticsEventType.mcpResourcesRead ? request.params?.uri : undefined,
508
+ timestamp: startTime,
509
+ }
510
+ stampClientIdentity(event, request, extra, server)
511
+ stampTransportIdentity(event, extra)
512
+ const identity = await handleIdentify(server, data, sessionId, request, sessionInfo, extra)
513
+ await applyResolvedMetadata(event, data, request, extra)
514
+ preparedEvent = { event, requestAttribution: withIdentity(sessionInfo, identity) }
515
+ } catch (error) {
516
+ logger(`Warning: PostHog MCP analytics could not prepare ${request.method} analytics - ${error}`)
517
+ }
518
+
519
+ let result: unknown
520
+ try {
521
+ result = await originalHandler(request, extra)
522
+ } catch (error) {
523
+ publishResourceEvent(server, preparedEvent, startTime, params, { error })
524
+ throw error
525
+ }
526
+
527
+ publishResourceEvent(server, preparedEvent, startTime, params, { result })
528
+ return result
529
+ }
530
+
398
531
  // --- tools/list -----------------------------------------------------------
399
532
 
400
533
  /**
@@ -508,26 +641,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
508
641
  }
509
642
 
510
643
  /**
511
- * Ownership for the `get_more_tools` virtual tool, resolved without the
512
- * `tools/list` cache.
644
+ * Ownership for an SDK-owned virtual tool (`get_more_tools`, `send_feedback`),
645
+ * resolved from its own descriptor rather than the `tools/list` cache.
513
646
  *
514
- * The missing-capability branch is only entered when the application does not
515
- * advertise a tool by this name, so the descriptor is the SDK's own and what it
516
- * declares is known statically. An instance that never served a listing — a
517
- * per-request `McpServer`/`Server`, the topology in ADR-0011 — would otherwise
518
- * read every injected parameter as not-ours and neither capture nor strip it.
647
+ * A virtual-tool branch is only entered when the application does not advertise
648
+ * a tool by this name, so the descriptor is the SDK's own and what it declares
649
+ * is known statically. An instance that never served a listing — a per-request
650
+ * `McpServer`/`Server`, the topology in ADR-0011 — would otherwise read every
651
+ * injected parameter as not-ours and neither capture nor strip it.
519
652
  *
520
653
  * `conversation_id` still comes from the cache on purpose: resolving it here too
521
654
  * would start minting a handle, and appending its prompt-back block, on
522
655
  * instances that today mint none. That changes session anchoring (ADR-0004)
523
656
  * rather than closing this gap.
657
+ *
658
+ * `virtualToolInputSchema` is required (not defaulted to one specific virtual
659
+ * tool's descriptor) so every call site names the tool it means; each caller
660
+ * passes its own descriptor's `inputSchema`.
524
661
  */
525
662
  export function getVirtualToolParameterOwnership(
526
663
  data: MCPAnalyticsData,
527
- toolName: string
664
+ toolName: string,
665
+ virtualToolInputSchema: unknown
528
666
  ): AnalyticsParameterOwnership {
529
667
  return {
530
- ...getAnalyticsParameterOwnership(getReportMissingToolDescriptor(toolName).inputSchema),
668
+ ...getAnalyticsParameterOwnership(virtualToolInputSchema),
531
669
  conversationId: data.toolAnalyticsParameterOwnership.get(toolName)?.conversationId === true,
532
670
  }
533
671
  }
@@ -703,6 +841,21 @@ async function getTracedToolsList(
703
841
  }
704
842
  }
705
843
 
844
+ const feedbackOptions = resolveCollectFeedbackOptions(data.options.collectFeedback)
845
+ if (feedbackOptions) {
846
+ const feedbackToolName = feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME
847
+ const alreadyPresent = tools.some((tool) => tool?.name === feedbackToolName)
848
+ if (alreadyPresent) {
849
+ data.logger(
850
+ `Warning: Cannot inject agent-feedback tool "${feedbackToolName}" because a real tool already uses that name. The real tool will not be intercepted.`
851
+ )
852
+ } else {
853
+ const virtualTool = getFeedbackToolDescriptor(feedbackOptions)
854
+ tools.push(virtualTool)
855
+ cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool])
856
+ }
857
+ }
858
+
706
859
  // After the virtual tool is appended, so it advertises `llm_model` too.
707
860
  // Its ownership was cached above from the un-injected descriptor, so the
708
861
  // SDK already strips and captures the argument on its calls — without
@@ -226,15 +226,26 @@ export async function resolveEventProperties(
226
226
  }
227
227
  }
228
228
 
229
+ /**
230
+ * Names what a request is about, for `$identify`. `resources/read` addresses its
231
+ * subject by `uri` rather than `name`, so an identify published from a read used
232
+ * to report `Unknown`.
233
+ */
229
234
  function getRequestResourceName(request: unknown): string {
230
235
  if (!request || typeof request !== 'object' || !('params' in request)) {
231
236
  return 'Unknown'
232
237
  }
233
238
 
234
239
  const params = request.params
235
- if (!params || typeof params !== 'object' || !('name' in params)) {
240
+ if (!params || typeof params !== 'object') {
236
241
  return 'Unknown'
237
242
  }
238
243
 
239
- return typeof params.name === 'string' ? params.name : 'Unknown'
244
+ for (const key of ['name', 'uri'] as const) {
245
+ const value = (params as Record<string, unknown>)[key]
246
+ if (typeof value === 'string') {
247
+ return value
248
+ }
249
+ }
250
+ return 'Unknown'
240
251
  }