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.");