Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-input-aliases-used.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/mcp': minor
---

Record which declared parameter aliases a tool call relied on as `$mcp_input_aliases_used`, from a server-owned `inputAliases` map.
18 changes: 14 additions & 4 deletions packages/mcp/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,20 @@ 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.
A server that accepts alternative field names passes its own alias map as `inputAliases`, canonical name to aliases in the order the server tries them:

```ts
const properties = getToolInputProperties(rawArguments, originalTool.inputSchema, {
inputAliases: { id: ['experimentId', 'experiment_id'] },
})
// { experimentId: 30 } → $mcp_input_keys: ['experimentId'], $mcp_input_aliases_used: ['experimentId:id']
```

Alias names count as declared, so they stay visible in `$mcp_input_keys`.
`$mcp_input_aliases_used` records `alias:canonical` for each canonical name the call did not send, using the first of its aliases that the call did send.
It is omitted when no alias was needed, and it holds at most 20 entries.
Do not report alias use through server-specific `$mcp_*` properties.
The SDK does not normalize arguments; the map only describes what the server's own normalizer does.

The helper adds no request values to the event.
Existing parameter and response capture remains unchanged.
Expand Down
46 changes: 46 additions & 0 deletions packages/mcp/src/__tests__/tool-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,52 @@ describe('getToolInputProperties', () => {
})
})

describe('inputAliases', () => {
const schema = { properties: { id: {}, key: {}, name: {} } }
const inputAliases = { id: ['experimentId', 'experiment_id'], key: ['flagKey'], name: ['title'] }

it('shows alias names and records each alias the server needed', () => {
expect(
getToolInputProperties({ experimentId: 1, experiment_id: 2, flagKey: 'k', person_email: 'x' }, schema, {
inputAliases,
})
).toEqual({
$mcp_input_keys: ['experimentId', 'experiment_id', 'flagKey', '[redacted]'],
$mcp_input_aliases_used: ['experimentId:id', 'flagKey:key'],
})
})

it('does not record an alias when the canonical name was also sent', () => {
expect(getToolInputProperties({ id: 1, experiment_id: 2 }, schema, { inputAliases })).toEqual({
$mcp_input_keys: ['experiment_id', 'id'],
})
})

it('does not read argument values', () => {
const input = Object.defineProperty({}, 'experimentId', {
enumerable: true,
get() {
throw new Error('must not read values')
},
})
expect(getToolInputProperties(input, schema, { inputAliases })).toEqual({
$mcp_input_keys: ['experimentId'],
$mcp_input_aliases_used: ['experimentId:id'],
})
})

it('ignores malformed alias maps', () => {
expect(
getToolInputProperties({ id: 1, other: 1 }, schema, {
inputAliases: { id: 'experimentId', key: [1, null] } as never,
})
).toEqual({ $mcp_input_keys: ['id', '[redacted]'] })
expect(getToolInputProperties({ id: 1 }, schema, { inputAliases: 'x' as never })).toEqual({
$mcp_input_keys: ['id'],
})
})
})

it.each([undefined, null, 'invalid', ['id'], new Date()])('omits names for non-object arguments %j', (input) => {
expect(getToolInputProperties(input, { properties: { id: {} } })).toEqual({})
})
Expand Down
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',
InputAliasesUsed: '$mcp_input_aliases_used',
InputKeys: '$mcp_input_keys',
Intent: '$mcp_intent',
IntentSource: '$mcp_intent_source',
Expand Down
34 changes: 31 additions & 3 deletions packages/mcp/src/extensions/tool-input.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JsonRecord, ShouldRecordInputKeyFn, ToolInputOptions } from '../types'
import type { InputAliasMap, JsonRecord, ShouldRecordInputKeyFn, ToolInputOptions } from '../types'
import { PostHogMCPAnalyticsProperty } from './constants'
import { getObjectShape, isZodRawShapeCompat, unwrapInputSchema } from './mcp-sdk-compat'

Expand Down Expand Up @@ -27,6 +27,30 @@ function shouldRecord(fn: ShouldRecordInputKeyFn, key: string, declared: boolean
}
}

function aliasNames(aliases: InputAliasMap | undefined): string[] {
if (!isRecord(aliases)) return []
return Object.values(aliases).flatMap((names) =>
Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string') : []
)
}

/**
* Each alias the server needed: the canonical name is absent and this is the first of its
* aliases present, the same order a normalizer that fills the canonical from its aliases uses.
*/
function describeAliasesUsed(aliases: InputAliasMap | undefined, input: Record<string, unknown>): string[] {
if (!isRecord(aliases)) return []
const used: string[] = []
for (const [canonical, names] of Object.entries(aliases)) {
if (!Array.isArray(names) || Object.prototype.hasOwnProperty.call(input, canonical)) continue
const alias = names.find((name) => typeof name === 'string' && Object.prototype.hasOwnProperty.call(input, name))
if (alias && alias.length <= MAX_KEY_LENGTH && canonical.length <= MAX_KEY_LENGTH) {
used.push(`${alias}:${canonical}`)
}
}
return used.sort().slice(0, MAX_INPUT_KEYS)
}

/**
* Describe the original arguments without reading their values.
* Pass a server-owned JSON Schema or Zod object schema, never a schema from the caller.
Expand All @@ -39,7 +63,7 @@ export function getToolInputProperties(input: unknown, inputSchema?: unknown, op
const prototype = Object.getPrototypeOf(input)
if (prototype !== null && prototype !== Object.prototype) return {}
const properties = declaredProperties(inputSchema)
const known = new Set(Object.keys(properties ?? {}))
const known = new Set([...Object.keys(properties ?? {}), ...aliasNames(options?.inputAliases)])
const keys = Object.keys(input).filter((key) => known.has(key) || !ANALYTICS_KEYS.has(key))
const record = options?.shouldRecordInputKey ?? recordDeclaredOnly
const declared: string[] = []
Expand All @@ -57,7 +81,11 @@ export function getToolInputProperties(input: unknown, inputSchema?: unknown, op
if (hasRedacted && visibleKeys.length < MAX_INPUT_KEYS) {
visibleKeys.push('[redacted]')
}
return { [PostHogMCPAnalyticsProperty.InputKeys]: visibleKeys }
const aliasesUsed = describeAliasesUsed(options?.inputAliases, input)
return {
[PostHogMCPAnalyticsProperty.InputKeys]: visibleKeys,
...(aliasesUsed.length > 0 ? { [PostHogMCPAnalyticsProperty.InputAliasesUsed]: aliasesUsed } : {}),
}
} catch {
return {}
}
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export type {
CaptureEventData,
CollectFeedbackConfig,
InitializeCaptureData,
InputAliasMap,
McpAnalytics,
McpCaptureCommon,
MCPAnalyticsContextOptions,
Expand Down
9 changes: 9 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,17 @@ export interface ToolInputOptions {
* drops names longer than 64 characters and records at most 20 names.
*/
shouldRecordInputKey?: ShouldRecordInputKeyFn
/**
* The alternative argument names the server accepts, as canonical name to aliases in the
* order the server tries them, for example `{ id: ['experimentId'] }`. Must be owned by the
* server, never taken from the caller. Alias names count as declared in `$mcp_input_keys`,
* and `$mcp_input_aliases_used` records each alias the server needed, as `alias:canonical`.
*/
inputAliases?: InputAliasMap
}

export type InputAliasMap = Readonly<Record<string, readonly string[]>>

export interface Event {
actorId?: string
clientName?: string
Expand Down
Loading