@posthog/mcp 0.0.8 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +307 -2
- package/README.md +186 -93
- package/dist/extensions/capture.d.ts +13 -0
- package/dist/extensions/capture.d.ts.map +1 -0
- package/dist/extensions/capture.js +80 -0
- package/dist/extensions/capture.mjs +46 -0
- package/dist/extensions/compatibility.d.ts +23 -0
- package/dist/extensions/compatibility.d.ts.map +1 -0
- package/dist/extensions/compatibility.js +113 -0
- package/dist/extensions/compatibility.mjs +67 -0
- package/dist/extensions/constants.d.ts +39 -0
- package/dist/extensions/constants.d.ts.map +1 -0
- package/dist/extensions/constants.js +86 -0
- package/dist/extensions/constants.mjs +37 -0
- package/dist/extensions/context-parameters.d.ts +31 -0
- package/dist/extensions/context-parameters.d.ts.map +1 -0
- package/dist/extensions/context-parameters.js +93 -0
- package/dist/extensions/context-parameters.mjs +50 -0
- package/dist/extensions/conversation-id.d.ts +52 -0
- package/dist/extensions/conversation-id.d.ts.map +1 -0
- package/dist/extensions/conversation-id.js +164 -0
- package/dist/extensions/conversation-id.mjs +103 -0
- package/dist/extensions/event-types.d.ts +21 -0
- package/dist/extensions/event-types.d.ts.map +1 -0
- package/dist/extensions/event-types.js +47 -0
- package/dist/extensions/event-types.mjs +13 -0
- package/dist/extensions/exceptions.d.ts +13 -0
- package/dist/extensions/exceptions.d.ts.map +1 -0
- package/dist/extensions/exceptions.js +58 -0
- package/dist/extensions/exceptions.mjs +24 -0
- package/dist/extensions/ids.d.ts +13 -0
- package/dist/extensions/ids.d.ts.map +1 -0
- package/dist/extensions/ids.js +55 -0
- package/dist/extensions/ids.mjs +18 -0
- package/dist/extensions/instrument-highlevel.d.ts +3 -0
- package/dist/extensions/instrument-highlevel.d.ts.map +1 -0
- package/dist/extensions/instrument-highlevel.js +229 -0
- package/dist/extensions/instrument-highlevel.mjs +195 -0
- package/dist/extensions/instrument-lowlevel.d.ts +8 -0
- package/dist/extensions/instrument-lowlevel.d.ts.map +1 -0
- package/dist/extensions/instrument-lowlevel.js +85 -0
- package/dist/extensions/instrument-lowlevel.mjs +51 -0
- package/dist/extensions/instrumentation.d.ts +59 -0
- package/dist/extensions/instrumentation.d.ts.map +1 -0
- package/dist/extensions/instrumentation.js +237 -0
- package/dist/extensions/instrumentation.mjs +194 -0
- package/dist/extensions/intent.d.ts +10 -0
- package/dist/extensions/intent.d.ts.map +1 -0
- package/dist/extensions/intent.js +84 -0
- package/dist/extensions/intent.mjs +44 -0
- package/dist/extensions/internal.d.ts +32 -0
- package/dist/extensions/internal.d.ts.map +1 -0
- package/dist/extensions/internal.js +158 -0
- package/dist/extensions/internal.mjs +106 -0
- package/dist/extensions/logger.d.ts +14 -0
- package/dist/extensions/logger.d.ts.map +1 -0
- package/dist/extensions/logger.js +47 -0
- package/dist/extensions/logger.mjs +10 -0
- package/dist/extensions/mcp-payloads.d.ts +5 -0
- package/dist/extensions/mcp-payloads.d.ts.map +1 -0
- package/dist/extensions/mcp-payloads.js +91 -0
- package/dist/extensions/mcp-payloads.mjs +54 -0
- package/dist/extensions/mcp-sdk-compat.d.ts +33 -0
- package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -0
- package/dist/extensions/mcp-sdk-compat.js +112 -0
- package/dist/extensions/mcp-sdk-compat.mjs +60 -0
- package/dist/extensions/posthog-events.d.ts +14 -0
- package/dist/extensions/posthog-events.d.ts.map +1 -0
- package/dist/extensions/posthog-events.js +143 -0
- package/dist/extensions/posthog-events.mjs +109 -0
- package/dist/extensions/sanitization.d.ts +10 -0
- package/dist/extensions/sanitization.d.ts.map +1 -0
- package/dist/extensions/sanitization.js +96 -0
- package/dist/extensions/sanitization.mjs +62 -0
- package/dist/extensions/session.d.ts +20 -0
- package/dist/extensions/session.d.ts.map +1 -0
- package/dist/extensions/session.js +113 -0
- package/dist/extensions/session.mjs +67 -0
- package/dist/extensions/sink.d.ts +44 -0
- package/dist/extensions/sink.d.ts.map +1 -0
- package/dist/extensions/sink.js +102 -0
- package/dist/extensions/sink.mjs +65 -0
- package/dist/extensions/tools.d.ts +9 -0
- package/dist/extensions/tools.d.ts.map +1 -0
- package/dist/extensions/tools.js +79 -0
- package/dist/extensions/tools.mjs +39 -0
- package/dist/extensions/tracing-helpers.d.ts +29 -0
- package/dist/extensions/tracing-helpers.d.ts.map +1 -0
- package/dist/extensions/tracing-helpers.js +59 -0
- package/dist/extensions/tracing-helpers.mjs +16 -0
- package/dist/extensions/truncation.d.ts +25 -0
- package/dist/extensions/truncation.d.ts.map +1 -0
- package/dist/extensions/truncation.js +277 -0
- package/dist/extensions/truncation.mjs +228 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.mjs +83 -2928
- package/dist/types.d.ts +220 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.mjs +0 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +36 -0
- package/dist/version.mjs +2 -0
- package/package.json +55 -87
- package/src/extensions/capture.ts +68 -0
- package/src/extensions/compatibility.ts +138 -0
- package/src/extensions/constants.ts +48 -0
- package/src/extensions/context-parameters.ts +119 -0
- package/src/extensions/conversation-id.ts +177 -0
- package/src/extensions/event-types.ts +21 -0
- package/src/extensions/exceptions.ts +83 -0
- package/src/extensions/ids.ts +32 -0
- package/src/extensions/instrument-highlevel.ts +297 -0
- package/src/extensions/instrument-lowlevel.ts +83 -0
- package/src/extensions/instrumentation.ts +415 -0
- package/src/extensions/intent.ts +78 -0
- package/src/extensions/internal.ts +193 -0
- package/src/extensions/logger.ts +27 -0
- package/src/extensions/mcp-payloads.ts +98 -0
- package/src/extensions/mcp-sdk-compat.ts +160 -0
- package/src/extensions/posthog-events.ts +212 -0
- package/src/extensions/sanitization.ts +121 -0
- package/src/extensions/session.ts +117 -0
- package/src/extensions/sink.ts +130 -0
- package/src/extensions/tools.ts +50 -0
- package/src/extensions/tracing-helpers.ts +51 -0
- package/src/extensions/truncation.ts +453 -0
- package/src/index.ts +174 -0
- package/src/types.ts +244 -0
- package/src/version.ts +1 -0
- package/dist/index.cjs +0 -2936
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -264
- package/dist/index.d.mts +0 -263
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { PostHog } from 'posthog-node'
|
|
2
|
+
import { uuidv7 } from '@posthog/core'
|
|
3
|
+
|
|
4
|
+
import type { BeforeSendFn, Event, McpEvent } from '../types'
|
|
5
|
+
import { log } from './logger'
|
|
6
|
+
import { type PostHogCaptureEvent, buildPostHogCaptureEvents } from './posthog-events'
|
|
7
|
+
import { newPrefixedId } from './ids'
|
|
8
|
+
import { sanitizeEvent } from './sanitization'
|
|
9
|
+
import { truncateEvent } from './truncation'
|
|
10
|
+
|
|
11
|
+
/** Per-event toggles consulted by the sink when fanning out an event. */
|
|
12
|
+
export interface McpCaptureOptions {
|
|
13
|
+
enableExceptionAutocapture: boolean
|
|
14
|
+
/** Inspect/modify/drop hook applied to each fanned-out payload before capture. */
|
|
15
|
+
beforeSend?: BeforeSendFn
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs an MCP event through the full transform: sanitize → truncate → fan out
|
|
20
|
+
* into the `$mcp_*` / `$exception` capture payloads → `beforeSend`. Returns
|
|
21
|
+
* `null` (and logs) if a transform stage throws, so the event is dropped rather
|
|
22
|
+
* than partially sent. Individual payloads dropped by `beforeSend` are filtered
|
|
23
|
+
* out of the returned `captures`.
|
|
24
|
+
*
|
|
25
|
+
* This is the single source of truth for the pipeline — both {@link McpEventSink}
|
|
26
|
+
* and the test harness call it, so tests assert on exactly the payloads that
|
|
27
|
+
* reach `posthog.capture()`.
|
|
28
|
+
*/
|
|
29
|
+
export async function processMcpEvent(
|
|
30
|
+
event: McpEvent,
|
|
31
|
+
options: McpCaptureOptions
|
|
32
|
+
): Promise<{ event: Event; captures: PostHogCaptureEvent[] } | null> {
|
|
33
|
+
let processed: McpEvent = event
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
processed = sanitizeEvent(processed)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log(`Failed to sanitize event: ${err}`)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
processed = truncateEvent(processed)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log(`Failed to truncate event: ${err}`)
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
processed.id = processed.id || newPrefixedId('evt')
|
|
50
|
+
const fullEvent = processed as Event
|
|
51
|
+
|
|
52
|
+
const built = buildPostHogCaptureEvents(fullEvent, {
|
|
53
|
+
enableExceptionAutocapture: options.enableExceptionAutocapture,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const captures = await applyBeforeSend(built, options.beforeSend)
|
|
57
|
+
|
|
58
|
+
return { event: fullEvent, captures }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Runs each payload through `beforeSend`, keeping those it returns and dropping
|
|
63
|
+
* those it nullifies or throws on. No-op (identity) when no hook is configured.
|
|
64
|
+
*/
|
|
65
|
+
async function applyBeforeSend(
|
|
66
|
+
captures: PostHogCaptureEvent[],
|
|
67
|
+
beforeSend: BeforeSendFn | undefined
|
|
68
|
+
): Promise<PostHogCaptureEvent[]> {
|
|
69
|
+
if (!beforeSend) {
|
|
70
|
+
return captures
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kept: PostHogCaptureEvent[] = []
|
|
74
|
+
for (const capture of captures) {
|
|
75
|
+
try {
|
|
76
|
+
const result = await beforeSend(capture)
|
|
77
|
+
if (result) {
|
|
78
|
+
kept.push(result)
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log(`beforeSend threw for event ${capture.event}; dropping it: ${err}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return kept
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wraps a user-supplied `posthog-node` client. Runs every MCP event through the
|
|
89
|
+
* sanitize → truncate pipeline, fans it out into the `$mcp_*` / `$exception`
|
|
90
|
+
* events, applies `beforeSend`, and hands each to `posthog.capture()`.
|
|
91
|
+
*
|
|
92
|
+
* The SDK does not own the client lifecycle — the host application constructs
|
|
93
|
+
* the `PostHog` instance and is responsible for `shutdown()` (matching
|
|
94
|
+
* `@posthog/ai`).
|
|
95
|
+
*/
|
|
96
|
+
export class McpEventSink {
|
|
97
|
+
constructor(private readonly posthog: PostHog) {}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Push an MCP event through the pipeline (sanitize → truncate → fan out →
|
|
101
|
+
* beforeSend → capture). Errors at any stage are logged and the event is
|
|
102
|
+
* dropped, never re-thrown into tool code.
|
|
103
|
+
*/
|
|
104
|
+
async capture(event: McpEvent, options: McpCaptureOptions): Promise<void> {
|
|
105
|
+
const result = await processMcpEvent(event, options)
|
|
106
|
+
if (!result) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { event: fullEvent, captures } = result
|
|
111
|
+
try {
|
|
112
|
+
for (const captureEvent of captures) {
|
|
113
|
+
this.posthog.capture({
|
|
114
|
+
distinctId: captureEvent.distinct_id,
|
|
115
|
+
event: captureEvent.event,
|
|
116
|
+
properties: captureEvent.properties,
|
|
117
|
+
timestamp: new Date(captureEvent.timestamp),
|
|
118
|
+
uuid: uuidv7(),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
log(
|
|
122
|
+
`Captured PostHog event ${fullEvent.id} | ${fullEvent.eventType} | ${fullEvent.duration} ms | ${
|
|
123
|
+
fullEvent.identifyActorGivenId || 'anonymous'
|
|
124
|
+
}`
|
|
125
|
+
)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
log(`Failed to capture PostHog event ${fullEvent.id}: ${err}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type CallToolResult, type ListToolsResult } from '@modelcontextprotocol/sdk/types.js'
|
|
2
|
+
import { log } from './logger'
|
|
3
|
+
|
|
4
|
+
export const GET_MORE_TOOLS_NAME = 'get_more_tools' as const
|
|
5
|
+
|
|
6
|
+
type ReportMissingToolDescriptor = ListToolsResult['tools'][number]
|
|
7
|
+
|
|
8
|
+
export function getReportMissingToolDescriptor(): ReportMissingToolDescriptor {
|
|
9
|
+
return {
|
|
10
|
+
name: GET_MORE_TOOLS_NAME,
|
|
11
|
+
description:
|
|
12
|
+
'Check for additional tools whenever your task might benefit from specialized capabilities - even if existing tools could work as a fallback.',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
context: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'A description of your goal and what kind of tool would help accomplish it.',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ['context'],
|
|
22
|
+
},
|
|
23
|
+
annotations: {
|
|
24
|
+
title: 'Get More Tools',
|
|
25
|
+
// Doesn't mutate state on the MCP server
|
|
26
|
+
readOnlyHint: true,
|
|
27
|
+
// Interacts with external entities because we store this in analytics
|
|
28
|
+
openWorldHint: true,
|
|
29
|
+
// A tool like `get_more_tools` would usually NOT be idempontent, but since we are
|
|
30
|
+
// only using this to keep track of missing tools/feedback/analytics, it is actually idempontent.
|
|
31
|
+
// It's also preferable to track it as idempontent to make agents more prone to call it proactively.
|
|
32
|
+
idempotentHint: true,
|
|
33
|
+
// Never deletes any data from the MCP server
|
|
34
|
+
destructiveHint: false,
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function handleReportMissing(args: { context: string }): CallToolResult {
|
|
40
|
+
log(`Missing tool reported: ${JSON.stringify(args)}`)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: 'text' as const,
|
|
46
|
+
text: 'Unfortunately, we have shown you the full tool list. We have noted your feedback and will work to improve the tool list in the future.',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CompatibleRequestHandlerExtra, MCPAnalyticsData, MCPRequestLike, McpEvent } from '../types'
|
|
2
|
+
import { resolveEventProperties } from './internal'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helpers shared by the low-level (`instrument-lowlevel.ts`) and high-level
|
|
6
|
+
* (`instrument-highlevel.ts`) MCP server wrappers. Extracted so both paths stay in sync — any change to
|
|
7
|
+
* "what counts as an error result", "how do we resolve customer event
|
|
8
|
+
* properties", "where do we find the context argument", or "how do we measure
|
|
9
|
+
* duration" needs to land in one place.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MCP tool results signal errors by setting `isError: true` on the result
|
|
14
|
+
* object rather than throwing. Detect that consistently.
|
|
15
|
+
*/
|
|
16
|
+
export function isToolResultError(result: unknown): boolean {
|
|
17
|
+
return !!result && typeof result === 'object' && 'isError' in result && result.isError === true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolves the `eventProperties` callback (if configured) and stamps the
|
|
22
|
+
* result onto the event. No-op when the callback is absent or returns nullish.
|
|
23
|
+
*/
|
|
24
|
+
export async function applyResolvedMetadata(
|
|
25
|
+
event: McpEvent,
|
|
26
|
+
data: MCPAnalyticsData,
|
|
27
|
+
request: MCPRequestLike,
|
|
28
|
+
extra?: CompatibleRequestHandlerExtra
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const resolvedProperties = await resolveEventProperties(data, request, extra)
|
|
31
|
+
if (resolvedProperties) {
|
|
32
|
+
event.properties = resolvedProperties
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Reads the SDK-injected `context` argument off a tool-call request. Returns
|
|
38
|
+
* the trimmed string when present, otherwise `undefined`.
|
|
39
|
+
*/
|
|
40
|
+
export function getContextArgument(request: MCPRequestLike): string | undefined {
|
|
41
|
+
const context = request.params?.arguments?.context
|
|
42
|
+
return typeof context === 'string' ? context : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Wall-clock duration of a tracked operation in milliseconds, from `event.timestamp`
|
|
47
|
+
* to now. Returns `0` when timestamp is missing so callers don't have to null-check.
|
|
48
|
+
*/
|
|
49
|
+
export function getEventDuration(event: McpEvent): number {
|
|
50
|
+
return event.timestamp ? Date.now() - event.timestamp.getTime() : 0
|
|
51
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import type { ErrorProperties, Event, StackFrame, McpEvent } from '../types'
|
|
2
|
+
|
|
3
|
+
// --- Constants ---
|
|
4
|
+
export const MAX_DEPTH = 10
|
|
5
|
+
export const MAX_BREADTH = 100
|
|
6
|
+
export const MAX_STRING_LENGTH = 32_768 // 32KB
|
|
7
|
+
export const MAX_EVENT_BYTES = 102_400 // 100KB
|
|
8
|
+
|
|
9
|
+
// --- Field-level limit constants ---
|
|
10
|
+
const MAX_USER_INTENT_LENGTH = 2048
|
|
11
|
+
const MAX_ERROR_MESSAGE_LENGTH = 2048
|
|
12
|
+
const MAX_RESOURCE_NAME_LENGTH = 256
|
|
13
|
+
const MAX_METADATA_LENGTH = 256
|
|
14
|
+
const MAX_STACK_FRAMES = 50
|
|
15
|
+
const MAX_CONTENT_TEXT_LENGTH = 32_768
|
|
16
|
+
|
|
17
|
+
// --- Truncation markers ---
|
|
18
|
+
const TRUNCATION_SUFFIX = '...'
|
|
19
|
+
|
|
20
|
+
type MutableEvent = Partial<Event | McpEvent> & Record<string, unknown>
|
|
21
|
+
type MutableRecord = Record<string, unknown>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively normalizes a value, handling:
|
|
25
|
+
* - String truncation (> MAX_STRING_LENGTH)
|
|
26
|
+
* - Non-serializable values (functions, symbols, undefined, BigInt, NaN, Infinity)
|
|
27
|
+
* - Date objects -> ISO string
|
|
28
|
+
* - Circular reference detection
|
|
29
|
+
* - Depth limiting
|
|
30
|
+
* - Breadth limiting
|
|
31
|
+
*/
|
|
32
|
+
export function normalize(
|
|
33
|
+
input: unknown,
|
|
34
|
+
depth: number = MAX_DEPTH,
|
|
35
|
+
maxBreadth: number = MAX_BREADTH,
|
|
36
|
+
maxStringLength: number = MAX_STRING_LENGTH
|
|
37
|
+
): unknown {
|
|
38
|
+
const memo = new WeakSet<object>()
|
|
39
|
+
return visit(input, depth, maxBreadth, maxStringLength, memo)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function visit(
|
|
43
|
+
value: unknown,
|
|
44
|
+
remainingDepth: number,
|
|
45
|
+
maxBreadth: number,
|
|
46
|
+
maxStringLength: number,
|
|
47
|
+
memo: WeakSet<object>
|
|
48
|
+
): unknown {
|
|
49
|
+
// null
|
|
50
|
+
if (value === null) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// undefined
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return '[undefined]'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// boolean
|
|
60
|
+
if (typeof value === 'boolean') {
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// number (including NaN, Infinity)
|
|
65
|
+
if (typeof value === 'number') {
|
|
66
|
+
if (Number.isNaN(value)) {
|
|
67
|
+
return '[NaN]'
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isFinite(value)) {
|
|
70
|
+
return value > 0 ? '[Infinity]' : '[-Infinity]'
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// bigint
|
|
76
|
+
if (typeof value === 'bigint') {
|
|
77
|
+
return `[BigInt: ${value}]`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// string
|
|
81
|
+
if (typeof value === 'string') {
|
|
82
|
+
if (value.length > maxStringLength) {
|
|
83
|
+
return value.slice(0, maxStringLength) + TRUNCATION_SUFFIX
|
|
84
|
+
}
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// symbol
|
|
89
|
+
if (typeof value === 'symbol') {
|
|
90
|
+
const desc = value.description
|
|
91
|
+
return desc ? `[Symbol(${desc})]` : '[Symbol()]'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// function
|
|
95
|
+
if (typeof value === 'function') {
|
|
96
|
+
const name = value.name || '<anonymous>'
|
|
97
|
+
return `[Function: ${name}]`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Date
|
|
101
|
+
if (value instanceof Date) {
|
|
102
|
+
return Number.isNaN(value.getTime()) ? '[Invalid Date]' : value.toISOString()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Objects and arrays from here — need depth/breadth/circular checks
|
|
106
|
+
if (typeof value === 'object') {
|
|
107
|
+
// Circular reference detection
|
|
108
|
+
if (memo.has(value)) {
|
|
109
|
+
return '[Circular ~]'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Depth limit
|
|
113
|
+
if (remainingDepth <= 0) {
|
|
114
|
+
return Array.isArray(value) ? '[Array]' : '[Object]'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
memo.add(value)
|
|
118
|
+
|
|
119
|
+
let result: unknown
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
result = visitArray(value, remainingDepth - 1, maxBreadth, maxStringLength, memo)
|
|
122
|
+
} else {
|
|
123
|
+
result = visitObject(value as Record<string, unknown>, remainingDepth - 1, maxBreadth, maxStringLength, memo)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
memo.delete(value)
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fallback: coerce to string
|
|
131
|
+
return String(value)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function visitArray(
|
|
135
|
+
arr: unknown[],
|
|
136
|
+
remainingDepth: number,
|
|
137
|
+
maxBreadth: number,
|
|
138
|
+
maxStringLength: number,
|
|
139
|
+
memo: WeakSet<object>
|
|
140
|
+
): unknown[] {
|
|
141
|
+
const result: unknown[] = []
|
|
142
|
+
for (let i = 0; i < arr.length; i++) {
|
|
143
|
+
if (i >= maxBreadth) {
|
|
144
|
+
result.push('[MaxProperties ~]')
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
result.push(visit(arr[i], remainingDepth, maxBreadth, maxStringLength, memo))
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function visitObject(
|
|
153
|
+
obj: Record<string, unknown>,
|
|
154
|
+
remainingDepth: number,
|
|
155
|
+
maxBreadth: number,
|
|
156
|
+
maxStringLength: number,
|
|
157
|
+
memo: WeakSet<object>
|
|
158
|
+
): Record<string, unknown> {
|
|
159
|
+
const result: Record<string, unknown> = {}
|
|
160
|
+
const keys = Object.keys(obj)
|
|
161
|
+
let count = 0
|
|
162
|
+
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
if (count >= maxBreadth) {
|
|
165
|
+
result['...'] = '[MaxProperties ~]'
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
// Skip undefined values — matches JSON.stringify behavior (omits undefined properties)
|
|
169
|
+
if (obj[key] === undefined) {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
result[key] = visit(obj[key], remainingDepth, maxBreadth, maxStringLength, memo)
|
|
173
|
+
count++
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Field-level truncation helpers ---
|
|
180
|
+
|
|
181
|
+
function truncateString(str: string | undefined, maxLength: number): string | undefined {
|
|
182
|
+
if (str == null) {
|
|
183
|
+
return str
|
|
184
|
+
}
|
|
185
|
+
if (str.length <= maxLength) {
|
|
186
|
+
return str
|
|
187
|
+
}
|
|
188
|
+
return str.slice(0, maxLength) + TRUNCATION_SUFFIX
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function truncateStackFrames(frames: StackFrame[] | undefined): StackFrame[] | undefined {
|
|
192
|
+
if (!frames || frames.length <= MAX_STACK_FRAMES) {
|
|
193
|
+
return frames
|
|
194
|
+
}
|
|
195
|
+
const half = Math.floor(MAX_STACK_FRAMES / 2)
|
|
196
|
+
return [...frames.slice(0, half), ...frames.slice(-half)]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Bounds an `ErrorProperties` payload: truncates each exception's `value`
|
|
201
|
+
* (message) and caps its stack frames. Operates on the core `$exception_list`
|
|
202
|
+
* shape produced by `captureException`.
|
|
203
|
+
*/
|
|
204
|
+
function truncateExceptionList(error: ErrorProperties): ErrorProperties {
|
|
205
|
+
if (!Array.isArray(error.$exception_list)) {
|
|
206
|
+
return error
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
...error,
|
|
210
|
+
$exception_list: error.$exception_list.map((exception) => {
|
|
211
|
+
const next = { ...exception }
|
|
212
|
+
if (typeof next.value === 'string') {
|
|
213
|
+
next.value = truncateString(next.value, MAX_ERROR_MESSAGE_LENGTH)
|
|
214
|
+
}
|
|
215
|
+
if (next.stacktrace?.frames) {
|
|
216
|
+
next.stacktrace = { ...next.stacktrace, frames: truncateStackFrames(next.stacktrace.frames) }
|
|
217
|
+
}
|
|
218
|
+
return next
|
|
219
|
+
}),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function truncateResponseContent(response: unknown): unknown {
|
|
224
|
+
if (response == null || typeof response !== 'object') {
|
|
225
|
+
return response
|
|
226
|
+
}
|
|
227
|
+
const result: MutableRecord = { ...(response as MutableRecord) }
|
|
228
|
+
if (Array.isArray(result.content)) {
|
|
229
|
+
result.content = result.content.map((block: unknown) => {
|
|
230
|
+
if (
|
|
231
|
+
block != null &&
|
|
232
|
+
typeof block === 'object' &&
|
|
233
|
+
'type' in block &&
|
|
234
|
+
'text' in block &&
|
|
235
|
+
block?.type === 'text' &&
|
|
236
|
+
typeof block.text === 'string' &&
|
|
237
|
+
block.text.length > MAX_CONTENT_TEXT_LENGTH
|
|
238
|
+
) {
|
|
239
|
+
return {
|
|
240
|
+
...(block as MutableRecord),
|
|
241
|
+
text: block.text.slice(0, MAX_CONTENT_TEXT_LENGTH) + TRUNCATION_SUFFIX,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return block
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
return result
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Calculates the UTF-8 byte size of a JSON-serialized value.
|
|
252
|
+
*/
|
|
253
|
+
const textEncoder = new TextEncoder()
|
|
254
|
+
|
|
255
|
+
function jsonByteSize(value: unknown): number {
|
|
256
|
+
return textEncoder.encode(JSON.stringify(value)).length
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Finds and truncates the largest string values in an object to fit within a byte budget.
|
|
261
|
+
* Last-resort mechanism when depth reduction alone isn't enough.
|
|
262
|
+
* Iterates until the result fits or no further reduction is possible.
|
|
263
|
+
*/
|
|
264
|
+
function truncateLargestFields<T>(obj: T, maxBytes: number): T {
|
|
265
|
+
const result = structuredClone(obj)
|
|
266
|
+
|
|
267
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
268
|
+
const currentSize = jsonByteSize(result)
|
|
269
|
+
if (currentSize <= maxBytes) {
|
|
270
|
+
return result
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const excess = currentSize - maxBytes
|
|
274
|
+
|
|
275
|
+
// Find all string values and their sizes, sorted largest first
|
|
276
|
+
const stringPaths: Array<{ path: string[]; length: number }> = []
|
|
277
|
+
collectStringPaths(result, [], stringPaths)
|
|
278
|
+
stringPaths.sort((a, b) => b.length - a.length)
|
|
279
|
+
|
|
280
|
+
if (stringPaths.length === 0) {
|
|
281
|
+
break // no strings left to truncate
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Distribute the reduction across the largest strings
|
|
285
|
+
let remaining = excess + 200 // buffer for JSON overhead from added "..." suffixes
|
|
286
|
+
let truncated = false
|
|
287
|
+
|
|
288
|
+
for (const { path, length } of stringPaths) {
|
|
289
|
+
if (remaining <= 0) {
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
const reduction = Math.min(remaining, Math.floor(length * 0.5))
|
|
293
|
+
if (reduction < 10) {
|
|
294
|
+
continue // not worth truncating tiny strings
|
|
295
|
+
}
|
|
296
|
+
const newLength = length - reduction
|
|
297
|
+
const currentValue = getNestedValue(result, path)
|
|
298
|
+
if (typeof currentValue !== 'string') {
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
setNestedValue(result, path, currentValue.slice(0, newLength) + TRUNCATION_SUFFIX)
|
|
302
|
+
remaining -= reduction
|
|
303
|
+
truncated = true
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!truncated) {
|
|
307
|
+
break // no progress possible
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return result
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function collectStringPaths(
|
|
315
|
+
obj: unknown,
|
|
316
|
+
currentPath: string[],
|
|
317
|
+
results: Array<{ path: string[]; length: number }>
|
|
318
|
+
): void {
|
|
319
|
+
if (typeof obj === 'string' && obj.length > 100) {
|
|
320
|
+
results.push({ path: [...currentPath], length: obj.length })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (Array.isArray(obj)) {
|
|
324
|
+
for (const [i, item] of obj.entries()) {
|
|
325
|
+
collectStringPaths(item, [...currentPath, String(i)], results)
|
|
326
|
+
}
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (obj != null && typeof obj === 'object') {
|
|
330
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
331
|
+
collectStringPaths(value, [...currentPath, key], results)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getNestedValue(obj: unknown, path: string[]): unknown {
|
|
337
|
+
let current: unknown = obj
|
|
338
|
+
for (const key of path) {
|
|
339
|
+
if (current == null || typeof current !== 'object') {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
current = (current as MutableRecord)[key]
|
|
343
|
+
}
|
|
344
|
+
return current
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function setNestedValue(obj: unknown, path: string[], value: unknown): void {
|
|
348
|
+
let current: unknown = obj
|
|
349
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
350
|
+
if (current == null || typeof current !== 'object') {
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
current = (current as MutableRecord)[path[i]]
|
|
354
|
+
}
|
|
355
|
+
const finalKey = path.at(-1)
|
|
356
|
+
if (finalKey !== undefined && current != null && typeof current === 'object') {
|
|
357
|
+
;(current as MutableRecord)[finalKey] = value
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Ensures an event fits within MAX_EVENT_BYTES by progressively reducing
|
|
363
|
+
* normalization depth, then truncating largest string fields as a last resort.
|
|
364
|
+
*/
|
|
365
|
+
function truncateToSize(event: MutableEvent): MutableEvent {
|
|
366
|
+
// Check if already within budget
|
|
367
|
+
if (jsonByteSize(event) <= MAX_EVENT_BYTES) {
|
|
368
|
+
return event
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Progressive depth reduction
|
|
372
|
+
for (let depth = MAX_DEPTH - 1; depth >= 1; depth--) {
|
|
373
|
+
const reduced: MutableEvent = { ...event }
|
|
374
|
+
if (reduced.parameters != null) {
|
|
375
|
+
reduced.parameters = normalize(reduced.parameters, depth)
|
|
376
|
+
}
|
|
377
|
+
if (reduced.response != null) {
|
|
378
|
+
reduced.response = normalize(reduced.response, depth)
|
|
379
|
+
}
|
|
380
|
+
if (reduced.identifyActorData != null) {
|
|
381
|
+
reduced.identifyActorData = normalize(reduced.identifyActorData, depth) as Event['identifyActorData']
|
|
382
|
+
}
|
|
383
|
+
if (reduced.error != null) {
|
|
384
|
+
reduced.error = normalize(reduced.error, depth) as Event['error']
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (jsonByteSize(reduced) <= MAX_EVENT_BYTES) {
|
|
388
|
+
return reduced
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Last resort: truncate largest string fields
|
|
393
|
+
const minimal: MutableEvent = { ...event }
|
|
394
|
+
if (minimal.parameters != null) {
|
|
395
|
+
minimal.parameters = normalize(minimal.parameters, 1)
|
|
396
|
+
}
|
|
397
|
+
if (minimal.response != null) {
|
|
398
|
+
minimal.response = normalize(minimal.response, 1)
|
|
399
|
+
}
|
|
400
|
+
if (minimal.identifyActorData != null) {
|
|
401
|
+
minimal.identifyActorData = normalize(minimal.identifyActorData, 1) as Event['identifyActorData']
|
|
402
|
+
}
|
|
403
|
+
if (minimal.error != null) {
|
|
404
|
+
minimal.error = normalize(minimal.error, 1) as Event['error']
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return truncateLargestFields(minimal, MAX_EVENT_BYTES)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Applies layered truncation to an event:
|
|
412
|
+
* 1. Field-level string limits (userIntent, resourceName, metadata fields, error.message)
|
|
413
|
+
* 2. Error frame limiting (first 25 + last 25 if > 50)
|
|
414
|
+
* 3. Response content text limits (32KB per text block)
|
|
415
|
+
* 4. Recursive normalization on user-controlled fields
|
|
416
|
+
* 5. Size-targeted truncation (progressive depth reduction + last-resort string truncation)
|
|
417
|
+
*/
|
|
418
|
+
export function truncateEvent<T extends Event | McpEvent>(event: T): T {
|
|
419
|
+
const result: MutableEvent = { ...event }
|
|
420
|
+
|
|
421
|
+
// Layer 1: Field-level string limits
|
|
422
|
+
result.userIntent = truncateString(result.userIntent, MAX_USER_INTENT_LENGTH)
|
|
423
|
+
result.resourceName = truncateString(result.resourceName, MAX_RESOURCE_NAME_LENGTH)
|
|
424
|
+
result.serverName = truncateString(result.serverName, MAX_METADATA_LENGTH)
|
|
425
|
+
result.serverVersion = truncateString(result.serverVersion, MAX_METADATA_LENGTH)
|
|
426
|
+
result.clientName = truncateString(result.clientName, MAX_METADATA_LENGTH)
|
|
427
|
+
result.clientVersion = truncateString(result.clientVersion, MAX_METADATA_LENGTH)
|
|
428
|
+
|
|
429
|
+
// Error field limits — operate on the core `$exception_list` shape
|
|
430
|
+
if (result.error != null && typeof result.error === 'object') {
|
|
431
|
+
result.error = truncateExceptionList(result.error as ErrorProperties)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Response content text limits
|
|
435
|
+
result.response = truncateResponseContent(result.response)
|
|
436
|
+
|
|
437
|
+
// Layer 2: Recursive normalization on user-controlled fields
|
|
438
|
+
if (result.parameters != null) {
|
|
439
|
+
result.parameters = normalize(result.parameters)
|
|
440
|
+
}
|
|
441
|
+
if (result.response != null) {
|
|
442
|
+
result.response = normalize(result.response)
|
|
443
|
+
}
|
|
444
|
+
if (result.identifyActorData != null) {
|
|
445
|
+
result.identifyActorData = normalize(result.identifyActorData) as Event['identifyActorData']
|
|
446
|
+
}
|
|
447
|
+
if (result.error != null) {
|
|
448
|
+
result.error = normalize(result.error) as Event['error']
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Layer 3: Size-targeted normalization
|
|
452
|
+
return truncateToSize(result) as T
|
|
453
|
+
}
|