diff --git a/README.md b/README.md index f834937a..1081363f 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`) 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. - `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 53cab961..4541857e 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 f38930ef..bbb96b7c 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 cccabe81..2e3c1572 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 49faa4b3..2ea4bdc5 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,27 +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_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 6975604d..d2f0160a 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 1aaabb0a..394ae390 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/deprecated-params.test.ts b/src/lib/mcp/deprecated-params.test.ts new file mode 100644 index 00000000..b33458f1 --- /dev/null +++ b/src/lib/mcp/deprecated-params.test.ts @@ -0,0 +1,37 @@ +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("tracked tools advertise each deprecated param without schema references", 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; + expect( + JSON.stringify(properties), + `${toolName} input schema`, + ).not.toContain('"$ref"'); + 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 00000000..926e1ef8 --- /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 7ed28b5d..698a039e 100644 --- a/src/lib/mcp/prompts.test.ts +++ b/src/lib/mcp/prompts.test.ts @@ -16,6 +16,10 @@ const API_FIELD_NAMES = new Set([ "manage_browsers:captcha", "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", "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 00000000..2910397a --- /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 ef96dcd1..16b2a663 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(); } @@ -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 64243e38..6883e807 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 b8e0828d..ea43587a 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,48 @@ 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", + stealth: false, + }, + }, + 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 874043be..5fcb3533 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,54 @@ 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(), + 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).", + ) + .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 +139,29 @@ 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 stealth = browser?.stealth; + 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 +171,10 @@ 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 }), + ...(stealth !== undefined && { stealth }), + ...(proxy && { proxy }), }; } @@ -166,6 +227,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 +325,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 c9be4a44..9f21f908 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,102 @@ 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 = { + 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" } }, + ], + }; + 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(); + } + }); + + 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( @@ -501,6 +600,105 @@ 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 226e5309..61dcb5ac 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,10 +537,40 @@ export function registerBrowserCapabilities( "(create, update) save session changes back to profile on close.", ) .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.", + ) + .optional(), proxy_id: z .string() .describe( - "(create, update) 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({ + 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({ + 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 @@ -546,19 +583,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) 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) 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 @@ -706,11 +743,37 @@ 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 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": { const createParams: Kernel.BrowserCreateParams = {}; @@ -733,7 +796,10 @@ 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.network !== undefined) + createParams.network = params.network; if (params.proxy_routes !== undefined) { const proxyRoutes: Array = []; for (const { @@ -799,6 +865,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; } diff --git a/src/lib/mcp/tools/managed-auth-start.test.ts b/src/lib/mcp/tools/managed-auth-start.test.ts index 654980ed..674a5131 100644 --- a/src/lib/mcp/tools/managed-auth-start.test.ts +++ b/src/lib/mcp/tools/managed-auth-start.test.ts @@ -109,6 +109,24 @@ describe("managed-auth start/resume state machine", () => { }); }); + test("secure App login forwards the selected proxy and site-compatibility setting", async () => { + const initial = connection(); + const { client, calls } = fakeClient({ initial }); + await beginAuthLogin(client, { + mode: "new_login", + domain: "example.com", + profile_name: "work", + proxy: { mode: "direct" }, + stealth: false, + }); + expect(calls.createParams).toMatchObject({ + browser: { proxy: { mode: "direct" }, stealth: false }, + }); + expect(calls.loginParams).toMatchObject({ + browser: { proxy: { mode: "direct" }, 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 6bb8295e..828d77f3 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,8 @@ export interface AuthLoginInput { record_session?: boolean; browser_telemetry?: ManagedAuthBrowserTelemetry; region?: ManagedAuthRegion; - proxy_id?: string; - proxy_name?: string; + stealth?: boolean; + proxy?: ProxyConfig; } export class AuthLoginStartError extends Error { @@ -346,9 +347,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 +458,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 +472,8 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(proxy && { proxy }), + ...(input.stealth !== undefined && { stealth: input.stealth }), + ...(input.proxy && { proxy: input.proxy }), }, }); } catch (error) { @@ -530,7 +524,8 @@ export async function beginAuthLogin( browser: { telemetry: browserTelemetry, ...(input.region && { region: input.region }), - ...(proxy && { proxy }), + ...(input.stealth !== undefined && { stealth: input.stealth }), + ...(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 00000000..47ed2c60 --- /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 9b7b18ca..5017e91b 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 }), });