@posthog/mcp 0.14.1 → 0.16.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 (65) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +64 -0
  3. package/dist/extensions/constants.d.ts +9 -0
  4. package/dist/extensions/constants.d.ts.map +1 -1
  5. package/dist/extensions/constants.js +9 -0
  6. package/dist/extensions/constants.mjs +9 -0
  7. package/dist/extensions/event-types.d.ts +1 -0
  8. package/dist/extensions/event-types.d.ts.map +1 -1
  9. package/dist/extensions/event-types.js +1 -0
  10. package/dist/extensions/event-types.mjs +1 -0
  11. package/dist/extensions/feedback.d.ts +41 -0
  12. package/dist/extensions/feedback.d.ts.map +1 -0
  13. package/dist/extensions/feedback.js +259 -0
  14. package/dist/extensions/feedback.mjs +200 -0
  15. package/dist/extensions/instrument-highlevel.d.ts.map +1 -1
  16. package/dist/extensions/instrument-highlevel.js +23 -2
  17. package/dist/extensions/instrument-highlevel.mjs +25 -4
  18. package/dist/extensions/instrument-lowlevel.d.ts +4 -3
  19. package/dist/extensions/instrument-lowlevel.d.ts.map +1 -1
  20. package/dist/extensions/instrument-lowlevel.js +23 -2
  21. package/dist/extensions/instrument-lowlevel.mjs +25 -4
  22. package/dist/extensions/instrumentation.d.ts +33 -13
  23. package/dist/extensions/instrumentation.d.ts.map +1 -1
  24. package/dist/extensions/instrumentation.js +103 -9
  25. package/dist/extensions/instrumentation.mjs +99 -8
  26. package/dist/extensions/internal.js +9 -2
  27. package/dist/extensions/internal.mjs +9 -2
  28. package/dist/extensions/mcp-payloads.d.ts +19 -0
  29. package/dist/extensions/mcp-payloads.d.ts.map +1 -1
  30. package/dist/extensions/mcp-payloads.js +217 -7
  31. package/dist/extensions/mcp-payloads.mjs +210 -6
  32. package/dist/extensions/posthog-events.d.ts.map +1 -1
  33. package/dist/extensions/posthog-events.js +1 -0
  34. package/dist/extensions/posthog-events.mjs +1 -0
  35. package/dist/extensions/posthog-mcp.d.ts +23 -3
  36. package/dist/extensions/posthog-mcp.d.ts.map +1 -1
  37. package/dist/extensions/posthog-mcp.js +27 -1
  38. package/dist/extensions/posthog-mcp.mjs +27 -1
  39. package/dist/extensions/sanitization.d.ts.map +1 -1
  40. package/dist/extensions/sanitization.js +2 -1
  41. package/dist/extensions/sanitization.mjs +3 -2
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +10 -0
  45. package/dist/index.mjs +5 -1
  46. package/dist/types.d.ts +114 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/version.d.ts +1 -1
  49. package/dist/version.js +1 -1
  50. package/dist/version.mjs +1 -1
  51. package/package.json +4 -4
  52. package/src/extensions/constants.ts +9 -0
  53. package/src/extensions/event-types.ts +1 -0
  54. package/src/extensions/feedback.ts +349 -0
  55. package/src/extensions/instrument-highlevel.ts +45 -2
  56. package/src/extensions/instrument-lowlevel.ts +49 -5
  57. package/src/extensions/instrumentation.ts +170 -17
  58. package/src/extensions/internal.ts +13 -2
  59. package/src/extensions/mcp-payloads.ts +443 -7
  60. package/src/extensions/posthog-events.ts +1 -0
  61. package/src/extensions/posthog-mcp.ts +74 -3
  62. package/src/extensions/sanitization.ts +14 -6
  63. package/src/index.ts +24 -3
  64. package/src/types.ts +120 -0
  65. package/src/version.ts +1 -1
@@ -0,0 +1,349 @@
1
+ import type {
2
+ CollectFeedbackOptions,
3
+ FeedbackExtraPropertySchema,
4
+ FeedbackReport,
5
+ FeedbackSentiment,
6
+ FeedbackType,
7
+ CollectFeedbackConfig,
8
+ CompatibleTextToolResult,
9
+ CompatibleToolsListLike,
10
+ JsonRecord,
11
+ } from '../types'
12
+ import { PostHogMCPAnalyticsProperty } from './constants'
13
+ import { log, type LoggerFn } from './logger'
14
+ import { sanitizeFreeText, sanitizeFreeTextValue } from './mcp-payloads'
15
+
16
+ export const SEND_FEEDBACK_TOOL_NAME = 'send_feedback' as const
17
+
18
+ const FEEDBACK_TYPES: readonly FeedbackType[] = ['missing_capability', 'issue', 'praise', 'other']
19
+ const SENTIMENTS: readonly FeedbackSentiment[] = ['positive', 'neutral', 'negative', 'mixed']
20
+
21
+ // Free-text fields are agent-narrated, like `$mcp_intent`; bound them the same way.
22
+ const MAX_FEEDBACK_TEXT_LENGTH = 2048
23
+ const MAX_FEEDBACK_TOOL_NAME_LENGTH = 256
24
+ const TRUNCATION_SUFFIX = '...'
25
+
26
+ const DEFAULT_FEEDBACK_DESCRIPTION =
27
+ 'Send feedback about this server to its developers. Most important: report a missing capability whenever no ' +
28
+ 'available tool fits your task, even if you can work around it (feedback_type "missing_capability"). Also ' +
29
+ 'welcome: a tool that failed or confused you, an unhelpful error, or something that worked well. This records ' +
30
+ 'the feedback; it does not add or change tools. Do not include user PII or sensitive content.'
31
+
32
+ const CORE_FEEDBACK_SCHEMA_PROPERTIES = {
33
+ feedback_type: {
34
+ type: 'string',
35
+ enum: [...FEEDBACK_TYPES],
36
+ description:
37
+ "What kind of feedback this is. Use 'missing_capability' when the tool you needed does not exist in the " +
38
+ 'tool list - nothing failed, the capability is absent (this is the most valuable report; send it even if ' +
39
+ "you found a workaround). Use 'issue' when an existing tool behaved badly: it failed, returned a confusing " +
40
+ "error, or its description or schema misled you. Use 'praise' when something worked notably well. Use " +
41
+ "'other' for anything else.",
42
+ },
43
+ summary: {
44
+ type: 'string',
45
+ description:
46
+ "One self-contained sentence. For 'missing_capability': the capability you needed, e.g. 'No tool to " +
47
+ "delete multiple cohorts in one call.' For 'issue': the tool and the problem, e.g. 'query-trends rejects " +
48
+ "relative date ranges with an unclear error.'",
49
+ },
50
+ details: {
51
+ type: 'string',
52
+ description:
53
+ 'Optional longer context: what you tried, exact parameter values, the error text you saw, and any ' +
54
+ 'workaround you used. Omit when the summary says it all.',
55
+ },
56
+ friction_points: {
57
+ type: 'string',
58
+ description:
59
+ 'Optional: the specific moments that slowed you down, as short bullet-like sentences, quoting exact tool ' +
60
+ 'names, parameters, or error text.',
61
+ },
62
+ suggested_improvement: {
63
+ type: 'string',
64
+ description:
65
+ 'Optional: the concrete change that would have helped, e.g. the tool to add, the description to reword, ' +
66
+ 'or the error message to improve.',
67
+ },
68
+ tool_name: {
69
+ type: 'string',
70
+ description:
71
+ "Optional: the existing tool this feedback is about (for 'issue' or 'praise'). Leave empty for " +
72
+ "'missing_capability' - the point is that no tool fits.",
73
+ },
74
+ sentiment: {
75
+ type: 'string',
76
+ enum: [...SENTIMENTS],
77
+ description: 'Optional: how the experience felt overall.',
78
+ },
79
+ task_completed: {
80
+ type: 'boolean',
81
+ description: "Optional: whether you still completed the user's task despite the problem.",
82
+ },
83
+ } as const
84
+
85
+ /**
86
+ * Extra-property names a host may not declare: the core fields themselves, the
87
+ * names whose `$mcp_feedback_<key>` property would collide with a core property
88
+ * (`type` → `$mcp_feedback_type`, `tool` → `$mcp_feedback_tool`), and the
89
+ * SDK-injected analytics arguments — the report is parsed from the raw
90
+ * arguments before those are stripped, so an extra by the same name would
91
+ * capture an SDK-owned value.
92
+ */
93
+ const RESERVED_EXTRA_PROPERTY_KEYS = new Set([
94
+ ...Object.keys(CORE_FEEDBACK_SCHEMA_PROPERTIES),
95
+ 'type',
96
+ 'tool',
97
+ 'context',
98
+ 'conversation_id',
99
+ 'llm_model',
100
+ ])
101
+
102
+ type FeedbackToolDescriptor = CompatibleToolsListLike['tools'][number]
103
+
104
+ /** `collectFeedback` normalized to its object form; `undefined` when the feature is off. */
105
+ export function resolveCollectFeedbackOptions(
106
+ config: CollectFeedbackConfig | undefined
107
+ ): CollectFeedbackOptions | undefined {
108
+ if (!config) {
109
+ return undefined
110
+ }
111
+ return config === true ? {} : config
112
+ }
113
+
114
+ /**
115
+ * The advertised descriptor: the core feedback schema plus the host's declared
116
+ * `extraProperties`. Throws on a config error (a reserved extra key, or an
117
+ * `extraRequired` entry that was never declared) so a bad setup fails at
118
+ * configuration time instead of silently corrupting the advertised schema.
119
+ */
120
+ export function getFeedbackToolDescriptor(options: CollectFeedbackOptions = {}): FeedbackToolDescriptor {
121
+ const extraProperties = options.extraProperties ?? {}
122
+ const extraRequired = options.extraRequired ?? []
123
+
124
+ for (const key of Object.keys(extraProperties)) {
125
+ if (RESERVED_EXTRA_PROPERTY_KEYS.has(key)) {
126
+ throw new Error(
127
+ `collectFeedback.extraProperties key "${key}" collides with a core or SDK-reserved send_feedback field. Rename it.`
128
+ )
129
+ }
130
+ }
131
+ for (const key of extraRequired) {
132
+ if (!Object.prototype.hasOwnProperty.call(extraProperties, key)) {
133
+ throw new Error(`collectFeedback.extraRequired key "${key}" is not declared in extraProperties.`)
134
+ }
135
+ }
136
+
137
+ // Deep copy: the host may reuse or mutate the fragments it handed us.
138
+ const copiedExtras =
139
+ Object.keys(extraProperties).length > 0
140
+ ? (JSON.parse(JSON.stringify(extraProperties)) as Record<string, unknown>)
141
+ : undefined
142
+
143
+ return {
144
+ name: options.toolName ?? SEND_FEEDBACK_TOOL_NAME,
145
+ description: options.description ?? DEFAULT_FEEDBACK_DESCRIPTION,
146
+ inputSchema: {
147
+ type: 'object',
148
+ properties: {
149
+ ...(JSON.parse(JSON.stringify(CORE_FEEDBACK_SCHEMA_PROPERTIES)) as Record<string, unknown>),
150
+ ...copiedExtras,
151
+ },
152
+ required: ['feedback_type', 'summary', ...extraRequired],
153
+ },
154
+ annotations: {
155
+ title: 'Send feedback',
156
+ readOnlyHint: true,
157
+ // Interacts with an external entity: the report lands in analytics.
158
+ openWorldHint: true,
159
+ // Only records the feedback, so repeat calls are harmless — and advertising
160
+ // it as idempotent makes agents more willing to call it proactively.
161
+ idempotentHint: true,
162
+ destructiveHint: false,
163
+ },
164
+ }
165
+ }
166
+
167
+ function readString(value: unknown): string | undefined {
168
+ return typeof value === 'string' && value.trim() ? value : undefined
169
+ }
170
+
171
+ function parseFeedbackType(value: unknown): FeedbackType {
172
+ return FEEDBACK_TYPES.includes(value as FeedbackType) ? (value as FeedbackType) : 'other'
173
+ }
174
+
175
+ function parseSentiment(value: unknown): FeedbackSentiment | undefined {
176
+ return SENTIMENTS.includes(value as FeedbackSentiment) ? (value as FeedbackSentiment) : undefined
177
+ }
178
+
179
+ /**
180
+ * True when the value conforms to the declared fragment's `type` and `enum` —
181
+ * the same advisory-schema enforcement the core fields get, so `extras` only
182
+ * ever holds schema-conforming values and a misbehaving agent shows up as
183
+ * absence rather than as an unexpected shape in the host's handler.
184
+ */
185
+ function matchesExtraSchema(value: unknown, schema: FeedbackExtraPropertySchema): boolean {
186
+ const type = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value
187
+ if (schema.type !== type && !(schema.type === 'integer' && typeof value === 'number')) {
188
+ return false
189
+ }
190
+ return !Array.isArray(schema.enum) || schema.enum.includes(value as string)
191
+ }
192
+
193
+ /**
194
+ * Parses the raw `send_feedback` arguments into a typed report. Never throws:
195
+ * an invalid `feedback_type` falls back to `other`, missing fields stay
196
+ * undefined, and only **declared** extras whose values match their declared
197
+ * `type`/`enum` are lifted into `extras` — mismatches and anything the agent
198
+ * invented reach the handler via `raw` only and are never captured.
199
+ */
200
+ export function parseFeedbackReport(
201
+ args: Record<string, unknown> | undefined,
202
+ options: CollectFeedbackOptions = {}
203
+ ): FeedbackReport {
204
+ const raw = args ?? {}
205
+ const extras: JsonRecord = {}
206
+ for (const [key, schema] of Object.entries(options.extraProperties ?? {})) {
207
+ if (raw[key] !== undefined && matchesExtraSchema(raw[key], schema)) {
208
+ extras[key] = raw[key]
209
+ }
210
+ }
211
+ return {
212
+ feedbackType: parseFeedbackType(raw.feedback_type),
213
+ summary: readString(raw.summary) ?? '',
214
+ sentiment: parseSentiment(raw.sentiment),
215
+ frictionPoints: readString(raw.friction_points),
216
+ suggestedImprovement: readString(raw.suggested_improvement),
217
+ details: readString(raw.details),
218
+ toolName: readString(raw.tool_name),
219
+ taskCompleted: typeof raw.task_completed === 'boolean' ? raw.task_completed : undefined,
220
+ extras,
221
+ raw,
222
+ }
223
+ }
224
+
225
+ /** The report's free text, used as the event's `$mcp_intent`. */
226
+ export function buildFeedbackIntent(report: FeedbackReport): string {
227
+ return [report.summary, report.details].filter(Boolean).join('\n\n')
228
+ }
229
+
230
+ function truncateFeedbackText(value: string, maxLength: number): string {
231
+ return value.length > maxLength ? value.slice(0, maxLength) + TRUNCATION_SUFFIX : value
232
+ }
233
+
234
+ /**
235
+ * Agent-narrated free text can contain a secret the LLM read aloud or personal
236
+ * data it narrated, so it gets exactly the `$mcp_intent` pass (`sanitizeFreeText`:
237
+ * credentials → structured PII → URLs — the order is load-bearing, the URL
238
+ * rewrite would percent-encode the `@` the email pattern anchors on), then a
239
+ * length bound. The event-level pipeline does not process `event.properties`,
240
+ * so this happens here.
241
+ */
242
+ function captureFreeText(value: string): string {
243
+ return truncateFeedbackText(sanitizeFreeText(value), MAX_FEEDBACK_TEXT_LENGTH)
244
+ }
245
+
246
+ /**
247
+ * A declared extra is agent-supplied like the core free-text fields, so its
248
+ * string leaves get the same intent-grade pass (with the key-based redaction
249
+ * `sanitizeFreeTextValue` keeps for nested objects), then non-scalars are
250
+ * JSON-stringified and everything is bounded.
251
+ */
252
+ function captureExtraValue(value: unknown): unknown {
253
+ const sanitized = sanitizeFreeTextValue(value)
254
+ if (typeof sanitized === 'string') {
255
+ return truncateFeedbackText(sanitized, MAX_FEEDBACK_TEXT_LENGTH)
256
+ }
257
+ if (sanitized == null || typeof sanitized === 'number' || typeof sanitized === 'boolean') {
258
+ return sanitized
259
+ }
260
+ try {
261
+ return truncateFeedbackText(JSON.stringify(sanitized), MAX_FEEDBACK_TEXT_LENGTH)
262
+ } catch {
263
+ return undefined
264
+ }
265
+ }
266
+
267
+ /** The `$mcp_feedback_*` event properties for one report, declared extras included. */
268
+ export function buildFeedbackEventProperties(report: FeedbackReport): JsonRecord {
269
+ const properties: JsonRecord = {
270
+ [PostHogMCPAnalyticsProperty.FeedbackType]: report.feedbackType,
271
+ }
272
+ if (report.summary) {
273
+ properties[PostHogMCPAnalyticsProperty.FeedbackSummary] = captureFreeText(report.summary)
274
+ }
275
+ if (report.sentiment) {
276
+ properties[PostHogMCPAnalyticsProperty.FeedbackSentiment] = report.sentiment
277
+ }
278
+ if (report.frictionPoints) {
279
+ properties[PostHogMCPAnalyticsProperty.FeedbackFrictionPoints] = captureFreeText(report.frictionPoints)
280
+ }
281
+ if (report.suggestedImprovement) {
282
+ properties[PostHogMCPAnalyticsProperty.FeedbackSuggestedImprovement] = captureFreeText(report.suggestedImprovement)
283
+ }
284
+ if (report.details) {
285
+ properties[PostHogMCPAnalyticsProperty.FeedbackDetails] = captureFreeText(report.details)
286
+ }
287
+ if (report.toolName) {
288
+ // Nominally an identifier, but the schema can't stop an agent from writing
289
+ // prose into it — so it gets the same intent-grade pass as the other free text.
290
+ properties[PostHogMCPAnalyticsProperty.FeedbackTool] = truncateFeedbackText(
291
+ sanitizeFreeText(report.toolName),
292
+ MAX_FEEDBACK_TOOL_NAME_LENGTH
293
+ )
294
+ }
295
+ if (report.taskCompleted !== undefined) {
296
+ properties[PostHogMCPAnalyticsProperty.FeedbackTaskCompleted] = report.taskCompleted
297
+ }
298
+ for (const [key, value] of Object.entries(report.extras)) {
299
+ const captured = captureExtraValue(value)
300
+ if (captured !== undefined) {
301
+ properties[`$mcp_feedback_${key}`] = captured
302
+ }
303
+ }
304
+ return properties
305
+ }
306
+
307
+ /**
308
+ * The default acknowledgement returned to the agent after it calls
309
+ * `send_feedback`. Reply with this from your dispatcher (custom dispatcher
310
+ * path); the `instrument()` path returns it automatically, or the string your
311
+ * `onFeedback` handler returned instead.
312
+ */
313
+ export function sendFeedbackResult(): CompatibleTextToolResult {
314
+ return {
315
+ content: [
316
+ {
317
+ type: 'text' as const,
318
+ text: "Your feedback was recorded for the server's developers. No additional tools are available - continue with the tools already listed.",
319
+ },
320
+ ],
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Runs the host's `onFeedback` handler (when configured) and builds the reply.
326
+ * A returned non-blank string replaces the default acknowledgement; a thrown
327
+ * handler is logged and falls back to it — feedback capture must never break
328
+ * the agent's turn.
329
+ */
330
+ export async function handleFeedback(
331
+ report: FeedbackReport,
332
+ options: CollectFeedbackOptions = {},
333
+ logger: LoggerFn = log
334
+ ): Promise<CompatibleTextToolResult> {
335
+ // Only the type: the summary is agent-narrated free text (possible PII,
336
+ // newlines for log forging, unbounded length) and does not belong in host logs.
337
+ logger(`Agent feedback reported (${report.feedbackType})`)
338
+ if (options.onFeedback) {
339
+ try {
340
+ const reply = await options.onFeedback(report)
341
+ if (typeof reply === 'string' && reply.trim()) {
342
+ return { content: [{ type: 'text' as const, text: reply }] }
343
+ }
344
+ } catch (error) {
345
+ logger(`Warning: onFeedback handler threw; returning the default acknowledgement - ${error}`)
346
+ }
347
+ }
348
+ return sendFeedbackResult()
349
+ }
@@ -11,6 +11,15 @@ import type {
11
11
  RegisteredTool,
12
12
  ToolCallback,
13
13
  } from '../types'
14
+ import {
15
+ buildFeedbackEventProperties,
16
+ buildFeedbackIntent,
17
+ getFeedbackToolDescriptor,
18
+ handleFeedback,
19
+ parseFeedbackReport,
20
+ resolveCollectFeedbackOptions,
21
+ SEND_FEEDBACK_TOOL_NAME,
22
+ } from './feedback'
14
23
  import {
15
24
  analyticsOwnsParameter,
16
25
  getAnalyticsParameterOwnership,
@@ -22,10 +31,11 @@ import { MCPAnalyticsEventType } from './event-types'
22
31
  import { getServerTrackingData } from './internal'
23
32
  import type { LoggerFn } from './logger'
24
33
  import { createWrappedTool, getToolFunction, hasToolFunction } from './mcp-sdk-compat'
25
- import { handleReportMissing, resolveMissingCapabilityToolName } from './tools'
34
+ import { getReportMissingToolDescriptor, handleReportMissing, resolveMissingCapabilityToolName } from './tools'
26
35
  import {
27
36
  handleInitializeRequest,
28
37
  handleListToolsRequest,
38
+ traceResourceRequest,
29
39
  patchRequestHandlers,
30
40
  captureToolCall,
31
41
  getVirtualToolParameterOwnership,
@@ -250,11 +260,39 @@ async function handleToolCallRequest(
250
260
  extra,
251
261
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
252
262
  explicitContextIntent: context,
253
- parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
263
+ parameterOwnership: getVirtualToolParameterOwnership(
264
+ data,
265
+ toolName,
266
+ getReportMissingToolDescriptor(toolName).inputSchema
267
+ ),
254
268
  execute: async () => handleReportMissing({ context }, data.logger),
255
269
  })
256
270
  }
257
271
 
272
+ const feedbackOptions = resolveCollectFeedbackOptions(data.options.collectFeedback)
273
+ const isFeedbackCandidate =
274
+ feedbackOptions !== undefined && toolName === (feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME)
275
+
276
+ if (isFeedbackCandidate && (await isToolAdvertised(server, toolName, extra, data.logger)) === false) {
277
+ const report = parseFeedbackReport(request.params?.arguments, feedbackOptions)
278
+ return await captureToolCall({
279
+ server,
280
+ data,
281
+ request,
282
+ extra,
283
+ eventType: MCPAnalyticsEventType.mcpFeedback,
284
+ explicitContextIntent: buildFeedbackIntent(report),
285
+ omitCapturedParameters: true,
286
+ extraEventProperties: buildFeedbackEventProperties(report),
287
+ parameterOwnership: getVirtualToolParameterOwnership(
288
+ data,
289
+ toolName,
290
+ getFeedbackToolDescriptor(feedbackOptions).inputSchema
291
+ ),
292
+ execute: async () => handleFeedback(report, feedbackOptions, data.logger),
293
+ })
294
+ }
295
+
258
296
  const registeredTool = toolName ? highLevelServer._registeredTools[toolName] : undefined
259
297
 
260
298
  // Strip SDK-owned arguments before the MCP SDK validates the registered Zod
@@ -291,6 +329,11 @@ export function instrumentHighLevelServer(server: HighLevelMCPServerLike, logger
291
329
  handleListToolsRequest(trackedServer, originalHandler, request, extra, logger),
292
330
  'tools/call': (trackedServer, originalHandler, request, extra) =>
293
331
  handleToolCallRequest(server, trackedServer, originalHandler, request, extra, logger),
332
+ 'resources/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
333
+ // Both listings publish `$mcp_resources_list`; the captured
334
+ // `request.method` is what tells a static listing from a templated one.
335
+ 'resources/templates/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
336
+ 'resources/read': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesRead, logger),
294
337
  }
295
338
  patchRequestHandlers(lowLevelServer, handlers)
296
339
 
@@ -4,13 +4,23 @@
4
4
  // Licensed under the MIT License: https://github.com/agentcathq/agentcat-typescript-sdk/blob/main/LICENSE
5
5
 
6
6
  import type { CompatibleRequestHandlerExtra, MCPRequestLike, MCPServerLike } from '../types'
7
+ import {
8
+ buildFeedbackEventProperties,
9
+ buildFeedbackIntent,
10
+ getFeedbackToolDescriptor,
11
+ handleFeedback,
12
+ parseFeedbackReport,
13
+ resolveCollectFeedbackOptions,
14
+ SEND_FEEDBACK_TOOL_NAME,
15
+ } from './feedback'
7
16
  import { MCPAnalyticsEventType } from './event-types'
8
17
  import { getServerTrackingData } from './internal'
9
18
  import type { LoggerFn } from './logger'
10
- import { handleReportMissing, resolveMissingCapabilityToolName } from './tools'
19
+ import { getReportMissingToolDescriptor, handleReportMissing, resolveMissingCapabilityToolName } from './tools'
11
20
  import {
12
21
  handleInitializeRequest,
13
22
  handleListToolsRequest,
23
+ traceResourceRequest,
14
24
  patchRequestHandlers,
15
25
  registerFallbackRequestHandler,
16
26
  captureToolCall,
@@ -27,9 +37,10 @@ type MCPRequest = Parameters<MCPRequestHandler>[0]
27
37
  type MCPRequestExtra = Parameters<MCPRequestHandler>[1]
28
38
 
29
39
  /**
30
- * Instruments a low-level `Server`: wraps `initialize`, `tools/list`, and
31
- * `tools/call`. The tool-call lifecycle is delegated to {@link captureToolCall},
32
- * shared with the high-level wrapper.
40
+ * Instruments a low-level `Server`: wraps `initialize`, `tools/list`,
41
+ * `tools/call`, `resources/list`, `resources/templates/list`, and
42
+ * `resources/read`. The tool-call lifecycle is delegated to
43
+ * {@link captureToolCall}, shared with the high-level wrapper.
33
44
  */
34
45
  export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn): void {
35
46
  try {
@@ -42,6 +53,11 @@ export function instrumentLowLevelServer(server: MCPServerLike, logger: LoggerFn
42
53
  'tools/list': (server, originalHandler, request, extra) =>
43
54
  handleListToolsRequest(server, originalHandler, request, extra, logger),
44
55
  'tools/call': traceToolCall,
56
+ 'resources/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
57
+ // Both listings publish `$mcp_resources_list`; the captured
58
+ // `request.method` is what tells a static listing from a templated one.
59
+ 'resources/templates/list': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesList, logger),
60
+ 'resources/read': traceResourceRequest(MCPAnalyticsEventType.mcpResourcesRead, logger),
45
61
  }
46
62
  patchRequestHandlers(server, handlers)
47
63
 
@@ -91,11 +107,39 @@ async function handleToolCallRequest(
91
107
  extra,
92
108
  eventType: MCPAnalyticsEventType.mcpMissingCapability,
93
109
  explicitContextIntent: context,
94
- parameterOwnership: getVirtualToolParameterOwnership(data, toolName),
110
+ parameterOwnership: getVirtualToolParameterOwnership(
111
+ data,
112
+ toolName,
113
+ getReportMissingToolDescriptor(toolName).inputSchema
114
+ ),
95
115
  execute: async () => handleReportMissing({ context }, data.logger),
96
116
  })
97
117
  }
98
118
 
119
+ const feedbackOptions = resolveCollectFeedbackOptions(data.options.collectFeedback)
120
+ const isFeedbackCandidate =
121
+ feedbackOptions !== undefined && toolName === (feedbackOptions.toolName ?? SEND_FEEDBACK_TOOL_NAME)
122
+
123
+ if (isFeedbackCandidate && (await isToolAdvertised(server, toolName, extra, data.logger)) === false) {
124
+ const report = parseFeedbackReport(request.params?.arguments, feedbackOptions)
125
+ return await captureToolCall({
126
+ server,
127
+ data,
128
+ request,
129
+ extra,
130
+ eventType: MCPAnalyticsEventType.mcpFeedback,
131
+ explicitContextIntent: buildFeedbackIntent(report),
132
+ omitCapturedParameters: true,
133
+ extraEventProperties: buildFeedbackEventProperties(report),
134
+ parameterOwnership: getVirtualToolParameterOwnership(
135
+ data,
136
+ toolName,
137
+ getFeedbackToolDescriptor(feedbackOptions).inputSchema
138
+ ),
139
+ execute: async () => handleFeedback(report, feedbackOptions, data.logger),
140
+ })
141
+ }
142
+
99
143
  return await captureToolCall({
100
144
  server,
101
145
  data,