From 47b658aaad8ab11cafc91817303703fb392f9976 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Mon, 21 Sep 2026 16:43:22 +0100 Subject: [PATCH 01/10] feat(mcp): record safe tool input field names Generated-By: PostHog Desktop Task-Id: af720d3f-e37c-4608-8fed-65d03187eb2b --- .changeset/swift-hoops-yell.md | 5 ++ packages/mcp/docs/ARCHITECTURE.md | 40 ++++++++++++ packages/mcp/src/__tests__/beforeSend.test.ts | 2 + .../mcp/src/__tests__/error-capture.test.ts | 3 +- .../src/__tests__/instrument-lowlevel.test.ts | 11 +++- .../mcp/src/__tests__/posthog-mcp.test.ts | 14 ++++- packages/mcp/src/__tests__/tool-input.test.ts | 62 +++++++++++++++++++ packages/mcp/src/extensions/constants.ts | 1 + .../src/extensions/instrument-highlevel.ts | 1 + .../mcp/src/extensions/instrumentation.ts | 13 ++++ packages/mcp/src/extensions/tool-input.ts | 41 ++++++++++++ packages/mcp/src/index.ts | 2 + packages/mcp/src/types.ts | 1 + 13 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 .changeset/swift-hoops-yell.md create mode 100644 packages/mcp/src/__tests__/tool-input.test.ts create mode 100644 packages/mcp/src/extensions/tool-input.ts diff --git a/.changeset/swift-hoops-yell.md b/.changeset/swift-hoops-yell.md new file mode 100644 index 0000000000..888f23bc71 --- /dev/null +++ b/.changeset/swift-hoops-yell.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': minor +--- + +Record safe tool input field names for automatic and custom MCP servers. diff --git a/packages/mcp/docs/ARCHITECTURE.md b/packages/mcp/docs/ARCHITECTURE.md index 575dcdf47b..9954895ff9 100644 --- a/packages/mcp/docs/ARCHITECTURE.md +++ b/packages/mcp/docs/ARCHITECTURE.md @@ -74,6 +74,46 @@ The pipeline lives in an exported `processMcpEvent()` function in `src/extension 4. **`beforeSend`** — each fully-built PostHog payload (`{ event, distinct_id, properties }`) is passed through `options.beforeSend(event)` (sync or async) right before dispatch — so it runs **once per emitted event**, including the `$exception` sibling. Returning the (possibly mutated) payload sends it; returning a nullish value drops it; a throw drops that event (and is logged). This is the seam for customer redaction or property tweaks. 5. **Dispatch** — each surviving event is handed to the user's `posthog-node` client via `posthog.capture()`. Batching, retries, and flushing are owned by that client. The host calls `posthog.shutdown()` to drain — the SDK installs no process-signal handlers and owns no client lifecycle. +### Tool input field names + +Automatic tool-call events include `$mcp_input_keys` on success and failure. +The SDK reads the original arguments before validation can remove unknown fields. +It records up to 20 top-level field names, sorted, without their values. +Only names declared by the server's input schema remain visible. +Unknown names and names longer than 64 characters become `*`. +SDK argument names (`context`, `llm_model`, and `conversation_id`) are omitted unless the application schema declares them. +Non-object arguments do not produce this property. + +High-level servers use the registered tool's schema. +Low-level servers use schemas from prior `tools/list` responses on the same server instance. +Before a listing, or when a schema cannot be inspected, names become `*`. +The helper supports top-level JSON Schema properties, Zod object schemas, and Zod raw shapes. +It does not resolve JSON Schema references or inspect fields inside unions and transforms. + +Custom dispatchers use the same helper through the existing `properties` argument: + +```ts +import { getToolInputProperties, PostHogMCP } from '@posthog/mcp' + +const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN) +await posthog.register({ $mcp_server_build: 'example-build' }) + +const properties = getToolInputProperties(rawArguments, originalTool.inputSchema) +posthog.captureToolCall({ toolName, isError: false, properties }) +``` + +Compute these properties before argument normalization, and include them in both success and error events. +Pass a schema owned by the server, never one supplied by the caller. +Custom command formats must extract the actual tool arguments and schema before calling the helper. +Alternative field names must appear in the supplied schema to remain visible. +The server can report the alternative names it actually used through the existing event `properties` argument. +The SDK does not normalize arguments or infer which alternative a server accepted. + +The helper adds no request values to the event. +Existing parameter and response capture remains unchanged. +Use `beforeSend` to remove `$mcp_input_keys` when needed (`before_send` on the underlying PostHog client). +No session store or additional network request is required. + ## 4. Session & identity ### Shared event properties diff --git a/packages/mcp/src/__tests__/beforeSend.test.ts b/packages/mcp/src/__tests__/beforeSend.test.ts index a9d853d7fd..2e1230a943 100644 --- a/packages/mcp/src/__tests__/beforeSend.test.ts +++ b/packages/mcp/src/__tests__/beforeSend.test.ts @@ -60,6 +60,7 @@ describe('beforeSend option', () => { beforeSend: (event) => { if (event.properties.$mcp_parameters) { event.properties.$mcp_parameters = '[redacted]' + delete event.properties.$mcp_input_keys } return event }, @@ -70,6 +71,7 @@ describe('beforeSend option', () => { const toolCall = capture.findCapturesByEvent('$mcp_tool_call')[0] expect(toolCall.properties.$mcp_parameters).toBe('[redacted]') + expect(toolCall.properties).not.toHaveProperty('$mcp_input_keys') }) it('drops an event when beforeSend returns null', async () => { diff --git a/packages/mcp/src/__tests__/error-capture.test.ts b/packages/mcp/src/__tests__/error-capture.test.ts index 0f278d2791..13ca1a7082 100644 --- a/packages/mcp/src/__tests__/error-capture.test.ts +++ b/packages/mcp/src/__tests__/error-capture.test.ts @@ -132,7 +132,7 @@ describe('error capture on the tool-call path', () => { ) }, 'calculate', - { op: 'modulo', a: 10, b: 3, context: 'test' }, + { op: 'modulo', a: 10, b: 3, context: 'test', private_identifier: true }, ], [ 'missing required parameter', @@ -154,6 +154,7 @@ describe('error capture on the tool-call path', () => { await new Promise((r) => setTimeout(r, 50)) const event = capture.findEventsByResourceName(toolName).find((e) => e.isError) const exception = event?.error?.$exception_list?.[0] + expect(event?.properties?.$mcp_input_keys).toEqual(toolName === 'calculate' ? ['*', 'a', 'b', 'op'] : []) expect(exception?.value).toMatch(/Invalid|required/i) expect(['McpError', 'Error', undefined]).toContain(exception?.type) }) diff --git a/packages/mcp/src/__tests__/instrument-lowlevel.test.ts b/packages/mcp/src/__tests__/instrument-lowlevel.test.ts index 6b3fc99735..1efd831f14 100644 --- a/packages/mcp/src/__tests__/instrument-lowlevel.test.ts +++ b/packages/mcp/src/__tests__/instrument-lowlevel.test.ts @@ -455,17 +455,21 @@ describe('Low-level Server tracing (e2e)', () => { await eventCapture.stop() }) - it('captures a single $mcp_tool_call for a successful call', async () => { + it.each([false, true])('captures safe input names with a prior listing: %s', async (listed) => { const { server, client, connect, cleanup } = await setupLowLevelServer() try { instrument(server, fakePostHog()) await connect() + if (listed) await client.request({ method: 'tools/list', params: {} }, ListToolsResultSchema) const result = await client.request( - { method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } }, + { + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'hi', private_identifier: true, context: 'example' } }, + }, CallToolResultSchema ) - await new Promise((r) => setTimeout(r, 50)) + await vi.waitFor(() => expect(eventCapture.findCapturesByEvent('$mcp_tool_call')).toHaveLength(1)) expect((result.content as { text: string }[])[0].text).toBe('echo: hi') @@ -473,6 +477,7 @@ describe('Low-level Server tracing (e2e)', () => { expect(toolCalls).toHaveLength(1) const props = toolCalls[0].properties expect(props.$mcp_tool_name).toBe('echo') + expect(props.$mcp_input_keys).toEqual(listed ? ['*', 'text'] : ['*', '*']) expect(props.$mcp_resource_name).toBe('echo') expect(props.$mcp_is_error).toBe(false) expect(props.$mcp_duration_ms).toEqual(expect.any(Number)) diff --git a/packages/mcp/src/__tests__/posthog-mcp.test.ts b/packages/mcp/src/__tests__/posthog-mcp.test.ts index 29a49fdbe8..8a984eaae9 100644 --- a/packages/mcp/src/__tests__/posthog-mcp.test.ts +++ b/packages/mcp/src/__tests__/posthog-mcp.test.ts @@ -1,4 +1,4 @@ -import { getMoreToolsResult, PostHogMCP } from '../index' +import { getMoreToolsResult, getToolInputProperties, PostHogMCP } from '../index' import { PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty } from '../extensions/constants' import { GET_MORE_TOOLS_NAME } from '../extensions/tools' import type { PostHogCaptureEvent } from '../extensions/posthog-events' @@ -53,7 +53,14 @@ describe('PostHogMCP', () => { distinctId: 'user-123', sessionId: 'session-abc', groups: { organization: 'org-1', project: 'proj-1' }, - properties: { $mcp_client_name: 'claude-code', custom_flag: true }, + properties: { + $mcp_client_name: 'claude-code', + custom_flag: true, + ...getToolInputProperties( + { query: 'example-value', private_identifier: true }, + { properties: { query: {} } } + ), + }, }) await tick() @@ -70,6 +77,9 @@ describe('PostHogMCP', () => { expect(p.$groups).toEqual({ organization: 'org-1', project: 'proj-1' }) expect(p.$mcp_client_name).toBe('claude-code') expect(p.custom_flag).toBe(true) + expect(p.$mcp_input_keys).toEqual(['*', 'query']) + expect(p).not.toHaveProperty('$mcp_parameters') + expect(JSON.stringify(p)).not.toContain('example-value') // A resolved identity keeps person processing on. expect(p.$process_person_profile).toBeUndefined() }) diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts new file mode 100644 index 0000000000..8356963c46 --- /dev/null +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import { z as z4 } from 'zod4' +import { getToolInputProperties } from '../index' + +describe('getToolInputProperties', () => { + it.each([ + { + type: 'object', + properties: { id: { type: 'string' }, context: { type: 'string' }, properties: { type: 'string' } }, + }, + { id: z.string(), context: z.string(), properties: z.string() }, + z.object({ id: z.string(), context: z.string(), properties: z.string() }), + z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() }), + ])('keeps declared names and masks unknown names with schema %j', (schema) => { + const input = { + id: 'private-value', + context: 'application-value', + properties: 'application-properties', + llm_model: 'example-model', + conversation_id: 'example-conversation', + private_identifier_123: true, + 'person@example.com': true, + } + expect(getToolInputProperties(input, schema)).toEqual({ + $mcp_input_keys: ['*', '*', 'context', 'id', 'properties'], + }) + expect(input.id).toBe('private-value') + }) + + it.each([undefined, null, 'invalid', ['id'], new Date()])('omits names for non-object arguments %j', (input) => { + expect(getToolInputProperties(input, { properties: { id: {} } })).toEqual({}) + }) + + it('masks names when no schema is available and does not read values', () => { + const input = Object.defineProperty({}, 'id', { + enumerable: true, + get() { + throw new Error('must not read values') + }, + }) + expect(getToolInputProperties(input)).toEqual({ $mcp_input_keys: ['*'] }) + expect(getToolInputProperties(input, { properties: { id: {} } })).toEqual({ $mcp_input_keys: ['id'] }) + }) + + it('bounds the names and drops malformed analytics without throwing', () => { + const properties = Object.fromEntries(Array.from({ length: 30 }, (_, index) => [`key${index}`, {}])) + expect(getToolInputProperties(properties, { properties }).$mcp_input_keys).toHaveLength(20) + const longName = 'x'.repeat(65) + expect(getToolInputProperties({ [longName]: 1 }, { properties: { [longName]: {} } })).toEqual({ + $mcp_input_keys: ['*'], + }) + const input = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('unavailable') + }, + } + ) + expect(getToolInputProperties(input, { properties })).toEqual({}) + }) +}) diff --git a/packages/mcp/src/extensions/constants.ts b/packages/mcp/src/extensions/constants.ts index a497c3a8cb..379e2b9221 100644 --- a/packages/mcp/src/extensions/constants.ts +++ b/packages/mcp/src/extensions/constants.ts @@ -56,6 +56,7 @@ export const PostHogMCPAnalyticsProperty = { FeedbackTool: '$mcp_feedback_tool', FeedbackType: '$mcp_feedback_type', IsError: '$mcp_is_error', + InputKeys: '$mcp_input_keys', Intent: '$mcp_intent', IntentSource: '$mcp_intent_source', ListedToolNames: '$mcp_listed_tool_names', diff --git a/packages/mcp/src/extensions/instrument-highlevel.ts b/packages/mcp/src/extensions/instrument-highlevel.ts index dc11a74b6b..7cf9071501 100644 --- a/packages/mcp/src/extensions/instrument-highlevel.ts +++ b/packages/mcp/src/extensions/instrument-highlevel.ts @@ -298,6 +298,7 @@ async function handleToolCallRequest( parameterOwnership: registeredTool ? getAnalyticsParameterOwnership(registeredTool.inputSchema, registeredTool.outputSchema) : undefined, + inputSchema: registeredTool?.inputSchema, takeCapturedError: () => { const captured = extra?.__mcp_analytics_error if (extra) { diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 5eb97dd9af..5e0f68eaad 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -48,6 +48,7 @@ import { decodeSessionId, encodeSessionId, readMcpSessionHeader, writeSessionIdT import { getFeedbackToolDescriptor, resolveCollectFeedbackOptions, SEND_FEEDBACK_TOOL_NAME } from './feedback' import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools' import { applyResolvedMetadata, isToolResultError } from './tracing-helpers' +import { getToolInputProperties } from './tool-input' /** * Single instrumentation core shared by the low-level (`Server`) and high-level @@ -70,6 +71,7 @@ interface TraceToolCallParams { execute: ToolExecutor /** Optional schema-derived ownership override for adapters with direct registry access. */ parameterOwnership?: AnalyticsParameterOwnership + inputSchema?: unknown /** * Event type to capture. Defaults to a tool call; the `get_more_tools` virtual * tool passes `mcpMissingCapability` and `send_feedback` passes @@ -120,6 +122,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function declaredProperties(schema: unknown): Record | undefined { + if (!isRecord(schema)) return undefined + const properties = schema.properties + if (isZodRawShapeCompat(schema)) return schema + return getObjectShape(schema) ?? (isRecord(properties) ? properties : undefined) +} + +/** + * Describe the original arguments without reading their values. + * Pass a server-owned JSON Schema or Zod object schema, never a schema from the caller. + * Unknown names become `*` because an argument name can contain private data. + */ +export function getToolInputProperties(input: unknown, inputSchema?: unknown): JsonRecord { + try { + if (!isRecord(input)) return {} + const prototype = Object.getPrototypeOf(input) + if (prototype !== null && prototype !== Object.prototype) return {} + const properties = declaredProperties(inputSchema) + const known = new Set(Object.keys(properties ?? {})) + const keys = Object.keys(input) + .filter((key) => known.has(key) || !ANALYTICS_KEYS.has(key)) + .map((key) => (known.has(key) && key.length <= MAX_KEY_LENGTH ? key : '*')) + .sort() + .slice(0, MAX_INPUT_KEYS) + return { [PostHogMCPAnalyticsProperty.InputKeys]: keys } + } catch { + return {} + } +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 419a67064c..2413c904cc 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -161,6 +161,7 @@ function buildTrackingData( toolAnalyticsParameterOwnership: new Map(), toolCategories: new Map(), toolDescriptions: new Map(), + toolInputSchemas: new Map(), sessionInfo: getSessionInfo(lowLevelServer, undefined), options: { ...DEFAULT_OPTIONS, @@ -230,6 +231,7 @@ export { // Host callbacks receive the SDK's `extra`/`ctx` unchanged, and the two SDK // majors carry HTTP headers in different places and shapes. This reads either. export { getRequestHeaders } from './extensions/request-headers' +export { getToolInputProperties } from './extensions/tool-input' export { PostHogMCP, type PostHogMCPOptions } from './extensions/posthog-mcp' export { getMoreToolsResult } from './extensions/tools' export { sendFeedbackResult, SEND_FEEDBACK_TOOL_NAME } from './extensions/feedback' diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 7cf2b975ef..4f6b0c6ac1 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -515,6 +515,7 @@ export interface MCPAnalyticsData { toolAnalyticsParameterOwnership: Map toolCategories: Map toolDescriptions: Map + toolInputSchemas: Map } export interface CaptureEventData { From fa9df28a4188e406edc2d0e5115a367aae18f83c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 24 Sep 2026 21:10:38 +0100 Subject: [PATCH 02/10] fix(mcp): preserve declared tool input keys --- packages/mcp/src/__tests__/tool-input.test.ts | 6 +++--- packages/mcp/src/extensions/instrumentation.ts | 7 +++++-- packages/mcp/src/extensions/tool-input.ts | 16 +++++++++------- packages/mcp/src/types.ts | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts index 8356963c46..d8d0d87f54 100644 --- a/packages/mcp/src/__tests__/tool-input.test.ts +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -22,7 +22,7 @@ describe('getToolInputProperties', () => { 'person@example.com': true, } expect(getToolInputProperties(input, schema)).toEqual({ - $mcp_input_keys: ['*', '*', 'context', 'id', 'properties'], + $mcp_input_keys: ['context', 'id', 'properties', '[redacted]'], }) expect(input.id).toBe('private-value') }) @@ -38,7 +38,7 @@ describe('getToolInputProperties', () => { throw new Error('must not read values') }, }) - expect(getToolInputProperties(input)).toEqual({ $mcp_input_keys: ['*'] }) + expect(getToolInputProperties(input)).toEqual({ $mcp_input_keys: ['[redacted]'] }) expect(getToolInputProperties(input, { properties: { id: {} } })).toEqual({ $mcp_input_keys: ['id'] }) }) @@ -47,7 +47,7 @@ describe('getToolInputProperties', () => { expect(getToolInputProperties(properties, { properties }).$mcp_input_keys).toHaveLength(20) const longName = 'x'.repeat(65) expect(getToolInputProperties({ [longName]: 1 }, { properties: { [longName]: {} } })).toEqual({ - $mcp_input_keys: ['*'], + $mcp_input_keys: ['[redacted]'], }) const input = new Proxy( {}, diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 5e0f68eaad..2fd8b9e14c 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -166,7 +166,8 @@ export async function captureToolCall(params: TraceToolCallParams): Promise() for (const tool of tools) { - if (tool?.name) data.toolInputSchemas.set(tool.name, tool.inputSchema) + if (tool?.name) sessionSchemas.set(tool.name, tool.inputSchema) } + data.toolInputSchemas.set(requestAttribution.sessionId, sessionSchemas) } if (data && isContextEnabled(data.options.context)) { tools = addContextParameterToTools(tools, getContextDescription(data.options.context), data.logger) diff --git a/packages/mcp/src/extensions/tool-input.ts b/packages/mcp/src/extensions/tool-input.ts index 9c0e138ead..6a234b664d 100644 --- a/packages/mcp/src/extensions/tool-input.ts +++ b/packages/mcp/src/extensions/tool-input.ts @@ -20,7 +20,7 @@ function declaredProperties(schema: unknown): Record | undefine /** * Describe the original arguments without reading their values. * Pass a server-owned JSON Schema or Zod object schema, never a schema from the caller. - * Unknown names become `*` because an argument name can contain private data. + * Unknown names become `[redacted]` because an argument name can contain private data. */ export function getToolInputProperties(input: unknown, inputSchema?: unknown): JsonRecord { try { @@ -29,12 +29,14 @@ export function getToolInputProperties(input: unknown, inputSchema?: unknown): J if (prototype !== null && prototype !== Object.prototype) return {} const properties = declaredProperties(inputSchema) const known = new Set(Object.keys(properties ?? {})) - const keys = Object.keys(input) - .filter((key) => known.has(key) || !ANALYTICS_KEYS.has(key)) - .map((key) => (known.has(key) && key.length <= MAX_KEY_LENGTH ? key : '*')) - .sort() - .slice(0, MAX_INPUT_KEYS) - return { [PostHogMCPAnalyticsProperty.InputKeys]: keys } + const keys = Object.keys(input).filter((key) => known.has(key) || !ANALYTICS_KEYS.has(key)) + const declared = keys.filter((key) => known.has(key) && key.length <= MAX_KEY_LENGTH).sort() + const hasRedacted = keys.some((key) => !known.has(key) || key.length > MAX_KEY_LENGTH) + const visibleKeys = declared.slice(0, MAX_INPUT_KEYS) + if (hasRedacted && visibleKeys.length < MAX_INPUT_KEYS) { + visibleKeys.push('[redacted]') + } + return { [PostHogMCPAnalyticsProperty.InputKeys]: visibleKeys } } catch { return {} } diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 4f6b0c6ac1..2950bda7b7 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -515,7 +515,7 @@ export interface MCPAnalyticsData { toolAnalyticsParameterOwnership: Map toolCategories: Map toolDescriptions: Map - toolInputSchemas: Map + toolInputSchemas: Map> } export interface CaptureEventData { From 44f471315594ccd8b1ad3fab0707c68ce19efbad Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 24 Sep 2026 21:14:43 +0100 Subject: [PATCH 03/10] fix(mcp): use event session for schema cache --- packages/mcp/src/extensions/instrumentation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index 2fd8b9e14c..adb577dbe5 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -166,7 +166,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise() + const sessionSchemas = data.toolInputSchemas.get(event.sessionId) ?? new Map() for (const tool of tools) { if (tool?.name) sessionSchemas.set(tool.name, tool.inputSchema) } - data.toolInputSchemas.set(requestAttribution.sessionId, sessionSchemas) + data.toolInputSchemas.set(event.sessionId, sessionSchemas) } if (data && isContextEnabled(data.options.context)) { tools = addContextParameterToTools(tools, getContextDescription(data.options.context), data.logger) From 64452bbc209ddba9e4671ec1f93f3d6b7a2d0aeb Mon Sep 17 00:00:00 2001 From: pauldambra Date: Thu, 24 Sep 2026 21:17:44 +0100 Subject: [PATCH 04/10] fix(mcp): handle missing event sessions --- packages/mcp/src/extensions/instrumentation.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index adb577dbe5..f904378ead 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -166,7 +166,9 @@ export async function captureToolCall(params: TraceToolCallParams): Promise() - for (const tool of tools) { - if (tool?.name) sessionSchemas.set(tool.name, tool.inputSchema) + if (event.sessionId) { + const sessionSchemas = data.toolInputSchemas.get(event.sessionId) ?? new Map() + for (const tool of tools) { + if (tool?.name) sessionSchemas.set(tool.name, tool.inputSchema) + } + data.toolInputSchemas.set(event.sessionId, sessionSchemas) } - data.toolInputSchemas.set(event.sessionId, sessionSchemas) } if (data && isContextEnabled(data.options.context)) { tools = addContextParameterToTools(tools, getContextDescription(data.options.context), data.logger) From b562d2ea1966994ce7a3a0f3703f46588ce34985 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 25 Sep 2026 01:04:53 +0100 Subject: [PATCH 05/10] fix(mcp): bound tool schema session cache --- .../mcp/src/__tests__/error-capture.test.ts | 2 +- .../mcp/src/__tests__/identity-cache.test.ts | 16 ++++++++- .../src/__tests__/instrument-lowlevel.test.ts | 2 +- .../mcp/src/__tests__/posthog-mcp.test.ts | 2 +- .../mcp/src/extensions/instrumentation.ts | 5 ++- packages/mcp/src/extensions/internal.ts | 35 ++++++++++--------- packages/mcp/src/index.ts | 4 +-- packages/mcp/src/types.ts | 4 +-- 8 files changed, 44 insertions(+), 26 deletions(-) diff --git a/packages/mcp/src/__tests__/error-capture.test.ts b/packages/mcp/src/__tests__/error-capture.test.ts index 13ca1a7082..8b74ca1cd4 100644 --- a/packages/mcp/src/__tests__/error-capture.test.ts +++ b/packages/mcp/src/__tests__/error-capture.test.ts @@ -154,7 +154,7 @@ describe('error capture on the tool-call path', () => { await new Promise((r) => setTimeout(r, 50)) const event = capture.findEventsByResourceName(toolName).find((e) => e.isError) const exception = event?.error?.$exception_list?.[0] - expect(event?.properties?.$mcp_input_keys).toEqual(toolName === 'calculate' ? ['*', 'a', 'b', 'op'] : []) + expect(event?.properties?.$mcp_input_keys).toEqual(toolName === 'calculate' ? ['a', 'b', 'op', '[redacted]'] : []) expect(exception?.value).toMatch(/Invalid|required/i) expect(['McpError', 'Error', undefined]).toContain(exception?.type) }) diff --git a/packages/mcp/src/__tests__/identity-cache.test.ts b/packages/mcp/src/__tests__/identity-cache.test.ts index 9b6d98cccb..47dd10aef8 100644 --- a/packages/mcp/src/__tests__/identity-cache.test.ts +++ b/packages/mcp/src/__tests__/identity-cache.test.ts @@ -1,4 +1,4 @@ -import { IdentityCache } from '../extensions/internal' +import { BoundedCache, IdentityCache } from '../extensions/internal' describe('IdentityCache', () => { it('stores and retrieves identities by session id', () => { @@ -31,3 +31,17 @@ describe('IdentityCache', () => { expect(serverB.get('ses_shared')).toBeUndefined() }) }) + +describe('BoundedCache', () => { + it('evicts the least-recently-used entry', () => { + const cache = new BoundedCache(2) + cache.set('one', 1) + cache.set('two', 2) + expect(cache.get('one')).toBe(1) + cache.set('three', 3) + + expect(cache.get('two')).toBeUndefined() + expect(cache.get('one')).toBe(1) + expect(cache.get('three')).toBe(3) + }) +}) diff --git a/packages/mcp/src/__tests__/instrument-lowlevel.test.ts b/packages/mcp/src/__tests__/instrument-lowlevel.test.ts index 1efd831f14..74147bb94a 100644 --- a/packages/mcp/src/__tests__/instrument-lowlevel.test.ts +++ b/packages/mcp/src/__tests__/instrument-lowlevel.test.ts @@ -477,7 +477,7 @@ describe('Low-level Server tracing (e2e)', () => { expect(toolCalls).toHaveLength(1) const props = toolCalls[0].properties expect(props.$mcp_tool_name).toBe('echo') - expect(props.$mcp_input_keys).toEqual(listed ? ['*', 'text'] : ['*', '*']) + expect(props.$mcp_input_keys).toEqual(listed ? ['text', '[redacted]'] : ['[redacted]']) expect(props.$mcp_resource_name).toBe('echo') expect(props.$mcp_is_error).toBe(false) expect(props.$mcp_duration_ms).toEqual(expect.any(Number)) diff --git a/packages/mcp/src/__tests__/posthog-mcp.test.ts b/packages/mcp/src/__tests__/posthog-mcp.test.ts index 8a984eaae9..9b76d95147 100644 --- a/packages/mcp/src/__tests__/posthog-mcp.test.ts +++ b/packages/mcp/src/__tests__/posthog-mcp.test.ts @@ -77,7 +77,7 @@ describe('PostHogMCP', () => { expect(p.$groups).toEqual({ organization: 'org-1', project: 'proj-1' }) expect(p.$mcp_client_name).toBe('claude-code') expect(p.custom_flag).toBe(true) - expect(p.$mcp_input_keys).toEqual(['*', 'query']) + expect(p.$mcp_input_keys).toEqual(['query', '[redacted]']) expect(p).not.toHaveProperty('$mcp_parameters') expect(JSON.stringify(p)).not.toContain('example-value') // A resolved identity keeps person processing on. diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index f904378ead..e13da04449 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -167,7 +167,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise() +export class BoundedCache { + private readonly _cache = new Map() private readonly _maxSize: number constructor(maxSize = 1000) { this._maxSize = maxSize } - get(sessionId: string): UserIdentity | undefined { - const identity = this._cache.get(sessionId) - if (identity === undefined) { + get(key: string): T | undefined { + const value = this._cache.get(key) + if (value === undefined) { return } // Touch: re-insert so it counts as most-recently-used. - this._cache.delete(sessionId) - this._cache.set(sessionId, identity) - return identity + this._cache.delete(key) + this._cache.set(key, value) + return value } - set(sessionId: string, identity: UserIdentity): void { - this._cache.delete(sessionId) + set(key: string, value: T): void { + this._cache.delete(key) if (this._cache.size >= this._maxSize) { const oldestKey = this._cache.keys().next().value @@ -52,11 +51,11 @@ export class IdentityCache { } } - this._cache.set(sessionId, identity) + this._cache.set(key, value) } - has(sessionId: string): boolean { - return this._cache.has(sessionId) + has(key: string): boolean { + return this._cache.has(key) } size(): number { @@ -64,6 +63,8 @@ export class IdentityCache { } } +export class IdentityCache extends BoundedCache {} + const _serverTracking = new WeakMap() export function getServerTrackingData(server: MCPServerLike): MCPAnalyticsData | undefined { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 2413c904cc..5710388453 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -7,7 +7,7 @@ import type { PostHog } from 'posthog-node' import { isCompatibleServerType, isHighLevelServer } from './extensions/compatibility' import { McpEventSink } from './extensions/sink' import { MCPAnalyticsEventType } from './extensions/event-types' -import { IdentityCache, getServerTrackingData, setServerTrackingData } from './extensions/internal' +import { BoundedCache, IdentityCache, getServerTrackingData, setServerTrackingData } from './extensions/internal' import { createLogger } from './extensions/logger' import { captureEvent } from './extensions/capture' import { applyMcpLibIdentity } from './extensions/lib-identity' @@ -161,7 +161,7 @@ function buildTrackingData( toolAnalyticsParameterOwnership: new Map(), toolCategories: new Map(), toolDescriptions: new Map(), - toolInputSchemas: new Map(), + toolInputSchemas: new BoundedCache(), sessionInfo: getSessionInfo(lowLevelServer, undefined), options: { ...DEFAULT_OPTIONS, diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 2950bda7b7..4e159890c1 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -6,7 +6,7 @@ import type { ErrorTracking } from '@posthog/core' import type { AnalyticsInjectableJsonSchema } from './extensions/analytics-parameters' import type { MCPAnalyticsEventType } from './extensions/event-types' -import type { IdentityCache } from './extensions/internal' +import type { BoundedCache, IdentityCache } from './extensions/internal' import type { PostHogCaptureEvent } from './extensions/posthog-events' import type { McpEventSink } from './extensions/sink' import type { LoggerFn } from './extensions/logger' @@ -515,7 +515,7 @@ export interface MCPAnalyticsData { toolAnalyticsParameterOwnership: Map toolCategories: Map toolDescriptions: Map - toolInputSchemas: Map> + toolInputSchemas: BoundedCache> } export interface CaptureEventData { From 6c62c4cb9dbe270e9fd619cfefdaedcd351b73e6 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 25 Sep 2026 19:25:54 +0100 Subject: [PATCH 06/10] fix(mcp): read input names through wrapped Zod schemas Unwrap refine, transform, preprocess, pipe, and optional-style wrappers (Zod v3 and v4) so declared names stay visible instead of becoming [redacted]. A Zod v4 pipe is no longer mistaken for a raw shape. The architecture doc now describes the [redacted] marker everywhere. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 8a3c5163-fb8f-423e-8008-17af786b8cae --- packages/mcp/docs/ARCHITECTURE.md | 10 +++--- packages/mcp/src/__tests__/tool-input.test.ts | 12 +++++++ packages/mcp/src/extensions/mcp-sdk-compat.ts | 31 ++++++++++++++++++- packages/mcp/src/extensions/tool-input.ts | 4 +-- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/mcp/docs/ARCHITECTURE.md b/packages/mcp/docs/ARCHITECTURE.md index 9954895ff9..45d695557e 100644 --- a/packages/mcp/docs/ARCHITECTURE.md +++ b/packages/mcp/docs/ARCHITECTURE.md @@ -80,15 +80,17 @@ Automatic tool-call events include `$mcp_input_keys` on success and failure. The SDK reads the original arguments before validation can remove unknown fields. It records up to 20 top-level field names, sorted, without their values. Only names declared by the server's input schema remain visible. -Unknown names and names longer than 64 characters become `*`. +Unknown names and names longer than 64 characters are replaced by one `[redacted]` entry, the same marker the SDK uses for other hidden data. +Declared names come first, so `[redacted]` appears only when the 20-name limit leaves space. SDK argument names (`context`, `llm_model`, and `conversation_id`) are omitted unless the application schema declares them. Non-object arguments do not produce this property. High-level servers use the registered tool's schema. Low-level servers use schemas from prior `tools/list` responses on the same server instance. -Before a listing, or when a schema cannot be inspected, names become `*`. -The helper supports top-level JSON Schema properties, Zod object schemas, and Zod raw shapes. -It does not resolve JSON Schema references or inspect fields inside unions and transforms. +Before a listing, or when a schema cannot be inspected, every name is hidden behind `[redacted]`. +The helper supports top-level JSON Schema properties, Zod raw shapes, and Zod object schemas, including objects wrapped by refinements, transforms, preprocessors, pipes, and optional, nullable, default, catch, or readonly wrappers. +A pipe reports the names of its input schema. +It does not resolve JSON Schema references or inspect fields inside unions. Custom dispatchers use the same helper through the existing `properties` argument: diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts index d8d0d87f54..9633a21625 100644 --- a/packages/mcp/src/__tests__/tool-input.test.ts +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -11,6 +11,18 @@ describe('getToolInputProperties', () => { { id: z.string(), context: z.string(), properties: z.string() }, z.object({ id: z.string(), context: z.string(), properties: z.string() }), z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() }), + z + .object({ id: z.string(), context: z.string(), properties: z.string() }) + .refine(() => true) + .transform((value) => value), + z.preprocess((value) => value, z.object({ id: z.string(), context: z.string(), properties: z.string() })), + z.object({ id: z.string(), context: z.string(), properties: z.string() }).pipe(z.any()), + z.object({ id: z.string(), context: z.string(), properties: z.string() }).optional(), + z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() }).transform((value) => value), + z4 + .object({ id: z4.string(), context: z4.string(), properties: z4.string() }) + .pipe(z4.any()) + .default({ id: '', context: '', properties: '' }), ])('keeps declared names and masks unknown names with schema %j', (schema) => { const input = { id: 'private-value', diff --git a/packages/mcp/src/extensions/mcp-sdk-compat.ts b/packages/mcp/src/extensions/mcp-sdk-compat.ts index 05e30a4866..629aaa6805 100644 --- a/packages/mcp/src/extensions/mcp-sdk-compat.ts +++ b/packages/mcp/src/extensions/mcp-sdk-compat.ts @@ -109,7 +109,36 @@ function isZodTypeLike(value: unknown): boolean { } export function isZodRawShapeCompat(schema: unknown): schema is Record { - return !!schema && typeof schema === 'object' && Object.values(schema).some(isZodTypeLike) + // A Zod v4 pipe exposes its `in` and `out` schemas as own fields, so a Zod schema is never a raw shape + return !!schema && typeof schema === 'object' && !isZodTypeLike(schema) && Object.values(schema).some(isZodTypeLike) +} + +interface ZodWrapperDef { + schema?: unknown + in?: unknown + innerType?: unknown +} + +const MAX_UNWRAP_DEPTH = 8 + +/** + * Follows Zod wrappers to the schema that parses the caller's input: v3 effects + * (refine, transform, preprocess) and pipelines, v4 pipes (which include + * transforms), and optional, nullable, default, catch, and readonly wrappers. + */ +export function unwrapInputSchema(schema: unknown): unknown { + let current = schema + for (let depth = 0; depth < MAX_UNWRAP_DEPTH && isZodTypeLike(current); depth++) { + const def = (isZ4Schema(current) ? (current as ZodV4Internal)._zod?.def : (current as ZodV3Internal)._def) as + | ZodWrapperDef + | undefined + const inner = def?.schema ?? def?.in ?? def?.innerType + if (!inner) { + break + } + current = inner + } + return current } export function getObjectShape(schema: unknown): Record | undefined { diff --git a/packages/mcp/src/extensions/tool-input.ts b/packages/mcp/src/extensions/tool-input.ts index 6a234b664d..5dd7fe82f8 100644 --- a/packages/mcp/src/extensions/tool-input.ts +++ b/packages/mcp/src/extensions/tool-input.ts @@ -1,6 +1,6 @@ import type { JsonRecord } from '../types' import { PostHogMCPAnalyticsProperty } from './constants' -import { getObjectShape, isZodRawShapeCompat } from './mcp-sdk-compat' +import { getObjectShape, isZodRawShapeCompat, unwrapInputSchema } from './mcp-sdk-compat' const MAX_INPUT_KEYS = 20 const MAX_KEY_LENGTH = 64 @@ -14,7 +14,7 @@ function declaredProperties(schema: unknown): Record | undefine if (!isRecord(schema)) return undefined const properties = schema.properties if (isZodRawShapeCompat(schema)) return schema - return getObjectShape(schema) ?? (isRecord(properties) ? properties : undefined) + return getObjectShape(unwrapInputSchema(schema)) ?? (isRecord(properties) ? properties : undefined) } /** From 22a32643c90ae198b143e4e0a20b1268d6dd3da0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 25 Sep 2026 19:45:48 +0100 Subject: [PATCH 07/10] docs(mcp): keep alias telemetry as SDK follow-up Servers should not add their own $mcp_* alias properties. The helper will take a server-owned alias map and emit $mcp_input_aliases_used in a later change. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 8a3c5163-fb8f-423e-8008-17af786b8cae --- packages/mcp/docs/ARCHITECTURE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp/docs/ARCHITECTURE.md b/packages/mcp/docs/ARCHITECTURE.md index 45d695557e..0b6376166a 100644 --- a/packages/mcp/docs/ARCHITECTURE.md +++ b/packages/mcp/docs/ARCHITECTURE.md @@ -108,7 +108,8 @@ Compute these properties before argument normalization, and include them in both Pass a schema owned by the server, never one supplied by the caller. Custom command formats must extract the actual tool arguments and schema before calling the helper. Alternative field names must appear in the supplied schema to remain visible. -The server can report the alternative names it actually used through the existing event `properties` argument. +Do not report which alternative names a call used through server-specific `$mcp_*` properties. +Alias telemetry is planned SDK follow-up work: the server will pass its own alias map to the helper, and the helper will add `$mcp_input_aliases_used` (for example `["experimentId:id"]`) without exposing unknown names. The SDK does not normalize arguments or infer which alternative a server accepted. The helper adds no request values to the event. From d74d735c88c2c082f912badceb3e4b9985e762ff Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 25 Sep 2026 20:19:56 +0100 Subject: [PATCH 08/10] feat(mcp): let servers replace the input key redaction rule Add shouldRecordInputKey(key, { declared }) as an instrument option and as the third argument of getToolInputProperties. The default still records only declared names; the SDK keeps the 64-character and 20-name limits and declared-first ordering whatever the function returns. A throw or a non-true result records [redacted]. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 8a3c5163-fb8f-423e-8008-17af786b8cae --- .changeset/swift-hoops-yell.md | 2 +- packages/mcp/docs/ARCHITECTURE.md | 15 +++++++- packages/mcp/src/__tests__/beforeSend.test.ts | 18 +++++++++ packages/mcp/src/__tests__/tool-input.test.ts | 37 +++++++++++++++++++ .../mcp/src/extensions/instrumentation.ts | 4 +- packages/mcp/src/extensions/tool-input.ts | 33 ++++++++++++++--- packages/mcp/src/index.ts | 2 + packages/mcp/src/types.ts | 21 +++++++++++ 8 files changed, 122 insertions(+), 10 deletions(-) diff --git a/.changeset/swift-hoops-yell.md b/.changeset/swift-hoops-yell.md index 888f23bc71..34b3276f1b 100644 --- a/.changeset/swift-hoops-yell.md +++ b/.changeset/swift-hoops-yell.md @@ -2,4 +2,4 @@ '@posthog/mcp': minor --- -Record safe tool input field names for automatic and custom MCP servers. +Record safe tool input field names for automatic and custom MCP servers. Unknown names are `[redacted]` by default; `shouldRecordInputKey` replaces that rule. diff --git a/packages/mcp/docs/ARCHITECTURE.md b/packages/mcp/docs/ARCHITECTURE.md index 0b6376166a..e0040984a2 100644 --- a/packages/mcp/docs/ARCHITECTURE.md +++ b/packages/mcp/docs/ARCHITECTURE.md @@ -79,9 +79,20 @@ The pipeline lives in an exported `processMcpEvent()` function in `src/extension Automatic tool-call events include `$mcp_input_keys` on success and failure. The SDK reads the original arguments before validation can remove unknown fields. It records up to 20 top-level field names, sorted, without their values. -Only names declared by the server's input schema remain visible. +By default, only names declared by the server's input schema remain visible. Unknown names and names longer than 64 characters are replaced by one `[redacted]` entry, the same marker the SDK uses for other hidden data. Declared names come first, so `[redacted]` appears only when the 20-name limit leaves space. + +The `shouldRecordInputKey(key, { declared })` option replaces the default rule, for automatic capture and as the helper's third argument. +Return `true` to record a name; any other result, or a throw, records `[redacted]`. +The 64-character limit, the 20-name limit, and declared-names-first ordering still apply. +Use it when your server can accept that a caller-chosen name reaches analytics, for example to see misspelled parameter names: + +```ts +instrument(server, posthog, { + shouldRecordInputKey: (key, { declared }) => declared || /^[A-Za-z0-9_.-]+$/.test(key), +}) +``` SDK argument names (`context`, `llm_model`, and `conversation_id`) are omitted unless the application schema declares them. Non-object arguments do not produce this property. @@ -107,7 +118,7 @@ posthog.captureToolCall({ toolName, isError: false, properties }) Compute these properties before argument normalization, and include them in both success and error events. Pass a schema owned by the server, never one supplied by the caller. Custom command formats must extract the actual tool arguments and schema before calling the helper. -Alternative field names must appear in the supplied schema to remain visible. +Alternative field names must appear in the supplied schema, or pass `shouldRecordInputKey`, to remain visible. Do not report which alternative names a call used through server-specific `$mcp_*` properties. Alias telemetry is planned SDK follow-up work: the server will pass its own alias map to the helper, and the helper will add `$mcp_input_aliases_used` (for example `["experimentId:id"]`) without exposing unknown names. The SDK does not normalize arguments or infer which alternative a server accepted. diff --git a/packages/mcp/src/__tests__/beforeSend.test.ts b/packages/mcp/src/__tests__/beforeSend.test.ts index 2e1230a943..5c7dc9d4d9 100644 --- a/packages/mcp/src/__tests__/beforeSend.test.ts +++ b/packages/mcp/src/__tests__/beforeSend.test.ts @@ -74,6 +74,24 @@ describe('beforeSend option', () => { expect(toolCall.properties).not.toHaveProperty('$mcp_input_keys') }) + it('lets shouldRecordInputKey record undeclared argument names', async () => { + instrument(server, fakePostHog(), { + shouldRecordInputKey: (key, { declared }) => declared || /^[A-Za-z_]+$/.test(key), + }) + + await client.request( + { + method: 'tools/call', + params: { name: 'add_todo', arguments: { text: 'secret-value', todoText: 'x', 'person@example.com': 1 } }, + }, + CallToolResultSchema + ) + await new Promise((r) => setTimeout(r, 50)) + + const toolCall = capture.findCapturesByEvent('$mcp_tool_call')[0] + expect(toolCall.properties.$mcp_input_keys).toEqual(['text', 'todoText', '[redacted]']) + }) + it('drops an event when beforeSend returns null', async () => { instrument(server, fakePostHog(), { beforeSend: () => null, diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts index 9633a21625..a028e1c489 100644 --- a/packages/mcp/src/__tests__/tool-input.test.ts +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -39,6 +39,43 @@ describe('getToolInputProperties', () => { expect(input.id).toBe('private-value') }) + it('lets the caller replace the default rule and keeps declared names first', () => { + const schema = { + properties: { id: {}, ...Object.fromEntries(Array.from({ length: 19 }, (_, i) => [`d${i}`, {}])) }, + } + const input = { ...schema.properties, aKey: 1, experimentId: 1, 'person@example.com': 1, ['x'.repeat(65)]: 1 } + const seen: Array<[string, boolean]> = [] + const keys = getToolInputProperties(input, schema, { + shouldRecordInputKey: (key, { declared }) => { + seen.push([key, declared]) + return /^[A-Za-z0-9_]+$/.test(key) + }, + }).$mcp_input_keys as string[] + expect(keys).toHaveLength(20) + expect(keys).toContain('id') + expect(keys).not.toContain('aKey') + expect(seen).toContainEqual(['experimentId', false]) + expect(seen).toContainEqual(['id', true]) + expect(seen.map(([key]) => key)).not.toContain('x'.repeat(65)) + + expect( + getToolInputProperties({ id: 1, experimentId: 1, other: 1 }, schema, { + shouldRecordInputKey: (key) => key !== 'id' && key !== 'other', + }) + ).toEqual({ $mcp_input_keys: ['experimentId', '[redacted]'] }) + }) + + it.each([ + () => { + throw new Error('boom') + }, + () => 'yes' as unknown as boolean, + ])('records [redacted] when shouldRecordInputKey throws or does not return true', (shouldRecordInputKey) => { + expect(getToolInputProperties({ id: 1 }, { properties: { id: {} } }, { shouldRecordInputKey })).toEqual({ + $mcp_input_keys: ['[redacted]'], + }) + }) + it.each([undefined, null, 'invalid', ['id'], new Date()])('omits names for non-object arguments %j', (input) => { expect(getToolInputProperties(input, { properties: { id: {} } })).toEqual({}) }) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index e13da04449..c3de6a5f22 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -172,7 +172,9 @@ export async function captureToolCall(params: TraceToolCallParams): Promise | undefine return getObjectShape(unwrapInputSchema(schema)) ?? (isRecord(properties) ? properties : undefined) } +const recordDeclaredOnly: ShouldRecordInputKeyFn = (_key, { declared }) => declared + +function shouldRecord(fn: ShouldRecordInputKeyFn, key: string, declared: boolean): boolean { + try { + return fn(key, { declared }) === true + } catch { + return false + } +} + /** * Describe the original arguments without reading their values. * Pass a server-owned JSON Schema or Zod object schema, never a schema from the caller. - * Unknown names become `[redacted]` because an argument name can contain private data. + * By default unknown names become `[redacted]` because an argument name can contain private data; + * `shouldRecordInputKey` replaces that rule. */ -export function getToolInputProperties(input: unknown, inputSchema?: unknown): JsonRecord { +export function getToolInputProperties(input: unknown, inputSchema?: unknown, options?: ToolInputOptions): JsonRecord { try { if (!isRecord(input)) return {} const prototype = Object.getPrototypeOf(input) @@ -30,9 +41,19 @@ export function getToolInputProperties(input: unknown, inputSchema?: unknown): J const properties = declaredProperties(inputSchema) const known = new Set(Object.keys(properties ?? {})) const keys = Object.keys(input).filter((key) => known.has(key) || !ANALYTICS_KEYS.has(key)) - const declared = keys.filter((key) => known.has(key) && key.length <= MAX_KEY_LENGTH).sort() - const hasRedacted = keys.some((key) => !known.has(key) || key.length > MAX_KEY_LENGTH) - const visibleKeys = declared.slice(0, MAX_INPUT_KEYS) + const record = options?.shouldRecordInputKey ?? recordDeclaredOnly + const declared: string[] = [] + const undeclared: string[] = [] + let hasRedacted = false + for (const key of keys) { + const isDeclared = known.has(key) + if (key.length <= MAX_KEY_LENGTH && shouldRecord(record, key, isDeclared)) { + ;(isDeclared ? declared : undeclared).push(key) + } else { + hasRedacted = true + } + } + const visibleKeys = [...declared.sort(), ...undeclared.sort()].slice(0, MAX_INPUT_KEYS) if (hasRedacted && visibleKeys.length < MAX_INPUT_KEYS) { visibleKeys.push('[redacted]') } diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 5710388453..983bf2de1e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -263,7 +263,9 @@ export type { PrepareToolCallOptions, PrepareToolListOptions, RequestHeaderBag, + ShouldRecordInputKeyFn, ToolCallCaptureData, + ToolInputOptions, ToolsListCaptureData, UserIdentity, } from './types' diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 4e159890c1..bfb18f84b4 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -186,6 +186,12 @@ export interface MCPAnalyticsOptions { * suppress specific events. A throw drops that event. */ beforeSend?: BeforeSendFn + /** + * Decide which argument names `$mcp_input_keys` records on tool-call events. + * By default only names the tool's input schema declares are recorded; every + * other name becomes one `[redacted]` entry, because a name can carry private data. + */ + shouldRecordInputKey?: ShouldRecordInputKeyFn /** * Attach extra event properties on every auto-captured event. Spread into the PostHog * event properties as-is; values must be JSON-serializable. @@ -304,6 +310,21 @@ export type RegisteredTool = { */ export type BeforeSendFn = (event: PostHogCaptureEvent) => MaybePromise +/** + * Decides whether one top-level argument name appears in `$mcp_input_keys`. + * `declared` is true when the server's input schema declares the name. + * Return `true` to record the name; any other result, or a throw, records `[redacted]`. + */ +export type ShouldRecordInputKeyFn = (key: string, details: { declared: boolean }) => boolean + +export interface ToolInputOptions { + /** + * Replace the default rule, which records only declared names. The SDK still + * drops names longer than 64 characters and records at most 20 names. + */ + shouldRecordInputKey?: ShouldRecordInputKeyFn +} + export interface Event { actorId?: string clientName?: string From 47d019228794d6b9fef4032decc1b0b85f98ac7d Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 25 Sep 2026 20:28:58 +0100 Subject: [PATCH 09/10] fix(mcp): read input names through Zod v4 preprocess A v4 z.preprocess is a pipe whose input side is the transform, so follow its output side to reach the object schema. Found while adopting the helper in the PostHog MCP server, whose parameter aliases use z.preprocess. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 8a3c5163-fb8f-423e-8008-17af786b8cae --- packages/mcp/src/__tests__/tool-input.test.ts | 5 +++++ packages/mcp/src/extensions/mcp-sdk-compat.ts | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/__tests__/tool-input.test.ts b/packages/mcp/src/__tests__/tool-input.test.ts index a028e1c489..af14b7fa2d 100644 --- a/packages/mcp/src/__tests__/tool-input.test.ts +++ b/packages/mcp/src/__tests__/tool-input.test.ts @@ -19,6 +19,11 @@ describe('getToolInputProperties', () => { z.object({ id: z.string(), context: z.string(), properties: z.string() }).pipe(z.any()), z.object({ id: z.string(), context: z.string(), properties: z.string() }).optional(), z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() }).transform((value) => value), + z4.preprocess((value) => value, z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() })), + z4.preprocess( + (value) => value, + z4.preprocess((value) => value, z4.object({ id: z4.string(), context: z4.string(), properties: z4.string() })) + ), z4 .object({ id: z4.string(), context: z4.string(), properties: z4.string() }) .pipe(z4.any()) diff --git a/packages/mcp/src/extensions/mcp-sdk-compat.ts b/packages/mcp/src/extensions/mcp-sdk-compat.ts index 629aaa6805..1c216dc542 100644 --- a/packages/mcp/src/extensions/mcp-sdk-compat.ts +++ b/packages/mcp/src/extensions/mcp-sdk-compat.ts @@ -114,25 +114,33 @@ export function isZodRawShapeCompat(schema: unknown): schema is Record Date: Mon, 28 Sep 2026 13:18:42 +0300 Subject: [PATCH 10/10] fix(mcp): scope input schemas to request sessions Generated-By: PostHog Desktop Task-Id: 0ae96fc8-046b-44f2-83b1-55b6145f49d1 --- .../__tests__/concurrent-attribution.test.ts | 43 +++++++++++++++++++ .../mcp/src/extensions/instrumentation.ts | 25 ++++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/__tests__/concurrent-attribution.test.ts b/packages/mcp/src/__tests__/concurrent-attribution.test.ts index dcd8c79942..18eeac4fc7 100644 --- a/packages/mcp/src/__tests__/concurrent-attribution.test.ts +++ b/packages/mcp/src/__tests__/concurrent-attribution.test.ts @@ -655,4 +655,47 @@ describe('concurrent request attribution', () => { $mcp_is_error: true, }) }) + + it('keeps listed input schemas scoped to the request session', async () => { + const listAStarted = deferred() + const releaseListA = deferred() + const server = createServer({}) + let listIndex = 0 + server.setRequestHandler(ListToolsRequestSchema, async () => { + listIndex += 1 + if (listIndex === 1) { + listAStarted.resolve() + await releaseListA.promise + return { + tools: [{ name: 'echo', inputSchema: { type: 'object', properties: { labelA: { type: 'string' } } } }], + } + } + return { + tools: [{ name: 'echo', inputSchema: { type: 'object', properties: { requestLabel: { type: 'string' } } } }], + } + }) + const tokenA = encodeSessionId({ sessionId: 'ses_a' }) + const tokenB = encodeSessionId({ sessionId: 'ses_b' }) + + const listA = invokeListTools(server, { + requestInfo: { headers: { 'mcp-session-id': tokenA } }, + }) + await listAStarted.promise + await invokeListTools(server, { + requestInfo: { headers: { 'mcp-session-id': tokenB } }, + }) + releaseListA.resolve() + await listA + + await invokeTool(server, 'B', { + requestInfo: { headers: { 'mcp-session-id': tokenB } }, + }) + await flushCaptures() + + const toolCall = capture.findCapturesByEvent('$mcp_tool_call')[0] + expect(toolCall.properties).toMatchObject({ + $session_id: 'ses_b', + $mcp_input_keys: ['requestLabel'], + }) + }) }) diff --git a/packages/mcp/src/extensions/instrumentation.ts b/packages/mcp/src/extensions/instrumentation.ts index c3de6a5f22..042f7fa4ca 100644 --- a/packages/mcp/src/extensions/instrumentation.ts +++ b/packages/mcp/src/extensions/instrumentation.ts @@ -43,7 +43,13 @@ import type { LoggerFn } from './logger' import { buildCapturedMcpParameters } from './mcp-payloads' import { readRequestHandlerMethod } from './mcp-sdk-compat' import { getRequestHeaders } from './request-headers' -import { getSessionId, getSessionInfo, isModernEraRequest, newSessionId } from './session' +import { + deriveSessionIdFromMCPSession, + getSessionId, + getSessionInfo, + isModernEraRequest, + newSessionId, +} from './session' import { decodeSessionId, encodeSessionId, readMcpSessionHeader, writeSessionIdToTransport } from './session-token' import { getFeedbackToolDescriptor, resolveCollectFeedbackOptions, SEND_FEEDBACK_TOOL_NAME } from './feedback' import { getReportMissingToolDescriptor, resolveMissingCapabilityToolName } from './tools' @@ -63,6 +69,13 @@ type MCPRequestHandler = (request: MCPRequestLike, extra?: CompatibleRequestHand /** Runs the underlying tool with SDK-owned analytics arguments removed. */ type ToolExecutor = (downstreamRequest: MCPRequestLike) => Promise +function resolveToolSchemaSessionId(data: MCPAnalyticsData, extra?: CompatibleRequestHandlerExtra): string { + const token = decodeSessionId(readMcpSessionHeader(getRequestHeaders(extra))) + if (token) return token.sessionId + if (extra?.sessionId) return deriveSessionIdFromMCPSession(extra.sessionId) + return data.sessionId +} + interface TraceToolCallParams { server: MCPServerLike data: MCPAnalyticsData @@ -149,6 +162,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise