@posthog/mcp 0.25.2 → 0.26.0

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 (76) hide show
  1. package/README.md +3 -4
  2. package/dist/extensions/analytics-parameters.d.ts +2 -2
  3. package/dist/extensions/analytics-parameters.d.ts.map +1 -1
  4. package/dist/extensions/analytics-parameters.js +9 -8
  5. package/dist/extensions/analytics-parameters.mjs +9 -8
  6. package/dist/extensions/capture.js +1 -1
  7. package/dist/extensions/capture.mjs +1 -1
  8. package/dist/extensions/constants.d.ts +2 -1
  9. package/dist/extensions/constants.d.ts.map +1 -1
  10. package/dist/extensions/constants.js +3 -2
  11. package/dist/extensions/constants.mjs +3 -2
  12. package/dist/extensions/context-parameters.d.ts.map +1 -1
  13. package/dist/extensions/context-parameters.js +1 -1
  14. package/dist/extensions/context-parameters.mjs +1 -1
  15. package/dist/extensions/event-types.d.ts +1 -0
  16. package/dist/extensions/event-types.d.ts.map +1 -1
  17. package/dist/extensions/event-types.js +2 -1
  18. package/dist/extensions/event-types.mjs +2 -1
  19. package/dist/extensions/feedback.d.ts.map +1 -1
  20. package/dist/extensions/feedback.js +2 -5
  21. package/dist/extensions/feedback.mjs +2 -5
  22. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  23. package/dist/extensions/instrument-highlevel.js +15 -3
  24. package/dist/extensions/instrument-highlevel.mjs +16 -4
  25. package/dist/extensions/instrument-lowlevel.js +19 -6
  26. package/dist/extensions/instrument-lowlevel.mjs +19 -6
  27. package/dist/extensions/instrumentation.d.ts +5 -0
  28. package/dist/extensions/instrumentation.d.ts.map +1 -1
  29. package/dist/extensions/instrumentation.js +29 -5
  30. package/dist/extensions/instrumentation.mjs +29 -5
  31. package/dist/extensions/model-parameters.d.ts +1 -1
  32. package/dist/extensions/model-parameters.d.ts.map +1 -1
  33. package/dist/extensions/model-parameters.js +1 -1
  34. package/dist/extensions/model-parameters.mjs +1 -1
  35. package/dist/extensions/posthog-events.d.ts.map +1 -1
  36. package/dist/extensions/posthog-events.js +3 -2
  37. package/dist/extensions/posthog-events.mjs +3 -2
  38. package/dist/extensions/posthog-mcp.d.ts +8 -1
  39. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  40. package/dist/extensions/posthog-mcp.js +7 -1
  41. package/dist/extensions/posthog-mcp.mjs +8 -2
  42. package/dist/extensions/sanitization.d.ts.map +1 -1
  43. package/dist/extensions/sanitization.js +2 -1
  44. package/dist/extensions/sanitization.mjs +2 -1
  45. package/dist/extensions/tools.d.ts +11 -0
  46. package/dist/extensions/tools.d.ts.map +1 -1
  47. package/dist/extensions/tools.js +10 -5
  48. package/dist/extensions/tools.mjs +8 -6
  49. package/dist/extensions/truncation.js +1 -1
  50. package/dist/extensions/truncation.mjs +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/types.d.ts +10 -2
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/dist/version.mjs +1 -1
  58. package/package.json +3 -3
  59. package/src/extensions/analytics-parameters.ts +7 -11
  60. package/src/extensions/capture.ts +1 -1
  61. package/src/extensions/constants.ts +2 -1
  62. package/src/extensions/context-parameters.ts +1 -0
  63. package/src/extensions/event-types.ts +1 -0
  64. package/src/extensions/feedback.ts +2 -10
  65. package/src/extensions/instrument-highlevel.ts +29 -4
  66. package/src/extensions/instrument-lowlevel.ts +14 -8
  67. package/src/extensions/instrumentation.ts +60 -8
  68. package/src/extensions/model-parameters.ts +2 -1
  69. package/src/extensions/posthog-events.ts +5 -1
  70. package/src/extensions/posthog-mcp.ts +18 -2
  71. package/src/extensions/sanitization.ts +7 -2
  72. package/src/extensions/tools.ts +13 -10
  73. package/src/extensions/truncation.ts +1 -1
  74. package/src/index.ts +1 -0
  75. package/src/types.ts +11 -2
  76. package/src/version.ts +1 -1
package/dist/version.js CHANGED
@@ -27,7 +27,7 @@ var __webpack_require__ = {};
27
27
  })();
28
28
  var __webpack_exports__ = {};
29
29
  __webpack_require__.r(__webpack_exports__);
30
- const version = "0.25.2";
30
+ const version = "0.26.0";
31
31
  __webpack_require__.d(__webpack_exports__, {}, {
32
32
  version: version
33
33
  });
package/dist/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- const version = "0.25.2";
1
+ const version = "0.26.0";
2
2
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/mcp",
3
- "version": "0.25.2",
3
+ "version": "0.26.0",
4
4
  "bugs": {
5
5
  "url": "https://github.com/PostHog/posthog-js/issues"
6
6
  },
@@ -54,8 +54,8 @@
54
54
  "zod4": "npm:zod@^4.4.3",
55
55
  "@vitest/coverage-v8": "^1.6.1",
56
56
  "vitest": "^1.6.1",
57
- "@posthog-tooling/tsconfig-base": "1.1.1",
58
- "posthog-node": "^5.63.1"
57
+ "posthog-node": "^5.63.1",
58
+ "@posthog-tooling/tsconfig-base": "1.1.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@modelcontextprotocol/sdk": ">=1.26.0",
@@ -50,7 +50,7 @@ export interface AnalyticsInjectableTool {
50
50
  }
51
51
 
52
52
  /**
53
- * Adds a required string parameter to a tool's JSON Schema. Called AFTER the MCP
53
+ * Adds a string parameter to a tool's JSON Schema. Called AFTER the MCP
54
54
  * SDK has converted Zod schemas to JSON Schema, so only JSON Schema needs
55
55
  * handling.
56
56
  *
@@ -67,6 +67,7 @@ export function addAnalyticsParameterToTool<TTool extends AnalyticsInjectableToo
67
67
  parameterName: string,
68
68
  description: string,
69
69
  injectionLabel: string,
70
+ required: boolean,
70
71
  logger: LoggerFn = log
71
72
  ): TTool {
72
73
  const modifiedTool = { ...tool }
@@ -85,11 +86,7 @@ export function addAnalyticsParameterToTool<TTool extends AnalyticsInjectableToo
85
86
  }
86
87
 
87
88
  if (!modifiedTool.inputSchema) {
88
- modifiedTool.inputSchema = {
89
- type: 'object',
90
- properties: {},
91
- required: [],
92
- }
89
+ modifiedTool.inputSchema = { type: 'object', properties: {} }
93
90
  }
94
91
 
95
92
  // Deep copy: the server may reuse or freeze the schema object it handed us.
@@ -112,12 +109,11 @@ export function addAnalyticsParameterToTool<TTool extends AnalyticsInjectableToo
112
109
  description,
113
110
  }
114
111
 
115
- if (Array.isArray(inputSchema.required)) {
116
- if (!inputSchema.required.includes(parameterName)) {
117
- inputSchema.required.push(parameterName)
112
+ if (required) {
113
+ const existing = Array.isArray(inputSchema.required) ? inputSchema.required : []
114
+ if (!existing.includes(parameterName)) {
115
+ inputSchema.required = [...existing, parameterName]
118
116
  }
119
- } else {
120
- inputSchema.required = [parameterName]
121
117
  }
122
118
 
123
119
  return modifiedTool
@@ -42,7 +42,7 @@ export function captureEvent(
42
42
  const sessionInfo = requestAttribution ?? getSessionInfo(server, data, eventInput.sessionId)
43
43
 
44
44
  const duration =
45
- eventInput.duration || (eventInput.timestamp ? Date.now() - eventInput.timestamp.getTime() : undefined)
45
+ eventInput.duration ?? (eventInput.timestamp ? Date.now() - eventInput.timestamp.getTime() : undefined)
46
46
 
47
47
  const fullEvent: McpEvent = {
48
48
  id: eventInput.id || '',
@@ -5,7 +5,7 @@
5
5
 
6
6
  export const INACTIVITY_TIMEOUT_IN_MINUTES = 30
7
7
 
8
- export const DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = `Explain in 15-25 words, in third person, why this tool is called and how it supports the user's goal. For analytics only. You MUST describe only the abstract purpose of the tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying information from the user request or tool results, including names, emails, phone numbers, IPs, IDs, or credentials. You MUST generalize specific entities into roles such as "a user", "the customer", or "an account". Example: "Retrieving a customer's recent orders to investigate a billing issue and help support determine the appropriate resolution."`
8
+ export const DEFAULT_CONTEXT_PARAMETER_DESCRIPTION = `A few words on why you are calling this tool, for the server developer's analytics. Describe people and things by role, such as "a customer". Do not quote the user or include names, emails, or other personal details.`
9
9
 
10
10
  export const DEFAULT_MODEL_PARAMETER_DESCRIPTION = `The model identifier you are running as, for example "claude-opus-4-8" or "gpt-5.2". Pass "unknown" if you are not certain. Recorded for the server developer's analytics.`
11
11
 
@@ -36,6 +36,7 @@ export const PostHogMCPAnalyticsEvent = {
36
36
  ResourcesList: '$mcp_resources_list',
37
37
  ToolCall: '$mcp_tool_call',
38
38
  ToolsList: '$mcp_tools_list',
39
+ UnknownTool: '$mcp_unknown_tool',
39
40
  } as const
40
41
 
41
42
  export type PostHogMCPAnalyticsEvent = (typeof PostHogMCPAnalyticsEvent)[keyof typeof PostHogMCPAnalyticsEvent]
@@ -42,6 +42,7 @@ export function addContextParameterToTool<TTool extends ContextInjectableTool>(
42
42
  'context',
43
43
  contextDescriptionOverride || DEFAULT_CONTEXT_PARAMETER_DESCRIPTION,
44
44
  'context',
45
+ true,
45
46
  logger
46
47
  )
47
48
  }
@@ -21,6 +21,7 @@ export const MCPAnalyticsEventType = {
21
21
  mcpResourcesRead: 'mcp:resources/read',
22
22
  mcpToolsCall: 'mcp:tools/call',
23
23
  mcpToolsList: 'mcp:tools/list',
24
+ mcpUnknownTool: 'mcp:unknown_tool',
24
25
  } as const
25
26
 
26
27
  export type MCPAnalyticsEventType = (typeof MCPAnalyticsEventType)[keyof typeof MCPAnalyticsEventType]
@@ -12,6 +12,7 @@ import type {
12
12
  import { PostHogMCPAnalyticsProperty } from './constants'
13
13
  import { log, type LoggerFn } from './logger'
14
14
  import { sanitizeFreeText, sanitizeFreeTextValue } from './mcp-payloads'
15
+ import { VIRTUAL_TOOL_ANNOTATIONS } from './tools'
15
16
  import { TRUNCATION_SUFFIX } from './truncation'
16
17
 
17
18
  export const SEND_FEEDBACK_TOOL_NAME = 'send_feedback' as const
@@ -150,16 +151,7 @@ export function getFeedbackToolDescriptor(options: CollectFeedbackOptions = {}):
150
151
  },
151
152
  required: ['feedback_type', 'summary', ...extraRequired],
152
153
  },
153
- annotations: {
154
- title: 'Send feedback',
155
- readOnlyHint: true,
156
- // Interacts with an external entity: the report lands in analytics.
157
- openWorldHint: true,
158
- // Only records the feedback, so repeat calls are harmless — and advertising
159
- // it as idempotent makes agents more willing to call it proactively.
160
- idempotentHint: true,
161
- destructiveHint: false,
162
- },
154
+ annotations: { ...VIRTUAL_TOOL_ANNOTATIONS },
163
155
  }
164
156
  }
165
157
 
@@ -30,7 +30,7 @@ import { isCaptureModelEnabled } from './model-parameters'
30
30
  import { MCPAnalyticsEventType } from './event-types'
31
31
  import { getServerTrackingData } from './internal'
32
32
  import type { LoggerFn } from './logger'
33
- import { createWrappedTool, getToolFunction, hasToolFunction } from './mcp-sdk-compat'
33
+ import { createWrappedTool, getToolFunction, hasToolFunction, readRequestHandlerMethod } from './mcp-sdk-compat'
34
34
  import { getReportMissingToolDescriptor, handleReportMissing, resolveMissingCapabilityToolName } from './tools'
35
35
  import {
36
36
  handleInitializeRequest,
@@ -236,7 +236,8 @@ async function handleToolCallRequest(
236
236
  originalCallToolHandler: MCPRequestHandler,
237
237
  request: MCPRequest,
238
238
  extra: MCPRequestExtra,
239
- logger: LoggerFn
239
+ logger: LoggerFn,
240
+ registryDispatches: () => boolean
240
241
  ): Promise<unknown> {
241
242
  const data = getServerTrackingData(server)
242
243
  if (!data) {
@@ -284,7 +285,10 @@ async function handleToolCallRequest(
284
285
  })
285
286
  }
286
287
 
287
- const registeredTool = toolName ? highLevelServer._registeredTools[toolName] : undefined
288
+ const registeredTool =
289
+ toolName && Object.prototype.hasOwnProperty.call(highLevelServer._registeredTools, toolName)
290
+ ? highLevelServer._registeredTools[toolName]
291
+ : undefined
288
292
 
289
293
  // Strip SDK-owned arguments before the MCP SDK validates the registered Zod
290
294
  // schema. The callback wrapper repeats the ownership-aware cleanup as a
@@ -299,6 +303,7 @@ async function handleToolCallRequest(
299
303
  ? getAnalyticsParameterOwnership(registeredTool.inputSchema, registeredTool.outputSchema)
300
304
  : undefined,
301
305
  inputSchema: registeredTool?.inputSchema,
306
+ isUnknownTool: !!toolName && !registeredTool && registryDispatches(),
302
307
  takeCapturedError: () => {
303
308
  const captured = extra?.__mcp_analytics_error
304
309
  if (extra) {
@@ -313,6 +318,7 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
313
318
  try {
314
319
  const lowLevelServer = server.server
315
320
  const mcpAnalyticsData = getServerTrackingData(lowLevelServer)
321
+ const registryDispatches = trackRegistryDispatch(server)
316
322
 
317
323
  const handlers: Record<string, HandlerPatch> = {
318
324
  initialize: (trackedServer, originalHandler, request, extra) =>
@@ -320,7 +326,7 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
320
326
  'tools/list': (trackedServer, originalHandler, request, extra) =>
321
327
  handleListToolsRequest(trackedServer, originalHandler, request, extra, logger),
322
328
  'tools/call': (trackedServer, originalHandler, request, extra) =>
323
- handleToolCallRequest(server, trackedServer, originalHandler, request, extra, logger),
329
+ handleToolCallRequest(server, trackedServer, originalHandler, request, extra, logger, registryDispatches),
324
330
  'resources/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
325
331
  // Both listings publish `$mcp_resources_list`; the captured
326
332
  // `request.method` is what tells a static listing from a templated one.
@@ -342,6 +348,25 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
342
348
  }
343
349
  }
344
350
 
351
+ /**
352
+ * Whether the MCP SDK's own `tools/call` dispatcher, which reads `_registeredTools`,
353
+ * is the one serving calls. Adapters such as @rekog/mcp-nest bind their own
354
+ * dispatcher instead, and keep tools the registry never holds; on SDK v2 they
355
+ * replace the dispatcher the McpServer constructor installed.
356
+ */
357
+ function trackRegistryDispatch(server: HighLevelMCPServerLike): () => boolean {
358
+ const lowLevelServer = server.server
359
+ let replaced = false
360
+ const setRequestHandler = lowLevelServer.setRequestHandler.bind(lowLevelServer) as (...args: unknown[]) => unknown
361
+ lowLevelServer.setRequestHandler = ((...args: unknown[]) => {
362
+ if (server._toolHandlersInitialized && readRequestHandlerMethod(args[0]) === 'tools/call') {
363
+ replaced = true
364
+ }
365
+ return setRequestHandler(...args)
366
+ }) as MCPServerLike['setRequestHandler']
367
+ return () => server._toolHandlersInitialized === true && !replaced
368
+ }
369
+
345
370
  function seedToolDescriptionsFromRegistry(cache: Map<string, string>, tools: Record<string, RegisteredTool>): void {
346
371
  for (const [name, tool] of Object.entries(tools)) {
347
372
  if (typeof tool?.description === 'string') {
@@ -132,7 +132,9 @@ async function handleToolCallRequest(
132
132
  })
133
133
  }
134
134
 
135
- const originalTool = resolveOriginalTool(data, toolName)
135
+ const lookup = lookUpOriginalTool(data, toolName)
136
+ const originalTool = lookup.registered ? lookup.tool : undefined
137
+ const hasDispatcher = originalCallToolHandler !== undefined && originalCallToolHandler !== unknownToolHandler
136
138
  return await captureToolCall({
137
139
  server,
138
140
  data,
@@ -145,19 +147,23 @@ async function handleToolCallRequest(
145
147
  ? getAnalyticsParameterOwnership(originalTool.inputSchema)
146
148
  : undefined,
147
149
  inputSchema: originalTool?.inputSchema,
150
+ isUnknownTool: !!toolName && (!hasDispatcher || lookup.registered === false),
148
151
  })
149
152
  }
150
153
 
151
- function resolveOriginalTool(
152
- data: MCPAnalyticsData,
153
- toolName: string | undefined
154
- ): { inputSchema?: unknown } | undefined {
155
- if (!toolName || !data.options.resolveOriginalTool) return undefined
154
+ type OriginalToolLookup =
155
+ | { registered: true; tool: { inputSchema?: unknown } }
156
+ | { registered: false }
157
+ | { registered: undefined }
158
+
159
+ function lookUpOriginalTool(data: MCPAnalyticsData, toolName: string | undefined): OriginalToolLookup {
160
+ if (!toolName || !data.options.resolveOriginalTool) return { registered: undefined }
156
161
  try {
157
- return data.options.resolveOriginalTool(toolName) ?? undefined
162
+ const tool = data.options.resolveOriginalTool(toolName)
163
+ return tool ? { registered: true, tool } : { registered: false }
158
164
  } catch (error) {
159
165
  data.logger(`Warning: resolveOriginalTool failed for tool ${toolName} - ${error}`)
160
- return undefined
166
+ return { registered: undefined }
161
167
  }
162
168
  }
163
169
 
@@ -128,6 +128,11 @@ interface TraceToolCallParams {
128
128
  * read it here to capture the real stack rather than the result envelope.
129
129
  */
130
130
  takeCapturedError?: () => unknown
131
+ /**
132
+ * The SDK knows the server has no tool by this name, so a rejected call is
133
+ * recorded as `$mcp_unknown_tool`, not as a tool call.
134
+ */
135
+ isUnknownTool?: boolean
131
136
  }
132
137
 
133
138
  /**
@@ -152,6 +157,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
152
157
  extraEventProperties,
153
158
  omitCapturedParameters,
154
159
  takeCapturedError,
160
+ isUnknownTool,
155
161
  } = params
156
162
  const resolvedEventType = eventType ?? MCPAnalyticsEventType.mcpToolsCall
157
163
  // The SDK's own virtual tools carry their intent in their own arguments, so
@@ -190,6 +196,7 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
190
196
  resolvedEventType,
191
197
  canCaptureContextIntent
192
198
  )
199
+ const hostProperties = preparedEvent?.event.properties
193
200
  if (preparedEvent && resolvedEventType === MCPAnalyticsEventType.mcpToolsCall) {
194
201
  const sessionSchemas =
195
202
  (preparedEvent.event.sessionId ? data.toolInputSchemas.get(preparedEvent.event.sessionId) : undefined) ??
@@ -217,14 +224,25 @@ export async function captureToolCall(params: TraceToolCallParams): Promise<unkn
217
224
  try {
218
225
  result = await execute(downstreamRequest)
219
226
  } catch (error) {
220
- publishFailedToolEvent(server, preparedEvent, error, startTime, conversation, data.logger)
227
+ if (preparedEvent && conversation.minted) {
228
+ preparedEvent.event.conversationId = undefined
229
+ }
230
+ if (isUnknownTool) {
231
+ publishUnknownToolEvent(server, preparedEvent, hostProperties, data.logger)
232
+ } else {
233
+ publishFailedToolEvent(server, preparedEvent, error, startTime, data.logger)
234
+ }
221
235
  throw error
222
236
  }
223
237
 
224
238
  const finalResult = applyConversationInstructions(preparedEvent?.event ?? null, result, conversation, ownership)
225
- // `result`, not `finalResult`: the error is read from what the tool produced,
226
- // before the conversation handle was written into it. See below.
227
- publishSuccessfulToolEvent(server, preparedEvent, finalResult, startTime, data.logger, takeCapturedError, result)
239
+ if (isUnknownTool && isToolResultError(result)) {
240
+ publishUnknownToolEvent(server, preparedEvent, hostProperties, data.logger)
241
+ } else {
242
+ // `result`, not `finalResult`: the error is read from what the tool produced,
243
+ // before the conversation handle was written into it. See below.
244
+ publishSuccessfulToolEvent(server, preparedEvent, finalResult, startTime, data.logger, takeCapturedError, result)
245
+ }
228
246
  return finalResult
229
247
  }
230
248
 
@@ -469,7 +487,6 @@ function publishFailedToolEvent(
469
487
  preparedEvent: PreparedToolEvent | null,
470
488
  error: unknown,
471
489
  startTime: Date,
472
- conversation: ConversationIdResolution,
473
490
  logger: LoggerFn
474
491
  ): void {
475
492
  if (!preparedEvent) {
@@ -477,9 +494,6 @@ function publishFailedToolEvent(
477
494
  }
478
495
  const { event, requestAttribution } = preparedEvent
479
496
  try {
480
- if (conversation.minted) {
481
- event.conversationId = undefined
482
- }
483
497
  event.isError = true
484
498
  event.error = captureException(error)
485
499
  event.duration = Date.now() - startTime.getTime()
@@ -489,6 +503,44 @@ function publishFailedToolEvent(
489
503
  }
490
504
  }
491
505
 
506
+ /**
507
+ * The agent asked for a tool the server does not have, so the call is not a
508
+ * tool call: only the requested name, identity, and session are recorded.
509
+ */
510
+ function publishUnknownToolEvent(
511
+ server: MCPServerLike,
512
+ preparedEvent: PreparedToolEvent | null,
513
+ properties: McpEvent['properties'],
514
+ logger: LoggerFn
515
+ ): void {
516
+ if (!preparedEvent) {
517
+ return
518
+ }
519
+ const { event, requestAttribution } = preparedEvent
520
+ try {
521
+ captureEvent(
522
+ server,
523
+ {
524
+ eventType: MCPAnalyticsEventType.mcpUnknownTool,
525
+ timestamp: event.timestamp,
526
+ resourceName: event.resourceName,
527
+ sessionId: event.sessionId,
528
+ conversationId: event.conversationId,
529
+ clientName: event.clientName,
530
+ clientVersion: event.clientVersion,
531
+ protocolVersion: event.protocolVersion,
532
+ clientUserAgent: event.clientUserAgent,
533
+ vendorClient: event.vendorClient,
534
+ properties,
535
+ },
536
+ logger,
537
+ requestAttribution
538
+ )
539
+ } catch (publishError) {
540
+ logger(`Warning: PostHog MCP analytics failed to publish unknown tool event - ${publishError}`)
541
+ }
542
+ }
543
+
492
544
  type ResourceEventType = typeof MCPAnalyticsEventType.mcpResourcesList | typeof MCPAnalyticsEventType.mcpResourcesRead
493
545
 
494
546
  interface TraceRequestParams {
@@ -10,7 +10,7 @@ import { log, type LoggerFn } from './logger'
10
10
  * vendor metadata, while other harnesses inject the model id into the system
11
11
  * prompt so the agent can state it like intent through the `context` parameter.
12
12
  *
13
- * This module injects a required `llm_model` string parameter into every tool
13
+ * This module injects an optional `llm_model` string parameter into every tool
14
14
  * (mirroring `context-parameters.ts`), strips it before the tool runs, and
15
15
  * captures the best available value as `$mcp_llm_model`. Client metadata wins
16
16
  * over self-report, and `$mcp_llm_model_source` preserves that provenance.
@@ -54,6 +54,7 @@ export function addModelParameterToTool<TTool extends ModelInjectableTool>(
54
54
  'llm_model',
55
55
  modelDescriptionOverride || DEFAULT_MODEL_PARAMETER_DESCRIPTION,
56
56
  'model',
57
+ false,
57
58
  logger
58
59
  )
59
60
  }
@@ -18,6 +18,7 @@ const BUILT_IN_EVENT_NAME_BY_TYPE = {
18
18
  [MCPAnalyticsEventType.mcpResourcesRead]: PostHogMCPAnalyticsEvent.ResourceRead,
19
19
  [MCPAnalyticsEventType.mcpToolsCall]: PostHogMCPAnalyticsEvent.ToolCall,
20
20
  [MCPAnalyticsEventType.mcpToolsList]: PostHogMCPAnalyticsEvent.ToolsList,
21
+ [MCPAnalyticsEventType.mcpUnknownTool]: PostHogMCPAnalyticsEvent.UnknownTool,
21
22
  } satisfies Record<MCPAnalyticsEventType, PostHogMCPAnalyticsEvent>
22
23
 
23
24
  function getDistinctId(event: Event): string {
@@ -121,7 +122,10 @@ function addPersonProcessingProperty(event: Event, properties: Record<string, un
121
122
  function addCommonEventProperties(event: Event, properties: Record<string, unknown>): void {
122
123
  if (event.resourceName) {
123
124
  properties[PostHogMCPAnalyticsProperty.ResourceName] = event.resourceName
124
- if (event.eventType === MCPAnalyticsEventType.mcpToolsCall) {
125
+ if (
126
+ event.eventType === MCPAnalyticsEventType.mcpToolsCall ||
127
+ event.eventType === MCPAnalyticsEventType.mcpUnknownTool
128
+ ) {
125
129
  properties[PostHogMCPAnalyticsProperty.ToolName] = event.resourceName
126
130
  }
127
131
  }
@@ -17,6 +17,7 @@ import type {
17
17
  PrepareToolListOptions,
18
18
  ToolCallCaptureData,
19
19
  ToolsListCaptureData,
20
+ UnknownToolCaptureData,
20
21
  } from '../types'
21
22
  import {
22
23
  buildFeedbackEventProperties,
@@ -54,7 +55,7 @@ import {
54
55
  } from './model-parameters'
55
56
  import { McpEventSink } from './sink'
56
57
  import { addInstructionsToOutputSchemas, mirrorInstructionsIntoStructuredContent } from './output-instructions'
57
- import { deriveSessionIdFromConversation } from './session'
58
+ import { deriveSessionIdFromConversation, newSessionId } from './session'
58
59
  import { validateServerBuild } from './server-build'
59
60
  import { GET_MORE_TOOLS_NAME, getReportMissingToolDescriptor } from './tools'
60
61
 
@@ -479,6 +480,21 @@ export class PostHogMCP extends PostHog {
479
480
  this.#emit(event)
480
481
  }
481
482
 
483
+ /**
484
+ * Capture a call to a tool your server does not have. Emits
485
+ * `$mcp_unknown_tool`. Use it instead of {@link captureToolCall} when your
486
+ * dispatcher rejects the name, so the call is not counted as a tool call. A
487
+ * call that names no tool emits nothing.
488
+ */
489
+ captureUnknownTool(data: UnknownToolCaptureData): void {
490
+ if (!data.toolName) {
491
+ return
492
+ }
493
+ const event = baseEvent(MCPAnalyticsEventType.mcpUnknownTool, data)
494
+ event.resourceName = data.toolName
495
+ this.#emit(event)
496
+ }
497
+
482
498
  /**
483
499
  * Capture a `send_feedback` call as an agent-feedback report. Emits
484
500
  * `$mcp_feedback` with the report's `$mcp_feedback_*` properties and its
@@ -534,7 +550,7 @@ export class PostHogMCP extends PostHog {
534
550
  function baseEvent(eventType: MCPAnalyticsEventType, common: McpCaptureCommon): McpEvent {
535
551
  const event: McpEvent = {
536
552
  eventType,
537
- sessionId: common.sessionId,
553
+ sessionId: common.sessionId || newSessionId(),
538
554
  conversationId: common.conversationId,
539
555
  protocolVersion: common.protocolVersion,
540
556
  // There is no `extra` on this path, so the host reads the request headers and
@@ -4,6 +4,7 @@
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
6
  import type { ErrorProperties, Event, McpEvent } from '../types'
7
+ import { MCPAnalyticsEventType } from './event-types'
7
8
  import { sanitizeCapturedValue, sanitizeFreeText } from './mcp-payloads'
8
9
 
9
10
  type SanitizedRecord = Record<string, unknown>
@@ -34,9 +35,13 @@ export function sanitizeEvent<T extends Event | McpEvent>(event: T): T {
34
35
 
35
36
  // Every event type, not just `resources/read`: the `$exception` sibling
36
37
  // carries the same name, and a tool or prompt name is free text an
37
- // application can spell as a URL too.
38
+ // application can spell as a URL too. An unknown tool's name is whatever the
39
+ // agent typed, so it also gets the intent's personal-data redaction.
38
40
  if (result.resourceName != null) {
39
- result.resourceName = sanitizeCapturedValue(result.resourceName) as string
41
+ result.resourceName =
42
+ result.eventType === MCPAnalyticsEventType.mcpUnknownTool
43
+ ? sanitizeFreeText(result.resourceName)
44
+ : (sanitizeCapturedValue(result.resourceName) as string)
40
45
  }
41
46
 
42
47
  // The intent comes straight from an agent-narrated `context` string, so it can
@@ -17,6 +17,18 @@ export function resolveMissingCapabilityToolName(options?: { missingCapabilityTo
17
17
  return options?.missingCapabilityToolName ?? GET_MORE_TOOLS_NAME
18
18
  }
19
19
 
20
+ /**
21
+ * A virtual tool call sends one event to the server owner's PostHog project: it
22
+ * writes outside the conversation, overwrites nothing, records again on every
23
+ * repeat, and reaches nothing else.
24
+ */
25
+ export const VIRTUAL_TOOL_ANNOTATIONS = {
26
+ readOnlyHint: false,
27
+ destructiveHint: false,
28
+ idempotentHint: false,
29
+ openWorldHint: false,
30
+ } as const
31
+
20
32
  type ReportMissingToolDescriptor = CompatibleToolsListLike['tools'][number]
21
33
 
22
34
  export function getReportMissingToolDescriptor(name: string = GET_MORE_TOOLS_NAME): ReportMissingToolDescriptor {
@@ -34,16 +46,7 @@ export function getReportMissingToolDescriptor(name: string = GET_MORE_TOOLS_NAM
34
46
  },
35
47
  required: ['context'],
36
48
  },
37
- annotations: {
38
- title: 'Get More Tools',
39
- readOnlyHint: true,
40
- // Interacts with an external entity: the report lands in analytics.
41
- openWorldHint: true,
42
- // Only records the gap, so repeat calls are harmless — and advertising it
43
- // as idempotent makes agents more willing to call it proactively.
44
- idempotentHint: true,
45
- destructiveHint: false,
46
- },
49
+ annotations: { ...VIRTUAL_TOOL_ANNOTATIONS },
47
50
  }
48
51
  }
49
52
 
@@ -190,7 +190,7 @@ function truncateString(str: string | undefined, maxLength: number): string | un
190
190
  if (str.length <= maxLength) {
191
191
  return str
192
192
  }
193
- return str.slice(0, maxLength) + TRUNCATION_SUFFIX
193
+ return str.slice(0, maxLength - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX
194
194
  }
195
195
 
196
196
  function truncateStackFrames(frames: StackFrame[] | undefined): StackFrame[] | undefined {
package/src/index.ts CHANGED
@@ -271,6 +271,7 @@ export type {
271
271
  ToolCallCaptureData,
272
272
  ToolInputOptions,
273
273
  ToolsListCaptureData,
274
+ UnknownToolCaptureData,
274
275
  UserIdentity,
275
276
  } from './types'
276
277
  export type IdentifyFunction = MCPAnalyticsOptions['identify']
package/src/types.ts CHANGED
@@ -224,8 +224,9 @@ export interface MCPAnalyticsOptions {
224
224
  * fresh instance per request strips them before the handler runs. Ownership
225
225
  * follows the same rule as a served listing: a Zod schema is read the way the
226
226
  * MCP SDK advertises it, and a host that lists its own JSON Schema returns that.
227
- * Ownership learned from a listing on the instance wins. Ignored on a
228
- * high-level `McpServer`, which reads its tool registry.
227
+ * Ownership learned from a listing on the instance wins. A rejected call to a
228
+ * tool this returns `undefined` for is recorded as `$mcp_unknown_tool`, not as
229
+ * a tool call. Ignored on a high-level `McpServer`, which reads its tool registry.
229
230
  */
230
231
  resolveOriginalTool?: (toolName: string) => { inputSchema?: unknown } | undefined
231
232
  /**
@@ -494,6 +495,8 @@ export interface ServerClientInfoLike {
494
495
 
495
496
  export interface HighLevelMCPServerLike {
496
497
  _registeredTools: { [name: string]: RegisteredTool }
498
+ /** Set once the MCP SDK has installed its own `tools/list` and `tools/call` handlers. */
499
+ _toolHandlersInitialized?: boolean
497
500
  registerTool?(name: string, config: { description?: string; inputSchema?: unknown }, handler: ToolCallback): void
498
501
  server: MCPServerLike
499
502
  tool?(name: string, cb: ToolCallback): void
@@ -848,6 +851,12 @@ export interface MissingCapabilityCaptureData extends McpCaptureCommon {
848
851
  parameters?: unknown
849
852
  }
850
853
 
854
+ /** Payload for {@link PostHogMCP.captureUnknownTool}. Emits `$mcp_unknown_tool`. */
855
+ export interface UnknownToolCaptureData extends McpCaptureCommon {
856
+ /** The tool name the agent asked for → `$mcp_tool_name`, sanitized as free text. */
857
+ toolName: string
858
+ }
859
+
851
860
  /** Payload for {@link PostHogMCP.captureFeedback}. Emits `$mcp_feedback`. */
852
861
  export interface FeedbackCaptureData extends McpCaptureCommon {
853
862
  /**
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = "0.25.2"
1
+ export const version = "0.26.0"