diff --git a/README.md b/README.md index 89cb36a..de0de52 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ For reconnect_required wallets share the new enrollment action; degraded wallets - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. - `get_more_tools` - Report a structured KERNEL capability, external-integration gap, or missing site-specific WebMCP action after checking available tools. For site actions, list `webmcp` tools first; use `site_tool_missing` with `capability_area: "webmcp"`, an optional public registrable `site_domain`, and a generic action name. Site requests are routed separately as `webmcp_catalog_demand`; reporting does not install a tool, so continue with Playwright when possible. Existing-tool failures, transient capacity errors, and client permission restrictions are rejected from capability-demand analytics. Accepted requests emit `mcp_capability_requested`; clients using the previous context-only schema receive a non-recording refresh response instead of a tool error. -- `submit_feedback` - Send product, site-compatibility, config-registry, MCP, or documentation feedback directly to the KERNEL team without interrupting the current task. Reports include a normalized task outcome; MCP reports identify one KERNEL-owned tool. Config-registry reports connect exactly one observed outcome to the browser session, recommendation metadata and evidence, and unchanged browser and proxy settings. +- `submit_feedback` - Send product, site-compatibility, config-registry, MCP, or documentation feedback directly to the KERNEL team without interrupting the current task. Reports include a normalized task outcome; MCP reports identify one KERNEL-owned tool. Config-registry reports connect exactly one observed outcome to the browser session, recommendation metadata and evidence, and unchanged browser and proxy settings. Site-compatibility and config-registry reports that name a browser session are also sent to the KERNEL API as site reports for that session. - `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. ### WebMCP actions diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 74e6795..6f08d6c 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -25,6 +25,11 @@ import { DEPRECATED_TOOL_PARAMS, deprecatedParamsUsed, } from "@/lib/mcp/deprecated-params"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; +import { forwardSiteReport } from "@/lib/mcp/site-report-forwarder"; import { type KernelFeedback, LEGACY_SITE_COMPATIBILITY_FEEDBACK_TYPE, @@ -948,6 +953,7 @@ export function captureMcpFeedback( export function instrumentMcpAnalytics( server: McpServer, client: PostHog | null = posthog, + dependencies: McpDependencies = defaultMcpDependencies, ) { if (!client) { registerMissingCapabilityTool(server); @@ -1031,9 +1037,10 @@ export function instrumentMcpAnalytics( beforeSend: sanitizeMcpAnalyticsEvent, }); - registerFeedbackTool(server, (feedback, extra) => - captureMcpFeedback(feedback, extra, analytics), - ); + registerFeedbackTool(server, async (feedback, extra) => { + await forwardSiteReport(feedback, extra, dependencies); + captureMcpFeedback(feedback, extra, analytics); + }); } /** diff --git a/src/lib/mcp/site-report-forwarder.test.ts b/src/lib/mcp/site-report-forwarder.test.ts new file mode 100644 index 0000000..6ccc1b4 --- /dev/null +++ b/src/lib/mcp/site-report-forwarder.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import type { KernelClient } from "@/lib/mcp/kernel-client"; +import { + forwardSiteReport, + siteReportFromFeedback, +} from "@/lib/mcp/site-report-forwarder"; +import type { KernelFeedback } from "@/lib/mcp/tools/feedback"; + +const siteFeedback: KernelFeedback = { + summary: "checkout blocked by an access denied page", + feedback_type: "site_compatibility", + sentiment: "negative", + task_outcome: "blocked", + task_completed: false, + friction_points: "- the page showed an access denied reference id", + user_request: "add an item to the cart and open checkout", + site_compatibility: { + registrable_domain: "example.com", + observed_outcome: "blocked", + access_provider: "akamai", + challenge_type: "access_denied", + reproducibility: "consistent", + browser_session_id: "sess_123", + }, +}; + +function fakeDependencies(calls: { path: string; body: unknown }[]) { + const tokens: string[] = []; + const dependencies: McpDependencies = { + createKernelClient: (token) => { + tokens.push(token); + return { + post: async (path: string, opts?: { body?: unknown }) => { + calls.push({ path, body: opts?.body }); + return {}; + }, + } as unknown as KernelClient; + }, + }; + return { dependencies, tokens }; +} + +describe("siteReportFromFeedback", () => { + test("maps site-compatibility feedback with a session onto a site report", () => { + expect(siteReportFromFeedback(siteFeedback)).toEqual({ + browserSessionId: "sess_123", + request: { + url: "https://example.com/", + outcome: "blocked", + challenge_type: "access_denied", + access_provider: "akamai", + reproducibility: "consistent", + task: "add an item to the cart and open checkout", + notes: + "checkout blocked by an access denied page\n- the page showed an access denied reference id", + config_source: "custom", + }, + }); + }); + + test("attributes config registry feedback to the analysis", () => { + const report = siteReportFromFeedback({ + ...siteFeedback, + feedback_type: "config_registry", + site_compatibility: { + ...siteFeedback.site_compatibility!, + reproducibility: "unknown", + }, + config_registry: { + request_method: "resolve", + analysis_id: "analysis_1", + recommendation_evidence: { sample_size: 3, success_rate: 1 }, + applied_browser: { + stealth: true, + headless: false, + gpu: false, + viewport: { width: 1280, height: 720 }, + }, + applied_proxy: { mode: "direct" }, + }, + }); + expect(report?.request.config_source).toBe("config_registry"); + expect(report?.request.config_registry_analysis_id).toBe("analysis_1"); + expect(report?.request.reproducibility).toBeUndefined(); + }); + + test("returns null without a session or site outcome", () => { + expect( + siteReportFromFeedback({ + ...siteFeedback, + site_compatibility: { + ...siteFeedback.site_compatibility!, + browser_session_id: undefined, + }, + }), + ).toBeNull(); + expect( + siteReportFromFeedback({ + summary: "manage_browsers worked well", + feedback_type: "product", + sentiment: "positive", + product_area: "browsers", + }), + ).toBeNull(); + }); +}); + +describe("forwardSiteReport", () => { + test("posts the report with the caller's token", async () => { + const calls: { path: string; body: unknown }[] = []; + const { dependencies, tokens } = fakeDependencies(calls); + + await forwardSiteReport( + siteFeedback, + { http: { authInfo: { token: "kernel-token" } } }, + dependencies, + ); + + expect(tokens).toEqual(["kernel-token"]); + expect(calls).toHaveLength(1); + expect(calls[0].path).toBe("/browsers/sess_123/site-reports"); + expect(calls[0].body).toMatchObject({ + url: "https://example.com/", + outcome: "blocked", + }); + }); + + test("skips feedback without a token or a session", async () => { + const calls: { path: string; body: unknown }[] = []; + const { dependencies } = fakeDependencies(calls); + + await forwardSiteReport(siteFeedback, {}, dependencies); + await forwardSiteReport( + { ...siteFeedback, site_compatibility: undefined }, + { http: { authInfo: { token: "kernel-token" } } }, + dependencies, + ); + + expect(calls).toHaveLength(0); + }); + + test("swallows api failures", async () => { + const dependencies: McpDependencies = { + createKernelClient: () => + ({ + post: async () => { + throw new Error("boom"); + }, + }) as unknown as KernelClient, + }; + + await expect( + forwardSiteReport( + siteFeedback, + { http: { authInfo: { token: "kernel-token" } } }, + dependencies, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/mcp/site-report-forwarder.ts b/src/lib/mcp/site-report-forwarder.ts new file mode 100644 index 0000000..cfba816 --- /dev/null +++ b/src/lib/mcp/site-report-forwarder.ts @@ -0,0 +1,88 @@ +import type { McpDependencies } from "@/lib/mcp/dependencies"; +import { defaultMcpDependencies } from "@/lib/mcp/dependencies"; +import type { KernelFeedback } from "@/lib/mcp/tools/feedback"; + +const NOTES_MAX_LENGTH = 2000; + +type SiteReportRequest = { + url: string; + outcome: "blocked" | "challenged" | "degraded" | "passed"; + challenge_type?: string; + access_provider?: string; + reproducibility?: "single_observation" | "intermittent" | "consistent"; + task?: string; + notes?: string; + config_source?: "config_registry" | "custom"; + config_registry_analysis_id?: string; +}; + +/** + * Builds the site-report request for feedback that names a browser session. + * Returns null when the feedback has no site outcome to attach to a session. + */ +export function siteReportFromFeedback( + feedback: KernelFeedback, +): { browserSessionId: string; request: SiteReportRequest } | null { + const site = feedback.site_compatibility; + const isSiteOutcome = + feedback.feedback_type === "site_compatibility" || + feedback.feedback_type === "config_registry"; + if (!isSiteOutcome || !site?.browser_session_id) return null; + + const notes = [feedback.summary, feedback.friction_points, feedback.details] + .filter((value): value is string => Boolean(value)) + .join("\n") + .slice(0, NOTES_MAX_LENGTH); + const request: SiteReportRequest = { + // The feedback schema accepts only the registrable domain, so the report + // carries the site root rather than the page that was visited. + url: `https://${site.registrable_domain}/`, + outcome: site.observed_outcome, + notes, + }; + if (site.challenge_type) request.challenge_type = site.challenge_type; + if (site.access_provider) request.access_provider = site.access_provider; + if (site.reproducibility !== "unknown") { + request.reproducibility = site.reproducibility; + } + if (feedback.user_request) request.task = feedback.user_request; + if (feedback.feedback_type === "config_registry") { + request.config_source = "config_registry"; + const analysisId = feedback.config_registry?.analysis_id; + if (analysisId) request.config_registry_analysis_id = analysisId; + } else { + request.config_source = "custom"; + } + return { browserSessionId: site.browser_session_id, request }; +} + +/** + * Sends site-compatibility feedback that names a browser session to the Kernel + * API as a site report, so it lands next to the session's telemetry instead of + * only in analytics. Failures are swallowed: the report is a side channel and + * must not change the feedback tool's result. + */ +export async function forwardSiteReport( + feedback: KernelFeedback, + extra: unknown, + dependencies: McpDependencies = defaultMcpDependencies, +) { + const report = siteReportFromFeedback(feedback); + if (!report) return; + const token = (extra as { http?: { authInfo?: { token?: string } } })?.http + ?.authInfo?.token; + if (!token) return; + try { + await dependencies + .createKernelClient(token) + .post( + `/browsers/${encodeURIComponent(report.browserSessionId)}/site-reports`, + { body: report.request }, + ); + } catch (error) { + console.warn( + "[feedback] site report forwarding failed", + error instanceof Error ? error.message : String(error), + ); + } +}