Skip to content
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/server": "2.0.0",
"@onkernel/managed-auth-react": "0.5.5",
"@onkernel/sdk": "0.132.0",
"@onkernel/sdk": "0.140.0",
"@posthog/mcp": "0.17.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/redis": "^4.0.11",
Expand Down
1 change: 1 addition & 0 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const ENTITLEMENT_GATED_TOOLS = new Set([
"manage_vaults",
"manage_vault_wallets",
"manage_vault_cards",
"manage_vault_payment_requests",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"manage_vault_credentials",
"manage_vault_items",
"manage_vault_provider_configs",
Expand Down
1 change: 1 addition & 0 deletions src/lib/mcp/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const BLOCKED_LANGUAGE =
const BLOCKED_NAMES = new RegExp(`${BLOCKED_LANGUAGE.source}|captcha`, "i");
const API_FIELD_NAMES = new Set([
"manage_browsers:stealth",
"manage_browsers:fingerprint",
"manage_browsers:captcha",
"manage_browser_pools:stealth",
"manage_browser_pools:captcha",
Expand Down
5 changes: 4 additions & 1 deletion src/lib/mcp/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ const EXPECTED_ANNOTATIONS: Record<string, [boolean, boolean, boolean]> = {
manage_vault_items: [false, true, true],
manage_vault_credentials: [false, true, true],
manage_vault_cards: [false, true, true],
manage_vault_wallets: [false, false, true],
manage_vault_payment_requests: [false, true, true],
manage_vault_wallets: [false, true, true],
manage_vault_provider_configs: [false, true, true],
};

Expand Down Expand Up @@ -224,6 +225,7 @@ describe("MCP toolset allowlist", () => {
"manage_vault_provider_configs",
"manage_vault_wallets",
"manage_vault_cards",
"manage_vault_payment_requests",
"manage_vault_credentials",
"manage_vault_items",
"manage_vaults",
Expand Down Expand Up @@ -297,6 +299,7 @@ describe("project selection registration", () => {
"manage_vaults",
"manage_vault_wallets",
"manage_vault_cards",
"manage_vault_payment_requests",
"manage_vault_credentials",
"manage_vault_items",
"open_auth_login",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export const telemetryEventCategories = [
"monitor",
] as const;

export const TELEMETRY_EVENT_CATALOG = `event categories: console (console output and uncaught exceptions), network (request/response metadata), page (navigation and lifecycle), interaction (clicks, keys, scrolls), control (agent-driven api calls), platform (KERNEL platform lifecycle and service events), connection (cdp/live-view attach/detach), system (vm health), screenshot (periodic monitor screenshots), captcha (verification prompts a site displayed during the session), monitor (telemetry collector health; captured automatically with any cdp category). high-signal event types: console_error, network_loading_failed, network_response with non-2xx status, system_oom_kill, service_crashed, monitor_disconnected (telemetry gap — treat following events as incomplete).`;
export const TELEMETRY_EVENT_CATALOG = `event categories: console (console output and uncaught exceptions), network (request/response metadata), page (navigation and lifecycle), interaction (clicks, keys, scrolls), control (agent-driven api calls), platform (KERNEL platform lifecycle and service events), connection (cdp/live-view attach/detach), system (vm health), screenshot (periodic monitor screenshots), captcha (verification prompts a site displayed during the session), monitor (telemetry collector health; captured automatically with any cdp category). high-signal event types: console_error, network_loading_failed, network_response with non-2xx status, system_oom_kill, service_crashed, monitor_disconnected (telemetry gap — treat following events as incomplete). browser_proxy_error data.code can be payment_window_expired when the proxy reports that a payment window has expired; unrecognized proxy codes appear as unknown with the original value in raw_code.`;
1 change: 1 addition & 0 deletions src/lib/mcp/tool-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const KERNEL_MCP_TOOL_NAMES = [
"manage_vault_cards",
"manage_vault_credentials",
"manage_vault_items",
"manage_vault_payment_requests",
"manage_vault_provider_configs",
"manage_vault_wallets",
"manage_vaults",
Expand Down
24 changes: 22 additions & 2 deletions src/lib/mcp/tools/api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ async function callAPIKeys(apiKeys: unknown, args: Record<string, unknown>[]) {
}

describe("manage_api_keys SDK parity", () => {
test("advertises last-used sorting and null ordering", async () => {
const { client, close } = await connectTestMcp(
registerAPIKeyCapabilities,
{},
);
try {
const tool = (await client.listTools()).tools.find(
({ name }) => name === "manage_api_keys",
);
expect(tool?.inputSchema.properties?.sort_by).toMatchObject({
enum: ["created_at", "name", "expires_at", "last_used_at"],
description: expect.stringContaining(
"keys missing that value sort last in either direction",
),
});
} finally {
await close();
}
});

test("rejects an empty api_key_id in the schema", async () => {
let called = false;
const [result] = await callAPIKeys(
Expand Down Expand Up @@ -106,7 +126,7 @@ describe("manage_api_keys SDK parity", () => {
name: "staging",
query: "stage",
status: "all",
sort_by: "expires_at",
sort_by: "last_used_at",
sort_direction: "asc",
limit: 5,
offset: 0,
Expand All @@ -123,7 +143,7 @@ describe("manage_api_keys SDK parity", () => {
name: "staging",
query: "stage",
status: "all",
sort_by: "expires_at",
sort_by: "last_used_at",
sort_direction: "asc",
limit: 5,
offset: 0,
Expand Down
6 changes: 4 additions & 2 deletions src/lib/mcp/tools/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ export function registerAPIKeyCapabilities(
.describe("(list) key status filter. defaults to active.")
.optional(),
sort_by: z
.enum(["created_at", "name", "expires_at"])
.describe("(list) field to sort by.")
.enum(["created_at", "name", "expires_at", "last_used_at"])
.describe(
"(list) field to sort by. when sorting by expires_at or last_used_at, keys missing that value sort last in either direction.",
)
.optional(),
sort_direction: z
.enum(["asc", "desc"])
Expand Down
8 changes: 5 additions & 3 deletions src/lib/mcp/tools/apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ describe("manage_apps invocation contract", () => {

try {
const tools = await client.listTools();
expect(
tools.tools.some((tool) => tool.name === "manage_apps"),
).toBeTrue();
const appTool = tools.tools.find((tool) => tool.name === "manage_apps");
expect(appTool).toBeDefined();
expect(appTool?.description).toContain("leased_at");

const result = await client.callTool({
name: "manage_apps",
Expand Down Expand Up @@ -110,6 +110,7 @@ describe("manage_apps invocation contract", () => {
cdp_ws_url: "wss://api.onkernel.com/cdp",
webdriver_ws_url: "wss://api.onkernel.com/webdriver",
created_at: "2026-08-08T00:00:00Z",
leased_at: "2026-08-08T00:05:00Z",
headless: false,
stealth: true,
memory: "2GiB",
Expand Down Expand Up @@ -149,6 +150,7 @@ describe("manage_apps invocation contract", () => {
session_id: "brr_123",
browser_live_view_url:
"https://api.onkernel.com/browser/live/signed",
leased_at: "2026-08-08T00:05:00Z",
location: {
timezone: "America/New_York",
timezone_source: "default",
Expand Down
5 changes: 4 additions & 1 deletion src/lib/mcp/tools/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function registerAppCapabilities(
"manage_apps",
{
description:
'manage KERNEL apps when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. use "list_apps" before invoking an unknown app. "invoke" starts an action asynchronously and returns an invocation_id immediately. use "list_invocations" to find runs, "list_invocation_browsers" to discover their browser sessions, and "get_invocation" after a short delay to inspect one state. do not poll indefinitely; if a run is still active, report its id. "delete_invocation_browsers" terminates every active browser created by one invocation. deployment and invocation event streams are intentionally represented by bounded list/get calls rather than an open sse stream.',
'manage KERNEL apps when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. use "list_apps" before invoking an unknown app. "invoke" starts an action asynchronously and returns an invocation_id immediately. use "list_invocations" to find runs, "list_invocation_browsers" to discover their browser sessions, and "get_invocation" after a short delay to inspect one state. list_invocation_browsers includes leased_at only for leased pooled sessions, showing when each was last leased. do not poll indefinitely; if a run is still active, report its id. "delete_invocation_browsers" terminates every active browser created by one invocation. deployment and invocation event streams are intentionally represented by bounded list/get calls rather than an open sse stream.',
inputSchema: z.object({
...projectSelectionInputSchema(),
action: z
Expand Down Expand Up @@ -280,6 +280,9 @@ export function registerAppCapabilities(
browsers: browsers.browsers.map((browser) => ({
session_id: browser.session_id,
browser_live_view_url: browser.browser_live_view_url,
...(browser.leased_at !== undefined && {
leased_at: browser.leased_at,
}),
...(browser.location !== undefined && {
location: browser.location,
}),
Expand Down
1 change: 1 addition & 0 deletions src/lib/mcp/tools/auth-connections.test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function connection(overrides: Partial<ManagedAuth> = {}): ManagedAuth {
return {
id: "conn_1",
domain: "example.com",
hitl: false,
profile_name: "work",
status: "NEEDS_AUTH",
record_session: false,
Expand Down
72 changes: 70 additions & 2 deletions src/lib/mcp/tools/auth-connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("manage_auth_connections programmatic surface", () => {
expect(schema?.allowed_domains).toBeDefined();
expect(schema?.login_url).toBeDefined();
expect(schema?.health_checks).toBeDefined();
expect(schema?.hitl).toBeDefined();
expect(schema?.auto_reauth).toBeDefined();
expect(schema?.browser_region.safeParse("eu-west").success).toBe(true);
expect(schema?.browser_region.safeParse("us-west").success).toBe(true);
Expand Down Expand Up @@ -88,6 +89,12 @@ describe("manage_auth_connections programmatic surface", () => {
({ name }) => name === "manage_auth_connections",
);
const browserTelemetry = tool?.inputSchema.properties?.browser_telemetry;
expect(tool?.inputSchema.properties?.hitl).toMatchObject({
description: expect.stringContaining("AWAITING_HUMAN_INTERVENTION"),
});
expect(tool?.inputSchema.properties?.selected_choice_id).toMatchObject({
description: expect.stringContaining("human_intervention"),
});
for (const field of [
"credential_name",
"credential_provider",
Expand Down Expand Up @@ -187,6 +194,7 @@ describe("manage_auth_connections programmatic surface", () => {
domain: "example.com",
profile_name: "work",
record_session: true,
hitl: true,
browser_region: "eu-west",
browser_telemetry: {
enabled: true,
Expand All @@ -197,6 +205,7 @@ describe("manage_auth_connections programmatic surface", () => {
);
expect(createBody).toMatchObject({
record_session: true,
hitl: true,
browser: {
region: "eu-west",
telemetry: {
Expand Down Expand Up @@ -347,6 +356,7 @@ describe("manage_auth_connections programmatic surface", () => {
login_url: "",
health_check_interval: 1200,
health_checks: false,
hitl: false,
auto_reauth: false,
save_credentials: false,
record_session: true,
Expand All @@ -372,6 +382,7 @@ describe("manage_auth_connections programmatic surface", () => {
login_url: "",
health_check_interval: 1200,
health_checks: false,
hitl: false,
auto_reauth: false,
save_credentials: false,
record_session: true,
Expand Down Expand Up @@ -560,7 +571,19 @@ describe("manage_auth_connections programmatic surface", () => {
},
retrieve: async () => {
calls.retrieve++;
return connection({ status: "AUTHENTICATED" });
return connection({
status: "AUTHENTICATED",
hitl: true,
flow_status: "IN_PROGRESS",
flow_step: "AWAITING_HUMAN_INTERVENTION",
choices: [
{
id: "captcha-complete",
label: "I completed the challenge",
type: "human_intervention",
},
],
});
},
login: async () => {
calls.login++;
Expand Down Expand Up @@ -603,9 +626,17 @@ describe("manage_auth_connections programmatic surface", () => {
const got = await handler({ action: "get", id: "conn_1" }, extra);
const gotJson = JSON.parse(got.content[0].text);
expect(gotJson.status).toBe("AUTHENTICATED");
expect(gotJson.hitl).toBe(true);
expect(gotJson.flow_step).toBe("AWAITING_HUMAN_INTERVENTION");
expect(gotJson.interaction_id).toBe("mai_secret");
expect(gotJson.fields).toHaveLength(1);
expect(gotJson.choices).toHaveLength(1);
expect(gotJson.choices).toEqual([
{
id: "captcha-complete",
label: "I completed the challenge",
type: "human_intervention",
},
]);
expect(gotJson.discovered_fields).toHaveLength(1);

// login returns the raw hosted flow response.
Expand Down Expand Up @@ -841,6 +872,43 @@ describe("manage_auth_connections programmatic surface", () => {
}
});

test("wait returns human-intervention guidance without polling", async () => {
const { handler } = captureHandler();
let calls = 0;
kernelClientMock.factory = () => ({
auth: {
connections: {
retrieve: async () => {
calls++;
return connection({
hitl: true,
flow_status: "IN_PROGRESS",
flow_step: "AWAITING_HUMAN_INTERVENTION",
});
},
},
},
});
try {
const result = await handler(
{ action: "wait", id: "conn_1", wait_seconds: 30 },
{ authInfo: { token: "test-token" } },
);
expect(result.structuredContent.state).toBe("pending");
expect(result.structuredContent.connection.flow_step).toBe(
"AWAITING_HUMAN_INTERVENTION",
);
expect(result.structuredContent.instruction).toContain("action=get");
expect(result.structuredContent.instruction).toContain(
"human_intervention",
);
expect(calls).toBe(1);
assertNoSecrets(result.structuredContent);
} finally {
kernelClientMock.factory = () => unusedKernelClient;
}
});

test("flow checkpoint waits require the checkpoint's connection id", async () => {
const { handler } = captureHandler();
const result = await handler(
Expand Down
Loading
Loading