@posthog/mcp 0.10.9 → 0.11.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 (73) hide show
  1. package/dist/extensions/capture.d.ts.map +1 -1
  2. package/dist/extensions/capture.js +2 -0
  3. package/dist/extensions/capture.mjs +2 -0
  4. package/dist/extensions/compatibility.d.ts.map +1 -1
  5. package/dist/extensions/constants.d.ts +2 -0
  6. package/dist/extensions/constants.d.ts.map +1 -1
  7. package/dist/extensions/constants.js +3 -1
  8. package/dist/extensions/constants.mjs +3 -1
  9. package/dist/extensions/context-parameters.d.ts.map +1 -1
  10. package/dist/extensions/conversation-id.d.ts +2 -13
  11. package/dist/extensions/conversation-id.d.ts.map +1 -1
  12. package/dist/extensions/headers.d.ts +33 -0
  13. package/dist/extensions/headers.d.ts.map +1 -0
  14. package/dist/extensions/headers.js +54 -0
  15. package/dist/extensions/headers.mjs +17 -0
  16. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  17. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  18. package/dist/extensions/instrumentation.d.ts +7 -0
  19. package/dist/extensions/instrumentation.d.ts.map +1 -1
  20. package/dist/extensions/instrumentation.js +4 -0
  21. package/dist/extensions/instrumentation.mjs +4 -0
  22. package/dist/extensions/internal.d.ts.map +1 -1
  23. package/dist/extensions/internal.js +2 -0
  24. package/dist/extensions/internal.mjs +2 -0
  25. package/dist/extensions/output-instructions.d.ts +6 -8
  26. package/dist/extensions/output-instructions.d.ts.map +1 -1
  27. package/dist/extensions/posthog-events.js +4 -0
  28. package/dist/extensions/posthog-events.mjs +4 -0
  29. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  30. package/dist/extensions/posthog-mcp.js +3 -0
  31. package/dist/extensions/posthog-mcp.mjs +3 -0
  32. package/dist/extensions/session-token.d.ts.map +1 -1
  33. package/dist/extensions/session-token.js +5 -14
  34. package/dist/extensions/session-token.mjs +2 -11
  35. package/dist/extensions/session.d.ts +4 -3
  36. package/dist/extensions/session.d.ts.map +1 -1
  37. package/dist/extensions/sink.d.ts +4 -6
  38. package/dist/extensions/sink.d.ts.map +1 -1
  39. package/dist/extensions/tools.d.ts.map +1 -1
  40. package/dist/extensions/transport-identity.d.ts +59 -0
  41. package/dist/extensions/transport-identity.d.ts.map +1 -0
  42. package/dist/extensions/transport-identity.js +63 -0
  43. package/dist/extensions/transport-identity.mjs +20 -0
  44. package/dist/extensions/truncation.d.ts.map +1 -1
  45. package/dist/extensions/truncation.js +2 -0
  46. package/dist/extensions/truncation.mjs +2 -0
  47. package/dist/types.d.ts +33 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/dist/version.mjs +1 -1
  52. package/package.json +1 -1
  53. package/src/extensions/capture.ts +6 -0
  54. package/src/extensions/compatibility.ts +0 -9
  55. package/src/extensions/constants.ts +2 -0
  56. package/src/extensions/context-parameters.ts +3 -11
  57. package/src/extensions/conversation-id.ts +5 -17
  58. package/src/extensions/headers.ts +52 -0
  59. package/src/extensions/instrument-highlevel.ts +0 -1
  60. package/src/extensions/instrument-lowlevel.ts +0 -1
  61. package/src/extensions/instrumentation.ts +29 -37
  62. package/src/extensions/internal.ts +6 -5
  63. package/src/extensions/output-instructions.ts +6 -8
  64. package/src/extensions/posthog-events.ts +14 -0
  65. package/src/extensions/posthog-mcp.ts +7 -5
  66. package/src/extensions/session-token.ts +3 -15
  67. package/src/extensions/session.ts +21 -35
  68. package/src/extensions/sink.ts +4 -6
  69. package/src/extensions/tools.ts +3 -6
  70. package/src/extensions/transport-identity.ts +94 -0
  71. package/src/extensions/truncation.ts +4 -0
  72. package/src/types.ts +33 -0
  73. package/src/version.ts +1 -1
@@ -24,6 +24,7 @@ import {
24
24
  resolveConversationId,
25
25
  } from './conversation-id'
26
26
  import { stampMetaClientInfo } from './client-identity'
27
+ import { stampTransportIdentity } from './transport-identity'
27
28
  import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
28
29
  import { captureEvent } from './capture'
29
30
  import { MCPAnalyticsEventType } from './event-types'
@@ -157,22 +158,13 @@ function getActiveAnalyticsParameterOwnership(
157
158
  return {
158
159
  context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
159
160
  conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
160
- // Deliberately read off `listed`, never the override. This asks whether
161
- // `tools/list` actually declared `_mcp_instructions`, and only the advertised
162
- // JSON Schema can answer it — an override is built from the live registry,
163
- // which on the high-level path holds Zod.
164
- //
165
- // The cache is per-instance, so this is not merely "before the first
166
- // `tools/list`": an instance that never serves a listing never writes the
167
- // mirror at all. That is the per-request server pattern — `tools/list` lands
168
- // on one instance, `tools/call` on a cold one — where the handle falls back
169
- // to the `content` block and a structuredContent-only client misses it.
170
- //
171
- // Failing closed is deliberate. Writing a key the advertised schema did not
172
- // declare fails the *entire* tool result under `additionalProperties: false`,
173
- // so guessing costs the caller their result, while not guessing costs us one
174
- // delivery channel. The fix is a process-scoped ownership cache, so a listing
175
- // served by any instance answers for the rest — not a per-call guess.
161
+ // Deliberately read off `listed`, never the override: only the advertised
162
+ // JSON Schema can say whether `tools/list` declared `_mcp_instructions` (an
163
+ // override is built from the live registry, which holds Zod on the
164
+ // high-level path). An instance that never served a listing has no answer
165
+ // and fails closed — writing an undeclared key fails the customer's entire
166
+ // tool result under `additionalProperties: false`. See ADR-0004 for the
167
+ // per-request-instance gap this leaves and the planned fix.
176
168
  outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
177
169
  }
178
170
  }
@@ -237,6 +229,9 @@ async function prepareToolCallEvent(
237
229
  // `_meta` on every request rather than at `initialize`; stamp them onto this
238
230
  // event now so concurrent requests can't cross-attribute it.
239
231
  stampMetaClientInfo(event, request)
232
+ // Which *surface* of the client made this call lives only in the request
233
+ // headers (HTTP transports); `clientInfo` can't tell a vendor's products apart.
234
+ stampTransportIdentity(event, extra)
240
235
 
241
236
  await applyResolvedMetadata(event, data, request, extra)
242
237
  setEventIntent(event, await resolveToolCallIntent(data, request, ownership.context, extra))
@@ -250,19 +245,14 @@ async function prepareToolCallEvent(
250
245
  }
251
246
 
252
247
  /**
253
- * Delivers the conversation session handle back to the agent, over both channels a tool
254
- * result has:
248
+ * Delivers the conversation session handle back to the agent over both channels
249
+ * a tool result has: mirrored into `structuredContent` on every response (for
250
+ * tools whose output schema declares the key), and as a `content` text block on
251
+ * the minting response only. Why two channels and why those cadences: ADR-0004.
255
252
  *
256
- * - `structuredContent`, on *every* response for a tool whose output schema we
257
- * declared the key on. Clients that read structured results never see the text
258
- * block, and re-sending it each time lets an agent that dropped the session handle read
259
- * it back.
260
- * - `content`, as a text block, only on the response that minted the session handle.
261
- * Repeating it every call would put a `[SERVER]:` line in front of the user on
262
- * every single tool result.
263
- *
264
- * If neither channel could carry a session handle we minted, the agent never received it,
265
- * so clear it off the event rather than showing analytics an id nobody has.
253
+ * If neither channel could carry a session handle we minted, the agent never
254
+ * received it, so clear it off the event rather than showing analytics an id
255
+ * nobody has.
266
256
  */
267
257
  function applyConversationInstructions(
268
258
  event: McpEvent | null,
@@ -364,13 +354,7 @@ export type HandlerPatch = (
364
354
  extra: CompatibleRequestHandlerExtra | undefined
365
355
  ) => Promise<unknown>
366
356
 
367
- /**
368
- * Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
369
- * handlers already registered, and patches `setRequestHandler` so matching
370
- * handlers registered later are patched too. The latter is what makes adapters
371
- * that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
372
- * bare server to instrument() and only then registers its handlers.
373
- */
357
+ /** Pre-patch handlers, kept so `isToolAdvertised` can query the raw listing. */
374
358
  const originalRequestHandlers = new WeakMap<MCPServerLike, Map<string, MCPRequestHandler>>()
375
359
 
376
360
  function rememberOriginalRequestHandler(
@@ -417,8 +401,14 @@ export function registerFallbackRequestHandler(
417
401
  server._requestHandlers.set(handlerName, (request, extra) => patch(server, fallbackHandler, request, extra))
418
402
  }
419
403
 
404
+ /**
405
+ * Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
406
+ * handlers already registered, and patches `setRequestHandler` so matching
407
+ * handlers registered later are patched too. The latter is what makes adapters
408
+ * that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
409
+ * bare server to instrument() and only then registers its handlers.
410
+ */
420
411
  export function patchRequestHandlers(server: MCPServerLike, patches: Record<string, HandlerPatch>): void {
421
- // Monkey patch existing handlers.
422
412
  for (const [handlerName, patch] of Object.entries(patches)) {
423
413
  const originalHandler = server._requestHandlers.get(handlerName)
424
414
  if (originalHandler) {
@@ -519,6 +509,7 @@ export async function handleListToolsRequest(
519
509
  timestamp: startTime,
520
510
  }
521
511
  stampMetaClientInfo(event, request)
512
+ stampTransportIdentity(event, extra)
522
513
 
523
514
  if (data) {
524
515
  await applyResolvedMetadata(event, data, request, extra)
@@ -683,7 +674,7 @@ export function cacheToolCategories(cache: Map<string, string>, tools: ListTools
683
674
  * client name/version is lost after `initialize`. Fix: mint the
684
675
  * `Mcp-Session-Id` response header as a token carrying both. Clients replay
685
676
  * the header on every request, so any pod recovers them with no server-side
686
- * store (decoded in `getSessionId`).
677
+ * store (decoded in `getSessionId`). See ADR-0003.
687
678
  *
688
679
  * The header only reaches the wire when response headers are built after the
689
680
  * handler runs — StreamableHTTP with `enableJsonResponse: true`. SSE flushes
@@ -830,6 +821,7 @@ export async function handleInitializeRequest(
830
821
  // negotiated protocol version below overrides any `_meta` one); picks up client
831
822
  // info if a client also sends it in `_meta`.
832
823
  stampMetaClientInfo(event, request)
824
+ stampTransportIdentity(event, extra)
833
825
 
834
826
  await applyResolvedMetadata(event, data, request, extra)
835
827
 
@@ -15,6 +15,7 @@ import type {
15
15
  import { MCPAnalyticsEventType } from './event-types'
16
16
  import { captureEvent } from './capture'
17
17
  import { stampMetaClientInfo } from './client-identity'
18
+ import { stampTransportIdentity } from './transport-identity'
18
19
 
19
20
  /**
20
21
  * Bounded LRU cache for session identities, capped at `maxSize` entries so a
@@ -153,6 +154,7 @@ export async function handleIdentify(
153
154
  timestamp: new Date(),
154
155
  }
155
156
  stampMetaClientInfo(identifyEvent, request)
157
+ stampTransportIdentity(identifyEvent, extra)
156
158
 
157
159
  try {
158
160
  const identityResult =
@@ -166,11 +168,10 @@ export async function handleIdentify(
166
168
  // was already announced by whichever pod handled `initialize`, so only the
167
169
  // handshake, or a genuine change a long-lived server observed, publishes
168
170
  // $identify. Every event still carries distinct_id/$set regardless, so
169
- // person properties are never lost. Known gap: if identity is null at
170
- // `initialize` but resolves later on a token session, that first $identify
171
- // is suppressed too, so any pre-identify (anonymous) events aren't aliased
172
- // onto the user. Inherent to statelessness (no pod knows a sibling already
173
- // announced); revisit with the stateless-by-default rework.
171
+ // person properties are never lost. Known accepted gap (ADR-0003): an
172
+ // identity resolving only after `initialize` on a token session gets no
173
+ // standalone $identify, so its pre-identify events aren't aliased onto
174
+ // the user.
174
175
  const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
175
176
  const firstSeen = previousIdentity === undefined
176
177
  const announcedAtInitialize =
@@ -3,18 +3,16 @@ import { log } from './logger'
3
3
  import type { AnalyticsInjectableJsonSchema } from './analytics-parameters'
4
4
 
5
5
  /**
6
- * The `conversation_id` session handle reaches the agent as a text block appended to the
7
- * tool result. That block is invisible to any client that reads
8
- * `structuredContent` instead of `content` — which is what clients do whenever a
9
- * tool declares an `outputSchema`. Measured against Claude Code, correlation for
10
- * such tools drops to zero.
11
- *
12
- * The fix is to mirror the session handle into `structuredContent` as well, which this
13
- * module does in two halves that must stay in that order:
6
+ * Mirrors the `conversation_id` session handle into `structuredContent`, in two
7
+ * halves that must stay in that order:
14
8
  *
15
9
  * 1. declare `_mcp_instructions` on the tool's advertised `outputSchema`
16
10
  * 2. write it into the result's `structuredContent`
17
11
  *
12
+ * Needed because clients that read `structuredContent` — which they do whenever
13
+ * a tool declares an `outputSchema` — never see the `content` text block that
14
+ * carries the handle (ADR-0004).
15
+ *
18
16
  * The declaration is what makes the write safe. The MCP client ajv-validates
19
17
  * `structuredContent` against the schema from `tools/list`, and
20
18
  * `zod-to-json-schema` emits `additionalProperties: false` for a plain
@@ -151,6 +151,14 @@ function addCommonEventProperties(event: Event, properties: Record<string, unkno
151
151
  if (event.clientVersion) {
152
152
  properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
153
153
  }
154
+ // HTTP transports only, and only for the request that carried the header —
155
+ // stdio and in-memory servers simply never set these.
156
+ if (event.clientUserAgent) {
157
+ properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
158
+ }
159
+ if (event.vendorClient) {
160
+ properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
161
+ }
154
162
  // Present on every event once negotiated at `initialize` (carried via
155
163
  // sessionInfo / the session token). Absent only before the handshake.
156
164
  if (event.protocolVersion) {
@@ -242,6 +250,12 @@ function buildExceptionEvent(event: Event): PostHogCaptureEvent {
242
250
  if (event.clientVersion) {
243
251
  properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
244
252
  }
253
+ if (event.clientUserAgent) {
254
+ properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
255
+ }
256
+ if (event.vendorClient) {
257
+ properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
258
+ }
245
259
  if (event.protocolVersion) {
246
260
  properties[PostHogMCPAnalyticsProperty.ProtocolVersion] = event.protocolVersion
247
261
  }
@@ -19,6 +19,7 @@ import {
19
19
  } from './context-parameters'
20
20
  import { MCPAnalyticsEventType } from './event-types'
21
21
  import { captureException } from './exceptions'
22
+ import { normalizeHeaderString } from './headers'
22
23
  import { applyMcpLibIdentity } from './lib-identity'
23
24
  import { log } from './logger'
24
25
  import { McpEventSink } from './sink'
@@ -72,10 +73,6 @@ export interface PostHogMCPOptions extends PostHogOptions {
72
73
  * ```
73
74
  */
74
75
  export class PostHogMCP extends PostHog {
75
- // Reuses the shared sink so MCP events flow through the identical
76
- // sanitize/truncate/fan-out pipeline as the `instrument()` path; the sink
77
- // publishes via `this` (the inherited `capture()`), so the client's own
78
- // `beforeSend` and batching apply.
79
76
  readonly #sink = new McpEventSink(this)
80
77
 
81
78
  // The get_more_tools name lives here (not on the per-call options) so that
@@ -85,7 +82,6 @@ export class PostHogMCP extends PostHog {
85
82
  constructor(apiKey: string, options: PostHogMCPOptions = {}) {
86
83
  super(apiKey, options)
87
84
  this.#missingCapabilityToolName = options.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
88
- // Report `$lib: 'posthog-node-mcp'` instead of the inherited `posthog-node`.
89
85
  applyMcpLibIdentity(this)
90
86
  }
91
87
 
@@ -244,6 +240,12 @@ function baseEvent(eventType: MCPAnalyticsEventType, common: McpCaptureCommon):
244
240
  eventType,
245
241
  sessionId: common.sessionId,
246
242
  protocolVersion: common.protocolVersion,
243
+ // There is no `extra` on this path, so the host reads the request headers and
244
+ // passes them per capture — the SDK has no transport to read them from. Normalized
245
+ // with the same rules `instrument()` applies, so one request yields one set of
246
+ // properties whichever path captured it.
247
+ clientUserAgent: normalizeHeaderString(common.clientUserAgent),
248
+ vendorClient: normalizeHeaderString(common.vendorClient),
247
249
  timestamp: common.timestamp ?? new Date(),
248
250
  properties: common.properties,
249
251
  groups: common.groups,
@@ -10,6 +10,8 @@
10
10
  * The token is unsigned: it holds only what the client already self-reports.
11
11
  */
12
12
 
13
+ import { readHeaderValue } from './headers'
14
+
13
15
  export const MCP_SESSION_HEADER = 'mcp-session-id'
14
16
 
15
17
  /** What a session token carries. */
@@ -105,21 +107,7 @@ export function decodeSessionId(value: unknown): SessionTokenPayload | null {
105
107
  * transports lowercase header keys; the fallback scan covers hand-built extras.
106
108
  */
107
109
  export function readMcpSessionHeader(headers: unknown): string | undefined {
108
- if (!headers || typeof headers !== 'object') {
109
- return undefined
110
- }
111
- const record = headers as Record<string, unknown>
112
- let value = record[MCP_SESSION_HEADER]
113
- if (value === undefined) {
114
- const key = Object.keys(record).find((k) => k.toLowerCase() === MCP_SESSION_HEADER)
115
- value = key === undefined ? undefined : record[key]
116
- }
117
- const first = Array.isArray(value) ? value[0] : value
118
- if (typeof first !== 'string') {
119
- return undefined
120
- }
121
- const trimmed = first.trim()
122
- return trimmed.length > 0 ? trimmed : undefined
110
+ return readHeaderValue(headers, MCP_SESSION_HEADER)
123
111
  }
124
112
 
125
113
  /**
@@ -30,9 +30,10 @@ export function deriveSessionIdFromMCPSession(mcpSessionId: string): string {
30
30
  }
31
31
 
32
32
  /**
33
- * Derives the SDK session id from the agent's conversation handle. Deterministic
34
- * and unsalted on purpose: two pods that never met must agree on the session, and
35
- * the 2026-07-28 revision leaves them no shared state to agree through.
33
+ * Derives the SDK session id from the agent's conversation handle (ADR-0004).
34
+ * Deterministic and unsalted on purpose: two pods that never met must agree on
35
+ * the session, and the 2026-07-28 revision leaves them no shared state to agree
36
+ * through.
36
37
  *
37
38
  * Hashed rather than used verbatim so an MCP session can never collide with a
38
39
  * Session Replay id — a bare uuidv7 would render a "View recording" button that
@@ -69,26 +70,16 @@ export function getSessionId(
69
70
  throw new Error('Server tracking data not found')
70
71
  }
71
72
 
72
- // 1. The agent's conversation handle. It outranks both steps below because it
73
- // is the only id that survives reconnects, restarts, and the per-request
74
- // server instances the 2026-07-28 revision introduces. Hashed rather than used
75
- // verbatim so it can never collide with Session Replay ids.
76
- //
77
- // This returns instead of falling through to the shared-state writes at the
78
- // end: the handle belongs to this one request, and persisting it would leak
79
- // one chat's session onto a concurrent chat's `tools/list`.
80
- //
81
- // `lastActivity` therefore does not advance either, so a conversation that
82
- // always echoes lets the in-memory fallback age past the inactivity timeout —
83
- // and a later call carrying no handle rotates it. That is the honest reading:
84
- // the fallback session really has been idle for that whole time.
73
+ // 1. The agent's conversation handle — the only id that survives reconnects,
74
+ // restarts, and the per-request server instances of the 2026-07-28 revision
75
+ // (ADR-0004). Returns before the shared-state writes at the end: the handle
76
+ // belongs to this one request, and persisting it (or advancing lastActivity)
77
+ // would leak one chat's session onto a concurrent chat's `tools/list`.
85
78
  if (conversationId) {
86
- // The handle decides the *session*, but the request may still carry our token,
87
- // and on a stateless instance that never processed `initialize` its payload is
88
- // the only place client identity exists. Restore that before returning: the
89
- // early return is about not persisting a session, not about ignoring what the
90
- // request told us about the client. Without this, tool calls and `$identify`
91
- // go out unattributed on exactly the deployments this branch exists for.
79
+ // The session comes from the handle, but on an instance that never processed
80
+ // `initialize` the request's token is still the only source of client
81
+ // identity — without this, tool calls and `$identify` go out unattributed
82
+ // on exactly the deployments this branch exists for.
92
83
  applyTokenClientIdentity(data, extra)
93
84
  return deriveSessionIdFromConversation(conversationId)
94
85
  }
@@ -110,14 +101,10 @@ export function getSessionId(
110
101
  /**
111
102
  * Restores the client name/version and protocol version baked into our token at
112
103
  * mint time, and hands the decoded token back so the caller can also take the
113
- * session id from it.
114
- *
115
- * Split out from step 2 because the two halves of the token have different
116
- * scopes. The session id it carries is per-chat, so a conversation-anchored
117
- * request must not adopt it. The client identity is per-connection — the same
118
- * client for every request on it — so a conversation-anchored request should,
119
- * and on a stateless instance that never saw `initialize` this is the only
120
- * source of it.
104
+ * session id from it. Split from step 2 because the token's two halves have
105
+ * different scopes: its session id is per-chat (a conversation-anchored request
106
+ * must not adopt it), its client identity is per-connection (every request on
107
+ * the connection should).
121
108
  */
122
109
  function applyTokenClientIdentity(
123
110
  data: MCPAnalyticsData,
@@ -139,9 +126,8 @@ function applyTokenClientIdentity(
139
126
  * issued. Returns undefined when the request carried neither, which is what
140
127
  * sends the caller on to 3.
141
128
  *
142
- * Both are 2025-11-25 mechanisms. The 2026-07-28 revision removed that header
143
- * outright — servers MUST NOT mint or echo it — so this entire step becomes
144
- * legacy-only once era detection lands.
129
+ * Both are 2025-11-25 mechanisms; the 2026-07-28 revision removed the header
130
+ * outright, so this step is legacy-only once era detection lands (ADR-0003).
145
131
  */
146
132
  function readSessionIdFromRequest(data: MCPAnalyticsData, extra?: CompatibleRequestHandlerExtra): string | undefined {
147
133
  // 2a. A token we minted at `initialize` and the client replayed. It rides the
@@ -203,8 +189,8 @@ export function getSessionInfo(
203
189
  const actorInfo = data?.identifiedSessions.get(sessionId ?? data.sessionId)
204
190
 
205
191
  const sessionInfo: SessionInfo = {
206
- ipAddress: undefined, // grab from django
207
- sdkLanguage: 'TypeScript', // hardcoded for now
192
+ ipAddress: undefined,
193
+ sdkLanguage: 'TypeScript',
208
194
  sdkVersion: version,
209
195
  serverName: server._serverInfo?.name,
210
196
  serverVersion: server._serverInfo?.version,
@@ -92,9 +92,8 @@ async function applyBeforeSend(
92
92
  }
93
93
 
94
94
  /**
95
- * Wraps a user-supplied `posthog-node` client. Runs every MCP event through the
96
- * sanitize → truncate pipeline, fans it out into the `$mcp_*` / `$exception`
97
- * events, applies `beforeSend`, and hands each to `posthog.capture()`.
95
+ * Wraps a user-supplied `posthog-node` client: runs every MCP event through
96
+ * {@link processMcpEvent} and hands each surviving payload to `posthog.capture()`.
98
97
  *
99
98
  * The SDK does not own the client lifecycle — the host application constructs
100
99
  * the `PostHog` instance and is responsible for `shutdown()` (matching
@@ -107,9 +106,8 @@ export class McpEventSink {
107
106
  ) {}
108
107
 
109
108
  /**
110
- * Push an MCP event through the pipeline (sanitize → truncate → fan out →
111
- * beforeSend → capture). Errors at any stage are logged and the event is
112
- * dropped, never re-thrown into tool code.
109
+ * Errors at any stage are logged and the event is dropped, never re-thrown
110
+ * into tool code.
113
111
  */
114
112
  async capture(event: McpEvent, options: McpCaptureOptions): Promise<void> {
115
113
  const result = await processMcpEvent(event, options, this.logger)
@@ -36,15 +36,12 @@ export function getReportMissingToolDescriptor(name: string = GET_MORE_TOOLS_NAM
36
36
  },
37
37
  annotations: {
38
38
  title: 'Get More Tools',
39
- // Doesn't mutate state on the MCP server
40
39
  readOnlyHint: true,
41
- // Interacts with external entities because we store this in analytics
40
+ // Interacts with an external entity: the report lands in analytics.
42
41
  openWorldHint: true,
43
- // A tool like `get_more_tools` would usually NOT be idempontent, but since we are
44
- // only using this to keep track of missing tools/feedback/analytics, it is actually idempontent.
45
- // It's also preferable to track it as idempontent to make agents more prone to call it proactively.
42
+ // Only records the gap, so repeat calls are harmless — and advertising it
43
+ // as idempotent makes agents more willing to call it proactively.
46
44
  idempotentHint: true,
47
- // Never deletes any data from the MCP server
48
45
  destructiveHint: false,
49
46
  },
50
47
  }
@@ -0,0 +1,94 @@
1
+ import type { CompatibleRequestHandlerExtra, McpEvent } from '../types'
2
+ import { readHeaderValue } from './headers'
3
+
4
+ /**
5
+ * Transport-level client identity: the two request headers that say *which
6
+ * product* is calling, where `clientInfo` only says which client library is.
7
+ *
8
+ * MCP's own identity fields are too coarse to attribute usage to a surface. A
9
+ * vendor ships many products on one client: Anthropic reports
10
+ * `clientInfo.name = "claude-code"` from the CLI, the Agent SDK, the VS Code
11
+ * extension, the desktop app and more, so `$mcp_client_name` collapses all of
12
+ * them into one bucket. The distinguishing detail exists only in the User-Agent
13
+ * parenthetical — `claude-code/2.1.0 (cli)` vs `claude-code/2.1.0 (sdk-ts)` vs
14
+ * `claude-code/2.1.0 (claude-vscode)` — alongside vendor-specific headers like
15
+ * `x-anthropic-client`. Capturing them is the only way a server owner can tell
16
+ * their surfaces apart.
17
+ *
18
+ * We capture the raw strings and classify nothing. No vendor table, no product
19
+ * labels: friendly names are resolved at query time server-side, so labels can
20
+ * improve (and new surfaces can appear) without waiting on an SDK release, and
21
+ * there is exactly one resolver rather than one per installed SDK version.
22
+ *
23
+ * This is deliberately separate from `client-identity.ts`. That module reads the
24
+ * request body's `_meta` and therefore works on every transport, including stdio
25
+ * and in-memory. Headers exist only on HTTP transports, so everything here is a
26
+ * silent no-op elsewhere and those events stay byte-identical to before.
27
+ */
28
+
29
+ /** Header carrying the client's product/surface, e.g. `claude-code/2.1.0 (cli)`. */
30
+ export const CLIENT_USER_AGENT_HEADER = 'user-agent'
31
+
32
+ /**
33
+ * Vendor-specific client header. Anthropic's clients send it alongside the
34
+ * User-Agent; it is captured verbatim as a second, independent signal rather
35
+ * than merged into one, so a query-time resolver can prefer whichever the
36
+ * vendor keeps stable.
37
+ */
38
+ export const VENDOR_CLIENT_HEADER = 'x-anthropic-client'
39
+
40
+ export interface TransportIdentity {
41
+ clientUserAgent?: string
42
+ vendorClient?: string
43
+ }
44
+
45
+ /**
46
+ * Reads the transport identity headers off `extra.requestInfo.headers`. Returns
47
+ * `undefined` when the request carries neither — including every stdio and
48
+ * in-memory request, which has no `requestInfo` at all. Never throws.
49
+ */
50
+ export function readTransportIdentity(extra: CompatibleRequestHandlerExtra | undefined): TransportIdentity | undefined {
51
+ const headers = extra?.requestInfo?.headers
52
+ if (!headers) {
53
+ return undefined
54
+ }
55
+ const result: TransportIdentity = {}
56
+
57
+ const clientUserAgent = readHeaderValue(headers, CLIENT_USER_AGENT_HEADER)
58
+ if (clientUserAgent) {
59
+ result.clientUserAgent = clientUserAgent
60
+ }
61
+
62
+ const vendorClient = readHeaderValue(headers, VENDOR_CLIENT_HEADER)
63
+ if (vendorClient) {
64
+ result.vendorClient = vendorClient
65
+ }
66
+
67
+ return result.clientUserAgent || result.vendorClient ? result : undefined
68
+ }
69
+
70
+ /**
71
+ * Stamps the transport identity onto the event being built for *this* request,
72
+ * so it carries `$mcp_client_user_agent` and `$mcp_vendor_client`.
73
+ *
74
+ * Headers are per-request, so this writes to the event — a per-request object —
75
+ * and never to the server-wide `sessionInfo`. One instrumented server can
76
+ * multiplex concurrent requests from different clients, and caching a header
77
+ * into shared state would attribute one client's surface to another's event.
78
+ * `client-identity.ts` and `capture.ts` guard the same hazard.
79
+ *
80
+ * Values are capped downstream by `truncateEvent`, which runs on every capture
81
+ * path, so a hostile 1MB header cannot inflate an event.
82
+ */
83
+ export function stampTransportIdentity(event: McpEvent, extra: CompatibleRequestHandlerExtra | undefined): void {
84
+ const identity = readTransportIdentity(extra)
85
+ if (!identity) {
86
+ return
87
+ }
88
+ if (identity.clientUserAgent) {
89
+ event.clientUserAgent = identity.clientUserAgent
90
+ }
91
+ if (identity.vendorClient) {
92
+ event.vendorClient = identity.vendorClient
93
+ }
94
+ }
@@ -430,6 +430,10 @@ export function truncateEvent<T extends Event | McpEvent>(event: T): T {
430
430
  result.serverVersion = truncateString(result.serverVersion, MAX_METADATA_LENGTH)
431
431
  result.clientName = truncateString(result.clientName, MAX_METADATA_LENGTH)
432
432
  result.clientVersion = truncateString(result.clientVersion, MAX_METADATA_LENGTH)
433
+ // Client-supplied HTTP headers, so unlike the fields above they are attacker-
434
+ // controlled in length. Any real User-Agent fits well inside the metadata cap.
435
+ result.clientUserAgent = truncateString(result.clientUserAgent, MAX_METADATA_LENGTH)
436
+ result.vendorClient = truncateString(result.vendorClient, MAX_METADATA_LENGTH)
433
437
  result.errorType = truncateString(result.errorType, MAX_METADATA_LENGTH)
434
438
 
435
439
  // Error field limits — operate on the core `$exception_list` shape
package/src/types.ts CHANGED
@@ -151,6 +151,15 @@ export type BeforeSendFn = (event: PostHogCaptureEvent) => MaybePromise<PostHogC
151
151
  export interface Event {
152
152
  actorId?: string
153
153
  clientName?: string
154
+ /**
155
+ * Raw `user-agent` request header → `$mcp_client_user_agent`. HTTP transports
156
+ * only. The only place a client's *surface* is distinguishable: one vendor
157
+ * ships many products under a single `clientName` (Anthropic's CLI, Agent SDK
158
+ * and VS Code extension all report `claude-code`), and only the User-Agent
159
+ * parenthetical tells them apart. Captured verbatim — surfaces are resolved to
160
+ * friendly labels at query time, never in this SDK.
161
+ */
162
+ clientUserAgent?: string
154
163
  clientVersion?: string
155
164
  conversationId?: string
156
165
  duration?: number
@@ -199,6 +208,12 @@ export interface Event {
199
208
  toolDescription?: string
200
209
  userIntent?: string
201
210
  userIntentSource?: MCPAnalyticsIntentSource
211
+ /**
212
+ * Raw vendor client header (`x-anthropic-client`) → `$mcp_vendor_client`. HTTP
213
+ * transports only. A second, independent surface signal alongside
214
+ * {@link Event.clientUserAgent}; captured verbatim, never classified.
215
+ */
216
+ vendorClient?: string
202
217
  }
203
218
 
204
219
  /** A partially-built MCP event as it flows through the SDK before capture. */
@@ -344,6 +359,24 @@ export interface McpCaptureCommon {
344
359
  * not just the initialize event — the `PostHogMCP` client holds no per-session state.
345
360
  */
346
361
  protocolVersion?: string
362
+ /**
363
+ * Raw `user-agent` request header → `$mcp_client_user_agent`. The
364
+ * `instrument()` path reads this off the transport automatically; a custom
365
+ * dispatcher has no `extra`, so pass `req.headers['user-agent']` yourself.
366
+ *
367
+ * Worth wiring up: it is the only signal that separates a vendor's surfaces
368
+ * (Anthropic's CLI, Agent SDK and VS Code extension all report
369
+ * `clientName: "claude-code"`, and only the User-Agent parenthetical —
370
+ * `(cli)` / `(sdk-ts)` / `(claude-vscode)` — tells them apart). Send it raw;
371
+ * PostHog resolves friendly product labels at query time.
372
+ */
373
+ clientUserAgent?: string
374
+ /**
375
+ * Raw vendor client header → `$mcp_vendor_client`. Anthropic's clients send
376
+ * `x-anthropic-client`; pass it as a second, independent surface signal
377
+ * alongside {@link McpCaptureCommon.clientUserAgent}. Sent verbatim.
378
+ */
379
+ vendorClient?: string
347
380
  /** Person properties → `$set` (e.g. `{ name, email, plan }`). */
348
381
  setProperties?: JsonRecord
349
382
  /** Group memberships → `$groups`. */
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.10.9'
1
+ export const version = '0.11.0'