@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.
- 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/conversation-id.d.ts.map +1 -1
- package/dist/extensions/conversation-id.js +3 -1
- package/dist/extensions/conversation-id.mjs +3 -1
- 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/output-instructions.d.ts +0 -1
- package/dist/extensions/output-instructions.d.ts.map +1 -1
- package/dist/extensions/output-instructions.js +3 -8
- package/dist/extensions/output-instructions.mjs +3 -8
- 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 +28 -1
- 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/conversation-id.ts +4 -1
- 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/output-instructions.ts +3 -9
- 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 +29 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/mcp",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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/
|
|
37
|
-
"@modelcontextprotocol/
|
|
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.
|
|
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
|
|
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>(
|
|
@@ -162,7 +162,10 @@ export function buildConversationIdPromptBack(conversationId: string): {
|
|
|
162
162
|
} {
|
|
163
163
|
return {
|
|
164
164
|
type: 'text',
|
|
165
|
-
|
|
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
|
|
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