diff --git a/.changeset/mcp-capture-initialize-option.md b/.changeset/mcp-capture-initialize-option.md new file mode 100644 index 0000000000..366ed834fd --- /dev/null +++ b/.changeset/mcp-capture-initialize-option.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': minor +--- + +Add a `sendInitializeEvent` option. It is on by default. Set it to `false` to stop sending `$mcp_initialize`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 80fa451670..fa38380bff 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -177,8 +177,8 @@ On fresh low-level instances, tools with an `outputSchema` deliver new handles t A valid echoed conversation handle takes precedence over transport sessions. A request that already carries a transport session or a PostHog session token does not mint a new handle or append a prompt-back block. Requests carrying neither use the conversation fallback. -Intent, model capture, conversation correlation, and exception capture are enabled by default. -Missing-capability reporting and feedback collection remain disabled. +Intent, model capture, conversation correlation, exception capture, and `$mcp_initialize` are +enabled by default. Missing-capability reporting and feedback collection remain disabled. ```ts instrument(server, posthog, { captureModel: false, enableConversationId: false }) @@ -193,6 +193,16 @@ to retain transport-based session grouping and unchanged response content. Custo dispatchers also enable model capture and conversation correlation by default. The reasoning behind these defaults is in [ADR-0013](./docs/adr/0013-analytics-capture-is-on-by-default.md). +Set `sendInitializeEvent: false` to stop sending `$mcp_initialize`. Many clients reconnect before +every few tool calls, so this event can outnumber `$mcp_tool_call`. Nothing else changes in what +the SDK sends: tool calls keep their sessions and client identity. In PostHog, MCP onboarding and +the users and sessions tiles of the default MCP dashboard read `$mcp_initialize`. They show no data +while the option is off. + +```ts +instrument(server, posthog, { sendInitializeEvent: false }) +``` + ### Identifying the deployed server build MCP advertises a server version, but it does not define an exact build identifier. Pass an immutable diff --git a/packages/mcp/src/__tests__/stateless-session.test.ts b/packages/mcp/src/__tests__/stateless-session.test.ts index b41c28649f..a295e99ba9 100644 --- a/packages/mcp/src/__tests__/stateless-session.test.ts +++ b/packages/mcp/src/__tests__/stateless-session.test.ts @@ -1,5 +1,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { + CallToolRequestSchema, + LATEST_PROTOCOL_VERSION, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' import { instrument } from '../index' import { getServerTrackingData } from '../extensions/internal' import { decodeSessionId } from '../extensions/session-token' @@ -326,4 +330,58 @@ describe('Stateless session minting', () => { expect(eventCapture.findCapturesByEvent('$mcp_tool_call')[0].distinct_id).toBe('user_mock') }) }) + + describe('sendInitializeEvent: false', () => { + const OFF: MCPAnalyticsOptions = { sendInitializeEvent: false } + const callTool = { method: 'tools/call', params: { name: 'get_plan', arguments: {} } } + + it('sends no $mcp_initialize but still mints the token another pod resolves', async () => { + const podA = createPod('pod-off-a', OFF) + const transportA: { sessionId?: string } = {} + setFakeTransport(podA.server, transportA) + await invokeHandler(podA.lowLevel, 'initialize', INITIALIZE_REQUEST, { requestInfo: { headers: {} } }) + + const decoded = decodeSessionId(transportA.sessionId) + expect(decoded).not.toBeNull() + expect(eventCapture.findCapturesByEvent('$mcp_initialize')).toHaveLength(0) + + const podB = createPod('pod-off-b', OFF) + await invokeHandler(podB.lowLevel, 'tools/call', callTool, { + requestInfo: { headers: { 'mcp-session-id': transportA.sessionId } }, + }) + + const toolCalls = eventCapture.findCapturesByEvent('$mcp_tool_call') + expect(toolCalls).toHaveLength(1) + expect(toolCalls[0].properties.$session_id).toBe(decoded?.sessionId) + expect(toolCalls[0].properties.$mcp_client_name).toBe('Claude') + expect(toolCalls[0].properties.$mcp_client_version).toBe('1.2.3') + expect(toolCalls[0].properties.$mcp_protocol_version).toBe('2025-06-18') + }) + + it('still re-mints the token with the negotiated version after a downgrade', async () => { + const podA = createPod('pod-off-downgrade', OFF) + const transportA: { sessionId?: string } = {} + setFakeTransport(podA.server, transportA) + await invokeHandler( + podA.lowLevel, + 'initialize', + { method: 'initialize', params: { ...INITIALIZE_REQUEST.params, protocolVersion: '1999-01-01' } }, + { requestInfo: { headers: {} } } + ) + + expect(decodeSessionId(transportA.sessionId)?.protocolVersion).toBe(LATEST_PROTOCOL_VERSION) + expect(eventCapture.findCapturesByEvent('$mcp_initialize')).toHaveLength(0) + }) + + it('still stamps the negotiated version on same-pod tool calls', async () => { + const { server, lowLevel } = createPod('pod-off-persist', OFF) + setFakeTransport(server, {}) + await invokeHandler(lowLevel, 'initialize', INITIALIZE_REQUEST, { requestInfo: { headers: {} } }) + await invokeHandler(lowLevel, 'tools/call', callTool) + + const toolCalls = eventCapture.findCapturesByEvent('$mcp_tool_call') + expect(toolCalls).toHaveLength(1) + expect(toolCalls[0].properties.$mcp_protocol_version).toBe('2025-06-18') + }) + }) }) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 1cdf19f61c..8f23abe49c 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -1198,10 +1198,12 @@ export async function handleInitializeRequest( if (mintedSessionId) { upgradeMintedTokenToNegotiated(server, mintedSessionId, requestSessionInfo, negotiatedProtocolVersion, data.logger) } - captureEvent(server, event, data.logger, { - ...requestAttribution, - protocolVersion: negotiatedProtocolVersion, - }) + if (data.options.sendInitializeEvent !== false) { + captureEvent(server, event, data.logger, { + ...requestAttribution, + protocolVersion: negotiatedProtocolVersion, + }) + } return result } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 599f2668db..03674e0ff2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -144,6 +144,7 @@ const DEFAULT_OPTIONS = { collectFeedback: false, enableConversationId: true, captureModel: true, + sendInitializeEvent: true, } satisfies Partial function buildTrackingData( diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 2de5ee81f0..c6887b1327 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -164,6 +164,11 @@ export interface MCPAnalyticsOptions { * security. Missing, blank, and `"unknown"` values are dropped. */ captureModel?: boolean | MCPAnalyticsModelOptions + /** + * Send `$mcp_initialize` for each `initialize` handshake. On by default. + * Set to `false` to stop sending it. Nothing else changes. + */ + sendInitializeEvent?: boolean /** * Identify the calling user. Returning a non-null value sets `distinct_id` and `$set` * on subsequent events for the session. Object form is treated as a static identity.