diff --git a/README.md b/README.md index ce55868..f9bd672 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (18 model-facing, plus 1 app-only helper) +## Tools (19 model-facing, plus 1 app-only helper) Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows. @@ -288,6 +288,7 @@ Call `get_connection_context` before deciding whether to create or select a proj - `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. +- `submit_feedback` - send product, mcp, or documentation feedback directly to the KERNEL team without interrupting the current task. - `open_auth_login` - Open a secure interactive Managed Auth MCP App after user consent. Registered only for clients that declare MCP Apps support; credentials and MFA never enter MCP/model traffic. ## Resources diff --git a/src/lib/mcp/analytics-context.ts b/src/lib/mcp/analytics-context.ts new file mode 100644 index 0000000..12fb55c --- /dev/null +++ b/src/lib/mcp/analytics-context.ts @@ -0,0 +1,6 @@ +export const MCP_INTENT_ARGUMENT_DESCRIPTION = + "Why this tool is being called and how it fits the user's overall goal, in 15-25 words, " + + "third person. Used for product analytics. Never restate argument values, and never " + + "include credentials, tokens, URLs, file contents, or personal data. Example: " + + '"Inspecting a running browser session to diagnose a checkout automation that stopped ' + + 'responding partway through the flow."'; diff --git a/src/lib/mcp/analytics.test.ts b/src/lib/mcp/analytics.test.ts index 15182b3..be1f5a7 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -2,11 +2,15 @@ import { describe, expect, test } from "bun:test"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { PostHog } from "posthog-node"; import { + encodeSessionId, + MCP_SESSION_HEADER, PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty, + type McpAnalytics, } from "@posthog/mcp"; import { captureMcpConnectionScopeFailure, + captureMcpFeedback, captureOAuthTokenExchange, clientCapabilityAnalyticsFromInitialize, enrichMcpAnalyticsEvent, @@ -19,11 +23,14 @@ import { MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY, MCP_CLIENT_SUPPORTS_TASKS_PROPERTY, MCP_CONNECTION_SCOPE_FAILURE_EVENT, + MCP_FEEDBACK_SUBMITTED_EVENT, MCP_USED_PROJECT_ID_PROPERTY, MCP_USED_PROJECT_PROPERTY, OAUTH_TOKEN_EXCHANGE_EVENT, sanitizeMcpAnalyticsEvent, } from "@/lib/mcp/analytics"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { KERNEL_FEEDBACK_TOOL_NAME } from "@/lib/mcp/tools/feedback"; const privateContextProperty = "__mcp_connection_analytics_context"; @@ -548,9 +555,110 @@ describe("captureMcpConnectionScopeFailure", () => { }); }); +describe("captureMcpFeedback", () => { + test("routes redacted feedback through contextual MCP analytics", async () => { + const captured: unknown[] = []; + const analytics = { + capture: async (event: unknown) => { + captured.push(event); + }, + } as McpAnalytics; + + await captureMcpFeedback( + { + summary: "Browser timeout guidance was unclear", + feedback_type: "product", + sentiment: "mixed", + product_area: "browsers", + task_completed: true, + tools_used: ["manage_browsers"], + friction_points: "- The response did not say when to retry.", + suggested_improvement: "Include a retry interval in the response.", + details: + "The error linked to https://example.com/support for user@example.com.", + }, + { + authInfo: { + extra: { + connectionContext: { + scope: { organizationId: "org_analytics" }, + }, + }, + }, + }, + analytics, + ); + + expect(captured).toEqual([ + { + event: MCP_FEEDBACK_SUBMITTED_EVENT, + properties: { + $groups: { organization: "org_analytics" }, + feedback_summary: "Browser timeout guidance was unclear", + feedback_type: "product", + feedback_sentiment: "mixed", + feedback_product_area: "browsers", + feedback_category: undefined, + feedback_task_completed: true, + feedback_tools_used: ["manage_browsers"], + feedback_friction_points: "- The response did not say when to retry.", + feedback_suggested_improvement: + "Include a retry interval in the response.", + feedback_user_request: undefined, + feedback_details: "The error linked to [url] for [email]", + }, + }, + ]); + }); +}); + describe("instrumentMcpAnalytics (SDK integration)", () => { const ORG = "org_integration"; + test("keeps the feedback tool schema stable when analytics is disabled", async () => { + const disabled = await connectTestMcp( + (server) => instrumentMcpAnalytics(server, null), + {}, + ); + const enabled = await connectTestMcp( + (server) => + instrumentMcpAnalytics(server, { + capture: () => undefined, + } as unknown as PostHog), + {}, + ); + + try { + const disabledTool = (await disabled.client.listTools()).tools.find( + ({ name }) => name === KERNEL_FEEDBACK_TOOL_NAME, + ); + const enabledTool = (await enabled.client.listTools()).tools.find( + ({ name }) => name === KERNEL_FEEDBACK_TOOL_NAME, + ); + expect(disabledTool).toBeDefined(); + expect(disabledTool?.inputSchema).toEqual(enabledTool?.inputSchema); + expect(disabledTool?.inputSchema.required).toContain("context"); + + const result = await disabled.client.callTool({ + name: KERNEL_FEEDBACK_TOOL_NAME, + arguments: { + context: + "Reporting product feedback while analytics delivery is unavailable for this server instance.", + summary: "Feedback analytics are unavailable", + feedback_type: "mcp", + sentiment: "negative", + }, + }); + expect(toolResultJSON(result)).toMatchObject({ + recorded: false, + status: "unavailable", + }); + } finally { + await disabled.close(); + await enabled.close(); + } + }); + // mcp-handler builds a fresh McpServer per HTTP request, so each simulated request // gets its own instrumented server and the SDK's per-session identity cache starts // cold — this is exactly the deployed topology. @@ -581,7 +689,16 @@ describe("instrumentMcpAnalytics (SDK integration)", () => { extra: { connectionContext: { scope: { organizationId: ORG } } }, }, signal: new AbortController().signal, - requestInfo: { headers: {} }, + requestInfo: { + headers: { + [MCP_SESSION_HEADER]: encodeSessionId({ + sessionId: "ses_integration", + clientName: "test-client", + clientVersion: "0.0.0", + protocolVersion: "2025-03-26", + }), + }, + }, }; const handlers = ( server.server as unknown as { @@ -657,6 +774,51 @@ describe("instrumentMcpAnalytics (SDK integration)", () => { expect(byEvent.has("$identify")).toBe(false); }); + test("captures feedback with the surrounding MCP session metadata", async () => { + const captured: { event?: string }[] = []; + + await simulateRequest(captured, "tools/call", { + name: KERNEL_FEEDBACK_TOOL_NAME, + arguments: { + context: + "Reporting that browser timeout guidance did not explain when the caller should retry.", + summary: "Browser timeout guidance was unclear", + feedback_type: "product", + sentiment: "mixed", + product_area: "browsers", + }, + }); + + const feedback = captured.find( + ({ event }) => event === MCP_FEEDBACK_SUBMITTED_EVENT, + ) as { distinctId: string; properties: Record }; + const toolCall = captured.find( + ({ event }) => event === "$mcp_tool_call", + ) as { + distinctId: string; + properties: Record; + }; + + expect(feedback.distinctId).toBe("ses_integration"); + expect(feedback.distinctId).toBe(toolCall.distinctId); + expect(feedback.properties).toMatchObject({ + $groups: { organization: ORG }, + [PostHogMCPAnalyticsProperty.SessionId]: "ses_integration", + [PostHogMCPAnalyticsProperty.ClientName]: "test-client", + [PostHogMCPAnalyticsProperty.ClientVersion]: "0.0.0", + [PostHogMCPAnalyticsProperty.ProtocolVersion]: "2025-03-26", + [PostHogMCPAnalyticsProperty.ServerName]: "test", + [PostHogMCPAnalyticsProperty.ServerVersion]: "0.0.0", + feedback_summary: "Browser timeout guidance was unclear", + feedback_type: "product", + feedback_sentiment: "mixed", + feedback_product_area: "browsers", + }); + expect(toolCall.properties[PostHogMCPAnalyticsProperty.Intent]).toBe( + "Reporting that browser timeout guidance did not explain when the caller should retry.", + ); + }); + test("stays anonymous when no connection context is attached", async () => { const captured: { event?: string }[] = []; const server = makeServer(captured); diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index f7eb399..a2dad93 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -4,6 +4,7 @@ import { PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty, type BeforeSendFn, + type McpAnalytics, } from "@posthog/mcp"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { PostHog } from "posthog-node"; @@ -12,6 +13,11 @@ import type { McpConnectionAnalyticsContext, McpConnectionContext, } from "@/lib/mcp/auth-context"; +import { MCP_INTENT_ARGUMENT_DESCRIPTION } from "@/lib/mcp/analytics-context"; +import { + type KernelFeedback, + registerFeedbackTool, +} from "@/lib/mcp/tools/feedback"; import { clientDeclaresExtension, clientElicitationModes, @@ -60,6 +66,7 @@ export type McpConnectionScopeFailureAnalytics = { export const MCP_CONNECTION_SCOPE_FAILURE_EVENT = "mcp_connection_scope_failure"; +export const MCP_FEEDBACK_SUBMITTED_EVENT = "mcp_feedback_submitted"; if (!projectToken && process.env.NODE_ENV !== "production") { console.error( @@ -153,15 +160,19 @@ const SENT_PROPERTIES = new Set([ PostHogMCPAnalyticsProperty.ToolCategory, PostHogMCPAnalyticsProperty.ToolDescription, PostHogMCPAnalyticsProperty.ToolName, + "feedback_summary", + "feedback_type", + "feedback_sentiment", + "feedback_product_area", + "feedback_category", + "feedback_task_completed", + "feedback_tools_used", + "feedback_friction_points", + "feedback_suggested_improvement", + "feedback_user_request", + "feedback_details", ]); -const INTENT_ARGUMENT_DESCRIPTION = - "Why this tool is being called and how it fits the user's overall goal, in 15-25 words, " + - "third person. Used for product analytics. Never restate argument values, and never " + - "include credentials, tokens, URLs, file contents, or personal data. Example: " + - '"Inspecting a running browser session to diagnose a checkout automation that stopped ' + - 'responding partway through the flow."'; - // Intent is the only free-form text this captures, and an agent writes it. Long enough for // the 15-25 words asked for, short enough that a client ignoring the instruction can't // stream a payload or a prompt into an event property. @@ -262,13 +273,16 @@ function annotateProjectParamUsage(properties: Record) { properties[MCP_USED_PROJECT_PROPERTY] = hasNonEmptyParam(args, "project"); } -function sanitizeIntent(intent: string) { - const redacted = INTENT_REDACTIONS.reduce( - (text, [pattern, replacement]) => text.replace(pattern, replacement), - intent.trim(), +function redactAnalyticsText(text: string) { + return INTENT_REDACTIONS.reduce( + (redacted, [pattern, replacement]) => + redacted.replace(pattern, replacement), + text.trim(), ); +} - return redacted.slice(0, INTENT_MAX_LENGTH); +function sanitizeIntent(intent: string) { + return redactAnalyticsText(intent).slice(0, INTENT_MAX_LENGTH); } // Must stay the SDK's default name: reportMissing advertises a tool under this name and @@ -355,11 +369,7 @@ export const sanitizeMcpAnalyticsEvent: BeforeSendFn = (event) => { return event; }; -/** - * Captures every tool call, tools/list, and initialize handled by the server as a - * `$mcp_*` PostHog event, and advertises the tool agents use to report a capability the - * server doesn't have. No-op when POSTHOG_PROJECT_TOKEN is unset. - */ +/** Extracts the analytics identity resolved during MCP authentication. */ function connectionAnalyticsContext(extra: unknown) { const authInfo = (extra as { authInfo?: { extra?: unknown } } | undefined) ?.authInfo; @@ -381,6 +391,22 @@ function connectionOrgId(extra: unknown) { return authExtra?.connectionContext?.scope.organizationId; } +export function captureMcpCustomEvent( + analytics: McpAnalytics, + extra: unknown, + event: string, + properties: Record, +) { + const organizationId = connectionOrgId(extra); + return analytics.capture({ + event, + properties: { + ...properties, + ...(organizationId && { $groups: { organization: organizationId } }), + }, + }); +} + export function enrichMcpAnalyticsEvent(event: { event: string; distinct_id: string; @@ -486,13 +512,50 @@ export function captureMcpConnectionScopeFailure( } } +export function captureMcpFeedback( + feedback: KernelFeedback, + extra: unknown, + analytics: McpAnalytics, +) { + return captureMcpCustomEvent(analytics, extra, MCP_FEEDBACK_SUBMITTED_EVENT, { + feedback_summary: redactAnalyticsText(feedback.summary), + feedback_type: feedback.feedback_type, + feedback_sentiment: feedback.sentiment, + feedback_product_area: feedback.product_area + ? redactAnalyticsText(feedback.product_area) + : undefined, + feedback_category: feedback.category, + feedback_task_completed: feedback.task_completed, + feedback_tools_used: feedback.tools_used?.map(redactAnalyticsText), + feedback_friction_points: feedback.friction_points + ? redactAnalyticsText(feedback.friction_points) + : undefined, + feedback_suggested_improvement: feedback.suggested_improvement + ? redactAnalyticsText(feedback.suggested_improvement) + : undefined, + feedback_user_request: feedback.user_request + ? redactAnalyticsText(feedback.user_request) + : undefined, + feedback_details: feedback.details + ? redactAnalyticsText(feedback.details) + : undefined, + }); +} + +/** + * Captures MCP protocol analytics and registers the analytics-backed reporting tools. + * Feedback remains available when analytics is disabled so the tool contract is stable. + */ export function instrumentMcpAnalytics( server: McpServer, client: PostHog | null = posthog, ) { - if (!client) return; + if (!client) { + registerFeedbackTool(server); + return; + } - instrument(server, client, { + const analytics = instrument(server, client, { // Records a `$mcp_missing_capability` event, carrying the reported gap as $mcp_intent, // when an agent calls the tool registered by registerMissingCapabilityTool. reportMissing: true, @@ -501,7 +564,7 @@ export function instrumentMcpAnalytics( // with why it is making the call. Recorded as $mcp_intent. The description replaces // the SDK default: it repeats per tool in every tools/list response, so it stays // short, and it names the arguments agents must not copy into it. - context: { description: INTENT_ARGUMENT_DESCRIPTION }, + context: { description: MCP_INTENT_ARGUMENT_DESCRIPTION }, // A failed tool call otherwise fans out into a second `$exception` event whose // `$exception_list` is built from the text the tool returned. enableExceptionAutocapture: false, @@ -541,6 +604,9 @@ export function instrumentMcpAnalytics( }); registerMissingCapabilityTool(server); + registerFeedbackTool(server, (feedback, extra) => + captureMcpFeedback(feedback, extra, analytics), + ); } /** diff --git a/src/lib/mcp/tools/feedback.test.ts b/src/lib/mcp/tools/feedback.test.ts new file mode 100644 index 0000000..fb25afd --- /dev/null +++ b/src/lib/mcp/tools/feedback.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { + KERNEL_FEEDBACK_TOOL_NAME, + type KernelFeedback, + registerFeedbackTool, +} from "@/lib/mcp/tools/feedback"; + +describe("submit_feedback", () => { + test("advertises the feedback schema and records a submission", async () => { + const captured: KernelFeedback[] = []; + const { client, close } = await connectTestMcp( + (server) => + registerFeedbackTool(server, (feedback) => { + captured.push(feedback); + }), + {}, + ); + + try { + const tools = await client.listTools(); + const tool = tools.tools.find( + ({ name }) => name === KERNEL_FEEDBACK_TOOL_NAME, + ); + expect(tool?.title).toBe("submit KERNEL feedback"); + expect(tool?.annotations?.readOnlyHint).toBe(false); + expect(tool?.inputSchema.required).toEqual([ + "context", + "summary", + "feedback_type", + "sentiment", + ]); + + const result = await client.callTool({ + name: KERNEL_FEEDBACK_TOOL_NAME, + arguments: { + context: + "Reporting that browser creation timeout responses did not explain when callers should retry.", + summary: "Browser creation needs clearer timeout guidance", + feedback_type: "product", + sentiment: "mixed", + product_area: "browsers", + friction_points: "- The timeout response did not suggest a retry.", + suggested_improvement: + "Include retry timing in browser creation timeout responses.", + task_completed: true, + tools_used: ["manage_browsers"], + }, + }); + + expect(toolResultJSON(result)).toMatchObject({ + recorded: true, + status: "recorded", + summary: "Browser creation needs clearer timeout guidance", + feedback_type: "product", + sentiment: "mixed", + }); + expect(captured).toEqual([ + { + summary: "Browser creation needs clearer timeout guidance", + feedback_type: "product", + sentiment: "mixed", + product_area: "browsers", + friction_points: "- The timeout response did not suggest a retry.", + suggested_improvement: + "Include retry timing in browser creation timeout responses.", + task_completed: true, + tools_used: ["manage_browsers"], + }, + ]); + } finally { + await close(); + } + }); + + test("keeps analytics failures from failing the tool call", async () => { + const { client, close } = await connectTestMcp( + (server) => + registerFeedbackTool(server, () => { + throw new Error("analytics unavailable"); + }), + {}, + ); + + try { + const result = await client.callTool({ + name: KERNEL_FEEDBACK_TOOL_NAME, + arguments: { + context: + "Reporting that the MCP response directly supported the user's task without additional parsing.", + summary: "The MCP response was easy to use", + feedback_type: "mcp", + sentiment: "positive", + }, + }); + + expect(toolResultJSON(result)).toMatchObject({ + recorded: false, + status: "failed", + summary: "The MCP response was easy to use", + message: expect.stringContaining("was not recorded"), + }); + } finally { + await close(); + } + }); + + test("reports when feedback analytics are unavailable", async () => { + const { client, close } = await connectTestMcp( + (server) => registerFeedbackTool(server), + {}, + ); + + try { + const result = await client.callTool({ + name: KERNEL_FEEDBACK_TOOL_NAME, + arguments: { + context: + "Reporting product feedback while analytics delivery is unavailable for this server instance.", + summary: "Browser feedback could not be delivered", + feedback_type: "product", + sentiment: "negative", + }, + }); + + expect(toolResultJSON(result)).toMatchObject({ + recorded: false, + status: "unavailable", + summary: "Browser feedback could not be delivered", + message: expect.stringContaining("was not recorded"), + }); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/mcp/tools/feedback.ts b/src/lib/mcp/tools/feedback.ts new file mode 100644 index 0000000..3b6e0fa --- /dev/null +++ b/src/lib/mcp/tools/feedback.ts @@ -0,0 +1,167 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { MCP_INTENT_ARGUMENT_DESCRIPTION } from "@/lib/mcp/analytics-context"; +import { jsonResponse } from "@/lib/mcp/responses"; + +export const KERNEL_FEEDBACK_TOOL_NAME = "submit_feedback"; + +const feedbackFields = { + context: z.string().describe(MCP_INTENT_ARGUMENT_DESCRIPTION), + summary: z + .string() + .trim() + .min(1) + .max(300) + .describe( + 'a one-sentence headline capturing the feedback (e.g. "browser creation timed out without recovery guidance", "manage_browsers returned exactly the context needed", or "the proxy docs need a residential example").', + ), + feedback_type: z + .enum(["product", "mcp", "docs", "other"]) + .describe( + 'what this feedback is about. "product" = any KERNEL product or feature, such as browsers, apps, profiles, proxies, browser pools, replays, telemetry, managed auth, credentials, extensions, projects, or api keys. "mcp" = this mcp server itself, including a tool, input schema, response format, error, or its instructions. "docs" = KERNEL documentation. "other" = anything that does not fit the other types.', + ), + sentiment: z + .enum(["positive", "neutral", "negative", "mixed"]) + .describe( + 'the overall tone. use "negative" for something broken or blocking, "mixed" for mostly fine with a concrete problem, "neutral" for a suggestion or feature request with no strong sentiment, and "positive" for praise or something that worked well. all sentiments are welcome.', + ), + product_area: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe( + 'the KERNEL product or area this is about, in free text (e.g. "browsers", "apps", "managed auth", "browser pools", "proxies", or "telemetry"). most useful for product feedback; for mcp feedback put the tool name in `details` or `friction_points` instead.', + ), + category: z + .enum([ + "tool_correctness", + "tool_description", + "tool_input_schema", + "tool_output_format", + "missing_tool", + "instructions_clarity", + "performance", + "error_message", + "other", + ]) + .optional() + .describe( + 'for mcp feedback (`feedback_type: "mcp"`) only: the single category that best describes the dominant theme. use "missing_tool" when a capability is absent, "tool_description" when tool documentation is unclear, "tool_input_schema" when arguments are confusing, "tool_output_format" when a response is hard to consume, "instructions_clarity" when mcp instructions are unclear, "tool_correctness" when a tool returns wrong data, "error_message" when an error is unhelpful, and "performance" when latency is the issue. omit for product, docs, or other feedback.', + ), + task_completed: z + .boolean() + .optional() + .describe( + 'whether the user\'s task was completed. be honest: `false` is useful signal. most relevant when `feedback_type` is "mcp".', + ), + tools_used: z + .array(z.string().trim().min(1).max(100)) + .max(50) + .optional() + .describe( + 'the mcp tool names called while working on the user\'s task (e.g. ["manage_browsers", "execute_playwright_code"]).', + ), + friction_points: z + .string() + .trim() + .min(1) + .max(5000) + .optional() + .describe( + "clear, concise bullet points describing what was confusing, broken, slow, or missing. quote the exact product surface, tool name, parameter, or error text when possible. omit for purely positive feedback.", + ), + suggested_improvement: z + .string() + .trim() + .min(1) + .max(3000) + .optional() + .describe( + "the single most impactful, concrete change that would address this feedback, when one can be named. optional for praise or observations.", + ), + user_request: z + .string() + .trim() + .min(1) + .max(1000) + .optional() + .describe( + "a short, anonymized paraphrase of what the user originally asked. do not include personal data, customer or account names, target urls, or sensitive browser content.", + ), + details: z + .string() + .trim() + .min(1) + .max(5000) + .optional() + .describe( + "additional context that does not fit the other fields. keep it to clear, concise bullet points.", + ), +}; + +export type KernelFeedback = Omit< + z.infer>, + "context" +>; +export type KernelFeedbackCapture = ( + feedback: KernelFeedback, + extra: unknown, +) => void | Promise; + +const TOOL_DESCRIPTION = + "send feedback about anything KERNEL to the KERNEL team. set `feedback_type` to route it: `product` for any KERNEL product or feature, `mcp` for this mcp server, `docs` for KERNEL documentation, or `other`. all sentiments are welcome through `sentiment`: praise and feature requests are useful, not just problems. use this for confusing or broken experiences, papercuts, missing capabilities, unhelpful errors, feature requests, and things that worked especially well. keep `summary` to one sentence and make the detail fields concise and actionable, quoting the product surface, tool name, parameter, or error text when possible. include a concrete `suggested_improvement` when one is clear. never include credentials, tokens, api keys, urls, browser or page content, customer or account names, or personal data. the user can also ask to send feedback directly. submitting feedback is a side report to KERNEL, not a reason to stop: continue and finish the user's task with the other available tools."; + +const RESPONSE_MESSAGES = { + recorded: + "thank you for the feedback. it has been recorded and will be reviewed by the KERNEL team. " + + "submitting feedback does not mean the current task is done; continue using the other available tools to finish it.", + unavailable: + "feedback analytics are unavailable, so this feedback was not recorded. continue using the other available tools to finish the current task.", + failed: + "feedback capture failed, so this feedback was not recorded. continue using the other available tools to finish the current task.", +} as const; + +type FeedbackCaptureStatus = keyof typeof RESPONSE_MESSAGES; + +export function registerFeedbackTool( + server: McpServer, + capture?: KernelFeedbackCapture, +) { + server.registerTool( + KERNEL_FEEDBACK_TOOL_NAME, + { + title: "submit KERNEL feedback", + description: TOOL_DESCRIPTION, + inputSchema: feedbackFields, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ context: _context, ...feedback }, extra) => { + let status: FeedbackCaptureStatus = "unavailable"; + if (capture) { + try { + await capture(feedback, extra); + status = "recorded"; + } catch { + // Feedback analytics must not block the user's original task. + status = "failed"; + } + } + + return jsonResponse({ + recorded: status === "recorded", + status, + summary: feedback.summary, + feedback_type: feedback.feedback_type, + sentiment: feedback.sentiment, + message: RESPONSE_MESSAGES[status], + }); + }, + ); +}