diff --git a/README.md b/README.md index 6529328..9c0d82d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 0b810a1..68070f1 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -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"; @@ -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])[]; diff --git a/src/lib/mcp/tools/browsers.test.js b/src/lib/mcp/tools/browsers.test.js new file mode 100644 index 0000000..3e38ee6 --- /dev/null +++ b/src/lib/mcp/tools/browsers.test.js @@ -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, + }); + }); +}); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 6865fe2..09a7130 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -37,6 +37,13 @@ type TelemetryParams = { telemetry_interaction?: boolean; }; +export function browserForMCP( + browser: T, +): Omit { + const { cdp_ws_url: _cdpWsURL, ...safeBrowser } = browser; + return safeBrowser; +} + const telemetryCategories = [ ["telemetry_console", "console"], ["telemetry_network", "network"], @@ -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, @@ -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), }); } @@ -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.', }); } @@ -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) diff --git a/src/lib/mcp/tools/hosted-auth.test.js b/src/lib/mcp/tools/hosted-auth.test.js new file mode 100644 index 0000000..2921ea1 --- /dev/null +++ b/src/lib/mcp/tools/hosted-auth.test.js @@ -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(); + }); +}); diff --git a/src/lib/mcp/tools/hosted-auth.ts b/src/lib/mcp/tools/hosted-auth.ts new file mode 100644 index 0000000..4996ed3 --- /dev/null +++ b/src/lib/mcp/tools/hosted-auth.ts @@ -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); + } + }, + ); +}