Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions src/lib/mcp/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -948,6 +953,7 @@ export function captureMcpFeedback(
export function instrumentMcpAnalytics(
server: McpServer,
client: PostHog | null = posthog,
dependencies: McpDependencies = defaultMcpDependencies,
) {
if (!client) {
registerMissingCapabilityTool(server);
Expand Down Expand Up @@ -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);
});
}

/**
Expand Down
161 changes: 161 additions & 0 deletions src/lib/mcp/site-report-forwarder.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
88 changes: 88 additions & 0 deletions src/lib/mcp/site-report-forwarder.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
}
Loading