From dfd1f28fc4ccd69c1d32eb6b48adf04d768ce29b Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Fri, 23 Oct 2026 16:38:34 +0300 Subject: [PATCH 1/2] feat(mcp): add a sendToolsListEvent option sendToolsListEvent: false stops $mcp_tools_list. The option is on by default. Clients list tools on every reconnect, so the listing event is about as common as $mcp_tool_call. All three places that send the event go through captureToolsListEvent, which skips the capture when the option is off. That also drops a failed listing's $exception. Nothing else changes: tools keep their injected arguments, and the virtual tools stay listed. The name says "event" to match sendInitializeEvent. PostHogMCP.captureToolsList() already captures the event by hand. Co-Authored-By: Claude Opus 5.5 --- .changeset/mcp-capture-tools-list-option.md | 5 ++ packages/mcp/README.md | 3 + .../__tests__/send-tools-list-event.test.ts | 60 +++++++++++++++++++ .../mcp/src/extensions/instrumentation.ts | 14 ++++- packages/mcp/src/index.ts | 1 + packages/mcp/src/types.ts | 6 ++ 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/mcp-capture-tools-list-option.md create mode 100644 packages/mcp/src/__tests__/send-tools-list-event.test.ts diff --git a/.changeset/mcp-capture-tools-list-option.md b/.changeset/mcp-capture-tools-list-option.md new file mode 100644 index 0000000000..8cf5e014bc --- /dev/null +++ b/.changeset/mcp-capture-tools-list-option.md @@ -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`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index fa38380bff..6e22204bb5 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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 diff --git a/packages/mcp/src/__tests__/send-tools-list-event.test.ts b/packages/mcp/src/__tests__/send-tools-list-event.test.ts new file mode 100644 index 0000000000..6a904873b1 --- /dev/null +++ b/packages/mcp/src/__tests__/send-tools-list-event.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 8f23abe49c..b432b9e43c 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -806,7 +806,7 @@ 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) + captureToolsListEvent(server, event, data.logger, requestAttribution) return response } @@ -814,10 +814,18 @@ export async function handleListToolsRequest( event.listedToolNames = collectListedToolNames(tools) event.isError = false event.duration = Date.now() - startTime.getTime() - captureEvent(server, event, data.logger, requestAttribution) + captureToolsListEvent(server, event, data.logger, requestAttribution) return response } +/** Sends `$mcp_tools_list` unless the host set `sendToolsListEvent: false`. */ +function captureToolsListEvent(...args: Parameters): void { + if (getServerTrackingData(args[0])?.options.sendToolsListEvent === false) { + return + } + captureEvent(...args) +} + function cacheToolAnalyticsParameterOwnership( cache: Map, tools: CompatibleToolsListLike['tools'] @@ -957,7 +965,7 @@ 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) + captureToolsListEvent(server, event, logger, requestAttribution) throw error } } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 03674e0ff2..678dc17809 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -145,6 +145,7 @@ const DEFAULT_OPTIONS = { enableConversationId: true, captureModel: true, sendInitializeEvent: true, + sendToolsListEvent: true, } satisfies Partial function buildTrackingData( diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index c6887b1327..f620f0bfb5 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -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. From 56ad84b3dc321849cfc1925fad5494c7b7ebbdc9 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Wed, 28 Oct 2026 15:26:00 +0200 Subject: [PATCH 2/2] refactor(mcp): gate $mcp_tools_list with a plain if, like the initialize event The captureToolsListEvent wrapper read the tracking data again through its first argument. Now each of the three emit points checks sendToolsListEvent directly, as handleInitializeRequest does for sendInitializeEvent. getTracedToolsList gets the flag as an argument for its failed-listing path. The behaviour does not change. Co-Authored-By: Claude Opus 5.5 --- .../mcp/src/extensions/instrumentation.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index b432b9e43c..9df875f491 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -788,7 +788,8 @@ export async function handleListToolsRequest( extra, event, logger, - requestAttribution + requestAttribution, + data?.options.sendToolsListEvent !== false ) const tools = response.tools @@ -806,7 +807,9 @@ export async function handleListToolsRequest( event.error = captureException('No tools were sent to MCP client.') event.isError = true event.duration = Date.now() - startTime.getTime() - captureToolsListEvent(server, event, data.logger, requestAttribution) + if (data.options.sendToolsListEvent !== false) { + captureEvent(server, event, data.logger, requestAttribution) + } return response } @@ -814,16 +817,10 @@ export async function handleListToolsRequest( event.listedToolNames = collectListedToolNames(tools) event.isError = false event.duration = Date.now() - startTime.getTime() - captureToolsListEvent(server, event, data.logger, requestAttribution) - return response -} - -/** Sends `$mcp_tools_list` unless the host set `sendToolsListEvent: false`. */ -function captureToolsListEvent(...args: Parameters): void { - if (getServerTrackingData(args[0])?.options.sendToolsListEvent === false) { - return + if (data.options.sendToolsListEvent !== false) { + captureEvent(server, event, data.logger, requestAttribution) } - captureEvent(...args) + return response } function cacheToolAnalyticsParameterOwnership( @@ -860,7 +857,8 @@ async function getTracedToolsList( extra: CompatibleRequestHandlerExtra | undefined, event: McpEvent, logger: LoggerFn, - requestAttribution: SessionInfo + requestAttribution: SessionInfo, + sendToolsListEvent: boolean ): Promise { try { const data = getServerTrackingData(server) @@ -965,7 +963,9 @@ async function getTracedToolsList( event.error = captureException(error) event.isError = true event.duration = event.timestamp ? Date.now() - event.timestamp.getTime() : 0 - captureToolsListEvent(server, event, logger, requestAttribution) + if (sendToolsListEvent) { + captureEvent(server, event, logger, requestAttribution) + } throw error } }