From f820770388c85772b82a878a7202104a9a48593a Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:47:27 +0000 Subject: [PATCH 1/6] Accept proxy config object in manage_browsers --- src/lib/mcp/tools/browsers.test.ts | 95 ++++++++++++++++++++++++++++++ src/lib/mcp/tools/browsers.ts | 35 ++++++++++- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index c9be4a4..b79391b 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -501,6 +501,101 @@ describe("manage_browsers proxy routes", () => { }); }); +describe("manage_browsers proxy", () => { + test("passes the proxy config through SDK create and update", async () => { + const creates: unknown[] = []; + const updates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + { + browsers: { + create: async (params: unknown) => { + creates.push(params); + return { session_id: "brr_123" }; + }, + update: async (sessionId: string, params: unknown) => { + updates.push([sessionId, params]); + return { session_id: sessionId }; + }, + }, + }, + ); + try { + for (const proxy of [{ name: "residential" }, { id: "prx_123" }]) { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { action: "create", proxy }, + }); + expect(result.isError).toBeFalsy(); + } + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "update", + session_id: "brr_123", + proxy: { mode: "direct" }, + }, + }); + expect(result.isError).toBeFalsy(); + expect(creates).toEqual([ + { proxy: { name: "residential" } }, + { proxy: { id: "prx_123" } }, + ]); + expect(updates).toEqual([["brr_123", { proxy: { mode: "direct" } }]]); + } finally { + await close(); + } + }); + + test("rejects ambiguous proxy selection without calling the SDK", async () => { + let calls = 0; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + { + browsers: { + create: async () => { + calls++; + return { session_id: "brr_123" }; + }, + update: async () => { + calls++; + return { session_id: "brr_123" }; + }, + }, + }, + ); + try { + const invalid = [ + { action: "create", proxy: {} }, + { action: "create", proxy: { id: "prx_123", name: "residential" } }, + { action: "create", proxy: { name: "residential" }, proxy_id: "prx_123" }, + { + action: "update", + session_id: "brr_123", + proxy: { name: "residential" }, + clear_proxy: true, + }, + { + action: "update", + session_id: "brr_123", + proxy: { mode: "default" }, + disable_default_proxy: false, + }, + ]; + for (const args of invalid) { + const result = await client.callTool({ + name: "manage_browsers", + arguments: args, + }); + expect(result.isError).toBe(true); + } + expect(calls).toBe(0); + } finally { + await close(); + } + }); +}); + describe("manage_browsers region", () => { test("passes region to create and list", async () => { const createCalls: unknown[] = []; diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 226e530..8d689ec 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -530,10 +530,20 @@ export function registerBrowserCapabilities( "(create, update) save session changes back to profile on close.", ) .optional(), + proxy: z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1).optional(), + mode: z.enum(["direct", "default"]).optional(), + }) + .describe( + "(create, update) proxy egress, set with exactly one of id, name, or mode. id or name selects that proxy. mode direct forces direct egress; mode default restores the browser's default egress. on create, omit for the browser default; on update, omit to leave unchanged. cannot be combined with proxy_id, clear_proxy, or disable_default_proxy.", + ) + .optional(), proxy_id: z .string() .describe( - "(create, update) proxy id for traffic routing. for update, omit to leave unchanged.", + "(create, update) deprecated: use proxy.id. proxy id for traffic routing. for update, omit to leave unchanged.", ) .optional(), proxy_routes: z @@ -552,13 +562,13 @@ export function registerBrowserCapabilities( clear_proxy: z .boolean() .describe( - "(update) remove the current proxy from the browser session.", + "(update) deprecated: use proxy.mode=default. remove the current proxy from the browser session.", ) .optional(), disable_default_proxy: z .boolean() .describe( - "(update) connect directly instead of through the session's default KERNEL-managed proxy.", + "(update) deprecated: use proxy.mode=direct. connect directly instead of through the session's default KERNEL-managed proxy.", ) .optional(), kiosk_mode: z @@ -711,6 +721,23 @@ export function registerBrowserCapabilities( "proxy routes are creation-only; they cannot be added to an existing browser.", ); } + if (params.proxy !== undefined) { + const { id, name, mode } = params.proxy; + if ([id, name, mode].filter(Boolean).length !== 1) { + return errorResponse( + "error: proxy requires exactly one of id, name, or mode.", + ); + } + if ( + params.proxy_id !== undefined || + params.clear_proxy !== undefined || + params.disable_default_proxy !== undefined + ) { + return errorResponse( + "error: proxy cannot be combined with proxy_id, clear_proxy, or disable_default_proxy.", + ); + } + } switch (params.action) { case "create": { const createParams: Kernel.BrowserCreateParams = {}; @@ -733,6 +760,7 @@ export function registerBrowserCapabilities( ) { createParams.chrome_policy = params.chrome_policy; } + if (params.proxy !== undefined) createParams.proxy = params.proxy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; if (params.proxy_routes !== undefined) { const proxyRoutes: Array = []; @@ -799,6 +827,7 @@ export function registerBrowserCapabilities( } const updateParams: BrowserUpdateParams = {}; + if (params.proxy !== undefined) updateParams.proxy = params.proxy; if (params.disable_default_proxy !== undefined) { updateParams.disable_default_proxy = params.disable_default_proxy; } From e3fe1da8f65931c101b772015f4275df9535b083 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:03:49 +0000 Subject: [PATCH 2/6] Mirror API proxy and browser objects across MCP tools Add nested network.proxy_routes to manage_browsers, a browser object to manage_auth_connections and the login tools, and config, bypass_hosts, protocol, and list filters to manage_proxies. Keep the flat inputs as deprecated aliases and record their use in tool-call analytics. --- README.md | 8 +- src/lib/mcp/analytics.test.ts | 56 ++++++ src/lib/mcp/analytics.ts | 24 ++- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 1 + src/lib/mcp/deprecated-params.test.ts | 33 ++++ src/lib/mcp/deprecated-params.ts | 55 ++++++ src/lib/mcp/prompts.test.ts | 2 + src/lib/mcp/proxy-config.ts | 35 ++++ src/lib/mcp/tools/auth-connections.test.ts | 93 ++++++++++ src/lib/mcp/tools/auth-connections.ts | 62 ++++++- src/lib/mcp/tools/auth-login-app.test.ts | 39 +++- src/lib/mcp/tools/auth-login-app.ts | 79 ++++++-- src/lib/mcp/tools/browsers.test.ts | 98 +++++++++- src/lib/mcp/tools/browsers.ts | 84 ++++++--- src/lib/mcp/tools/managed-auth-start.test.ts | 17 ++ src/lib/mcp/tools/managed-auth-state.ts | 20 +-- src/lib/mcp/tools/proxies.test.ts | 150 ++++++++++++++++ src/lib/mcp/tools/proxies.ts | 169 +++++++++++++----- 19 files changed, 922 insertions(+), 105 deletions(-) create mode 100644 src/lib/mcp/deprecated-params.test.ts create mode 100644 src/lib/mcp/deprecated-params.ts create mode 100644 src/lib/mcp/proxy-config.ts create mode 100644 src/lib/mcp/tools/proxies.test.ts diff --git a/README.md b/README.md index f834937..ad413c7 100644 --- a/README.md +++ b/README.md @@ -320,15 +320,17 @@ Self-hosted deployments can select tool families with `KERNEL_MCP_ENABLED_TOOLSE Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools advertise an optional `project` (name or ID) and a deprecated `project_id`: organization-wide connections may omit them to preserve organization-wide reads and API default-project behavior, while fixed-project connections may omit them or pass the matching project. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it. +Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `config` objects replace older flat inputs such as `proxy_id`, `proxy_routes`, `browser_region`, and `custom_host`. The flat inputs remain as deprecated aliases, and combining one with its nested replacement is rejected. Tool-call analytics records the names, never the values, of deprecated inputs each call used in `$mcp_deprecated_params`; the tracked list lives in `src/lib/mcp/deprecated-params.ts`. + ### manage\_\* tools -- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies, create-only per-host proxy routes (`proxy_routes`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. +- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies (`proxy` by id, name, or mode), create-only per-host proxy routes (`network.proxy_routes`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. - `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. - `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. - `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once. - `manage_browser_pools` - Create, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools. - `manage_config_registry` - Look up current browser and proxy recommendations, start and inspect analyses, request cancellation, and list project configurations or analysis history. -- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). +- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). List filters by exact `name` or a `query` substring. - `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan. - `web_search` - Search the web, retrieve retained results, and inspect provider capabilities. Tool visibility uses a per-credential, per-connection Search entitlement snapshot cached for up to 30 minutes; the Search API remains authoritative for execution access. Search creation is billable and is not automatically retried. - `manage_extensions` - List and delete uploaded browser extensions. @@ -443,7 +445,7 @@ Returns: the REPL ID, ordered text output, and screenshot. Later browser_repl ca Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. -The secure App defaults `record_session` and `browser_telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `region` in `open_auth_login`, or `browser_region` in `manage_auth_connections`, to choose where a managed-auth browser runs. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass browser telemetry through the API’s current nested `browser.telemetry` configuration while preserving defaults and inheritance when the MCP parameter is omitted. +The secure App defaults `record_session` and `browser.telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `browser.region` in `open_auth_login` or `manage_auth_connections` to choose where a managed-auth browser runs, and `browser.proxy` to choose its proxy by id, name, or mode. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass the `browser` object through to the API unchanged, preserving defaults and inheritance when it is omitted. ### Set up browser profiles for authentication diff --git a/src/lib/mcp/analytics.test.ts b/src/lib/mcp/analytics.test.ts index 53cab96..4541857 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -28,6 +28,7 @@ import { MCP_FEEDBACK_SUBMITTED_EVENT, MCP_USED_PROJECT_ID_PROPERTY, MCP_USED_PROJECT_PROPERTY, + MCP_DEPRECATED_PARAMS_PROPERTY, OAUTH_TOKEN_EXCHANGE_EVENT, sanitizeMcpAnalyticsEvent, } from "@/lib/mcp/analytics"; @@ -513,6 +514,61 @@ describe("sanitizeMcpAnalyticsEvent", () => { expect(result?.properties[MCP_USED_PROJECT_PROPERTY]).toBe(true); }); + test("lists deprecated parameter names a tool call used, without values", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.ToolName]: "manage_browsers", + [PostHogMCPAnalyticsProperty.Parameters]: { + request: { + params: { + arguments: { + action: "update", + session_id: "brr_123", + proxy_id: "prx_secret", + clear_proxy: false, + }, + }, + }, + }, + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties[MCP_DEPRECATED_PARAMS_PROPERTY]).toEqual([ + "proxy_id", + "clear_proxy", + ]); + expect(JSON.stringify(result)).not.toContain("prx_secret"); + }); + + test("records an empty deprecated parameter list for current inputs only", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.ToolName]: "manage_proxies", + [PostHogMCPAnalyticsProperty.Parameters]: { + request: { + params: { + arguments: { action: "create", type: "isp", config: {} }, + }, + }, + }, + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties[MCP_DEPRECATED_PARAMS_PROPERTY]).toEqual([]); + }); + + test("omits the deprecated parameter list for tools without deprecated inputs", async () => { + const event = toolCallEvent({ + [PostHogMCPAnalyticsProperty.ToolName]: "computer_action", + }); + + const result = await sanitizeMcpAnalyticsEvent(event); + + expect(result?.properties).not.toHaveProperty( + MCP_DEPRECATED_PARAMS_PROPERTY, + ); + }); + test("records false/false when a tool call omits both project selectors", async () => { const result = await sanitizeMcpAnalyticsEvent(toolCallEvent()); diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index f38930e..bbb96b7 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -17,6 +17,10 @@ import type { McpConnectionContext, } from "@/lib/mcp/auth-context"; import { MCP_INTENT_ARGUMENT_DESCRIPTION } from "@/lib/mcp/analytics-context"; +import { + DEPRECATED_TOOL_PARAMS, + deprecatedParamsUsed, +} from "@/lib/mcp/deprecated-params"; import { type KernelFeedback, KERNEL_FEEDBACK_TOOL_NAME, @@ -98,6 +102,7 @@ const posthog = projectToken export const MCP_USED_PROJECT_ID_PROPERTY = "$mcp_used_project_id"; export const MCP_USED_PROJECT_PROPERTY = "$mcp_used_project"; +export const MCP_DEPRECATED_PARAMS_PROPERTY = "$mcp_deprecated_params"; export const MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY = "$mcp_client_supports_sampling"; export const MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY = @@ -138,7 +143,8 @@ const CLIENT_EXTENSION_PROPERTIES = { // property the pinned SDK doesn't emit today — a renamed payload field, a new one — // can't start flowing on an upgrade. Deliberately absent: $mcp_parameters and // $mcp_response (call payloads), and $mcp_error_message (the text a failed tool -// returned). $mcp_used_project_id / $mcp_used_project are presence flags only. +// returned). $mcp_used_project_id / $mcp_used_project are presence flags only, and +// $mcp_deprecated_params lists parameter names, never values. const SENT_PROPERTIES = new Set([ "$groups", "$insert_id", @@ -149,6 +155,7 @@ const SENT_PROPERTIES = new Set([ "$mcp_scope_source", MCP_USED_PROJECT_ID_PROPERTY, MCP_USED_PROJECT_PROPERTY, + MCP_DEPRECATED_PARAMS_PROPERTY, MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY, MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY, MCP_CLIENT_ELICITATION_MODE_PROPERTY, @@ -332,6 +339,20 @@ function annotateProjectParamUsage(properties: Record) { properties[MCP_USED_PROJECT_PROPERTY] = hasNonEmptyParam(args, "project"); } +function annotateDeprecatedParamUsage(properties: Record) { + const toolName = properties[PostHogMCPAnalyticsProperty.ToolName]; + if ( + typeof toolName !== "string" || + !Object.prototype.hasOwnProperty.call(DEPRECATED_TOOL_PARAMS, toolName) + ) { + return; + } + properties[MCP_DEPRECATED_PARAMS_PROPERTY] = deprecatedParamsUsed( + toolCallArguments(properties) ?? {}, + DEPRECATED_TOOL_PARAMS[toolName as keyof typeof DEPRECATED_TOOL_PARAMS], + ); +} + const IPV6_CANDIDATE_PATTERN = /(? { enrichMcpAnalyticsEvent(event); if (event.event === PostHogMCPAnalyticsEvent.ToolCall) { annotateProjectParamUsage(properties); + annotateDeprecatedParamUsage(properties); const errorMessage = properties[PostHogMCPAnalyticsProperty.ErrorMessage]; if ( properties[PostHogMCPAnalyticsProperty.ToolName] === diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index cccabe8..4428b31 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +1,2 @@ // Generated by scripts/build-managed-auth-app.mjs. Do not edit. -export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index 49faa4b..186d119 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -39,6 +39,7 @@ function sanitizeBeginArguments(input: JsonObject): JsonObject { "project_id", "save_credentials", "record_session", + "browser", "browser_telemetry", "region", "proxy_id", diff --git a/src/lib/mcp/deprecated-params.test.ts b/src/lib/mcp/deprecated-params.test.ts new file mode 100644 index 0000000..9091975 --- /dev/null +++ b/src/lib/mcp/deprecated-params.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { DEPRECATED_TOOL_PARAMS } from "@/lib/mcp/deprecated-params"; +import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures"; +import { registerMcpCapabilities } from "@/lib/mcp/register"; + +describe("deprecated tool params", () => { + test("every tracked param is advertised and described as deprecated", async () => { + const mcp = await connectTestMcp((server) => { + registerMcpCapabilities(server, { + mcpApps: true, + vaults: true, + search: true, + }); + }, {}); + try { + const { tools } = await mcp.client.listTools(); + for (const [toolName, params] of Object.entries(DEPRECATED_TOOL_PARAMS)) { + const properties = tools.find((tool) => tool.name === toolName) + ?.inputSchema.properties as + | Record + | undefined; + for (const param of params) { + expect( + properties?.[param]?.description, + `${toolName}.${param}`, + ).toStartWith("deprecated: "); + } + } + } finally { + await mcp.close(); + } + }); +}); diff --git a/src/lib/mcp/deprecated-params.ts b/src/lib/mcp/deprecated-params.ts new file mode 100644 index 0000000..926e1ef --- /dev/null +++ b/src/lib/mcp/deprecated-params.ts @@ -0,0 +1,55 @@ +// Flat inputs kept as aliases for the nested fields that mirror the KERNEL api. +// Tool-call analytics records which of these each call used, so they can be +// removed once usage drops off. +const AUTH_LOGIN_DEPRECATED_PARAMS = [ + "proxy_id", + "proxy_name", + "region", + "browser_telemetry", +] as const; + +export const DEPRECATED_TOOL_PARAMS = { + manage_browsers: [ + "proxy_id", + "clear_proxy", + "disable_default_proxy", + "proxy_routes", + ], + manage_auth_connections: [ + "proxy_id", + "proxy_name", + "proxy_mode", + "browser_region", + "browser_stealth", + "browser_telemetry", + ], + open_auth_login: AUTH_LOGIN_DEPRECATED_PARAMS, + begin_auth_login: AUTH_LOGIN_DEPRECATED_PARAMS, + manage_proxies: [ + "country", + "city", + "state", + "custom_host", + "custom_port", + "custom_username", + "custom_password", + ], +} as const satisfies Record; + +export function deprecatedParamsUsed( + params: Record, + deprecated: readonly string[], +): string[] { + return deprecated.filter((key) => params[key] !== undefined); +} + +export function deprecatedParamConflict( + field: string, + params: Record, + deprecated: readonly string[], +): string | undefined { + const used = deprecatedParamsUsed(params, deprecated); + return used.length > 0 + ? `${field} cannot be combined with ${used.join(", ")}.` + : undefined; +} diff --git a/src/lib/mcp/prompts.test.ts b/src/lib/mcp/prompts.test.ts index 7ed28b5..5038b54 100644 --- a/src/lib/mcp/prompts.test.ts +++ b/src/lib/mcp/prompts.test.ts @@ -16,6 +16,8 @@ const API_FIELD_NAMES = new Set([ "manage_browsers:captcha", "manage_browser_pools:stealth", "manage_auth_connections:browser_stealth", + "manage_auth_connections:stealth", + "manage_proxies:bypass_hosts", "manage_auth_connections:captcha", "open_auth_login:captcha", "begin_auth_login:captcha", diff --git a/src/lib/mcp/proxy-config.ts b/src/lib/mcp/proxy-config.ts new file mode 100644 index 0000000..2910397 --- /dev/null +++ b/src/lib/mcp/proxy-config.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export function proxyConfigSchema() { + return z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1).optional(), + mode: z.enum(["direct", "default"]).optional(), + }); +} + +export function proxySelectorSchema() { + return z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1).optional(), + }); +} + +export type ProxyConfig = z.infer>; +type ProxySelector = z.infer>; + +function selectedCount(value: ProxyConfig) { + return Object.values(value).filter((v) => v !== undefined).length; +} + +export function proxyConfigError(field: string, proxy: ProxyConfig) { + return selectedCount(proxy) === 1 + ? undefined + : `${field} requires exactly one of id, name, or mode.`; +} + +export function proxySelectorError(field: string, proxy: ProxySelector) { + return selectedCount(proxy) === 1 + ? undefined + : `${field} requires exactly one of id or name.`; +} diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index ef96dcd..f667a6b 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -225,6 +225,99 @@ describe("manage_auth_connections programmatic surface", () => { } }); + test("forwards the nested browser object on create, update, and login", async () => { + const { handler } = captureHandler(); + const bodies: unknown[] = []; + kernelClientMock.factory = () => ({ + auth: { + connections: { + create: async (body: unknown) => { + bodies.push(body); + return connection(); + }, + update: async (_id: string, body: unknown) => { + bodies.push(body); + return connection(); + }, + login: async (_id: string, body: unknown) => { + bodies.push(body); + return { id: "conn_1" }; + }, + }, + }, + }); + try { + const extra = { authInfo: { token: "test-token" } }; + const browser = { + proxy: { name: "residential" }, + region: "eu-west", + stealth: false, + telemetry: { enabled: true }, + }; + await handler( + { + action: "create", + domain: "example.com", + profile_name: "work", + browser, + }, + extra, + ); + await handler( + { + action: "update", + id: "conn_1", + browser: { proxy: { mode: "direct" } }, + }, + extra, + ); + await handler( + { + action: "login", + id: "conn_1", + browser: { proxy: { id: "proxy_1" } }, + }, + extra, + ); + expect(bodies).toEqual([ + { domain: "example.com", profile_name: "work", browser }, + { browser: { proxy: { mode: "direct" } } }, + { browser: { proxy: { id: "proxy_1" } } }, + ]); + } finally { + kernelClientMock.factory = () => unusedKernelClient; + } + }); + + test("rejects a nested browser object mixed with deprecated fields", async () => { + const { handler } = captureHandler(); + const extra = { authInfo: { token: "test-token" } }; + const cases: Array<[Record, string]> = [ + [ + { + action: "login", + id: "conn_1", + browser: { region: "eu-west" }, + proxy_name: "residential", + }, + "browser cannot be combined with proxy_name", + ], + [ + { + action: "update", + id: "conn_1", + browser: { proxy: { id: "proxy_1", mode: "direct" } }, + }, + "browser.proxy requires exactly one of id, name, or mode", + ], + ]; + for (const [params, message] of cases) { + const result = await handler(params, extra); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(message); + } + }); + test("forwards current connection settings on update", async () => { const { handler } = captureHandler(); let updateBody: unknown; diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 64243e3..6883e80 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -14,6 +14,11 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +import { + DEPRECATED_TOOL_PARAMS, + deprecatedParamConflict, +} from "@/lib/mcp/deprecated-params"; +import { proxyConfigError, proxyConfigSchema } from "@/lib/mcp/proxy-config"; import { projectForOperation, projectSelectionInputSchema, @@ -140,41 +145,70 @@ export function registerAuthConnectionTools(server: McpServer) { "(create, update) set the connection default for recording replay video of future login, reauth, and health-check browser sessions. (login) override that default for this login only. omitted preserves the api default or inherited value.", ) .optional(), + browser: z + .object({ + proxy: proxyConfigSchema() + .describe( + "proxy egress, set with exactly one of id, name, or mode. id or name selects that proxy; mode direct forces direct egress; mode default restores the default egress. omitted on create derives the default; omitted on update or login preserves or inherits the connection setting.", + ) + .optional(), + region: z + .enum(["us-east", "eu-west", "ap-southeast"]) + .describe( + "region for managed-auth browser sessions. defaults to us-east on create; omitted on update or login preserves or inherits the connection setting.", + ) + .optional(), + stealth: z + .boolean() + .describe( + "whether managed-auth browser sessions use site-compatibility settings. defaults to true on create; omitted on update or login preserves or inherits the connection setting.", + ) + .optional(), + telemetry: managedAuthBrowserTelemetrySchema + .describe( + "use { enabled: true } for the default operational categories (control, connection, system, captcha); browser category settings can opt into console, network, page, interaction, screenshot, or platform capture, tune control cdp exclusions, and configure otlp export. omitted preserves the api default or inherited value.", + ) + .optional(), + }) + .describe( + "(create, update) set the connection defaults for future managed-auth browser sessions. (login) override them for this login only. cannot be combined with the deprecated browser_*, proxy_id, proxy_name, or proxy_mode fields.", + ) + .optional(), browser_telemetry: managedAuthBrowserTelemetrySchema .describe( - "(create, update) set the connection default for browser telemetry. (login) override it for this login only. use { enabled: true } for the default operational categories (control, connection, system, captcha); browser category settings can opt into console, network, page, interaction, screenshot, or platform capture, tune control cdp exclusions, and configure otlp export. omitted preserves the api default or inherited value.", + "deprecated: use `browser.telemetry` instead. (create, update) set the connection default for browser telemetry. (login) override it for this login only. use { enabled: true } for the default operational categories (control, connection, system, captcha); browser category settings can opt into console, network, page, interaction, screenshot, or platform capture, tune control cdp exclusions, and configure otlp export. omitted preserves the api default or inherited value.", ) .optional(), browser_region: z .enum(["us-east", "eu-west", "ap-southeast"]) .describe( - "(create, update) set the region for future managed-auth browser sessions. (login) override the region for this login only. defaults to us-east on create; omitted on update or login preserves or inherits the connection setting.", + "deprecated: use `browser.region` instead. (create, update) set the region for future managed-auth browser sessions. (login) override the region for this login only. defaults to us-east on create; omitted on update or login preserves or inherits the connection setting.", ) .optional(), browser_stealth: z .boolean() .describe( - "(create, update, login) whether managed-auth browser sessions use site-compatibility settings. defaults to true on create; omitted on update or login preserves or inherits the connection setting.", + "deprecated: use the site-compatibility setting in `browser` instead. (create, update, login) whether managed-auth browser sessions use site-compatibility settings. defaults to true on create; omitted on update or login preserves or inherits the connection setting.", ) .optional(), proxy_id: z .string() .min(1) .describe( - "(create, update, login) proxy id to route managed-auth browser sessions through.", + "deprecated: use `browser.proxy.id` instead. (create, update, login) proxy id to route managed-auth browser sessions through.", ) .optional(), proxy_name: z .string() .min(1) .describe( - "(create, update, login) proxy name to route managed-auth browser sessions through.", + "deprecated: use `browser.proxy.name` instead. (create, update, login) proxy name to route managed-auth browser sessions through.", ) .optional(), proxy_mode: z .enum(["direct", "default"]) .describe( - "(create, update, login) proxy mode. direct disables proxy egress; default restores the session's default proxy setting. cannot be combined with proxy_id or proxy_name.", + "deprecated: use `browser.proxy.mode` instead. (create, update, login) proxy mode. direct disables proxy egress; default restores the session's default proxy setting. cannot be combined with proxy_id or proxy_name.", ) .optional(), domain_filter: z @@ -289,6 +323,11 @@ export function registerAuthConnectionTools(server: McpServer) { } : undefined; const buildBrowser = () => { + if (params.browser !== undefined) { + return Object.keys(params.browser).length > 0 + ? params.browser + : undefined; + } const proxy = buildProxy(); return params.browser_region !== undefined || params.browser_stealth !== undefined || @@ -351,6 +390,17 @@ export function registerAuthConnectionTools(server: McpServer) { }; try { + if (params.browser !== undefined) { + const error = + deprecatedParamConflict( + "browser", + params, + DEPRECATED_TOOL_PARAMS.manage_auth_connections, + ) ?? + (params.browser.proxy && + proxyConfigError("browser.proxy", params.browser.proxy)); + if (error) return errorResponse(`error: ${error}`); + } if (proxySelectors.length > 1) { return errorResponse( "error: provide exactly one of proxy_id, proxy_name, or proxy_mode.", diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index b8e0828..0860863 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -155,7 +155,7 @@ describe("managed-auth MCP App registration", () => { expect(schema.safeParse({ ...base, region: "emea" }).success).toBe(false); const defaults = schema.parse(base); expect(defaults.record_session).toBe(true); - expect(defaults.browser_telemetry).toEqual({ enabled: true }); + expect(defaults.browser_telemetry).toBeUndefined(); expect( schema.safeParse({ ...base, @@ -213,6 +213,43 @@ describe("managed-auth MCP App registration", () => { expect(result._meta).toBeUndefined(); }); + test("launcher validates the nested browser object before any flow starts", async () => { + const { tools } = captureRegistration(); + const base = { + mode: "new_login", + domain: "example.com", + profile_name: "work", + }; + const cases: Array<[Record, string]> = [ + [ + { ...base, browser: { region: "eu-west" }, region: "us-east" }, + "browser cannot be combined with region", + ], + [ + { ...base, browser: { proxy: { id: "proxy_1", name: "residential" } } }, + "proxy requires exactly one of id, name, or mode", + ], + [ + { ...base, proxy_id: "proxy_1", proxy_name: "residential" }, + "proxy requires exactly one of id, name, or mode", + ], + ]; + for (const [params, message] of cases) { + const result = await tools + .get("open_auth_login")! + .handler(params, projectScopedExtra("proj_test", "unused-api-key")); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(message); + } + const ok = await tools + .get("open_auth_login")! + .handler( + { ...base, browser: { proxy: { mode: "direct" }, region: "eu-west" } }, + projectScopedExtra("proj_test", "unused-api-key"), + ); + expect(ok.isError).toBeUndefined(); + }); + test("app-only tools fail closed on hosts without MCP Apps support", async () => { const { tools } = captureRegistration({ appsSupport: false }); const result = await tools diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 874043b..f39ed8c 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -19,13 +19,17 @@ import { } from "@/lib/mcp/tools/managed-auth-state"; import { managedAuthBrowserTelemetrySchema } from "@/lib/mcp/tools/managed-auth-telemetry"; import { errorResponse } from "@/lib/mcp/responses"; +import { + DEPRECATED_TOOL_PARAMS, + deprecatedParamConflict, +} from "@/lib/mcp/deprecated-params"; +import { proxyConfigSchema } from "@/lib/mcp/proxy-config"; import { projectForOperation, projectSelectionInputSchema, - type ProjectSelection, } from "@/lib/mcp/project-selection"; -type AuthLoginParams = AuthLoginInput & ProjectSelection; +type AuthLoginParams = z.infer>; export { initializeDeclaresMcpApps }; @@ -67,19 +71,48 @@ const authLoginInputSchema = () => "record replay video for this managed-auth flow and make it the connection default for new connections. defaults to true in the secure app.", ) .default(true), + browser: z + .object({ + proxy: proxyConfigSchema() + .describe( + "proxy egress, set with exactly one of id, name, or mode. id or name selects that proxy; mode direct forces direct egress; mode default restores the default egress.", + ) + .optional(), + region: z + .enum(["us-east", "eu-west", "ap-southeast"]) + .describe("region for the managed-auth browser session.") + .optional(), + telemetry: managedAuthBrowserTelemetrySchema + .describe( + "defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", + ) + .optional(), + }) + .describe( + "browser settings for this managed-auth flow. they become the connection defaults for a new login or override them for this reauth. cannot be combined with browser_telemetry, region, proxy_id, or proxy_name.", + ) + .optional(), browser_telemetry: managedAuthBrowserTelemetrySchema .describe( - "browser telemetry for this managed-auth flow and the connection default for new connections. defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", + "deprecated: use `browser.telemetry` instead. browser telemetry for this managed-auth flow and the connection default for new connections. defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", ) - .default({ enabled: true }), + .optional(), region: z .enum(["us-east", "eu-west", "ap-southeast"]) .describe( - "region for the managed-auth browser session. sets the connection default for a new login or overrides it for this reauth.", + "deprecated: use `browser.region` instead. region for the managed-auth browser session. sets the connection default for a new login or overrides it for this reauth.", ) .optional(), - proxy_id: z.string().min(1).optional(), - proxy_name: z.string().min(1).optional(), + proxy_id: z + .string() + .min(1) + .describe("deprecated: use `browser.proxy.id` instead.") + .optional(), + proxy_name: z + .string() + .min(1) + .describe("deprecated: use `browser.proxy.name` instead.") + .optional(), }); function waitAction( @@ -100,7 +133,28 @@ function waitAction( }; } +function browserParamConflict(params: AuthLoginParams) { + return params.browser + ? deprecatedParamConflict( + "browser", + params, + DEPRECATED_TOOL_PARAMS.open_auth_login, + ) + : undefined; +} + function inputFromParams(params: AuthLoginParams): AuthLoginInput { + const { browser } = params; + const telemetry = browser ? browser.telemetry : params.browser_telemetry; + const region = browser ? browser.region : params.region; + const proxy = browser + ? browser.proxy + : params.proxy_id || params.proxy_name + ? { + ...(params.proxy_id && { id: params.proxy_id }), + ...(params.proxy_name && { name: params.proxy_name }), + } + : undefined; return { mode: params.mode, ...(params.connection_id && { connection_id: params.connection_id }), @@ -110,10 +164,9 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { save_credentials: params.save_credentials, }), record_session: params.record_session ?? true, - browser_telemetry: params.browser_telemetry ?? { enabled: true }, - ...(params.region && { region: params.region }), - ...(params.proxy_id && { proxy_id: params.proxy_id }), - ...(params.proxy_name && { proxy_name: params.proxy_name }), + browser_telemetry: telemetry ?? { enabled: true }, + ...(region && { region }), + ...(proxy && { proxy }), }; } @@ -166,6 +219,8 @@ export function registerAuthLoginApp(server: McpServer) { async (params, ctx) => { if (!ctx.http?.authInfo) throw new Error("authentication required"); const project = projectForOperation(ctx.http.authInfo, params); + const conflict = browserParamConflict(params); + if (conflict) return errorResponse(`error: ${conflict}`); const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); if (validationError) return errorResponse(`error: ${validationError}`); @@ -262,6 +317,8 @@ export function registerAuthLoginApp(server: McpServer) { ); if (gateError) return errorResponse(gateError); const project = projectForOperation(ctx.http.authInfo, params); + const conflict = browserParamConflict(params); + if (conflict) return errorResponse(`error: ${conflict}`); const input = inputFromParams(params); const validationError = validateAuthLoginInput(input); if (validationError) return errorResponse(`error: ${validationError}`); diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index b79391b..35c90b4 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -351,7 +351,10 @@ describe("manage_browsers proxy routes", () => { try { const { tools } = await client.listTools(); const browser = tools.find(({ name }) => name === "manage_browsers"); - const routes = browser?.inputSchema.properties?.proxy_routes as + const network = browser?.inputSchema.properties?.network as + | { properties?: Record } + | undefined; + const routes = network?.properties?.proxy_routes as | { description?: string; maxItems?: number; @@ -469,6 +472,93 @@ describe("manage_browsers proxy routes", () => { } }); + test("passes network.proxy_routes through SDK create unchanged", async () => { + const requests: unknown[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + { + browsers: { + create: async (params: unknown) => { + requests.push(params); + return { session_id: "brr_123" }; + }, + }, + }, + ); + try { + const network = { + proxy_routes: [ + { hosts: ["example.com"], proxy: { id: "prx_route" } }, + { hosts: ["*.example.org"], proxy: { name: "backup" } }, + ], + }; + const result = await client.callTool({ + name: "manage_browsers", + arguments: { action: "create", proxy: { name: "default" }, network }, + }); + expect(result.isError).toBeFalsy(); + expect(requests).toEqual([{ proxy: { name: "default" }, network }]); + } finally { + await close(); + } + }); + + test("rejects invalid network routes before SDK create", async () => { + let creates = 0; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + { + browsers: { + create: async () => { + creates++; + return { session_id: "brr_123" }; + }, + }, + }, + ); + try { + const cases: Array<[Record, string]> = [ + [ + { + network: { proxy_routes: [{ hosts: ["example.com"], proxy: {} }] }, + }, + "network.proxy_routes[0].proxy requires exactly one of id or name", + ], + [ + { + network: { + proxy_routes: [ + { + hosts: ["example.com"], + proxy: { id: "prx_route", name: "backup" }, + }, + ], + }, + }, + "network.proxy_routes[0].proxy requires exactly one of id or name", + ], + [ + { + network: { proxy_routes: [] }, + proxy_routes: [{ hosts: ["example.com"], proxy_id: "prx_route" }], + }, + "network cannot be combined with proxy_routes", + ], + ]; + for (const [args, message] of cases) { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { action: "create", ...args }, + }); + expect(result.isError).toBe(true); + expect(toolResultText(result)).toContain(message); + } + expect(creates).toBe(0); + } finally { + await close(); + } + }); + test("rejects routes on update without calling the SDK", async () => { let updates = 0; const { client, close } = await connectTestMcp( @@ -568,7 +658,11 @@ describe("manage_browsers proxy", () => { const invalid = [ { action: "create", proxy: {} }, { action: "create", proxy: { id: "prx_123", name: "residential" } }, - { action: "create", proxy: { name: "residential" }, proxy_id: "prx_123" }, + { + action: "create", + proxy: { name: "residential" }, + proxy_id: "prx_123", + }, { action: "update", session_id: "brr_123", diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 8d689ec..11e9b43 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -25,6 +25,13 @@ import { } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { browserVaultsSchema } from "@/lib/mcp/vault-schemas"; +import { deprecatedParamConflict } from "@/lib/mcp/deprecated-params"; +import { + proxyConfigError, + proxyConfigSchema, + proxySelectorError, + proxySelectorSchema, +} from "@/lib/mcp/proxy-config"; import { projectForOperation, projectSelectionInputSchema, @@ -530,12 +537,7 @@ export function registerBrowserCapabilities( "(create, update) save session changes back to profile on close.", ) .optional(), - proxy: z - .object({ - id: z.string().min(1).optional(), - name: z.string().min(1).optional(), - mode: z.enum(["direct", "default"]).optional(), - }) + proxy: proxyConfigSchema() .describe( "(create, update) proxy egress, set with exactly one of id, name, or mode. id or name selects that proxy. mode direct forces direct egress; mode default restores the browser's default egress. on create, omit for the browser default; on update, omit to leave unchanged. cannot be combined with proxy_id, clear_proxy, or disable_default_proxy.", ) @@ -543,7 +545,26 @@ export function registerBrowserCapabilities( proxy_id: z .string() .describe( - "(create, update) deprecated: use proxy.id. proxy id for traffic routing. for update, omit to leave unchanged.", + "deprecated: use `proxy.id` instead. (create, update) proxy id for traffic routing. for update, omit to leave unchanged.", + ) + .optional(), + network: z + .object({ + proxy_routes: z + .array( + z.object({ + hosts: z.array(z.string().min(1)).min(1).max(50), + proxy: proxySelectorSchema(), + }), + ) + .max(10) + .describe( + 'route requests for 1–50 host patterns per route through a proxy selected by exactly one of proxy.id or proxy.name (max 10 routes). use exact hostnames or leading "*." wildcards, which match subdomains only, not the apex. matching ignores case and ports; the most specific match wins. matched hosts override the top-level proxy; unmatched hosts use the top-level proxy or the browser default. start_url uses the top-level proxy, not routes. if a route proxy is unavailable, matched requests fail closed.', + ) + .optional(), + }) + .describe( + "(create only) network settings for the browser session. cannot be combined with proxy_routes.", ) .optional(), proxy_routes: z @@ -556,19 +577,19 @@ export function registerBrowserCapabilities( ) .max(10) .describe( - '(create only) route requests for 1–50 host patterns per route through a proxy selected by exactly one of proxy_id or proxy_name (max 10 routes). use exact hostnames or leading "*." wildcards, which match subdomains only, not the apex. matching ignores case and ports; the most specific match wins. matched hosts override the top-level proxy; unmatched hosts use the top-level proxy or the browser default. start_url uses the top-level proxy, not routes. if a route proxy is unavailable, matched requests fail closed.', + "deprecated: use `network.proxy_routes` instead. (create only) the same routes, with each proxy selected by exactly one of proxy_id or proxy_name.", ) .optional(), clear_proxy: z .boolean() .describe( - "(update) deprecated: use proxy.mode=default. remove the current proxy from the browser session.", + "deprecated: use `proxy.mode` default instead. (update) remove the current proxy from the browser session.", ) .optional(), disable_default_proxy: z .boolean() .describe( - "(update) deprecated: use proxy.mode=direct. connect directly instead of through the session's default KERNEL-managed proxy.", + "deprecated: use `proxy.mode` direct instead. (update) connect directly instead of through the session's default KERNEL-managed proxy.", ) .optional(), kiosk_mode: z @@ -716,27 +737,36 @@ export function registerBrowserCapabilities( "vault bindings are creation-only; they cannot be added to an existing browser.", ); } - if (params.proxy_routes !== undefined && params.action !== "create") { + if ( + (params.network !== undefined || params.proxy_routes !== undefined) && + params.action !== "create" + ) { return errorResponse( "proxy routes are creation-only; they cannot be added to an existing browser.", ); } if (params.proxy !== undefined) { - const { id, name, mode } = params.proxy; - if ([id, name, mode].filter(Boolean).length !== 1) { - return errorResponse( - "error: proxy requires exactly one of id, name, or mode.", - ); - } - if ( - params.proxy_id !== undefined || - params.clear_proxy !== undefined || - params.disable_default_proxy !== undefined - ) { - return errorResponse( - "error: proxy cannot be combined with proxy_id, clear_proxy, or disable_default_proxy.", - ); - } + const error = + proxyConfigError("proxy", params.proxy) ?? + deprecatedParamConflict("proxy", params, [ + "proxy_id", + "clear_proxy", + "disable_default_proxy", + ]); + if (error) return errorResponse(`error: ${error}`); + } + if (params.network !== undefined) { + const error = + deprecatedParamConflict("network", params, ["proxy_routes"]) ?? + params.network.proxy_routes + ?.map((route, i) => + proxySelectorError( + `network.proxy_routes[${i}].proxy`, + route.proxy, + ), + ) + .find(Boolean); + if (error) return errorResponse(`error: ${error}`); } switch (params.action) { case "create": { @@ -762,6 +792,8 @@ export function registerBrowserCapabilities( } if (params.proxy !== undefined) createParams.proxy = params.proxy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; + if (params.network !== undefined) + createParams.network = params.network; if (params.proxy_routes !== undefined) { const proxyRoutes: Array = []; for (const { diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 654980e..88c477c 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -109,6 +109,23 @@ describe("managed-auth start/resume state machine", () => { }); }); + test("secure App login forwards the selected proxy", async () => { + const initial = connection(); + const { client, calls } = fakeClient({ initial }); + await beginAuthLogin(client, { + mode: "new_login", + domain: "example.com", + profile_name: "work", + proxy: { mode: "direct" }, + }); + expect(calls.createParams).toMatchObject({ + browser: { proxy: { mode: "direct" } }, + }); + expect(calls.loginParams).toMatchObject({ + browser: { proxy: { mode: "direct" } }, + }); + }); + test("explicit reauth starts login even when authenticated", async () => { const initial = connection({ status: "AUTHENTICATED" }); const { client, calls } = fakeClient({ initial }); diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 6bb8295..0e47e38 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -9,6 +9,7 @@ import { verifyAuthFlowCheckpoint, } from "@/lib/mcp/tools/managed-auth-checkpoint"; import type { ManagedAuthBrowserTelemetry } from "@/lib/mcp/tools/managed-auth-telemetry"; +import { proxyConfigError, type ProxyConfig } from "@/lib/mcp/proxy-config"; export type ManagedAuthRegion = "us-east" | "eu-west" | "ap-southeast"; @@ -55,8 +56,7 @@ export interface AuthLoginInput { record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; region?: ManagedAuthRegion; - proxy_id?: string; - proxy_name?: string; + proxy?: ProxyConfig; } export class AuthLoginStartError extends Error { @@ -346,9 +346,8 @@ export async function waitForAuthConnection( } export function validateAuthLoginInput(input: AuthLoginInput): string | null { - if (input.proxy_id && input.proxy_name) { - return "proxy_id and proxy_name cannot be used together."; - } + const proxyError = input.proxy && proxyConfigError("proxy", input.proxy); + if (proxyError) return proxyError; if (input.mode === "new_login") { if (!input.domain || !input.profile_name) { @@ -458,13 +457,6 @@ export async function beginAuthLogin( const recordSession = input.record_session ?? true; const browserTelemetry = input.browser_telemetry ?? { enabled: true }; - const proxy = - input.proxy_id || input.proxy_name - ? { - ...(input.proxy_id && { id: input.proxy_id }), - ...(input.proxy_name && { name: input.proxy_name }), - } - : undefined; let connection: ManagedAuth; if (input.mode === "new_login") { @@ -479,7 +471,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(proxy && { proxy }), + ...(input.proxy && { proxy: input.proxy }), }, }); } catch (error) { @@ -530,7 +522,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(proxy && { proxy }), + ...(input.proxy && { proxy: input.proxy }), }, }); let current = withLoginState(connection, login); diff --git a/src/lib/mcp/tools/proxies.test.ts b/src/lib/mcp/tools/proxies.test.ts new file mode 100644 index 0000000..47ed2c6 --- /dev/null +++ b/src/lib/mcp/tools/proxies.test.ts @@ -0,0 +1,150 @@ +/// +import { describe, expect, test } from "bun:test"; +import { connectTestMcp, toolResultJSON } from "@/lib/mcp/mcp-test-fixtures"; +import { registerProxyTools } from "@/lib/mcp/tools/proxies"; + +function proxyClient(creates: unknown[], lists: unknown[] = []) { + return { + proxies: { + create: async (params: unknown) => { + creates.push(params); + return { id: "proxy-1" }; + }, + list: async (params: unknown) => { + lists.push(params); + return { getPaginatedItems: () => [], has_more: false }; + }, + }, + }; +} + +describe("manage_proxies", () => { + test("passes config, bypass_hosts, and protocol through SDK create", async () => { + const creates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient(creates), + ); + try { + const residential = { + action: "create", + type: "residential", + name: "us-residential", + config: { country: "US", state: "OH", zip: "45202", asn: "7922" }, + bypass_hosts: ["internal.example.com"], + protocol: "https", + }; + const custom = { + action: "create", + type: "custom", + config: { host: "proxy.example.com", port: 8080, ca_bundle: "pem" }, + }; + for (const args of [residential, custom]) { + const result = await client.callTool({ + name: "manage_proxies", + arguments: args, + }); + expect(toolResultJSON(result)).toEqual({ id: "proxy-1" }); + } + expect(creates).toEqual([ + { + type: "residential", + name: "us-residential", + config: residential.config, + bypass_hosts: residential.bypass_hosts, + protocol: "https", + }, + { type: "custom", config: custom.config }, + ]); + } finally { + await close(); + } + }); + + test("keeps the deprecated flat create fields working", async () => { + const creates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient(creates), + ); + try { + await client.callTool({ + name: "manage_proxies", + arguments: { action: "create", type: "isp", country: "US" }, + }); + await client.callTool({ + name: "manage_proxies", + arguments: { + action: "create", + type: "custom", + custom_host: "proxy.example.com", + custom_port: 8080, + }, + }); + expect(creates).toEqual([ + { type: "isp", config: { country: "US" } }, + { + type: "custom", + config: { host: "proxy.example.com", port: 8080 }, + }, + ]); + } finally { + await close(); + } + }); + + test("rejects config mixed with deprecated fields or missing custom host and port", async () => { + const creates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient(creates), + ); + try { + const cases: Array<[Record, string]> = [ + [ + { type: "isp", config: { country: "US" }, country: "CA" }, + "config cannot be combined with country", + ], + [ + { type: "custom", config: { host: "proxy.example.com" } }, + "config.host and config.port are required", + ], + ]; + for (const [args, message] of cases) { + const result = await client.callTool({ + name: "manage_proxies", + arguments: { action: "create", ...args }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain(message); + } + expect(creates).toEqual([]); + } finally { + await close(); + } + }); + + test("passes name and query filters to list", async () => { + const lists: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient([], lists), + ); + try { + await client.callTool({ + name: "manage_proxies", + arguments: { action: "list", name: "us-residential" }, + }); + await client.callTool({ + name: "manage_proxies", + arguments: { action: "list", query: "residential", limit: 5 }, + }); + expect(lists).toEqual([ + { name: "us-residential" }, + { query: "residential", limit: 5 }, + ]); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 9b7b18c..5017e91 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -12,6 +12,10 @@ import { throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; +import { + DEPRECATED_TOOL_PARAMS, + deprecatedParamConflict, +} from "@/lib/mcp/deprecated-params"; import { projectForOperation, projectSelectionInputSchema, @@ -32,6 +36,64 @@ const httpUrlSchema = z { message: "url must use http or https." }, ); +const proxyCreateConfigSchema = z.object({ + country: z + .string() + .describe('iso 3166 country code (e.g., "US").') + .optional(), + state: z.string().describe("two-letter state code.").optional(), + city: z + .string() + .describe( + "city name without spaces (e.g., 'sanfrancisco'). requires country.", + ) + .optional(), + zip: z.string().describe("(residential) us zip code.").optional(), + asn: z + .string() + .describe("(residential) autonomous system number.") + .optional(), + host: z.string().describe("(custom) proxy host address.").optional(), + port: z.number().int().describe("(custom) proxy port.").optional(), + username: z.string().describe("(custom) auth username.").optional(), + password: z.string().describe("(custom) auth password.").optional(), + ca_bundle: z + .string() + .describe( + "(custom) pem-encoded ca certificate bundle the proxy re-signs upstream tls with. provide when the proxy terminates tls.", + ) + .optional(), +}); + +function legacyProxyConfig(params: { + type?: string; + country?: string; + city?: string; + state?: string; + custom_host?: string; + custom_port?: number; + custom_username?: string; + custom_password?: string; +}): z.infer | undefined { + if (params.type === "custom") { + return params.custom_host || params.custom_port + ? { + host: params.custom_host, + port: params.custom_port, + ...(params.custom_username && { username: params.custom_username }), + ...(params.custom_password && { password: params.custom_password }), + } + : undefined; + } + return params.country || params.city || params.state + ? { + ...(params.country && { country: params.country }), + ...(params.city && { city: params.city }), + ...(params.state && { state: params.state }), + } + : undefined; +} + export function registerProxyTools( server: McpServer, options: McpDependencies = { @@ -64,37 +126,72 @@ export function registerProxyTools( .optional(), name: z .string() - .describe("(create, rename) readable name for the proxy.") + .describe( + "(create, rename) readable name for the proxy. (list) exact-match name filter; names are not unique, so several proxies can match.", + ) + .optional(), + query: z + .string() + .describe( + "(list) case-insensitive substring match against proxy name, host, or ip address. ids match by exact value.", + ) + .optional(), + config: proxyCreateConfigSchema + .describe( + "(create) settings for the selected type. datacenter and isp accept country; residential accepts country, state, city, zip, and asn; mobile accepts country, state, and city; custom requires host and port. cannot be combined with the deprecated country, city, state, or custom_* fields.", + ) + .optional(), + bypass_hosts: z + .array(z.string().min(1)) + .describe( + "(create) hostnames that connect directly instead of through this proxy.", + ) + .optional(), + protocol: z + .enum(["http", "https"]) + .describe("(create) protocol for the proxy connection.") .optional(), country: z .string() - .describe('(create) iso 3166 country code (e.g., "US").') + .describe( + 'deprecated: use `config.country` instead. (create) iso 3166 country code (e.g., "US").', + ) .optional(), city: z .string() .describe( - "(create) city name without spaces (e.g., 'sanfrancisco'). requires country.", + "deprecated: use `config.city` instead. (create) city name without spaces (e.g., 'sanfrancisco'). requires country.", ) .optional(), state: z .string() - .describe("(create) two-letter state code.") + .describe( + "deprecated: use `config.state` instead. (create) two-letter state code.", + ) .optional(), custom_host: z .string() - .describe("(create, custom type) proxy host address.") + .describe( + "deprecated: use `config.host` instead. (create, custom type) proxy host address.", + ) .optional(), custom_port: z .number() - .describe("(create, custom type) proxy port.") + .describe( + "deprecated: use `config.port` instead. (create, custom type) proxy port.", + ) .optional(), custom_username: z .string() - .describe("(create, custom type) auth username.") + .describe( + "deprecated: use `config.username` instead. (create, custom type) auth username.", + ) .optional(), custom_password: z .string() - .describe("(create, custom type) auth password.") + .describe( + "deprecated: use `config.password` instead. (create, custom type) auth password.", + ) .optional(), ...paginationParams, }), @@ -118,47 +215,39 @@ export function registerProxyTools( case "create": { if (!params.type) return errorResponse("error: type is required for create."); - if ( - params.type === "custom" && - (!params.custom_host || !params.custom_port) - ) { + if (params.config !== undefined) { + const conflict = deprecatedParamConflict( + "config", + params, + DEPRECATED_TOOL_PARAMS.manage_proxies, + ); + if (conflict) return errorResponse(`error: ${conflict}`); + } + const config = params.config ?? legacyProxyConfig(params); + if (params.type === "custom" && (!config?.host || !config.port)) { return errorResponse( - "error: custom_host and custom_port are required for custom proxy type.", + params.config + ? "error: config.host and config.port are required for custom proxy type." + : "error: custom_host and custom_port are required for custom proxy type.", ); } - const createParams: Parameters[0] = - params.type === "custom" - ? { - type: params.type, - ...(params.name && { name: params.name }), - config: { - host: params.custom_host!, - port: params.custom_port!, - ...(params.custom_username && { - username: params.custom_username, - }), - ...(params.custom_password && { - password: params.custom_password, - }), - }, - } - : { - type: params.type, - ...(params.name && { name: params.name }), - ...((params.country || params.city || params.state) && { - config: { - ...(params.country && { country: params.country }), - ...(params.city && { city: params.city }), - ...(params.state && { state: params.state }), - }, - }), - }; + const createParams: Parameters[0] = { + type: params.type, + ...(params.name && { name: params.name }), + ...(config && { config }), + ...(params.bypass_hosts !== undefined && { + bypass_hosts: params.bypass_hosts, + }), + ...(params.protocol && { protocol: params.protocol }), + }; const proxy = await client.proxies.create(createParams); if (!proxy) return errorResponse("failed to create proxy"); return jsonResponse(proxy); } case "list": { const page = await client.proxies.list({ + ...(params.name && { name: params.name }), + ...(params.query && { query: params.query }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); From c66ea8110af6b8cf4597cccf465b299c7c9e64a9 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:27:30 +0000 Subject: [PATCH 3/6] Assert reshaped tool schemas advertise no $ref --- src/lib/mcp/deprecated-params.test.ts | 6 +++++- src/lib/mcp/tools/auth-connections.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/deprecated-params.test.ts b/src/lib/mcp/deprecated-params.test.ts index 9091975..b33458f 100644 --- a/src/lib/mcp/deprecated-params.test.ts +++ b/src/lib/mcp/deprecated-params.test.ts @@ -4,7 +4,7 @@ import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures"; import { registerMcpCapabilities } from "@/lib/mcp/register"; describe("deprecated tool params", () => { - test("every tracked param is advertised and described as deprecated", async () => { + test("tracked tools advertise each deprecated param without schema references", async () => { const mcp = await connectTestMcp((server) => { registerMcpCapabilities(server, { mcpApps: true, @@ -19,6 +19,10 @@ describe("deprecated tool params", () => { ?.inputSchema.properties as | Record | undefined; + expect( + JSON.stringify(properties), + `${toolName} input schema`, + ).not.toContain('"$ref"'); for (const param of params) { expect( properties?.[param]?.description, diff --git a/src/lib/mcp/tools/auth-connections.test.ts b/src/lib/mcp/tools/auth-connections.test.ts index f667a6b..16b2a66 100644 --- a/src/lib/mcp/tools/auth-connections.test.ts +++ b/src/lib/mcp/tools/auth-connections.test.ts @@ -97,7 +97,7 @@ describe("manage_auth_connections programmatic surface", () => { } expect(browserTelemetry).toBeDefined(); - expect(JSON.stringify(browserTelemetry)).not.toContain('"$ref"'); + expect(JSON.stringify(tool?.inputSchema)).not.toContain('"$ref"'); } finally { await close(); } From 266e72ca316f9a255be3e230e502edc485657024 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:42:37 +0000 Subject: [PATCH 4/6] Expose private hosts and auth stealth options --- README.md | 4 +- src/lib/mcp/tools/auth-login-app.test.ts | 47 +++++++++++++------- src/lib/mcp/tools/auth-login-app.ts | 8 ++++ src/lib/mcp/tools/browsers.test.ts | 1 + src/lib/mcp/tools/browsers.ts | 6 +++ src/lib/mcp/tools/managed-auth-start.test.ts | 13 ++++++ src/lib/mcp/tools/managed-auth-state.ts | 3 ++ 7 files changed, 65 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ad413c7..83e4634 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Self-hosted deployments can select tool families with `KERNEL_MCP_ENABLED_TOOLSE Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools advertise an optional `project` (name or ID) and a deprecated `project_id`: organization-wide connections may omit them to preserve organization-wide reads and API default-project behavior, while fixed-project connections may omit them or pass the matching project. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it. -Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `config` objects replace older flat inputs such as `proxy_id`, `proxy_routes`, `browser_region`, and `custom_host`. The flat inputs remain as deprecated aliases, and combining one with its nested replacement is rejected. Tool-call analytics records the names, never the values, of deprecated inputs each call used in `$mcp_deprecated_params`; the tracked list lives in `src/lib/mcp/deprecated-params.ts`. +Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `config` objects replace older flat inputs such as `proxy_id`, `proxy_routes`, `browser_region`, and `custom_host`. Browser `network` settings include both `private_hosts` and `proxy_routes`. The flat inputs remain as deprecated aliases, and combining one with its nested replacement is rejected. Tool-call analytics records the names, never the values, of deprecated inputs each call used in `$mcp_deprecated_params`; the tracked list lives in `src/lib/mcp/deprecated-params.ts`. ### manage\_\* tools @@ -445,7 +445,7 @@ Returns: the REPL ID, ordered text output, and screenshot. Later browser_repl ca Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. -The secure App defaults `record_session` and `browser.telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `browser.region` in `open_auth_login` or `manage_auth_connections` to choose where a managed-auth browser runs, and `browser.proxy` to choose its proxy by id, name, or mode. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass the `browser` object through to the API unchanged, preserving defaults and inheritance when it is omitted. +The secure App defaults `record_session` and `browser.telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `browser.region` in `open_auth_login` or `manage_auth_connections` to choose where a managed-auth browser runs, `browser.proxy` to choose its proxy by id, name, or mode, and `browser.stealth` to enable or disable stealth for the login flow. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass the `browser` object through to the API unchanged, preserving defaults and inheritance when it is omitted. ### Set up browser profiles for authentication diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 0860863..8f3ea5f 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -153,6 +153,9 @@ describe("managed-auth MCP App registration", () => { true, ); expect(schema.safeParse({ ...base, region: "emea" }).success).toBe(false); + expect( + schema.safeParse({ ...base, browser: { stealth: false } }).success, + ).toBe(true); const defaults = schema.parse(base); expect(defaults.record_session).toBe(true); expect(defaults.browser_telemetry).toBeUndefined(); @@ -241,12 +244,17 @@ describe("managed-auth MCP App registration", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain(message); } - const ok = await tools - .get("open_auth_login")! - .handler( - { ...base, browser: { proxy: { mode: "direct" }, region: "eu-west" } }, - projectScopedExtra("proj_test", "unused-api-key"), - ); + const ok = await tools.get("open_auth_login")!.handler( + { + ...base, + browser: { + proxy: { mode: "direct" }, + region: "eu-west", + stealth: false, + }, + }, + projectScopedExtra("proj_test", "unused-api-key"), + ); expect(ok.isError).toBeUndefined(); }); @@ -268,6 +276,7 @@ describe("managed-auth MCP App registration", () => { "%s stateless requests pass the App gate", async (era) => { redisMarkerPresent = era === "legacy"; + let loginParams: unknown; kernelClientMock.factory = () => ({ auth: { connections: { @@ -278,14 +287,17 @@ describe("managed-auth MCP App registration", () => { status: "AUTHENTICATED", flow_expires_at: "2026-01-01T00:00:00Z", }), - login: async () => ({ - id: "conn_1", - flow_type: "REAUTH", - flow_expires_at: "2099-01-01T00:00:00Z", - hosted_url: - "https://managed-auth.onkernel.com/login/conn_1?code=handoff-secret", - handoff_code: "handoff-secret", - }), + login: async (_connectionId: string, params: unknown) => { + loginParams = params; + return { + id: "conn_1", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + hosted_url: + "https://managed-auth.onkernel.com/login/conn_1?code=handoff-secret", + handoff_code: "handoff-secret", + }; + }, timeline: async () => ({ getPaginatedItems: () => [] }), }, }, @@ -293,7 +305,11 @@ describe("managed-auth MCP App registration", () => { try { const { tools } = captureRegistration({ appsSupport: false }); const result = await tools.get("begin_auth_login")!.handler( - { mode: "reauth", connection_id: "conn_1" }, + { + mode: "reauth", + connection_id: "conn_1", + browser: { stealth: false }, + }, { ...projectScopedExtra("proj_test", "unused-api-key"), mcpReq: { @@ -324,6 +340,7 @@ describe("managed-auth MCP App registration", () => { }, ); expect(result.isError).toBeUndefined(); + expect(loginParams).toMatchObject({ browser: { stealth: false } }); expect(result.structuredContent.kind).toBe("kernel.managed_auth.begin"); expect(result.structuredContent.next_action).toMatchObject({ tool: "manage_auth_connections", diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index f39ed8c..950142b 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -82,6 +82,12 @@ const authLoginInputSchema = () => .enum(["us-east", "eu-west", "ap-southeast"]) .describe("region for the managed-auth browser session.") .optional(), + stealth: z + .boolean() + .describe( + "whether the managed-auth browser session uses stealth mode.", + ) + .optional(), telemetry: managedAuthBrowserTelemetrySchema .describe( "defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", @@ -147,6 +153,7 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { const { browser } = params; const telemetry = browser ? browser.telemetry : params.browser_telemetry; const region = browser ? browser.region : params.region; + const stealth = browser?.stealth; const proxy = browser ? browser.proxy : params.proxy_id || params.proxy_name @@ -166,6 +173,7 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { record_session: params.record_session ?? true, browser_telemetry: telemetry ?? { enabled: true }, ...(region && { region }), + ...(stealth !== undefined && { stealth }), ...(proxy && { proxy }), }; } diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 35c90b4..5c4b308 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -487,6 +487,7 @@ describe("manage_browsers proxy routes", () => { ); try { const network = { + private_hosts: ["_.preview.example.ts.net", "100.64.0.0/10"], proxy_routes: [ { hosts: ["example.com"], proxy: { id: "prx_route" } }, { hosts: ["*.example.org"], proxy: { name: "backup" } }, diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 11e9b43..25dbb66 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -550,6 +550,12 @@ export function registerBrowserCapabilities( .optional(), network: z .object({ + private_hosts: z + .array(z.string().min(1)) + .describe( + "(create only) destinations to route directly through the session network. accepts private hostnames, IP addresses, CIDRs, and patterns supported by the API. an explicit list replaces the default private ranges; an empty list disables direct private-host routing. omit to keep the defaults.", + ) + .optional(), proxy_routes: z .array( z.object({ diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 88c477c..0e67ae1 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -126,6 +126,19 @@ describe("managed-auth start/resume state machine", () => { }); }); + test("secure App login forwards an explicit stealth opt-out", async () => { + const initial = connection(); + const { client, calls } = fakeClient({ initial }); + await beginAuthLogin(client, { + mode: "new_login", + domain: "example.com", + profile_name: "work", + stealth: false, + }); + expect(calls.createParams).toMatchObject({ browser: { stealth: false } }); + expect(calls.loginParams).toMatchObject({ browser: { stealth: false } }); + }); + test("explicit reauth starts login even when authenticated", async () => { const initial = connection({ status: "AUTHENTICATED" }); const { client, calls } = fakeClient({ initial }); diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 0e47e38..828d77f 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -56,6 +56,7 @@ export interface AuthLoginInput { record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; region?: ManagedAuthRegion; + stealth?: boolean; proxy?: ProxyConfig; } @@ -471,6 +472,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), + ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, }); @@ -522,6 +524,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), + ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, }); From dd0c701f4dab39ca96841434361f3adbaa542ae4 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:43:58 +0000 Subject: [PATCH 5/6] Revert "Expose private hosts and auth stealth options" This reverts commit 266e72ca316f9a255be3e230e502edc485657024. --- README.md | 4 +- src/lib/mcp/tools/auth-login-app.test.ts | 47 +++++++------------- src/lib/mcp/tools/auth-login-app.ts | 8 ---- src/lib/mcp/tools/browsers.test.ts | 1 - src/lib/mcp/tools/browsers.ts | 6 --- src/lib/mcp/tools/managed-auth-start.test.ts | 13 ------ src/lib/mcp/tools/managed-auth-state.ts | 3 -- 7 files changed, 17 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 83e4634..ad413c7 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Self-hosted deployments can select tool families with `KERNEL_MCP_ENABLED_TOOLSE Call `get_connection_context` before deciding whether to create or select a project. Its canonical `connection_scope` reports whether the connection is organization-wide or fixed to a project. Project-scoped tools advertise an optional `project` (name or ID) and a deprecated `project_id`: organization-wide connections may omit them to preserve organization-wide reads and API default-project behavior, while fixed-project connections may omit them or pass the matching project. Project resources use project-qualified `kernel://orgs/{organizationId}/projects/{projectId}/...` URIs. Authorization remains enforced by the Kernel API; selecting a project never grants access to it. -Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `config` objects replace older flat inputs such as `proxy_id`, `proxy_routes`, `browser_region`, and `custom_host`. Browser `network` settings include both `private_hosts` and `proxy_routes`. The flat inputs remain as deprecated aliases, and combining one with its nested replacement is rejected. Tool-call analytics records the names, never the values, of deprecated inputs each call used in `$mcp_deprecated_params`; the tracked list lives in `src/lib/mcp/deprecated-params.ts`. +Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `config` objects replace older flat inputs such as `proxy_id`, `proxy_routes`, `browser_region`, and `custom_host`. The flat inputs remain as deprecated aliases, and combining one with its nested replacement is rejected. Tool-call analytics records the names, never the values, of deprecated inputs each call used in `$mcp_deprecated_params`; the tracked list lives in `src/lib/mcp/deprecated-params.ts`. ### manage\_\* tools @@ -445,7 +445,7 @@ Returns: the REPL ID, ordered text output, and screenshot. Later browser_repl ca Example: “Log me into my Hacker News account and update my profile to add a random emoji at the bottom.” The agent should discover `news.ycombinator.com`, open the App when needed, wait for authentication, then continue the profile edit without asking for credentials or a profile name in chat. -The secure App defaults `record_session` and `browser.telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `browser.region` in `open_auth_login` or `manage_auth_connections` to choose where a managed-auth browser runs, `browser.proxy` to choose its proxy by id, name, or mode, and `browser.stealth` to enable or disable stealth for the login flow. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass the `browser` object through to the API unchanged, preserving defaults and inheritance when it is omitted. +The secure App defaults `record_session` and `browser.telemetry.enabled` to `true`, recording replay video plus the operational telemetry categories (`control`, `connection`, `system`, and `captcha`) for managed-auth browser sessions. Callers can explicitly disable either setting. Set `browser.region` in `open_auth_login` or `manage_auth_connections` to choose where a managed-auth browser runs, and `browser.proxy` to choose its proxy by id, name, or mode. Create and update set the connection default; login and reauth overrides apply only to that flow. Omit the field on create to use `us-east`, or omit it on update and login to preserve or inherit the connection default. The programmatic `manage_auth_connections` create, update, and login actions pass the `browser` object through to the API unchanged, preserving defaults and inheritance when it is omitted. ### Set up browser profiles for authentication diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 8f3ea5f..0860863 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -153,9 +153,6 @@ describe("managed-auth MCP App registration", () => { true, ); expect(schema.safeParse({ ...base, region: "emea" }).success).toBe(false); - expect( - schema.safeParse({ ...base, browser: { stealth: false } }).success, - ).toBe(true); const defaults = schema.parse(base); expect(defaults.record_session).toBe(true); expect(defaults.browser_telemetry).toBeUndefined(); @@ -244,17 +241,12 @@ describe("managed-auth MCP App registration", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain(message); } - const ok = await tools.get("open_auth_login")!.handler( - { - ...base, - browser: { - proxy: { mode: "direct" }, - region: "eu-west", - stealth: false, - }, - }, - projectScopedExtra("proj_test", "unused-api-key"), - ); + const ok = await tools + .get("open_auth_login")! + .handler( + { ...base, browser: { proxy: { mode: "direct" }, region: "eu-west" } }, + projectScopedExtra("proj_test", "unused-api-key"), + ); expect(ok.isError).toBeUndefined(); }); @@ -276,7 +268,6 @@ describe("managed-auth MCP App registration", () => { "%s stateless requests pass the App gate", async (era) => { redisMarkerPresent = era === "legacy"; - let loginParams: unknown; kernelClientMock.factory = () => ({ auth: { connections: { @@ -287,17 +278,14 @@ describe("managed-auth MCP App registration", () => { status: "AUTHENTICATED", flow_expires_at: "2026-01-01T00:00:00Z", }), - login: async (_connectionId: string, params: unknown) => { - loginParams = params; - return { - id: "conn_1", - flow_type: "REAUTH", - flow_expires_at: "2099-01-01T00:00:00Z", - hosted_url: - "https://managed-auth.onkernel.com/login/conn_1?code=handoff-secret", - handoff_code: "handoff-secret", - }; - }, + login: async () => ({ + id: "conn_1", + flow_type: "REAUTH", + flow_expires_at: "2099-01-01T00:00:00Z", + hosted_url: + "https://managed-auth.onkernel.com/login/conn_1?code=handoff-secret", + handoff_code: "handoff-secret", + }), timeline: async () => ({ getPaginatedItems: () => [] }), }, }, @@ -305,11 +293,7 @@ describe("managed-auth MCP App registration", () => { try { const { tools } = captureRegistration({ appsSupport: false }); const result = await tools.get("begin_auth_login")!.handler( - { - mode: "reauth", - connection_id: "conn_1", - browser: { stealth: false }, - }, + { mode: "reauth", connection_id: "conn_1" }, { ...projectScopedExtra("proj_test", "unused-api-key"), mcpReq: { @@ -340,7 +324,6 @@ describe("managed-auth MCP App registration", () => { }, ); expect(result.isError).toBeUndefined(); - expect(loginParams).toMatchObject({ browser: { stealth: false } }); expect(result.structuredContent.kind).toBe("kernel.managed_auth.begin"); expect(result.structuredContent.next_action).toMatchObject({ tool: "manage_auth_connections", diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index 950142b..f39ed8c 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -82,12 +82,6 @@ const authLoginInputSchema = () => .enum(["us-east", "eu-west", "ap-southeast"]) .describe("region for the managed-auth browser session.") .optional(), - stealth: z - .boolean() - .describe( - "whether the managed-auth browser session uses stealth mode.", - ) - .optional(), telemetry: managedAuthBrowserTelemetrySchema .describe( "defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", @@ -153,7 +147,6 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { const { browser } = params; const telemetry = browser ? browser.telemetry : params.browser_telemetry; const region = browser ? browser.region : params.region; - const stealth = browser?.stealth; const proxy = browser ? browser.proxy : params.proxy_id || params.proxy_name @@ -173,7 +166,6 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { record_session: params.record_session ?? true, browser_telemetry: telemetry ?? { enabled: true }, ...(region && { region }), - ...(stealth !== undefined && { stealth }), ...(proxy && { proxy }), }; } diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 5c4b308..35c90b4 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -487,7 +487,6 @@ describe("manage_browsers proxy routes", () => { ); try { const network = { - private_hosts: ["_.preview.example.ts.net", "100.64.0.0/10"], proxy_routes: [ { hosts: ["example.com"], proxy: { id: "prx_route" } }, { hosts: ["*.example.org"], proxy: { name: "backup" } }, diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 25dbb66..11e9b43 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -550,12 +550,6 @@ export function registerBrowserCapabilities( .optional(), network: z .object({ - private_hosts: z - .array(z.string().min(1)) - .describe( - "(create only) destinations to route directly through the session network. accepts private hostnames, IP addresses, CIDRs, and patterns supported by the API. an explicit list replaces the default private ranges; an empty list disables direct private-host routing. omit to keep the defaults.", - ) - .optional(), proxy_routes: z .array( z.object({ diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 0e67ae1..88c477c 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -126,19 +126,6 @@ describe("managed-auth start/resume state machine", () => { }); }); - test("secure App login forwards an explicit stealth opt-out", async () => { - const initial = connection(); - const { client, calls } = fakeClient({ initial }); - await beginAuthLogin(client, { - mode: "new_login", - domain: "example.com", - profile_name: "work", - stealth: false, - }); - expect(calls.createParams).toMatchObject({ browser: { stealth: false } }); - expect(calls.loginParams).toMatchObject({ browser: { stealth: false } }); - }); - test("explicit reauth starts login even when authenticated", async () => { const initial = connection({ status: "AUTHENTICATED" }); const { client, calls } = fakeClient({ initial }); diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 828d77f..0e47e38 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -56,7 +56,6 @@ export interface AuthLoginInput { record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; region?: ManagedAuthRegion; - stealth?: boolean; proxy?: ProxyConfig; } @@ -472,7 +471,6 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, }); @@ -524,7 +522,6 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, }); From 25b3b2ec4a5c4444f0119351dae580dcc93a1c6b Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:21:23 +0000 Subject: [PATCH 6/6] Accept network.private_hosts and login browser.stealth; forward project from login app --- README.md | 2 +- .../mcp/apps/generated/managed-auth-app.ts | 2 +- src/lib/mcp/apps/managed-auth-entry.tsx | 24 +------------------ src/lib/mcp/apps/managed-auth-flow.test.ts | 16 +++++++++++++ src/lib/mcp/apps/managed-auth-flow.ts | 23 ++++++++++++++++++ src/lib/mcp/prompts.test.ts | 2 ++ src/lib/mcp/tools/auth-login-app.test.ts | 17 ++++++++----- src/lib/mcp/tools/auth-login-app.ts | 8 +++++++ src/lib/mcp/tools/browsers.test.ts | 21 +++++++++++----- src/lib/mcp/tools/browsers.ts | 6 +++++ src/lib/mcp/tools/managed-auth-start.test.ts | 7 +++--- src/lib/mcp/tools/managed-auth-state.ts | 3 +++ 12 files changed, 91 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ad413c7..1081363 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `co ### manage\_\* tools -- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies (`proxy` by id, name, or mode), create-only per-host proxy routes (`network.proxy_routes`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. +- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies (`proxy` by id, name, or mode), create-only per-host proxy routes (`network.proxy_routes`) and private-host routing (`network.private_hosts`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. - `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. - `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. - `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once. diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts index 4428b31..2e3c157 100644 --- a/src/lib/mcp/apps/generated/managed-auth-app.ts +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -1,2 +1,2 @@ // Generated by scripts/build-managed-auth-app.mjs. Do not edit. -export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx index 186d119..2ea4bdc 100644 --- a/src/lib/mcp/apps/managed-auth-entry.tsx +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -15,12 +15,12 @@ import { useManagedAuthAutofocus } from "./managed-auth-focus"; import { initialManagedAuthFlowState, managedAuthFlowReducer, + sanitizeBeginArguments, waitArgumentsFromBegin, } from "./managed-auth-flow"; import { ManagedAuthHostBridge } from "./managed-auth-host"; import type { BeginResult, - JsonObject, LauncherResult, WaitToolResult, } from "./managed-auth-types"; @@ -30,28 +30,6 @@ const FAILURE_CONTEXT = "Managed authentication stopped. Verify its terminal state and report the recovery option; do not continue the protected action."; const host = new ManagedAuthHostBridge(); -function sanitizeBeginArguments(input: JsonObject): JsonObject { - const allowed = [ - "mode", - "connection_id", - "domain", - "profile_name", - "project_id", - "save_credentials", - "record_session", - "browser", - "browser_telemetry", - "region", - "proxy_id", - "proxy_name", - ]; - return Object.fromEntries( - allowed - .filter((key) => input[key] !== undefined) - .map((key) => [key, input[key]]), - ); -} - function ManagedAuthApp() { const launcher = useSyncExternalStore( host.subscribe, diff --git a/src/lib/mcp/apps/managed-auth-flow.test.ts b/src/lib/mcp/apps/managed-auth-flow.test.ts index 6975604..d2f0160 100644 --- a/src/lib/mcp/apps/managed-auth-flow.test.ts +++ b/src/lib/mcp/apps/managed-auth-flow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { initialManagedAuthFlowState, managedAuthFlowReducer, + sanitizeBeginArguments, waitArgumentsFromBegin, } from "./managed-auth-flow"; import type { BeginResult, SafeConnection } from "./managed-auth-types"; @@ -126,3 +127,18 @@ describe("managed-auth App flow reducer", () => { expect(complete.outcome).toBe("success"); }); }); + +describe("sanitizeBeginArguments", () => { + test("forwards launcher login arguments and drops everything else", () => { + const args = { + mode: "new_login", + domain: "example.com", + profile_name: "work", + project: "billing", + browser: { proxy: { name: "residential" }, stealth: false }, + }; + expect( + sanitizeBeginArguments({ ...args, intent: "log in", password: "x" }), + ).toEqual(args); + }); +}); diff --git a/src/lib/mcp/apps/managed-auth-flow.ts b/src/lib/mcp/apps/managed-auth-flow.ts index 1aaabb0..394ae39 100644 --- a/src/lib/mcp/apps/managed-auth-flow.ts +++ b/src/lib/mcp/apps/managed-auth-flow.ts @@ -135,3 +135,26 @@ export function waitArgumentsFromBegin( } return action.arguments; } + +export function sanitizeBeginArguments(input: JsonObject): JsonObject { + const allowed = [ + "mode", + "connection_id", + "domain", + "profile_name", + "project", + "project_id", + "save_credentials", + "record_session", + "browser", + "browser_telemetry", + "region", + "proxy_id", + "proxy_name", + ]; + return Object.fromEntries( + allowed + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]]), + ); +} diff --git a/src/lib/mcp/prompts.test.ts b/src/lib/mcp/prompts.test.ts index 5038b54..698a039 100644 --- a/src/lib/mcp/prompts.test.ts +++ b/src/lib/mcp/prompts.test.ts @@ -17,6 +17,8 @@ const API_FIELD_NAMES = new Set([ "manage_browser_pools:stealth", "manage_auth_connections:browser_stealth", "manage_auth_connections:stealth", + "open_auth_login:stealth", + "begin_auth_login:stealth", "manage_proxies:bypass_hosts", "manage_auth_connections:captcha", "open_auth_login:captcha", diff --git a/src/lib/mcp/tools/auth-login-app.test.ts b/src/lib/mcp/tools/auth-login-app.test.ts index 0860863..ea43587 100644 --- a/src/lib/mcp/tools/auth-login-app.test.ts +++ b/src/lib/mcp/tools/auth-login-app.test.ts @@ -241,12 +241,17 @@ describe("managed-auth MCP App registration", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain(message); } - const ok = await tools - .get("open_auth_login")! - .handler( - { ...base, browser: { proxy: { mode: "direct" }, region: "eu-west" } }, - projectScopedExtra("proj_test", "unused-api-key"), - ); + const ok = await tools.get("open_auth_login")!.handler( + { + ...base, + browser: { + proxy: { mode: "direct" }, + region: "eu-west", + stealth: false, + }, + }, + projectScopedExtra("proj_test", "unused-api-key"), + ); expect(ok.isError).toBeUndefined(); }); diff --git a/src/lib/mcp/tools/auth-login-app.ts b/src/lib/mcp/tools/auth-login-app.ts index f39ed8c..5fcb353 100644 --- a/src/lib/mcp/tools/auth-login-app.ts +++ b/src/lib/mcp/tools/auth-login-app.ts @@ -82,6 +82,12 @@ const authLoginInputSchema = () => .enum(["us-east", "eu-west", "ap-southeast"]) .describe("region for the managed-auth browser session.") .optional(), + stealth: z + .boolean() + .describe( + "whether the managed-auth browser session uses site-compatibility settings. defaults to true for a new login; omitted on reauth inherits the connection setting.", + ) + .optional(), telemetry: managedAuthBrowserTelemetrySchema .describe( "defaults to { enabled: true }, which captures the operational categories (control, connection, system, captcha).", @@ -147,6 +153,7 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { const { browser } = params; const telemetry = browser ? browser.telemetry : params.browser_telemetry; const region = browser ? browser.region : params.region; + const stealth = browser?.stealth; const proxy = browser ? browser.proxy : params.proxy_id || params.proxy_name @@ -166,6 +173,7 @@ function inputFromParams(params: AuthLoginParams): AuthLoginInput { record_session: params.record_session ?? true, browser_telemetry: telemetry ?? { enabled: true }, ...(region && { region }), + ...(stealth !== undefined && { stealth }), ...(proxy && { proxy }), }; } diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 35c90b4..9f21f90 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -487,17 +487,26 @@ describe("manage_browsers proxy routes", () => { ); try { const network = { + private_hosts: ["*.preview.example.ts.net", "100.64.0.0/10"], proxy_routes: [ { hosts: ["example.com"], proxy: { id: "prx_route" } }, { hosts: ["*.example.org"], proxy: { name: "backup" } }, ], }; - const result = await client.callTool({ - name: "manage_browsers", - arguments: { action: "create", proxy: { name: "default" }, network }, - }); - expect(result.isError).toBeFalsy(); - expect(requests).toEqual([{ proxy: { name: "default" }, network }]); + for (const args of [ + { proxy: { name: "default" }, network }, + { network: { private_hosts: [] } }, + ]) { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { action: "create", ...args }, + }); + expect(result.isError).toBeFalsy(); + } + expect(requests).toEqual([ + { proxy: { name: "default" }, network }, + { network: { private_hosts: [] } }, + ]); } finally { await close(); } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 11e9b43..61dcb5a 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -550,6 +550,12 @@ export function registerBrowserCapabilities( .optional(), network: z .object({ + private_hosts: z + .array(z.string().min(1)) + .describe( + "destinations the browser reaches directly through the session's own network instead of KERNEL-managed egress, such as private hosts on a vpn or tailnet the session joined. entries are hostname patterns or private ip and cidr literals. an explicit list replaces the default private ranges (rfc1918, cgnat, and ipv6 ula); an empty list disables them; omit to keep the defaults.", + ) + .optional(), proxy_routes: z .array( z.object({ diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 88c477c..674a513 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -109,7 +109,7 @@ describe("managed-auth start/resume state machine", () => { }); }); - test("secure App login forwards the selected proxy", async () => { + test("secure App login forwards the selected proxy and site-compatibility setting", async () => { const initial = connection(); const { client, calls } = fakeClient({ initial }); await beginAuthLogin(client, { @@ -117,12 +117,13 @@ describe("managed-auth start/resume state machine", () => { domain: "example.com", profile_name: "work", proxy: { mode: "direct" }, + stealth: false, }); expect(calls.createParams).toMatchObject({ - browser: { proxy: { mode: "direct" } }, + browser: { proxy: { mode: "direct" }, stealth: false }, }); expect(calls.loginParams).toMatchObject({ - browser: { proxy: { mode: "direct" } }, + browser: { proxy: { mode: "direct" }, stealth: false }, }); }); diff --git a/src/lib/mcp/tools/managed-auth-state.ts b/src/lib/mcp/tools/managed-auth-state.ts index 0e47e38..828d77f 100644 --- a/src/lib/mcp/tools/managed-auth-state.ts +++ b/src/lib/mcp/tools/managed-auth-state.ts @@ -56,6 +56,7 @@ export interface AuthLoginInput { record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; region?: ManagedAuthRegion; + stealth?: boolean; proxy?: ProxyConfig; } @@ -471,6 +472,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), + ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, }); @@ -522,6 +524,7 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), + ...(input.stealth !== undefined && { stealth: input.stealth }), ...(input.proxy && { proxy: input.proxy }), }, });