@posthog/mcp 0.11.7 → 0.13.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 (93) hide show
  1. package/README.md +51 -2
  2. package/dist/extensions/analytics-parameters.d.ts +22 -2
  3. package/dist/extensions/analytics-parameters.d.ts.map +1 -1
  4. package/dist/extensions/analytics-parameters.js +53 -10
  5. package/dist/extensions/analytics-parameters.mjs +37 -1
  6. package/dist/extensions/capture.d.ts.map +1 -1
  7. package/dist/extensions/capture.js +14 -8
  8. package/dist/extensions/capture.mjs +2 -0
  9. package/dist/extensions/client-identity.js +18 -14
  10. package/dist/extensions/compatibility.js +16 -12
  11. package/dist/extensions/constants.d.ts +3 -0
  12. package/dist/extensions/constants.d.ts.map +1 -1
  13. package/dist/extensions/constants.js +28 -18
  14. package/dist/extensions/constants.mjs +5 -2
  15. package/dist/extensions/context-parameters.d.ts +2 -2
  16. package/dist/extensions/context-parameters.d.ts.map +1 -1
  17. package/dist/extensions/context-parameters.js +14 -38
  18. package/dist/extensions/context-parameters.mjs +2 -30
  19. package/dist/extensions/conversation-id.js +18 -14
  20. package/dist/extensions/detect.js +23 -19
  21. package/dist/extensions/event-types.js +15 -11
  22. package/dist/extensions/exceptions.js +12 -8
  23. package/dist/extensions/headers.js +16 -12
  24. package/dist/extensions/ids.js +12 -8
  25. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  26. package/dist/extensions/instrument-highlevel.js +16 -9
  27. package/dist/extensions/instrument-highlevel.mjs +5 -2
  28. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  29. package/dist/extensions/instrument-lowlevel.js +13 -8
  30. package/dist/extensions/instrument-lowlevel.mjs +2 -1
  31. package/dist/extensions/instrumentation.d.ts +16 -0
  32. package/dist/extensions/instrumentation.d.ts.map +1 -1
  33. package/dist/extensions/instrumentation.js +31 -14
  34. package/dist/extensions/instrumentation.mjs +11 -1
  35. package/dist/extensions/intent.js +14 -10
  36. package/dist/extensions/internal.js +17 -13
  37. package/dist/extensions/lib-identity.js +12 -8
  38. package/dist/extensions/logger.js +17 -13
  39. package/dist/extensions/mcp-payloads.js +16 -12
  40. package/dist/extensions/mcp-sdk-compat.js +23 -19
  41. package/dist/extensions/model-parameters.d.ts +48 -0
  42. package/dist/extensions/model-parameters.d.ts.map +1 -0
  43. package/dist/extensions/model-parameters.js +84 -0
  44. package/dist/extensions/model-parameters.mjs +31 -0
  45. package/dist/extensions/output-instructions.js +15 -11
  46. package/dist/extensions/posthog-events.js +14 -8
  47. package/dist/extensions/posthog-events.mjs +2 -0
  48. package/dist/extensions/posthog-mcp.d.ts +23 -12
  49. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  50. package/dist/extensions/posthog-mcp.js +49 -11
  51. package/dist/extensions/posthog-mcp.mjs +37 -3
  52. package/dist/extensions/request-headers.js +15 -11
  53. package/dist/extensions/sanitization.d.ts +2 -2
  54. package/dist/extensions/sanitization.d.ts.map +1 -1
  55. package/dist/extensions/sanitization.js +13 -8
  56. package/dist/extensions/sanitization.mjs +1 -0
  57. package/dist/extensions/session-token.js +17 -13
  58. package/dist/extensions/session-token.mjs +2 -2
  59. package/dist/extensions/session.js +16 -12
  60. package/dist/extensions/sink.js +14 -10
  61. package/dist/extensions/tools.js +15 -11
  62. package/dist/extensions/tracing-helpers.js +15 -11
  63. package/dist/extensions/transport-identity.js +15 -11
  64. package/dist/extensions/truncation.d.ts.map +1 -1
  65. package/dist/extensions/truncation.js +22 -16
  66. package/dist/extensions/truncation.mjs +1 -0
  67. package/dist/index.d.ts +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +22 -18
  70. package/dist/index.mjs +7 -7
  71. package/dist/types.d.ts +55 -4
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/types.js +2 -2
  74. package/dist/types.mjs +1 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +15 -11
  77. package/dist/version.mjs +1 -1
  78. package/package.json +28 -11
  79. package/src/extensions/analytics-parameters.ts +88 -2
  80. package/src/extensions/capture.ts +4 -0
  81. package/src/extensions/constants.ts +4 -0
  82. package/src/extensions/context-parameters.ts +10 -59
  83. package/src/extensions/instrument-highlevel.ts +4 -0
  84. package/src/extensions/instrument-lowlevel.ts +2 -0
  85. package/src/extensions/instrumentation.ts +51 -0
  86. package/src/extensions/model-parameters.ts +98 -0
  87. package/src/extensions/posthog-events.ts +8 -0
  88. package/src/extensions/posthog-mcp.ts +84 -12
  89. package/src/extensions/sanitization.ts +6 -2
  90. package/src/extensions/truncation.ts +3 -0
  91. package/src/index.ts +3 -0
  92. package/src/types.ts +55 -4
  93. package/src/version.ts +1 -1
@@ -4,11 +4,7 @@
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
6
  import type { MCPAnalyticsOptions } from '../types'
7
- import {
8
- canInjectAnalyticsParameter,
9
- hasAnalyticsParameter,
10
- type AnalyticsInjectableJsonSchema,
11
- } from './analytics-parameters'
7
+ import { addAnalyticsParameterToTool, type AnalyticsInjectableJsonSchema } from './analytics-parameters'
12
8
  import { DEFAULT_CONTEXT_PARAMETER_DESCRIPTION } from './constants'
13
9
  import { log, type LoggerFn } from './logger'
14
10
 
@@ -27,8 +23,8 @@ export function getContextDescription(context: MCPAnalyticsOptions['context']):
27
23
  }
28
24
 
29
25
  /**
30
- * Adds a context parameter to a tool's JSON Schema.
31
- * This function is called AFTER the MCP SDK has converted Zod schemas to JSON Schema,
26
+ * Adds a context parameter to a tool's JSON Schema, via the shared injector.
27
+ * This is called AFTER the MCP SDK has converted Zod schemas to JSON Schema,
32
28
  * so we only need to handle JSON Schema format.
33
29
  *
34
30
  * Skips injection (with warning) for:
@@ -41,58 +37,13 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
41
37
  contextDescriptionOverride?: string,
42
38
  logger: LoggerFn = log
43
39
  ): TTool {
44
- const modifiedTool = { ...tool }
45
- const toolName = tool.name || 'unknown'
46
- const schema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema | undefined
47
-
48
- if (!canInjectAnalyticsParameter(schema, 'context')) {
49
- if (hasAnalyticsParameter(schema, 'context')) {
50
- logger(`WARN: Tool "${toolName}" already has 'context' parameter. Skipping context injection.`)
51
- } else {
52
- logger(`WARN: Tool "${toolName}" has complex schema (oneOf/allOf/anyOf/$ref). Skipping context injection.`)
53
- }
54
- return modifiedTool
55
- }
56
-
57
- if (!modifiedTool.inputSchema) {
58
- modifiedTool.inputSchema = {
59
- type: 'object',
60
- properties: {},
61
- required: [],
62
- }
63
- }
64
-
65
- const contextDescription = contextDescriptionOverride || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION
66
-
67
- // Deep copy: the server may reuse or freeze the schema object it handed us.
68
- modifiedTool.inputSchema = JSON.parse(JSON.stringify(modifiedTool.inputSchema)) as AnalyticsInjectableJsonSchema
69
-
70
- const inputSchema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema
71
-
72
- if (!inputSchema.properties) {
73
- inputSchema.properties = {}
74
- }
75
-
76
- // The MCP SDK emits `additionalProperties: false` when converting Zod schemas;
77
- // left in place it would make the injected `context` key invalid.
78
- if (inputSchema.additionalProperties === false) {
79
- inputSchema.additionalProperties = undefined
80
- }
81
-
82
- inputSchema.properties.context = {
83
- type: 'string',
84
- description: contextDescription,
85
- }
86
-
87
- if (Array.isArray(inputSchema.required)) {
88
- if (!inputSchema.required.includes('context')) {
89
- inputSchema.required.push('context')
90
- }
91
- } else {
92
- inputSchema.required = ['context']
93
- }
94
-
95
- return modifiedTool
40
+ return addAnalyticsParameterToTool(
41
+ tool,
42
+ 'context',
43
+ contextDescriptionOverride || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION,
44
+ 'context',
45
+ logger
46
+ )
96
47
  }
97
48
 
98
49
  export function addContextParameterToTools<TTool extends ContextInjectableTool>(
@@ -17,6 +17,7 @@ import {
17
17
  stripOwnedAnalyticsArguments,
18
18
  } from './analytics-parameters'
19
19
  import { isContextEnabled } from './context-parameters'
20
+ import { isCaptureModelEnabled } from './model-parameters'
20
21
  import { MCPAnalyticsEventType } from './event-types'
21
22
  import { getServerTrackingData } from './internal'
22
23
  import type { LoggerFn } from './logger'
@@ -27,6 +28,7 @@ import {
27
28
  handleListToolsRequest,
28
29
  patchRequestHandlers,
29
30
  captureToolCall,
31
+ getVirtualToolParameterOwnership,
30
32
  isToolAdvertised,
31
33
  readToolMetaCategory,
32
34
  type HandlerPatch,
@@ -189,6 +191,7 @@ function addTracingToToolCallbackInternal(
189
191
  const cleanedArgs = stripOwnedAnalyticsArguments(args, {
190
192
  context: isContextEnabled(options?.context) && analyticsOwnsParameter(inputSchema, 'context'),
191
193
  conversationId: options?.enableConversationId === true && analyticsOwnsParameter(inputSchema, 'conversation_id'),
194
+ llmModel: isCaptureModelEnabled(options?.captureModel) && analyticsOwnsParameter(inputSchema, 'llm_model'),
192
195
  })
193
196
  try {
194
197
  if (cleanedArgs === undefined) {
@@ -247,6 +250,7 @@ async function handleToolCallRequest(
247
250
  extra,
248
251
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
249
252
  explicitContextIntent: context,
253
+ parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
250
254
  execute: async () => handleReportMissing({ context }, data.logger),
251
255
  })
252
256
  }
@@ -14,6 +14,7 @@ import {
14
14
  patchRequestHandlers,
15
15
  registerFallbackRequestHandler,
16
16
  captureToolCall,
17
+ getVirtualToolParameterOwnership,
17
18
  isToolAdvertised,
18
19
  type HandlerPatch,
19
20
  } from './instrumentation'
@@ -90,6 +91,7 @@ async function handleToolCallRequest(
90
91
  extra,
91
92
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
92
93
  explicitContextIntent: context,
94
+ parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
93
95
  execute: async () => handleReportMissing({ context }, data.logger),
94
96
  })
95
97
  }
@@ -16,6 +16,13 @@ import type {
16
16
  } from '../types'
17
17
  import { getAnalyticsParameterOwnership, stripOwnedAnalyticsArguments } from './analytics-parameters'
18
18
  import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
19
+ import {
20
+ addModelParameterToTools,
21
+ getModelArgument,
22
+ getModelDescription,
23
+ isCaptureModelEnabled,
24
+ setEventModel,
25
+ } from './model-parameters'
19
26
  import {
20
27
  addConversationIdToTools,
21
28
  type ConversationIdResolution,
@@ -179,6 +186,7 @@ function getActiveAnalyticsParameterOwnership(
179
186
  contextOwnershipKnown: ownership !== undefined,
180
187
  context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
181
188
  conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
189
+ llmModel: isCaptureModelEnabled(data.options.captureModel) && ownership?.llmModel === true,
182
190
  // Deliberately read off `listed`, never the override: only the advertised
183
191
  // JSON Schema can say whether `tools/list` declared `_mcp_instructions` (an
184
192
  // override is built from the live registry, which holds Zod on the
@@ -263,6 +271,13 @@ async function prepareToolCallEvent(
263
271
 
264
272
  await applyResolvedMetadata(event, data, request, extra)
265
273
  setEventIntent(event, await resolveToolCallIntent(data, request, canCaptureContextIntent, extra))
274
+ // Unlike intent, the model is only read under positive ownership: with
275
+ // ownership unresolved, `llm_model` may be the application's own argument,
276
+ // and recording a customer value as the calling agent's model is worse
277
+ // than a gap in coverage.
278
+ if (ownership.llmModel) {
279
+ setEventModel(event, getModelArgument(request))
280
+ }
266
281
  return { event, requestAttribution }
267
282
  } catch (error) {
268
283
  data.logger(
@@ -493,6 +508,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
493
508
  }) as MCPServerLike['setRequestHandler']
494
509
  }
495
510
 
511
+ /**
512
+ * Ownership for the `get_more_tools` virtual tool, resolved without the
513
+ * `tools/list` cache.
514
+ *
515
+ * The missing-capability branch is only entered when the application does not
516
+ * advertise a tool by this name, so the descriptor is the SDK's own and what it
517
+ * declares is known statically. An instance that never served a listing — a
518
+ * per-request `McpServer`/`Server`, the topology in ADR-0011 — would otherwise
519
+ * read every injected parameter as not-ours and neither capture nor strip it.
520
+ *
521
+ * `conversation_id` still comes from the cache on purpose: resolving it here too
522
+ * would start minting a handle, and appending its prompt-back block, on
523
+ * instances that today mint none. That changes session anchoring (ADR-0004)
524
+ * rather than closing this gap.
525
+ */
526
+ export function getVirtualToolParameterOwnership(
527
+ data: MCPAnalyticsData,
528
+ toolName: string
529
+ ): AnalyticsParameterOwnership {
530
+ return {
531
+ ...getAnalyticsParameterOwnership(getReportMissingToolDescriptor(toolName).inputSchema),
532
+ conversationId: data.toolAnalyticsParameterOwnership.get(toolName)?.conversationId === true,
533
+ }
534
+ }
535
+
496
536
  /**
497
537
  * Checks the server's raw listing for a real owner of a candidate virtual tool.
498
538
  * This does not depend on a previous client request and does not call the
@@ -664,6 +704,17 @@ async function getTracedToolsList(
664
704
  }
665
705
  }
666
706
 
707
+ // After the virtual tool is appended, so it advertises `llm_model` too.
708
+ // Its ownership was cached above from the un-injected descriptor, so the
709
+ // SDK already strips and captures the argument on its calls — without
710
+ // injecting here the agent would never be asked for one, and
711
+ // missing-capability reports would be the only calls with no model.
712
+ // `context` is deliberately not injected into it: the virtual tool
713
+ // declares its own, and that argument is the report itself.
714
+ if (isCaptureModelEnabled(data.options.captureModel)) {
715
+ tools = addModelParameterToTools(tools, getModelDescription(data.options.captureModel), data.logger)
716
+ }
717
+
667
718
  if (data.options.enableConversationId) {
668
719
  tools = addConversationIdToTools(tools, data.logger)
669
720
  tools = addInstructionsToOutputSchemas(tools, data.logger)
@@ -0,0 +1,98 @@
1
+ import type { MCPAnalyticsOptions, MCPRequestLike, McpEvent } from '../types'
2
+ import { addAnalyticsParameterToTool, type AnalyticsInjectableJsonSchema } from './analytics-parameters'
3
+ import { DEFAULT_MODEL_PARAMETER_DESCRIPTION } from './constants'
4
+ import { log, type LoggerFn } from './logger'
5
+
6
+ /**
7
+ * Self-reported model capture (`captureModel`).
8
+ *
9
+ * MCP keeps servers deliberately model-ignorant: the wire carries client
10
+ * name/version and protocol version, never the LLM behind the client, and no
11
+ * spec revision changes that. The one place the information exists on the
12
+ * server side of the connection is the agent itself — harnesses inject the
13
+ * model id into the system prompt, so the agent can state it the same way it
14
+ * states its intent through the `context` parameter.
15
+ *
16
+ * This module injects a required `llm_model` string parameter into every tool
17
+ * (mirroring `context-parameters.ts`), strips it before the tool runs, and
18
+ * captures it as `$mcp_llm_model` with `$mcp_llm_model_source = "self_reported"`.
19
+ * The source property is deliberate: like `clientInfo` in the MCP spec, the
20
+ * value is self-reported and unverified — right for degradation analytics
21
+ * ("does our MCP get worse on model X?"), never for billing or security.
22
+ *
23
+ * Reasoning effort is deliberately NOT captured: it never crosses the wire,
24
+ * and models cannot reliably self-report it (harnesses apply it as a sampling
25
+ * parameter the model never sees), so any captured value would be noise.
26
+ */
27
+
28
+ export interface ModelInjectableTool {
29
+ inputSchema?: AnalyticsInjectableJsonSchema
30
+ name?: string
31
+ [key: string]: unknown
32
+ }
33
+
34
+ /** Unlike `context`, model capture is opt-in: off unless explicitly enabled. */
35
+ export function isCaptureModelEnabled(captureModel: MCPAnalyticsOptions['captureModel']): boolean {
36
+ return captureModel === true || (typeof captureModel === 'object' && captureModel !== null)
37
+ }
38
+
39
+ export function getModelDescription(captureModel: MCPAnalyticsOptions['captureModel']): string | undefined {
40
+ return typeof captureModel === 'object' && captureModel !== null ? captureModel.description : undefined
41
+ }
42
+
43
+ /**
44
+ * Adds an `llm_model` parameter to a tool's JSON Schema, via the shared
45
+ * injector so schema handling stays identical to `context`: a tool that already
46
+ * declares `llm_model` owns it, and complex schemas can't safely gain keys.
47
+ */
48
+ export function addModelParameterToTool<TTool extends ModelInjectableTool>(
49
+ tool: TTool,
50
+ modelDescriptionOverride?: string,
51
+ logger: LoggerFn = log
52
+ ): TTool {
53
+ return addAnalyticsParameterToTool(
54
+ tool,
55
+ 'llm_model',
56
+ modelDescriptionOverride || DEFAULT_MODEL_PARAMETER_DESCRIPTION,
57
+ 'model',
58
+ logger
59
+ )
60
+ }
61
+
62
+ export function addModelParameterToTools<TTool extends ModelInjectableTool>(
63
+ tools: TTool[],
64
+ modelDescriptionOverride?: string,
65
+ logger: LoggerFn = log
66
+ ): TTool[] {
67
+ return tools.map((tool) => addModelParameterToTool(tool, modelDescriptionOverride, logger))
68
+ }
69
+
70
+ /**
71
+ * Reads the self-reported model off a tool-call request. Returns `undefined`
72
+ * for a missing, blank, or `"unknown"` value — the parameter description asks
73
+ * agents to pass `unknown` when uncertain, and an honest "I don't know" must
74
+ * not become a property value queries would group by.
75
+ */
76
+ export function getModelArgument(request: MCPRequestLike): string | undefined {
77
+ return normalizeModel(request.params?.arguments?.llm_model)
78
+ }
79
+
80
+ function normalizeModel(model: unknown): string | undefined {
81
+ if (typeof model !== 'string') {
82
+ return undefined
83
+ }
84
+ const trimmed = model.trim()
85
+ if (!trimmed || trimmed.toLowerCase() === 'unknown') {
86
+ return undefined
87
+ }
88
+ return trimmed
89
+ }
90
+
91
+ export function setEventModel(event: McpEvent, model: string | undefined): void {
92
+ const normalizedModel = normalizeModel(model)
93
+ if (!normalizedModel) {
94
+ return
95
+ }
96
+ event.llmModel = normalizedModel
97
+ event.llmModelSource = 'self_reported'
98
+ }
@@ -170,6 +170,14 @@ function addCommonEventProperties(event: Event, properties: Record<string, unkno
170
170
  if (event.userIntentSource) {
171
171
  properties[PostHogMCPAnalyticsProperty.IntentSource] = event.userIntentSource
172
172
  }
173
+ // Self-reported by the calling agent (`captureModel`), so present only on the
174
+ // requests where the agent supplied a definite value — never inferred.
175
+ if (event.llmModel) {
176
+ properties[PostHogMCPAnalyticsProperty.LlmModel] = event.llmModel
177
+ }
178
+ if (event.llmModelSource) {
179
+ properties[PostHogMCPAnalyticsProperty.LlmModelSource] = event.llmModelSource
180
+ }
173
181
  if (event.isError !== undefined) {
174
182
  properties[PostHogMCPAnalyticsProperty.IsError] = event.isError
175
183
  }
@@ -3,14 +3,17 @@ import { PostHog, type PostHogOptions } from 'posthog-node'
3
3
  import type {
4
4
  InitializeCaptureData,
5
5
  JsonRecord,
6
+ MCPAnalyticsOptions,
6
7
  McpCaptureCommon,
7
8
  McpEvent,
8
9
  MissingCapabilityCaptureData,
9
10
  PreparedToolCall,
11
+ PrepareToolCallOptions,
10
12
  PrepareToolListOptions,
11
13
  ToolCallCaptureData,
12
14
  ToolsListCaptureData,
13
15
  } from '../types'
16
+ import { analyticsOwnsParameter, stripOwnedAnalyticsArguments } from './analytics-parameters'
14
17
  import {
15
18
  addContextParameterToTools,
16
19
  getContextDescription,
@@ -22,6 +25,13 @@ import { captureException } from './exceptions'
22
25
  import { normalizeHeaderString } from './headers'
23
26
  import { applyMcpLibIdentity } from './lib-identity'
24
27
  import { log } from './logger'
28
+ import {
29
+ addModelParameterToTool,
30
+ getModelArgument,
31
+ getModelDescription,
32
+ isCaptureModelEnabled,
33
+ setEventModel,
34
+ } from './model-parameters'
25
35
  import { McpEventSink } from './sink'
26
36
  import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
27
37
 
@@ -37,6 +47,11 @@ export interface PostHogMCPOptions extends PostHogOptions {
37
47
  * can't drift. Defaults to `get_more_tools`.
38
48
  */
39
49
  missingCapabilityToolName?: string
50
+ /**
51
+ * Inject a required `llm_model` argument into advertised tools and capture
52
+ * the agent's self-reported value. Off by default.
53
+ */
54
+ captureModel?: MCPAnalyticsOptions['captureModel']
40
55
  }
41
56
 
42
57
  /**
@@ -57,7 +72,10 @@ export interface PostHogMCPOptions extends PostHogOptions {
57
72
  * ```ts
58
73
  * import { PostHogMCP } from "@posthog/mcp"
59
74
  *
60
- * const posthog = new PostHogMCP("phc_your_project_token", { host: "https://us.i.posthog.com" })
75
+ * const posthog = new PostHogMCP("phc_your_project_token", {
76
+ * host: "https://us.i.posthog.com",
77
+ * captureModel: true,
78
+ * })
61
79
  *
62
80
  * posthog.captureToolCall({
63
81
  * toolName: "search_docs",
@@ -78,10 +96,13 @@ export class PostHogMCP extends PostHog {
78
96
  // The get_more_tools name lives here (not on the per-call options) so that
79
97
  // prepareToolList (inject) and prepareToolCall (detect) always agree.
80
98
  readonly #missingCapabilityToolName: string
99
+ readonly #captureModel: MCPAnalyticsOptions['captureModel']
100
+ readonly #modelParameterOwnership = new Map<string, boolean>()
81
101
 
82
102
  constructor(apiKey: string, options: PostHogMCPOptions = {}) {
83
103
  super(apiKey, options)
84
104
  this.#missingCapabilityToolName = options.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
105
+ this.#captureModel = options.captureModel
85
106
  applyMcpLibIdentity(this)
86
107
  }
87
108
 
@@ -97,6 +118,10 @@ export class PostHogMCP extends PostHog {
97
118
  event.isError = data.isError
98
119
  event.errorType = data.errorType
99
120
  applyIntent(event, data.intent, data.intentSource)
121
+ setEventModel(event, data.llmModel)
122
+ if (event.llmModel && data.llmModelSource) {
123
+ event.llmModelSource = data.llmModelSource
124
+ }
100
125
  if (data.isError) {
101
126
  event.error = captureException(data.error ?? `Tool ${data.toolName} returned an error`)
102
127
  }
@@ -137,9 +162,9 @@ export class PostHogMCP extends PostHog {
137
162
  /**
138
163
  * Decorate your `tools/list` response with PostHog's analytics affordances:
139
164
  * injects the `context` argument into every tool (so agents state their intent,
140
- * captured as `$mcp_intent`) and, when `reportMissing` is on, appends the
141
- * `get_more_tools` virtual tool (rename it via the `missingCapabilityToolName`
142
- * constructor option). Returns a new array; your tools are untouched.
165
+ * captured as `$mcp_intent`), injects `llm_model` when the constructor's
166
+ * `captureModel` option is enabled, and appends `get_more_tools` when
167
+ * `reportMissing` is on. Returns a new array; your tools are untouched.
143
168
  *
144
169
  * The appended `get_more_tools` descriptor carries only the base MCP tool fields
145
170
  * (name, description, input schema) — not any framework-specific fields your
@@ -165,18 +190,41 @@ export class PostHogMCP extends PostHog {
165
190
  if (options.reportMissing && !prepared.some((tool) => tool?.name === this.#missingCapabilityToolName)) {
166
191
  prepared = [...prepared, getReportMissingToolDescriptor(this.#missingCapabilityToolName) as TTool]
167
192
  }
193
+
194
+ if (isCaptureModelEnabled(this.#captureModel)) {
195
+ this.#modelParameterOwnership.clear()
196
+ const ownershipByName = new Map<string, boolean>()
197
+ for (const tool of prepared) {
198
+ if (typeof tool.name !== 'string') {
199
+ continue
200
+ }
201
+ const ownsModel = analyticsOwnsParameter(tool.inputSchema, 'llm_model')
202
+ ownershipByName.set(tool.name, (ownershipByName.get(tool.name) ?? true) && ownsModel)
203
+ }
204
+ for (const [toolName, ownsModel] of ownershipByName) {
205
+ this.#modelParameterOwnership.set(toolName, ownsModel)
206
+ }
207
+ const modelDescription = getModelDescription(this.#captureModel)
208
+ prepared = prepared.map((tool) =>
209
+ typeof tool.name === 'string' && ownershipByName.get(tool.name) === false
210
+ ? tool
211
+ : addModelParameterToTool(tool, modelDescription)
212
+ )
213
+ }
168
214
  return prepared
169
215
  }
170
216
 
171
217
  /**
172
- * Read an incoming `tools/call` before you dispatch it: pulls the agent's
173
- * intent off the injected `context` argument, strips `context` from the
174
- * arguments (so your handler and its schema validation never see it), and flags
218
+ * Read an incoming `tools/call` before you dispatch it: pulls analytics values
219
+ * from SDK-owned arguments, strips those arguments before validation, and flags
175
220
  * whether the call targeted the `get_more_tools` virtual tool.
176
221
  *
177
- * Pass the returned `intent` / `intentSource` to {@link captureToolCall}, and
222
+ * Pass the returned intent and model fields to {@link captureToolCall}, and
178
223
  * dispatch the returned `args` to your tool.
179
224
  *
225
+ * On stateless or multi-replica servers, pass the original tool descriptor
226
+ * so ownership does not depend on which process served `tools/list`.
227
+ *
180
228
  * This only extracts the explicit `context` argument (`intentSource:
181
229
  * 'context_parameter'`); it does not infer intent. If you run your own
182
230
  * inference, pass that string with `intentSource: 'inferred'` straight to
@@ -185,22 +233,42 @@ export class PostHogMCP extends PostHog {
185
233
  *
186
234
  * @example
187
235
  * ```ts
188
- * const { intent, intentSource, args, isMissingCapability } = posthog.prepareToolCall(name, rawArgs)
236
+ * const { intent, intentSource, llmModel, llmModelSource, args, isMissingCapability } =
237
+ * posthog.prepareToolCall(name, rawArgs)
189
238
  * if (isMissingCapability) {
190
- * posthog.captureMissingCapability({ context: intent, ...identity })
239
+ * posthog.captureMissingCapability({ context: intent, llmModel, llmModelSource, ...identity })
191
240
  * return getMoreToolsResult()
192
241
  * }
193
242
  * const result = await runTool(name, args)
194
243
  * posthog.captureToolCall({ toolName: name, intent, intentSource, ...identity })
195
244
  * ```
196
245
  */
197
- prepareToolCall(name: string, args?: Record<string, unknown>): PreparedToolCall {
246
+ prepareToolCall(
247
+ name: string,
248
+ args?: Record<string, unknown>,
249
+ options: PrepareToolCallOptions = {}
250
+ ): PreparedToolCall {
198
251
  const rawContext = args?.context
199
252
  const intent = typeof rawContext === 'string' && rawContext.trim() ? rawContext.trim() : undefined
253
+ const ownsModel =
254
+ isCaptureModelEnabled(this.#captureModel) &&
255
+ (options.originalTool
256
+ ? analyticsOwnsParameter(options.originalTool.inputSchema, 'llm_model')
257
+ : this.#modelParameterOwnership.get(name) === true)
258
+ const llmModel = ownsModel ? getModelArgument({ params: { arguments: args } }) : undefined
259
+ const strippedArgs = stripContext(args)
200
260
  return {
201
261
  intent,
202
262
  intentSource: intent ? 'context_parameter' : undefined,
203
- args: stripContext(args),
263
+ llmModel,
264
+ llmModelSource: llmModel ? 'self_reported' : undefined,
265
+ args: ownsModel
266
+ ? (stripOwnedAnalyticsArguments(strippedArgs, {
267
+ context: false,
268
+ conversationId: false,
269
+ llmModel: true,
270
+ }) as Record<string, unknown> | undefined)
271
+ : strippedArgs,
204
272
  isMissingCapability: name === this.#missingCapabilityToolName,
205
273
  }
206
274
  }
@@ -215,6 +283,10 @@ export class PostHogMCP extends PostHog {
215
283
  event.resourceName = this.#missingCapabilityToolName
216
284
  event.parameters = data.parameters
217
285
  applyIntent(event, data.context, 'context_parameter')
286
+ setEventModel(event, data.llmModel)
287
+ if (event.llmModel && data.llmModelSource) {
288
+ event.llmModelSource = data.llmModelSource
289
+ }
218
290
  this.#emit(event)
219
291
  }
220
292
 
@@ -15,8 +15,8 @@ function isRecord(value: unknown): value is SanitizedRecord {
15
15
  /**
16
16
  * Sanitizes an event by redacting non-text content blocks from responses
17
17
  * and large base64-encoded strings from parameters, and applying the same
18
- * string redaction (PostHog tokens, base64 blobs, sensitive keys) to the
19
- * agent-supplied intent.
18
+ * string redaction (PostHog tokens, base64 blobs, sensitive keys) to values
19
+ * supplied by the agent.
20
20
  *
21
21
  * This is a synchronous operation that returns a new object without mutating the original.
22
22
  * It should run after customer redaction in the event pipeline.
@@ -39,6 +39,10 @@ export function sanitizeEvent<T extends Event | McpEvent>(event: T): T {
39
39
  result.userIntent = sanitizeCapturedValue(result.userIntent) as string
40
40
  }
41
41
 
42
+ if (result.llmModel != null) {
43
+ result.llmModel = sanitizeCapturedValue(result.llmModel) as string
44
+ }
45
+
42
46
  if (result.error != null) {
43
47
  result.error = sanitizeExceptionValues(result.error)
44
48
  }
@@ -435,6 +435,9 @@ export function truncateEvent<T extends Event | McpEvent>(event: T): T {
435
435
  result.clientUserAgent = truncateString(result.clientUserAgent, MAX_METADATA_LENGTH)
436
436
  result.vendorClient = truncateString(result.vendorClient, MAX_METADATA_LENGTH)
437
437
  result.errorType = truncateString(result.errorType, MAX_METADATA_LENGTH)
438
+ // Agent-supplied free text, same trust level as a header. Real model ids are
439
+ // tens of characters; anything past the metadata cap is junk, not a model.
440
+ result.llmModel = truncateString(result.llmModel, MAX_METADATA_LENGTH)
438
441
 
439
442
  // Error field limits — operate on the core `$exception_list` shape
440
443
  if (result.error != null && typeof result.error === 'object') {
package/src/index.ts CHANGED
@@ -228,9 +228,12 @@ export type {
228
228
  McpCaptureCommon,
229
229
  MCPAnalyticsContextOptions,
230
230
  MCPAnalyticsIntentSource,
231
+ MCPAnalyticsModelOptions,
232
+ MCPAnalyticsModelSource,
231
233
  MCPAnalyticsOptions,
232
234
  MissingCapabilityCaptureData,
233
235
  PreparedToolCall,
236
+ PrepareToolCallOptions,
234
237
  PrepareToolListOptions,
235
238
  RequestHeaderBag,
236
239
  ToolCallCaptureData,