@posthog/mcp 0.10.8 → 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 (80) 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/instrument-lowlevel.js +6 -5
  19. package/dist/extensions/instrument-lowlevel.mjs +7 -6
  20. package/dist/extensions/instrumentation.d.ts +29 -0
  21. package/dist/extensions/instrumentation.d.ts.map +1 -1
  22. package/dist/extensions/instrumentation.js +17 -7
  23. package/dist/extensions/instrumentation.mjs +15 -8
  24. package/dist/extensions/internal.d.ts.map +1 -1
  25. package/dist/extensions/internal.js +2 -0
  26. package/dist/extensions/internal.mjs +2 -0
  27. package/dist/extensions/mcp-sdk-compat.d.ts +10 -0
  28. package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -1
  29. package/dist/extensions/mcp-sdk-compat.js +11 -2
  30. package/dist/extensions/mcp-sdk-compat.mjs +7 -1
  31. package/dist/extensions/output-instructions.d.ts +6 -8
  32. package/dist/extensions/output-instructions.d.ts.map +1 -1
  33. package/dist/extensions/posthog-events.js +4 -0
  34. package/dist/extensions/posthog-events.mjs +4 -0
  35. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  36. package/dist/extensions/posthog-mcp.js +3 -0
  37. package/dist/extensions/posthog-mcp.mjs +3 -0
  38. package/dist/extensions/session-token.d.ts.map +1 -1
  39. package/dist/extensions/session-token.js +5 -14
  40. package/dist/extensions/session-token.mjs +2 -11
  41. package/dist/extensions/session.d.ts +4 -3
  42. package/dist/extensions/session.d.ts.map +1 -1
  43. package/dist/extensions/sink.d.ts +4 -6
  44. package/dist/extensions/sink.d.ts.map +1 -1
  45. package/dist/extensions/tools.d.ts.map +1 -1
  46. package/dist/extensions/transport-identity.d.ts +59 -0
  47. package/dist/extensions/transport-identity.d.ts.map +1 -0
  48. package/dist/extensions/transport-identity.js +63 -0
  49. package/dist/extensions/transport-identity.mjs +20 -0
  50. package/dist/extensions/truncation.d.ts.map +1 -1
  51. package/dist/extensions/truncation.js +2 -0
  52. package/dist/extensions/truncation.mjs +2 -0
  53. package/dist/types.d.ts +33 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/dist/version.mjs +1 -1
  58. package/package.json +1 -1
  59. package/src/extensions/capture.ts +6 -0
  60. package/src/extensions/compatibility.ts +0 -9
  61. package/src/extensions/constants.ts +2 -0
  62. package/src/extensions/context-parameters.ts +3 -11
  63. package/src/extensions/conversation-id.ts +5 -17
  64. package/src/extensions/headers.ts +52 -0
  65. package/src/extensions/instrument-highlevel.ts +0 -1
  66. package/src/extensions/instrument-lowlevel.ts +14 -10
  67. package/src/extensions/instrumentation.ts +82 -46
  68. package/src/extensions/internal.ts +6 -5
  69. package/src/extensions/mcp-sdk-compat.ts +18 -0
  70. package/src/extensions/output-instructions.ts +6 -8
  71. package/src/extensions/posthog-events.ts +14 -0
  72. package/src/extensions/posthog-mcp.ts +7 -5
  73. package/src/extensions/session-token.ts +3 -15
  74. package/src/extensions/session.ts +21 -35
  75. package/src/extensions/sink.ts +4 -6
  76. package/src/extensions/tools.ts +3 -6
  77. package/src/extensions/transport-identity.ts +94 -0
  78. package/src/extensions/truncation.ts +4 -0
  79. package/src/types.ts +33 -0
  80. package/src/version.ts +1 -1
@@ -21,26 +21,22 @@ type ServerRecord = Record<string, unknown>
21
21
  * - Server info structure
22
22
  */
23
23
 
24
- // Function to log compatibility information
25
24
  export function logCompatibilityWarning(logger: LoggerFn): void {
26
25
  logger(
27
26
  'PostHog MCP analytics SDK Compatibility: This version only supports Model Context Protocol TypeScript SDK v1.11 and above. Please upgrade if using an older version.'
28
27
  )
29
28
  }
30
29
 
31
- // Check if server has high-level structure (wrapper with .server property)
32
30
  export function isHighLevelServer(server: unknown): server is ServerRecord & { server: ServerRecord } {
33
31
  return (
34
32
  !!server && typeof server === 'object' && 'server' in server && !!server.server && typeof server.server === 'object'
35
33
  )
36
34
  }
37
35
 
38
- // Check if server has low-level structure (no .server property)
39
36
  export function isLowLevelServer(server: unknown): server is ServerRecord {
40
37
  return !!server && typeof server === 'object' && !('server' in server)
41
38
  }
42
39
 
43
- // Type guard function that validates server compatibility and returns typed server
44
40
  export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPServerLike | HighLevelMCPServerLike {
45
41
  if (!server || typeof server !== 'object') {
46
42
  logCompatibilityWarning(logger)
@@ -50,7 +46,6 @@ export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPSe
50
46
  }
51
47
 
52
48
  if (isHighLevelServer(server)) {
53
- // Validate high-level server requirements
54
49
  if (!server._registeredTools || typeof server._registeredTools !== 'object') {
55
50
  logCompatibilityWarning(logger)
56
51
  throw new Error(
@@ -64,18 +59,15 @@ export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPSe
64
59
  )
65
60
  }
66
61
 
67
- // Validate the underlying low-level server
68
62
  const targetServer = server.server
69
63
  validateLowLevelServer(targetServer, logger)
70
64
 
71
65
  return server as unknown as HighLevelMCPServerLike
72
66
  }
73
- // Direct low-level server validation
74
67
  validateLowLevelServer(server, logger)
75
68
  return server as MCPServerLike
76
69
  }
77
70
 
78
- // Helper function to validate low-level server requirements
79
71
  function validateLowLevelServer(server: unknown, logger: LoggerFn): void {
80
72
  if (!server || typeof server !== 'object') {
81
73
  logCompatibilityWarning(logger)
@@ -100,7 +92,6 @@ function validateLowLevelServer(server: unknown, logger: LoggerFn): void {
100
92
  )
101
93
  }
102
94
 
103
- // Validate that _requestHandlers contains functions with compatible signatures
104
95
  if (typeof serverRecord._requestHandlers.get !== 'function') {
105
96
  logCompatibilityWarning(logger)
106
97
  throw new Error(
@@ -38,6 +38,7 @@ export type PostHogMCPAnalyticsEvent = (typeof PostHogMCPAnalyticsEvent)[keyof t
38
38
 
39
39
  export const PostHogMCPAnalyticsProperty = {
40
40
  ClientName: '$mcp_client_name',
41
+ ClientUserAgent: '$mcp_client_user_agent',
41
42
  ClientVersion: '$mcp_client_version',
42
43
  ConversationId: '$mcp_conversation_id',
43
44
  DurationMs: '$mcp_duration_ms',
@@ -58,6 +59,7 @@ export const PostHogMCPAnalyticsProperty = {
58
59
  ToolCategory: '$mcp_tool_category',
59
60
  ToolDescription: '$mcp_tool_description',
60
61
  ToolName: '$mcp_tool_name',
62
+ VendorClient: '$mcp_vendor_client',
61
63
  } as const
62
64
 
63
65
  export type PostHogMCPAnalyticsProperty = (typeof PostHogMCPAnalyticsProperty)[keyof typeof PostHogMCPAnalyticsProperty]
@@ -41,7 +41,6 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
41
41
  contextDescriptionOverride?: string,
42
42
  logger: LoggerFn = log
43
43
  ): TTool {
44
- // Create a shallow copy of the tool to avoid modifying the original
45
44
  const modifiedTool = { ...tool }
46
45
  const toolName = tool.name || 'unknown'
47
46
  const schema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema | undefined
@@ -55,10 +54,6 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
55
54
  return modifiedTool
56
55
  }
57
56
 
58
- // Note: If additionalProperties is false, we'll need to remove that constraint
59
- // when adding context, otherwise the schema would be invalid. We handle this
60
- // after the deep copy below.
61
-
62
57
  if (!modifiedTool.inputSchema) {
63
58
  modifiedTool.inputSchema = {
64
59
  type: 'object',
@@ -69,29 +64,26 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
69
64
 
70
65
  const contextDescription = contextDescriptionOverride || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION
71
66
 
72
- // Deep copy the inputSchema to avoid mutations
67
+ // Deep copy: the server may reuse or freeze the schema object it handed us.
73
68
  modifiedTool.inputSchema = JSON.parse(JSON.stringify(modifiedTool.inputSchema)) as AnalyticsInjectableJsonSchema
74
69
 
75
70
  const inputSchema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema
76
71
 
77
- // Ensure properties object exists
78
72
  if (!inputSchema.properties) {
79
73
  inputSchema.properties = {}
80
74
  }
81
75
 
82
- // Handle additionalProperties: false - must remove this constraint since we're adding context
83
- // The MCP SDK adds this constraint when converting Zod schemas to JSON Schema
76
+ // The MCP SDK emits `additionalProperties: false` when converting Zod schemas;
77
+ // left in place it would make the injected `context` key invalid.
84
78
  if (inputSchema.additionalProperties === false) {
85
79
  inputSchema.additionalProperties = undefined
86
80
  }
87
81
 
88
- // Add context property
89
82
  inputSchema.properties.context = {
90
83
  type: 'string',
91
84
  description: contextDescription,
92
85
  }
93
86
 
94
- // Add context to required array
95
87
  if (Array.isArray(inputSchema.required)) {
96
88
  if (!inputSchema.required.includes('context')) {
97
89
  inputSchema.required.push('context')
@@ -102,20 +102,9 @@ export type ConversationIdResolution =
102
102
  * derivation is deterministic so that two pods agree — which also means two
103
103
  * *callers* sending the same string land in the same session. The strings agents
104
104
  * invent are not random (`conv-1`, `1`, `session`), so trusting them verbatim
105
- * would silently merge unrelated conversations, potentially across users.
106
- *
107
- * A compliant agent echoes the uuidv7 we minted and is unaffected. Anything else
108
- * is treated exactly as if the handle were absent: mint, and prompt back. Shape
105
+ * would silently merge unrelated conversations, potentially across users. Shape
109
106
  * rather than a registry of issued ids, because a per-request server has no
110
- * memory of what it minted.
111
- *
112
- * This narrows the problem, it does not prove provenance: a caller supplying a
113
- * well-formed uuidv7 is trusted, so two that pick the *same* one still merge. That
114
- * residue is far smaller than the case it replaces — an agent ignoring the
115
- * parameter description reaches for `conv-1` or `1`, not a conforming uuidv7 — and
116
- * closing it needs a signed handle, i.e. a secret shared by every pod. Worth doing
117
- * if this ever anchors something security-bearing; `$session_id` is an analytics
118
- * grouping key, so it does not today.
107
+ * memory of what it minted. Residual risk and why it's accepted: ADR-0004.
119
108
  */
120
109
  export function resolveConversationId(enabled: boolean, args: unknown): ConversationIdResolution {
121
110
  if (!enabled) {
@@ -123,10 +112,9 @@ export function resolveConversationId(enabled: boolean, args: unknown): Conversa
123
112
  }
124
113
  const supplied = extractConversationId(args)
125
114
  if (supplied && MINTED_CONVERSATION_ID.test(supplied)) {
126
- // Lowercased because the shape test is case-insensitive but the hash behind
127
- // `$session_id` is not. Some hosts normalise uuids to uppercase, and an
128
- // uppercased echo of our own handle would otherwise clear the gate and then
129
- // land in a different session than the call that minted it.
115
+ // Lowercased: the shape test is case-insensitive but the hash behind
116
+ // `$session_id` is not, so an uppercased echo (some hosts normalise uuids)
117
+ // would land in a different session than the call that minted it.
130
118
  return { minted: false, conversationId: supplied.toLowerCase() }
131
119
  }
132
120
  return { minted: true, conversationId: uuidv7() }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The single place this package reads an HTTP request header.
3
+ *
4
+ * The MCP SDK hands us `IsomorphicHeaders` — a plain
5
+ * `Record<string, string | string[] | undefined>`, not a `Headers` instance — so
6
+ * values come out by bracket access rather than `.get()`. MCP SDK v2 plans to
7
+ * pass a real `Headers` object instead; funnelling every read through here keeps
8
+ * that migration a one-function change instead of a hunt for bracket lookups.
9
+ */
10
+
11
+ /**
12
+ * Reads one header value out of an `IsomorphicHeaders` bag. `name` must be
13
+ * lower-cased: the HTTP transports build the bag with
14
+ * `Object.fromEntries(req.headers.entries())` and the Fetch spec lower-cases
15
+ * header names. A hand-rolled transport can still hand us mixed-case keys, so a
16
+ * case-insensitive scan backs up the direct lookup.
17
+ *
18
+ * Repeated headers arrive as an array — we take the first entry, since every
19
+ * header we read is single-valued by definition. Returns `undefined` for a
20
+ * missing, non-string, or blank value, and never throws: a malformed header from
21
+ * an untrusted client must not break the request it rode in on.
22
+ */
23
+ export function readHeaderValue(headers: unknown, name: string): string | undefined {
24
+ if (!headers || typeof headers !== 'object') {
25
+ return undefined
26
+ }
27
+ const record = headers as Record<string, unknown>
28
+ let value = record[name]
29
+ if (value === undefined) {
30
+ const key = Object.keys(record).find((k) => k.toLowerCase() === name)
31
+ value = key === undefined ? undefined : record[key]
32
+ }
33
+ return normalizeHeaderString(value)
34
+ }
35
+
36
+ /**
37
+ * Coerces one raw header value into a clean string, or `undefined`.
38
+ *
39
+ * Exported so the custom-dispatcher path can apply the same rules to values a host
40
+ * read off the request itself. Without it the two integration paths emit different
41
+ * properties for the *same* request: a whitespace-only header would survive as a junk
42
+ * value, and a repeated header would reach the wire as an array, because the
43
+ * downstream length cap passes non-strings through untouched.
44
+ */
45
+ export function normalizeHeaderString(value: unknown): string | undefined {
46
+ const first = Array.isArray(value) ? value[0] : value
47
+ if (typeof first !== 'string') {
48
+ return undefined
49
+ }
50
+ const trimmed = first.trim()
51
+ return trimmed.length > 0 ? trimmed : undefined
52
+ }
@@ -280,7 +280,6 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
280
280
  const lowLevelServer = server.server
281
281
  const mcpAnalyticsData = getServerTrackingData(lowLevelServer)
282
282
 
283
- // Patch already existing handlers, and patch setRequestHandler to capture dynamically created handlers.
284
283
  const handlers: Record<string, HandlerPatch> = {
285
284
  initialize: (trackedServer, originalHandler, request, extra) =>
286
285
  handleInitializeRequest(trackedServer, originalHandler, request, extra, logger),
@@ -3,7 +3,6 @@
3
3
  // Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
- import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
7
6
  import type { CompatibleRequestHandlerExtra, MCPRequestLike, MCPServerLike } from '../types'
8
7
  import { MCPAnalyticsEventType } from './event-types'
9
8
  import { getServerTrackingData } from './internal'
@@ -13,6 +12,7 @@ import {
13
12
  handleInitializeRequest,
14
13
  handleListToolsRequest,
15
14
  patchRequestHandlers,
15
+ registerFallbackRequestHandler,
16
16
  captureToolCall,
17
17
  isToolAdvertised,
18
18
  type HandlerPatch,
@@ -32,25 +32,24 @@ type MCPRequestExtra = Parameters<MCPRequestHandler>[1]
32
32
  */
33
33
  export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn): void {
34
34
  try {
35
- // Patch already existing handlers, and patch setRequestHandler to capture dynamically created handlers.
36
35
  const hadCallToolHandler = server._requestHandlers.has('tools/call')
36
+ const traceToolCall: HandlerPatch = (server, originalHandler, request, extra) =>
37
+ handleToolCallRequest(server, originalHandler, request, extra, logger)
37
38
  const handlers: Record<string, HandlerPatch> = {
38
39
  initialize: (server, originalHandler, request, extra) =>
39
40
  handleInitializeRequest(server, originalHandler, request, extra, logger),
40
41
  'tools/list': (server, originalHandler, request, extra) =>
41
42
  handleListToolsRequest(server, originalHandler, request, extra, logger),
42
- 'tools/call': (server, originalHandler, request, extra) =>
43
- handleToolCallRequest(server, originalHandler, request, extra, logger),
43
+ 'tools/call': traceToolCall,
44
44
  }
45
45
  patchRequestHandlers(server, handlers)
46
46
 
47
47
  if (!hadCallToolHandler) {
48
- // Register a raw fallback through the patched setter so reportMissing works
49
- // even before an application dispatcher is attached. A later registration
50
- // replaces it and is wrapped by patchRequestHandlers.
51
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
52
- throw new Error(`Unknown tool: ${request.params?.name || 'unknown'}`)
53
- })
48
+ // Register a raw fallback so reportMissing works even before an application
49
+ // dispatcher is attached. A later registration replaces it and is wrapped by
50
+ // the patched setRequestHandler. Written into the handler map directly — see
51
+ // registerFallbackRequestHandler for why the SDK setter is the wrong door.
52
+ registerFallbackRequestHandler(server, 'tools/call', unknownToolHandler, traceToolCall)
54
53
  }
55
54
  } catch (error) {
56
55
  logger(`Warning: Failed to setup tool call instrumentation - ${error}`)
@@ -58,6 +57,11 @@ export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn
58
57
  }
59
58
  }
60
59
 
60
+ /** Stands in for an application dispatcher that has not been attached yet. */
61
+ async function unknownToolHandler(request: MCPRequestLike): Promise<never> {
62
+ throw new Error(`Unknown tool: ${request.params?.name || 'unknown'}`)
63
+ }
64
+
61
65
  async function handleToolCallRequest(
62
66
  server: MCPServerLike,
63
67
  originalCallToolHandler: MCPRequestHandler | undefined,
@@ -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'
@@ -32,7 +33,7 @@ import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from
32
33
  import { getServerTrackingData, handleIdentify, setServerTrackingData, withIdentity } from './internal'
33
34
  import type { LoggerFn } from './logger'
34
35
  import { buildCapturedMcpParameters } from './mcp-payloads'
35
- import { getLiteralValue, getObjectShape } from './mcp-sdk-compat'
36
+ import { readRequestHandlerMethod } from './mcp-sdk-compat'
36
37
  import { getSessionId, getSessionInfo, newSessionId } from './session'
37
38
  import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
38
39
  import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
@@ -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(
@@ -386,8 +370,45 @@ function rememberOriginalRequestHandler(
386
370
  handlers.set(handlerName, originalHandler)
387
371
  }
388
372
 
373
+ /**
374
+ * Registers a synthetic fallback handler for `handlerName`, already wrapped in
375
+ * `patch`, by writing straight into `_requestHandlers` instead of going through
376
+ * `setRequestHandler`.
377
+ *
378
+ * Bypassing the SDK setter is deliberate, on three counts:
379
+ *
380
+ * - **Capability assertion.** `setRequestHandler` refuses a method the server
381
+ * never declared a capability for, so instrumenting a low-level server built
382
+ * without `capabilities.tools` used to throw `Server does not support tools`
383
+ * and leave instrumentation half-applied. Our fallback is not a capability the
384
+ * server offers — it exists only so a call for a tool nobody claims is still
385
+ * captured — so the assertion has nothing to protect here.
386
+ * - **Schema validation.** The setter also wraps the handler in request/result
387
+ * parsing, which a handler that can only ever throw `Unknown tool` never needs.
388
+ * - **Portability.** The setter's first argument is a Zod schema on SDK v1 and a
389
+ * method string on v2; the map key is the same string on both, so this is the
390
+ * one registration form that does not need to know which major it is talking
391
+ * to — and it drops the last runtime `@modelcontextprotocol/sdk` import from
392
+ * the shipped bundle.
393
+ */
394
+ export function registerFallbackRequestHandler(
395
+ server: MCPServerLike,
396
+ handlerName: string,
397
+ fallbackHandler: MCPRequestHandler,
398
+ patch: HandlerPatch
399
+ ): void {
400
+ rememberOriginalRequestHandler(server, handlerName, fallbackHandler)
401
+ server._requestHandlers.set(handlerName, (request, extra) => patch(server, fallbackHandler, request, extra))
402
+ }
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
+ */
389
411
  export function patchRequestHandlers(server: MCPServerLike, patches: Record<string, HandlerPatch>): void {
390
- // Monkey patch existing handlers.
391
412
  for (const [handlerName, patch] of Object.entries(patches)) {
392
413
  const originalHandler = server._requestHandlers.get(handlerName)
393
414
  if (originalHandler) {
@@ -397,18 +418,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
397
418
  }
398
419
 
399
420
  // Monkey patch dynamically added handlers (registered after instrument()).
400
- const originalSetRequestHandler = server.setRequestHandler.bind(server)
401
- server.setRequestHandler = ((requestSchema: unknown, originalHandler: MCPRequestHandler) => {
402
- const shape = getObjectShape(requestSchema)
403
- const handlerName = shape?.method ? getLiteralValue(shape.method) : undefined
404
- const patch = typeof handlerName === 'string' ? patches[handlerName] : undefined
405
- if (!patch || typeof handlerName !== 'string') {
406
- return originalSetRequestHandler(requestSchema, originalHandler)
421
+ //
422
+ // Variadic, and every argument is forwarded verbatim. The registration form is
423
+ // the SDK's business, not ours — only *that* a registration happened is ours.
424
+ // SDK v2 has a three-argument form for custom methods,
425
+ // `setRequestHandler(method, { params, result }, handler)`, and a two-parameter
426
+ // wrapper drops the handler: the SDK then sees the schemas object where the
427
+ // handler should be and throws `setRequestHandler: handler is required`, which
428
+ // takes down the host server rather than just our instrumentation.
429
+ const originalSetRequestHandler = server.setRequestHandler.bind(server) as (...args: unknown[]) => unknown
430
+ server.setRequestHandler = ((...args: unknown[]) => {
431
+ const handlerName = readRequestHandlerMethod(args[0])
432
+ // `hasOwnProperty`, not a bare index: `handlerName` is now an arbitrary
433
+ // caller-supplied string, and a custom method named `toString` would
434
+ // otherwise resolve to an inherited function and be treated as a patch.
435
+ const patch =
436
+ handlerName !== undefined && Object.prototype.hasOwnProperty.call(patches, handlerName)
437
+ ? patches[handlerName]
438
+ : undefined
439
+ if (handlerName === undefined || !patch) {
440
+ return originalSetRequestHandler(...args)
407
441
  }
408
442
 
409
443
  // Register first so the MCP SDK's request/result validation stays inside
410
444
  // our analytics wrapper, matching handlers that existed before instrument().
411
- const result = originalSetRequestHandler(requestSchema, originalHandler)
445
+ const result = originalSetRequestHandler(...args)
412
446
  const registeredHandler = server._requestHandlers.get(handlerName)
413
447
  if (registeredHandler) {
414
448
  rememberOriginalRequestHandler(server, handlerName, registeredHandler)
@@ -475,6 +509,7 @@ export async function handleListToolsRequest(
475
509
  timestamp: startTime,
476
510
  }
477
511
  stampMetaClientInfo(event, request)
512
+ stampTransportIdentity(event, extra)
478
513
 
479
514
  if (data) {
480
515
  await applyResolvedMetadata(event, data, request, extra)
@@ -639,7 +674,7 @@ export function cacheToolCategories(cache: Map<string, string>, tools: ListTools
639
674
  * client name/version is lost after `initialize`. Fix: mint the
640
675
  * `Mcp-Session-Id` response header as a token carrying both. Clients replay
641
676
  * the header on every request, so any pod recovers them with no server-side
642
- * store (decoded in `getSessionId`).
677
+ * store (decoded in `getSessionId`). See ADR-0003.
643
678
  *
644
679
  * The header only reaches the wire when response headers are built after the
645
680
  * handler runs — StreamableHTTP with `enableJsonResponse: true`. SSE flushes
@@ -786,6 +821,7 @@ export async function handleInitializeRequest(
786
821
  // negotiated protocol version below overrides any `_meta` one); picks up client
787
822
  // info if a client also sends it in `_meta`.
788
823
  stampMetaClientInfo(event, request)
824
+ stampTransportIdentity(event, extra)
789
825
 
790
826
  await applyResolvedMetadata(event, data, request, extra)
791
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 =
@@ -143,6 +143,24 @@ export function getObjectShape(schema: unknown): Record<string, unknown> | undef
143
143
  return rawShape
144
144
  }
145
145
 
146
+ /**
147
+ * Reads the method name a `setRequestHandler` registration is for.
148
+ *
149
+ * The two SDK majors disagree on what the first argument is: v1 passes the Zod
150
+ * schema for the request and carries the method as a literal on its shape, v2
151
+ * passes the method string itself. A registration we cannot name is one we
152
+ * cannot match against a patch — which is how a handler registered after
153
+ * `instrument()` on v2 ends up replacing our wrapper instead of being wrapped.
154
+ */
155
+ export function readRequestHandlerMethod(requestSchema: unknown): string | undefined {
156
+ if (typeof requestSchema === 'string') {
157
+ return requestSchema
158
+ }
159
+ const shape = getObjectShape(requestSchema)
160
+ const method = shape?.method ? getLiteralValue(shape.method) : undefined
161
+ return typeof method === 'string' ? method : undefined
162
+ }
163
+
146
164
  export function getLiteralValue(schema: unknown): unknown {
147
165
  if (!schema || typeof schema !== 'object') {
148
166
  return
@@ -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
  }