@posthog/mcp 0.0.9 → 0.1.17

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.
Files changed (138) hide show
  1. package/LICENSE +307 -3
  2. package/README.md +182 -90
  3. package/dist/extensions/capture.d.ts +13 -0
  4. package/dist/extensions/capture.d.ts.map +1 -0
  5. package/dist/extensions/capture.js +80 -0
  6. package/dist/extensions/capture.mjs +46 -0
  7. package/dist/extensions/compatibility.d.ts +23 -0
  8. package/dist/extensions/compatibility.d.ts.map +1 -0
  9. package/dist/extensions/compatibility.js +113 -0
  10. package/dist/extensions/compatibility.mjs +67 -0
  11. package/dist/extensions/constants.d.ts +39 -0
  12. package/dist/extensions/constants.d.ts.map +1 -0
  13. package/dist/extensions/constants.js +86 -0
  14. package/dist/extensions/constants.mjs +37 -0
  15. package/dist/extensions/context-parameters.d.ts +31 -0
  16. package/dist/extensions/context-parameters.d.ts.map +1 -0
  17. package/dist/extensions/context-parameters.js +93 -0
  18. package/dist/extensions/context-parameters.mjs +50 -0
  19. package/dist/extensions/conversation-id.d.ts +52 -0
  20. package/dist/extensions/conversation-id.d.ts.map +1 -0
  21. package/dist/extensions/conversation-id.js +164 -0
  22. package/dist/extensions/conversation-id.mjs +103 -0
  23. package/dist/extensions/event-types.d.ts +21 -0
  24. package/dist/extensions/event-types.d.ts.map +1 -0
  25. package/dist/extensions/event-types.js +47 -0
  26. package/dist/extensions/event-types.mjs +13 -0
  27. package/dist/extensions/exceptions.d.ts +13 -0
  28. package/dist/extensions/exceptions.d.ts.map +1 -0
  29. package/dist/extensions/exceptions.js +58 -0
  30. package/dist/extensions/exceptions.mjs +24 -0
  31. package/dist/extensions/ids.d.ts +13 -0
  32. package/dist/extensions/ids.d.ts.map +1 -0
  33. package/dist/extensions/ids.js +55 -0
  34. package/dist/extensions/ids.mjs +18 -0
  35. package/dist/extensions/instrument-highlevel.d.ts +3 -0
  36. package/dist/extensions/instrument-highlevel.d.ts.map +1 -0
  37. package/dist/extensions/instrument-highlevel.js +229 -0
  38. package/dist/extensions/instrument-highlevel.mjs +195 -0
  39. package/dist/extensions/instrument-lowlevel.d.ts +8 -0
  40. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -0
  41. package/dist/extensions/instrument-lowlevel.js +85 -0
  42. package/dist/extensions/instrument-lowlevel.mjs +51 -0
  43. package/dist/extensions/instrumentation.d.ts +59 -0
  44. package/dist/extensions/instrumentation.d.ts.map +1 -0
  45. package/dist/extensions/instrumentation.js +237 -0
  46. package/dist/extensions/instrumentation.mjs +194 -0
  47. package/dist/extensions/intent.d.ts +10 -0
  48. package/dist/extensions/intent.d.ts.map +1 -0
  49. package/dist/extensions/intent.js +84 -0
  50. package/dist/extensions/intent.mjs +44 -0
  51. package/dist/extensions/internal.d.ts +32 -0
  52. package/dist/extensions/internal.d.ts.map +1 -0
  53. package/dist/extensions/internal.js +158 -0
  54. package/dist/extensions/internal.mjs +106 -0
  55. package/dist/extensions/logger.d.ts +14 -0
  56. package/dist/extensions/logger.d.ts.map +1 -0
  57. package/dist/extensions/logger.js +47 -0
  58. package/dist/extensions/logger.mjs +10 -0
  59. package/dist/extensions/mcp-payloads.d.ts +5 -0
  60. package/dist/extensions/mcp-payloads.d.ts.map +1 -0
  61. package/dist/extensions/mcp-payloads.js +91 -0
  62. package/dist/extensions/mcp-payloads.mjs +54 -0
  63. package/dist/extensions/mcp-sdk-compat.d.ts +33 -0
  64. package/dist/extensions/mcp-sdk-compat.d.ts.map +1 -0
  65. package/dist/extensions/mcp-sdk-compat.js +112 -0
  66. package/dist/extensions/mcp-sdk-compat.mjs +60 -0
  67. package/dist/extensions/posthog-events.d.ts +14 -0
  68. package/dist/extensions/posthog-events.d.ts.map +1 -0
  69. package/dist/extensions/posthog-events.js +143 -0
  70. package/dist/extensions/posthog-events.mjs +109 -0
  71. package/dist/extensions/sanitization.d.ts +10 -0
  72. package/dist/extensions/sanitization.d.ts.map +1 -0
  73. package/dist/extensions/sanitization.js +96 -0
  74. package/dist/extensions/sanitization.mjs +62 -0
  75. package/dist/extensions/session.d.ts +20 -0
  76. package/dist/extensions/session.d.ts.map +1 -0
  77. package/dist/extensions/session.js +113 -0
  78. package/dist/extensions/session.mjs +67 -0
  79. package/dist/extensions/sink.d.ts +44 -0
  80. package/dist/extensions/sink.d.ts.map +1 -0
  81. package/dist/extensions/sink.js +102 -0
  82. package/dist/extensions/sink.mjs +65 -0
  83. package/dist/extensions/tools.d.ts +9 -0
  84. package/dist/extensions/tools.d.ts.map +1 -0
  85. package/dist/extensions/tools.js +79 -0
  86. package/dist/extensions/tools.mjs +39 -0
  87. package/dist/extensions/tracing-helpers.d.ts +29 -0
  88. package/dist/extensions/tracing-helpers.d.ts.map +1 -0
  89. package/dist/extensions/tracing-helpers.js +59 -0
  90. package/dist/extensions/tracing-helpers.mjs +16 -0
  91. package/dist/extensions/truncation.d.ts +25 -0
  92. package/dist/extensions/truncation.d.ts.map +1 -0
  93. package/dist/extensions/truncation.js +277 -0
  94. package/dist/extensions/truncation.mjs +228 -0
  95. package/dist/index.d.ts +36 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +132 -0
  98. package/dist/index.mjs +83 -2929
  99. package/dist/types.d.ts +220 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +18 -0
  102. package/dist/types.mjs +0 -0
  103. package/dist/version.d.ts +2 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +36 -0
  106. package/dist/version.mjs +2 -0
  107. package/package.json +55 -87
  108. package/src/extensions/capture.ts +68 -0
  109. package/src/extensions/compatibility.ts +138 -0
  110. package/src/extensions/constants.ts +48 -0
  111. package/src/extensions/context-parameters.ts +119 -0
  112. package/src/extensions/conversation-id.ts +177 -0
  113. package/src/extensions/event-types.ts +21 -0
  114. package/src/extensions/exceptions.ts +83 -0
  115. package/src/extensions/ids.ts +32 -0
  116. package/src/extensions/instrument-highlevel.ts +297 -0
  117. package/src/extensions/instrument-lowlevel.ts +83 -0
  118. package/src/extensions/instrumentation.ts +415 -0
  119. package/src/extensions/intent.ts +78 -0
  120. package/src/extensions/internal.ts +193 -0
  121. package/src/extensions/logger.ts +27 -0
  122. package/src/extensions/mcp-payloads.ts +98 -0
  123. package/src/extensions/mcp-sdk-compat.ts +160 -0
  124. package/src/extensions/posthog-events.ts +212 -0
  125. package/src/extensions/sanitization.ts +121 -0
  126. package/src/extensions/session.ts +117 -0
  127. package/src/extensions/sink.ts +130 -0
  128. package/src/extensions/tools.ts +50 -0
  129. package/src/extensions/tracing-helpers.ts +51 -0
  130. package/src/extensions/truncation.ts +453 -0
  131. package/src/index.ts +174 -0
  132. package/src/types.ts +244 -0
  133. package/src/version.ts +1 -0
  134. package/dist/index.cjs +0 -2937
  135. package/dist/index.cjs.map +0 -1
  136. package/dist/index.d.cts +0 -264
  137. package/dist/index.d.mts +0 -263
  138. 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
+ }