Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-capture-initialize-option.md
Original file line number Diff line number Diff line change
@@ -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`.
14 changes: 12 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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
Expand Down
60 changes: 59 additions & 1 deletion packages/mcp/src/__tests__/stateless-session.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
})
})
})
10 changes: 6 additions & 4 deletions packages/mcp/src/extensions/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const DEFAULT_OPTIONS = {
collectFeedback: false,
enableConversationId: true,
captureModel: true,
sendInitializeEvent: true,
} satisfies Partial<MCPAnalyticsOptions>

function buildTrackingData(
Expand Down
5 changes: 5 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading