@posthog/mcp 0.11.7 → 0.12.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 (56) hide show
  1. package/README.md +29 -0
  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 +39 -0
  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 +2 -0
  8. package/dist/extensions/capture.mjs +2 -0
  9. package/dist/extensions/constants.d.ts +3 -0
  10. package/dist/extensions/constants.d.ts.map +1 -1
  11. package/dist/extensions/constants.js +6 -0
  12. package/dist/extensions/constants.mjs +4 -1
  13. package/dist/extensions/context-parameters.d.ts +2 -2
  14. package/dist/extensions/context-parameters.d.ts.map +1 -1
  15. package/dist/extensions/context-parameters.js +1 -29
  16. package/dist/extensions/context-parameters.mjs +2 -30
  17. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  18. package/dist/extensions/instrument-highlevel.js +4 -1
  19. package/dist/extensions/instrument-highlevel.mjs +5 -2
  20. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  21. package/dist/extensions/instrument-lowlevel.js +1 -0
  22. package/dist/extensions/instrument-lowlevel.mjs +2 -1
  23. package/dist/extensions/instrumentation.d.ts +16 -0
  24. package/dist/extensions/instrumentation.d.ts.map +1 -1
  25. package/dist/extensions/instrumentation.js +15 -2
  26. package/dist/extensions/instrumentation.mjs +11 -1
  27. package/dist/extensions/model-parameters.d.ts +48 -0
  28. package/dist/extensions/model-parameters.d.ts.map +1 -0
  29. package/dist/extensions/model-parameters.js +77 -0
  30. package/dist/extensions/model-parameters.mjs +28 -0
  31. package/dist/extensions/posthog-events.js +2 -0
  32. package/dist/extensions/posthog-events.mjs +2 -0
  33. package/dist/extensions/truncation.d.ts.map +1 -1
  34. package/dist/extensions/truncation.js +1 -0
  35. package/dist/extensions/truncation.mjs +1 -0
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/types.d.ts +27 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/version.mjs +1 -1
  43. package/package.json +24 -7
  44. package/src/extensions/analytics-parameters.ts +88 -2
  45. package/src/extensions/capture.ts +4 -0
  46. package/src/extensions/constants.ts +4 -0
  47. package/src/extensions/context-parameters.ts +10 -59
  48. package/src/extensions/instrument-highlevel.ts +4 -0
  49. package/src/extensions/instrument-lowlevel.ts +2 -0
  50. package/src/extensions/instrumentation.ts +51 -0
  51. package/src/extensions/model-parameters.ts +94 -0
  52. package/src/extensions/posthog-events.ts +8 -0
  53. package/src/extensions/truncation.ts +3 -0
  54. package/src/index.ts +2 -0
  55. package/src/types.ts +28 -0
  56. package/src/version.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import type { AnalyticsParameterOwnership } from '../types'
2
+ import { log, type LoggerFn } from './logger'
2
3
  import { getObjectShape, isZodRawShapeCompat } from './mcp-sdk-compat'
3
4
  import { canDeclareOutputInstructions } from './output-instructions'
4
5
 
@@ -42,6 +43,86 @@ export function canInjectAnalyticsParameter(
42
43
  )
43
44
  }
44
45
 
46
+ export interface AnalyticsInjectableTool {
47
+ inputSchema?: AnalyticsInjectableJsonSchema
48
+ name?: string
49
+ [key: string]: unknown
50
+ }
51
+
52
+ /**
53
+ * Adds a required string parameter to a tool's JSON Schema. Called AFTER the MCP
54
+ * SDK has converted Zod schemas to JSON Schema, so only JSON Schema needs
55
+ * handling.
56
+ *
57
+ * Shared by every analytics parameter we inject (`context`, `llm_model`) so
58
+ * schema ownership, cloning, and required-field behavior cannot drift between
59
+ * them. `injectionLabel` only names the feature in the skip warnings.
60
+ *
61
+ * Skips injection (with a warning) for:
62
+ * - Tools that already declare the parameter — it is theirs, not ours
63
+ * - Complex schemas (oneOf/allOf/anyOf/$ref) that can't safely gain properties
64
+ */
65
+ export function addAnalyticsParameterToTool<TTool extends AnalyticsInjectableTool>(
66
+ tool: TTool,
67
+ parameterName: string,
68
+ description: string,
69
+ injectionLabel: string,
70
+ logger: LoggerFn = log
71
+ ): TTool {
72
+ const modifiedTool = { ...tool }
73
+ const toolName = tool.name || 'unknown'
74
+ const schema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema | undefined
75
+
76
+ if (!canInjectAnalyticsParameter(schema, parameterName)) {
77
+ if (hasAnalyticsParameter(schema, parameterName)) {
78
+ logger(`WARN: Tool "${toolName}" already has '${parameterName}' parameter. Skipping ${injectionLabel} injection.`)
79
+ } else {
80
+ logger(
81
+ `WARN: Tool "${toolName}" has complex schema (oneOf/allOf/anyOf/$ref). Skipping ${injectionLabel} injection.`
82
+ )
83
+ }
84
+ return modifiedTool
85
+ }
86
+
87
+ if (!modifiedTool.inputSchema) {
88
+ modifiedTool.inputSchema = {
89
+ type: 'object',
90
+ properties: {},
91
+ required: [],
92
+ }
93
+ }
94
+
95
+ // Deep copy: the server may reuse or freeze the schema object it handed us.
96
+ modifiedTool.inputSchema = JSON.parse(JSON.stringify(modifiedTool.inputSchema)) as AnalyticsInjectableJsonSchema
97
+
98
+ const inputSchema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema
99
+
100
+ if (!inputSchema.properties) {
101
+ inputSchema.properties = {}
102
+ }
103
+
104
+ // The MCP SDK emits `additionalProperties: false` when converting Zod schemas;
105
+ // left in place it would make the injected key invalid.
106
+ if (inputSchema.additionalProperties === false) {
107
+ inputSchema.additionalProperties = undefined
108
+ }
109
+
110
+ inputSchema.properties[parameterName] = {
111
+ type: 'string',
112
+ description,
113
+ }
114
+
115
+ if (Array.isArray(inputSchema.required)) {
116
+ if (!inputSchema.required.includes(parameterName)) {
117
+ inputSchema.required.push(parameterName)
118
+ }
119
+ } else {
120
+ inputSchema.required = [parameterName]
121
+ }
122
+
123
+ return modifiedTool
124
+ }
125
+
45
126
  export function analyticsOwnsParameter(inputSchema: unknown, parameterName: string): boolean {
46
127
  if (!inputSchema || typeof inputSchema !== 'object') {
47
128
  return canInjectAnalyticsParameter(undefined, parameterName)
@@ -76,18 +157,19 @@ export function getAnalyticsParameterOwnership(
76
157
  return {
77
158
  context: analyticsOwnsParameter(inputSchema, 'context'),
78
159
  conversationId: analyticsOwnsParameter(inputSchema, 'conversation_id'),
160
+ llmModel: analyticsOwnsParameter(inputSchema, 'llm_model'),
79
161
  outputInstructions: canDeclareOutputInstructions(outputSchema),
80
162
  }
81
163
  }
82
164
 
83
165
  /**
84
166
  * Removes the arguments we injected, leaving the host's own. Input-side only, so
85
- * it takes just those two flags rather than the whole ownership record — callers
167
+ * it takes just those flags rather than the whole ownership record — callers
86
168
  * that have no output-schema context should not have to invent a value for it.
87
169
  */
88
170
  export function stripOwnedAnalyticsArguments(
89
171
  args: unknown,
90
- ownership: Pick<AnalyticsParameterOwnership, 'context' | 'conversationId'>
172
+ ownership: Pick<AnalyticsParameterOwnership, 'context' | 'conversationId' | 'llmModel'>
91
173
  ): unknown {
92
174
  let cleanedArgs = args
93
175
  if (ownership.context && cleanedArgs && typeof cleanedArgs === 'object' && 'context' in cleanedArgs) {
@@ -98,5 +180,9 @@ export function stripOwnedAnalyticsArguments(
98
180
  const { conversation_id: _conversationId, ...rest } = cleanedArgs
99
181
  cleanedArgs = rest
100
182
  }
183
+ if (ownership.llmModel && cleanedArgs && typeof cleanedArgs === 'object' && 'llm_model' in cleanedArgs) {
184
+ const { llm_model: _llmModel, ...rest } = cleanedArgs
185
+ cleanedArgs = rest
186
+ }
101
187
  return cleanedArgs
102
188
  }
@@ -81,6 +81,10 @@ export function captureEvent(
81
81
  response: eventInput.response,
82
82
  userIntent: eventInput.userIntent,
83
83
  userIntentSource: eventInput.userIntentSource,
84
+ // Self-reported per request (`captureModel`), so like the header-borne
85
+ // fields above there is deliberately no sessionInfo fallback.
86
+ llmModel: eventInput.llmModel,
87
+ llmModelSource: eventInput.llmModelSource,
84
88
  isError: eventInput.isError,
85
89
  // Two consumers of the same failure: `error` is the full thrown value that
86
90
  // fans out into the separate `$exception` event (stacktrace, error-tracking
@@ -7,6 +7,8 @@ export const INACTIVITY_TIMEOUT_IN_MINUTES = 30
7
7
 
8
8
  export const DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = `Explain why you are calling this tool and how it fits into the user's overall goal. This parameter is used for analytics and user intent tracking. YOU MUST provide 15-25 words (count carefully). NEVER use first person ('I', 'we', 'you') - maintain third-person perspective. NEVER include sensitive information such as credentials, passwords, or personal data. Example (20 words): "Searching across the organization's repositories to find all open issues related to performance complaints and latency issues for team prioritization."`
9
9
 
10
+ export const DEFAULT_MODEL_PARAMETER_DESCRIPTION = `The exact model identifier you (the assistant) are running as, taken from your system prompt or environment (e.g. "claude-opus-4-8", "gpt-5.2"). Used for analytics only. If you do not know your model identifier with certainty, pass "unknown" — never guess.`
11
+
10
12
  export const DEFAULT_CONVERSATION_ID_DESCRIPTION =
11
13
  "Echo the conversation_id from the server's previous response. The server provides it on the first call — never invent one, and do not issue parallel tool calls until you have it."
12
14
 
@@ -48,6 +50,8 @@ export const PostHogMCPAnalyticsProperty = {
48
50
  Intent: '$mcp_intent',
49
51
  IntentSource: '$mcp_intent_source',
50
52
  ListedToolNames: '$mcp_listed_tool_names',
53
+ LlmModel: '$mcp_llm_model',
54
+ LlmModelSource: '$mcp_llm_model_source',
51
55
  Parameters: '$mcp_parameters',
52
56
  ProtocolVersion: '$mcp_protocol_version',
53
57
  ResourceName: '$mcp_resource_name',
@@ -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,94 @@
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
+ const model = request.params?.arguments?.llm_model
78
+ if (typeof model !== 'string') {
79
+ return undefined
80
+ }
81
+ const trimmed = model.trim()
82
+ if (!trimmed || trimmed.toLowerCase() === 'unknown') {
83
+ return undefined
84
+ }
85
+ return trimmed
86
+ }
87
+
88
+ export function setEventModel(event: McpEvent, model: string | undefined): void {
89
+ if (!model) {
90
+ return
91
+ }
92
+ event.llmModel = model
93
+ event.llmModelSource = 'self_reported'
94
+ }
@@ -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
  }
@@ -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,6 +228,8 @@ export type {
228
228
  McpCaptureCommon,
229
229
  MCPAnalyticsContextOptions,
230
230
  MCPAnalyticsIntentSource,
231
+ MCPAnalyticsModelOptions,
232
+ MCPAnalyticsModelSource,
231
233
  MCPAnalyticsOptions,
232
234
  MissingCapabilityCaptureData,
233
235
  PreparedToolCall,
package/src/types.ts CHANGED
@@ -129,6 +129,20 @@ export interface MCPAnalyticsOptions {
129
129
  enableExceptionAutocapture?: boolean
130
130
  /** Inject a required `context` parameter on every tool to capture user intent. */
131
131
  context?: boolean | MCPAnalyticsContextOptions
132
+ /**
133
+ * Inject a required `llm_model` parameter on every tool so the calling agent
134
+ * self-reports the model it runs as, captured as `$mcp_llm_model` with
135
+ * `$mcp_llm_model_source = "self_reported"`. Off by default.
136
+ *
137
+ * The MCP wire deliberately carries no model identity, so self-report is the
138
+ * only capture path — harnesses inject the model id into the agent's system
139
+ * prompt, and agents restate it accurately. Like `clientInfo` in the MCP
140
+ * spec, the value is unverified: use it for per-model quality analytics, not
141
+ * billing or security. An honest `"unknown"` from the agent is dropped
142
+ * rather than captured. Reasoning effort is intentionally not collected —
143
+ * agents don't reliably know it, so it would only ever be noise.
144
+ */
145
+ captureModel?: boolean | MCPAnalyticsModelOptions
132
146
  /**
133
147
  * Identify the calling user. Returning a non-null value sets `distinct_id` and `$set`
134
148
  * on subsequent events for the session. Object form is treated as a static identity.
@@ -172,8 +186,13 @@ export interface MCPAnalyticsContextOptions {
172
186
  description?: string
173
187
  }
174
188
 
189
+ export interface MCPAnalyticsModelOptions {
190
+ description?: string
191
+ }
192
+
175
193
  export type MaybePromise<T> = T | Promise<T>
176
194
  export type MCPAnalyticsIntentSource = 'context_parameter' | 'inferred'
195
+ export type MCPAnalyticsModelSource = 'self_reported'
177
196
 
178
197
  export type ToolCallback =
179
198
  | ((
@@ -225,6 +244,14 @@ export interface Event {
225
244
  eventId?: string
226
245
  eventType: MCPAnalyticsEventType
227
246
  groups?: Record<string, string>
247
+ /**
248
+ * The calling agent's self-reported model id → `$mcp_llm_model`. Read off
249
+ * the SDK-injected `llm_model` argument (`captureModel` option); unverified
250
+ * by design, like the MCP spec's own `clientInfo`.
251
+ */
252
+ llmModel?: string
253
+ /** How the model id was obtained → `$mcp_llm_model_source`. Always `self_reported` today. */
254
+ llmModelSource?: MCPAnalyticsModelSource
228
255
  /**
229
256
  * Explicit PostHog event name. When set (via `capture(server, { event })`) it
230
257
  * overrides the built-in name derived from `eventType`, so callers can emit any
@@ -380,6 +407,7 @@ export interface SessionInfo {
380
407
  export interface AnalyticsParameterOwnership {
381
408
  context: boolean
382
409
  conversationId: boolean
410
+ llmModel: boolean
383
411
  /**
384
412
  * True when we declared `_mcp_instructions` on this tool's advertised output
385
413
  * schema, so writing that key into `structuredContent` will validate. False
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '0.11.7'
1
+ export const version = '0.12.0'