Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_
- `manage_extensions` - List and delete uploaded browser extensions.
- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results.
- `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections.
- `manage_hosted_auth` - Create and monitor hosted managed-auth flows without exposing credential or MFA fields to the agent.
- `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret.
- `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection.

Expand Down
2 changes: 2 additions & 0 deletions src/lib/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { registerCredentialProviderTools } from "@/lib/mcp/tools/credential-prov
import { registerCredentialTools } from "@/lib/mcp/tools/credentials";
import { registerDocsTools } from "@/lib/mcp/tools/docs";
import { registerExtensionTools } from "@/lib/mcp/tools/extensions";
import { registerHostedAuthTool } from "@/lib/mcp/tools/hosted-auth";
import { registerPlaywrightTool } from "@/lib/mcp/tools/playwright";
import { registerProfileCapabilities } from "@/lib/mcp/tools/profiles";
import { registerProjectCapabilities } from "@/lib/mcp/tools/projects";
Expand All @@ -36,6 +37,7 @@ const mcpToolRegistrations = [
["playwright", registerPlaywrightTool],
["replays", registerReplayTools],
["auth_connections", registerAuthConnectionTools],
["hosted_auth", registerHostedAuthTool],
["credentials", registerCredentialTools],
["credential_providers", registerCredentialProviderTools],
] as const satisfies readonly (readonly [string, RegisterMcpToolset])[];
Expand Down
19 changes: 19 additions & 0 deletions src/lib/mcp/tools/browsers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { browserForMCP } from "./browsers";

describe("browserForMCP", () => {
test("omits the CDP capability URL while preserving browser controls", () => {
expect(
browserForMCP({
session_id: "browser_123",
browser_live_view_url: "https://app.onkernel.com/browsers/browser_123",
cdp_ws_url: "wss://api.onkernel.com/browser?token=do-not-expose",
timeout_seconds: 60,
}),
).toEqual({
session_id: "browser_123",
browser_live_view_url: "https://app.onkernel.com/browsers/browser_123",
timeout_seconds: 60,
});
});
});
15 changes: 11 additions & 4 deletions src/lib/mcp/tools/browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ type TelemetryParams = {
telemetry_interaction?: boolean;
};

export function browserForMCP<T extends { cdp_ws_url?: unknown }>(
browser: T,
): Omit<T, "cdp_ws_url"> {
const { cdp_ws_url: _cdpWsURL, ...safeBrowser } = browser;
return safeBrowser;
}

const telemetryCategories = [
["telemetry_console", "console"],
["telemetry_network", "network"],
Expand Down Expand Up @@ -590,7 +597,7 @@ export function registerBrowserCapabilities(server: McpServer) {
browser.session_id,
);
return jsonResponse({
browser,
browser: browserForMCP(browser),
next_actions: browserSessionNextActions(browser.session_id),
...(sshPortForwarding && {
ssh_port_forwarding: sshPortForwarding,
Expand Down Expand Up @@ -638,7 +645,7 @@ export function registerBrowserCapabilities(server: McpServer) {
if (!browser)
return errorResponse("Failed to update browser session");
return jsonResponse({
browser,
browser: browserForMCP(browser),
next_actions: browserSessionNextActions(browser.session_id),
});
}
Expand All @@ -649,7 +656,7 @@ export function registerBrowserCapabilities(server: McpServer) {
...(params.offset !== undefined && { offset: params.offset }),
});
return paginatedJsonResponse(page, {
mapItem: ({ cdp_ws_url: _cdpWsUrl, ...browser }) => browser,
mapItem: browserForMCP,
note: 'Use action "get" with session_id for full browser details.',
});
}
Expand All @@ -663,7 +670,7 @@ export function registerBrowserCapabilities(server: McpServer) {
return errorResponse(
`Browser session "${params.session_id}" not found`,
);
return jsonResponse(browser);
return jsonResponse(browserForMCP(browser));
}
case "get_telemetry": {
if (!params.session_id)
Expand Down
80 changes: 80 additions & 0 deletions src/lib/mcp/tools/hosted-auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";
import { z } from "zod";
import {
hostedAuthConnectionForMCP,
hostedAuthLoginForMCP,
hostedAuthParams,
} from "./hosted-auth";

const schema = z.object(hostedAuthParams).strict();

describe("hostedAuthParams", () => {
test("accepts hosted login operations", () => {
expect(schema.parse({ action: "login", id: "auth_123" })).toEqual({
action: "login",
id: "auth_123",
});
});

test("projects login responses without internal handoff codes", () => {
expect(
hostedAuthLoginForMCP({
id: "auth_123",
hosted_url: "https://auth.onkernel.com/flow_123",
live_view_url: "https://app.onkernel.com/auth/flow_123",
flow_expires_at: "2026-07-31T13:00:00Z",
flow_type: "LOGIN",
handoff_code: "internal-secret",
}),
).toEqual({
id: "auth_123",
hosted_url: "https://auth.onkernel.com/flow_123",
live_view_url: "https://app.onkernel.com/auth/flow_123",
flow_expires_at: "2026-07-31T13:00:00Z",
flow_type: "LOGIN",
});
});

test("projects connection state without credential references", () => {
expect(
hostedAuthConnectionForMCP({
id: "auth_123",
domain: "example.com",
profile_name: "profile_123",
status: "NEEDS_AUTH",
flow_status: "IN_PROGRESS",
credential: { name: "private-login" },
}),
).toEqual({
id: "auth_123",
domain: "example.com",
profile_name: "profile_123",
status: "NEEDS_AUTH",
flow_status: "IN_PROGRESS",
flow_step: null,
flow_expires_at: null,
hosted_url: null,
live_view_url: null,
error_code: null,
error_message: null,
});
});

test("rejects credential and raw field submission inputs", () => {
expect(() =>
schema.parse({
action: "create",
domain: "example.com",
profile_name: "profile_123",
credential_name: "saved-login",
}),
).toThrow();
expect(() =>
schema.parse({
action: "submit",
id: "auth_123",
fields: { password: "do-not-expose" },
}),
).toThrow();
});
});
163 changes: 163 additions & 0 deletions src/lib/mcp/tools/hosted-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createKernelClient } from "@/lib/mcp/kernel-client";
import {
errorResponse,
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

export const hostedAuthParams = {
action: z.enum(["create", "list", "get", "delete", "login"]),
id: z.string().optional(),
domain: z.string().optional(),
profile_name: z.string().optional(),
allowed_domains: z.array(z.string()).optional(),
login_url: z.string().optional(),
health_check_interval: z.number().int().optional(),
proxy_id: z.string().optional(),
proxy_name: z.string().optional(),
domain_filter: z.string().optional(),
...paginationParams,
};

export function hostedAuthLoginForMCP(response: {
id: string;
hosted_url: string;
flow_expires_at: string;
flow_type: string;
live_view_url?: string;
}) {
return {
id: response.id,
hosted_url: response.hosted_url,
flow_expires_at: response.flow_expires_at,
flow_type: response.flow_type,
...(response.live_view_url && { live_view_url: response.live_view_url }),
};
}

export function hostedAuthConnectionForMCP(connection: {
id: string;
domain: string;
profile_name: string;
status: string;
flow_status?: string | null;
flow_step?: string | null;
flow_expires_at?: string | null;
hosted_url?: string | null;
live_view_url?: string | null;
error_code?: string | null;
error_message?: string | null;
}) {
return {
id: connection.id,
domain: connection.domain,
profile_name: connection.profile_name,
status: connection.status,
flow_status: connection.flow_status ?? null,
flow_step: connection.flow_step ?? null,
flow_expires_at: connection.flow_expires_at ?? null,
hosted_url: connection.hosted_url ?? null,
live_view_url: connection.live_view_url ?? null,
error_code: connection.error_code ?? null,
error_message: connection.error_message ?? null,
};
}

export function registerHostedAuthTool(server: McpServer) {
server.tool(
"manage_hosted_auth",
"Manage hosted Kernel authentication without exposing credentials to the agent. Create a connection for a profile and domain, start login to receive a hosted URL for the user, poll its state, list connections, or delete one.",
hostedAuthParams,
{
title: "Manage hosted Kernel authentication",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true,
},
async (params, extra) => {
if (!extra.authInfo) throw new Error("Authentication required");
const client = createKernelClient(extra.authInfo.token);
const proxy =
params.proxy_id || params.proxy_name
? {
...(params.proxy_id && { id: params.proxy_id }),
...(params.proxy_name && { name: params.proxy_name }),
}
: undefined;

try {
switch (params.action) {
case "create": {
if (!params.domain || !params.profile_name) {
return errorResponse(
"Error: domain and profile_name are required for create.",
);
}
const connection = await client.auth.connections.create({
domain: params.domain,
profile_name: params.profile_name,
...(params.allowed_domains && {
allowed_domains: params.allowed_domains,
}),
...(params.login_url && { login_url: params.login_url }),
...(params.health_check_interval !== undefined && {
health_check_interval: params.health_check_interval,
}),
});
return connection
? jsonResponse(hostedAuthConnectionForMCP(connection))
: errorResponse("Failed to create auth connection");
}
case "list": {
const page = await client.auth.connections.list({
...(params.profile_name && { profile_name: params.profile_name }),
...(params.domain_filter && { domain: params.domain_filter }),
...(params.limit !== undefined && { limit: params.limit }),
...(params.offset !== undefined && { offset: params.offset }),
});
return paginatedJsonResponse(page, {
mapItem: hostedAuthConnectionForMCP,
});
}
case "get": {
if (!params.id)
return errorResponse("Error: id is required for get.");
return jsonResponse(
hostedAuthConnectionForMCP(
await client.auth.connections.retrieve(params.id),
),
);
}
case "delete": {
if (!params.id) {
return errorResponse("Error: id is required for delete.");
}
await client.auth.connections.delete(params.id);
return textResponse("Hosted auth connection deleted successfully");
}
case "login": {
if (!params.id) {
return errorResponse("Error: id is required for login.");
}
return jsonResponse(
hostedAuthLoginForMCP(
await client.auth.connections.login(
params.id,
proxy ? { proxy } : undefined,
),
),
);
}
}
} catch (error) {
return toolErrorResponse("manage_hosted_auth", params.action, error);
}
},
);
}
Loading