@posthog/mcp 0.11.6 → 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 (65) 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/conversation-id.d.ts.map +1 -1
  18. package/dist/extensions/conversation-id.js +3 -1
  19. package/dist/extensions/conversation-id.mjs +3 -1
  20. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  21. package/dist/extensions/instrument-highlevel.js +4 -1
  22. package/dist/extensions/instrument-highlevel.mjs +5 -2
  23. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  24. package/dist/extensions/instrument-lowlevel.js +1 -0
  25. package/dist/extensions/instrument-lowlevel.mjs +2 -1
  26. package/dist/extensions/instrumentation.d.ts +16 -0
  27. package/dist/extensions/instrumentation.d.ts.map +1 -1
  28. package/dist/extensions/instrumentation.js +15 -2
  29. package/dist/extensions/instrumentation.mjs +11 -1
  30. package/dist/extensions/model-parameters.d.ts +48 -0
  31. package/dist/extensions/model-parameters.d.ts.map +1 -0
  32. package/dist/extensions/model-parameters.js +77 -0
  33. package/dist/extensions/model-parameters.mjs +28 -0
  34. package/dist/extensions/output-instructions.d.ts +0 -1
  35. package/dist/extensions/output-instructions.d.ts.map +1 -1
  36. package/dist/extensions/output-instructions.js +3 -8
  37. package/dist/extensions/output-instructions.mjs +3 -8
  38. package/dist/extensions/posthog-events.js +2 -0
  39. package/dist/extensions/posthog-events.mjs +2 -0
  40. package/dist/extensions/truncation.d.ts.map +1 -1
  41. package/dist/extensions/truncation.js +1 -0
  42. package/dist/extensions/truncation.mjs +1 -0
  43. package/dist/index.d.ts +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/types.d.ts +28 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/dist/version.mjs +1 -1
  50. package/package.json +24 -7
  51. package/src/extensions/analytics-parameters.ts +88 -2
  52. package/src/extensions/capture.ts +4 -0
  53. package/src/extensions/constants.ts +4 -0
  54. package/src/extensions/context-parameters.ts +10 -59
  55. package/src/extensions/conversation-id.ts +4 -1
  56. package/src/extensions/instrument-highlevel.ts +4 -0
  57. package/src/extensions/instrument-lowlevel.ts +2 -0
  58. package/src/extensions/instrumentation.ts +51 -0
  59. package/src/extensions/model-parameters.ts +94 -0
  60. package/src/extensions/output-instructions.ts +3 -9
  61. package/src/extensions/posthog-events.ts +8 -0
  62. package/src/extensions/truncation.ts +3 -0
  63. package/src/index.ts +2 -0
  64. package/src/types.ts +29 -1
  65. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/mcp",
3
- "version": "0.11.6",
3
+ "version": "0.12.0",
4
4
  "bugs": {
5
5
  "url": "https://github.com/PostHog/posthog-js/issues"
6
6
  },
@@ -28,20 +28,32 @@
28
28
  "module": "dist/index.mjs",
29
29
  "types": "dist/index.d.ts",
30
30
  "dependencies": {
31
- "@posthog/core": "^1.48.0"
31
+ "@posthog/core": "^1.49.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@babel/preset-env": "^7.27.1",
35
35
  "@babel/preset-typescript": "^7.27.1",
36
- "@modelcontextprotocol/sdk": "~1.29.0",
37
- "@modelcontextprotocol/server": "~2.0.0",
36
+ "@modelcontextprotocol/node": "^2.0.0",
37
+ "@modelcontextprotocol/sdk": "^1.30.0",
38
+ "@modelcontextprotocol/server": "^2.0.0",
39
+ "@nestjs/common": "^11.1.27",
40
+ "@nestjs/core": "^11.1.27",
41
+ "@nestjs/microservices": "^11.1.27",
42
+ "@nestjs/platform-express": "^11.1.27",
38
43
  "@rslib/core": "0.10.6",
39
44
  "@types/jest": "^29.5.14",
40
45
  "@types/node": "^20.0.0",
41
46
  "jest": "29.7.0",
47
+ "reflect-metadata": "^0.2.2",
48
+ "rekog-mcp-nest-v1": "npm:@rekog/mcp-nest@1.9.11",
49
+ "rekog-mcp-nest-v2": "npm:@rekog/mcp-nest@2.0.0",
50
+ "rxjs": "^7.8.2",
51
+ "ts-node": "^10.9.2",
52
+ "typescript": "5.8.2",
42
53
  "zod": "^3.25.0",
54
+ "zod4": "npm:zod@^4.4.3",
43
55
  "@posthog-tooling/tsconfig-base": "1.1.1",
44
- "posthog-node": "^5.49.0"
56
+ "posthog-node": "^5.51.4"
45
57
  },
46
58
  "peerDependencies": {
47
59
  "@modelcontextprotocol/sdk": ">=1.26.0",
@@ -73,11 +85,16 @@
73
85
  },
74
86
  "scripts": {
75
87
  "clean": "rimraf dist coverage",
76
- "lint": "eslint src",
77
- "lint:fix": "eslint src --fix",
88
+ "lint": "eslint src && prettier --check harness",
89
+ "lint:fix": "eslint src --fix && prettier --write harness",
78
90
  "generate-version": "node -p \"'export const version = \\'' + require('./package.json').version + '\\''\" > src/version.ts",
79
91
  "pretest:unit": "pnpm generate-version",
80
92
  "test:unit": "jest",
93
+ "test:integration": "pnpm test:integration:sdk-v1 && pnpm test:integration:sdk-v2 && pnpm test:integration:nest-v1 && pnpm test:integration:nest-v2",
94
+ "test:integration:sdk-v1": "node harness/dual-era/matrix.mjs --major v1 && node harness/dual-era/probe-late-handlers.mjs --major v1 && node harness/dual-era/probe-pagination.mjs --major v1",
95
+ "test:integration:sdk-v2": "node harness/dual-era/matrix.mjs --major v2 && node harness/dual-era/probe-late-handlers.mjs --major v2 && node harness/dual-era/probe-first-call-error.mjs && node harness/dual-era/probe-pagination.mjs --major v2",
96
+ "test:integration:nest-v1": "node harness/nest-v1/verify.mjs && LEVEL=low node harness/nest-v1/verify.mjs",
97
+ "test:integration:nest-v2": "node harness/nest-v2/verify.mjs && LEVEL=low node harness/nest-v2/verify.mjs",
81
98
  "prebuild": "pnpm generate-version",
82
99
  "build": "rslib build",
83
100
  "dev": "rslib build -w",
@@ -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>(
@@ -162,7 +162,10 @@ export function buildConversationIdPromptBack(conversationId: string): {
162
162
  } {
163
163
  return {
164
164
  type: 'text',
165
- text: `[SERVER]: Reuse conversation_id=${conversationId} on every subsequent tool call in this conversation. Required for the server to correlate calls and provide context-aware results.`,
165
+ // Tool results are untrusted content. Keep this as data rather than an
166
+ // instruction so clients do not classify it as prompt injection when they
167
+ // are still using a cached tool schema without `conversation_id`.
168
+ text: JSON.stringify({ conversation_id: conversationId }),
166
169
  }
167
170
  }
168
171
 
@@ -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
+ }
@@ -21,11 +21,9 @@ import type { AnalyticsInjectableJsonSchema } from './analytics-parameters'
21
21
  */
22
22
  export const MCP_INSTRUCTIONS_KEY = '_mcp_instructions'
23
23
 
24
- const INSTRUCTIONS_FIELD_DESCRIPTION =
25
- 'Server-issued handles for this conversation, and what to do with them. Read and follow.'
24
+ const INSTRUCTIONS_FIELD_DESCRIPTION = 'Server-issued metadata for this conversation.'
26
25
 
27
- const CONVERSATION_ID_FIELD_DESCRIPTION =
28
- 'Echo this exact value as the conversation_id argument on every subsequent tool call.'
26
+ const CONVERSATION_ID_FIELD_DESCRIPTION = 'The server-issued conversation identifier.'
29
27
 
30
28
  export interface OutputInstructionsInjectableTool {
31
29
  name?: string
@@ -165,7 +163,6 @@ export function addInstructionsToOutputSchema<TTool extends OutputInstructionsIn
165
163
  type: 'string',
166
164
  description: CONVERSATION_ID_FIELD_DESCRIPTION,
167
165
  },
168
- instructions: { type: 'string' },
169
166
  },
170
167
  }
171
168
 
@@ -186,14 +183,11 @@ export function addInstructionsToOutputSchemas<TTool extends OutputInstructionsI
186
183
 
187
184
  export interface ConversationInstructions {
188
185
  conversation_id: string
189
- instructions: string
190
186
  }
191
187
 
192
- const ECHO_INSTRUCTION = 'Send this conversation_id as an argument on every subsequent tool call in this conversation.'
193
-
194
188
  /** The payload mirrored into `structuredContent` for a tool we declared the key on. */
195
189
  export function buildConversationInstructions(conversationId: string): ConversationInstructions {
196
- return { conversation_id: conversationId, instructions: ECHO_INSTRUCTION }
190
+ return { conversation_id: conversationId }
197
191
  }
198
192
 
199
193
  /**
@@ -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,