From 4cb1949acf4259197e03017481358d049ecec774 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:00:10 +0000 Subject: [PATCH 1/3] Support reserved ISP proxy IPs in manage_proxies Update the Node SDK to 0.142.0. Add list_reserved_ips, get_reserved_ip and move actions, config.ip_address on ISP create, and an ip_address filter on list. --- README.md | 2 +- bun.lock | 4 +- package.json | 2 +- src/lib/mcp/tools/proxies.test.ts | 134 ++++++++++++++++++++++++++++++ src/lib/mcp/tools/proxies.ts | 75 ++++++++++++++++- 5 files changed, 209 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 89cb36a..3c2fce9 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `co - `manage_browser_pools` - Create, update, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools. Pool `network` mirrors the API object, including egress allowlists (`network.allowed_hosts`); an update replaces the whole object. - `manage_browser_files` - Read, write, upload, download, and manage files in running browser VMs. Supports text and base64 input and returns binary downloads as embedded MCP resources. Paths must be absolute. `read`, `download`, and `download_dir_zip` refuse files or archives over `max_bytes` (default 10 MiB, maximum 25 MiB) instead of truncating them; mutating actions are never retried. - `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). List filters by exact `name` or a `query` substring. +- `manage_proxies` - Create, list, get, rename, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). List filters by exact `name`, a `query` substring, or an ISP proxy's exit `ip_address`. Organizations with reserved ISP IPs can list them (`list_reserved_ips`, `get_reserved_ip`), pin a new ISP proxy with `config.ip_address`, and `move` an ISP proxy to another reserved IP. - `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. - `manage_playwright_executors` - List and delete the Playwright executors of a browser session. `list` returns every executor (`default` first) with `busy`, `created_at`, `last_used_at`, and for named executors the `target_id` and `url` of the tab it owns. `delete` takes `name` and an optional `close_tab` (default true); deleting `default` restarts it instead of removing it. A browser can have at most 8 named executors. - `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. diff --git a/bun.lock b/bun.lock index 22f0584..a370741 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@onkernel/managed-auth-react": "0.5.5", - "@onkernel/sdk": "0.141.0", + "@onkernel/sdk": "0.142.0", "@posthog/mcp": "0.17.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -160,7 +160,7 @@ "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.5.5", "", { "dependencies": { "clsx": "^2.1.1", "tldts": "7.4.15" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-uoeHAJ0RnLoEJe7TdpT0pA3RgPWEpJLf9F532aLEcahFNrMIqyAdMp94LNIwVuMlxkkfx4+3S/fqzHubbnzjJg=="], - "@onkernel/sdk": ["@onkernel/sdk@0.141.0", "", {}, "sha512-NAsynTkRK3N7udBgitPBd5OBbo5j+NzAAsX8eIbHWR4Ly9CNAVuhJQ2uljnuZaXrCQwZhQ/29L6f2X2Vd4gf1w=="], + "@onkernel/sdk": ["@onkernel/sdk@0.142.0", "", {}, "sha512-sb0hMY/Zc6blEDHbN5fyGENiBUficfnF26a99XWXp8INA+3nZ5fGJRNmn2ab6lSMHPUSKNDcFpabtrrtkPC9MQ=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], diff --git a/package.json b/package.json index 26dd279..9ae6cd9 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@onkernel/managed-auth-react": "0.5.5", - "@onkernel/sdk": "0.141.0", + "@onkernel/sdk": "0.142.0", "@posthog/mcp": "0.17.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", diff --git a/src/lib/mcp/tools/proxies.test.ts b/src/lib/mcp/tools/proxies.test.ts index fe153d1..bbadf67 100644 --- a/src/lib/mcp/tools/proxies.test.ts +++ b/src/lib/mcp/tools/proxies.test.ts @@ -280,9 +280,143 @@ describe("manage_proxies", () => { name: "manage_proxies", arguments: { action: "list", query: "residential", limit: 5 }, }); + await client.callTool({ + name: "manage_proxies", + arguments: { action: "list", ip_address: "203.0.113.10" }, + }); expect(lists).toEqual([ { name: "us-residential" }, { query: "residential", limit: 5 }, + { ip_address: "203.0.113.10" }, + ]); + } finally { + await close(); + } + }); + + test("pins a new isp proxy only through config.ip_address", async () => { + const creates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient(creates), + ); + try { + await client.callTool({ + name: "manage_proxies", + arguments: { + action: "create", + type: "isp", + config: { ip_address: "203.0.113.10" }, + }, + }); + const result = await client.callTool({ + name: "manage_proxies", + arguments: { + action: "create", + type: "isp", + ip_address: "203.0.113.10", + }, + }); + expect(result.isError).toBe(true); + expect(toolResultText(result)).toBe( + "error: use config.ip_address to pin a new isp proxy to a reserved ip.", + ); + expect(creates).toEqual([ + { type: "isp", config: { ip_address: "203.0.113.10" } }, + ]); + } finally { + await close(); + } + }); + + test("moves an isp proxy to a reserved ip", async () => { + const updates: unknown[] = []; + const { client, close } = await connectTestMcp(registerProxyTools, { + proxies: { + update: async (idOrName: string, params: unknown) => { + updates.push([idOrName, params]); + return { id: idOrName }; + }, + }, + }); + try { + const moved = await client.callTool({ + name: "manage_proxies", + arguments: { + action: "move", + id_or_name: "prx_1", + ip_address: "203.0.113.10", + }, + }); + expect(toolResultJSON(moved)).toEqual({ id: "prx_1" }); + + const cases: Array<[Record, string]> = [ + [ + { ip_address: "203.0.113.10" }, + "error: id_or_name is required for move.", + ], + [{ id_or_name: "prx_1" }, "error: ip_address is required for move."], + ]; + for (const [args, message] of cases) { + const result = await client.callTool({ + name: "manage_proxies", + arguments: { action: "move", ...args }, + }); + expect(result.isError).toBe(true); + expect(toolResultText(result)).toBe(message); + } + expect(updates).toEqual([ + ["prx_1", { config: { ip_address: "203.0.113.10" } }], + ]); + } finally { + await close(); + } + }); + + test("lists and gets reserved ips", async () => { + const calls: unknown[] = []; + const { client, close } = await connectTestMcp(registerProxyTools, { + proxyIPs: { + list: async (params: unknown) => { + calls.push(["list", params]); + return { + getPaginatedItems: () => [ + { ip_address: "203.0.113.10", country: "US", status: "active" }, + ], + has_more: false, + }; + }, + retrieve: async (ip: string) => { + calls.push(["get", ip]); + return { ip_address: ip, proxy_count: 0, proxies: [] }; + }, + }, + }); + try { + await client.callTool({ + name: "manage_proxies", + arguments: { action: "list_reserved_ips", status: "active", limit: 5 }, + }); + const got = await client.callTool({ + name: "manage_proxies", + arguments: { action: "get_reserved_ip", ip_address: "203.0.113.10" }, + }); + expect(toolResultJSON(got)).toEqual({ + ip_address: "203.0.113.10", + proxy_count: 0, + proxies: [], + }); + const missing = await client.callTool({ + name: "manage_proxies", + arguments: { action: "get_reserved_ip" }, + }); + expect(missing.isError).toBe(true); + expect(toolResultText(missing)).toBe( + "error: ip_address is required for get_reserved_ip.", + ); + expect(calls).toEqual([ + ["list", { status: "active", limit: 5 }], + ["get", "203.0.113.10"], ]); } finally { await close(); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 810dc38..0146b3f 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -53,6 +53,13 @@ const proxyCreateConfigSchema = z.object({ .string() .describe("(residential) autonomous system number.") .optional(), + ip_address: z + .string() + .min(1) + .describe( + "(isp) reserved exit ip to pin the proxy to. only for organizations with reserved isp ips; country defaults to the ip's own.", + ) + .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(), @@ -105,17 +112,27 @@ export function registerProxyTools( "manage_proxies", { description: - 'manage proxy configurations for routing browser traffic. use "create" to add a proxy, "list" to see all proxies, "get" to retrieve one by id or exact name, "rename" to change its name, "check" to test connectivity (optionally against a target url), or "delete" to remove one. names are not unique: an ambiguous get by name fails, while ids are stable and should be used for rename, check, and delete. choose a proxy type that fits the workload and the terms of the target site.', + 'manage proxy configurations for routing browser traffic. use "create" to add a proxy, "list" to see all proxies, "get" to retrieve one by id or exact name, "rename" to change its name, "check" to test connectivity (optionally against a target url), or "delete" to remove one. names are not unique: an ambiguous get by name fails, while ids are stable and should be used for rename, move, check, and delete. organizations with reserved isp ips can also use "list_reserved_ips" and "get_reserved_ip" to see their allocation and "move" to switch an isp proxy to another reserved exit ip; other organizations get a reserved_isp_not_enabled error. choose a proxy type that fits the workload and the terms of the target site.', inputSchema: z.object({ ...projectSelectionInputSchema(), action: z - .enum(["create", "list", "get", "rename", "check", "delete"]) + .enum([ + "create", + "list", + "get", + "rename", + "move", + "check", + "delete", + "list_reserved_ips", + "get_reserved_ip", + ]) .describe("operation to perform."), id_or_name: z .string() .min(1) .describe( - "(get) proxy id or exact name. ids take precedence if a value matches both; ambiguous names fail. (rename, check, delete) use the proxy id.", + "(get) proxy id or exact name. ids take precedence if a value matches both; ambiguous names fail. (rename, move, check, delete) use the proxy id.", ) .optional(), proxy_id: z @@ -148,9 +165,22 @@ export function registerProxyTools( "(list) case-insensitive substring match against proxy name, host, or ip address. ids match by exact value.", ) .optional(), + ip_address: z + .string() + .min(1) + .describe( + "(list) isp proxies whose primary exit ip is this address. (move) reserved exit ip to move the isp proxy to; it must be active and in the proxy's country, and the proxy keeps its id. (get_reserved_ip) the reserved ip to look up. for create, use config.ip_address.", + ) + .optional(), + status: z + .enum(["active", "excluded"]) + .describe( + "(list_reserved_ips) filter by whether the ip can currently be assigned. excluded ips cannot be pinned or moved to.", + ) + .optional(), config: proxyCreateConfigSchema .describe( - "(create) settings for the selected type. isp accepts 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.", + "(create) settings for the selected type. isp accepts country and ip_address; 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 @@ -233,6 +263,11 @@ export function registerProxyTools( case "create": { if (!params.type) return errorResponse("error: type is required for create."); + if (params.ip_address) { + return errorResponse( + "error: use config.ip_address to pin a new isp proxy to a reserved ip.", + ); + } if (params.config !== undefined) { const conflict = deprecatedParamConflict( "config", @@ -266,6 +301,7 @@ export function registerProxyTools( const page = await client.proxies.list({ ...(params.name && { name: params.name }), ...(params.query && { query: params.query }), + ...(params.ip_address && { ip_address: params.ip_address }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); @@ -273,6 +309,25 @@ export function registerProxyTools( emptyText: "no proxies found", }); } + case "list_reserved_ips": { + const page = await client.proxyIPs.list({ + ...(params.status && { status: params.status }), + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }); + return paginatedJsonResponse(page, { + emptyText: "no reserved ips found", + }); + } + case "get_reserved_ip": { + if (!params.ip_address) { + return errorResponse( + "error: ip_address is required for get_reserved_ip.", + ); + } + const ip = await client.proxyIPs.retrieve(params.ip_address); + return jsonResponse(ip); + } case "get": { if (!idOrName) { return errorResponse("error: id_or_name is required for get."); @@ -292,6 +347,18 @@ export function registerProxyTools( }); return jsonResponse(proxy); } + case "move": { + if (!idOrName) { + return errorResponse("error: id_or_name is required for move."); + } + if (!params.ip_address) { + return errorResponse("error: ip_address is required for move."); + } + const proxy = await client.proxies.update(idOrName, { + config: { ip_address: params.ip_address }, + }); + return jsonResponse(proxy); + } case "check": { if (!idOrName) { return errorResponse("error: id_or_name is required for check."); From 3ae1ee2002d9fcd7ded7fd29f8ddefa38aeddc8b Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:17:26 +0000 Subject: [PATCH 2/3] Filter reserved IPs by country and require IPv4 ip_address in manage_proxies --- README.md | 2 +- src/lib/mcp/tools/proxies.test.ts | 44 +++++++++++++++++++++++++++++-- src/lib/mcp/tools/proxies.ts | 16 +++++++---- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c2fce9..4e5e5d4 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `co - `manage_browser_pools` - Create, update, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools. Pool `network` mirrors the API object, including egress allowlists (`network.allowed_hosts`); an update replaces the whole object. - `manage_browser_files` - Read, write, upload, download, and manage files in running browser VMs. Supports text and base64 input and returns binary downloads as embedded MCP resources. Paths must be absolute. `read`, `download`, and `download_dir_zip` refuse files or archives over `max_bytes` (default 10 MiB, maximum 25 MiB) instead of truncating them; mutating actions are never retried. - `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, rename, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). List filters by exact `name`, a `query` substring, or an ISP proxy's exit `ip_address`. Organizations with reserved ISP IPs can list them (`list_reserved_ips`, `get_reserved_ip`), pin a new ISP proxy with `config.ip_address`, and `move` an ISP proxy to another reserved IP. +- `manage_proxies` - Create, list, get, rename, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). List filters by exact `name`, a `query` substring, or an ISP proxy's exit `ip_address`. Organizations with reserved ISP IPs can list them (`list_reserved_ips`, filterable by `ip_country` and `status`; `get_reserved_ip`), pin a new ISP proxy with `config.ip_address`, and `move` an ISP proxy to another reserved IP. - `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. - `manage_playwright_executors` - List and delete the Playwright executors of a browser session. `list` returns every executor (`default` first) with `busy`, `created_at`, `last_used_at`, and for named executors the `target_id` and `url` of the tab it owns. `delete` takes `name` and an optional `close_tab` (default true); deleting `default` restarts it instead of removing it. A browser can have at most 8 named executors. - `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. diff --git a/src/lib/mcp/tools/proxies.test.ts b/src/lib/mcp/tools/proxies.test.ts index bbadf67..cf6b574 100644 --- a/src/lib/mcp/tools/proxies.test.ts +++ b/src/lib/mcp/tools/proxies.test.ts @@ -99,6 +99,41 @@ describe("manage_proxies", () => { } }); + test("rejects a non-ipv4 ip_address in the schema", async () => { + const creates: unknown[] = []; + const { client, close } = await connectTestMcp( + registerProxyTools, + proxyClient(creates), + ); + try { + const cases: Array<[Record, string]> = [ + [ + { action: "move", id_or_name: "prx_1", ip_address: "2001:db8::1" }, + "ip_address", + ], + [ + { + action: "create", + type: "isp", + config: { ip_address: "not-an-ip" }, + }, + "config.ip_address", + ], + ]; + for (const [args, field] of cases) { + const result = await client.callTool({ + name: "manage_proxies", + arguments: args, + }); + expect(result.isError).toBe(true); + expect(toolResultText(result)).toContain(field); + } + expect(creates).toEqual([]); + } finally { + await close(); + } + }); + test("names id_or_name when a proxy selector is missing", async () => { const { client, close } = await connectTestMcp( registerProxyTools, @@ -395,7 +430,12 @@ describe("manage_proxies", () => { try { await client.callTool({ name: "manage_proxies", - arguments: { action: "list_reserved_ips", status: "active", limit: 5 }, + arguments: { + action: "list_reserved_ips", + ip_country: "GB", + status: "active", + limit: 5, + }, }); const got = await client.callTool({ name: "manage_proxies", @@ -415,7 +455,7 @@ describe("manage_proxies", () => { "error: ip_address is required for get_reserved_ip.", ); expect(calls).toEqual([ - ["list", { status: "active", limit: 5 }], + ["list", { country: "GB", status: "active", limit: 5 }], ["get", "203.0.113.10"], ]); } finally { diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 0146b3f..c127c71 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -54,8 +54,7 @@ const proxyCreateConfigSchema = z.object({ .describe("(residential) autonomous system number.") .optional(), ip_address: z - .string() - .min(1) + .ipv4() .describe( "(isp) reserved exit ip to pin the proxy to. only for organizations with reserved isp ips; country defaults to the ip's own.", ) @@ -166,10 +165,9 @@ export function registerProxyTools( ) .optional(), ip_address: z - .string() - .min(1) + .ipv4() .describe( - "(list) isp proxies whose primary exit ip is this address. (move) reserved exit ip to move the isp proxy to; it must be active and in the proxy's country, and the proxy keeps its id. (get_reserved_ip) the reserved ip to look up. for create, use config.ip_address.", + "(list) isp proxies whose primary exit ip is this address. (move) reserved exit ip to move the isp proxy to; it must be active and in the proxy's country, and the proxy keeps its id. (get_reserved_ip) the reserved ip to look up; proxy_count spans the organization, while proxies lists only the current project's. for create, use config.ip_address.", ) .optional(), status: z @@ -178,6 +176,13 @@ export function registerProxyTools( "(list_reserved_ips) filter by whether the ip can currently be assigned. excluded ips cannot be pinned or moved to.", ) .optional(), + ip_country: z + .string() + .min(1) + .describe( + '(list_reserved_ips) iso 3166 country code (e.g., "GB"). move only accepts an ip in the proxy\'s country.', + ) + .optional(), config: proxyCreateConfigSchema .describe( "(create) settings for the selected type. isp accepts country and ip_address; 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.", @@ -311,6 +316,7 @@ export function registerProxyTools( } case "list_reserved_ips": { const page = await client.proxyIPs.list({ + ...(params.ip_country && { country: params.ip_country }), ...(params.status && { status: params.status }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), From 3c62140461497f1fa07728e587ba34ea7a83f992 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:21:26 +0000 Subject: [PATCH 3/3] Reject country on list_reserved_ips in favor of ip_country --- src/lib/mcp/tools/proxies.test.ts | 8 ++++++++ src/lib/mcp/tools/proxies.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/proxies.test.ts b/src/lib/mcp/tools/proxies.test.ts index cf6b574..9d7d55f 100644 --- a/src/lib/mcp/tools/proxies.test.ts +++ b/src/lib/mcp/tools/proxies.test.ts @@ -454,6 +454,14 @@ describe("manage_proxies", () => { expect(toolResultText(missing)).toBe( "error: ip_address is required for get_reserved_ip.", ); + const deprecatedCountry = await client.callTool({ + name: "manage_proxies", + arguments: { action: "list_reserved_ips", country: "GB" }, + }); + expect(deprecatedCountry.isError).toBe(true); + expect(toolResultText(deprecatedCountry)).toBe( + "error: use ip_country to filter reserved ips by country.", + ); expect(calls).toEqual([ ["list", { country: "GB", status: "active", limit: 5 }], ["get", "203.0.113.10"], diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index c127c71..568d68e 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -106,7 +106,7 @@ export function registerProxyTools( ...defaultMcpDependencies, }, ) { - // manage_proxies -- Create, list, get, rename, check, and delete proxy configurations + // manage_proxies -- Create, list, get, rename, move, check, and delete proxy configurations, and list reserved ISP IPs server.registerTool( "manage_proxies", { @@ -315,6 +315,11 @@ export function registerProxyTools( }); } case "list_reserved_ips": { + if (params.country) { + return errorResponse( + "error: use ip_country to filter reserved ips by country.", + ); + } const page = await client.proxyIPs.list({ ...(params.ip_country && { country: params.ip_country }), ...(params.status && { status: params.status }),