@posthog/mcp 0.0.9 → 0.1.16
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/LICENSE +307 -3
- package/README.md +182 -90
- package/dist/extensions/capture.d.ts +13 -0
- package/dist/extensions/capture.d.ts.map +1 -0
- package/dist/extensions/capture.js +80 -0
- package/dist/extensions/capture.mjs +46 -0
- package/dist/extensions/compatibility.d.ts +23 -0
- package/dist/extensions/compatibility.d.ts.map +1 -0
- package/dist/extensions/compatibility.js +113 -0
- package/dist/extensions/compatibility.mjs +67 -0
- package/dist/extensions/constants.d.ts +39 -0
- package/dist/extensions/constants.d.ts.map +1 -0
- package/dist/extensions/constants.js +86 -0
- package/dist/extensions/constants.mjs +37 -0
- package/dist/extensions/context-parameters.d.ts +31 -0
- package/dist/extensions/context-parameters.d.ts.map +1 -0
- package/dist/extensions/context-parameters.js +93 -0
- package/dist/extensions/context-parameters.mjs +50 -0
- package/dist/extensions/conversation-id.d.ts +52 -0
- package/dist/extensions/conversation-id.d.ts.map +1 -0
- package/dist/extensions/conversation-id.js +164 -0
- package/dist/extensions/conversation-id.mjs +103 -0
- package/dist/extensions/event-types.d.ts +21 -0
- package/dist/extensions/event-types.d.ts.map +1 -0
- package/dist/extensions/event-types.js +47 -0
- package/dist/extensions/event-types.mjs +13 -0
- package/dist/extensions/exceptions.d.ts +13 -0
- package/dist/extensions/exceptions.d.ts.map +1 -0
- package/dist/extensions/exceptions.js +58 -0
- package/dist/extensions/exceptions.mjs +24 -0
- package/dist/extensions/ids.d.ts +13 -0
- package/dist/extensions/ids.d.ts.map +1 -0
- package/dist/extensions/ids.js +55 -0
- package/dist/extensions/ids.mjs +18 -0
- package/dist/extensions/instrument-highlevel.d.ts +3 -0
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -0
- package/dist/extensions/instrument-highlevel.js +229 -0
- package/dist/extensions/instrument-highlevel.mjs +195 -0
- package/dist/extensions/instrument-lowlevel.d.ts +8 -0
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -0
- package/dist/extensions/instrument-lowlevel.js +85 -0
- package/dist/extensions/instrument-lowlevel.mjs +51 -0
- package/dist/extensions/instrumentation.d.ts +59 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -0
- package/dist/extensions/instrumentation.js +237 -0
- package/dist/extensions/instrumentation.mjs +194 -0
- package/dist/extensions/intent.d.ts +10 -0
- package/dist/extensions/intent.d.ts.map +1 -0
- package/dist/extensions/intent.js +84 -0
- package/dist/extensions/intent.mjs +44 -0
- package/dist/extensions/internal.d.ts +32 -0
- package/dist/extensions/internal.d.ts.map +1 -0
- package/dist/extensions/internal.js +158 -0
- package/dist/extensions/internal.mjs +106 -0
- package/dist/extensions/logger.d.ts +14 -0
- package/dist/extensions/logger.d.ts.map +1 -0
- package/dist/extensions/logger.js +47 -0
- package/dist/extensions/logger.mjs +10 -0
- package/dist/extensions/mcp-payloads.d.ts +5 -0
- package/dist/extensions/mcp-payloads.d.ts.map +1 -0
- package/dist/extensions/mcp-payloads.js +91 -0
- package/dist/extensions/mcp-payloads.mjs +54 -0
- package/dist/extensions/mcp-sdk-compat.d.ts +33 -0
- package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -0
- package/dist/extensions/mcp-sdk-compat.js +112 -0
- package/dist/extensions/mcp-sdk-compat.mjs +60 -0
- package/dist/extensions/posthog-events.d.ts +14 -0
- package/dist/extensions/posthog-events.d.ts.map +1 -0
- package/dist/extensions/posthog-events.js +143 -0
- package/dist/extensions/posthog-events.mjs +109 -0
- package/dist/extensions/sanitization.d.ts +10 -0
- package/dist/extensions/sanitization.d.ts.map +1 -0
- package/dist/extensions/sanitization.js +96 -0
- package/dist/extensions/sanitization.mjs +62 -0
- package/dist/extensions/session.d.ts +20 -0
- package/dist/extensions/session.d.ts.map +1 -0
- package/dist/extensions/session.js +113 -0
- package/dist/extensions/session.mjs +67 -0
- package/dist/extensions/sink.d.ts +44 -0
- package/dist/extensions/sink.d.ts.map +1 -0
- package/dist/extensions/sink.js +102 -0
- package/dist/extensions/sink.mjs +65 -0
- package/dist/extensions/tools.d.ts +9 -0
- package/dist/extensions/tools.d.ts.map +1 -0
- package/dist/extensions/tools.js +79 -0
- package/dist/extensions/tools.mjs +39 -0
- package/dist/extensions/tracing-helpers.d.ts +29 -0
- package/dist/extensions/tracing-helpers.d.ts.map +1 -0
- package/dist/extensions/tracing-helpers.js +59 -0
- package/dist/extensions/tracing-helpers.mjs +16 -0
- package/dist/extensions/truncation.d.ts +25 -0
- package/dist/extensions/truncation.d.ts.map +1 -0
- package/dist/extensions/truncation.js +277 -0
- package/dist/extensions/truncation.mjs +228 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.mjs +83 -2929
- package/dist/types.d.ts +220 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.mjs +0 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +36 -0
- package/dist/version.mjs +2 -0
- package/package.json +55 -87
- package/src/extensions/capture.ts +68 -0
- package/src/extensions/compatibility.ts +138 -0
- package/src/extensions/constants.ts +48 -0
- package/src/extensions/context-parameters.ts +119 -0
- package/src/extensions/conversation-id.ts +177 -0
- package/src/extensions/event-types.ts +21 -0
- package/src/extensions/exceptions.ts +83 -0
- package/src/extensions/ids.ts +32 -0
- package/src/extensions/instrument-highlevel.ts +297 -0
- package/src/extensions/instrument-lowlevel.ts +83 -0
- package/src/extensions/instrumentation.ts +415 -0
- package/src/extensions/intent.ts +78 -0
- package/src/extensions/internal.ts +193 -0
- package/src/extensions/logger.ts +27 -0
- package/src/extensions/mcp-payloads.ts +98 -0
- package/src/extensions/mcp-sdk-compat.ts +160 -0
- package/src/extensions/posthog-events.ts +212 -0
- package/src/extensions/sanitization.ts +121 -0
- package/src/extensions/session.ts +117 -0
- package/src/extensions/sink.ts +130 -0
- package/src/extensions/tools.ts +50 -0
- package/src/extensions/tracing-helpers.ts +51 -0
- package/src/extensions/truncation.ts +453 -0
- package/src/index.ts +174 -0
- package/src/types.ts +244 -0
- package/src/version.ts +1 -0
- package/dist/index.cjs +0 -2937
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -264
- package/dist/index.d.mts +0 -263
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InitializeRequestSchema,
|
|
3
|
+
ListToolsRequestSchema,
|
|
4
|
+
type ListToolsResult,
|
|
5
|
+
} from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
import type { CompatibleRequestHandlerExtra, MCPAnalyticsData, MCPRequestLike, MCPServerLike, McpEvent } from '../types'
|
|
7
|
+
import { addContextParameterToTools, getContextDescription, isContextEnabled } from './context-parameters'
|
|
8
|
+
import {
|
|
9
|
+
addConversationIdToTools,
|
|
10
|
+
type ConversationIdResolution,
|
|
11
|
+
canInjectConversationIdPromptBack,
|
|
12
|
+
cloneRequestWithoutConversationId,
|
|
13
|
+
injectConversationIdPromptBack,
|
|
14
|
+
resolveConversationId,
|
|
15
|
+
} from './conversation-id'
|
|
16
|
+
import { captureEvent } from './capture'
|
|
17
|
+
import { MCPAnalyticsEventType } from './event-types'
|
|
18
|
+
import { captureException } from './exceptions'
|
|
19
|
+
import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from './intent'
|
|
20
|
+
import { getServerTrackingData, handleIdentify } from './internal'
|
|
21
|
+
import { log } from './logger'
|
|
22
|
+
import { buildCapturedMcpParameters } from './mcp-payloads'
|
|
23
|
+
import { getServerSessionId } from './session'
|
|
24
|
+
import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
|
|
25
|
+
import { applyResolvedMetadata, isToolResultError } from './tracing-helpers'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Single instrumentation core shared by the low-level (`Server`) and high-level
|
|
29
|
+
* (`McpServer`) wrappers. The two entrypoints differ only in how they reach the
|
|
30
|
+
* underlying tool — they both funnel the tool-call lifecycle through
|
|
31
|
+
* {@link captureToolCall}, so error handling, conversation-id minting, session
|
|
32
|
+
* attribution, and event capture are defined exactly once.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
type MCPRequestHandler = (request: MCPRequestLike, extra?: CompatibleRequestHandlerExtra) => Promise<unknown>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs the underlying tool. Receives the request with the SDK-injected
|
|
39
|
+
* `conversation_id` stripped; an adapter is free to ignore it (the high-level
|
|
40
|
+
* path strips arguments inside the wrapped callback instead).
|
|
41
|
+
*/
|
|
42
|
+
type ToolExecutor = (downstreamRequest: MCPRequestLike) => Promise<unknown>
|
|
43
|
+
|
|
44
|
+
interface TraceToolCallParams {
|
|
45
|
+
server: MCPServerLike
|
|
46
|
+
data: MCPAnalyticsData
|
|
47
|
+
request: MCPRequestLike
|
|
48
|
+
extra?: CompatibleRequestHandlerExtra
|
|
49
|
+
execute: ToolExecutor
|
|
50
|
+
/**
|
|
51
|
+
* Event type to capture. Defaults to a tool call; the `get_more_tools` virtual
|
|
52
|
+
* tool passes `mcpMissingCapability` so it records a capability gap rather than
|
|
53
|
+
* a tool invocation.
|
|
54
|
+
*/
|
|
55
|
+
eventType?: MCPAnalyticsEventType
|
|
56
|
+
/**
|
|
57
|
+
* When set, used verbatim as the captured intent (source `context_parameter`)
|
|
58
|
+
* instead of running `resolveToolCallIntent`. Used by the `get_more_tools`
|
|
59
|
+
* virtual tool, which carries its intent in the `context` argument.
|
|
60
|
+
*/
|
|
61
|
+
explicitContextIntent?: string
|
|
62
|
+
/**
|
|
63
|
+
* Optional accessor for an error the executor captured out-of-band. The
|
|
64
|
+
* high-level SDK turns thrown tool errors into `isError: true` results before
|
|
65
|
+
* they reach us, so the wrapped callback stashes the original error and we
|
|
66
|
+
* read it here to capture the real stack rather than the result envelope.
|
|
67
|
+
*/
|
|
68
|
+
takeCapturedError?: () => unknown
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shared tool-call lifecycle: resolve conversation id, build + enrich the
|
|
73
|
+
* analytics event, run the tool, then capture success/failure.
|
|
74
|
+
*
|
|
75
|
+
* Analytics is isolated from the tool path on both sides — a failure while
|
|
76
|
+
* preparing or publishing the event can never change what the tool returns or
|
|
77
|
+
* throws, and the tool's own errors are always re-thrown to the caller.
|
|
78
|
+
*/
|
|
79
|
+
export async function captureToolCall(params: TraceToolCallParams): Promise<unknown> {
|
|
80
|
+
const { server, data, request, extra, execute, eventType, explicitContextIntent, takeCapturedError } = params
|
|
81
|
+
|
|
82
|
+
const conversation = resolveConversationId(
|
|
83
|
+
data.options.enableConversationId ?? false,
|
|
84
|
+
request.params?.arguments,
|
|
85
|
+
request.params?.name
|
|
86
|
+
)
|
|
87
|
+
const downstreamRequest = conversation.conversationId ? cloneRequestWithoutConversationId(request) : request
|
|
88
|
+
|
|
89
|
+
// Prepare the event in isolation: if identity/metadata/intent resolution
|
|
90
|
+
// throws, we drop instrumentation for this call but still run the tool.
|
|
91
|
+
const startTime = new Date()
|
|
92
|
+
const event = await prepareToolCallEvent(
|
|
93
|
+
server,
|
|
94
|
+
data,
|
|
95
|
+
request,
|
|
96
|
+
downstreamRequest,
|
|
97
|
+
extra,
|
|
98
|
+
startTime,
|
|
99
|
+
conversation,
|
|
100
|
+
eventType ?? MCPAnalyticsEventType.mcpToolsCall
|
|
101
|
+
)
|
|
102
|
+
if (event && explicitContextIntent) {
|
|
103
|
+
setExplicitContextIntent(event, explicitContextIntent)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let result: unknown
|
|
107
|
+
try {
|
|
108
|
+
result = await execute(downstreamRequest)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
publishFailedToolEvent(server, event, error, startTime, conversation)
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const finalResult = applyConversationPromptBack(event, result, conversation)
|
|
115
|
+
publishSuccessfulToolEvent(server, event, finalResult, startTime, takeCapturedError)
|
|
116
|
+
return finalResult
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function prepareToolCallEvent(
|
|
120
|
+
server: MCPServerLike,
|
|
121
|
+
data: MCPAnalyticsData,
|
|
122
|
+
request: MCPRequestLike,
|
|
123
|
+
downstreamRequest: MCPRequestLike,
|
|
124
|
+
extra: CompatibleRequestHandlerExtra | undefined,
|
|
125
|
+
startTime: Date,
|
|
126
|
+
conversation: ConversationIdResolution,
|
|
127
|
+
eventType: MCPAnalyticsEventType
|
|
128
|
+
): Promise<McpEvent | null> {
|
|
129
|
+
try {
|
|
130
|
+
const sessionId = getServerSessionId(server, extra)
|
|
131
|
+
await handleIdentify(server, data, sessionId, request, extra)
|
|
132
|
+
|
|
133
|
+
const toolName = request.params?.name
|
|
134
|
+
const event: McpEvent = {
|
|
135
|
+
sessionId,
|
|
136
|
+
conversationId: conversation.conversationId,
|
|
137
|
+
resourceName: toolName || 'Unknown Tool Name',
|
|
138
|
+
parameters: buildCapturedMcpParameters(downstreamRequest),
|
|
139
|
+
eventType,
|
|
140
|
+
timestamp: startTime,
|
|
141
|
+
toolDescription: toolName ? data.toolDescriptions.get(toolName) : undefined,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await applyResolvedMetadata(event, data, request, extra)
|
|
145
|
+
setEventIntent(event, await resolveToolCallIntent(data, request, extra))
|
|
146
|
+
return event
|
|
147
|
+
} catch (error) {
|
|
148
|
+
log(
|
|
149
|
+
`Warning: PostHog MCP analytics instrumentation failed for tool ${request.params?.name}, the tool will still run - ${error}`
|
|
150
|
+
)
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* When we minted a conversation id, append the prompt-back so the agent echoes
|
|
157
|
+
* it on subsequent calls. If the result can't carry it, clear the id off the
|
|
158
|
+
* event so analytics doesn't show an orphan the agent never received.
|
|
159
|
+
*/
|
|
160
|
+
function applyConversationPromptBack(
|
|
161
|
+
event: McpEvent | null,
|
|
162
|
+
result: unknown,
|
|
163
|
+
conversation: ConversationIdResolution
|
|
164
|
+
): unknown {
|
|
165
|
+
if (!conversation.minted) {
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
if (canInjectConversationIdPromptBack(result)) {
|
|
169
|
+
return injectConversationIdPromptBack(result, conversation.conversationId)
|
|
170
|
+
}
|
|
171
|
+
if (event) {
|
|
172
|
+
event.conversationId = undefined
|
|
173
|
+
}
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function publishSuccessfulToolEvent(
|
|
178
|
+
server: MCPServerLike,
|
|
179
|
+
event: McpEvent | null,
|
|
180
|
+
result: unknown,
|
|
181
|
+
startTime: Date,
|
|
182
|
+
takeCapturedError?: () => unknown
|
|
183
|
+
): void {
|
|
184
|
+
if (!event) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
if (isToolResultError(result)) {
|
|
189
|
+
event.isError = true
|
|
190
|
+
const capturedError = takeCapturedError?.()
|
|
191
|
+
event.error = captureException(capturedError ?? result)
|
|
192
|
+
} else {
|
|
193
|
+
event.isError = false
|
|
194
|
+
}
|
|
195
|
+
event.response = result
|
|
196
|
+
event.duration = Date.now() - startTime.getTime()
|
|
197
|
+
captureEvent(server, event)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
log(`Warning: PostHog MCP analytics failed to publish tool event - ${error}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function publishFailedToolEvent(
|
|
204
|
+
server: MCPServerLike,
|
|
205
|
+
event: McpEvent | null,
|
|
206
|
+
error: unknown,
|
|
207
|
+
startTime: Date,
|
|
208
|
+
conversation: ConversationIdResolution
|
|
209
|
+
): void {
|
|
210
|
+
if (!event) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
if (conversation.minted) {
|
|
215
|
+
event.conversationId = undefined
|
|
216
|
+
}
|
|
217
|
+
event.isError = true
|
|
218
|
+
event.error = captureException(error)
|
|
219
|
+
event.duration = Date.now() - startTime.getTime()
|
|
220
|
+
captureEvent(server, event)
|
|
221
|
+
} catch (publishError) {
|
|
222
|
+
log(`Warning: PostHog MCP analytics failed to publish failed tool event - ${publishError}`)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- tools/list -----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
type MCPServerWithCapabilities = MCPServerLike & {
|
|
229
|
+
_capabilities?: {
|
|
230
|
+
tools?: unknown
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const listToolsTracingSetup = new WeakMap<MCPServerLike, boolean>()
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Wraps the server's `tools/list` handler so each listing is captured and the
|
|
238
|
+
* SDK-managed tools (context parameter, conversation id, `get_more_tools`) are
|
|
239
|
+
* injected. Idempotent per server. Works for both low-level and high-level
|
|
240
|
+
* servers — the high-level wrapper passes its underlying `server`.
|
|
241
|
+
*/
|
|
242
|
+
export function instrumentToolsListHandler(server: MCPServerLike): void {
|
|
243
|
+
if (!(server as MCPServerWithCapabilities)._capabilities?.tools) {
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
if (listToolsTracingSetup.get(server)) {
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const originalListToolsHandler = server._requestHandlers.get('tools/list')
|
|
251
|
+
if (!originalListToolsHandler) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
server.setRequestHandler(
|
|
257
|
+
ListToolsRequestSchema,
|
|
258
|
+
async (request, extra) => await handleListToolsRequest(server, originalListToolsHandler, request, extra)
|
|
259
|
+
)
|
|
260
|
+
listToolsTracingSetup.set(server, true)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
log(`Warning: Failed to override list tools handler - ${error}`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function handleListToolsRequest(
|
|
267
|
+
server: MCPServerLike,
|
|
268
|
+
originalListToolsHandler: MCPRequestHandler,
|
|
269
|
+
request: MCPRequestLike,
|
|
270
|
+
extra?: CompatibleRequestHandlerExtra
|
|
271
|
+
): Promise<{ tools: ListToolsResult['tools'] }> {
|
|
272
|
+
const data = getServerTrackingData(server)
|
|
273
|
+
const startTime = new Date()
|
|
274
|
+
const event: McpEvent = {
|
|
275
|
+
sessionId: getServerSessionId(server, extra),
|
|
276
|
+
parameters: buildCapturedMcpParameters(request),
|
|
277
|
+
eventType: MCPAnalyticsEventType.mcpToolsList,
|
|
278
|
+
timestamp: startTime,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (data) {
|
|
282
|
+
await applyResolvedMetadata(event, data, request, extra)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tools = await getTracedToolsList(server, originalListToolsHandler, request, extra, event)
|
|
286
|
+
|
|
287
|
+
if (!data) {
|
|
288
|
+
log(
|
|
289
|
+
'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using tool calls.'
|
|
290
|
+
)
|
|
291
|
+
return { tools }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (tools.length === 0) {
|
|
295
|
+
log(
|
|
296
|
+
'Warning: No tools found in the original list. This is likely due to the tools not being registered before PostHog MCP analytics.instrument().'
|
|
297
|
+
)
|
|
298
|
+
event.error = captureException('No tools were sent to MCP client.')
|
|
299
|
+
event.isError = true
|
|
300
|
+
event.duration = Date.now() - startTime.getTime()
|
|
301
|
+
captureEvent(server, event)
|
|
302
|
+
return { tools }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
event.response = { tools }
|
|
306
|
+
event.listedToolNames = collectListedToolNames(tools)
|
|
307
|
+
event.isError = false
|
|
308
|
+
event.duration = Date.now() - startTime.getTime()
|
|
309
|
+
captureEvent(server, event)
|
|
310
|
+
return { tools }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function collectListedToolNames(tools: ListToolsResult['tools'] | undefined): string[] | undefined {
|
|
314
|
+
if (!tools || tools.length === 0) {
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const names = tools.map((tool) => tool?.name).filter((name): name is string => typeof name === 'string')
|
|
318
|
+
return names.length > 0 ? names : undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function getTracedToolsList(
|
|
322
|
+
server: MCPServerLike,
|
|
323
|
+
originalListToolsHandler: MCPRequestHandler,
|
|
324
|
+
request: MCPRequestLike,
|
|
325
|
+
extra: CompatibleRequestHandlerExtra | undefined,
|
|
326
|
+
event: McpEvent
|
|
327
|
+
): Promise<ListToolsResult['tools']> {
|
|
328
|
+
try {
|
|
329
|
+
const data = getServerTrackingData(server)
|
|
330
|
+
const originalResponse = (await originalListToolsHandler(request, extra)) as ListToolsResult
|
|
331
|
+
let tools = originalResponse.tools || []
|
|
332
|
+
|
|
333
|
+
if (data && isContextEnabled(data.options.context)) {
|
|
334
|
+
tools = addContextParameterToTools(tools, getContextDescription(data.options.context))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (data?.options.enableConversationId) {
|
|
338
|
+
tools = addConversationIdToTools(tools)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (data?.options.reportMissing) {
|
|
342
|
+
const alreadyPresent = tools.some((tool) => tool?.name === GET_MORE_TOOLS_NAME)
|
|
343
|
+
if (!alreadyPresent) {
|
|
344
|
+
tools.push(getReportMissingToolDescriptor())
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (data) {
|
|
349
|
+
cacheToolDescriptions(data.toolDescriptions, tools)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return tools
|
|
353
|
+
} catch (error) {
|
|
354
|
+
log(
|
|
355
|
+
`Warning: Original list tools handler failed, this suggests an error PostHog MCP analytics did not cause - ${error}`
|
|
356
|
+
)
|
|
357
|
+
event.error = captureException(error)
|
|
358
|
+
event.isError = true
|
|
359
|
+
event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0
|
|
360
|
+
captureEvent(server, event)
|
|
361
|
+
throw error
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function cacheToolDescriptions(cache: Map<string, string>, tools: ListToolsResult['tools'] | undefined): void {
|
|
366
|
+
if (!tools) {
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
for (const tool of tools) {
|
|
370
|
+
if (tool?.name && typeof tool.description === 'string') {
|
|
371
|
+
cache.set(tool.name, tool.description)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- initialize -----------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Wraps the server's `initialize` handler so the connection handshake is
|
|
380
|
+
* captured (and identity resolved) before the original handler runs.
|
|
381
|
+
*/
|
|
382
|
+
export function instrumentInitializeHandler(server: MCPServerLike): void {
|
|
383
|
+
const originalInitializeHandler = server._requestHandlers.get('initialize')
|
|
384
|
+
if (!originalInitializeHandler) {
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
server.setRequestHandler(InitializeRequestSchema, async (request, extra) => {
|
|
389
|
+
const data = getServerTrackingData(server)
|
|
390
|
+
if (!data) {
|
|
391
|
+
log(
|
|
392
|
+
'Warning: PostHog MCP analytics is unable to find server tracking data. Please ensure you have called instrument(server, options) before using tool calls.'
|
|
393
|
+
)
|
|
394
|
+
return await originalInitializeHandler(request, extra)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const sessionId = getServerSessionId(server, extra)
|
|
398
|
+
await handleIdentify(server, data, sessionId, request, extra)
|
|
399
|
+
|
|
400
|
+
const event: McpEvent = {
|
|
401
|
+
sessionId,
|
|
402
|
+
resourceName: request.params?.name || 'Unknown Tool Name',
|
|
403
|
+
eventType: MCPAnalyticsEventType.mcpInitialize,
|
|
404
|
+
parameters: buildCapturedMcpParameters(request),
|
|
405
|
+
timestamp: new Date(),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await applyResolvedMetadata(event, data, request, extra)
|
|
409
|
+
|
|
410
|
+
const result = await originalInitializeHandler(request, extra)
|
|
411
|
+
event.response = result
|
|
412
|
+
captureEvent(server, event)
|
|
413
|
+
return result
|
|
414
|
+
})
|
|
415
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompatibleRequestHandlerExtra,
|
|
3
|
+
MCPAnalyticsData,
|
|
4
|
+
MCPAnalyticsIntentSource,
|
|
5
|
+
MCPRequestLike,
|
|
6
|
+
McpEvent,
|
|
7
|
+
} from '../types'
|
|
8
|
+
import { isContextEnabled } from './context-parameters'
|
|
9
|
+
import { log } from './logger'
|
|
10
|
+
|
|
11
|
+
interface ResolvedIntent {
|
|
12
|
+
intent: string
|
|
13
|
+
source: MCPAnalyticsIntentSource
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getContextArgument(request: MCPRequestLike): string | undefined {
|
|
17
|
+
const context = request.params?.arguments?.context
|
|
18
|
+
return typeof context === 'string' && context.trim() ? context : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeIntent(intent: string | null | undefined): string | null {
|
|
22
|
+
if (typeof intent !== 'string') {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const trimmed = intent.trim()
|
|
27
|
+
return trimmed ? trimmed : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runIntentFallback(
|
|
31
|
+
data: MCPAnalyticsData,
|
|
32
|
+
request: MCPRequestLike,
|
|
33
|
+
extra?: CompatibleRequestHandlerExtra
|
|
34
|
+
): Promise<ResolvedIntent | null> {
|
|
35
|
+
if (!data.options.intentFallback) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const intent = normalizeIntent(await data.options.intentFallback(request, extra))
|
|
41
|
+
return intent ? { intent, source: 'inferred' } : null
|
|
42
|
+
} catch (error) {
|
|
43
|
+
log(`intentFallback callback error: ${error}`)
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function resolveToolCallIntent(
|
|
49
|
+
data: MCPAnalyticsData,
|
|
50
|
+
request: MCPRequestLike,
|
|
51
|
+
extra?: CompatibleRequestHandlerExtra
|
|
52
|
+
): Promise<ResolvedIntent | null> {
|
|
53
|
+
const contextArgument = getContextArgument(request)
|
|
54
|
+
if (isContextEnabled(data.options.context) && request.params?.name !== 'get_more_tools' && contextArgument) {
|
|
55
|
+
return { intent: contextArgument, source: 'context_parameter' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return await runIntentFallback(data, request, extra)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setEventIntent(event: McpEvent, resolvedIntent: ResolvedIntent | null): void {
|
|
62
|
+
if (!resolvedIntent) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
event.userIntent = resolvedIntent.intent
|
|
67
|
+
event.userIntentSource = resolvedIntent.source
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function setExplicitContextIntent(event: McpEvent, context: string): void {
|
|
71
|
+
const intent = normalizeIntent(context)
|
|
72
|
+
if (!intent) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
event.userIntent = intent
|
|
77
|
+
event.userIntentSource = 'context_parameter'
|
|
78
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompatibleRequestHandlerExtra,
|
|
3
|
+
MCPAnalyticsData,
|
|
4
|
+
MCPRequestLike,
|
|
5
|
+
MCPServerLike,
|
|
6
|
+
McpEvent,
|
|
7
|
+
UserIdentity,
|
|
8
|
+
} from '../types'
|
|
9
|
+
import { MCPAnalyticsEventType } from './event-types'
|
|
10
|
+
import { log } from './logger'
|
|
11
|
+
import { captureEvent } from './capture'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounded LRU cache for session identities, capped at `maxSize` entries so a
|
|
15
|
+
* long-lived server can't accumulate identities for unboundedly many sessions.
|
|
16
|
+
* One instance lives on each server's tracking data — it is NOT shared across
|
|
17
|
+
* server instances, so identities never bleed between servers.
|
|
18
|
+
*/
|
|
19
|
+
export class IdentityCache {
|
|
20
|
+
private readonly _cache = new Map<string, UserIdentity>()
|
|
21
|
+
private readonly _maxSize: number
|
|
22
|
+
|
|
23
|
+
constructor(maxSize = 1000) {
|
|
24
|
+
this._maxSize = maxSize
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(sessionId: string): UserIdentity | undefined {
|
|
28
|
+
const identity = this._cache.get(sessionId)
|
|
29
|
+
if (identity === undefined) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
// Touch: re-insert so it counts as most-recently-used.
|
|
33
|
+
this._cache.delete(sessionId)
|
|
34
|
+
this._cache.set(sessionId, identity)
|
|
35
|
+
return identity
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(sessionId: string, identity: UserIdentity): void {
|
|
39
|
+
this._cache.delete(sessionId)
|
|
40
|
+
|
|
41
|
+
if (this._cache.size >= this._maxSize) {
|
|
42
|
+
const oldestKey = this._cache.keys().next().value
|
|
43
|
+
if (oldestKey !== undefined) {
|
|
44
|
+
this._cache.delete(oldestKey)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this._cache.set(sessionId, identity)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
has(sessionId: string): boolean {
|
|
52
|
+
return this._cache.has(sessionId)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
size(): number {
|
|
56
|
+
return this._cache.size
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const _serverTracking = new WeakMap<MCPServerLike, MCPAnalyticsData>()
|
|
61
|
+
|
|
62
|
+
export function getServerTrackingData(server: MCPServerLike): MCPAnalyticsData | undefined {
|
|
63
|
+
return _serverTracking.get(server)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setServerTrackingData(server: MCPServerLike, data: MCPAnalyticsData): void {
|
|
67
|
+
_serverTracking.set(server, data)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function areIdentitiesEqual(a: UserIdentity, b: UserIdentity): boolean {
|
|
71
|
+
if (a.distinctId !== b.distinctId) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
if (JSON.stringify(a.groups ?? {}) !== JSON.stringify(b.groups ?? {})) {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const aProps = a.properties || {}
|
|
79
|
+
const bProps = b.properties || {}
|
|
80
|
+
|
|
81
|
+
const aKeys = Object.keys(aProps)
|
|
82
|
+
const bKeys = Object.keys(bProps)
|
|
83
|
+
|
|
84
|
+
if (aKeys.length !== bKeys.length) {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const key of aKeys) {
|
|
89
|
+
if (!(key in bProps)) {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
if (JSON.stringify(aProps[key]) !== JSON.stringify(bProps[key])) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function mergeIdentities(previous: UserIdentity | undefined, next: UserIdentity): UserIdentity {
|
|
101
|
+
if (!previous) {
|
|
102
|
+
return next
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
distinctId: next.distinctId,
|
|
107
|
+
properties: {
|
|
108
|
+
...(previous.properties || {}),
|
|
109
|
+
...(next.properties || {}),
|
|
110
|
+
},
|
|
111
|
+
groups: next.groups ?? previous.groups,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves the optional `identify` callback, dedupes against the server's identity
|
|
117
|
+
* cache, and publishes an `$identify` event only when the identity has materially changed.
|
|
118
|
+
*/
|
|
119
|
+
export async function handleIdentify(
|
|
120
|
+
server: MCPServerLike,
|
|
121
|
+
data: MCPAnalyticsData,
|
|
122
|
+
sessionId: string,
|
|
123
|
+
request: MCPRequestLike,
|
|
124
|
+
extra?: CompatibleRequestHandlerExtra
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (!data.options.identify) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const identifyEvent: McpEvent = {
|
|
131
|
+
sessionId,
|
|
132
|
+
resourceName: getRequestResourceName(request),
|
|
133
|
+
eventType: MCPAnalyticsEventType.identify,
|
|
134
|
+
parameters: { request, extra },
|
|
135
|
+
timestamp: new Date(),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const identityResult =
|
|
140
|
+
typeof data.options.identify === 'function' ? await data.options.identify(request, extra) : data.options.identify
|
|
141
|
+
|
|
142
|
+
if (identityResult) {
|
|
143
|
+
const previousIdentity = data.identifiedSessions.get(sessionId)
|
|
144
|
+
const mergedIdentity = mergeIdentities(previousIdentity, identityResult)
|
|
145
|
+
const hasChanged = !(previousIdentity && areIdentitiesEqual(previousIdentity, mergedIdentity))
|
|
146
|
+
|
|
147
|
+
data.identifiedSessions.set(sessionId, mergedIdentity)
|
|
148
|
+
|
|
149
|
+
if (hasChanged) {
|
|
150
|
+
log(`Identified session ${sessionId} with identity: ${JSON.stringify(mergedIdentity)}`)
|
|
151
|
+
captureEvent(server, identifyEvent)
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
log(`Warning: Supplied identify function returned null for session ${sessionId}`)
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log(`Error: User supplied identify function threw an error while identifying session ${sessionId} - ${error}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolves the `eventProperties` callback. Returns null when no callback is
|
|
163
|
+
* configured, or when it returns nullish or throws — a throw never propagates
|
|
164
|
+
* into the tool path.
|
|
165
|
+
*/
|
|
166
|
+
export async function resolveEventProperties(
|
|
167
|
+
data: MCPAnalyticsData,
|
|
168
|
+
request: MCPRequestLike,
|
|
169
|
+
extra?: CompatibleRequestHandlerExtra
|
|
170
|
+
): Promise<Record<string, unknown> | null> {
|
|
171
|
+
if (!data.options.eventProperties) {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return (await data.options.eventProperties(request, extra)) ?? null
|
|
176
|
+
} catch (e) {
|
|
177
|
+
log(`eventProperties callback error: ${e}`)
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function getRequestResourceName(request: unknown): string {
|
|
183
|
+
if (!request || typeof request !== 'object' || !('params' in request)) {
|
|
184
|
+
return 'Unknown'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const params = request.params
|
|
188
|
+
if (!params || typeof params !== 'object' || !('name' in params)) {
|
|
189
|
+
return 'Unknown'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return typeof params.name === 'string' ? params.name : 'Unknown'
|
|
193
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STDIO-safe logger.
|
|
3
|
+
*
|
|
4
|
+
* MCP servers running over the STDIO transport use stdout/stderr to exchange
|
|
5
|
+
* protocol messages, so we cannot use the default `console.log`. We accept a
|
|
6
|
+
* `logger` option on the public API; when omitted, log calls are silently
|
|
7
|
+
* dropped. Errors that affect tracking should still be observable in apps that
|
|
8
|
+
* want them, so the consumer can plug in any function (e.g. `console.error`
|
|
9
|
+
* for non-STDIO transports, a file logger, etc.).
|
|
10
|
+
*/
|
|
11
|
+
export type LoggerFn = (message: string) => void
|
|
12
|
+
|
|
13
|
+
let activeLogger: LoggerFn | undefined
|
|
14
|
+
|
|
15
|
+
export function setLogger(logger: LoggerFn | undefined): void {
|
|
16
|
+
activeLogger = logger
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function log(message: string): void {
|
|
20
|
+
if (activeLogger) {
|
|
21
|
+
try {
|
|
22
|
+
activeLogger(message)
|
|
23
|
+
} catch {
|
|
24
|
+
// never let logging blow up the tracking pipeline
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|