Skip to content
5 changes: 5 additions & 0 deletions .changeset/swift-hoops-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/mcp': minor
---

Record safe tool input field names for automatic and custom MCP servers. Unknown names are `[redacted]` by default; `shouldRecordInputKey` replaces that rule.
54 changes: 54 additions & 0 deletions packages/mcp/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,60 @@ 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.
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.

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, 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:

```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, 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.

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
Expand Down
20 changes: 20 additions & 0 deletions packages/mcp/src/__tests__/beforeSend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -70,6 +71,25 @@ 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('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 () => {
Expand Down
43 changes: 43 additions & 0 deletions packages/mcp/src/__tests__/concurrent-attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
})
})
})
3 changes: 2 additions & 1 deletion packages/mcp/src/__tests__/error-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', '[redacted]'] : [])
expect(exception?.value).toMatch(/Invalid|required/i)
expect(['McpError', 'Error', undefined]).toContain(exception?.type)
})
Expand Down
16 changes: 15 additions & 1 deletion packages/mcp/src/__tests__/identity-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<number>(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)
})
})
11 changes: 8 additions & 3 deletions packages/mcp/src/__tests__/instrument-lowlevel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,24 +455,29 @@ 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')

const toolCalls = eventCapture.findCapturesByEvent('$mcp_tool_call')
expect(toolCalls).toHaveLength(1)
const props = toolCalls[0].properties
expect(props.$mcp_tool_name).toBe('echo')
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))
Expand Down
14 changes: 12 additions & 2 deletions packages/mcp/src/__tests__/posthog-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()

Expand All @@ -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', '[redacted]'])
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()
})
Expand Down
116 changes: 116 additions & 0 deletions packages/mcp/src/__tests__/tool-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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() }),
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.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())
.default({ id: '', context: '', properties: '' }),
])('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', '[redacted]'],
})
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({})
})

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: ['[redacted]'] })
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: ['[redacted]'],
})
const input = new Proxy(
{},
{
ownKeys: () => {
throw new Error('unavailable')
},
}
)
expect(getToolInputProperties(input, { properties })).toEqual({})
})
})
1 change: 1 addition & 0 deletions packages/mcp/src/extensions/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/extensions/instrument-highlevel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading