Skip to content
Closed
6 changes: 6 additions & 0 deletions .changeset/mcp-payload-personal-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@posthog/mcp': minor
'@posthog/core': minor
---

Tool arguments, tool results, error messages, exception values, and resource read URIs now have personal data redacted by default, as the intent already did. Emails (an accented local part such as `josé@example.com` included), Luhn-valid card numbers (13 to 19 digits, ungrouped or grouped the way cards are printed: 4-4-4-4, 4-4-4-4-3, 4-6-5, or 4-6-4, by single spaces or dashes), US social security numbers, IPv6 and public IPv4 addresses, and phone numbers written with an international prefix (`+`, `00`, `011`) or a parenthesized area code become `[redacted]`, in string values and object keys at any depth. In a text that parses as JSON, a card number written as a JSON number, `4111111111111111.0` and `4111111111111111e0` included, becomes the string `"[redacted]"`; negative numbers and fractions with digits after the point are kept. In any other text, a log line or a URL included, a card becomes `[redacted]`, unquoted. A JSON or JavaScript string escape (`\n`, `\"`, `\u00e9`) no longer hides an identifier or a PostHog token written right after it, an identifier written with `\uXXXX` escapes (`ada\u0040example.com`, `4111\u00201111\u00201111\u00201111`) is read as the characters they encode, and a JSON text copy of a result is redacted in place, so it keeps its formatting and stays valid JSON. Private, loopback, and link-local addresses, versions written after a letter (`v1.2.3.4`), and `415-555-0142`-style numbers are kept. Some identifiers still read as personal data and are redacted: `ddd-dd-dddd` references (read as social security numbers), a four-part version at the start of a string or after a space, `:`, or `@` (`22.11.0.3`, `node:22.11.0.3`), zero-padded ids starting `00` (`0012345678`, read as an international prefix), `+`-signed numbers and ISO 6709 coordinates (`+1500000000`, `+40.6894-074.0447/`, read as phone numbers), a URL with a user and password (redacted whole), and about 1 in 10 numeric ids of 13 to 19 digits (they pass the card checksum). Argument names these rules would change are no longer listed in `$mcp_input_keys`. If your tools return personal data on purpose, set `redactPayloadPersonalData: false` on `instrument()` or `PostHogMCP`. `beforeSend` can drop a value but cannot restore one. With the option off, the intent and feedback text keep their redaction, and credentials are redacted everywhere. A string longer than four times the captured string limit is redacted on its first part only, the part truncation can keep, so a multi-megabyte tool result costs about what a captured one does. A URL that runs past that part, such as a `data:` image in an HTML page or an address whose user or token sits after it, is now replaced whole by `[redacted]` and nothing after it is captured, with the option on or off. It was read in full before. Object keys in arguments and results are cut to 256 characters, with the option on or off.
644 changes: 560 additions & 84 deletions packages/core/src/privacy.ts

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions packages/mcp/src/__tests__/e2e-sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,107 @@ describe('E2E Sanitization - real MCP tool calls', () => {
}
})
})

describe('E2E payload personal data', () => {
const accounts = '{"accounts":{"ada@example.com":{"plan":"pro"}},"card":4111111111111111,"count":3}'

type Properties = Record<string, unknown>
const text = (value: unknown) => JSON.stringify(value)
const firstText = (properties: Properties) =>
(properties.$mcp_response as { content: { text: string }[] }).content[0].text

it.each([
{
scenario: 'personal data in arguments and results is redacted by default',
options: {},
args: { email: 'ada@example.com', limit: 5 },
result: async () => ({
content: [{ type: 'text' as const, text: 'Customer ada@example.com last seen from 203.0.113.7' }],
}),
check: (call: Properties) => {
expect(text(call.$mcp_parameters)).not.toContain('ada@example.com')
expect((call.$mcp_parameters as any).request.params.arguments).toEqual({ email: '[redacted]', limit: 5 })
expect(text(call.$mcp_response)).not.toContain('ada@example.com')
expect(text(call.$mcp_response)).not.toContain('203.0.113.7')
},
},
{
scenario: 'a personal key and a card number in a result are redacted and its JSON stays valid',
options: {},
args: {},
result: async () => ({
content: [{ type: 'text' as const, text: accounts }],
structuredContent: JSON.parse(accounts),
}),
check: (call: Properties) => {
expect(text(call.$mcp_response)).not.toContain('ada@example.com')
expect(text(call.$mcp_response)).not.toContain('4111111111111111')
expect(firstText(call)).toBe('{"accounts":{"[redacted]":{"plan":"pro"}},"card":"[redacted]","count":3}')
},
},
{
scenario: 'identifiers that only look like personal data are kept in payloads',
options: {},
args: {},
result: async () => ({
content: [
{
type: 'text' as const,
text: 'Order 415-555-0142 built with v22.11.0.3, db at 127.0.0.1:5432, client ip:203.0.113.7, support +1 415 555 0142',
},
],
}),
check: (call: Properties) => {
expect(firstText(call)).toBe(
'Order 415-555-0142 built with v22.11.0.3, db at 127.0.0.1:5432, client ip:[redacted], support [redacted]'
)
},
},
{
scenario: 'turning the option off keeps personal data in payloads but not in intent',
options: { redactPayloadPersonalData: false },
args: { email: 'ada@example.com', context: 'look up ada@example.com' },
result: async () => ({ content: [{ type: 'text' as const, text: 'Customer ada@example.com' }] }),
check: (call: Properties) => {
expect(text(call.$mcp_parameters)).toContain('ada@example.com')
expect(text(call.$mcp_response)).toContain('ada@example.com')
expect(call.$mcp_intent).toBe('look up [redacted]')
},
},
{
scenario: 'personal data in error text and exceptions is redacted by default',
options: {},
args: {},
result: async (): Promise<never> => {
throw new Error('No account for ada@example.com')
},
check: (call: Properties, everything: unknown) => {
expect(call.$mcp_error_message).toBe('No account for [redacted]')
expect(text(everything)).not.toContain('ada@example.com')
},
},
])('$scenario', async ({ options, args, result, check }) => {
const { server, client, cleanup } = await setupTestServerAndClient()
const eventCapture = new EventCapture()
await eventCapture.start()

try {
const { instrument } = await import('../index')
await instrument(server, fakePostHog(), { enableConversationId: false, ...options })
server.registerTool(
'lookup',
{ inputSchema: { email: z.string().optional(), limit: z.number().optional() } },
result as never
)

await client.request({ method: 'tools/call', params: { name: 'lookup', arguments: args } }, CallToolResultSchema)
await new Promise((resolve) => setTimeout(resolve, 100))

const [call] = eventCapture.findCapturesByEvent('$mcp_tool_call')
check(call.properties, eventCapture.getCaptures())
} finally {
await eventCapture.stop()
await cleanup()
}
})
})
28 changes: 27 additions & 1 deletion packages/mcp/src/__tests__/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,18 +508,44 @@ describe('collectFeedback (send_feedback virtual tool)', () => {
feedback_type: 'issue',
summary: 'A tool failed.',
reporter: 'jane@example.com',
meta: { contact: 'john@example.com' },
meta: { contact: 'john@example.com', 'ada@example.com': 'vip' },
})

await new Promise((r) => setTimeout(r, 50))
const p = capture.findCapturesByEvent(PostHogMCPAnalyticsEvent.Feedback)[0].properties
expect(p.$mcp_feedback_reporter).toBe('[redacted]')
expect(p.$mcp_feedback_meta).not.toContain('john@example.com')
expect(p.$mcp_feedback_meta).not.toContain('ada@example.com')
expect(p.$mcp_feedback_meta).toContain('[redacted]')

await capture.stop()
})

it.each([
['a card', { 'card\n4111111111111111': 1 }, '4111111111111111'],
['an SSN', { 'ssn\n078-05-1120': 1 }, '078-05-1120'],
['a public IPv4', { 'ip\n203.0.113.77': 1 }, '203.0.113.77'],
['a phone', { 'tel\n+1 415 555 0177': 1 }, '555 0177'],
['an IPv6', { nested: { 'x\t2001:db8::7334': 1 } }, '2001:db8::7334'],
['a card-number integer', { n: 4111111111111111 }, '4111111111111111'],
])(
'redacts %s in a stringified extra after a JSON escape, keeping valid JSON',
async (_label, meta, identifier) => {
const capture = new EventCapture()
await capture.start()
instrument(server, fakePostHog(), { collectFeedback: { extraProperties: { meta: { type: 'object' } } } })

await callTool(client, SEND_FEEDBACK, { feedback_type: 'issue', summary: 'A tool failed.', meta })

await new Promise((r) => setTimeout(r, 50))
const captured = capture.findCapturesByEvent(PostHogMCPAnalyticsEvent.Feedback)[0].properties.$mcp_feedback_meta
expect(captured).not.toContain(identifier)
expect(() => JSON.parse(captured as string)).not.toThrow()

await capture.stop()
}
)

it('does not log the agent-supplied summary', async () => {
const logger = vi.fn()
instrument(server, fakePostHog(), { collectFeedback: true, logger })
Expand Down
Loading
Loading