@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
@@ -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
+ // Deliberately no leading `\b`: `_` is a word character but not scheme-legal, so
20
+ // `resource_https://user:pw@host` has no boundary to anchor to and would keep its
21
+ // credentials. Leftmost matching makes `foo.https://x` match from `f` with scheme
22
+ // `foo.https`, which redacts the same URL and is therefore harmless.
23
+ //
24
+ // `'` is a valid URI sub-delimiter, so the terminal class must not stop at one:
25
+ // excluding it truncated `https://example.com/o'reilly?token=x` at the path and
26
+ // shipped the token in the clear. Only the characters that are never valid
27
+ // unencoded in a URI are excluded.
28
+ //
29
+ // The authority is optional too. An MCP resource URI frequently has none
30
+ // (`resource:guide?token=x`, `file:/guide.md`), and requiring `//` let those
31
+ // through untouched; `[^\s<>"]+` absorbs a `//host` when there is one. The cost
32
+ // is over-matching ordinary prose (`Error:foo`, `at12:30`, `C:\path`), which is
33
+ // harmless: a match with nothing to redact is returned byte-for-byte, so the
34
+ // text around it is never rewritten.
35
+ const URL_PATTERN = /[a-z][a-z0-9+.-]{0,63}:[^\s<>"]+/gi
36
+ /** The same pattern without `g`, for asking whether a value holds a URL at all. */
37
+ const URL_PATTERN_ONCE = new RegExp(URL_PATTERN.source, 'i')
38
+ /** Finds the first scheme that brings a real authority, i.e. what the pattern required before it went authority-less. */
39
+ const URL_AUTHORITY_SEARCH = /[a-z][a-z0-9+.-]{0,63}:\/\//i
40
+ /** The same, anchored: does this match *open* with an authority? */
41
+ const URL_AUTHORITY_PATTERN = new RegExp(`^${URL_AUTHORITY_SEARCH.source}`, 'i')
42
+ /** Every authority start in a value, in order. */
43
+ const URL_AUTHORITY_SEARCH_ALL = new RegExp(URL_AUTHORITY_SEARCH.source, 'gi')
44
+ /** The field separators. `?` and `#` are structural only as delimiters — see {@link isStructural}. */
45
+ const URL_FIELD_CHARACTERS = '=&'
46
+ /** The legacy field separator. Never split on, only checked; see {@link sanitizeUrlFieldValue}. */
47
+ const LEGACY_FIELD_SEPARATOR = ';'
48
+ // The terminal class above also absorbs the prose punctuation that follows a URL
49
+ // in a sentence, `'` included now that a URL can contain one. See
50
+ // `splitTrailingPunctuation`.
51
+ const URL_TRAILING_PUNCTUATION = ".,;:!?)]}'"
52
+ const MAX_URL_LENGTH = 8192
53
+ const MAX_URL_QUERY_FIELDS = 128
54
+ // A query key is sensitive when any `-`/`_`/`.`/`/`/`;`-delimited segment names
55
+ // a credential, so compound names (`private_token`, `oauth_signature`,
56
+ // `subscription-key`, `X-Amz-Security-Token`, `Key-Pair-Id`) are covered without
57
+ // enumerating every vendor's spelling. `/` is a separator because a fragment's
58
+ // field list can be written `#/token=…`, and `;` because a legacy `;`-separated
59
+ // pair parses into a single key (`download;token`) — either would otherwise hide
60
+ // the name. Over-redacting a benign `sort_key` — or `sort/key`, or `sort;key` —
61
+ // is the accepted trade for an analytics payload.
62
+ const SENSITIVE_QUERY_KEY_SEGMENT_PATTERN =
63
+ /(^|[-_./;])(auth|token|secret|password|passwd|pwd|credential|signature|sig|key|hmac|sas|bearer|jwt|session|sessionid)([-_./;]|$)/i
64
+ // `code` — the OAuth authorization code — is matched only as a whole key: as a
65
+ // segment it would eat `country_code`, `zip_code`, and `lang_code`.
66
+ const SENSITIVE_QUERY_KEY_EXACT_PATTERN = /^(code|AWSAccessKeyId|GoogleAccessId|Policy)$/i
67
+
19
68
  // PII redaction for the agent-narrated intent string only. `$mcp_intent` is free
20
69
  // text the calling LLM writes into the injected `context` argument, so it can
21
70
  // carry personal data the model read aloud despite being told not to. We redact
@@ -75,6 +124,12 @@ function shouldRedactKey(key: string): boolean {
75
124
  return SENSITIVE_KEY_PATTERN.test(key)
76
125
  }
77
126
 
127
+ function shouldRedactQueryKey(key: string): boolean {
128
+ return (
129
+ shouldRedactKey(key) || SENSITIVE_QUERY_KEY_SEGMENT_PATTERN.test(key) || SENSITIVE_QUERY_KEY_EXACT_PATTERN.test(key)
130
+ )
131
+ }
132
+
78
133
  function isBase64DataUrl(value: string): boolean {
79
134
  const prefix = BASE64_DATA_URL_PREFIX_PATTERN.exec(value)
80
135
  if (!prefix) {
@@ -91,16 +146,384 @@ function isBase64DataUrl(value: string): boolean {
91
146
  return BASE64_DATA_URL_PAYLOAD_PATTERN.test(payload.replace(/[\r\n]/g, ''))
92
147
  }
93
148
 
94
- function sanitizeString(value: string): string {
95
- if (
149
+ function exceedsUrlFieldLimit(fields: string): boolean {
150
+ return fields.split('&', MAX_URL_QUERY_FIELDS + 1).length > MAX_URL_QUERY_FIELDS
151
+ }
152
+
153
+ /** How much of the sanitizer one call is allowed to apply. */
154
+ interface UrlSanitizeMode {
155
+ /** Sanitize a URL found inside a retained field value. Off past depth 1. */
156
+ allowNestedUrls: boolean
157
+ /** Split prose punctuation off the end. Off when the match is a whole string. */
158
+ stripPunctuation: boolean
159
+ }
160
+
161
+ /** Whether any `;`-separated piece of `value` names a credential. */
162
+ function hasLegacySensitiveField(value: string): boolean {
163
+ return value.split(LEGACY_FIELD_SEPARATOR).some((piece) => shouldRedactQueryKey(piece.split('=', 1)[0]))
164
+ }
165
+
166
+ /** The sanitized value of one query or fragment field. */
167
+ function sanitizeUrlFieldValue(key: string, value: string, allowNestedUrls: boolean): string {
168
+ if (shouldRedactQueryKey(key)) {
169
+ return REDACTED_VALUE
170
+ }
171
+ // `;` once separated fields the way `&` does, so a value carrying one may hold
172
+ // a credential field inside it. Splitting on it is not an option — that cuts a
173
+ // credential's own value in two and publishes the tail as a bare key
174
+ // (`?password=prefix;rest`) — so instead the whole value goes when any piece
175
+ // of it names a credential.
176
+ if (value.includes(LEGACY_FIELD_SEPARATOR) && hasLegacySensitiveField(value)) {
177
+ return REDACTED_VALUE
178
+ }
179
+ if (!URL_PATTERN_ONCE.test(value)) {
180
+ return value
181
+ }
182
+ // One level of nesting is the whole budget. Past it a URL-bearing value is
183
+ // dropped rather than trusted: nothing deeper would sanitize it, so a gateway
184
+ // address wrapping a gateway address would otherwise ship its credentials.
185
+ return allowNestedUrls
186
+ ? sanitizeUrlsInString(value, { allowNestedUrls: false, stripPunctuation: true })
187
+ : REDACTED_VALUE
188
+ }
189
+
190
+ /**
191
+ * Redacts the values of credential-named fields in one `&`-separated field list
192
+ * — a query string or a fragment. `serialized` is only meaningful when `changed`
193
+ * is true, so an untouched part keeps its original encoding instead of being
194
+ * re-serialized. `lastFieldSensitive` says whether whatever follows the list
195
+ * could be more of a credential, which is what decides both the fragment tail
196
+ * and restored prose punctuation; see {@link sanitizeUrl}.
197
+ */
198
+ function sanitizeUrlFields(
199
+ fields: string,
200
+ allowNestedUrls: boolean
201
+ ): { changed: boolean; lastFieldSensitive: boolean; serialized: string } {
202
+ const sanitized = new URLSearchParams()
203
+ let changed = false
204
+ let lastFieldSensitive = false
205
+ for (const [key, value] of new URLSearchParams(fields)) {
206
+ const sanitizedValue = sanitizeUrlFieldValue(key, value, allowNestedUrls)
207
+ // `changed` is compared, not inferred from the key: the PostHog-token pass
208
+ // runs first, so a value can already read `[redacted]`, and calling that a
209
+ // change would re-serialize the field only to percent-encode it.
210
+ changed ||= sanitizedValue !== value
211
+ // Sensitivity is the separate question — whether what follows this field
212
+ // could be more of a credential — and an already-redacted value is still a
213
+ // credential's field.
214
+ lastFieldSensitive = shouldRedactQueryKey(key) || sanitizedValue !== value
215
+ sanitized.append(key, sanitizedValue)
216
+ }
217
+ return { changed, lastFieldSensitive, serialized: sanitized.toString() }
218
+ }
219
+
220
+ /**
221
+ * Splits the prose punctuation that follows a URL in a sentence off the end of a
222
+ * match, so it is not parsed as part of the address and can be re-appended
223
+ * verbatim to whatever the sanitizer returns.
224
+ *
225
+ * Walked back character by character rather than matched with a `$`-anchored
226
+ * pattern: a captured URI is attacker-influenceable, and on a long punctuation
227
+ * run that does not end the match a backtracking pattern is quadratic.
228
+ */
229
+ function splitTrailingPunctuation(value: string): { address: string; suffix: string } {
230
+ let end = value.length
231
+ while (end > 0 && URL_TRAILING_PUNCTUATION.includes(value[end - 1])) {
232
+ end--
233
+ }
234
+ return { address: value.slice(0, end), suffix: value.slice(end) }
235
+ }
236
+
237
+ /**
238
+ * A fragment's tail, given how its head came out.
239
+ *
240
+ * A `?` inside a credential looks exactly like the one between a route and its
241
+ * fields. So when the head's last field is a credential's, everything after that
242
+ * `?` may be the rest of its value — `#password=prefix?rest` — and the
243
+ * tail goes whole rather than being read on its own terms. An empty tail has
244
+ * nothing to hide and stays empty. Either way it counts as a rewritten trailing
245
+ * field, so prose punctuation after the URL goes with it for the same reason.
246
+ */
247
+ function sanitizeFragmentTail(
248
+ tail: string,
249
+ headLastFieldSensitive: boolean,
250
+ allowNestedUrls: boolean
251
+ ): { value: string; lastFieldSensitive: boolean } {
252
+ if (!headLastFieldSensitive) {
253
+ return sanitizeFragmentPart(tail, allowNestedUrls)
254
+ }
255
+ return { value: tail === '' ? '' : REDACTED_VALUE, lastFieldSensitive: true }
256
+ }
257
+
258
+ /**
259
+ * Splits a fragment at its first `?`. `tail` is null when there is none.
260
+ *
261
+ * Nothing is assumed about either part. Shape alone cannot tell a router's route
262
+ * from a field list — `#/docs/id=1?token=…` puts an `=` in the route, and
263
+ * `#/token=…&next=https://x/?page=1` puts a `/` and a `?` in a field list — so
264
+ * each part is read as fields when it holds an `=` and as text otherwise.
265
+ */
266
+ function splitFragmentParts(hash: string): { head: string; tail: string | null } {
267
+ const fragment = hash.slice(1)
268
+ const separator = fragment.indexOf('?')
269
+ if (separator < 0) {
270
+ return { head: fragment, tail: null }
271
+ }
272
+ return { head: fragment.slice(0, separator), tail: fragment.slice(separator + 1) }
273
+ }
274
+
275
+ /** One fragment part, and whether its last field was rewritten. */
276
+ function sanitizeFragmentPart(part: string, allowNestedUrls: boolean): { value: string; lastFieldSensitive: boolean } {
277
+ if (!part.includes('=')) {
278
+ return { value: sanitizeFragmentText(part, allowNestedUrls), lastFieldSensitive: false }
279
+ }
280
+ const fields = sanitizeUrlFields(part, allowNestedUrls)
281
+ // Verbatim unless something was actually rewritten: only the part that changed
282
+ // should change encoding.
283
+ return { value: fields.changed ? fields.serialized : part, lastFieldSensitive: fields.lastFieldSensitive }
284
+ }
285
+
286
+ /**
287
+ * Sanitizes the part of a fragment that is text rather than fields — a plain
288
+ * `#intro`, or the route in front of a field list. It is text, and text can
289
+ * carry an address, so it gets the URL pass.
290
+ *
291
+ * Depth is capped the way a field value's is: at the nested level a URL-bearing
292
+ * text is dropped rather than descended into. That is also what stops
293
+ * `resource:x#resource:x#…` from recursing once per `#`.
294
+ */
295
+ function sanitizeFragmentText(text: string, allowNestedUrls: boolean): string {
296
+ if (!allowNestedUrls) {
297
+ return URL_PATTERN_ONCE.test(text) ? REDACTED_VALUE : text
298
+ }
299
+ return sanitizeUrlsInString(text, { allowNestedUrls: false, stripPunctuation: true })
300
+ }
301
+
302
+ /**
303
+ * Every offset inside `value` where an adjacent address starts; see
304
+ * {@link sanitizeUrl}.
305
+ *
306
+ * An authority is in *value position* — left alone, because the field pass
307
+ * already gives it the nested pass — when a field name's `=` is the nearest
308
+ * structural character behind it. Structural means a field separator (`=`, `&`,
309
+ * `;`) or one of the URL's three delimiters; see {@link isDelimiter}. Before any
310
+ * delimiter no `=` has been seen, so `…/redirect=https://user:pw@host` is two
311
+ * addresses rather than one field.
312
+ *
313
+ * Decided in one forward walk, not a backward scan per authority. The scan was
314
+ * quadratic on a value that opens its fields once and then runs thousands of
315
+ * addresses together, because every one of them scanned back to the same `?`.
316
+ */
317
+ function findEmbeddedAuthorityIndexes(value: string): number[] {
318
+ const starts = [...value.matchAll(URL_AUTHORITY_SEARCH_ALL)].map((match) => match.index)
319
+ const indexes: number[] = []
320
+ const delimiters = { query: false, fragment: false, fragmentQuery: false }
321
+ let pending = 0
322
+ let inFields = false
323
+ let lastStructural = ''
324
+ for (let cursor = 0; cursor < value.length && pending < starts.length; cursor++) {
325
+ if (starts[pending] === cursor) {
326
+ if (cursor > 0 && lastStructural !== '=') {
327
+ indexes.push(cursor)
328
+ }
329
+ pending++
330
+ }
331
+ const character = value[cursor]
332
+ if (isDelimiter(character, delimiters, lastStructural)) {
333
+ lastStructural = character
334
+ inFields = true
335
+ } else if (inFields && URL_FIELD_CHARACTERS.includes(character)) {
336
+ lastStructural = character
337
+ }
338
+ }
339
+ return indexes
340
+ }
341
+
342
+ /**
343
+ * Whether this `?` or `#` is one of a URL's three delimiters, consuming it if so.
344
+ *
345
+ * A URL has exactly one query delimiter, one fragment delimiter, and — matching
346
+ * how the fragment is split — one delimiter between the fragment's own head and
347
+ * tail. Every other `?` or `#` is ordinary text inside a value, and reading one
348
+ * as structural would cut `?token=prefix?https://secret/…` in half at a `?` that
349
+ * belongs to the token.
350
+ */
351
+ function isDelimiter(
352
+ character: string,
353
+ seen: { query: boolean; fragment: boolean; fragmentQuery: boolean },
354
+ lastStructural: string
355
+ ): boolean {
356
+ if (character === '#') {
357
+ return seen.fragment ? false : (seen.fragment = true)
358
+ }
359
+ if (character !== '?') {
360
+ return false
361
+ }
362
+ if (seen.fragment) {
363
+ // Inside a fragment, a `?` right after a field name may belong to that
364
+ // field's value rather than open the tail. Leaving it unstructural keeps a
365
+ // following address attached, so the fragment split and its fail-closed tail
366
+ // rule get to decide — they can see the whole credential, this cannot.
367
+ if (lastStructural === '=') {
368
+ return false
369
+ }
370
+ return seen.fragmentQuery ? false : (seen.fragmentQuery = true)
371
+ }
372
+ return seen.query ? false : (seen.query = true)
373
+ }
374
+
375
+ /**
376
+ * Redacts the credentials embedded in one URL-shaped match: the userinfo, plus
377
+ * the values of credential-named query and fragment fields. A retained value that
378
+ * is itself a URL — a gateway's `?url=` passthrough — gets the same pass one
379
+ * level deep.
380
+ *
381
+ * One match can hold more than one address: a prose word in front of it
382
+ * (`Failed URL:https://…`, `a:b:https://…`), several run together
383
+ * (`…/doc,https://…`), or one parked in another's query or fragment
384
+ * (`…/?download)[b](https://…`). The later address always begins inside what
385
+ * would parse as the earlier one's path, query key, or fragment — none of which
386
+ * is examined for userinfo — so it is cut out and sanitized on its own.
387
+ *
388
+ * The exception is an authority in value position (see
389
+ * {@link findEmbeddedAuthorityIndexes}), which the field pass already gives the
390
+ * nested pass. Cutting there would take a gateway's `?url=https://…` out of the
391
+ * field it belongs to.
392
+ *
393
+ * One pass, no recursion: after the cuts, every authority left inside a piece is
394
+ * either at its start or in value position, so no piece can need cutting again.
395
+ *
396
+ * Every piece but the last is sanitized without the punctuation split: it is
397
+ * followed by an address rather than by prose, so its last character is a
398
+ * separator, not a sentence's. (It matters — stripping the `:` off `URL:` would
399
+ * leave `URL`, which `new URL()` rejects, and the prose word would become
400
+ * `[redacted]`.)
401
+ */
402
+ function sanitizeUrl(value: string, mode: UrlSanitizeMode): string {
403
+ const embedded = findEmbeddedAuthorityIndexes(value)
404
+ if (embedded.length === 0) {
405
+ return sanitizeSingleUrl(value, mode)
406
+ }
407
+ let result = ''
408
+ let start = 0
409
+ for (const index of embedded) {
410
+ result += sanitizeSingleUrl(value.slice(start, index), { ...mode, stripPunctuation: false })
411
+ start = index
412
+ }
413
+ return result + sanitizeSingleUrl(value.slice(start), mode)
414
+ }
415
+
416
+ /** {@link sanitizeUrl} for a value already known to hold exactly one address. */
417
+ function sanitizeSingleUrl(value: string, mode: UrlSanitizeMode): string {
418
+ // The length bound caps the work an attacker-shaped address can force, so it
419
+ // only applies to a match that has an authority. A long authority-less match
420
+ // is usually a data URI; it is parsed like any other, which is linear in
421
+ // `new URL()` and still capped by the field bound below, and comes back
422
+ // byte-for-byte when it holds nothing to redact.
423
+ if (value.length > MAX_URL_LENGTH && URL_AUTHORITY_PATTERN.test(value)) {
424
+ return REDACTED_VALUE
425
+ }
426
+
427
+ const { address, suffix } = mode.stripPunctuation ? splitTrailingPunctuation(value) : { address: value, suffix: '' }
428
+ let url: URL
429
+ try {
430
+ url = new URL(address)
431
+ } catch {
432
+ return REDACTED_VALUE + suffix
433
+ }
434
+
435
+ const query = url.search.slice(1)
436
+ const hasFragment = url.hash !== ''
437
+ const fragment = splitFragmentParts(url.hash)
438
+ if (exceedsUrlFieldLimit(query) || exceedsUrlFieldLimit(fragment.head) || exceedsUrlFieldLimit(fragment.tail ?? '')) {
439
+ return REDACTED_VALUE + suffix
440
+ }
441
+
442
+ let changed = false
443
+ if (url.username || url.password) {
444
+ url.username = REDACTED_VALUE
445
+ url.password = ''
446
+ changed = true
447
+ }
448
+ const sanitizedQuery = sanitizeUrlFields(query, mode.allowNestedUrls)
449
+ if (sanitizedQuery.changed) {
450
+ url.search = sanitizedQuery.serialized
451
+ changed = true
452
+ }
453
+ const sanitizedHead = sanitizeFragmentPart(fragment.head, mode.allowNestedUrls)
454
+ const sanitizedTail =
455
+ fragment.tail === null
456
+ ? null
457
+ : sanitizeFragmentTail(fragment.tail, sanitizedHead.lastFieldSensitive, mode.allowNestedUrls)
458
+ const sanitizedHash = sanitizedTail === null ? sanitizedHead.value : `${sanitizedHead.value}?${sanitizedTail.value}`
459
+ if (sanitizedHash !== url.hash.slice(1)) {
460
+ url.hash = sanitizedHash
461
+ changed = true
462
+ }
463
+
464
+ // The punctuation split off the end may be the tail of the very credential
465
+ // just replaced (`?password=fakepass!!!`) rather than the sentence's. When the
466
+ // last field of the URL's trailing part is a credential's the punctuation goes
467
+ // with it; losing a comma from the surrounding prose is the accepted cost of
468
+ // not shipping `!!!`.
469
+ const trailingFields = hasFragment ? (sanitizedTail ?? sanitizedHead) : sanitizedQuery
470
+ return (changed ? url.toString() : address) + (trailingFields.lastFieldSensitive ? '' : suffix)
471
+ }
472
+
473
+ /**
474
+ * Replaces every URL-shaped match in `text`.
475
+ *
476
+ * A match that IS the whole string is an address rather than prose — a captured
477
+ * `$mcp_resource_name`, a `params.uri`, a nested `?url=` value — so nothing is
478
+ * split off its end: a trailing `!` there belongs to the URL.
479
+ */
480
+ function sanitizeUrlsInString(text: string, mode: UrlSanitizeMode): string {
481
+ return text.replace(URL_PATTERN, (match) =>
482
+ sanitizeUrl(match, { ...mode, stripPunctuation: mode.stripPunctuation && match.length !== text.length })
483
+ )
484
+ }
485
+
486
+ /** Whether a string is large enough, and shaped enough like base64, to be a blob. */
487
+ function isBinaryBlob(value: string): boolean {
488
+ return (
96
489
  value.length >= SIZE_GATE &&
97
490
  (BASE64_PATTERN.test(value) ||
98
491
  isBase64DataUrl(value) ||
99
492
  (BASE64URL_SPECIFIC_CHAR_PATTERN.test(value) && BASE64URL_PATTERN.test(value)))
100
- ) {
493
+ )
494
+ }
495
+
496
+ /** Replaces PostHog API keys wherever they appear in a string. */
497
+ function redactCredentials(value: string): string {
498
+ return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
499
+ }
500
+
501
+ /** Replaces every URL-shaped match, credentials and all. */
502
+ function redactUrls(value: string): string {
503
+ return sanitizeUrlsInString(value, { allowNestedUrls: true, stripPunctuation: true })
504
+ }
505
+
506
+ function sanitizeString(value: string): string {
507
+ return isBinaryBlob(value) ? BINARY_REDACTED_VALUE : redactUrls(redactCredentials(value))
508
+ }
509
+
510
+ /**
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
+ *
514
+ * Every step of the order is load-bearing. The binary gate reads the value as it
515
+ * arrived, because splicing `[redacted]` into a blob — a Luhn-valid run inside
516
+ * base64 is enough — stops it looking like base64 and would ship it whole.
517
+ * Credentials go before PII, because a PII pattern can cut a token in half (a
518
+ * phone-shaped run inside one) and leave the halves behind. PII goes before the
519
+ * URL pass, because the URL rewrite percent-encodes the `@` that the email
520
+ * pattern anchors on.
521
+ */
522
+ export function sanitizeFreeText(value: string): string {
523
+ if (isBinaryBlob(value)) {
101
524
  return BINARY_REDACTED_VALUE
102
525
  }
103
- return value.replace(POSTHOG_TOKEN_PATTERN, REDACTED_VALUE)
526
+ return redactUrls(redactPii(redactCredentials(value)))
104
527
  }
105
528
 
106
529
  function passesLuhn(digits: string): boolean {
@@ -184,16 +607,29 @@ export function redactPii(value: string): string {
184
607
  }
185
608
 
186
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 {
187
623
  if (value == null) {
188
624
  return value
189
625
  }
190
626
 
191
627
  if (typeof value === 'string') {
192
- return sanitizeString(value)
628
+ return sanitizeStringFn(value)
193
629
  }
194
630
 
195
631
  if (Array.isArray(value)) {
196
- return value.map(sanitizeCapturedValue)
632
+ return value.map((item) => sanitizeValueWith(item, sanitizeStringFn))
197
633
  }
198
634
 
199
635
  if (value instanceof Date) {
@@ -206,7 +642,7 @@ export function sanitizeCapturedValue(value: unknown): unknown {
206
642
 
207
643
  const result: JsonRecord = {}
208
644
  for (const [key, nestedValue] of Object.entries(value)) {
209
- result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeCapturedValue(nestedValue)
645
+ result[key] = shouldRedactKey(key) ? REDACTED_VALUE : sanitizeValueWith(nestedValue, sanitizeStringFn)
210
646
  }
211
647
  return result
212
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>()
@@ -256,6 +304,7 @@ export class PostHogMCP extends PostHog {
256
304
  ? resolveModel({ params: { arguments: args, _meta: options.requestMeta } }, ownsModel)
257
305
  : undefined
258
306
  const strippedArgs = stripContext(args)
307
+ const isFeedback = this.#feedbackOptions !== undefined && name === this.#feedbackToolName
259
308
  return {
260
309
  intent,
261
310
  intentSource: intent ? 'context_parameter' : undefined,
@@ -269,6 +318,8 @@ export class PostHogMCP extends PostHog {
269
318
  }) as Record<string, unknown> | undefined)
270
319
  : strippedArgs,
271
320
  isMissingCapability: name === this.#missingCapabilityToolName,
321
+ isFeedback,
322
+ feedbackReport: isFeedback ? parseFeedbackReport(args, this.#feedbackOptions) : undefined,
272
323
  }
273
324
  }
274
325
 
@@ -286,6 +337,26 @@ export class PostHogMCP extends PostHog {
286
337
  this.#emit(event)
287
338
  }
288
339
 
340
+ /**
341
+ * Capture a `send_feedback` call as an agent-feedback report. Emits
342
+ * `$mcp_feedback` with the report's `$mcp_feedback_*` properties and its
343
+ * summary/details as `$mcp_intent`. Reply to the agent with
344
+ * `sendFeedbackResult()` (or a custom text) after routing the report to your
345
+ * own feedback backend.
346
+ */
347
+ captureFeedback(data: FeedbackCaptureData): void {
348
+ const event = baseEvent(MCPAnalyticsEventType.mcpFeedback, data)
349
+ event.resourceName = this.#feedbackToolName
350
+ // Deliberately no `$mcp_parameters`: the arguments are agent-narrated free
351
+ // text, and the PII-redacted `$mcp_feedback_*` properties are the captured
352
+ // surface. Raw arguments would bypass that redaction. Feedback properties
353
+ // win over the caller's, matching the instrument() path's spread order.
354
+ event.properties = { ...event.properties, ...buildFeedbackEventProperties(data.report) }
355
+ applyIntent(event, buildFeedbackIntent(data.report), 'context_parameter')
356
+ setEventModel(event, data.llmModel, data.llmModelSource)
357
+ this.#emit(event)
358
+ }
359
+
289
360
  /**
290
361
  * Fire-and-forget, mirroring posthog-node's `capture()`: the event is enqueued
291
362
  * on the client, not awaited. Never throws — a failure to record analytics