@posthog/mcp 0.15.0 → 0.16.1

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 (59) hide show
  1. package/README.md +69 -0
  2. package/dist/extensions/constants.d.ts +9 -0
  3. package/dist/extensions/constants.d.ts.map +1 -1
  4. package/dist/extensions/constants.js +9 -0
  5. package/dist/extensions/constants.mjs +9 -0
  6. package/dist/extensions/event-types.d.ts +1 -0
  7. package/dist/extensions/event-types.d.ts.map +1 -1
  8. package/dist/extensions/event-types.js +1 -0
  9. package/dist/extensions/event-types.mjs +1 -0
  10. package/dist/extensions/feedback.d.ts +41 -0
  11. package/dist/extensions/feedback.d.ts.map +1 -0
  12. package/dist/extensions/feedback.js +261 -0
  13. package/dist/extensions/feedback.mjs +202 -0
  14. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  15. package/dist/extensions/instrument-highlevel.js +19 -1
  16. package/dist/extensions/instrument-highlevel.mjs +20 -2
  17. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  18. package/dist/extensions/instrument-lowlevel.js +19 -1
  19. package/dist/extensions/instrument-lowlevel.mjs +20 -2
  20. package/dist/extensions/instrumentation.d.ts +30 -13
  21. package/dist/extensions/instrumentation.d.ts.map +1 -1
  22. package/dist/extensions/instrumentation.js +27 -7
  23. package/dist/extensions/instrumentation.mjs +27 -7
  24. package/dist/extensions/mcp-payloads.d.ts +9 -3
  25. package/dist/extensions/mcp-payloads.d.ts.map +1 -1
  26. package/dist/extensions/mcp-payloads.js +16 -7
  27. package/dist/extensions/mcp-payloads.mjs +11 -5
  28. package/dist/extensions/posthog-events.d.ts.map +1 -1
  29. package/dist/extensions/posthog-events.js +1 -0
  30. package/dist/extensions/posthog-events.mjs +1 -0
  31. package/dist/extensions/posthog-mcp.d.ts +28 -4
  32. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  33. package/dist/extensions/posthog-mcp.js +27 -1
  34. package/dist/extensions/posthog-mcp.mjs +27 -1
  35. package/dist/extensions/sanitization.js +1 -1
  36. package/dist/extensions/sanitization.mjs +2 -2
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +10 -0
  40. package/dist/index.mjs +5 -1
  41. package/dist/types.d.ts +119 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/dist/version.mjs +1 -1
  46. package/package.json +3 -3
  47. package/src/extensions/constants.ts +9 -0
  48. package/src/extensions/event-types.ts +1 -0
  49. package/src/extensions/feedback.ts +357 -0
  50. package/src/extensions/instrument-highlevel.ts +39 -2
  51. package/src/extensions/instrument-lowlevel.ts +39 -2
  52. package/src/extensions/instrumentation.ts +65 -17
  53. package/src/extensions/mcp-payloads.ts +19 -6
  54. package/src/extensions/posthog-events.ts +1 -0
  55. package/src/extensions/posthog-mcp.ts +85 -4
  56. package/src/extensions/sanitization.ts +3 -3
  57. package/src/index.ts +21 -0
  58. package/src/types.ts +125 -1
  59. package/src/version.ts +1 -1
@@ -11,6 +11,15 @@ import type {
11
11
  RegisteredTool,
12
12
  ToolCallback,
13
13
  } from '../types'
14
+ import {
15
+ buildFeedbackEventProperties,
16
+ buildFeedbackIntent,
17
+ getFeedbackToolDescriptor,
18
+ handleFeedback,
19
+ parseFeedbackReport,
20
+ resolveCollectFeedbackOptions,
21
+ SEND_FEEDBACK_TOOL_NAME,
22
+ } from './feedback'
14
23
  import {
15
24
  analyticsOwnsParameter,
16
25
  getAnalyticsParameterOwnership,
@@ -22,7 +31,7 @@ import { MCPAnalyticsEventType } from './event-types'
22
31
  import { getServerTrackingData } from './internal'
23
32
  import type { LoggerFn } from './logger'
24
33
  import { createWrappedTool, getToolFunction, hasToolFunction } from './mcp-sdk-compat'
25
- import { handleReportMissing, resolveMissingCapabilityToolName } from './tools'
34
+ import { getReportMissingToolDescriptor, handleReportMissing, resolveMissingCapabilityToolName } from './tools'
26
35
  import {
27
36
  handleInitializeRequest,
28
37
  handleListToolsRequest,
@@ -251,11 +260,39 @@ async function handleToolCallRequest(
251
260
  extra,
252
261
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
253
262
  explicitContextIntent: context,
254
- parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
263
+ parameterOwnership: getVirtualToolParameterOwnership(
264
+ data,
265
+ toolName,
266
+ getReportMissingToolDescriptor(toolName).inputSchema
267
+ ),
255
268
  execute: async () => handleReportMissing({ context }, data.logger),
256
269
  })
257
270
  }
258
271
 
272
+ const feedbackOptions = resolveCollectFeedbackOptions(data.options.collectFeedback)
273
+ const isFeedbackCandidate =
274
+ feedbackOptions !== undefined && toolName === (feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME)
275
+
276
+ if (isFeedbackCandidate && (await isToolAdvertised(server, toolName, extra, data.logger)) === false) {
277
+ const report = parseFeedbackReport(request.params?.arguments, feedbackOptions)
278
+ return await captureToolCall({
279
+ server,
280
+ data,
281
+ request,
282
+ extra,
283
+ eventType: MCPAnalyticsEventType.mcpFeedback,
284
+ explicitContextIntent: buildFeedbackIntent(report),
285
+ omitCapturedParameters: true,
286
+ extraEventProperties: buildFeedbackEventProperties(report),
287
+ parameterOwnership: getVirtualToolParameterOwnership(
288
+ data,
289
+ toolName,
290
+ getFeedbackToolDescriptor(feedbackOptions).inputSchema
291
+ ),
292
+ execute: async () => handleFeedback(report, feedbackOptions, data.logger),
293
+ })
294
+ }
295
+
259
296
  const registeredTool = toolName ? highLevelServer._registeredTools[toolName] : undefined
260
297
 
261
298
  // Strip SDK-owned arguments before the MCP SDK validates the registered Zod
@@ -4,10 +4,19 @@
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
6
  import type { CompatibleRequestHandlerExtra, MCPRequestLike, MCPServerLike } from '../types'
7
+ import {
8
+ buildFeedbackEventProperties,
9
+ buildFeedbackIntent,
10
+ getFeedbackToolDescriptor,
11
+ handleFeedback,
12
+ parseFeedbackReport,
13
+ resolveCollectFeedbackOptions,
14
+ SEND_FEEDBACK_TOOL_NAME,
15
+ } from './feedback'
7
16
  import { MCPAnalyticsEventType } from './event-types'
8
17
  import { getServerTrackingData } from './internal'
9
18
  import type { LoggerFn } from './logger'
10
- import { handleReportMissing, resolveMissingCapabilityToolName } from './tools'
19
+ import { getReportMissingToolDescriptor, handleReportMissing, resolveMissingCapabilityToolName } from './tools'
11
20
  import {
12
21
  handleInitializeRequest,
13
22
  handleListToolsRequest,
@@ -98,11 +107,39 @@ async function handleToolCallRequest(
98
107
  extra,
99
108
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
100
109
  explicitContextIntent: context,
101
- parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
110
+ parameterOwnership: getVirtualToolParameterOwnership(
111
+ data,
112
+ toolName,
113
+ getReportMissingToolDescriptor(toolName).inputSchema
114
+ ),
102
115
  execute: async () => handleReportMissing({ context }, data.logger),
103
116
  })
104
117
  }
105
118
 
119
+ const feedbackOptions = resolveCollectFeedbackOptions(data.options.collectFeedback)
120
+ const isFeedbackCandidate =
121
+ feedbackOptions !== undefined && toolName === (feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME)
122
+
123
+ if (isFeedbackCandidate && (await isToolAdvertised(server, toolName, extra, data.logger)) === false) {
124
+ const report = parseFeedbackReport(request.params?.arguments, feedbackOptions)
125
+ return await captureToolCall({
126
+ server,
127
+ data,
128
+ request,
129
+ extra,
130
+ eventType: MCPAnalyticsEventType.mcpFeedback,
131
+ explicitContextIntent: buildFeedbackIntent(report),
132
+ omitCapturedParameters: true,
133
+ extraEventProperties: buildFeedbackEventProperties(report),
134
+ parameterOwnership: getVirtualToolParameterOwnership(
135
+ data,
136
+ toolName,
137
+ getFeedbackToolDescriptor(feedbackOptions).inputSchema
138
+ ),
139
+ execute: async () => handleFeedback(report, feedbackOptions, data.logger),
140
+ })
141
+ }
142
+
106
143
  return await captureToolCall({
107
144
  server,
108
145
  data,
@@ -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
@@ -613,26 +641,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
613
641
  }
614
642
 
615
643
  /**
616
- * Ownership for the `get_more_tools` virtual tool, resolved without the
617
- * `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.
618
646
  *
619
- * The missing-capability branch is only entered when the application does not
620
- * advertise a tool by this name, so the descriptor is the SDK's own and what it
621
- * declares is known statically. An instance that never served a listing — a
622
- * per-request `McpServer`/`Server`, the topology in ADR-0011 — would otherwise
623
- * 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.
624
652
  *
625
653
  * `conversation_id` still comes from the cache on purpose: resolving it here too
626
654
  * would start minting a handle, and appending its prompt-back block, on
627
655
  * instances that today mint none. That changes session anchoring (ADR-0004)
628
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`.
629
661
  */
630
662
  export function getVirtualToolParameterOwnership(
631
663
  data: MCPAnalyticsData,
632
- toolName: string
664
+ toolName: string,
665
+ virtualToolInputSchema: unknown
633
666
  ): AnalyticsParameterOwnership {
634
667
  return {
635
- ...getAnalyticsParameterOwnership(getReportMissingToolDescriptor(toolName).inputSchema),
668
+ ...getAnalyticsParameterOwnership(virtualToolInputSchema),
636
669
  conversationId: data.toolAnalyticsParameterOwnership.get(toolName)?.conversationId === true,
637
670
  }
638
671
  }
@@ -808,6 +841,21 @@ async function getTracedToolsList(
808
841
  }
809
842
  }
810
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
+
811
859
  // After the virtual tool is appended, so it advertises `llm_model` too.
812
860
  // Its ownership was cached above from the un-injected descriptor, so the
813
861
  // SDK already strips and captures the argument on its calls — without
@@ -508,8 +508,8 @@ function sanitizeString(value: string): string {
508
508
  }
509
509
 
510
510
  /**
511
- * Sanitizes the agent-narrated intent: structured PII on top of the passes every
512
- * captured string gets.
511
+ * Sanitizes agent-narrated free text (`$mcp_intent`, the `send_feedback`
512
+ * fields): structured PII on top of the passes every captured string gets.
513
513
  *
514
514
  * Every step of the order is load-bearing. The binary gate reads the value as it
515
515
  * arrived, because splicing `[redacted]` into a blob — a Luhn-valid run inside
@@ -519,7 +519,7 @@ function sanitizeString(value: string): string {
519
519
  * URL pass, because the URL rewrite percent-encodes the `@` that the email
520
520
  * pattern anchors on.
521
521
  */
522
- export function sanitizeIntent(value: string): string {
522
+ export function sanitizeFreeText(value: string): string {
523
523
  if (isBinaryBlob(value)) {
524
524
  return BINARY_REDACTED_VALUE
525
525
  }
@@ -607,16 +607,29 @@ export function redactPii(value: string): string {
607
607
  }
608
608
 
609
609
  export function sanitizeCapturedValue(value: unknown): unknown {
610
+ return sanitizeValueWith(value, sanitizeString)
611
+ }
612
+
613
+ /**
614
+ * {@link sanitizeCapturedValue} with the intent-grade string pass
615
+ * ({@link sanitizeFreeText}) on every string leaf, so nested agent-narrated
616
+ * values get structured-PII redaction in the load-bearing order too.
617
+ */
618
+ export function sanitizeFreeTextValue(value: unknown): unknown {
619
+ return sanitizeValueWith(value, sanitizeFreeText)
620
+ }
621
+
622
+ function sanitizeValueWith(value: unknown, sanitizeStringFn: (value: string) => string): unknown {
610
623
  if (value == null) {
611
624
  return value
612
625
  }
613
626
 
614
627
  if (typeof value === 'string') {
615
- return sanitizeString(value)
628
+ return sanitizeStringFn(value)
616
629
  }
617
630
 
618
631
  if (Array.isArray(value)) {
619
- return value.map(sanitizeCapturedValue)
632
+ return value.map((item) => sanitizeValueWith(item, sanitizeStringFn))
620
633
  }
621
634
 
622
635
  if (value instanceof Date) {
@@ -629,7 +642,7 @@ export function sanitizeCapturedValue(value: unknown): unknown {
629
642
 
630
643
  const result: JsonRecord = {}
631
644
  for (const [key, nestedValue] of Object.entries(value)) {
632
- result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeCapturedValue(nestedValue)
645
+ result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeValueWith(nestedValue, sanitizeStringFn)
633
646
  }
634
647
  return result
635
648
  }
@@ -10,6 +10,7 @@ import { MCPAnalyticsEventType } from './event-types'
10
10
  const BUILT_IN_EVENT_NAME_BY_TYPE = {
11
11
  [MCPAnalyticsEventType.custom]: PostHogMCPAnalyticsEvent.Custom,
12
12
  [MCPAnalyticsEventType.identify]: PostHogMCPAnalyticsEvent.Identify,
13
+ [MCPAnalyticsEventType.mcpFeedback]: PostHogMCPAnalyticsEvent.Feedback,
13
14
  [MCPAnalyticsEventType.mcpMissingCapability]: PostHogMCPAnalyticsEvent.MissingCapability,
14
15
  [MCPAnalyticsEventType.mcpInitialize]: PostHogMCPAnalyticsEvent.Initialize,
15
16
  [MCPAnalyticsEventType.mcpPromptsGet]: PostHogMCPAnalyticsEvent.PromptGet,
@@ -1,6 +1,9 @@
1
1
  import { PostHog, type PostHogOptions } from 'posthog-node'
2
2
 
3
3
  import type {
4
+ FeedbackCaptureData,
5
+ CollectFeedbackOptions,
6
+ CollectFeedbackConfig,
4
7
  InitializeCaptureData,
5
8
  JsonRecord,
6
9
  MCPAnalyticsOptions,
@@ -13,6 +16,14 @@ import type {
13
16
  ToolCallCaptureData,
14
17
  ToolsListCaptureData,
15
18
  } from '../types'
19
+ import {
20
+ buildFeedbackEventProperties,
21
+ buildFeedbackIntent,
22
+ getFeedbackToolDescriptor,
23
+ parseFeedbackReport,
24
+ resolveCollectFeedbackOptions,
25
+ SEND_FEEDBACK_TOOL_NAME,
26
+ } from './feedback'
16
27
  import { analyticsOwnsParameter, stripOwnedAnalyticsArguments } from './analytics-parameters'
17
28
  import {
18
29
  addContextParameterToTools,
@@ -47,6 +58,17 @@ export interface PostHogMCPOptions extends PostHogOptions {
47
58
  * can't drift. Defaults to `get_more_tools`.
48
59
  */
49
60
  missingCapabilityToolName?: string
61
+ /**
62
+ * Enable + configure the `send_feedback` virtual tool: injected by
63
+ * {@link PostHogMCP.prepareToolList} (when its `collectFeedback` toggle is on)
64
+ * and detected by {@link PostHogMCP.prepareToolCall}. Set once here so
65
+ * injection and detection can't drift — without this option, `prepareToolCall`
66
+ * never flags a call as feedback, so a real tool that uses the name is not
67
+ * shadowed. Pick a `toolName` no real tool uses. `onFeedback` is ignored on
68
+ * this path — the host dispatcher routes reports itself via
69
+ * {@link PreparedToolCall.feedbackReport}.
70
+ */
71
+ collectFeedback?: CollectFeedbackConfig
50
72
  /**
51
73
  * Capture the calling model from recognized client metadata, with an injected
52
74
  * `llm_model` argument as fallback. Off by default.
@@ -93,19 +115,36 @@ export interface PostHogMCPOptions extends PostHogOptions {
93
115
  export class PostHogMCP extends PostHog {
94
116
  readonly #sink = new McpEventSink(this)
95
117
 
96
- // The get_more_tools name lives here (not on the per-call options) so that
118
+ // The virtual-tool config lives here (not on the per-call options) so that
97
119
  // prepareToolList (inject) and prepareToolCall (detect) always agree.
98
120
  readonly #missingCapabilityToolName: string
121
+ // `undefined` is the enable switch's off state: without it, prepareToolCall
122
+ // must never claim a call named like the virtual tool — the host may have a
123
+ // real tool by that name, and flagging it would shadow the real handler.
124
+ readonly #feedbackOptions: CollectFeedbackOptions | undefined
99
125
  readonly #captureModel: MCPAnalyticsOptions['captureModel']
100
126
  readonly #modelParameterOwnership = new Map<string, boolean>()
101
127
 
102
128
  constructor(apiKey: string, options: PostHogMCPOptions = {}) {
103
129
  super(apiKey, options)
104
130
  this.#missingCapabilityToolName = options.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
131
+ this.#feedbackOptions = resolveCollectFeedbackOptions(options.collectFeedback)
132
+ // Fail fast on a config error (reserved extra key, undeclared extraRequired)
133
+ // instead of first surfacing it when a tools/list is served.
134
+ getFeedbackToolDescriptor(this.#feedbackOptions)
135
+ if (this.#feedbackOptions?.onFeedback) {
136
+ log(
137
+ 'Warning: collectFeedback.onFeedback is ignored on the PostHogMCP path - route reports from your dispatcher via prepareToolCall().feedbackReport instead.'
138
+ )
139
+ }
105
140
  this.#captureModel = options.captureModel
106
141
  applyMcpLibIdentity(this)
107
142
  }
108
143
 
144
+ get #feedbackToolName(): string {
145
+ return this.#feedbackOptions?.toolName ?? SEND_FEEDBACK_TOOL_NAME
146
+ }
147
+
109
148
  /** Capture a tool invocation. Emits `$mcp_tool_call` (+ an `$exception` sibling on error). */
110
149
  captureToolCall(data: ToolCallCaptureData): void {
111
150
  const event = baseEvent(MCPAnalyticsEventType.mcpToolsCall, data)
@@ -160,8 +199,9 @@ export class PostHogMCP extends PostHog {
160
199
  * Decorate your `tools/list` response with PostHog's analytics affordances:
161
200
  * injects the `context` argument into every tool (so agents state their intent,
162
201
  * captured as `$mcp_intent`), injects `llm_model` when the constructor's
163
- * `captureModel` option is enabled, and appends `get_more_tools` when
164
- * `reportMissing` is on. Returns a new array; your tools are untouched.
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.
165
205
  *
166
206
  * The appended `get_more_tools` descriptor carries only the base MCP tool fields
167
207
  * (name, description, input schema) — not any framework-specific fields your
@@ -188,6 +228,14 @@ export class PostHogMCP extends PostHog {
188
228
  prepared = [...prepared, getReportMissingToolDescriptor(this.#missingCapabilityToolName) as TTool]
189
229
  }
190
230
 
231
+ if (
232
+ options.collectFeedback &&
233
+ this.#feedbackOptions !== undefined &&
234
+ !prepared.some((tool) => tool?.name === this.#feedbackToolName)
235
+ ) {
236
+ prepared = [...prepared, getFeedbackToolDescriptor(this.#feedbackOptions) as TTool]
237
+ }
238
+
191
239
  if (isCaptureModelEnabled(this.#captureModel)) {
192
240
  this.#modelParameterOwnership.clear()
193
241
  const ownershipByName = new Map<string, boolean>()
@@ -230,8 +278,12 @@ export class PostHogMCP extends PostHog {
230
278
  *
231
279
  * @example
232
280
  * ```ts
281
+ * const originalTool = myTools.find((tool) => tool.name === name)
233
282
  * const { intent, intentSource, llmModel, llmModelSource, args, isMissingCapability } =
234
- * posthog.prepareToolCall(name, rawArgs, { requestMeta: request.params?._meta })
283
+ * posthog.prepareToolCall(name, rawArgs, {
284
+ * originalTool,
285
+ * requestMeta: request.params?._meta,
286
+ * })
235
287
  * if (isMissingCapability) {
236
288
  * posthog.captureMissingCapability({ context: intent, llmModel, llmModelSource, ...identity })
237
289
  * return getMoreToolsResult()
@@ -256,6 +308,13 @@ export class PostHogMCP extends PostHog {
256
308
  ? resolveModel({ params: { arguments: args, _meta: options.requestMeta } }, ownsModel)
257
309
  : undefined
258
310
  const strippedArgs = stripContext(args)
311
+ // A supplied `originalTool` is a real application tool by this name (it
312
+ // comes from the host's own list, which never holds the virtual tool), so
313
+ // the real tool wins — the stateless twin of instrument()'s listing-derived
314
+ // collision handling. Without it the name match stands, and the documented
315
+ // remedy for a collision is configuring a non-colliding `toolName`.
316
+ const isFeedback =
317
+ this.#feedbackOptions !== undefined && name === this.#feedbackToolName && options.originalTool == null
259
318
  return {
260
319
  intent,
261
320
  intentSource: intent ? 'context_parameter' : undefined,
@@ -269,6 +328,8 @@ export class PostHogMCP extends PostHog {
269
328
  }) as Record<string, unknown> | undefined)
270
329
  : strippedArgs,
271
330
  isMissingCapability: name === this.#missingCapabilityToolName,
331
+ isFeedback,
332
+ feedbackReport: isFeedback ? parseFeedbackReport(args, this.#feedbackOptions) : undefined,
272
333
  }
273
334
  }
274
335
 
@@ -286,6 +347,26 @@ export class PostHogMCP extends PostHog {
286
347
  this.#emit(event)
287
348
  }
288
349
 
350
+ /**
351
+ * Capture a `send_feedback` call as an agent-feedback report. Emits
352
+ * `$mcp_feedback` with the report's `$mcp_feedback_*` properties and its
353
+ * summary/details as `$mcp_intent`. Reply to the agent with
354
+ * `sendFeedbackResult()` (or a custom text) after routing the report to your
355
+ * own feedback backend.
356
+ */
357
+ captureFeedback(data: FeedbackCaptureData): void {
358
+ const event = baseEvent(MCPAnalyticsEventType.mcpFeedback, data)
359
+ event.resourceName = this.#feedbackToolName
360
+ // Deliberately no `$mcp_parameters`: the arguments are agent-narrated free
361
+ // text, and the PII-redacted `$mcp_feedback_*` properties are the captured
362
+ // surface. Raw arguments would bypass that redaction. Feedback properties
363
+ // win over the caller's, matching the instrument() path's spread order.
364
+ event.properties = { ...event.properties, ...buildFeedbackEventProperties(data.report) }
365
+ applyIntent(event, buildFeedbackIntent(data.report), 'context_parameter')
366
+ setEventModel(event, data.llmModel, data.llmModelSource)
367
+ this.#emit(event)
368
+ }
369
+
289
370
  /**
290
371
  * Fire-and-forget, mirroring posthog-node's `capture()`: the event is enqueued
291
372
  * on the client, not awaited. Never throws — a failure to record analytics
@@ -4,7 +4,7 @@
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
6
  import type { ErrorProperties, Event, McpEvent } from '../types'
7
- import { sanitizeCapturedValue, sanitizeIntent } from './mcp-payloads'
7
+ import { sanitizeCapturedValue, sanitizeFreeText } from './mcp-payloads'
8
8
 
9
9
  type SanitizedRecord = Record<string, unknown>
10
10
 
@@ -41,13 +41,13 @@ export function sanitizeEvent<T extends Event | McpEvent>(event: T): T {
41
41
 
42
42
  // The intent comes straight from an agent-narrated `context` string, so it can
43
43
  // contain a secret the LLM read aloud or personal data it narrated about the
44
- // user. `sanitizeIntent` adds structured PII redaction (emails, phone numbers,
44
+ // user. `sanitizeFreeText` adds structured PII redaction (emails, phone numbers,
45
45
  // IPs, cards, SSNs) to the passes every captured value gets, in the one order
46
46
  // that works — see its doc comment. PII redaction is scoped to the intent only:
47
47
  // structured tool parameters and responses often hold the same shapes as
48
48
  // legitimate data.
49
49
  if (result.userIntent != null) {
50
- result.userIntent = sanitizeIntent(result.userIntent)
50
+ result.userIntent = sanitizeFreeText(result.userIntent)
51
51
  }
52
52
 
53
53
  if (result.llmModel != null) {
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { applyMcpLibIdentity } from './extensions/lib-identity'
14
14
  import { deriveSessionIdFromMCPSession, getSessionInfo, newSessionId } from './extensions/session'
15
15
  import { instrumentLowLevelServer } from './extensions/instrument-lowlevel'
16
16
  import { instrumentHighLevelServer } from './extensions/instrument-highlevel'
17
+ import { getFeedbackToolDescriptor, resolveCollectFeedbackOptions } from './extensions/feedback'
17
18
  import type {
18
19
  CaptureEventData,
19
20
  HighLevelMCPServerLike,
@@ -52,6 +53,17 @@ import type {
52
53
  */
53
54
  function instrument(server: unknown, posthog: PostHog, options: MCPAnalyticsOptions = {}): McpAnalytics {
54
55
  const logger = createLogger(options?.logger)
56
+
57
+ // Fail fast on a `collectFeedback` config error (reserved extra key,
58
+ // undeclared extraRequired). Above the graceful-degradation try so it throws
59
+ // out of instrument() like PostHogMCP's constructor does — inside the catch it
60
+ // would silently disable ALL analytics for the server, not just feedback.
61
+ // `options?.` — untyped JavaScript can pass null options (see logger-isolation.test.ts).
62
+ const feedbackOptions = resolveCollectFeedbackOptions(options?.collectFeedback)
63
+ if (feedbackOptions) {
64
+ getFeedbackToolDescriptor(feedbackOptions)
65
+ }
66
+
55
67
  try {
56
68
  if (!posthog) {
57
69
  logger('Warning: No PostHog client passed to instrument(). Events will not be sent anywhere.')
@@ -129,6 +141,7 @@ function getLowLevelServer(server: MCPServerLike | HighLevelMCPServerLike): MCPS
129
141
  */
130
142
  const DEFAULT_OPTIONS = {
131
143
  reportMissing: false,
144
+ collectFeedback: false,
132
145
  enableConversationId: false,
133
146
  } satisfies Partial<MCPAnalyticsOptions>
134
147
 
@@ -215,14 +228,22 @@ export {
215
228
  export { getRequestHeaders } from './extensions/request-headers'
216
229
  export { PostHogMCP, type PostHogMCPOptions } from './extensions/posthog-mcp'
217
230
  export { getMoreToolsResult } from './extensions/tools'
231
+ export { sendFeedbackResult, SEND_FEEDBACK_TOOL_NAME } from './extensions/feedback'
218
232
  export { setLogger } from './extensions/logger'
219
233
  // Re-export the posthog-node client so a single import works:
220
234
  // import { PostHog, instrument } from "@posthog/mcp"
221
235
  // posthog-node stays a peer dependency, so this resolves the host app's installed copy.
222
236
  export { PostHog, type PostHogOptions } from 'posthog-node'
223
237
  export type {
238
+ FeedbackCaptureData,
239
+ FeedbackExtraPropertySchema,
240
+ CollectFeedbackOptions,
241
+ FeedbackReport,
242
+ FeedbackSentiment,
243
+ FeedbackType,
224
244
  BeforeSendFn,
225
245
  CaptureEventData,
246
+ CollectFeedbackConfig,
226
247
  InitializeCaptureData,
227
248
  McpAnalytics,
228
249
  McpCaptureCommon,