@posthog/mcp 0.12.0 → 0.13.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 (70) hide show
  1. package/README.md +30 -2
  2. package/dist/extensions/analytics-parameters.js +14 -10
  3. package/dist/extensions/capture.js +12 -8
  4. package/dist/extensions/client-identity.js +18 -14
  5. package/dist/extensions/compatibility.js +16 -12
  6. package/dist/extensions/constants.d.ts +1 -1
  7. package/dist/extensions/constants.d.ts.map +1 -1
  8. package/dist/extensions/constants.js +25 -21
  9. package/dist/extensions/constants.mjs +3 -3
  10. package/dist/extensions/context-parameters.js +13 -9
  11. package/dist/extensions/conversation-id.js +18 -14
  12. package/dist/extensions/detect.js +23 -19
  13. package/dist/extensions/event-types.js +15 -11
  14. package/dist/extensions/exceptions.js +12 -8
  15. package/dist/extensions/headers.js +16 -12
  16. package/dist/extensions/ids.js +12 -8
  17. package/dist/extensions/instrument-highlevel.js +12 -8
  18. package/dist/extensions/instrument-lowlevel.js +12 -8
  19. package/dist/extensions/instrumentation.js +19 -15
  20. package/dist/extensions/intent.js +14 -10
  21. package/dist/extensions/internal.js +17 -13
  22. package/dist/extensions/lib-identity.js +12 -8
  23. package/dist/extensions/logger.js +17 -13
  24. package/dist/extensions/mcp-payloads.d.ts +10 -0
  25. package/dist/extensions/mcp-payloads.d.ts.map +1 -1
  26. package/dist/extensions/mcp-payloads.js +80 -12
  27. package/dist/extensions/mcp-payloads.mjs +62 -1
  28. package/dist/extensions/mcp-sdk-compat.js +23 -19
  29. package/dist/extensions/model-parameters.d.ts.map +1 -1
  30. package/dist/extensions/model-parameters.js +21 -14
  31. package/dist/extensions/model-parameters.mjs +6 -3
  32. package/dist/extensions/output-instructions.js +15 -11
  33. package/dist/extensions/posthog-events.js +12 -8
  34. package/dist/extensions/posthog-mcp.d.ts +23 -12
  35. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  36. package/dist/extensions/posthog-mcp.js +49 -11
  37. package/dist/extensions/posthog-mcp.mjs +37 -3
  38. package/dist/extensions/request-headers.js +15 -11
  39. package/dist/extensions/sanitization.d.ts +2 -2
  40. package/dist/extensions/sanitization.d.ts.map +1 -1
  41. package/dist/extensions/sanitization.js +14 -9
  42. package/dist/extensions/sanitization.mjs +3 -2
  43. package/dist/extensions/session-token.js +17 -13
  44. package/dist/extensions/session-token.mjs +2 -2
  45. package/dist/extensions/session.js +16 -12
  46. package/dist/extensions/sink.js +14 -10
  47. package/dist/extensions/tools.js +15 -11
  48. package/dist/extensions/tracing-helpers.js +15 -11
  49. package/dist/extensions/transport-identity.js +15 -11
  50. package/dist/extensions/truncation.js +21 -16
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +22 -18
  54. package/dist/index.mjs +7 -7
  55. package/dist/types.d.ts +28 -4
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js +2 -2
  58. package/dist/types.mjs +1 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +15 -11
  61. package/dist/version.mjs +1 -1
  62. package/package.json +12 -12
  63. package/src/extensions/constants.ts +1 -1
  64. package/src/extensions/mcp-payloads.ts +129 -0
  65. package/src/extensions/model-parameters.ts +7 -3
  66. package/src/extensions/posthog-mcp.ts +84 -12
  67. package/src/extensions/sanitization.ts +14 -7
  68. package/src/index.ts +1 -0
  69. package/src/types.ts +27 -4
  70. package/src/version.ts +1 -1
@@ -16,6 +16,55 @@ const POSTHOG_TOKEN_PATTERN = /\bph[a-z]_[A-Za-z0-9_-]{20,}\b/g
16
16
  const SENSITIVE_KEY_PATTERN =
17
17
  /^(authorization|cookie|set-cookie|x-api-key|api[-_]?key|api[-_]?token|access[-_]?token|refresh[-_]?token|token|password|secret|client[-_]?secret|private[-_]?key)$/i
18
18
 
19
+ // PII redaction for the agent-narrated intent string only. `$mcp_intent` is free
20
+ // text the calling LLM writes into the injected `context` argument, so it can
21
+ // carry personal data the model read aloud despite being told not to. We redact
22
+ // well-defined *structured identifiers* — the kind regex can match with high
23
+ // precision. Person names and postal addresses are deliberately out of scope:
24
+ // they need an NER model that a client SDK cannot ship, and naive patterns would
25
+ // over-redact ordinary prose. Patterns are ordered so an earlier pass never eats
26
+ // digits a later pass needs (email before phone, IPs before phone, cards before
27
+ // the generic phone pass). See `redactPii`.
28
+ // Horizontal Unicode spaces (NBSP, narrow NBSP, ideographic space, ...) are what
29
+ // appear when text is copied from web pages or PDFs. `redactPii` normalizes them
30
+ // to an ASCII space first so the separator-based card/phone/SSN candidates match
31
+ // them instead of leaking the identifier they group.
32
+ const UNICODE_SPACE_PATTERN = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g
33
+ // Quantifiers are bounded to RFC-ish limits (local-part <=64, domain <=255,
34
+ // TLD <=24) rather than open-ended `+`. Unbounded `+` here is quadratic: on a
35
+ // long run of local-part chars with no valid `.tld`, `replace` rescans from
36
+ // every start position. `$mcp_intent` is attacker-influenceable free text seen
37
+ // before truncation, so an open-ended pattern is a reachable event-loop stall.
38
+ const EMAIL_PATTERN = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
39
+ const IPV4_PATTERN = /\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g
40
+ // Four forms: full 8-group, `::`-terminated (`2001:db8::`), a middle `::`
41
+ // (`2001:db8::8a2e:1`), and a leading `::` (`::1`). The compressed branches use a
42
+ // `(?<![\w:])` boundary so a hex-looking C++ scope like `std::bad` — whose left
43
+ // side is not a valid hex group — is not mistaken for an address, while a
44
+ // genuinely address-shaped `dead::beef` still matches.
45
+ const IPV6_PATTERN =
46
+ /\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b|(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,7}:(?![\w:])|(?<![\w:])(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5}(?![\w])|(?<![\w:])::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})(?![\w])/g
47
+ // A separator (space, dot, or dash) is required between the 3-2-4 groups so bare
48
+ // 9-digit IDs are never mistaken for an SSN.
49
+ const US_SSN_PATTERN = /\b\d{3}[ .-]\d{2}[ .-]\d{4}\b/g
50
+ // A run of >=13 digits optionally grouped by a single space, dot, dash, or
51
+ // slash. This only marks the numeric region; `redactCardInMatch` then looks for
52
+ // the actual card as a run of whole separator-delimited groups that passes Luhn,
53
+ // so an adjacent field such as an expiry (`4111 1111 1111 1111 12/30`) is not
54
+ // absorbed into a failing check that would leak the card.
55
+ const CREDIT_CARD_CANDIDATE_PATTERN = /\b\d(?:[ ./-]?\d){12,}\b/g
56
+ // Matches each separator-delimited digit group inside a card candidate.
57
+ const DIGIT_GROUP_PATTERN = /\d+/g
58
+ // Phone matching is structural rather than "any 10–15 digits", so dates
59
+ // (`2024-01-15 12:30`) and dotted versions are not mistaken for numbers. Two
60
+ // forms: a North-American 3-3-4 grouping, and an international number that must
61
+ // start with `+` and a country code. The area code is either `(415)` (the
62
+ // separator after it is optional, so `(415)555-0142` matches) or a bare `415`
63
+ // that must be followed by a separator (space, dot, dash, or slash) — so a bare
64
+ // digit run is never taken for a phone number.
65
+ const PHONE_NANP_PATTERN = /(?<![\w+])(?:\+?1[ ./-]?)?(?:\(\d{3}\)[ ./-]?|\d{3}[ ./-])\d{3}[ ./-]\d{4}(?![\w])/g
66
+ const PHONE_INTL_PATTERN = /(?<!\w)\+\d{1,3}(?:[ ./()-]{0,2}\d){7,13}(?![\w])/g
67
+
19
68
  type JsonRecord = Record<string, unknown>
20
69
 
21
70
  function isRecord(value: unknown): value is JsonRecord {
@@ -54,6 +103,86 @@ function sanitizeString(value: string): string {
54
103
  return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
55
104
  }
56
105
 
106
+ function passesLuhn(digits: string): boolean {
107
+ let sum = 0
108
+ let double = false
109
+ for (let index = digits.length - 1; index >= 0; index--) {
110
+ let digit = digits.charCodeAt(index) - 48
111
+ if (digit < 0 || digit > 9) {
112
+ return false
113
+ }
114
+ if (double) {
115
+ digit *= 2
116
+ if (digit > 9) {
117
+ digit -= 9
118
+ }
119
+ }
120
+ sum += digit
121
+ double = !double
122
+ }
123
+ return sum % 10 === 0
124
+ }
125
+
126
+ // A card candidate can span more than one card plus adjacent fields (e.g. an
127
+ // expiry, or a second card). This redacts every card in it: scanning
128
+ // left-to-right over the whole separator-delimited digit groups, at each start it
129
+ // takes the longest run whose joined digits are 13–19 long and pass Luhn, redacts
130
+ // just that span, and resumes after it; a group that begins no valid run is kept
131
+ // and skipped. Checking group-aligned runs rather than arbitrary digit windows
132
+ // keeps the false-positive rate at Luhn's own ~1-in-10, instead of letting a
133
+ // chance-valid sub-window of an ordinary long ID trigger redaction.
134
+ function redactCardInMatch(match: string): string {
135
+ const groups: { digits: string; start: number; end: number }[] = []
136
+ for (let m = DIGIT_GROUP_PATTERN.exec(match); m !== null; m = DIGIT_GROUP_PATTERN.exec(match)) {
137
+ groups.push({ digits: m[0], start: m.index, end: m.index + m[0].length })
138
+ }
139
+ let output = ''
140
+ let cursor = 0
141
+ let first = 0
142
+ while (first < groups.length) {
143
+ let digits = ''
144
+ let matchedLast = -1
145
+ for (let last = first; last < groups.length; last++) {
146
+ digits += groups[last].digits
147
+ if (digits.length > 19) {
148
+ break
149
+ }
150
+ if (digits.length >= 13 && passesLuhn(digits)) {
151
+ matchedLast = last
152
+ }
153
+ }
154
+ if (matchedLast >= 0) {
155
+ output += match.slice(cursor, groups[first].start) + REDACTED_VALUE
156
+ cursor = groups[matchedLast].end
157
+ first = matchedLast + 1
158
+ } else {
159
+ first++
160
+ }
161
+ }
162
+ return output + match.slice(cursor)
163
+ }
164
+
165
+ /**
166
+ * Redacts structured personal identifiers (emails, IP addresses, credit-card
167
+ * numbers, US SSNs, and phone numbers) from a free-text string. Intended for the
168
+ * agent-narrated `$mcp_intent` value only — not for structured tool parameters or
169
+ * responses, where the same shapes are often legitimate data. Horizontal Unicode
170
+ * spaces are first normalized to an ASCII space so copy-pasted identifiers still
171
+ * match. Returns a new string; leaves the input's identifiers untouched when
172
+ * nothing matches.
173
+ */
174
+ export function redactPii(value: string): string {
175
+ let result = value.replace(UNICODE_SPACE_PATTERN, ' ')
176
+ result = result.replace(EMAIL_PATTERN, REDACTED_VALUE)
177
+ result = result.replace(IPV4_PATTERN, REDACTED_VALUE)
178
+ result = result.replace(IPV6_PATTERN, REDACTED_VALUE)
179
+ result = result.replace(CREDIT_CARD_CANDIDATE_PATTERN, redactCardInMatch)
180
+ result = result.replace(US_SSN_PATTERN, REDACTED_VALUE)
181
+ result = result.replace(PHONE_NANP_PATTERN, REDACTED_VALUE)
182
+ result = result.replace(PHONE_INTL_PATTERN, REDACTED_VALUE)
183
+ return result
184
+ }
185
+
57
186
  export function sanitizeCapturedValue(value: unknown): unknown {
58
187
  if (value == null) {
59
188
  return value
@@ -74,7 +74,10 @@ export function addModelParameterToTools<TTool extends ModelInjectableTool>(
74
74
  * not become a property value queries would group by.
75
75
  */
76
76
  export function getModelArgument(request: MCPRequestLike): string | undefined {
77
- const model = request.params?.arguments?.llm_model
77
+ return normalizeModel(request.params?.arguments?.llm_model)
78
+ }
79
+
80
+ function normalizeModel(model: unknown): string | undefined {
78
81
  if (typeof model !== 'string') {
79
82
  return undefined
80
83
  }
@@ -86,9 +89,10 @@ export function getModelArgument(request: MCPRequestLike): string | undefined {
86
89
  }
87
90
 
88
91
  export function setEventModel(event: McpEvent, model: string | undefined): void {
89
- if (!model) {
92
+ const normalizedModel = normalizeModel(model)
93
+ if (!normalizedModel) {
90
94
  return
91
95
  }
92
- event.llmModel = model
96
+ event.llmModel = normalizedModel
93
97
  event.llmModelSource = 'self_reported'
94
98
  }
@@ -3,14 +3,17 @@ import { PostHog, type PostHogOptions } from 'posthog-node'
3
3
  import type {
4
4
  InitializeCaptureData,
5
5
  JsonRecord,
6
+ MCPAnalyticsOptions,
6
7
  McpCaptureCommon,
7
8
  McpEvent,
8
9
  MissingCapabilityCaptureData,
9
10
  PreparedToolCall,
11
+ PrepareToolCallOptions,
10
12
  PrepareToolListOptions,
11
13
  ToolCallCaptureData,
12
14
  ToolsListCaptureData,
13
15
  } from '../types'
16
+ import { analyticsOwnsParameter, stripOwnedAnalyticsArguments } from './analytics-parameters'
14
17
  import {
15
18
  addContextParameterToTools,
16
19
  getContextDescription,
@@ -22,6 +25,13 @@ import { captureException } from './exceptions'
22
25
  import { normalizeHeaderString } from './headers'
23
26
  import { applyMcpLibIdentity } from './lib-identity'
24
27
  import { log } from './logger'
28
+ import {
29
+ addModelParameterToTool,
30
+ getModelArgument,
31
+ getModelDescription,
32
+ isCaptureModelEnabled,
33
+ setEventModel,
34
+ } from './model-parameters'
25
35
  import { McpEventSink } from './sink'
26
36
  import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
27
37
 
@@ -37,6 +47,11 @@ export interface PostHogMCPOptions extends PostHogOptions {
37
47
  * can't drift. Defaults to `get_more_tools`.
38
48
  */
39
49
  missingCapabilityToolName?: string
50
+ /**
51
+ * Inject a required `llm_model` argument into advertised tools and capture
52
+ * the agent's self-reported value. Off by default.
53
+ */
54
+ captureModel?: MCPAnalyticsOptions['captureModel']
40
55
  }
41
56
 
42
57
  /**
@@ -57,7 +72,10 @@ export interface PostHogMCPOptions extends PostHogOptions {
57
72
  * ```ts
58
73
  * import { PostHogMCP } from "@posthog/mcp"
59
74
  *
60
- * const posthog = new PostHogMCP("phc_your_project_token", { host: "https://us.i.posthog.com" })
75
+ * const posthog = new PostHogMCP("phc_your_project_token", {
76
+ * host: "https://us.i.posthog.com",
77
+ * captureModel: true,
78
+ * })
61
79
  *
62
80
  * posthog.captureToolCall({
63
81
  * toolName: "search_docs",
@@ -78,10 +96,13 @@ export class PostHogMCP extends PostHog {
78
96
  // The get_more_tools name lives here (not on the per-call options) so that
79
97
  // prepareToolList (inject) and prepareToolCall (detect) always agree.
80
98
  readonly #missingCapabilityToolName: string
99
+ readonly #captureModel: MCPAnalyticsOptions['captureModel']
100
+ readonly #modelParameterOwnership = new Map<string, boolean>()
81
101
 
82
102
  constructor(apiKey: string, options: PostHogMCPOptions = {}) {
83
103
  super(apiKey, options)
84
104
  this.#missingCapabilityToolName = options.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
105
+ this.#captureModel = options.captureModel
85
106
  applyMcpLibIdentity(this)
86
107
  }
87
108
 
@@ -97,6 +118,10 @@ export class PostHogMCP extends PostHog {
97
118
  event.isError = data.isError
98
119
  event.errorType = data.errorType
99
120
  applyIntent(event, data.intent, data.intentSource)
121
+ setEventModel(event, data.llmModel)
122
+ if (event.llmModel && data.llmModelSource) {
123
+ event.llmModelSource = data.llmModelSource
124
+ }
100
125
  if (data.isError) {
101
126
  event.error = captureException(data.error ?? `Tool ${data.toolName} returned an error`)
102
127
  }
@@ -137,9 +162,9 @@ export class PostHogMCP extends PostHog {
137
162
  /**
138
163
  * Decorate your `tools/list` response with PostHog's analytics affordances:
139
164
  * injects the `context` argument into every tool (so agents state their intent,
140
- * captured as `$mcp_intent`) and, when `reportMissing` is on, appends the
141
- * `get_more_tools` virtual tool (rename it via the `missingCapabilityToolName`
142
- * constructor option). Returns a new array; your tools are untouched.
165
+ * captured as `$mcp_intent`), injects `llm_model` when the constructor's
166
+ * `captureModel` option is enabled, and appends `get_more_tools` when
167
+ * `reportMissing` is on. Returns a new array; your tools are untouched.
143
168
  *
144
169
  * The appended `get_more_tools` descriptor carries only the base MCP tool fields
145
170
  * (name, description, input schema) — not any framework-specific fields your
@@ -165,18 +190,41 @@ export class PostHogMCP extends PostHog {
165
190
  if (options.reportMissing && !prepared.some((tool) => tool?.name === this.#missingCapabilityToolName)) {
166
191
  prepared = [...prepared, getReportMissingToolDescriptor(this.#missingCapabilityToolName) as TTool]
167
192
  }
193
+
194
+ if (isCaptureModelEnabled(this.#captureModel)) {
195
+ this.#modelParameterOwnership.clear()
196
+ const ownershipByName = new Map<string, boolean>()
197
+ for (const tool of prepared) {
198
+ if (typeof tool.name !== 'string') {
199
+ continue
200
+ }
201
+ const ownsModel = analyticsOwnsParameter(tool.inputSchema, 'llm_model')
202
+ ownershipByName.set(tool.name, (ownershipByName.get(tool.name) ?? true) && ownsModel)
203
+ }
204
+ for (const [toolName, ownsModel] of ownershipByName) {
205
+ this.#modelParameterOwnership.set(toolName, ownsModel)
206
+ }
207
+ const modelDescription = getModelDescription(this.#captureModel)
208
+ prepared = prepared.map((tool) =>
209
+ typeof tool.name === 'string' && ownershipByName.get(tool.name) === false
210
+ ? tool
211
+ : addModelParameterToTool(tool, modelDescription)
212
+ )
213
+ }
168
214
  return prepared
169
215
  }
170
216
 
171
217
  /**
172
- * Read an incoming `tools/call` before you dispatch it: pulls the agent's
173
- * intent off the injected `context` argument, strips `context` from the
174
- * arguments (so your handler and its schema validation never see it), and flags
218
+ * Read an incoming `tools/call` before you dispatch it: pulls analytics values
219
+ * from SDK-owned arguments, strips those arguments before validation, and flags
175
220
  * whether the call targeted the `get_more_tools` virtual tool.
176
221
  *
177
- * Pass the returned `intent` / `intentSource` to {@link captureToolCall}, and
222
+ * Pass the returned intent and model fields to {@link captureToolCall}, and
178
223
  * dispatch the returned `args` to your tool.
179
224
  *
225
+ * On stateless or multi-replica servers, pass the original tool descriptor
226
+ * so ownership does not depend on which process served `tools/list`.
227
+ *
180
228
  * This only extracts the explicit `context` argument (`intentSource:
181
229
  * 'context_parameter'`); it does not infer intent. If you run your own
182
230
  * inference, pass that string with `intentSource: 'inferred'` straight to
@@ -185,22 +233,42 @@ export class PostHogMCP extends PostHog {
185
233
  *
186
234
  * @example
187
235
  * ```ts
188
- * const { intent, intentSource, args, isMissingCapability } = posthog.prepareToolCall(name, rawArgs)
236
+ * const { intent, intentSource, llmModel, llmModelSource, args, isMissingCapability } =
237
+ * posthog.prepareToolCall(name, rawArgs)
189
238
  * if (isMissingCapability) {
190
- * posthog.captureMissingCapability({ context: intent, ...identity })
239
+ * posthog.captureMissingCapability({ context: intent, llmModel, llmModelSource, ...identity })
191
240
  * return getMoreToolsResult()
192
241
  * }
193
242
  * const result = await runTool(name, args)
194
243
  * posthog.captureToolCall({ toolName: name, intent, intentSource, ...identity })
195
244
  * ```
196
245
  */
197
- prepareToolCall(name: string, args?: Record<string, unknown>): PreparedToolCall {
246
+ prepareToolCall(
247
+ name: string,
248
+ args?: Record<string, unknown>,
249
+ options: PrepareToolCallOptions = {}
250
+ ): PreparedToolCall {
198
251
  const rawContext = args?.context
199
252
  const intent = typeof rawContext === 'string' && rawContext.trim() ? rawContext.trim() : undefined
253
+ const ownsModel =
254
+ isCaptureModelEnabled(this.#captureModel) &&
255
+ (options.originalTool
256
+ ? analyticsOwnsParameter(options.originalTool.inputSchema, 'llm_model')
257
+ : this.#modelParameterOwnership.get(name) === true)
258
+ const llmModel = ownsModel ? getModelArgument({ params: { arguments: args } }) : undefined
259
+ const strippedArgs = stripContext(args)
200
260
  return {
201
261
  intent,
202
262
  intentSource: intent ? 'context_parameter' : undefined,
203
- args: stripContext(args),
263
+ llmModel,
264
+ llmModelSource: llmModel ? 'self_reported' : undefined,
265
+ args: ownsModel
266
+ ? (stripOwnedAnalyticsArguments(strippedArgs, {
267
+ context: false,
268
+ conversationId: false,
269
+ llmModel: true,
270
+ }) as Record<string, unknown> | undefined)
271
+ : strippedArgs,
204
272
  isMissingCapability: name === this.#missingCapabilityToolName,
205
273
  }
206
274
  }
@@ -215,6 +283,10 @@ export class PostHogMCP extends PostHog {
215
283
  event.resourceName = this.#missingCapabilityToolName
216
284
  event.parameters = data.parameters
217
285
  applyIntent(event, data.context, 'context_parameter')
286
+ setEventModel(event, data.llmModel)
287
+ if (event.llmModel && data.llmModelSource) {
288
+ event.llmModelSource = data.llmModelSource
289
+ }
218
290
  this.#emit(event)
219
291
  }
220
292
 
@@ -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 } from './mcp-payloads'
7
+ import { redactPii, sanitizeCapturedValue } from './mcp-payloads'
8
8
 
9
9
  type SanitizedRecord = Record<string, unknown>
10
10
 
@@ -15,8 +15,8 @@ function isRecord(value: unknown): value is SanitizedRecord {
15
15
  /**
16
16
  * Sanitizes an event by redacting non-text content blocks from responses
17
17
  * and large base64-encoded strings from parameters, and applying the same
18
- * string redaction (PostHog tokens, base64 blobs, sensitive keys) to the
19
- * agent-supplied intent.
18
+ * string redaction (PostHog tokens, base64 blobs, sensitive keys) to values
19
+ * supplied by the agent.
20
20
  *
21
21
  * This is a synchronous operation that returns a new object without mutating the original.
22
22
  * It should run after customer redaction in the event pipeline.
@@ -32,11 +32,18 @@ export function sanitizeEvent<T extends Event | McpEvent>(event: T): T {
32
32
  result.parameters = sanitizeParameters(result.parameters)
33
33
  }
34
34
 
35
- // The intent comes straight from an agent-narrated `context` string, so it
36
- // can contain a secret the LLM read aloud. Redact it like any other captured
37
- // value rather than shipping it raw as `$mcp_intent`.
35
+ // The intent comes straight from an agent-narrated `context` string, so it can
36
+ // contain a secret the LLM read aloud or personal data it narrated about the
37
+ // user. Redact it like any other captured value, then strip structured PII
38
+ // (emails, phone numbers, IPs, cards, SSNs) rather than shipping it raw as
39
+ // `$mcp_intent`. PII redaction is scoped to the intent only — structured tool
40
+ // parameters and responses often hold the same shapes as legitimate data.
38
41
  if (result.userIntent != null) {
39
- result.userIntent = sanitizeCapturedValue(result.userIntent) as string
42
+ result.userIntent = redactPii(sanitizeCapturedValue(result.userIntent) as string)
43
+ }
44
+
45
+ if (result.llmModel != null) {
46
+ result.llmModel = sanitizeCapturedValue(result.llmModel) as string
40
47
  }
41
48
 
42
49
  if (result.error != null) {
package/src/index.ts CHANGED
@@ -233,6 +233,7 @@ export type {
233
233
  MCPAnalyticsOptions,
234
234
  MissingCapabilityCaptureData,
235
235
  PreparedToolCall,
236
+ PrepareToolCallOptions,
236
237
  PrepareToolListOptions,
237
238
  RequestHeaderBag,
238
239
  ToolCallCaptureData,
package/src/types.ts CHANGED
@@ -511,6 +511,13 @@ export interface ToolCallCaptureData extends McpCaptureCommon {
511
511
  * the host derived it. Defaults to `context_parameter` when an intent is set.
512
512
  */
513
513
  intentSource?: MCPAnalyticsIntentSource
514
+ /**
515
+ * The calling agent's self-reported model id -> `$mcp_llm_model`. On the
516
+ * custom-dispatcher path, read it from {@link PostHogMCP.prepareToolCall}.
517
+ */
518
+ llmModel?: string
519
+ /** How the model id was obtained -> `$mcp_llm_model_source`. */
520
+ llmModelSource?: MCPAnalyticsModelSource
514
521
  /** Captured call arguments → `$mcp_parameters` (sanitized + truncated). */
515
522
  parameters?: unknown
516
523
  /** Captured tool result → `$mcp_response` (sanitized + truncated). */
@@ -586,18 +593,30 @@ export interface PrepareToolListOptions {
586
593
  reportMissing?: boolean
587
594
  }
588
595
 
596
+ /** Options for {@link PostHogMCP.prepareToolCall}. */
597
+ export interface PrepareToolCallOptions {
598
+ /**
599
+ * The tool descriptor before PostHog preparation. Pass this on stateless or
600
+ * multi-replica servers so SDK argument ownership is resolved per request.
601
+ */
602
+ originalTool?: { inputSchema?: unknown }
603
+ }
604
+
589
605
  /**
590
606
  * Result of {@link PostHogMCP.prepareToolCall}: the intent pulled off the
591
- * incoming call, the arguments with the injected `context` removed (so your tool
592
- * handler and its schema validation never see it), and whether the call targeted
593
- * the `get_more_tools` virtual tool.
607
+ * incoming call, the arguments with SDK-owned analytics fields removed, and
608
+ * whether the call targeted the `get_more_tools` virtual tool.
594
609
  */
595
610
  export interface PreparedToolCall {
596
611
  /** The agent's stated intent (the `context` argument), if present. */
597
612
  intent?: string
598
613
  /** Where the intent came from. Always `context_parameter` here when set. */
599
614
  intentSource?: MCPAnalyticsIntentSource
600
- /** The call arguments with the injected `context` key stripped out. */
615
+ /** The agent's self-reported model id, when `captureModel` is enabled and owned by the SDK. */
616
+ llmModel?: string
617
+ /** How the model id was obtained. Always `self_reported` when present. */
618
+ llmModelSource?: MCPAnalyticsModelSource
619
+ /** The call arguments with SDK-owned `context` and `llm_model` keys removed. */
601
620
  args?: Record<string, unknown>
602
621
  /** True when `name` is the `get_more_tools` virtual tool. */
603
622
  isMissingCapability: boolean
@@ -610,6 +629,10 @@ export interface MissingCapabilityCaptureData extends McpCaptureCommon {
610
629
  * on the `get_more_tools` call) → `$mcp_intent`.
611
630
  */
612
631
  context?: string
632
+ /** The calling agent's self-reported model id -> `$mcp_llm_model`. */
633
+ llmModel?: string
634
+ /** How the model id was obtained -> `$mcp_llm_model_source`. */
635
+ llmModelSource?: MCPAnalyticsModelSource
613
636
  /** Captured call arguments → `$mcp_parameters` (sanitized + truncated). */
614
637
  parameters?: unknown
615
638
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.12.0'
1
+ export const version = '0.13.1'