Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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.
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
182 changes: 182 additions & 0 deletions src/lib/mcp/tools/proxies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>, 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,
Expand Down Expand Up @@ -280,9 +315,156 @@ 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, unknown>, 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",
ip_country: "GB",
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.",
);
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"],
]);
} finally {
await close();
Expand Down
88 changes: 83 additions & 5 deletions src/lib/mcp/tools/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ const proxyCreateConfigSchema = z.object({
.string()
.describe("(residential) autonomous system number.")
.optional(),
ip_address: z
.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.",
)
.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(),
Expand Down Expand Up @@ -100,22 +106,32 @@ 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",
{
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
Expand Down Expand Up @@ -148,9 +164,28 @@ export function registerProxyTools(
"(list) case-insensitive substring match against proxy name, host, or ip address. ids match by exact value.",
)
.optional(),
ip_address: z
.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; proxy_count spans the organization, while proxies lists only the current project's. 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(),
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; 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
Expand Down Expand Up @@ -233,6 +268,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",
Expand Down Expand Up @@ -266,13 +306,39 @@ 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 }),
});
return paginatedJsonResponse(page, {
emptyText: "no proxies found",
});
}
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 }),
...(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.");
Expand All @@ -292,6 +358,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.");
Expand Down
Loading