@posthog/mcp 0.10.6 → 0.10.8
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/dist/extensions/analytics-parameters.d.ts +7 -2
- package/dist/extensions/analytics-parameters.d.ts.map +1 -1
- package/dist/extensions/analytics-parameters.js +4 -2
- package/dist/extensions/analytics-parameters.mjs +4 -2
- package/dist/extensions/capture.d.ts +2 -2
- package/dist/extensions/capture.d.ts.map +1 -1
- package/dist/extensions/capture.js +2 -2
- package/dist/extensions/capture.mjs +2 -2
- package/dist/extensions/conversation-id.d.ts +37 -8
- package/dist/extensions/conversation-id.d.ts.map +1 -1
- package/dist/extensions/conversation-id.js +8 -13
- package/dist/extensions/conversation-id.mjs +8 -13
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-highlevel.js +1 -1
- package/dist/extensions/instrument-highlevel.mjs +1 -1
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +86 -42
- package/dist/extensions/instrumentation.mjs +88 -44
- package/dist/extensions/internal.d.ts +10 -2
- package/dist/extensions/internal.d.ts.map +1 -1
- package/dist/extensions/internal.js +25 -9
- package/dist/extensions/internal.mjs +19 -6
- package/dist/extensions/output-instructions.d.ts +70 -0
- package/dist/extensions/output-instructions.d.ts.map +1 -0
- package/dist/extensions/output-instructions.js +131 -0
- package/dist/extensions/output-instructions.mjs +82 -0
- package/dist/extensions/session.d.ts +26 -5
- package/dist/extensions/session.d.ts.map +1 -1
- package/dist/extensions/session.js +35 -15
- package/dist/extensions/session.mjs +33 -16
- package/dist/types.d.ts +23 -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 +3 -3
- package/src/extensions/analytics-parameters.ts +15 -2
- package/src/extensions/capture.ts +13 -6
- package/src/extensions/conversation-id.ts +51 -29
- package/src/extensions/instrument-highlevel.ts +3 -1
- package/src/extensions/instrumentation.ts +151 -60
- package/src/extensions/internal.ts +30 -7
- package/src/extensions/output-instructions.ts +231 -0
- package/src/extensions/session.ts +128 -29
- package/src/types.ts +23 -1
- package/src/version.ts +1 -1
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
} from './analytics-parameters'
|
|
12
12
|
import { DEFAULT_CONVERSATION_ID_DESCRIPTION } from './constants'
|
|
13
13
|
import { log, type LoggerFn } from './logger'
|
|
14
|
-
import { GET_MORE_TOOLS_NAME } from './tools'
|
|
15
14
|
|
|
16
15
|
export const CONVERSATION_ID_PARAM_NAME = 'conversation_id'
|
|
17
16
|
|
|
@@ -70,60 +69,83 @@ export function addConversationIdToTool<TTool extends ConversationIdInjectableTo
|
|
|
70
69
|
return modifiedTool
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Injects `conversation_id` across a tool listing, including the virtual
|
|
74
|
+
* `get_more_tools` tool. Its calls publish `$mcp_missing_capability`, and a
|
|
75
|
+
* capability gap is only meaningful next to the work that hit it — so it belongs
|
|
76
|
+
* in the same session as the surrounding tool calls.
|
|
77
|
+
*/
|
|
73
78
|
export function addConversationIdToTools<TTool extends ConversationIdInjectableTool>(
|
|
74
79
|
tools: TTool[],
|
|
75
|
-
missingCapabilityToolName: string = GET_MORE_TOOLS_NAME,
|
|
76
|
-
skipMissingCapabilityTool = true,
|
|
77
80
|
logger: LoggerFn = log
|
|
78
81
|
): TTool[] {
|
|
79
|
-
return tools.map((tool) =>
|
|
80
|
-
if (skipMissingCapabilityTool && tool.name === missingCapabilityToolName) {
|
|
81
|
-
return tool
|
|
82
|
-
}
|
|
83
|
-
return addConversationIdToTool(tool, logger)
|
|
84
|
-
})
|
|
82
|
+
return tools.map((tool) => addConversationIdToTool(tool, logger))
|
|
85
83
|
}
|
|
86
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The shape of every id we mint: a uuidv7. Used to tell an echo of our own handle
|
|
87
|
+
* from a value the agent made up.
|
|
88
|
+
*/
|
|
89
|
+
const MINTED_CONVERSATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
90
|
+
|
|
87
91
|
export type ConversationIdResolution =
|
|
88
92
|
| { minted: false; conversationId: string | undefined }
|
|
89
93
|
| { minted: true; conversationId: string }
|
|
90
94
|
|
|
91
95
|
/**
|
|
92
96
|
* Decides which conversation_id to use for a tool call:
|
|
93
|
-
* - disabled
|
|
94
|
-
* - agent
|
|
95
|
-
* -
|
|
97
|
+
* - disabled → none
|
|
98
|
+
* - agent echoed a handle we could have minted → use it
|
|
99
|
+
* - anything else → mint a fresh one
|
|
100
|
+
*
|
|
101
|
+
* The shape check matters because this value becomes `$session_id`, and the
|
|
102
|
+
* derivation is deterministic so that two pods agree — which also means two
|
|
103
|
+
* *callers* sending the same string land in the same session. The strings agents
|
|
104
|
+
* invent are not random (`conv-1`, `1`, `session`), so trusting them verbatim
|
|
105
|
+
* would silently merge unrelated conversations, potentially across users.
|
|
106
|
+
*
|
|
107
|
+
* A compliant agent echoes the uuidv7 we minted and is unaffected. Anything else
|
|
108
|
+
* is treated exactly as if the handle were absent: mint, and prompt back. Shape
|
|
109
|
+
* rather than a registry of issued ids, because a per-request server has no
|
|
110
|
+
* memory of what it minted.
|
|
111
|
+
*
|
|
112
|
+
* This narrows the problem, it does not prove provenance: a caller supplying a
|
|
113
|
+
* well-formed uuidv7 is trusted, so two that pick the *same* one still merge. That
|
|
114
|
+
* residue is far smaller than the case it replaces — an agent ignoring the
|
|
115
|
+
* parameter description reaches for `conv-1` or `1`, not a conforming uuidv7 — and
|
|
116
|
+
* closing it needs a signed handle, i.e. a secret shared by every pod. Worth doing
|
|
117
|
+
* if this ever anchors something security-bearing; `$session_id` is an analytics
|
|
118
|
+
* grouping key, so it does not today.
|
|
96
119
|
*/
|
|
97
|
-
export function resolveConversationId(
|
|
98
|
-
enabled
|
|
99
|
-
args: unknown,
|
|
100
|
-
toolName: string | undefined,
|
|
101
|
-
missingCapabilityToolName: string = GET_MORE_TOOLS_NAME
|
|
102
|
-
): ConversationIdResolution {
|
|
103
|
-
if (!enabled || toolName === missingCapabilityToolName) {
|
|
120
|
+
export function resolveConversationId(enabled: boolean, args: unknown): ConversationIdResolution {
|
|
121
|
+
if (!enabled) {
|
|
104
122
|
return { minted: false, conversationId: undefined }
|
|
105
123
|
}
|
|
106
124
|
const supplied = extractConversationId(args)
|
|
107
|
-
if (supplied) {
|
|
108
|
-
|
|
125
|
+
if (supplied && MINTED_CONVERSATION_ID.test(supplied)) {
|
|
126
|
+
// Lowercased because the shape test is case-insensitive but the hash behind
|
|
127
|
+
// `$session_id` is not. Some hosts normalise uuids to uppercase, and an
|
|
128
|
+
// uppercased echo of our own handle would otherwise clear the gate and then
|
|
129
|
+
// land in a different session than the call that minted it.
|
|
130
|
+
return { minted: false, conversationId: supplied.toLowerCase() }
|
|
109
131
|
}
|
|
110
132
|
return { minted: true, conversationId: uuidv7() }
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
136
|
+
* Whether the prompt-back can ride this result's `content`. The only requirement
|
|
137
|
+
* is an array to append to.
|
|
138
|
+
*
|
|
139
|
+
* Errored results included on purpose. A tool that fails on the first call of a
|
|
140
|
+
* conversation is exactly when the agent needs the session handle: without it the
|
|
141
|
+
* retry starts a fresh conversation, so the failure and its fix land in different
|
|
142
|
+
* sessions.
|
|
117
143
|
*/
|
|
118
144
|
export function canInjectConversationIdPromptBack(result: unknown): boolean {
|
|
119
145
|
if (!(result && typeof result === 'object')) {
|
|
120
146
|
return false
|
|
121
147
|
}
|
|
122
|
-
|
|
123
|
-
if (resultObj.isError === true) {
|
|
124
|
-
return false
|
|
125
|
-
}
|
|
126
|
-
return Array.isArray(resultObj.content)
|
|
148
|
+
return Array.isArray((result as { content?: unknown }).content)
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
export function extractConversationId(args: unknown): string | undefined {
|
|
@@ -262,7 +262,9 @@ async function handleToolCallRequest(
|
|
|
262
262
|
request,
|
|
263
263
|
extra,
|
|
264
264
|
execute: (downstreamRequest) => originalCallToolHandler(downstreamRequest as MCPRequest, extra),
|
|
265
|
-
parameterOwnership: registeredTool
|
|
265
|
+
parameterOwnership: registeredTool
|
|
266
|
+
? getAnalyticsParameterOwnership(registeredTool.inputSchema, registeredTool.outputSchema)
|
|
267
|
+
: undefined,
|
|
266
268
|
takeCapturedError: () => {
|
|
267
269
|
const captured = extra?.__mcp_analytics_error
|
|
268
270
|
if (extra) {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
MCPServerLike,
|
|
13
13
|
McpEvent,
|
|
14
14
|
ServerClientInfoLike,
|
|
15
|
+
SessionInfo,
|
|
15
16
|
} from '../types'
|
|
16
17
|
import { getAnalyticsParameterOwnership, stripOwnedAnalyticsArguments } from './analytics-parameters'
|
|
17
18
|
import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
|
|
@@ -23,15 +24,16 @@ import {
|
|
|
23
24
|
resolveConversationId,
|
|
24
25
|
} from './conversation-id'
|
|
25
26
|
import { stampMetaClientInfo } from './client-identity'
|
|
27
|
+
import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
|
|
26
28
|
import { captureEvent } from './capture'
|
|
27
29
|
import { MCPAnalyticsEventType } from './event-types'
|
|
28
30
|
import { captureException } from './exceptions'
|
|
29
31
|
import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from './intent'
|
|
30
|
-
import { getServerTrackingData, handleIdentify, setServerTrackingData } from './internal'
|
|
32
|
+
import { getServerTrackingData, handleIdentify, setServerTrackingData, withIdentity } from './internal'
|
|
31
33
|
import type { LoggerFn } from './logger'
|
|
32
34
|
import { buildCapturedMcpParameters } from './mcp-payloads'
|
|
33
35
|
import { getLiteralValue, getObjectShape } from './mcp-sdk-compat'
|
|
34
|
-
import { getSessionId, newSessionId } from './session'
|
|
36
|
+
import { getSessionId, getSessionInfo, newSessionId } from './session'
|
|
35
37
|
import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
|
|
36
38
|
import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
|
|
37
39
|
import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
|
|
@@ -105,20 +107,13 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
|
|
|
105
107
|
parameterOwnership,
|
|
106
108
|
resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
|
|
107
109
|
)
|
|
108
|
-
const conversation = resolveConversationId(
|
|
109
|
-
ownership.conversationId,
|
|
110
|
-
request.params?.arguments,
|
|
111
|
-
request.params?.name,
|
|
112
|
-
resolvedEventType === MCPAnalyticsEventType.mcpMissingCapability
|
|
113
|
-
? resolveMissingCapabilityToolName(data.options)
|
|
114
|
-
: ''
|
|
115
|
-
)
|
|
110
|
+
const conversation = resolveConversationId(ownership.conversationId, request.params?.arguments)
|
|
116
111
|
const downstreamRequest = cloneRequestWithoutOwnedAnalyticsArguments(request, ownership)
|
|
117
112
|
|
|
118
113
|
// Prepare the event in isolation: if identity/metadata/intent resolution
|
|
119
114
|
// throws, we drop instrumentation for this call but still run the tool.
|
|
120
115
|
const startTime = new Date()
|
|
121
|
-
const
|
|
116
|
+
const preparedEvent = await prepareToolCallEvent(
|
|
122
117
|
server,
|
|
123
118
|
data,
|
|
124
119
|
request,
|
|
@@ -129,34 +124,56 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
|
|
|
129
124
|
ownership,
|
|
130
125
|
resolvedEventType
|
|
131
126
|
)
|
|
132
|
-
if (
|
|
133
|
-
setExplicitContextIntent(event, explicitContextIntent)
|
|
127
|
+
if (preparedEvent && explicitContextIntent) {
|
|
128
|
+
setExplicitContextIntent(preparedEvent.event, explicitContextIntent)
|
|
134
129
|
}
|
|
135
130
|
|
|
136
131
|
let result: unknown
|
|
137
132
|
try {
|
|
138
133
|
result = await execute(downstreamRequest)
|
|
139
134
|
} catch (error) {
|
|
140
|
-
publishFailedToolEvent(server,
|
|
135
|
+
publishFailedToolEvent(server, preparedEvent, error, startTime, conversation, data.logger)
|
|
141
136
|
throw error
|
|
142
137
|
}
|
|
143
138
|
|
|
144
|
-
const finalResult =
|
|
145
|
-
publishSuccessfulToolEvent(server,
|
|
139
|
+
const finalResult = applyConversationInstructions(preparedEvent?.event ?? null, result, conversation, ownership)
|
|
140
|
+
publishSuccessfulToolEvent(server, preparedEvent, finalResult, startTime, data.logger, takeCapturedError)
|
|
146
141
|
return finalResult
|
|
147
142
|
}
|
|
148
143
|
|
|
144
|
+
interface PreparedToolEvent {
|
|
145
|
+
event: McpEvent
|
|
146
|
+
requestAttribution: SessionInfo
|
|
147
|
+
}
|
|
148
|
+
|
|
149
149
|
function getActiveAnalyticsParameterOwnership(
|
|
150
150
|
data: MCPAnalyticsData,
|
|
151
151
|
toolName: string | undefined,
|
|
152
152
|
override: AnalyticsParameterOwnership | undefined,
|
|
153
153
|
isMissingCapabilityTool: boolean
|
|
154
154
|
): AnalyticsParameterOwnership {
|
|
155
|
-
const
|
|
155
|
+
const listed = toolName ? data.toolAnalyticsParameterOwnership.get(toolName) : undefined
|
|
156
|
+
const ownership = override ?? listed
|
|
156
157
|
return {
|
|
157
158
|
context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
|
|
158
|
-
conversationId:
|
|
159
|
-
|
|
159
|
+
conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
|
|
160
|
+
// Deliberately read off `listed`, never the override. This asks whether
|
|
161
|
+
// `tools/list` actually declared `_mcp_instructions`, and only the advertised
|
|
162
|
+
// JSON Schema can answer it — an override is built from the live registry,
|
|
163
|
+
// which on the high-level path holds Zod.
|
|
164
|
+
//
|
|
165
|
+
// The cache is per-instance, so this is not merely "before the first
|
|
166
|
+
// `tools/list`": an instance that never serves a listing never writes the
|
|
167
|
+
// mirror at all. That is the per-request server pattern — `tools/list` lands
|
|
168
|
+
// on one instance, `tools/call` on a cold one — where the handle falls back
|
|
169
|
+
// to the `content` block and a structuredContent-only client misses it.
|
|
170
|
+
//
|
|
171
|
+
// Failing closed is deliberate. Writing a key the advertised schema did not
|
|
172
|
+
// declare fails the *entire* tool result under `additionalProperties: false`,
|
|
173
|
+
// so guessing costs the caller their result, while not guessing costs us one
|
|
174
|
+
// delivery channel. The fix is a process-scoped ownership cache, so a listing
|
|
175
|
+
// served by any instance answers for the rest — not a per-call guess.
|
|
176
|
+
outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
|
|
160
177
|
}
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -188,10 +205,22 @@ async function prepareToolCallEvent(
|
|
|
188
205
|
conversation: ConversationIdResolution,
|
|
189
206
|
ownership: AnalyticsParameterOwnership,
|
|
190
207
|
eventType: MCPAnalyticsEventType
|
|
191
|
-
): Promise<
|
|
208
|
+
): Promise<PreparedToolEvent | null> {
|
|
192
209
|
try {
|
|
193
|
-
const sessionId = getSessionId(server, extra)
|
|
194
|
-
|
|
210
|
+
const sessionId = getSessionId(server, extra, conversation.conversationId)
|
|
211
|
+
// Snapshot token/client/protocol metadata synchronously, before identify or
|
|
212
|
+
// metadata callbacks can yield and let another request replace shared state.
|
|
213
|
+
const sessionInfo = getSessionInfo(server, data, sessionId)
|
|
214
|
+
const identity = await handleIdentify(
|
|
215
|
+
server,
|
|
216
|
+
data,
|
|
217
|
+
sessionId,
|
|
218
|
+
request,
|
|
219
|
+
sessionInfo,
|
|
220
|
+
extra,
|
|
221
|
+
!!conversation.conversationId
|
|
222
|
+
)
|
|
223
|
+
const requestAttribution = withIdentity(sessionInfo, identity)
|
|
195
224
|
|
|
196
225
|
const toolName = request.params?.name
|
|
197
226
|
const event: McpEvent = {
|
|
@@ -211,7 +240,7 @@ async function prepareToolCallEvent(
|
|
|
211
240
|
|
|
212
241
|
await applyResolvedMetadata(event, data, request, extra)
|
|
213
242
|
setEventIntent(event, await resolveToolCallIntent(data, request, ownership.context, extra))
|
|
214
|
-
return event
|
|
243
|
+
return { event, requestAttribution }
|
|
215
244
|
} catch (error) {
|
|
216
245
|
data.logger(
|
|
217
246
|
`Warning: PostHog MCP analytics instrumentation failed for tool ${request.params?.name}, the tool will still run - ${error}`
|
|
@@ -221,38 +250,65 @@ async function prepareToolCallEvent(
|
|
|
221
250
|
}
|
|
222
251
|
|
|
223
252
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
253
|
+
* Delivers the conversation session handle back to the agent, over both channels a tool
|
|
254
|
+
* result has:
|
|
255
|
+
*
|
|
256
|
+
* - `structuredContent`, on *every* response for a tool whose output schema we
|
|
257
|
+
* declared the key on. Clients that read structured results never see the text
|
|
258
|
+
* block, and re-sending it each time lets an agent that dropped the session handle read
|
|
259
|
+
* it back.
|
|
260
|
+
* - `content`, as a text block, only on the response that minted the session handle.
|
|
261
|
+
* Repeating it every call would put a `[SERVER]:` line in front of the user on
|
|
262
|
+
* every single tool result.
|
|
263
|
+
*
|
|
264
|
+
* If neither channel could carry a session handle we minted, the agent never received it,
|
|
265
|
+
* so clear it off the event rather than showing analytics an id nobody has.
|
|
227
266
|
*/
|
|
228
|
-
function
|
|
267
|
+
function applyConversationInstructions(
|
|
229
268
|
event: McpEvent | null,
|
|
230
269
|
result: unknown,
|
|
231
|
-
conversation: ConversationIdResolution
|
|
270
|
+
conversation: ConversationIdResolution,
|
|
271
|
+
ownership: AnalyticsParameterOwnership
|
|
232
272
|
): unknown {
|
|
233
|
-
|
|
273
|
+
const conversationId = conversation.conversationId
|
|
274
|
+
if (!conversationId) {
|
|
234
275
|
return result
|
|
235
276
|
}
|
|
236
|
-
|
|
237
|
-
|
|
277
|
+
|
|
278
|
+
let updated = result
|
|
279
|
+
let delivered = false
|
|
280
|
+
|
|
281
|
+
if (ownership.outputInstructions) {
|
|
282
|
+
const mirrored = mirrorInstructionsIntoStructuredContent(updated, conversationId)
|
|
283
|
+
delivered = mirrored !== updated
|
|
284
|
+
updated = mirrored
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (conversation.minted && canInjectConversationIdPromptBack(updated)) {
|
|
288
|
+
updated = injectConversationIdPromptBack(updated, conversationId)
|
|
289
|
+
delivered = true
|
|
238
290
|
}
|
|
239
|
-
|
|
291
|
+
|
|
292
|
+
// Only a minted session handle can be lost this way — one the agent supplied, it has.
|
|
293
|
+
if (!delivered && conversation.minted && event) {
|
|
240
294
|
event.conversationId = undefined
|
|
241
295
|
}
|
|
242
|
-
|
|
296
|
+
|
|
297
|
+
return updated
|
|
243
298
|
}
|
|
244
299
|
|
|
245
300
|
function publishSuccessfulToolEvent(
|
|
246
301
|
server: MCPServerLike,
|
|
247
|
-
|
|
302
|
+
preparedEvent: PreparedToolEvent | null,
|
|
248
303
|
result: unknown,
|
|
249
304
|
startTime: Date,
|
|
250
305
|
logger: LoggerFn,
|
|
251
306
|
takeCapturedError?: () => unknown
|
|
252
307
|
): void {
|
|
253
|
-
if (!
|
|
308
|
+
if (!preparedEvent) {
|
|
254
309
|
return
|
|
255
310
|
}
|
|
311
|
+
const { event, requestAttribution } = preparedEvent
|
|
256
312
|
try {
|
|
257
313
|
if (isToolResultError(result)) {
|
|
258
314
|
event.isError = true
|
|
@@ -263,7 +319,7 @@ function publishSuccessfulToolEvent(
|
|
|
263
319
|
}
|
|
264
320
|
event.response = result
|
|
265
321
|
event.duration = Date.now() - startTime.getTime()
|
|
266
|
-
captureEvent(server, event, logger)
|
|
322
|
+
captureEvent(server, event, logger, requestAttribution)
|
|
267
323
|
} catch (error) {
|
|
268
324
|
logger(`Warning: PostHog MCP analytics failed to publish tool event - ${error}`)
|
|
269
325
|
}
|
|
@@ -271,15 +327,16 @@ function publishSuccessfulToolEvent(
|
|
|
271
327
|
|
|
272
328
|
function publishFailedToolEvent(
|
|
273
329
|
server: MCPServerLike,
|
|
274
|
-
|
|
330
|
+
preparedEvent: PreparedToolEvent | null,
|
|
275
331
|
error: unknown,
|
|
276
332
|
startTime: Date,
|
|
277
333
|
conversation: ConversationIdResolution,
|
|
278
334
|
logger: LoggerFn
|
|
279
335
|
): void {
|
|
280
|
-
if (!
|
|
336
|
+
if (!preparedEvent) {
|
|
281
337
|
return
|
|
282
338
|
}
|
|
339
|
+
const { event, requestAttribution } = preparedEvent
|
|
283
340
|
try {
|
|
284
341
|
if (conversation.minted) {
|
|
285
342
|
event.conversationId = undefined
|
|
@@ -287,7 +344,7 @@ function publishFailedToolEvent(
|
|
|
287
344
|
event.isError = true
|
|
288
345
|
event.error = captureException(error)
|
|
289
346
|
event.duration = Date.now() - startTime.getTime()
|
|
290
|
-
captureEvent(server, event, logger)
|
|
347
|
+
captureEvent(server, event, logger, requestAttribution)
|
|
291
348
|
} catch (publishError) {
|
|
292
349
|
logger(`Warning: PostHog MCP analytics failed to publish failed tool event - ${publishError}`)
|
|
293
350
|
}
|
|
@@ -407,8 +464,12 @@ export async function handleListToolsRequest(
|
|
|
407
464
|
): Promise<{ tools: ListToolsResult['tools'] }> {
|
|
408
465
|
const data = getServerTrackingData(server)
|
|
409
466
|
const startTime = new Date()
|
|
467
|
+
const sessionId = getSessionId(server, extra)
|
|
468
|
+
// Snapshot before metadata resolution or the list handler can yield to a
|
|
469
|
+
// concurrent request using the same instrumented server.
|
|
470
|
+
const requestAttribution = getSessionInfo(server, data, sessionId)
|
|
410
471
|
const event: McpEvent = {
|
|
411
|
-
sessionId
|
|
472
|
+
sessionId,
|
|
412
473
|
parameters: buildCapturedMcpParameters(request),
|
|
413
474
|
eventType: MCPAnalyticsEventType.mcpToolsList,
|
|
414
475
|
timestamp: startTime,
|
|
@@ -419,7 +480,15 @@ export async function handleListToolsRequest(
|
|
|
419
480
|
await applyResolvedMetadata(event, data, request, extra)
|
|
420
481
|
}
|
|
421
482
|
|
|
422
|
-
const tools = await getTracedToolsList(
|
|
483
|
+
const tools = await getTracedToolsList(
|
|
484
|
+
server,
|
|
485
|
+
originalListToolsHandler,
|
|
486
|
+
request,
|
|
487
|
+
extra,
|
|
488
|
+
event,
|
|
489
|
+
logger,
|
|
490
|
+
requestAttribution
|
|
491
|
+
)
|
|
423
492
|
|
|
424
493
|
if (!data) {
|
|
425
494
|
logger(
|
|
@@ -435,7 +504,7 @@ export async function handleListToolsRequest(
|
|
|
435
504
|
event.error = captureException('No tools were sent to MCP client.')
|
|
436
505
|
event.isError = true
|
|
437
506
|
event.duration = Date.now() - startTime.getTime()
|
|
438
|
-
captureEvent(server, event, data.logger)
|
|
507
|
+
captureEvent(server, event, data.logger, requestAttribution)
|
|
439
508
|
return { tools }
|
|
440
509
|
}
|
|
441
510
|
|
|
@@ -443,7 +512,7 @@ export async function handleListToolsRequest(
|
|
|
443
512
|
event.listedToolNames = collectListedToolNames(tools)
|
|
444
513
|
event.isError = false
|
|
445
514
|
event.duration = Date.now() - startTime.getTime()
|
|
446
|
-
captureEvent(server, event, data.logger)
|
|
515
|
+
captureEvent(server, event, data.logger, requestAttribution)
|
|
447
516
|
return { tools }
|
|
448
517
|
}
|
|
449
518
|
|
|
@@ -454,7 +523,7 @@ function cacheToolAnalyticsParameterOwnership(
|
|
|
454
523
|
// Merge pages and concurrent enumerations; repeated tool names overwrite stale schemas.
|
|
455
524
|
for (const tool of tools) {
|
|
456
525
|
if (tool?.name) {
|
|
457
|
-
cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema))
|
|
526
|
+
cache.set(tool.name, getAnalyticsParameterOwnership(tool.inputSchema, tool.outputSchema))
|
|
458
527
|
}
|
|
459
528
|
}
|
|
460
529
|
}
|
|
@@ -473,7 +542,8 @@ async function getTracedToolsList(
|
|
|
473
542
|
request: MCPRequestLike,
|
|
474
543
|
extra: CompatibleRequestHandlerExtra | undefined,
|
|
475
544
|
event: McpEvent,
|
|
476
|
-
logger: LoggerFn
|
|
545
|
+
logger: LoggerFn,
|
|
546
|
+
requestAttribution: SessionInfo
|
|
477
547
|
): Promise<ListToolsResult['tools']> {
|
|
478
548
|
try {
|
|
479
549
|
const data = getServerTrackingData(server)
|
|
@@ -490,7 +560,6 @@ async function getTracedToolsList(
|
|
|
490
560
|
|
|
491
561
|
if (data) {
|
|
492
562
|
const missingToolName = resolveMissingCapabilityToolName(data.options)
|
|
493
|
-
let injectedMissingCapabilityTool = false
|
|
494
563
|
if (data.options.reportMissing) {
|
|
495
564
|
const alreadyPresent = tools.some((tool) => tool?.name === missingToolName)
|
|
496
565
|
if (alreadyPresent) {
|
|
@@ -498,13 +567,17 @@ async function getTracedToolsList(
|
|
|
498
567
|
`Warning: Cannot inject missing-capability tool "${missingToolName}" because a real tool already uses that name. The real tool will not be intercepted.`
|
|
499
568
|
)
|
|
500
569
|
} else {
|
|
501
|
-
|
|
502
|
-
|
|
570
|
+
const virtualTool = getReportMissingToolDescriptor(missingToolName)
|
|
571
|
+
tools.push(virtualTool)
|
|
572
|
+
// Cached separately because the virtual tool is added after the listing
|
|
573
|
+
// was cached, and its calls need ownership like any other tool's.
|
|
574
|
+
cacheToolAnalyticsParameterOwnership(data.toolAnalyticsParameterOwnership, [virtualTool])
|
|
503
575
|
}
|
|
504
576
|
}
|
|
505
577
|
|
|
506
578
|
if (data.options.enableConversationId) {
|
|
507
|
-
tools = addConversationIdToTools(tools,
|
|
579
|
+
tools = addConversationIdToTools(tools, data.logger)
|
|
580
|
+
tools = addInstructionsToOutputSchemas(tools, data.logger)
|
|
508
581
|
}
|
|
509
582
|
}
|
|
510
583
|
|
|
@@ -521,7 +594,7 @@ async function getTracedToolsList(
|
|
|
521
594
|
event.error = captureException(error)
|
|
522
595
|
event.isError = true
|
|
523
596
|
event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0
|
|
524
|
-
captureEvent(server, event, logger)
|
|
597
|
+
captureEvent(server, event, logger, requestAttribution)
|
|
525
598
|
throw error
|
|
526
599
|
}
|
|
527
600
|
}
|
|
@@ -630,9 +703,10 @@ function mintStatelessSessionOnInitialize(
|
|
|
630
703
|
*/
|
|
631
704
|
function upgradeMintedTokenToNegotiated(
|
|
632
705
|
server: MCPServerLike,
|
|
633
|
-
data: MCPAnalyticsData,
|
|
634
706
|
mintedSessionId: string,
|
|
635
|
-
|
|
707
|
+
sessionInfo: SessionInfo,
|
|
708
|
+
negotiatedProtocolVersion: string | undefined,
|
|
709
|
+
logger: LoggerFn
|
|
636
710
|
): void {
|
|
637
711
|
try {
|
|
638
712
|
const transport = server.transport
|
|
@@ -641,13 +715,13 @@ function upgradeMintedTokenToNegotiated(
|
|
|
641
715
|
}
|
|
642
716
|
const token = encodeSessionId({
|
|
643
717
|
sessionId: mintedSessionId,
|
|
644
|
-
clientName:
|
|
645
|
-
clientVersion:
|
|
718
|
+
clientName: sessionInfo.clientName,
|
|
719
|
+
clientVersion: sessionInfo.clientVersion,
|
|
646
720
|
protocolVersion: negotiatedProtocolVersion,
|
|
647
721
|
})
|
|
648
722
|
writeSessionIdToTransport(transport, token)
|
|
649
723
|
} catch (error) {
|
|
650
|
-
|
|
724
|
+
logger(`Warning: PostHog MCP analytics failed to upgrade the stateless session token - ${error}`)
|
|
651
725
|
}
|
|
652
726
|
}
|
|
653
727
|
|
|
@@ -689,7 +763,17 @@ export async function handleInitializeRequest(
|
|
|
689
763
|
// Mint first so the `$mcp_initialize` event below already carries the minted id.
|
|
690
764
|
const mintedSessionId = mintStatelessSessionOnInitialize(server, data, request, extra)
|
|
691
765
|
const sessionId = getSessionId(server, extra)
|
|
692
|
-
|
|
766
|
+
// Snapshot before identify, metadata, or the initialize handler can yield to
|
|
767
|
+
// another request using the same instrumented server.
|
|
768
|
+
const sessionInfo = getSessionInfo(server, data, sessionId)
|
|
769
|
+
const initializeClientInfo = readInitializeClientInfo(request)
|
|
770
|
+
const requestSessionInfo: SessionInfo = {
|
|
771
|
+
...sessionInfo,
|
|
772
|
+
clientName: initializeClientInfo?.name ?? sessionInfo.clientName,
|
|
773
|
+
clientVersion: initializeClientInfo?.version ?? sessionInfo.clientVersion,
|
|
774
|
+
}
|
|
775
|
+
const identity = await handleIdentify(server, data, sessionId, request, requestSessionInfo, extra)
|
|
776
|
+
const requestAttribution = withIdentity(requestSessionInfo, identity)
|
|
693
777
|
|
|
694
778
|
const event: McpEvent = {
|
|
695
779
|
sessionId,
|
|
@@ -712,12 +796,19 @@ export async function handleInitializeRequest(
|
|
|
712
796
|
// re-mint the token so pods replaying it report the negotiated version too.
|
|
713
797
|
const negotiatedProtocolVersion = readProtocolVersion(result, request)
|
|
714
798
|
event.protocolVersion = negotiatedProtocolVersion
|
|
715
|
-
|
|
716
|
-
|
|
799
|
+
// Do not let a delayed initialize overwrite whichever session became current
|
|
800
|
+
// while its callbacks or the original handler were awaiting.
|
|
801
|
+
if (data.sessionId === sessionId) {
|
|
802
|
+
data.sessionInfo = { ...data.sessionInfo, protocolVersion: negotiatedProtocolVersion }
|
|
803
|
+
setServerTrackingData(server, data)
|
|
804
|
+
}
|
|
717
805
|
if (mintedSessionId) {
|
|
718
|
-
upgradeMintedTokenToNegotiated(server,
|
|
806
|
+
upgradeMintedTokenToNegotiated(server, mintedSessionId, requestSessionInfo, negotiatedProtocolVersion, data.logger)
|
|
719
807
|
}
|
|
720
|
-
captureEvent(server, event, data.logger
|
|
808
|
+
captureEvent(server, event, data.logger, {
|
|
809
|
+
...requestAttribution,
|
|
810
|
+
protocolVersion: negotiatedProtocolVersion,
|
|
811
|
+
})
|
|
721
812
|
return result
|
|
722
813
|
}
|
|
723
814
|
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
MCPRequestLike,
|
|
10
10
|
MCPServerLike,
|
|
11
11
|
McpEvent,
|
|
12
|
+
SessionInfo,
|
|
12
13
|
UserIdentity,
|
|
13
14
|
} from '../types'
|
|
14
15
|
import { MCPAnalyticsEventType } from './event-types'
|
|
@@ -128,10 +129,20 @@ export async function handleIdentify(
|
|
|
128
129
|
data: MCPAnalyticsData,
|
|
129
130
|
sessionId: string,
|
|
130
131
|
request: MCPRequestLike,
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
requestAttribution: SessionInfo,
|
|
133
|
+
extra?: CompatibleRequestHandlerExtra,
|
|
134
|
+
/**
|
|
135
|
+
* True when this session id came from an agent-carried `conversation_id`
|
|
136
|
+
* rather than the transport. Such a session is brand new and was never
|
|
137
|
+
* announced at `initialize`, whatever `data.sessionSource` still says about
|
|
138
|
+
* the connection.
|
|
139
|
+
*/
|
|
140
|
+
sessionFromConversation = false
|
|
141
|
+
): Promise<UserIdentity | undefined> {
|
|
142
|
+
const identityBeforeRequest = data.identifiedSessions.get(sessionId)
|
|
143
|
+
const sessionSourceBeforeIdentify = data.sessionSource
|
|
133
144
|
if (!data.options.identify) {
|
|
134
|
-
return
|
|
145
|
+
return identityBeforeRequest
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
const identifyEvent: McpEvent = {
|
|
@@ -162,23 +173,35 @@ export async function handleIdentify(
|
|
|
162
173
|
// announced); revisit with the stateless-by-default rework.
|
|
163
174
|
const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
|
|
164
175
|
const firstSeen = previousIdentity === undefined
|
|
165
|
-
const announcedAtInitialize =
|
|
176
|
+
const announcedAtInitialize =
|
|
177
|
+
!sessionFromConversation && sessionSourceBeforeIdentify === 'token' && request.method !== 'initialize'
|
|
166
178
|
const shouldPublish = changed || (firstSeen && !announcedAtInitialize)
|
|
167
179
|
|
|
168
180
|
data.identifiedSessions.set(sessionId, mergedIdentity)
|
|
169
181
|
|
|
170
182
|
if (shouldPublish) {
|
|
171
183
|
data.logger(`Identified session ${sessionId}`)
|
|
172
|
-
captureEvent(server, identifyEvent, data.logger)
|
|
184
|
+
captureEvent(server, identifyEvent, data.logger, withIdentity(requestAttribution, mergedIdentity))
|
|
173
185
|
}
|
|
174
|
-
|
|
175
|
-
data.logger(`Warning: Supplied identify function returned null for session ${sessionId}`)
|
|
186
|
+
return mergedIdentity
|
|
176
187
|
}
|
|
188
|
+
|
|
189
|
+
data.logger(`Warning: Supplied identify function returned null for session ${sessionId}`)
|
|
177
190
|
} catch (error) {
|
|
178
191
|
data.logger(
|
|
179
192
|
`Error: User supplied identify function threw an error while identifying session ${sessionId} - ${error}`
|
|
180
193
|
)
|
|
181
194
|
}
|
|
195
|
+
return identityBeforeRequest
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function withIdentity(sessionInfo: SessionInfo, identity: UserIdentity | undefined): SessionInfo {
|
|
199
|
+
return {
|
|
200
|
+
...sessionInfo,
|
|
201
|
+
identifyActorGivenId: identity?.distinctId,
|
|
202
|
+
identifyActorData: identity?.properties || {},
|
|
203
|
+
identifyActorGroups: identity?.groups,
|
|
204
|
+
}
|
|
182
205
|
}
|
|
183
206
|
|
|
184
207
|
/**
|