diff --git a/src/lib/mcp/dependencies.ts b/src/lib/mcp/dependencies.ts
new file mode 100644
index 0000000..84586df
--- /dev/null
+++ b/src/lib/mcp/dependencies.ts
@@ -0,0 +1,9 @@
+import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client";
+
+export type McpDependencies = {
+ createKernelClient: (token: string) => KernelClient;
+};
+
+export const defaultMcpDependencies: McpDependencies = {
+ createKernelClient,
+};
diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts
index c7b9b0c..d5fce38 100644
--- a/src/lib/mcp/register.ts
+++ b/src/lib/mcp/register.ts
@@ -1,4 +1,8 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import {
+ defaultMcpDependencies,
+ type McpDependencies,
+} from "@/lib/mcp/dependencies";
import { registerKernelPrompts } from "@/lib/mcp/prompts";
import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys";
import { registerAppCapabilities } from "@/lib/mcp/tools/apps";
@@ -19,7 +23,10 @@ import { registerProxyTools } from "@/lib/mcp/tools/proxies";
import { registerReplayTools } from "@/lib/mcp/tools/replays";
import { registerShellTool } from "@/lib/mcp/tools/shell";
-type RegisterMcpToolset = (server: McpServer) => void;
+type RegisterMcpToolset = (
+ server: McpServer,
+ dependencies?: McpDependencies,
+) => void;
function registerManagedAuthCapabilities(server: McpServer) {
registerAuthConnectionTools(server);
@@ -125,7 +132,10 @@ function toolsetEnabled(
export function registerMcpCapabilities(
server: McpServer,
- { mcpApps = false }: { mcpApps?: boolean } = {},
+ {
+ mcpApps = false,
+ dependencies = defaultMcpDependencies,
+ }: { mcpApps?: boolean; dependencies?: McpDependencies } = {},
) {
const disabledToolsets = disabledMcpToolsetsFromEnv();
@@ -133,7 +143,7 @@ export function registerMcpCapabilities(
for (const [toolset, registerToolset] of mcpToolRegistrations) {
if (toolsetEnabled(disabledToolsets, toolset)) {
- registerToolset(server);
+ registerToolset(server, dependencies);
}
}
diff --git a/src/lib/mcp/resource-templates.ts b/src/lib/mcp/resource-templates.ts
index 839ac6e..6ce9867 100644
--- a/src/lib/mcp/resource-templates.ts
+++ b/src/lib/mcp/resource-templates.ts
@@ -2,7 +2,11 @@ import {
ResourceTemplate,
type McpServer,
} from "@modelcontextprotocol/sdk/server/mcp.js";
-import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client";
+import {
+ defaultMcpDependencies,
+ type McpDependencies,
+} from "@/lib/mcp/dependencies";
+import type { KernelClient } from "@/lib/mcp/kernel-client";
type JsonResourceTemplateOptions = {
name: string;
@@ -26,6 +30,7 @@ function templateVariableValue(
export function registerJsonResourceTemplate(
server: McpServer,
options: JsonResourceTemplateOptions,
+ dependencies: McpDependencies = defaultMcpDependencies,
) {
server.resource(
options.name,
@@ -40,7 +45,7 @@ export function registerJsonResourceTemplate(
throw new Error(`Invalid ${options.resourceLabel} URI: ${uri}`);
}
- const client = createKernelClient(extra.authInfo.token);
+ const client = dependencies.createKernelClient(extra.authInfo.token);
const resource = await options.read(client, identifier);
if (!resource) {
diff --git a/src/lib/mcp/responses.test.ts b/src/lib/mcp/responses.test.ts
index b13332e..f7eaa53 100644
--- a/src/lib/mcp/responses.test.ts
+++ b/src/lib/mcp/responses.test.ts
@@ -17,6 +17,10 @@ function apiError(status: number, message: string) {
return APIError.generate(status, undefined, message, new Headers());
}
+function codedApiError(status: number, code: string, message: string) {
+ return APIError.generate(status, { code, message }, undefined, new Headers());
+}
+
function caught(error: unknown) {
try {
throwToolError("manage_browsers", "get", error);
@@ -62,6 +66,38 @@ describe("throwToolError classification", () => {
"Error in manage_browsers (get): plain string",
);
});
+
+ test("keeps stable API codes visible", () => {
+ expect(
+ caught(
+ codedApiError(
+ 409,
+ "project_not_empty",
+ "Project still contains resources",
+ ),
+ ).message,
+ ).toBe(
+ "Error in manage_browsers (get): 409 Project still contains resources [code: project_not_empty]",
+ );
+ expect(
+ caught(
+ codedApiError(
+ 409,
+ "last_active_project",
+ "Cannot delete the last active project",
+ ),
+ ).message,
+ ).toContain("[code: last_active_project]");
+ });
+
+ test("ignores absent and non-string API codes", () => {
+ const absent = apiError(409, "conflict");
+ expect(caught(absent).message).not.toContain("[code:");
+
+ const numeric = codedApiError(409, "temporary", "conflict");
+ (numeric as unknown as { error: { code: number } }).error.code = 123;
+ expect(caught(numeric).message).not.toContain("[code:");
+ });
});
describe("what the client receives", () => {
@@ -76,6 +112,18 @@ describe("what the client receives", () => {
);
});
+ server.tool("coded_api_failure", {}, async () => {
+ throwToolError(
+ "manage_projects",
+ "delete",
+ codedApiError(
+ 409,
+ "project_not_empty",
+ "Project still contains resources",
+ ),
+ );
+ });
+
server.tool("input_guard", {}, async () =>
errorResponse("Error: session_id is required for get action."),
);
@@ -113,4 +161,16 @@ describe("what the client receives", () => {
{ type: "text", text: "Error: session_id is required for get action." },
]);
});
+
+ test("returns coded API rejections to the client", async () => {
+ const result = await callTool("coded_api_failure");
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toEqual([
+ {
+ type: "text",
+ text: "Error in manage_projects (delete): 409 Project still contains resources [code: project_not_empty]",
+ },
+ ]);
+ });
});
diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts
index 1481dac..db63100 100644
--- a/src/lib/mcp/responses.ts
+++ b/src/lib/mcp/responses.ts
@@ -67,8 +67,23 @@ export function errorResponse(text: string) {
return { ...textResponse(text), isError: true as const };
}
+function apiErrorCode(error: APIError) {
+ if (
+ error.error &&
+ typeof error.error === "object" &&
+ "code" in error.error &&
+ typeof error.error.code === "string"
+ ) {
+ return error.error.code;
+ }
+}
+
function errorMessage(error: unknown) {
- return error instanceof Error ? error.message : String(error);
+ const message = error instanceof Error ? error.message : String(error);
+ if (!(error instanceof APIError)) return message;
+
+ const code = apiErrorCode(error);
+ return code ? `${message} [code: ${code}]` : message;
}
// Named after what the API said, so a stale session id (404) is distinguishable from an
diff --git a/src/lib/mcp/tools/durable-contracts.test.ts b/src/lib/mcp/tools/durable-contracts.test.ts
new file mode 100644
index 0000000..7dae9e5
--- /dev/null
+++ b/src/lib/mcp/tools/durable-contracts.test.ts
@@ -0,0 +1,498 @@
+///
+
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { describe, expect, test } from "bun:test";
+import type { McpDependencies } from "@/lib/mcp/dependencies";
+import type { KernelClient } from "@/lib/mcp/kernel-client";
+import { registerProfileCapabilities } from "@/lib/mcp/tools/profiles";
+import { registerProxyTools } from "@/lib/mcp/tools/proxies";
+
+type ToolResult = {
+ content: Array<{ type: string; text: string }>;
+ isError?: boolean;
+};
+
+type ToolHandler = (
+ params: Record,
+ extra: { authInfo?: { token: string } },
+) => Promise;
+
+type RegisterTool = (server: McpServer, dependencies?: McpDependencies) => void;
+
+function testDependencies(client: unknown): McpDependencies {
+ return {
+ createKernelClient: () => client as KernelClient,
+ };
+}
+
+function captureTool(register: RegisterTool, name: string, client: unknown) {
+ let handler: ToolHandler | undefined;
+ const server = {
+ resource() {},
+ tool(toolName: string, ...args: unknown[]) {
+ if (toolName === name) {
+ handler = args.at(-1) as ToolHandler;
+ }
+ },
+ } as unknown as McpServer;
+
+ register(server, testDependencies(client));
+ if (!handler) throw new Error(`${name} was not registered`);
+ return handler;
+}
+
+async function connectTool(register: RegisterTool, kernelClient: unknown) {
+ const server = new McpServer({ name: "test", version: "0.0.0" });
+ const tokens: string[] = [];
+ register(server, {
+ createKernelClient: (token) => {
+ tokens.push(token);
+ return kernelClient as KernelClient;
+ },
+ });
+
+ const client = new Client({ name: "test-client", version: "0.0.0" });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ const send = clientTransport.send.bind(clientTransport);
+ clientTransport.send = (message, options) =>
+ send(message, {
+ ...options,
+ authInfo: {
+ token: "test-token",
+ clientId: "test-client",
+ scopes: [],
+ },
+ });
+
+ await Promise.all([
+ server.connect(serverTransport),
+ client.connect(clientTransport),
+ ]);
+ return { client, tokens };
+}
+
+function profilePage(profiles: Array<{ id: string; name: string }>) {
+ return {
+ async *[Symbol.asyncIterator]() {
+ yield* profiles;
+ },
+ };
+}
+
+const auth = { authInfo: { token: "test-token" } };
+
+describe("durable profile contracts", () => {
+ test("creates a profile when no exact match exists", async () => {
+ const listCalls: unknown[] = [];
+ const createCalls: unknown[] = [];
+ const browserCalls: unknown[] = [];
+ const client = {
+ profiles: {
+ list: (params: unknown) => {
+ listCalls.push(params);
+ return profilePage([]);
+ },
+ create: async (params: unknown) => {
+ createCalls.push(params);
+ return { id: "profile-new", name: "Acme" };
+ },
+ },
+ browsers: {
+ create: async (params: unknown) => {
+ browserCalls.push(params);
+ return {
+ session_id: "session-1",
+ browser_live_view_url: "https://example.test/live",
+ };
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler(
+ { action: "setup", profile_name: "Acme" },
+ auth,
+ );
+
+ expect(listCalls).toEqual([{ name: "Acme" }]);
+ expect(createCalls).toEqual([{ name: "Acme" }]);
+ expect(browserCalls).toEqual([
+ {
+ stealth: true,
+ timeout_seconds: 300,
+ profile: { id: "profile-new", save_changes: true },
+ },
+ ]);
+ expect(result.isError).toBeUndefined();
+ });
+
+ test("rejects ambiguous exact profile matches", async () => {
+ let browserCreated = false;
+ const client = {
+ profiles: {
+ list: () =>
+ profilePage([
+ { id: "profile-1", name: "Acme" },
+ { id: "profile-2", name: "Acme" },
+ ]),
+ },
+ browsers: {
+ create: async () => {
+ browserCreated = true;
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler(
+ { action: "setup", profile_name: "Acme" },
+ auth,
+ );
+
+ expect(result).toEqual({
+ content: [
+ {
+ type: "text",
+ text: 'Error: multiple profiles match the exact name "Acme": Acme (ID: profile-1), Acme (ID: profile-2). Rename or delete duplicate profiles by ID, then retry setup.',
+ },
+ ],
+ isError: true,
+ });
+ expect(browserCreated).toBe(false);
+ });
+
+ test("rejects a missing existing profile", async () => {
+ let profileCreated = false;
+ const client = {
+ profiles: {
+ list: () => profilePage([]),
+ create: async () => {
+ profileCreated = true;
+ },
+ },
+ browsers: {
+ create: async () => {
+ throw new Error("browser setup should not start");
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler(
+ {
+ action: "setup",
+ profile_name: "Missing",
+ update_existing: true,
+ },
+ auth,
+ );
+
+ expect(result).toEqual({
+ content: [
+ {
+ type: "text",
+ text: 'Error: profile "Missing" does not exist. Omit update_existing to create it.',
+ },
+ ],
+ isError: true,
+ });
+ expect(profileCreated).toBe(false);
+ });
+
+ test("loads the exact existing profile", async () => {
+ let profileCreated = false;
+ const browserCalls: unknown[] = [];
+ const client = {
+ profiles: {
+ list: () => profilePage([{ id: "profile-1", name: "Acme" }]),
+ create: async () => {
+ profileCreated = true;
+ },
+ },
+ browsers: {
+ create: async (params: unknown) => {
+ browserCalls.push(params);
+ return {
+ session_id: "session-1",
+ browser_live_view_url: "https://example.test/live",
+ };
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler(
+ { action: "setup", profile_name: "Acme", update_existing: true },
+ auth,
+ );
+
+ expect(profileCreated).toBe(false);
+ expect(browserCalls).toEqual([
+ {
+ stealth: true,
+ timeout_seconds: 300,
+ profile: { id: "profile-1", save_changes: true },
+ },
+ ]);
+ expect(result.content[0].text).toContain(
+ 'Profile "Acme" loaded for update.',
+ );
+ expect(result.content[0].text).toContain("Profile ID: profile-1");
+ });
+
+ test("discovers and renames a profile through the MCP boundary", async () => {
+ const updateCalls: unknown[] = [];
+ const { client, tokens } = await connectTool(registerProfileCapabilities, {
+ profiles: {
+ update: async (...args: unknown[]) => {
+ updateCalls.push(args);
+ return { id: "profile-1", name: "Renamed" };
+ },
+ },
+ });
+ try {
+ const tools = await client.listTools();
+ const tool = tools.tools.find((item) => item.name === "manage_profiles");
+ const schema = tool?.inputSchema as
+ | { properties?: Record }
+ | undefined;
+ expect(schema?.properties?.action.enum).toContain("rename");
+ expect(schema?.properties).toHaveProperty("profile_id");
+ expect(schema?.properties).toHaveProperty("profile_name");
+ expect(schema?.properties).toHaveProperty("new_name");
+
+ const invalid = await client.callTool({
+ name: "manage_profiles",
+ arguments: {
+ action: "rename",
+ profile_id: "profile-1",
+ new_name: 123,
+ },
+ });
+ expect(invalid.isError).toBe(true);
+ expect(updateCalls).toEqual([]);
+
+ for (const selector of [
+ { profile_id: "profile-1" },
+ { profile_name: "Acme" },
+ ]) {
+ const result = await client.callTool({
+ name: "manage_profiles",
+ arguments: { action: "rename", ...selector, new_name: "Renamed" },
+ });
+
+ expect(result.content).toEqual([
+ {
+ type: "text",
+ text: JSON.stringify({ id: "profile-1", name: "Renamed" }, null, 2),
+ },
+ ]);
+ }
+ } finally {
+ await client.close();
+ }
+
+ expect(updateCalls).toEqual([
+ ["profile-1", { name: "Renamed" }],
+ ["Acme", { name: "Renamed" }],
+ ]);
+ expect(tokens).toEqual(["test-token", "test-token"]);
+ });
+
+ test.each([
+ [
+ "both profile identifiers",
+ { profile_id: "profile-1", profile_name: "Acme", new_name: "New" },
+ "Error: Cannot specify both profile_name and profile_id.",
+ ],
+ [
+ "no profile identifier",
+ { new_name: "New" },
+ "Error: profile_name or profile_id is required for rename.",
+ ],
+ [
+ "no new name",
+ { profile_id: "profile-1" },
+ "Error: new_name is required for rename.",
+ ],
+ ])("rejects rename with %s", async (_name, params, wantError) => {
+ let updated = false;
+ const client = {
+ profiles: {
+ update: async () => {
+ updated = true;
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler({ action: "rename", ...params }, auth);
+
+ expect(result).toEqual({
+ content: [{ type: "text", text: wantError }],
+ isError: true,
+ });
+ expect(updated).toBe(false);
+ });
+
+ test.each([
+ [
+ "get",
+ "both identifiers",
+ { profile_id: "profile-1", profile_name: "Acme" },
+ "Error: Cannot specify both profile_name and profile_id.",
+ ],
+ [
+ "get",
+ "no identifier",
+ {},
+ "Error: profile_name or profile_id is required for get.",
+ ],
+ [
+ "delete",
+ "both identifiers",
+ { profile_id: "profile-1", profile_name: "Acme" },
+ "Error: Cannot specify both profile_name and profile_id.",
+ ],
+ [
+ "delete",
+ "no identifier",
+ {},
+ "Error: profile_name or profile_id is required for delete.",
+ ],
+ ])(
+ "preserves %s validation for %s",
+ async (action, _case, params, wantError) => {
+ let called = false;
+ const client = {
+ profiles: {
+ retrieve: async () => {
+ called = true;
+ },
+ delete: async () => {
+ called = true;
+ },
+ },
+ };
+ const handler = captureTool(
+ registerProfileCapabilities,
+ "manage_profiles",
+ client,
+ );
+
+ const result = await handler({ action, ...params }, auth);
+
+ expect(result).toEqual({
+ content: [{ type: "text", text: wantError }],
+ isError: true,
+ });
+ expect(called).toBe(false);
+ },
+ );
+});
+
+describe("durable proxy contracts", () => {
+ test("discovers and renames a proxy through the MCP boundary", async () => {
+ const updateCalls: unknown[] = [];
+ const { client, tokens } = await connectTool(registerProxyTools, {
+ proxies: {
+ update: async (...args: unknown[]) => {
+ updateCalls.push(args);
+ return { id: "proxy-1", name: "Renamed" };
+ },
+ },
+ });
+ try {
+ const tools = await client.listTools();
+ const tool = tools.tools.find((item) => item.name === "manage_proxies");
+ const schema = tool?.inputSchema as
+ | { properties?: Record }
+ | undefined;
+ expect(schema?.properties?.action.enum).toContain("rename");
+ expect(schema?.properties).toHaveProperty("proxy_id");
+ expect(schema?.properties).toHaveProperty("name");
+
+ const invalid = await client.callTool({
+ name: "manage_proxies",
+ arguments: {
+ action: "rename",
+ proxy_id: "proxy-1",
+ name: 123,
+ },
+ });
+ expect(invalid.isError).toBe(true);
+ expect(updateCalls).toEqual([]);
+
+ const result = await client.callTool({
+ name: "manage_proxies",
+ arguments: {
+ action: "rename",
+ proxy_id: "proxy-1",
+ name: "Renamed",
+ },
+ });
+
+ expect(result.content).toEqual([
+ {
+ type: "text",
+ text: JSON.stringify({ id: "proxy-1", name: "Renamed" }, null, 2),
+ },
+ ]);
+ } finally {
+ await client.close();
+ }
+
+ expect(updateCalls).toEqual([["proxy-1", { name: "Renamed" }]]);
+ expect(tokens).toEqual(["test-token"]);
+ });
+
+ test.each([
+ [
+ "no proxy ID",
+ { name: "Renamed" },
+ "Error: proxy_id is required for rename.",
+ ],
+ ["no name", { proxy_id: "proxy-1" }, "Error: name is required for rename."],
+ ])("rejects rename with %s", async (_name, params, wantError) => {
+ let updated = false;
+ const client = {
+ proxies: {
+ update: async () => {
+ updated = true;
+ },
+ },
+ };
+ const handler = captureTool(registerProxyTools, "manage_proxies", client);
+
+ const result = await handler({ action: "rename", ...params }, auth);
+
+ expect(result).toEqual({
+ content: [{ type: "text", text: wantError }],
+ isError: true,
+ });
+ expect(updated).toBe(false);
+ });
+});
diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts
index c444a3e..388b4e8 100644
--- a/src/lib/mcp/tools/profiles.ts
+++ b/src/lib/mcp/tools/profiles.ts
@@ -1,6 +1,10 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client";
+import {
+ defaultMcpDependencies,
+ type McpDependencies,
+} from "@/lib/mcp/dependencies";
+import type { KernelClient } from "@/lib/mcp/kernel-client";
import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates";
import {
errorResponse,
@@ -37,13 +41,38 @@ function fullProfileListResponse(profiles: Profile[], query?: string) {
});
}
-export function registerProfileCapabilities(server: McpServer) {
+function requireProfileIdentifier(
+ params: { profile_name?: string; profile_id?: string },
+ action: "get" | "rename" | "delete",
+) {
+ if (params.profile_name && params.profile_id) {
+ return {
+ ok: false as const,
+ error: "Error: Cannot specify both profile_name and profile_id.",
+ };
+ }
+
+ const identifier = params.profile_name || params.profile_id;
+ if (!identifier) {
+ return {
+ ok: false as const,
+ error: `Error: profile_name or profile_id is required for ${action}.`,
+ };
+ }
+
+ return { ok: true as const, value: identifier };
+}
+
+export function registerProfileCapabilities(
+ server: McpServer,
+ dependencies: McpDependencies = defaultMcpDependencies,
+) {
server.resource("profiles", "profiles://", async (uri, extra) => {
if (!extra.authInfo) {
throw new Error("Authentication required");
}
- const client = createKernelClient(extra.authInfo.token);
+ const client = dependencies.createKernelClient(extra.authInfo.token);
const profiles = await listProfiles(client);
return {
contents: [
@@ -59,29 +88,38 @@ export function registerProfileCapabilities(server: McpServer) {
};
});
- registerJsonResourceTemplate(server, {
- name: "profile",
- uriTemplate: "profiles://{profileName}",
- variableName: "profileName",
- resourceLabel: "Profile",
- read: (client, profileName) => client.profiles.retrieve(profileName),
- });
+ registerJsonResourceTemplate(
+ server,
+ {
+ name: "profile",
+ uriTemplate: "profiles://{profileName}",
+ variableName: "profileName",
+ resourceLabel: "Profile",
+ read: (client, profileName) => client.profiles.retrieve(profileName),
+ },
+ dependencies,
+ );
server.tool(
"manage_profiles",
- 'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, "get" to retrieve one, and "delete" only when a profile should be removed.',
+ 'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, "get" to retrieve one, "rename" to change its name, and "delete" only when a profile should be removed. Do not rename a profile while a browser is using it because that session may no longer save changes back to the profile.',
{
action: z
- .enum(["setup", "list", "get", "delete"])
+ .enum(["setup", "list", "get", "rename", "delete"])
.describe("Operation to perform."),
profile_name: z
.string()
- .describe("(setup, get, delete) Profile name. For setup: 1-255 chars.")
+ .describe(
+ "(setup, get, rename, delete) Profile name. For setup: 1-255 chars.",
+ )
.optional(),
profile_id: z
.string()
- .describe("(get, delete) Profile ID. Alternative to profile_name.")
+ .describe(
+ "(get, rename, delete) Profile ID. Alternative to profile_name.",
+ )
.optional(),
+ new_name: z.string().describe("(rename) New profile name.").optional(),
update_existing: z
.boolean()
.describe("(setup) If true, update existing profile. Default false.")
@@ -101,7 +139,7 @@ export function registerProfileCapabilities(server: McpServer) {
},
async (params, extra) => {
if (!extra.authInfo) throw new Error("Authentication required");
- const client = createKernelClient(extra.authInfo.token);
+ const client = dependencies.createKernelClient(extra.authInfo.token);
try {
switch (params.action) {
@@ -110,13 +148,23 @@ export function registerProfileCapabilities(server: McpServer) {
return errorResponse(
"Error: profile_name is required for setup.",
);
- // Scan all profiles for an exact name match: the list `query` is a
- // search and may not reliably return an exact-named profile, which
- // would let setup create a duplicate.
- const existingProfiles = await listProfiles(client);
- const existingProfile = existingProfiles?.find(
- (p) => p.name === params.profile_name,
- );
+ const existingProfiles = await listProfiles(client, {
+ name: params.profile_name,
+ });
+ if (existingProfiles.length > 1) {
+ const matches = existingProfiles
+ .map((profile) => `${profile.name} (ID: ${profile.id})`)
+ .join(", ");
+ return errorResponse(
+ `Error: multiple profiles match the exact name "${params.profile_name}": ${matches}. Rename or delete duplicate profiles by ID, then retry setup.`,
+ );
+ }
+ const existingProfile = existingProfiles[0];
+ if (!existingProfile && params.update_existing) {
+ return errorResponse(
+ `Error: profile "${params.profile_name}" does not exist. Omit update_existing to create it.`,
+ );
+ }
let profile;
let isNewProfile = false;
@@ -138,7 +186,7 @@ export function registerProfileCapabilities(server: McpServer) {
const browser = await client.browsers.create({
stealth: true,
timeout_seconds: 300,
- profile: { name: params.profile_name, save_changes: true },
+ profile: { id: profile.id, save_changes: true },
});
if (!browser)
return errorResponse(
@@ -182,34 +230,28 @@ export function registerProfileCapabilities(server: McpServer) {
);
}
case "get": {
- if (params.profile_name && params.profile_id) {
- return errorResponse(
- "Error: Cannot specify both profile_name and profile_id.",
- );
- }
- const identifier = params.profile_name || params.profile_id;
- if (!identifier) {
- return errorResponse(
- "Error: profile_name or profile_id is required for get.",
- );
+ const identifier = requireProfileIdentifier(params, "get");
+ if (!identifier.ok) return errorResponse(identifier.error);
+ const profile = await client.profiles.retrieve(identifier.value);
+ return jsonResponse(profile);
+ }
+ case "rename": {
+ const identifier = requireProfileIdentifier(params, "rename");
+ if (!identifier.ok) return errorResponse(identifier.error);
+ if (!params.new_name) {
+ return errorResponse("Error: new_name is required for rename.");
}
- const profile = await client.profiles.retrieve(identifier);
+ const profile = await client.profiles.update(identifier.value, {
+ name: params.new_name,
+ });
return jsonResponse(profile);
}
case "delete": {
- if (params.profile_name && params.profile_id) {
- return errorResponse(
- "Error: Cannot specify both profile_name and profile_id.",
- );
- }
- const identifier = params.profile_name || params.profile_id;
- if (!identifier)
- return errorResponse(
- "Error: profile_name or profile_id is required for delete.",
- );
- await client.profiles.delete(identifier);
+ const identifier = requireProfileIdentifier(params, "delete");
+ if (!identifier.ok) return errorResponse(identifier.error);
+ await client.profiles.delete(identifier.value);
return textResponse(
- `Profile "${identifier}" deleted successfully.`,
+ `Profile "${identifier.value}" deleted successfully.`,
);
}
}
diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts
index 974503f..0a439c1 100644
--- a/src/lib/mcp/tools/proxies.ts
+++ b/src/lib/mcp/tools/proxies.ts
@@ -1,6 +1,9 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
-import { createKernelClient } from "@/lib/mcp/kernel-client";
+import {
+ defaultMcpDependencies,
+ type McpDependencies,
+} from "@/lib/mcp/dependencies";
import {
errorResponse,
jsonResponse,
@@ -25,18 +28,21 @@ const httpUrlSchema = z
{ message: "URL must use http or https." },
);
-export function registerProxyTools(server: McpServer) {
- // manage_proxies -- Create, list, get, check, and delete proxy configurations
+export function registerProxyTools(
+ server: McpServer,
+ dependencies: McpDependencies = defaultMcpDependencies,
+) {
+ // manage_proxies -- Create, list, get, rename, check, and delete proxy configurations
server.tool(
"manage_proxies",
- 'Manage proxy configurations for routing browser traffic. Use "create" to add a proxy, "list" to see all proxies, "get" to retrieve one, "check" to test connectivity (optionally against a target URL), or "delete" to remove one. Proxy quality for bot detection avoidance, best to worst: mobile > residential > ISP > datacenter.',
+ 'Manage proxy configurations for routing browser traffic. Use "create" to add a proxy, "list" to see all proxies, "get" to retrieve one, "rename" to change its name, "check" to test connectivity (optionally against a target URL), or "delete" to remove one. Proxy quality for bot detection avoidance, best to worst: mobile > residential > ISP > datacenter.',
{
action: z
- .enum(["create", "list", "get", "check", "delete"])
+ .enum(["create", "list", "get", "rename", "check", "delete"])
.describe("Operation to perform."),
proxy_id: z
.string()
- .describe("(get, check, delete) Proxy ID.")
+ .describe("(get, rename, check, delete) Proxy ID.")
.optional(),
check_url: httpUrlSchema
.describe(
@@ -49,7 +55,7 @@ export function registerProxyTools(server: McpServer) {
.optional(),
name: z
.string()
- .describe("(create) Readable name for the proxy.")
+ .describe("(create, rename) Readable name for the proxy.")
.optional(),
country: z
.string()
@@ -89,7 +95,7 @@ export function registerProxyTools(server: McpServer) {
},
async (params, extra) => {
if (!extra.authInfo) throw new Error("Authentication required");
- const client = createKernelClient(extra.authInfo.token);
+ const client = dependencies.createKernelClient(extra.authInfo.token);
try {
switch (params.action) {
@@ -151,6 +157,18 @@ export function registerProxyTools(server: McpServer) {
const proxy = await client.proxies.retrieve(params.proxy_id);
return jsonResponse(proxy);
}
+ case "rename": {
+ if (!params.proxy_id) {
+ return errorResponse("Error: proxy_id is required for rename.");
+ }
+ if (!params.name) {
+ return errorResponse("Error: name is required for rename.");
+ }
+ const proxy = await client.proxies.update(params.proxy_id, {
+ name: params.name,
+ });
+ return jsonResponse(proxy);
+ }
case "check": {
if (!params.proxy_id) {
return errorResponse("Error: proxy_id is required for check.");