From 52f806e41de4d5344a11878aad4f2a2d26f30241 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Fri, 23 Oct 2026 16:24:21 +0300 Subject: [PATCH 1/3] feat(mcp): add a captureInitialize option captureInitialize: false stops $mcp_initialize. The option is on by default. Many clients reconnect before every few tool calls, so the handshake event can outnumber $mcp_tool_call. With the option off, the initialize wrapper skips captureEvent. Nothing else changes: it still mints the session token and records the negotiated protocol version, so tool calls keep their sessions and client identity. Co-Authored-By: Claude Opus 5.5 --- .changeset/mcp-capture-initialize-option.md | 5 ++ packages/mcp/README.md | 12 +++- .../src/__tests__/stateless-session.test.ts | 60 ++++++++++++++++++- .../mcp/src/extensions/instrumentation.ts | 10 ++-- packages/mcp/src/index.ts | 1 + packages/mcp/src/types.ts | 5 ++ 6 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/mcp-capture-initialize-option.md diff --git a/.changeset/mcp-capture-initialize-option.md b/.changeset/mcp-capture-initialize-option.md new file mode 100644 index 0000000000..1e2294cd22 --- /dev/null +++ b/.changeset/mcp-capture-initialize-option.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': minor +--- + +Add a `captureInitialize` 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..63d2a46725 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,14 @@ 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 `captureInitialize: 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: tool +calls keep their sessions and client identity. + +```ts +instrument(server, posthog, { captureInitialize: 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..751f7320ca 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('captureInitialize: false', () => { + const OFF: MCPAnalyticsOptions = { captureInitialize: 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..2c522d2df9 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.captureInitialize !== 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..35ded6a8ed 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, + captureInitialize: true, } satisfies Partial function buildTrackingData( diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 2de5ee81f0..6b9dd122c4 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. + */ + captureInitialize?: 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. From 52c540c5484bfaa2cfb9fd32b1777b9821901d3f Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Wed, 28 Oct 2026 11:43:50 +0200 Subject: [PATCH 2/3] refactor(mcp): rename captureInitialize to sendInitializeEvent PostHogMCP already has a captureInitialize() method that sends the event by hand. An option with the same name next to that method makes readers link the two. The new name says what the option controls. Co-Authored-By: Claude Opus 5.5 --- .changeset/mcp-capture-initialize-option.md | 2 +- packages/mcp/README.md | 4 ++-- packages/mcp/src/__tests__/stateless-session.test.ts | 4 ++-- packages/mcp/src/extensions/instrumentation.ts | 2 +- packages/mcp/src/index.ts | 2 +- packages/mcp/src/types.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.changeset/mcp-capture-initialize-option.md b/.changeset/mcp-capture-initialize-option.md index 1e2294cd22..366ed834fd 100644 --- a/.changeset/mcp-capture-initialize-option.md +++ b/.changeset/mcp-capture-initialize-option.md @@ -2,4 +2,4 @@ '@posthog/mcp': minor --- -Add a `captureInitialize` option. It is on by default. Set it to `false` to stop sending `$mcp_initialize`. +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 63d2a46725..0a8c789f64 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -193,12 +193,12 @@ 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 `captureInitialize: false` to stop sending `$mcp_initialize`. Many clients reconnect before +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: tool calls keep their sessions and client identity. ```ts -instrument(server, posthog, { captureInitialize: false }) +instrument(server, posthog, { sendInitializeEvent: false }) ``` ### Identifying the deployed server build diff --git a/packages/mcp/src/__tests__/stateless-session.test.ts b/packages/mcp/src/__tests__/stateless-session.test.ts index 751f7320ca..a295e99ba9 100644 --- a/packages/mcp/src/__tests__/stateless-session.test.ts +++ b/packages/mcp/src/__tests__/stateless-session.test.ts @@ -331,8 +331,8 @@ describe('Stateless session minting', () => { }) }) - describe('captureInitialize: false', () => { - const OFF: MCPAnalyticsOptions = { captureInitialize: false } + 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 () => { diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 2c522d2df9..8f23abe49c 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -1198,7 +1198,7 @@ export async function handleInitializeRequest( if (mintedSessionId) { upgradeMintedTokenToNegotiated(server, mintedSessionId, requestSessionInfo, negotiatedProtocolVersion, data.logger) } - if (data.options.captureInitialize !== false) { + if (data.options.sendInitializeEvent !== false) { captureEvent(server, event, data.logger, { ...requestAttribution, protocolVersion: negotiatedProtocolVersion, diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 35ded6a8ed..03674e0ff2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -144,7 +144,7 @@ const DEFAULT_OPTIONS = { collectFeedback: false, enableConversationId: true, captureModel: true, - captureInitialize: true, + sendInitializeEvent: true, } satisfies Partial function buildTrackingData( diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 6b9dd122c4..c6887b1327 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -168,7 +168,7 @@ export interface MCPAnalyticsOptions { * Send `$mcp_initialize` for each `initialize` handshake. On by default. * Set to `false` to stop sending it. Nothing else changes. */ - captureInitialize?: boolean + 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. From 80bc985f9d1eaba42c0ede555a21428317c1f64f Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Wed, 28 Oct 2026 11:43:51 +0200 Subject: [PATCH 3/3] docs(mcp): name the PostHog screens that read $mcp_initialize MCP onboarding and the users and sessions tiles of the default MCP dashboard read $mcp_initialize. The README now says that they show no data while sendInitializeEvent is off. Co-Authored-By: Claude Opus 5.5 --- packages/mcp/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 0a8c789f64..fa38380bff 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -194,8 +194,10 @@ 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: tool -calls keep their sessions and client identity. +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 })