Skip to content
Merged
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 @@ -354,7 +354,7 @@ See [Vault payments](docs/vault-payments.md) for both provider flows, safety rul
- `webmcp` - List native page tools across every tab and frame in a browser, then synchronously invoke an exact opaque `tool_ref` with structured input.
- `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 or external-integration gap after checking the available tools. 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.
- `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, bot-detection, 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.
- `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.

Expand Down
44 changes: 42 additions & 2 deletions src/lib/mcp/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,11 +1045,12 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {
expect(missingCapabilityTool?.description).toContain(
"after checking the tool list",
);
expect(missingCapabilityTool?.description).toContain("site_tool_missing");
expect(missingCapabilityTool?.description).toContain(
"transient or capacity failure",
"transient capacity failure",
);
expect(missingCapabilityTool?.description).toContain(
"client-side permission restriction",
"client permission restriction",
);
expect(missingCapabilityTool?.inputSchema.required).toEqual([
"context",
Expand Down Expand Up @@ -1321,6 +1322,45 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {
});
});

test("routes site tool demand with only a public domain and distinct dedupe keys", async () => {
const captured: { event?: string }[] = [];
const request = {
name: "get_more_tools",
arguments: {
context:
"The page lacks a reusable structured search action, so the agent is continuing with browser interaction instead.",
gap_reason: "site_tool_missing",
capability_area: "webmcp",
capability: "search available products",
requested_action: "search",
task_outcome: "completed_with_workaround",
tools_checked: ["webmcp"],
site_domain: "example.com",
},
};

await simulateRequest(captured, "tools/call", request);
await simulateRequest(captured, "tools/call", {
...request,
arguments: { ...request.arguments, site_domain: "example.org" },
});

const requests = captured.filter(
({ event }) => event === MCP_CAPABILITY_REQUESTED_EVENT,
) as { properties: Record<string, unknown> }[];
expect(requests).toHaveLength(2);
expect(requests[0]?.properties).toMatchObject({
missing_capability_gap_reason: "site_tool_missing",
missing_capability_destination: "webmcp_catalog_demand",
missing_capability_area: "webmcp",
missing_capability_name: "search available products",
missing_capability_site_domain: "example.com",
});
expect(requests[0]?.properties.missing_capability_dedupe_key).not.toBe(
requests[1]?.properties.missing_capability_dedupe_key,
);
});

test("captures feedback with the surrounding MCP session metadata", async () => {
const captured: { event?: string }[] = [];

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 @@ -219,6 +219,7 @@ const SENT_PROPERTIES = new Set<string>([
"missing_capability_destination",
"missing_capability_area",
"missing_capability_name",
"missing_capability_site_domain",
"missing_capability_requested_action",
"missing_capability_task_outcome",
"missing_capability_tools_checked",
Expand Down Expand Up @@ -467,9 +468,11 @@ export function captureMissingCapabilityReport(
: { value: "", redacted: false };
const capability = redactAnalyticsTextWithStatus(report.capability);
const destination =
report.gap_reason === "kernel_capability_missing"
? "kernel_product_demand"
: "external_integration_demand";
report.gap_reason === "site_tool_missing"
? "webmcp_catalog_demand"
: report.gap_reason === "kernel_capability_missing"
? "kernel_product_demand"
: "external_integration_demand";

return captureMcpCustomEvent(
analytics,
Expand All @@ -483,12 +486,16 @@ export function captureMissingCapabilityReport(
missing_capability_destination: destination,
missing_capability_area: report.capability_area,
missing_capability_name: capability.value,
...(report.site_domain && {
missing_capability_site_domain: report.site_domain,
}),
missing_capability_requested_action: report.requested_action,
missing_capability_task_outcome: report.task_outcome,
missing_capability_tools_checked: report.tools_checked,
missing_capability_dedupe_key: analyticsDedupeKey([
destination,
report.capability_area,
report.site_domain,
report.requested_action,
capability.value.toLowerCase(),
]),
Expand Down
102 changes: 102 additions & 0 deletions src/lib/mcp/tools/missing-capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,108 @@ describe("get_more_tools", () => {
recorded: false,
status: "not_a_capability_gap",
});

for (const gap_reason of [
"existing_tool_failed",
"transient_or_capacity_failure",
"client_permission_restriction",
]) {
const failure = await client.callTool({
name: KERNEL_MISSING_CAPABILITY_TOOL_NAME,
arguments: {
context:
"An existing WebMCP action could not complete, so this is not demand for a missing site action.",
gap_reason,
capability_area: "webmcp",
capability: "search available products",
requested_action: "search",
task_outcome: "blocked",
tools_checked: ["webmcp"],
site_domain: "example.com",
},
});
expect(toolResultJSON(failure)).toMatchObject({
recorded: false,
status: "not_a_capability_gap",
});
if (gap_reason === "existing_tool_failed") {
expect(toolResultJSON(failure).message).toContain("submit_feedback");
}
}
expect(captured).toHaveLength(1);
} finally {
await close();
}
});

test("records missing site actions separately and validates domain and ownership", async () => {
const captured: MissingCapabilityReport[] = [];
const { client, close } = await connectTestMcp(
(server) =>
registerMissingCapabilityTool(server, (report) => {
captured.push(report);
}),
{},
);

const request = {
context:
"A reusable page search action is not listed by WebMCP, so the agent will continue using browser interaction.",
gap_reason: "site_tool_missing",
capability_area: "webmcp",
capability: "search available products",
requested_action: "search",
task_outcome: "completed_with_workaround",
tools_checked: ["webmcp", "execute_playwright_code"],
};

try {
const result = await client.callTool({
name: KERNEL_MISSING_CAPABILITY_TOOL_NAME,
arguments: { ...request, site_domain: "EXAMPLE.COM" },
});
expect(toolResultJSON(result)).toMatchObject({
recorded: true,
destination: "webmcp_catalog_demand",
});
expect(captured[0]).toMatchObject({
site_domain: "example.com",
tools_checked: ["webmcp", "execute_playwright_code"],
});

const wrongArea = await client.callTool({
name: KERNEL_MISSING_CAPABILITY_TOOL_NAME,
arguments: { ...request, capability_area: "browsers" },
});
expect(toolResultJSON(wrongArea)).toMatchObject({
recorded: false,
status: "invalid_capability_owner",
});

const wrongReason = await client.callTool({
name: KERNEL_MISSING_CAPABILITY_TOOL_NAME,
arguments: {
...request,
gap_reason: "kernel_capability_missing",
site_domain: "example.com",
},
});
expect(toolResultJSON(wrongReason)).toMatchObject({
recorded: false,
status: "invalid_capability_owner",
});

for (const site_domain of [
"https://example.com/path",
"foo.example.com",
"localhost",
]) {
const invalid = await client.callTool({
name: KERNEL_MISSING_CAPABILITY_TOOL_NAME,
arguments: { ...request, site_domain },
});
expect(invalid.isError).toBe(true);
}
expect(captured).toHaveLength(1);
} finally {
await close();
Expand Down
64 changes: 42 additions & 22 deletions src/lib/mcp/tools/missing-capability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import { parse as parseDomain } from "tldts";
import { jsonResponse } from "@/lib/mcp/responses";
import {
normalizeKernelMcpToolName,
Expand All @@ -10,6 +11,7 @@ export const KERNEL_MISSING_CAPABILITY_TOOL_NAME = "get_more_tools";

const gapReasonSchema = z.enum([
"kernel_capability_missing",
"site_tool_missing",
"existing_tool_failed",
"transient_or_capacity_failure",
"client_permission_restriction",
Expand All @@ -19,6 +21,7 @@ const gapReasonSchema = z.enum([

const capabilityAreaSchema = z.enum([
"browsers",
"webmcp",
"browser_files",
"profiles",
"projects",
Expand Down Expand Up @@ -77,13 +80,13 @@ const missingCapabilityFields = {
context: z
.string()
.describe(
"The missing capability and the user's goal, in 15-25 words and third person. Never include credentials, URLs, domains, account names, file contents, paths, or personal data.",
"The missing capability and the user's goal, in 15-25 words and third person. Never include credentials, URLs, domains, account names, file contents, paths, or personal data. For site tools, put the public registrable domain only in site_domain.",
),
gap_reason: gapReasonSchema.describe(
"Why the task could not proceed. Only kernel_capability_missing and external_integration_unavailable are recorded as demand. For an existing tool failure, use submit_feedback instead; transient failures and client restrictions are not capability gaps.",
"Classify the gap. kernel_capability_missing, site_tool_missing, and external_integration_unavailable are recorded separately. site_tool_missing means a reusable site action is not exposed by webmcp after checking its list, even if Playwright can complete the task. For an existing tool failure, use submit_feedback instead; transient failures and client restrictions are not capability gaps.",
),
capability_area: capabilityAreaSchema.describe(
"The single KERNEL product area that would own the capability, or external_integration/client_environment when Kernel does not own it.",
"The single KERNEL product area that would own the capability. Use webmcp for site_tool_missing, or external_integration/client_environment when Kernel does not own it.",
),
capability: z
.string()
Expand All @@ -93,6 +96,18 @@ const missingCapabilityFields = {
.describe(
'A short generic capability name, such as "browser filesystem upload". Do not include a site, customer, account, domain, path, or payload.',
),
site_domain: z
.string()
.trim()
.toLowerCase()
.refine((value) => {
const parsed = parseDomain(value, { allowPrivateDomains: false });
return parsed.isIcann && parsed.domain === value;
}, "must be a public registrable domain without a subdomain or URL components")
.optional()
.describe(
"Only for site_tool_missing: the public registrable domain (e.g. example.com), when known. No URL, subdomain, path, query, port, account identifier, or private hostname.",
),
requested_action: requestedActionSchema.describe(
"The primary operation the missing capability needed to perform.",
),
Expand Down Expand Up @@ -173,7 +188,7 @@ export function registerMissingCapabilityTool(
KERNEL_MISSING_CAPABILITY_TOOL_NAME,
{
description:
"Report a capability that no available KERNEL tool can provide after checking the tool list. Classify disconnected third-party services as external integrations. Do not use this for an existing tool that failed, a transient or capacity failure, or a client-side permission restriction; use submit_feedback for an existing KERNEL tool failure. Reports never replace the original task, so continue with any available workaround.",
"Report a missing KERNEL capability, external integration, or reusable site-specific WebMCP action after checking the tool list. For a site action, first list webmcp tools in the browser; if no suitable action is exposed, report site_tool_missing with capability_area webmcp, optionally site_domain, and continue using Playwright when possible. Do not report an existing tool failure, transient capacity failure, or client permission restriction as demand; use submit_feedback for an existing KERNEL tool failure. A request does not install a tool or replace the original task.",
inputSchema: structuredMissingCapabilitySchema,
annotations: {
title: "Get more tools",
Expand All @@ -186,9 +201,27 @@ export function registerMissingCapabilityTool(
async (report, ctx) => {
const externalIntegration =
report.gap_reason === "external_integration_unavailable";
const siteTool = report.gap_reason === "site_tool_missing";
const recordable =
report.gap_reason === "kernel_capability_missing" ||
siteTool ||
externalIntegration;
if (!recordable) {
return jsonResponse({
recorded: false,
status: "not_a_capability_gap",
message:
report.gap_reason === "existing_tool_failed"
? "Use submit_feedback for the existing KERNEL tool, then continue the original task."
: "This is not a missing capability request. Continue the original task using its normal recovery or client-permission path.",
});
}
if (
(externalIntegration &&
report.capability_area !== "external_integration") ||
(siteTool && report.capability_area !== "webmcp") ||
(!siteTool &&
(report.capability_area === "webmcp" || report.site_domain)) ||
Comment thread
cursor[bot] marked this conversation as resolved.
(report.gap_reason === "kernel_capability_missing" &&
(report.capability_area === "external_integration" ||
report.capability_area === "client_environment"))
Expand All @@ -201,20 +234,6 @@ export function registerMissingCapabilityTool(
});
}

const recordable =
report.gap_reason === "kernel_capability_missing" ||
externalIntegration;
if (!recordable) {
return jsonResponse({
recorded: false,
status: "not_a_capability_gap",
message:
report.gap_reason === "existing_tool_failed"
? "Use submit_feedback for the existing KERNEL tool, then continue the original task."
: "This is not a missing capability request. Continue the original task using its normal recovery or client-permission path.",
});
}

let status: "recorded" | "unavailable" | "failed" = "unavailable";
if (capture) {
try {
Expand All @@ -229,10 +248,11 @@ export function registerMissingCapabilityTool(
recorded: status === "recorded",
status,
capability: report.capability,
destination:
report.gap_reason === "kernel_capability_missing"
? "kernel_product_demand"
: "external_integration_demand",
destination: siteTool
? "webmcp_catalog_demand"
: externalIntegration
? "external_integration_demand"
: "kernel_product_demand",
message:
status === "recorded"
? "The capability request was recorded. No additional KERNEL tools are available; continue the original task with any available workaround."
Expand Down
Loading
Loading