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-tools-list-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/mcp': minor
---

Add a `sendToolsListEvent` option. It is on by default. Set it to `false` to stop sending `$mcp_tools_list`.
3 changes: 3 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ while the option is off.
instrument(server, posthog, { sendInitializeEvent: false })
```

Set `sendToolsListEvent: false` to stop sending `$mcp_tools_list`. Nothing else changes: tools keep
their injected arguments, and the virtual tools stay listed.

### 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: 60 additions & 0 deletions packages/mcp/src/__tests__/send-tools-list-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { CallToolResultSchema, ListToolsRequestSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { instrument } from '../index'
import type { MCPServerLike } from '../types'
import { EventCapture, fakePostHog } from './test-utils'
import { resetTodos, setupTestServerAndClient } from './test-utils/client-server-factory'

/** `sendToolsListEvent: false` sends no `$mcp_tools_list`, and nothing else changes. */
describe('sendToolsListEvent', () => {
let eventCapture: EventCapture

beforeEach(async () => {
eventCapture = new EventCapture()
await eventCapture.start()
})

afterEach(async () => {
await eventCapture.stop()
})

it.each([true, false])('sendToolsListEvent: %s controls only the listing event', async (sendToolsListEvent) => {
resetTodos()
const { server, client, cleanup } = await setupTestServerAndClient()
try {
instrument(server, fakePostHog(), { sendToolsListEvent })

const listing = await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema)
await client.request(
{ method: 'tools/call', params: { name: 'add_todo', arguments: { text: 'milk', context: 'adding' } } },
CallToolResultSchema
)
await new Promise((resolve) => setTimeout(resolve, 50))

expect(eventCapture.findCapturesByEvent('$mcp_tools_list')).toHaveLength(sendToolsListEvent ? 1 : 0)
// The listing itself is unchanged: the SDK still injects `context`.
const addTodo = listing.tools.find((tool) => tool.name === 'add_todo')
expect(addTodo?.inputSchema.properties).toHaveProperty('context')
const calls = eventCapture.findCapturesByEvent('$mcp_tool_call')
expect(calls).toHaveLength(1)
expect(calls[0].properties.$mcp_intent).toBe('adding')
} finally {
await cleanup()
}
})

it('also drops a failed listing and its $exception', async () => {
const server = new Server({ name: 'failing-listing', version: '1.0.0' }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => {
throw new Error('listing failed')
})
instrument(server, fakePostHog(), { sendToolsListEvent: false })

const handler = (server as unknown as MCPServerLike)._requestHandlers.get('tools/list')
await expect(handler?.({ method: 'tools/list', params: {} }, undefined)).rejects.toThrow('listing failed')
await new Promise((resolve) => setTimeout(resolve, 50))

expect(eventCapture.findCapturesByEvent('$mcp_tools_list')).toHaveLength(0)
expect(eventCapture.findCapturesByEvent('$exception')).toHaveLength(0)
})
})
18 changes: 13 additions & 5 deletions packages/mcp/src/extensions/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,8 @@ export async function handleListToolsRequest(
extra,
event,
logger,
requestAttribution
requestAttribution,
data?.options.sendToolsListEvent !== false
)
const tools = response.tools

Expand All @@ -806,15 +807,19 @@ export async function handleListToolsRequest(
event.error = captureException('No tools were sent to MCP client.')
event.isError = true
event.duration = Date.now() - startTime.getTime()
captureEvent(server, event, data.logger, requestAttribution)
if (data.options.sendToolsListEvent !== false) {
captureEvent(server, event, data.logger, requestAttribution)
}
return response
}

event.response = toolsListEnvelope(response)
event.listedToolNames = collectListedToolNames(tools)
event.isError = false
event.duration = Date.now() - startTime.getTime()
captureEvent(server, event, data.logger, requestAttribution)
if (data.options.sendToolsListEvent !== false) {
captureEvent(server, event, data.logger, requestAttribution)
}
return response
}

Expand Down Expand Up @@ -852,7 +857,8 @@ async function getTracedToolsList(
extra: CompatibleRequestHandlerExtra | undefined,
event: McpEvent,
logger: LoggerFn,
requestAttribution: SessionInfo
requestAttribution: SessionInfo,
sendToolsListEvent: boolean
): Promise<CompatibleToolsListLike> {
try {
const data = getServerTrackingData(server)
Expand Down Expand Up @@ -957,7 +963,9 @@ async function getTracedToolsList(
event.error = captureException(error)
event.isError = true
event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0
captureEvent(server, event, logger, requestAttribution)
if (sendToolsListEvent) {
captureEvent(server, event, logger, requestAttribution)
}
throw error
}
}
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 @@ -145,6 +145,7 @@ const DEFAULT_OPTIONS = {
enableConversationId: true,
captureModel: true,
sendInitializeEvent: true,
sendToolsListEvent: true,
} satisfies Partial<MCPAnalyticsOptions>

function buildTrackingData(
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ export interface MCPAnalyticsOptions {
* Set to `false` to stop sending it. Nothing else changes.
*/
sendInitializeEvent?: boolean
/**
* Send `$mcp_tools_list` for each `tools/list`. On by default.
* Set to `false` to stop sending it. Nothing else changes: tools keep their
* injected arguments, and the virtual tools stay listed.
*/
sendToolsListEvent?: 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