@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.
- package/README.md +29 -0
- package/dist/extensions/analytics-parameters.d.ts +22 -2
- package/dist/extensions/analytics-parameters.d.ts.map +1 -1
- package/dist/extensions/analytics-parameters.js +39 -0
- package/dist/extensions/analytics-parameters.mjs +37 -1
- package/dist/extensions/capture.d.ts.map +1 -1
- package/dist/extensions/capture.js +2 -0
- package/dist/extensions/capture.mjs +2 -0
- package/dist/extensions/constants.d.ts +3 -0
- package/dist/extensions/constants.d.ts.map +1 -1
- package/dist/extensions/constants.js +6 -0
- package/dist/extensions/constants.mjs +4 -1
- package/dist/extensions/context-parameters.d.ts +2 -2
- package/dist/extensions/context-parameters.d.ts.map +1 -1
- package/dist/extensions/context-parameters.js +1 -29
- package/dist/extensions/context-parameters.mjs +2 -30
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-highlevel.js +4 -1
- package/dist/extensions/instrument-highlevel.mjs +5 -2
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.js +1 -0
- package/dist/extensions/instrument-lowlevel.mjs +2 -1
- package/dist/extensions/instrumentation.d.ts +16 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +15 -2
- package/dist/extensions/instrumentation.mjs +11 -1
- package/dist/extensions/model-parameters.d.ts +48 -0
- package/dist/extensions/model-parameters.d.ts.map +1 -0
- package/dist/extensions/model-parameters.js +77 -0
- package/dist/extensions/model-parameters.mjs +28 -0
- package/dist/extensions/posthog-events.js +2 -0
- package/dist/extensions/posthog-events.mjs +2 -0
- package/dist/extensions/truncation.d.ts.map +1 -1
- package/dist/extensions/truncation.js +1 -0
- package/dist/extensions/truncation.mjs +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.mjs +1 -1
- package/package.json +24 -7
- package/src/extensions/analytics-parameters.ts +88 -2
- package/src/extensions/capture.ts +4 -0
- package/src/extensions/constants.ts +4 -0
- package/src/extensions/context-parameters.ts +10 -59
- package/src/extensions/instrument-highlevel.ts +4 -0
- package/src/extensions/instrument-lowlevel.ts +2 -0
- package/src/extensions/instrumentation.ts +51 -0
- package/src/extensions/model-parameters.ts +94 -0
- package/src/extensions/posthog-events.ts +8 -0
- package/src/extensions/truncation.ts +3 -0
- package/src/index.ts +2 -0
- package/src/types.ts +28 -0
- 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
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
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.
|
|
1
|
+
export const version = '0.12.0'
|