@posthog/mcp 0.10.8 → 0.11.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/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/compatibility.d.ts.map +1 -1
- package/dist/extensions/constants.d.ts +2 -0
- package/dist/extensions/constants.d.ts.map +1 -1
- package/dist/extensions/constants.js +3 -1
- package/dist/extensions/constants.mjs +3 -1
- package/dist/extensions/context-parameters.d.ts.map +1 -1
- package/dist/extensions/conversation-id.d.ts +2 -13
- package/dist/extensions/conversation-id.d.ts.map +1 -1
- package/dist/extensions/headers.d.ts +33 -0
- package/dist/extensions/headers.d.ts.map +1 -0
- package/dist/extensions/headers.js +54 -0
- package/dist/extensions/headers.mjs +17 -0
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
- package/dist/extensions/instrument-lowlevel.js +6 -5
- package/dist/extensions/instrument-lowlevel.mjs +7 -6
- package/dist/extensions/instrumentation.d.ts +29 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -1
- package/dist/extensions/instrumentation.js +17 -7
- package/dist/extensions/instrumentation.mjs +15 -8
- package/dist/extensions/internal.d.ts.map +1 -1
- package/dist/extensions/internal.js +2 -0
- package/dist/extensions/internal.mjs +2 -0
- package/dist/extensions/mcp-sdk-compat.d.ts +10 -0
- package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -1
- package/dist/extensions/mcp-sdk-compat.js +11 -2
- package/dist/extensions/mcp-sdk-compat.mjs +7 -1
- package/dist/extensions/output-instructions.d.ts +6 -8
- package/dist/extensions/output-instructions.d.ts.map +1 -1
- package/dist/extensions/posthog-events.js +4 -0
- package/dist/extensions/posthog-events.mjs +4 -0
- package/dist/extensions/posthog-mcp.d.ts.map +1 -1
- package/dist/extensions/posthog-mcp.js +3 -0
- package/dist/extensions/posthog-mcp.mjs +3 -0
- package/dist/extensions/session-token.d.ts.map +1 -1
- package/dist/extensions/session-token.js +5 -14
- package/dist/extensions/session-token.mjs +2 -11
- package/dist/extensions/session.d.ts +4 -3
- package/dist/extensions/session.d.ts.map +1 -1
- package/dist/extensions/sink.d.ts +4 -6
- package/dist/extensions/sink.d.ts.map +1 -1
- package/dist/extensions/tools.d.ts.map +1 -1
- package/dist/extensions/transport-identity.d.ts +59 -0
- package/dist/extensions/transport-identity.d.ts.map +1 -0
- package/dist/extensions/transport-identity.js +63 -0
- package/dist/extensions/transport-identity.mjs +20 -0
- package/dist/extensions/truncation.d.ts.map +1 -1
- package/dist/extensions/truncation.js +2 -0
- package/dist/extensions/truncation.mjs +2 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.mjs +1 -1
- package/package.json +1 -1
- package/src/extensions/capture.ts +6 -0
- package/src/extensions/compatibility.ts +0 -9
- package/src/extensions/constants.ts +2 -0
- package/src/extensions/context-parameters.ts +3 -11
- package/src/extensions/conversation-id.ts +5 -17
- package/src/extensions/headers.ts +52 -0
- package/src/extensions/instrument-highlevel.ts +0 -1
- package/src/extensions/instrument-lowlevel.ts +14 -10
- package/src/extensions/instrumentation.ts +82 -46
- package/src/extensions/internal.ts +6 -5
- package/src/extensions/mcp-sdk-compat.ts +18 -0
- package/src/extensions/output-instructions.ts +6 -8
- package/src/extensions/posthog-events.ts +14 -0
- package/src/extensions/posthog-mcp.ts +7 -5
- package/src/extensions/session-token.ts +3 -15
- package/src/extensions/session.ts +21 -35
- package/src/extensions/sink.ts +4 -6
- package/src/extensions/tools.ts +3 -6
- package/src/extensions/transport-identity.ts +94 -0
- package/src/extensions/truncation.ts +4 -0
- package/src/types.ts +33 -0
- package/src/version.ts +1 -1
|
@@ -21,26 +21,22 @@ type ServerRecord = Record<string, unknown>
|
|
|
21
21
|
* - Server info structure
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
// Function to log compatibility information
|
|
25
24
|
export function logCompatibilityWarning(logger: LoggerFn): void {
|
|
26
25
|
logger(
|
|
27
26
|
'PostHog MCP analytics SDK Compatibility: This version only supports Model Context Protocol TypeScript SDK v1.11 and above. Please upgrade if using an older version.'
|
|
28
27
|
)
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
// Check if server has high-level structure (wrapper with .server property)
|
|
32
30
|
export function isHighLevelServer(server: unknown): server is ServerRecord & { server: ServerRecord } {
|
|
33
31
|
return (
|
|
34
32
|
!!server && typeof server === 'object' && 'server' in server && !!server.server && typeof server.server === 'object'
|
|
35
33
|
)
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
// Check if server has low-level structure (no .server property)
|
|
39
36
|
export function isLowLevelServer(server: unknown): server is ServerRecord {
|
|
40
37
|
return !!server && typeof server === 'object' && !('server' in server)
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
// Type guard function that validates server compatibility and returns typed server
|
|
44
40
|
export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPServerLike | HighLevelMCPServerLike {
|
|
45
41
|
if (!server || typeof server !== 'object') {
|
|
46
42
|
logCompatibilityWarning(logger)
|
|
@@ -50,7 +46,6 @@ export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPSe
|
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
if (isHighLevelServer(server)) {
|
|
53
|
-
// Validate high-level server requirements
|
|
54
49
|
if (!server._registeredTools || typeof server._registeredTools !== 'object') {
|
|
55
50
|
logCompatibilityWarning(logger)
|
|
56
51
|
throw new Error(
|
|
@@ -64,18 +59,15 @@ export function isCompatibleServerType(server: unknown, logger: LoggerFn): MCPSe
|
|
|
64
59
|
)
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
// Validate the underlying low-level server
|
|
68
62
|
const targetServer = server.server
|
|
69
63
|
validateLowLevelServer(targetServer, logger)
|
|
70
64
|
|
|
71
65
|
return server as unknown as HighLevelMCPServerLike
|
|
72
66
|
}
|
|
73
|
-
// Direct low-level server validation
|
|
74
67
|
validateLowLevelServer(server, logger)
|
|
75
68
|
return server as MCPServerLike
|
|
76
69
|
}
|
|
77
70
|
|
|
78
|
-
// Helper function to validate low-level server requirements
|
|
79
71
|
function validateLowLevelServer(server: unknown, logger: LoggerFn): void {
|
|
80
72
|
if (!server || typeof server !== 'object') {
|
|
81
73
|
logCompatibilityWarning(logger)
|
|
@@ -100,7 +92,6 @@ function validateLowLevelServer(server: unknown, logger: LoggerFn): void {
|
|
|
100
92
|
)
|
|
101
93
|
}
|
|
102
94
|
|
|
103
|
-
// Validate that _requestHandlers contains functions with compatible signatures
|
|
104
95
|
if (typeof serverRecord._requestHandlers.get !== 'function') {
|
|
105
96
|
logCompatibilityWarning(logger)
|
|
106
97
|
throw new Error(
|
|
@@ -38,6 +38,7 @@ export type PostHogMCPAnalyticsEvent = (typeof PostHogMCPAnalyticsEvent)[keyof t
|
|
|
38
38
|
|
|
39
39
|
export const PostHogMCPAnalyticsProperty = {
|
|
40
40
|
ClientName: '$mcp_client_name',
|
|
41
|
+
ClientUserAgent: '$mcp_client_user_agent',
|
|
41
42
|
ClientVersion: '$mcp_client_version',
|
|
42
43
|
ConversationId: '$mcp_conversation_id',
|
|
43
44
|
DurationMs: '$mcp_duration_ms',
|
|
@@ -58,6 +59,7 @@ export const PostHogMCPAnalyticsProperty = {
|
|
|
58
59
|
ToolCategory: '$mcp_tool_category',
|
|
59
60
|
ToolDescription: '$mcp_tool_description',
|
|
60
61
|
ToolName: '$mcp_tool_name',
|
|
62
|
+
VendorClient: '$mcp_vendor_client',
|
|
61
63
|
} as const
|
|
62
64
|
|
|
63
65
|
export type PostHogMCPAnalyticsProperty = (typeof PostHogMCPAnalyticsProperty)[keyof typeof PostHogMCPAnalyticsProperty]
|
|
@@ -41,7 +41,6 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
|
|
|
41
41
|
contextDescriptionOverride?: string,
|
|
42
42
|
logger: LoggerFn = log
|
|
43
43
|
): TTool {
|
|
44
|
-
// Create a shallow copy of the tool to avoid modifying the original
|
|
45
44
|
const modifiedTool = { ...tool }
|
|
46
45
|
const toolName = tool.name || 'unknown'
|
|
47
46
|
const schema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema | undefined
|
|
@@ -55,10 +54,6 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
|
|
|
55
54
|
return modifiedTool
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
// Note: If additionalProperties is false, we'll need to remove that constraint
|
|
59
|
-
// when adding context, otherwise the schema would be invalid. We handle this
|
|
60
|
-
// after the deep copy below.
|
|
61
|
-
|
|
62
57
|
if (!modifiedTool.inputSchema) {
|
|
63
58
|
modifiedTool.inputSchema = {
|
|
64
59
|
type: 'object',
|
|
@@ -69,29 +64,26 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
|
|
|
69
64
|
|
|
70
65
|
const contextDescription = contextDescriptionOverride || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION
|
|
71
66
|
|
|
72
|
-
// Deep copy the
|
|
67
|
+
// Deep copy: the server may reuse or freeze the schema object it handed us.
|
|
73
68
|
modifiedTool.inputSchema = JSON.parse(JSON.stringify(modifiedTool.inputSchema)) as AnalyticsInjectableJsonSchema
|
|
74
69
|
|
|
75
70
|
const inputSchema = modifiedTool.inputSchema as AnalyticsInjectableJsonSchema
|
|
76
71
|
|
|
77
|
-
// Ensure properties object exists
|
|
78
72
|
if (!inputSchema.properties) {
|
|
79
73
|
inputSchema.properties = {}
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
76
|
+
// The MCP SDK emits `additionalProperties: false` when converting Zod schemas;
|
|
77
|
+
// left in place it would make the injected `context` key invalid.
|
|
84
78
|
if (inputSchema.additionalProperties === false) {
|
|
85
79
|
inputSchema.additionalProperties = undefined
|
|
86
80
|
}
|
|
87
81
|
|
|
88
|
-
// Add context property
|
|
89
82
|
inputSchema.properties.context = {
|
|
90
83
|
type: 'string',
|
|
91
84
|
description: contextDescription,
|
|
92
85
|
}
|
|
93
86
|
|
|
94
|
-
// Add context to required array
|
|
95
87
|
if (Array.isArray(inputSchema.required)) {
|
|
96
88
|
if (!inputSchema.required.includes('context')) {
|
|
97
89
|
inputSchema.required.push('context')
|
|
@@ -102,20 +102,9 @@ export type ConversationIdResolution =
|
|
|
102
102
|
* derivation is deterministic so that two pods agree — which also means two
|
|
103
103
|
* *callers* sending the same string land in the same session. The strings agents
|
|
104
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
|
|
105
|
+
* would silently merge unrelated conversations, potentially across users. Shape
|
|
109
106
|
* 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.
|
|
107
|
+
* memory of what it minted. Residual risk and why it's accepted: ADR-0004.
|
|
119
108
|
*/
|
|
120
109
|
export function resolveConversationId(enabled: boolean, args: unknown): ConversationIdResolution {
|
|
121
110
|
if (!enabled) {
|
|
@@ -123,10 +112,9 @@ export function resolveConversationId(enabled: boolean, args: unknown): Conversa
|
|
|
123
112
|
}
|
|
124
113
|
const supplied = extractConversationId(args)
|
|
125
114
|
if (supplied && MINTED_CONVERSATION_ID.test(supplied)) {
|
|
126
|
-
// Lowercased
|
|
127
|
-
// `$session_id` is not
|
|
128
|
-
//
|
|
129
|
-
// land in a different session than the call that minted it.
|
|
115
|
+
// Lowercased: the shape test is case-insensitive but the hash behind
|
|
116
|
+
// `$session_id` is not, so an uppercased echo (some hosts normalise uuids)
|
|
117
|
+
// would land in a different session than the call that minted it.
|
|
130
118
|
return { minted: false, conversationId: supplied.toLowerCase() }
|
|
131
119
|
}
|
|
132
120
|
return { minted: true, conversationId: uuidv7() }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single place this package reads an HTTP request header.
|
|
3
|
+
*
|
|
4
|
+
* The MCP SDK hands us `IsomorphicHeaders` — a plain
|
|
5
|
+
* `Record<string, string | string[] | undefined>`, not a `Headers` instance — so
|
|
6
|
+
* values come out by bracket access rather than `.get()`. MCP SDK v2 plans to
|
|
7
|
+
* pass a real `Headers` object instead; funnelling every read through here keeps
|
|
8
|
+
* that migration a one-function change instead of a hunt for bracket lookups.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads one header value out of an `IsomorphicHeaders` bag. `name` must be
|
|
13
|
+
* lower-cased: the HTTP transports build the bag with
|
|
14
|
+
* `Object.fromEntries(req.headers.entries())` and the Fetch spec lower-cases
|
|
15
|
+
* header names. A hand-rolled transport can still hand us mixed-case keys, so a
|
|
16
|
+
* case-insensitive scan backs up the direct lookup.
|
|
17
|
+
*
|
|
18
|
+
* Repeated headers arrive as an array — we take the first entry, since every
|
|
19
|
+
* header we read is single-valued by definition. Returns `undefined` for a
|
|
20
|
+
* missing, non-string, or blank value, and never throws: a malformed header from
|
|
21
|
+
* an untrusted client must not break the request it rode in on.
|
|
22
|
+
*/
|
|
23
|
+
export function readHeaderValue(headers: unknown, name: string): string | undefined {
|
|
24
|
+
if (!headers || typeof headers !== 'object') {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
const record = headers as Record<string, unknown>
|
|
28
|
+
let value = record[name]
|
|
29
|
+
if (value === undefined) {
|
|
30
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name)
|
|
31
|
+
value = key === undefined ? undefined : record[key]
|
|
32
|
+
}
|
|
33
|
+
return normalizeHeaderString(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Coerces one raw header value into a clean string, or `undefined`.
|
|
38
|
+
*
|
|
39
|
+
* Exported so the custom-dispatcher path can apply the same rules to values a host
|
|
40
|
+
* read off the request itself. Without it the two integration paths emit different
|
|
41
|
+
* properties for the *same* request: a whitespace-only header would survive as a junk
|
|
42
|
+
* value, and a repeated header would reach the wire as an array, because the
|
|
43
|
+
* downstream length cap passes non-strings through untouched.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeHeaderString(value: unknown): string | undefined {
|
|
46
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
47
|
+
if (typeof first !== 'string') {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
const trimmed = first.trim()
|
|
51
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
52
|
+
}
|
|
@@ -280,7 +280,6 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
|
|
|
280
280
|
const lowLevelServer = server.server
|
|
281
281
|
const mcpAnalyticsData = getServerTrackingData(lowLevelServer)
|
|
282
282
|
|
|
283
|
-
// Patch already existing handlers, and patch setRequestHandler to capture dynamically created handlers.
|
|
284
283
|
const handlers: Record<string, HandlerPatch> = {
|
|
285
284
|
initialize: (trackedServer, originalHandler, request, extra) =>
|
|
286
285
|
handleInitializeRequest(trackedServer, originalHandler, request, extra, logger),
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// Copyright (c) 2025 AgentCat, Inc. (formerly MCPcat)
|
|
4
4
|
// Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
|
|
5
5
|
|
|
6
|
-
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
7
6
|
import type { CompatibleRequestHandlerExtra, MCPRequestLike, MCPServerLike } from '../types'
|
|
8
7
|
import { MCPAnalyticsEventType } from './event-types'
|
|
9
8
|
import { getServerTrackingData } from './internal'
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
handleInitializeRequest,
|
|
14
13
|
handleListToolsRequest,
|
|
15
14
|
patchRequestHandlers,
|
|
15
|
+
registerFallbackRequestHandler,
|
|
16
16
|
captureToolCall,
|
|
17
17
|
isToolAdvertised,
|
|
18
18
|
type HandlerPatch,
|
|
@@ -32,25 +32,24 @@ type MCPRequestExtra = Parameters<MCPRequestHandler>[1]
|
|
|
32
32
|
*/
|
|
33
33
|
export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn): void {
|
|
34
34
|
try {
|
|
35
|
-
// Patch already existing handlers, and patch setRequestHandler to capture dynamically created handlers.
|
|
36
35
|
const hadCallToolHandler = server._requestHandlers.has('tools/call')
|
|
36
|
+
const traceToolCall: HandlerPatch = (server, originalHandler, request, extra) =>
|
|
37
|
+
handleToolCallRequest(server, originalHandler, request, extra, logger)
|
|
37
38
|
const handlers: Record<string, HandlerPatch> = {
|
|
38
39
|
initialize: (server, originalHandler, request, extra) =>
|
|
39
40
|
handleInitializeRequest(server, originalHandler, request, extra, logger),
|
|
40
41
|
'tools/list': (server, originalHandler, request, extra) =>
|
|
41
42
|
handleListToolsRequest(server, originalHandler, request, extra, logger),
|
|
42
|
-
'tools/call':
|
|
43
|
-
handleToolCallRequest(server, originalHandler, request, extra, logger),
|
|
43
|
+
'tools/call': traceToolCall,
|
|
44
44
|
}
|
|
45
45
|
patchRequestHandlers(server, handlers)
|
|
46
46
|
|
|
47
47
|
if (!hadCallToolHandler) {
|
|
48
|
-
// Register a raw fallback
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
48
|
+
// Register a raw fallback so reportMissing works even before an application
|
|
49
|
+
// dispatcher is attached. A later registration replaces it and is wrapped by
|
|
50
|
+
// the patched setRequestHandler. Written into the handler map directly — see
|
|
51
|
+
// registerFallbackRequestHandler for why the SDK setter is the wrong door.
|
|
52
|
+
registerFallbackRequestHandler(server, 'tools/call', unknownToolHandler, traceToolCall)
|
|
54
53
|
}
|
|
55
54
|
} catch (error) {
|
|
56
55
|
logger(`Warning: Failed to setup tool call instrumentation - ${error}`)
|
|
@@ -58,6 +57,11 @@ export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn
|
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
|
|
60
|
+
/** Stands in for an application dispatcher that has not been attached yet. */
|
|
61
|
+
async function unknownToolHandler(request: MCPRequestLike): Promise<never> {
|
|
62
|
+
throw new Error(`Unknown tool: ${request.params?.name || 'unknown'}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
61
65
|
async function handleToolCallRequest(
|
|
62
66
|
server: MCPServerLike,
|
|
63
67
|
originalCallToolHandler: MCPRequestHandler | undefined,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveConversationId,
|
|
25
25
|
} from './conversation-id'
|
|
26
26
|
import { stampMetaClientInfo } from './client-identity'
|
|
27
|
+
import { stampTransportIdentity } from './transport-identity'
|
|
27
28
|
import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
|
|
28
29
|
import { captureEvent } from './capture'
|
|
29
30
|
import { MCPAnalyticsEventType } from './event-types'
|
|
@@ -32,7 +33,7 @@ import { resolveToolCallIntent, setEventIntent, setExplicitContextIntent } from
|
|
|
32
33
|
import { getServerTrackingData, handleIdentify, setServerTrackingData, withIdentity } from './internal'
|
|
33
34
|
import type { LoggerFn } from './logger'
|
|
34
35
|
import { buildCapturedMcpParameters } from './mcp-payloads'
|
|
35
|
-
import {
|
|
36
|
+
import { readRequestHandlerMethod } from './mcp-sdk-compat'
|
|
36
37
|
import { getSessionId, getSessionInfo, newSessionId } from './session'
|
|
37
38
|
import { encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token'
|
|
38
39
|
import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools'
|
|
@@ -157,22 +158,13 @@ function getActiveAnalyticsParameterOwnership(
|
|
|
157
158
|
return {
|
|
158
159
|
context: !isMissingCapabilityTool && isContextEnabled(data.options.context) && ownership?.context === true,
|
|
159
160
|
conversationId: data.options.enableConversationId === true && ownership?.conversationId === true,
|
|
160
|
-
// Deliberately read off `listed`, never the override
|
|
161
|
-
// `tools/list`
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
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.
|
|
161
|
+
// Deliberately read off `listed`, never the override: only the advertised
|
|
162
|
+
// JSON Schema can say whether `tools/list` declared `_mcp_instructions` (an
|
|
163
|
+
// override is built from the live registry, which holds Zod on the
|
|
164
|
+
// high-level path). An instance that never served a listing has no answer
|
|
165
|
+
// and fails closed — writing an undeclared key fails the customer's entire
|
|
166
|
+
// tool result under `additionalProperties: false`. See ADR-0004 for the
|
|
167
|
+
// per-request-instance gap this leaves and the planned fix.
|
|
176
168
|
outputInstructions: data.options.enableConversationId === true && listed?.outputInstructions === true,
|
|
177
169
|
}
|
|
178
170
|
}
|
|
@@ -237,6 +229,9 @@ async function prepareToolCallEvent(
|
|
|
237
229
|
// `_meta` on every request rather than at `initialize`; stamp them onto this
|
|
238
230
|
// event now so concurrent requests can't cross-attribute it.
|
|
239
231
|
stampMetaClientInfo(event, request)
|
|
232
|
+
// Which *surface* of the client made this call lives only in the request
|
|
233
|
+
// headers (HTTP transports); `clientInfo` can't tell a vendor's products apart.
|
|
234
|
+
stampTransportIdentity(event, extra)
|
|
240
235
|
|
|
241
236
|
await applyResolvedMetadata(event, data, request, extra)
|
|
242
237
|
setEventIntent(event, await resolveToolCallIntent(data, request, ownership.context, extra))
|
|
@@ -250,19 +245,14 @@ async function prepareToolCallEvent(
|
|
|
250
245
|
}
|
|
251
246
|
|
|
252
247
|
/**
|
|
253
|
-
* Delivers the conversation session handle back to the agent
|
|
254
|
-
* result has:
|
|
248
|
+
* Delivers the conversation session handle back to the agent over both channels
|
|
249
|
+
* a tool result has: mirrored into `structuredContent` on every response (for
|
|
250
|
+
* tools whose output schema declares the key), and as a `content` text block on
|
|
251
|
+
* the minting response only. Why two channels and why those cadences: ADR-0004.
|
|
255
252
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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.
|
|
253
|
+
* If neither channel could carry a session handle we minted, the agent never
|
|
254
|
+
* received it, so clear it off the event rather than showing analytics an id
|
|
255
|
+
* nobody has.
|
|
266
256
|
*/
|
|
267
257
|
function applyConversationInstructions(
|
|
268
258
|
event: McpEvent | null,
|
|
@@ -364,13 +354,7 @@ export type HandlerPatch = (
|
|
|
364
354
|
extra: CompatibleRequestHandlerExtra | undefined
|
|
365
355
|
) => Promise<unknown>
|
|
366
356
|
|
|
367
|
-
/**
|
|
368
|
-
* Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
|
|
369
|
-
* handlers already registered, and patches `setRequestHandler` so matching
|
|
370
|
-
* handlers registered later are patched too. The latter is what makes adapters
|
|
371
|
-
* that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
|
|
372
|
-
* bare server to instrument() and only then registers its handlers.
|
|
373
|
-
*/
|
|
357
|
+
/** Pre-patch handlers, kept so `isToolAdvertised` can query the raw listing. */
|
|
374
358
|
const originalRequestHandlers = new WeakMap<MCPServerLike, Map<string, MCPRequestHandler>>()
|
|
375
359
|
|
|
376
360
|
function rememberOriginalRequestHandler(
|
|
@@ -386,8 +370,45 @@ function rememberOriginalRequestHandler(
|
|
|
386
370
|
handlers.set(handlerName, originalHandler)
|
|
387
371
|
}
|
|
388
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Registers a synthetic fallback handler for `handlerName`, already wrapped in
|
|
375
|
+
* `patch`, by writing straight into `_requestHandlers` instead of going through
|
|
376
|
+
* `setRequestHandler`.
|
|
377
|
+
*
|
|
378
|
+
* Bypassing the SDK setter is deliberate, on three counts:
|
|
379
|
+
*
|
|
380
|
+
* - **Capability assertion.** `setRequestHandler` refuses a method the server
|
|
381
|
+
* never declared a capability for, so instrumenting a low-level server built
|
|
382
|
+
* without `capabilities.tools` used to throw `Server does not support tools`
|
|
383
|
+
* and leave instrumentation half-applied. Our fallback is not a capability the
|
|
384
|
+
* server offers — it exists only so a call for a tool nobody claims is still
|
|
385
|
+
* captured — so the assertion has nothing to protect here.
|
|
386
|
+
* - **Schema validation.** The setter also wraps the handler in request/result
|
|
387
|
+
* parsing, which a handler that can only ever throw `Unknown tool` never needs.
|
|
388
|
+
* - **Portability.** The setter's first argument is a Zod schema on SDK v1 and a
|
|
389
|
+
* method string on v2; the map key is the same string on both, so this is the
|
|
390
|
+
* one registration form that does not need to know which major it is talking
|
|
391
|
+
* to — and it drops the last runtime `@modelcontextprotocol/sdk` import from
|
|
392
|
+
* the shipped bundle.
|
|
393
|
+
*/
|
|
394
|
+
export function registerFallbackRequestHandler(
|
|
395
|
+
server: MCPServerLike,
|
|
396
|
+
handlerName: string,
|
|
397
|
+
fallbackHandler: MCPRequestHandler,
|
|
398
|
+
patch: HandlerPatch
|
|
399
|
+
): void {
|
|
400
|
+
rememberOriginalRequestHandler(server, handlerName, fallbackHandler)
|
|
401
|
+
server._requestHandlers.set(handlerName, (request, extra) => patch(server, fallbackHandler, request, extra))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Applies the `patches` (keyed by method, e.g. `initialize`, `tools/list`) to the
|
|
406
|
+
* handlers already registered, and patches `setRequestHandler` so matching
|
|
407
|
+
* handlers registered later are patched too. The latter is what makes adapters
|
|
408
|
+
* that register handlers post-construction work — e.g. `@rekog/mcp-nest` hands a
|
|
409
|
+
* bare server to instrument() and only then registers its handlers.
|
|
410
|
+
*/
|
|
389
411
|
export function patchRequestHandlers(server: MCPServerLike, patches: Record<string, HandlerPatch>): void {
|
|
390
|
-
// Monkey patch existing handlers.
|
|
391
412
|
for (const [handlerName, patch] of Object.entries(patches)) {
|
|
392
413
|
const originalHandler = server._requestHandlers.get(handlerName)
|
|
393
414
|
if (originalHandler) {
|
|
@@ -397,18 +418,31 @@ export function patchRequestHandlers(server: MCPServerLike, patches: Record<stri
|
|
|
397
418
|
}
|
|
398
419
|
|
|
399
420
|
// Monkey patch dynamically added handlers (registered after instrument()).
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
421
|
+
//
|
|
422
|
+
// Variadic, and every argument is forwarded verbatim. The registration form is
|
|
423
|
+
// the SDK's business, not ours — only *that* a registration happened is ours.
|
|
424
|
+
// SDK v2 has a three-argument form for custom methods,
|
|
425
|
+
// `setRequestHandler(method, { params, result }, handler)`, and a two-parameter
|
|
426
|
+
// wrapper drops the handler: the SDK then sees the schemas object where the
|
|
427
|
+
// handler should be and throws `setRequestHandler: handler is required`, which
|
|
428
|
+
// takes down the host server rather than just our instrumentation.
|
|
429
|
+
const originalSetRequestHandler = server.setRequestHandler.bind(server) as (...args: unknown[]) => unknown
|
|
430
|
+
server.setRequestHandler = ((...args: unknown[]) => {
|
|
431
|
+
const handlerName = readRequestHandlerMethod(args[0])
|
|
432
|
+
// `hasOwnProperty`, not a bare index: `handlerName` is now an arbitrary
|
|
433
|
+
// caller-supplied string, and a custom method named `toString` would
|
|
434
|
+
// otherwise resolve to an inherited function and be treated as a patch.
|
|
435
|
+
const patch =
|
|
436
|
+
handlerName !== undefined && Object.prototype.hasOwnProperty.call(patches, handlerName)
|
|
437
|
+
? patches[handlerName]
|
|
438
|
+
: undefined
|
|
439
|
+
if (handlerName === undefined || !patch) {
|
|
440
|
+
return originalSetRequestHandler(...args)
|
|
407
441
|
}
|
|
408
442
|
|
|
409
443
|
// Register first so the MCP SDK's request/result validation stays inside
|
|
410
444
|
// our analytics wrapper, matching handlers that existed before instrument().
|
|
411
|
-
const result = originalSetRequestHandler(
|
|
445
|
+
const result = originalSetRequestHandler(...args)
|
|
412
446
|
const registeredHandler = server._requestHandlers.get(handlerName)
|
|
413
447
|
if (registeredHandler) {
|
|
414
448
|
rememberOriginalRequestHandler(server, handlerName, registeredHandler)
|
|
@@ -475,6 +509,7 @@ export async function handleListToolsRequest(
|
|
|
475
509
|
timestamp: startTime,
|
|
476
510
|
}
|
|
477
511
|
stampMetaClientInfo(event, request)
|
|
512
|
+
stampTransportIdentity(event, extra)
|
|
478
513
|
|
|
479
514
|
if (data) {
|
|
480
515
|
await applyResolvedMetadata(event, data, request, extra)
|
|
@@ -639,7 +674,7 @@ export function cacheToolCategories(cache: Map<string, string>, tools: ListTools
|
|
|
639
674
|
* client name/version is lost after `initialize`. Fix: mint the
|
|
640
675
|
* `Mcp-Session-Id` response header as a token carrying both. Clients replay
|
|
641
676
|
* the header on every request, so any pod recovers them with no server-side
|
|
642
|
-
* store (decoded in `getSessionId`).
|
|
677
|
+
* store (decoded in `getSessionId`). See ADR-0003.
|
|
643
678
|
*
|
|
644
679
|
* The header only reaches the wire when response headers are built after the
|
|
645
680
|
* handler runs — StreamableHTTP with `enableJsonResponse: true`. SSE flushes
|
|
@@ -786,6 +821,7 @@ export async function handleInitializeRequest(
|
|
|
786
821
|
// negotiated protocol version below overrides any `_meta` one); picks up client
|
|
787
822
|
// info if a client also sends it in `_meta`.
|
|
788
823
|
stampMetaClientInfo(event, request)
|
|
824
|
+
stampTransportIdentity(event, extra)
|
|
789
825
|
|
|
790
826
|
await applyResolvedMetadata(event, data, request, extra)
|
|
791
827
|
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { MCPAnalyticsEventType } from './event-types'
|
|
16
16
|
import { captureEvent } from './capture'
|
|
17
17
|
import { stampMetaClientInfo } from './client-identity'
|
|
18
|
+
import { stampTransportIdentity } from './transport-identity'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Bounded LRU cache for session identities, capped at `maxSize` entries so a
|
|
@@ -153,6 +154,7 @@ export async function handleIdentify(
|
|
|
153
154
|
timestamp: new Date(),
|
|
154
155
|
}
|
|
155
156
|
stampMetaClientInfo(identifyEvent, request)
|
|
157
|
+
stampTransportIdentity(identifyEvent, extra)
|
|
156
158
|
|
|
157
159
|
try {
|
|
158
160
|
const identityResult =
|
|
@@ -166,11 +168,10 @@ export async function handleIdentify(
|
|
|
166
168
|
// was already announced by whichever pod handled `initialize`, so only the
|
|
167
169
|
// handshake, or a genuine change a long-lived server observed, publishes
|
|
168
170
|
// $identify. Every event still carries distinct_id/$set regardless, so
|
|
169
|
-
// person properties are never lost. Known gap:
|
|
170
|
-
// `initialize`
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// announced); revisit with the stateless-by-default rework.
|
|
171
|
+
// person properties are never lost. Known accepted gap (ADR-0003): an
|
|
172
|
+
// identity resolving only after `initialize` on a token session gets no
|
|
173
|
+
// standalone $identify, so its pre-identify events aren't aliased onto
|
|
174
|
+
// the user.
|
|
174
175
|
const changed = previousIdentity !== undefined && !areIdentitiesEqual(previousIdentity, mergedIdentity)
|
|
175
176
|
const firstSeen = previousIdentity === undefined
|
|
176
177
|
const announcedAtInitialize =
|
|
@@ -143,6 +143,24 @@ export function getObjectShape(schema: unknown): Record<string, unknown> | undef
|
|
|
143
143
|
return rawShape
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Reads the method name a `setRequestHandler` registration is for.
|
|
148
|
+
*
|
|
149
|
+
* The two SDK majors disagree on what the first argument is: v1 passes the Zod
|
|
150
|
+
* schema for the request and carries the method as a literal on its shape, v2
|
|
151
|
+
* passes the method string itself. A registration we cannot name is one we
|
|
152
|
+
* cannot match against a patch — which is how a handler registered after
|
|
153
|
+
* `instrument()` on v2 ends up replacing our wrapper instead of being wrapped.
|
|
154
|
+
*/
|
|
155
|
+
export function readRequestHandlerMethod(requestSchema: unknown): string | undefined {
|
|
156
|
+
if (typeof requestSchema === 'string') {
|
|
157
|
+
return requestSchema
|
|
158
|
+
}
|
|
159
|
+
const shape = getObjectShape(requestSchema)
|
|
160
|
+
const method = shape?.method ? getLiteralValue(shape.method) : undefined
|
|
161
|
+
return typeof method === 'string' ? method : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
export function getLiteralValue(schema: unknown): unknown {
|
|
147
165
|
if (!schema || typeof schema !== 'object') {
|
|
148
166
|
return
|
|
@@ -3,18 +3,16 @@ import { log } from './logger'
|
|
|
3
3
|
import type { AnalyticsInjectableJsonSchema } from './analytics-parameters'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `structuredContent` instead of `content` — which is what clients do whenever a
|
|
9
|
-
* tool declares an `outputSchema`. Measured against Claude Code, correlation for
|
|
10
|
-
* such tools drops to zero.
|
|
11
|
-
*
|
|
12
|
-
* The fix is to mirror the session handle into `structuredContent` as well, which this
|
|
13
|
-
* module does in two halves that must stay in that order:
|
|
6
|
+
* Mirrors the `conversation_id` session handle into `structuredContent`, in two
|
|
7
|
+
* halves that must stay in that order:
|
|
14
8
|
*
|
|
15
9
|
* 1. declare `_mcp_instructions` on the tool's advertised `outputSchema`
|
|
16
10
|
* 2. write it into the result's `structuredContent`
|
|
17
11
|
*
|
|
12
|
+
* Needed because clients that read `structuredContent` — which they do whenever
|
|
13
|
+
* a tool declares an `outputSchema` — never see the `content` text block that
|
|
14
|
+
* carries the handle (ADR-0004).
|
|
15
|
+
*
|
|
18
16
|
* The declaration is what makes the write safe. The MCP client ajv-validates
|
|
19
17
|
* `structuredContent` against the schema from `tools/list`, and
|
|
20
18
|
* `zod-to-json-schema` emits `additionalProperties: false` for a plain
|
|
@@ -151,6 +151,14 @@ function addCommonEventProperties(event: Event, properties: Record<string, unkno
|
|
|
151
151
|
if (event.clientVersion) {
|
|
152
152
|
properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
|
|
153
153
|
}
|
|
154
|
+
// HTTP transports only, and only for the request that carried the header —
|
|
155
|
+
// stdio and in-memory servers simply never set these.
|
|
156
|
+
if (event.clientUserAgent) {
|
|
157
|
+
properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
|
|
158
|
+
}
|
|
159
|
+
if (event.vendorClient) {
|
|
160
|
+
properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
|
|
161
|
+
}
|
|
154
162
|
// Present on every event once negotiated at `initialize` (carried via
|
|
155
163
|
// sessionInfo / the session token). Absent only before the handshake.
|
|
156
164
|
if (event.protocolVersion) {
|
|
@@ -242,6 +250,12 @@ function buildExceptionEvent(event: Event): PostHogCaptureEvent {
|
|
|
242
250
|
if (event.clientVersion) {
|
|
243
251
|
properties[PostHogMCPAnalyticsProperty.ClientVersion] = event.clientVersion
|
|
244
252
|
}
|
|
253
|
+
if (event.clientUserAgent) {
|
|
254
|
+
properties[PostHogMCPAnalyticsProperty.ClientUserAgent] = event.clientUserAgent
|
|
255
|
+
}
|
|
256
|
+
if (event.vendorClient) {
|
|
257
|
+
properties[PostHogMCPAnalyticsProperty.VendorClient] = event.vendorClient
|
|
258
|
+
}
|
|
245
259
|
if (event.protocolVersion) {
|
|
246
260
|
properties[PostHogMCPAnalyticsProperty.ProtocolVersion] = event.protocolVersion
|
|
247
261
|
}
|