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..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() - captureEvent(server, event, data.logger, requestAttribution) + if (data.options.sendToolsListEvent !== false) { + captureEvent(server, event, data.logger, requestAttribution) + } return response } @@ -814,7 +817,9 @@ export async function handleListToolsRequest( 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 } @@ -852,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) @@ -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 } } 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.