diff --git a/README.md b/README.md index 7f34311..6a2ef94 100644 --- a/README.md +++ b/README.md @@ -255,21 +255,26 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (10 total) +## Tools (15 total) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Four standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Seven standalone tools handle high-frequency workflows. ### manage\_\* tools - `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. -- `manage_profiles` - Setup (with guided live browser session), list, and delete browser profiles for persisting cookies and logins. +- `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. +- `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. +- `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. - `manage_browser_pools` - Create, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools. -- `manage_proxies` - Create, list, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). +- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). - `manage_extensions` - List and delete uploaded browser extensions. -- `manage_apps` - List apps, invoke actions, get/list deployments, and get invocation results. +- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. ### Standalone tools +- `browser_curl` - Run an HTTP request from inside a browser session's network context. +- `read_browser_clipboard` - Read clipboard text from a browser session. +- `write_browser_clipboard` - Write clipboard text to a browser session. - `computer_action` - Mouse, keyboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, screenshot). - `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. @@ -277,10 +282,14 @@ Each Kernel feature has a single `manage_*` tool with an `action` parameter, kee ## Resources -- `browsers://` - Access browser sessions (list all or get specific session) -- `browser_pools://` - Access browser pools (list all or get specific pool) -- `profiles://` - Access browser profiles (list all or get specific profile) -- `apps://` - Access deployed apps (list all or get specific app) +- `browsers://` - List browser sessions +- `browser-pools://` - List browser pools +- `profiles://` - List browser profiles +- `apps://` - List deployed apps +- `browsers://{session_id}` - Access one browser session +- `browser-pools://{id_or_name}` - Access one browser pool +- `profiles://{profile_name}` - Access one browser profile +- `apps://{app_name}` - Access one deployed app ## Prompts diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts new file mode 100644 index 0000000..c859d13 --- /dev/null +++ b/src/lib/mcp/browser-config.ts @@ -0,0 +1,114 @@ +export type BrowserProfileParams = { + profile_name?: string; + profile_id?: string; + save_profile_changes?: boolean; +}; + +export type BrowserExtensionParams = { + extension_id?: string; + extension_name?: string; +}; + +export type BrowserViewportParams = { + viewport_width?: number; + viewport_height?: number; + viewport_refresh_rate?: number; +}; + +export type BrowserViewportUpdateParams = BrowserViewportParams & { + viewport_force?: boolean; +}; + +export function buildBrowserStartUrl(startUrl: string | undefined) { + if (startUrl === undefined) return undefined; + + try { + new URL(startUrl); + } catch { + throw new Error("start_url must be a valid URL."); + } + + return startUrl; +} + +export function buildBrowserProfile(params: BrowserProfileParams) { + if (params.profile_name && params.profile_id) { + throw new Error("Cannot specify both profile_name and profile_id."); + } + if ( + params.save_profile_changes !== undefined && + !params.profile_name && + !params.profile_id + ) { + throw new Error( + "profile_name or profile_id is required when save_profile_changes is set.", + ); + } + if (!params.profile_name && !params.profile_id) return undefined; + return { + ...(params.profile_name && { name: params.profile_name }), + ...(params.profile_id && { id: params.profile_id }), + ...(params.save_profile_changes !== undefined && { + save_changes: params.save_profile_changes, + }), + }; +} + +export function buildBrowserExtensions(params: BrowserExtensionParams) { + if (params.extension_id && params.extension_name) { + throw new Error("Cannot specify both extension_id and extension_name."); + } + if (!params.extension_id && !params.extension_name) return undefined; + return [ + { + ...(params.extension_id && { id: params.extension_id }), + ...(params.extension_name && { name: params.extension_name }), + }, + ]; +} + +export function buildBrowserViewport(params: BrowserViewportParams) { + const width = params.viewport_width; + const height = params.viewport_height; + const hasViewportOptions = + width !== undefined || + height !== undefined || + params.viewport_refresh_rate !== undefined; + + if (!hasViewportOptions) return undefined; + if (width === undefined || height === undefined) { + throw new Error( + "viewport_width and viewport_height must be provided together.", + ); + } + + return { + width, + height, + ...(params.viewport_refresh_rate !== undefined && { + refresh_rate: params.viewport_refresh_rate, + }), + }; +} + +export function buildBrowserViewportUpdate( + params: BrowserViewportUpdateParams, +) { + const viewport = buildBrowserViewport(params); + + if (!viewport) { + if (params.viewport_force !== undefined) { + throw new Error( + "viewport_width and viewport_height must be provided when viewport_force is set.", + ); + } + return undefined; + } + + return { + ...viewport, + ...(params.viewport_force !== undefined && { + force: params.viewport_force, + }), + }; +} diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 161148c..22cf0d2 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -3,6 +3,7 @@ import { registerKernelPrompts } from "@/lib/mcp/prompts"; import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys"; import { registerAppCapabilities } from "@/lib/mcp/tools/apps"; import { registerBrowserPoolCapabilities } from "@/lib/mcp/tools/browser-pools"; +import { registerBrowserUtilityTools } from "@/lib/mcp/tools/browser-utilities"; import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; import { registerComputerActionTool } from "@/lib/mcp/tools/computer-action"; import { registerDocsTools } from "@/lib/mcp/tools/docs"; @@ -21,6 +22,7 @@ export function registerMcpCapabilities(server: McpServer) { registerProjectCapabilities(server); registerAPIKeyCapabilities(server); registerBrowserPoolCapabilities(server); + registerBrowserUtilityTools(server); registerProxyTools(server); registerExtensionTools(server); registerAppCapabilities(server); diff --git a/src/lib/mcp/resource-templates.ts b/src/lib/mcp/resource-templates.ts new file mode 100644 index 0000000..839ac6e --- /dev/null +++ b/src/lib/mcp/resource-templates.ts @@ -0,0 +1,61 @@ +import { + ResourceTemplate, + type McpServer, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; + +type JsonResourceTemplateOptions = { + name: string; + uriTemplate: string; + variableName: string; + resourceLabel: string; + read: ( + client: KernelClient, + identifier: string, + ) => Promise; +}; + +function templateVariableValue( + variables: Record, + name: string, +) { + const value = variables[name]; + return Array.isArray(value) ? value[0] : value; +} + +export function registerJsonResourceTemplate( + server: McpServer, + options: JsonResourceTemplateOptions, +) { + server.resource( + options.name, + new ResourceTemplate(options.uriTemplate, { list: undefined }), + async (uri, variables, extra) => { + if (!extra.authInfo) { + throw new Error("Authentication required"); + } + + const identifier = templateVariableValue(variables, options.variableName); + if (!identifier) { + throw new Error(`Invalid ${options.resourceLabel} URI: ${uri}`); + } + + const client = createKernelClient(extra.authInfo.token); + const resource = await options.read(client, identifier); + + if (!resource) { + throw new Error(`${options.resourceLabel} "${identifier}" not found`); + } + + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(resource, null, 2), + }, + ], + }; + }, + ); +} diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts new file mode 100644 index 0000000..582f531 --- /dev/null +++ b/src/lib/mcp/responses.ts @@ -0,0 +1,37 @@ +export function textResponse(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +export function jsonResponse(value: unknown) { + return textResponse(JSON.stringify(value, null, 2)); +} + +export function jsonListResponse( + items: readonly T[] | null | undefined, + emptyText: string, +) { + return items && items.length > 0 + ? jsonResponse(items) + : textResponse(emptyText); +} + +export function paginatedJsonResponse( + page: { + getPaginatedItems(): T[]; + has_more?: boolean | null; + next_offset?: number | null; + }, + emptyText?: string, +) { + const items = page.getPaginatedItems(); + if (items.length === 0 && emptyText) return textResponse(emptyText); + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); +} + +export function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/lib/mcp/tools/api-keys.ts b/src/lib/mcp/tools/api-keys.ts index ec7bd26..4e6c4af 100644 --- a/src/lib/mcp/tools/api-keys.ts +++ b/src/lib/mcp/tools/api-keys.ts @@ -1,6 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; export function registerAPIKeyCapabilities(server: McpServer) { // manage_api_keys -- Create, list, get, update, and delete Kernel API keys @@ -44,11 +45,7 @@ export function registerAPIKeyCapabilities(server: McpServer) { switch (params.action) { case "create": { if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for create." }, - ], - }; + return textResponse("Error: name is required for create."); } const createParams: Parameters[0] = { name: params.name, @@ -60,11 +57,7 @@ export function registerAPIKeyCapabilities(server: McpServer) { createParams.days_to_expire = params.days_to_expire; } const apiKey = await client.apiKeys.create(createParams); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "list": { const page = await client.apiKeys.list({ @@ -72,94 +65,43 @@ export function registerAPIKeyCapabilities(server: McpServer) { ...(params.offset !== undefined && { offset: params.offset }), }); const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ), - }, - ], - }; + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); } case "get": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for get.", - }, - ], - }; + return textResponse("Error: api_key_id is required for get."); } const apiKey = await client.apiKeys.retrieve(params.api_key_id); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "update": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for update.", - }, - ], - }; + return textResponse("Error: api_key_id is required for update."); } if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for update." }, - ], - }; + return textResponse("Error: name is required for update."); } const apiKey = await client.apiKeys.update(params.api_key_id, { name: params.name, }); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "delete": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for delete.", - }, - ], - }; + return textResponse("Error: api_key_id is required for delete."); } await client.apiKeys.delete(params.api_key_id); - return { - content: [{ type: "text", text: "API key deleted successfully" }], - }; + return textResponse("API key deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_api_keys (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_api_keys (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 25e317d..f3d94a5 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -1,6 +1,13 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -9,52 +16,35 @@ export function registerAppCapabilities(server: McpServer) { } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); + const appsPage = await client.apps.list(); + const items = appsPage.getPaginatedItems(); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + items.length > 0 ? JSON.stringify(items, null, 2) : "No apps found", + }, + ], + }; + }); - if (uriString === "apps://") { - // List all apps - const appsPage = await client.apps.list(); - const items = appsPage.getPaginatedItems(); - return { - contents: [ - { - uri: "apps://", - mimeType: "application/json", - text: - items.length > 0 - ? JSON.stringify(items, null, 2) - : "No apps found", - }, - ], - }; - } else if (uriString.startsWith("apps://")) { - // Get specific app by name - const appName = uriString.replace("apps://", ""); + registerJsonResourceTemplate(server, { + name: "app", + uriTemplate: "apps://{appName}", + variableName: "appName", + resourceLabel: "App", + read: async (client, appName) => { const appsPage = await client.apps.list({ app_name: appName }); - const app = appsPage.getPaginatedItems()[0]; - - if (!app) { - throw new Error(`App "${appName}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(app, null, 2), - }, - ], - }; - } - - throw new Error(`Invalid app URI: ${uriString}`); + return appsPage.getPaginatedItems()[0]; + }, }); // manage_apps -- List apps, invoke actions, manage deployments, check invocations server.tool( "manage_apps", - 'Manage Kernel apps, deployments, and invocations. Use "list_apps" to discover apps, "invoke" to execute an app action, "get_deployment"/"list_deployments" to check deployment status, or "get_invocation" to check action results.', + 'Manage Kernel apps, deployments, and invocations. Use "list_apps" to discover apps, "invoke" to execute an app action, "get_deployment"/"list_deployments" to check deployment status, "delete_deployment" to remove a deployment, or "get_invocation" to check action results.', { action: z .enum([ @@ -62,6 +52,7 @@ export function registerAppCapabilities(server: McpServer) { "invoke", "get_deployment", "list_deployments", + "delete_deployment", "get_invocation", ]) .describe("Operation to perform."), @@ -74,9 +65,10 @@ export function registerAppCapabilities(server: McpServer) { version: z .string() .describe( - "(list_apps, invoke) App version filter. Defaults to 'latest' for invoke.", + "(list_apps, invoke, list_deployments) App version filter. Defaults to 'latest' for invoke. Deployment version filtering requires app_name.", ) .optional(), + query: z.string().describe("(list_apps) Search apps by name.").optional(), action_name: z .string() .describe("(invoke) Action to execute within the app.") @@ -87,7 +79,7 @@ export function registerAppCapabilities(server: McpServer) { .optional(), deployment_id: z .string() - .describe("(get_deployment) Deployment ID to retrieve.") + .describe("(get_deployment, delete_deployment) Deployment ID.") .optional(), invocation_id: z .string() @@ -112,40 +104,17 @@ export function registerAppCapabilities(server: McpServer) { const page = await client.apps.list({ ...(params.app_name && { app_name: params.app_name }), ...(params.version && { version: params.version }), + ...(params.query && { query: params.query }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No apps found", - }, - ], - }; + return paginatedJsonResponse(page, "No apps found"); } case "invoke": { if (!params.app_name || !params.action_name) { - return { - content: [ - { - type: "text", - text: "Error: app_name and action_name are required for invoke.", - }, - ], - }; + return textResponse( + "Error: app_name and action_name are required for invoke.", + ); } const invocation = await client.invocations.create({ app_name: params.app_name, @@ -160,22 +129,11 @@ export function registerAppCapabilities(server: McpServer) { let finalInvocation = invocation; for await (const evt of stream) { if (evt.event === "error") { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "error", - invocation_id: invocation.id, - error: evt, - }, - null, - 2, - ), - }, - ], - }; + return jsonResponse({ + status: "error", + invocation_id: invocation.id, + error: evt, + }); } if (evt.event === "invocation_state") { finalInvocation = evt.invocation || finalInvocation; @@ -186,102 +144,62 @@ export function registerAppCapabilities(server: McpServer) { break; } } - return { - content: [ - { - type: "text", - text: JSON.stringify(finalInvocation, null, 2), - }, - ], - }; + return jsonResponse(finalInvocation); } case "get_deployment": { if (!params.deployment_id) - return { - content: [ - { type: "text", text: "Error: deployment_id is required." }, - ], - }; + return textResponse("Error: deployment_id is required."); const deployment = await client.deployments.retrieve( params.deployment_id, ); if (!deployment) - return { - content: [ - { - type: "text", - text: `Deployment "${params.deployment_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(deployment, null, 2) }, - ], - }; + return textResponse( + `Deployment "${params.deployment_id}" not found`, + ); + return jsonResponse(deployment); } case "list_deployments": { + if (params.version && !params.app_name) { + return textResponse( + "Error: app_name is required when filtering deployments by version.", + ); + } const page = await client.deployments.list({ ...(params.app_name && { app_name: params.app_name }), + ...(params.version && { app_version: params.version }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No deployments found", - }, - ], - }; + return paginatedJsonResponse(page, "No deployments found"); + } + case "delete_deployment": { + if (!params.deployment_id) { + return textResponse( + "Error: deployment_id is required for delete_deployment.", + ); + } + await client.deployments.delete(params.deployment_id); + return textResponse( + `Deployment "${params.deployment_id}" deleted successfully.`, + ); } case "get_invocation": { if (!params.invocation_id) - return { - content: [ - { type: "text", text: "Error: invocation_id is required." }, - ], - }; + return textResponse("Error: invocation_id is required."); const invocation = await client.invocations.retrieve( params.invocation_id, ); if (!invocation) - return { - content: [ - { - type: "text", - text: `Invocation "${params.invocation_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(invocation, null, 2) }, - ], - }; + return textResponse( + `Invocation "${params.invocation_id}" not found`, + ); + return jsonResponse(invocation); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_apps (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_apps (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 8d07b5b..a74771c 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -1,60 +1,212 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + buildBrowserExtensions, + buildBrowserProfile, + buildBrowserStartUrl, + buildBrowserViewport, + type BrowserExtensionParams, + type BrowserProfileParams, + type BrowserViewportParams, +} from "@/lib/mcp/browser-config"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; + +type BrowserPoolCreateParams = Parameters< + KernelClient["browserPools"]["create"] +>[0]; +type BrowserPoolUpdateParams = Parameters< + KernelClient["browserPools"]["update"] +>[1]; +type BrowserPoolUpdateBody = Omit & { + size?: BrowserPoolUpdateParams["size"]; +}; + +type BrowserPoolAction = + | "create" + | "update" + | "list" + | "get" + | "delete" + | "flush" + | "acquire" + | "release"; + +type PoolConfigParams = BrowserProfileParams & + BrowserExtensionParams & + BrowserViewportParams & { + size?: number; + name?: string; + headless?: boolean; + stealth?: boolean; + timeout_seconds?: number; + proxy_id?: string; + fill_rate_per_minute?: number; + start_url?: string; + chrome_policy?: Record; + kiosk_mode?: boolean; + }; + +const updateActions: readonly BrowserPoolAction[] = ["update"]; +const createUpdateActions: readonly BrowserPoolAction[] = ["create", "update"]; +const idOrNameActions: readonly BrowserPoolAction[] = [ + "update", + "get", + "delete", + "flush", + "acquire", + "release", +]; +const deleteActions: readonly BrowserPoolAction[] = ["delete"]; +const acquireActions: readonly BrowserPoolAction[] = ["acquire"]; +const releaseActions: readonly BrowserPoolAction[] = ["release"]; + +const browserPoolFieldScopes = { + id_or_name: idOrNameActions, + size: createUpdateActions, + name: createUpdateActions, + headless: createUpdateActions, + stealth: createUpdateActions, + timeout_seconds: createUpdateActions, + profile_name: createUpdateActions, + profile_id: createUpdateActions, + save_profile_changes: createUpdateActions, + proxy_id: createUpdateActions, + fill_rate_per_minute: createUpdateActions, + start_url: createUpdateActions, + chrome_policy: createUpdateActions, + kiosk_mode: createUpdateActions, + extension_id: createUpdateActions, + extension_name: createUpdateActions, + viewport_width: createUpdateActions, + viewport_height: createUpdateActions, + viewport_refresh_rate: createUpdateActions, + discard_all_idle: updateActions, + force: deleteActions, + acquire_timeout_seconds: acquireActions, + session_id: releaseActions, + reuse: releaseActions, +} satisfies Record; + +type BrowserPoolToolField = keyof typeof browserPoolFieldScopes; + +const scopedBrowserPoolFields = Object.keys( + browserPoolFieldScopes, +) as BrowserPoolToolField[]; + +function formatActionScope(field: BrowserPoolToolField) { + return browserPoolFieldScopes[field].join(", "); +} + +function actionFieldError( + params: Partial>, + action: BrowserPoolAction, +) { + const unsupportedField = scopedBrowserPoolFields.find( + (field) => + params[field] !== undefined && + !browserPoolFieldScopes[field].includes(action), + ); + + return unsupportedField + ? `Error: ${unsupportedField} is only supported for ${formatActionScope( + unsupportedField, + )}.` + : undefined; +} + +function buildPoolConfigParams( + params: PoolConfigParams, +): BrowserPoolUpdateBody { + const profile = buildBrowserProfile(params); + const extensions = buildBrowserExtensions(params); + const viewport = buildBrowserViewport(params); + const startUrl = buildBrowserStartUrl(params.start_url); + + return { + ...(params.size !== undefined && { size: params.size }), + ...(params.name && { name: params.name }), + ...(params.headless !== undefined && { headless: params.headless }), + ...(params.stealth !== undefined && { stealth: params.stealth }), + ...(params.timeout_seconds !== undefined && { + timeout_seconds: params.timeout_seconds, + }), + ...(profile && { profile }), + ...(params.proxy_id !== undefined && { proxy_id: params.proxy_id }), + ...(params.fill_rate_per_minute !== undefined && { + fill_rate_per_minute: params.fill_rate_per_minute, + }), + ...(startUrl !== undefined && { start_url: startUrl }), + ...(params.chrome_policy !== undefined && { + chrome_policy: params.chrome_policy, + }), + ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), + ...(extensions && { extensions }), + ...(viewport && { viewport }), + }; +} + +function buildPoolCreateParams( + params: PoolConfigParams, +): BrowserPoolCreateParams { + if (params.size === undefined) { + throw new Error("size is required for create."); + } + + return { ...buildPoolConfigParams(params), size: params.size }; +} + +function buildPoolUpdateParams( + params: PoolConfigParams & { discard_all_idle?: boolean }, +) { + return { + ...buildPoolConfigParams(params), + ...(params.discard_all_idle !== undefined && { + discard_all_idle: params.discard_all_idle, + }), + }; +} export function registerBrowserPoolCapabilities(server: McpServer) { - server.resource("browser_pools", "browser_pools://", async (uri, extra) => { + server.resource("browser_pools", "browser-pools://", async (uri, extra) => { if (!extra.authInfo) { throw new Error("Authentication required"); } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); - - if (uriString === "browser_pools://") { - const pools = await client.browserPools.list(); - return { - contents: [ - { - uri: "browser_pools://", - mimeType: "application/json", - text: - pools && pools.length > 0 - ? JSON.stringify(pools, null, 2) - : "No browser pools found", - }, - ], - }; - } else if (uriString.startsWith("browser_pools://")) { - const idOrName = uriString.replace("browser_pools://", ""); - const pool = await client.browserPools.retrieve(idOrName); - - if (!pool) { - throw new Error(`Browser pool "${idOrName}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(pool, null, 2), - }, - ], - }; - } + const pools = await client.browserPools.list(); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + pools && pools.length > 0 + ? JSON.stringify(pools, null, 2) + : "No browser pools found", + }, + ], + }; + }); - throw new Error(`Invalid browser pool URI: ${uriString}`); + registerJsonResourceTemplate(server, { + name: "browser_pool", + uriTemplate: "browser-pools://{idOrName}", + variableName: "idOrName", + resourceLabel: "Browser pool", + read: (client, idOrName) => client.browserPools.retrieve(idOrName), }); - // manage_browser_pools -- Create, list, get, delete, flush, acquire, and release browser pools + // manage_browser_pools -- Create, update, list, get, delete, flush, acquire, and release browser pools server.tool( "manage_browser_pools", - 'Manage pools of pre-warmed browser instances for fast acquisition. Use "create" to set up a pool, "list"/"get" to inspect pools, "acquire" to get a browser from a pool, "release" to return it, "flush" to destroy idle browsers, or "delete" to remove a pool.', + 'Manage pools of pre-warmed browser instances for fast acquisition. Use "create" to set up a pool, "update" to change pool configuration, "list"/"get" to inspect pools, "acquire" to get a browser from a pool, "release" to return it, "flush" to destroy idle browsers, or "delete" to remove a pool.', { action: z .enum([ "create", + "update", "list", "get", "delete", @@ -66,37 +218,107 @@ export function registerBrowserPoolCapabilities(server: McpServer) { id_or_name: z .string() .describe( - "Pool ID or name. Required for get/delete/flush/acquire/release.", + "Pool ID or name. Required for update/get/delete/flush/acquire/release.", ) .optional(), size: z .number() - .describe("(create) Number of browsers to maintain in the pool.") + .describe( + "(create, update) Number of browsers to maintain in the pool.", + ) + .optional(), + name: z + .string() + .describe("(create, update) Unique pool name.") .optional(), - name: z.string().describe("(create) Unique pool name.").optional(), headless: z .boolean() - .describe("(create) Headless mode for pool browsers.") + .describe("(create, update) Headless mode for pool browsers.") .optional(), stealth: z .boolean() - .describe("(create) Stealth mode for pool browsers.") + .describe("(create, update) Stealth mode for pool browsers.") .optional(), timeout_seconds: z .number() - .describe("(create) Idle timeout for acquired browsers. Default 600.") + .describe( + "(create, update) Idle timeout for acquired browsers. Default 600.", + ) .optional(), profile_name: z .string() - .describe("(create) Profile to load into pool browsers.") + .describe( + "(create, update) Profile name to load into pool browsers. Cannot use with profile_id.", + ) + .optional(), + profile_id: z + .string() + .describe( + "(create, update) Profile ID to load into pool browsers. Cannot use with profile_name.", + ) + .optional(), + save_profile_changes: z + .boolean() + .describe( + "(create, update) Save browser changes back to the selected profile when sessions end.", + ) .optional(), proxy_id: z .string() - .describe("(create) Proxy for pool browsers.") + .describe("(create, update) Proxy for pool browsers.") .optional(), fill_rate_per_minute: z .number() - .describe("(create) Pool fill rate percentage per minute. Default 10%.") + .describe( + "(create, update) Pool fill rate percentage per minute. Default 10%.", + ) + .optional(), + start_url: z + .string() + .url() + .describe( + "(create, update) URL to open when a browser is warmed into the pool. Navigation is best-effort.", + ) + .optional(), + chrome_policy: z + .record(z.string(), z.unknown()) + .describe( + "(create, update) Chrome enterprise policy overrides for all browsers in the pool. Kernel-managed policies such as extensions, proxy, CDP, and automation are blocked by the API.", + ) + .optional(), + kiosk_mode: z + .boolean() + .describe("(create, update) Hide address bar/tabs in live view.") + .optional(), + extension_id: z + .string() + .describe("(create, update) Extension ID to load.") + .optional(), + extension_name: z + .string() + .describe("(create, update) Extension name to load.") + .optional(), + viewport_width: z + .number() + .describe( + "(create, update) Window width in pixels. Must pair with viewport_height.", + ) + .optional(), + viewport_height: z + .number() + .describe( + "(create, update) Window height in pixels. Must pair with viewport_width.", + ) + .optional(), + viewport_refresh_rate: z + .number() + .describe("(create, update) Display refresh rate in Hz.") + .optional(), + discard_all_idle: z + .boolean() + .describe( + "(update) Discard idle browsers and rebuild the pool immediately.", + ) .optional(), force: z .boolean() @@ -122,127 +344,84 @@ export function registerBrowserPoolCapabilities(server: McpServer) { try { switch (params.action) { case "create": { - if (params.size === undefined) - return { - content: [ - { type: "text", text: "Error: size is required for create." }, - ], - }; - const pool = await client.browserPools.create({ - size: params.size, - ...(params.name && { name: params.name }), - ...(params.headless !== undefined && { - headless: params.headless, - }), - ...(params.stealth !== undefined && { stealth: params.stealth }), - ...(params.timeout_seconds !== undefined && { - timeout_seconds: params.timeout_seconds, - }), - ...(params.profile_name && { - profile: { name: params.profile_name }, - }), - ...(params.proxy_id && { proxy_id: params.proxy_id }), - ...(params.fill_rate_per_minute !== undefined && { - fill_rate_per_minute: params.fill_rate_per_minute, - }), - }); - if (!pool) - return { - content: [ - { type: "text", text: "Failed to create browser pool" }, - ], - }; - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + const scopeError = actionFieldError(params, "create"); + if (scopeError) return textResponse(scopeError); + + const pool = await client.browserPools.create( + buildPoolCreateParams(params), + ); + if (!pool) return textResponse("Failed to create browser pool"); + return jsonResponse(pool); + } + case "update": { + const scopeError = actionFieldError(params, "update"); + if (scopeError) return textResponse(scopeError); + if (!params.id_or_name) { + return textResponse("Error: id_or_name is required for update."); + } + + const updateParams = buildPoolUpdateParams(params); + if (Object.keys(updateParams).length === 0) { + return textResponse( + "Error: at least one update field is required.", + ); + } + + // Generated SDK types still require size, but pool PATCH accepts partial bodies. + const pool = await client.browserPools.update( + params.id_or_name, + updateParams as BrowserPoolUpdateParams, + ); + return jsonResponse(pool); } case "list": { + const scopeError = actionFieldError(params, "list"); + if (scopeError) return textResponse(scopeError); + const pools = await client.browserPools.list(); - return { - content: [ - { - type: "text", - text: - pools?.length > 0 - ? JSON.stringify(pools, null, 2) - : "No browser pools found", - }, - ], - }; + return textResponse( + pools?.length > 0 + ? JSON.stringify(pools, null, 2) + : "No browser pools found", + ); } case "get": { + const scopeError = actionFieldError(params, "get"); + if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for get.", - }, - ], - }; + return textResponse("Error: id_or_name is required for get."); const pool = await client.browserPools.retrieve(params.id_or_name); if (!pool) - return { - content: [ - { - type: "text", - text: `Browser pool "${params.id_or_name}" not found`, - }, - ], - }; - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + return textResponse( + `Browser pool "${params.id_or_name}" not found`, + ); + return jsonResponse(pool); } case "delete": { + const scopeError = actionFieldError(params, "delete"); + if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for delete.", - }, - ], - }; + return textResponse("Error: id_or_name is required for delete."); await client.browserPools.delete(params.id_or_name, { ...(params.force !== undefined && { force: params.force }), }); - return { - content: [ - { type: "text", text: "Browser pool deleted successfully" }, - ], - }; + return textResponse("Browser pool deleted successfully"); } case "flush": { + const scopeError = actionFieldError(params, "flush"); + if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for flush.", - }, - ], - }; + return textResponse("Error: id_or_name is required for flush."); await client.browserPools.flush(params.id_or_name); - return { - content: [ - { - type: "text", - text: "Pool flushed successfully. All idle browsers destroyed.", - }, - ], - }; + return textResponse( + "Pool flushed successfully. All idle browsers destroyed.", + ); } case "acquire": { + const scopeError = actionFieldError(params, "acquire"); + if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for acquire.", - }, - ], - }; + return textResponse("Error: id_or_name is required for acquire."); const browser = await client.browserPools.acquire( params.id_or_name, { @@ -252,59 +431,29 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }, ); if (!browser) - return { - content: [ - { type: "text", text: "Failed to acquire browser from pool" }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(browser, null, 2) }, - ], - }; + return textResponse("Failed to acquire browser from pool"); + return jsonResponse(browser); } case "release": { + const scopeError = actionFieldError(params, "release"); + if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for release.", - }, - ], - }; + return textResponse("Error: id_or_name is required for release."); if (!params.session_id) - return { - content: [ - { - type: "text", - text: "Error: session_id is required for release.", - }, - ], - }; + return textResponse("Error: session_id is required for release."); await client.browserPools.release(params.id_or_name, { session_id: params.session_id, ...(params.reuse !== undefined && { reuse: params.reuse }), }); - return { - content: [ - { - type: "text", - text: "Browser released back to pool successfully", - }, - ], - }; + return textResponse("Browser released back to pool successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_browser_pools (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_browser_pools (${params.action}): ${errorMessage( + error, + )}`, + ); } }, ); diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-utilities.ts new file mode 100644 index 0000000..193b77e --- /dev/null +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -0,0 +1,107 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; + +type BrowserCurlParams = Parameters[1]; + +function validateCurlUrl(url: string) { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("url must use http or https."); + } +} + +export function registerBrowserUtilityTools(server: McpServer) { + server.tool( + "browser_curl", + "Send an HTTP request through an existing Kernel browser session's Chrome network stack.", + { + session_id: z.string().describe("Browser session ID."), + url: z.string().url().describe("Target http or https URL."), + method: z + .enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) + .describe("HTTP method. Defaults to GET.") + .optional(), + headers: z + .record(z.string(), z.string()) + .describe("Custom headers merged with browser defaults.") + .optional(), + body: z + .string() + .describe("Request body for POST, PUT, or PATCH requests.") + .optional(), + response_encoding: z + .enum(["utf8", "base64"]) + .describe("Response body encoding. Use base64 for binary content.") + .optional(), + timeout_ms: z + .number() + .describe("Request timeout in milliseconds.") + .optional(), + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + const { session_id, ...curlParams } = params satisfies { + session_id: string; + } & BrowserCurlParams; + validateCurlUrl(curlParams.url); + + const response = await client.browsers.curl(session_id, curlParams); + return jsonResponse(response); + } catch (error) { + return textResponse(`Error in browser_curl: ${errorMessage(error)}`); + } + }, + ); + + server.tool( + "read_browser_clipboard", + "Read clipboard text from an existing Kernel browser session.", + { + session_id: z.string().describe("Browser session ID."), + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + const response = await client.browsers.computer.readClipboard( + params.session_id, + ); + return jsonResponse(response); + } catch (error) { + return textResponse( + `Error in read_browser_clipboard: ${errorMessage(error)}`, + ); + } + }, + ); + + server.tool( + "write_browser_clipboard", + "Write clipboard text to an existing Kernel browser session.", + { + session_id: z.string().describe("Browser session ID."), + text: z.string().describe("Text to write to the browser clipboard."), + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + await client.browsers.computer.writeClipboard(params.session_id, { + text: params.text, + }); + return textResponse("Clipboard updated successfully"); + } catch (error) { + return textResponse( + `Error in write_browser_clipboard: ${errorMessage(error)}`, + ); + } + }, + ); +} diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index e2fd24e..f452d86 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,6 +1,137 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + buildBrowserExtensions, + buildBrowserProfile, + buildBrowserStartUrl, + buildBrowserViewport, + buildBrowserViewportUpdate, +} from "@/lib/mcp/browser-config"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; + +type BrowserCreateParams = NonNullable< + Parameters[0] +>; +type BrowserUpdateParams = Parameters[1]; + +type TelemetryParams = { + telemetry_enabled?: boolean; + telemetry_console?: boolean; + telemetry_network?: boolean; + telemetry_page?: boolean; + telemetry_interaction?: boolean; +}; + +type BrowserAction = "create" | "update" | "list" | "get" | "delete"; + +const createActions: readonly BrowserAction[] = ["create"]; +const updateActions: readonly BrowserAction[] = ["update"]; +const createUpdateActions: readonly BrowserAction[] = ["create", "update"]; +const sessionIdActions: readonly BrowserAction[] = ["update", "get", "delete"]; +const listActions: readonly BrowserAction[] = ["list"]; + +const browserFieldScopes = { + session_id: sessionIdActions, + start_url: createActions, + chrome_policy: createActions, + headless: createActions, + gpu: createActions, + stealth: createActions, + timeout_seconds: createActions, + profile_name: createUpdateActions, + profile_id: createUpdateActions, + save_profile_changes: createUpdateActions, + proxy_id: createUpdateActions, + clear_proxy: updateActions, + disable_default_proxy: updateActions, + kiosk_mode: createActions, + viewport_width: createUpdateActions, + viewport_height: createUpdateActions, + viewport_refresh_rate: createUpdateActions, + viewport_force: updateActions, + extension_id: createActions, + extension_name: createActions, + local_forward: createActions, + remote_forward: createActions, + status: listActions, + limit: listActions, + offset: listActions, + telemetry_enabled: createUpdateActions, + telemetry_console: createUpdateActions, + telemetry_network: createUpdateActions, + telemetry_page: createUpdateActions, + telemetry_interaction: createUpdateActions, +} satisfies Record; + +type BrowserToolField = keyof typeof browserFieldScopes; + +const scopedBrowserFields = Object.keys( + browserFieldScopes, +) as BrowserToolField[]; + +const telemetryCategories = [ + ["telemetry_console", "console"], + ["telemetry_network", "network"], + ["telemetry_page", "page"], + ["telemetry_interaction", "interaction"], +] as const; + +function formatActionScope(field: BrowserToolField) { + return browserFieldScopes[field].join(", "); +} + +function actionFieldError( + params: Partial>, + action: BrowserAction, +) { + const unsupportedField = scopedBrowserFields.find( + (field) => + params[field] !== undefined && + !browserFieldScopes[field].includes(action), + ); + + return unsupportedField + ? `Error: ${unsupportedField} is only supported for ${formatActionScope( + unsupportedField, + )}.` + : undefined; +} + +function buildTelemetry( + params: TelemetryParams, +): BrowserCreateParams["telemetry"] | BrowserUpdateParams["telemetry"] { + const browser: NonNullable< + NonNullable["browser"] + > = {}; + let hasBrowserCategories = false; + + for (const [paramKey, category] of telemetryCategories) { + const enabled = params[paramKey]; + if (enabled !== undefined) { + browser[category] = { enabled }; + hasBrowserCategories = true; + } + } + + if (params.telemetry_enabled === false && hasBrowserCategories) { + throw new Error( + "telemetry_enabled=false cannot be combined with telemetry category settings.", + ); + } + + if (params.telemetry_enabled === undefined && !hasBrowserCategories) { + return undefined; + } + + return { + ...(params.telemetry_enabled !== undefined && { + enabled: params.telemetry_enabled, + }), + ...(hasBrowserCategories && { browser }), + }; +} export function registerBrowserCapabilities(server: McpServer) { server.resource("browsers", "browsers://", async (uri, extra) => { @@ -9,63 +140,67 @@ export function registerBrowserCapabilities(server: McpServer) { } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); - - if (uriString === "browsers://") { - // List all browsers - const browsersPage = await client.browsers.list(); - const items = browsersPage.getPaginatedItems(); - return { - contents: [ - { - uri: "browsers://", - mimeType: "application/json", - text: - items.length > 0 - ? JSON.stringify(items, null, 2) - : "No browsers found", - }, - ], - }; - } else if (uriString.startsWith("browsers://")) { - // Get specific browser by session ID - const sessionId = uriString.replace("browsers://", ""); - const browser = await client.browsers.retrieve(sessionId); - - if (!browser) { - throw new Error(`Browser session "${sessionId}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(browser, null, 2), - }, - ], - }; - } + const browsersPage = await client.browsers.list(); + const items = browsersPage.getPaginatedItems(); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + items.length > 0 + ? JSON.stringify(items, null, 2) + : "No browsers found", + }, + ], + }; + }); - throw new Error(`Invalid browser URI: ${uriString}`); + registerJsonResourceTemplate(server, { + name: "browser", + uriTemplate: "browsers://{sessionId}", + variableName: "sessionId", + resourceLabel: "Browser session", + read: (client, sessionId) => client.browsers.retrieve(sessionId), }); - // manage_browsers -- Create, list, get, and delete browser sessions + // manage_browsers -- Create, update, list, get, and delete browser sessions server.tool( "manage_browsers", - 'Manage browser sessions in the Kernel platform. Use action "create" to launch a new browser, "list" to see existing sessions, "get" to retrieve details about a specific session, or "delete" to terminate one. Created browsers run in isolated VMs and support headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling.', + 'Manage browser sessions in the Kernel platform. Use action "create" to launch a new browser, "update" to modify supported session settings, "list" to see existing sessions, "get" to retrieve details about a specific session, or "delete" to terminate one. Created browsers run in isolated VMs and support headless/stealth modes, profiles, proxies, viewports, extensions, Chrome policy overrides, telemetry, start URLs, and SSH tunneling.', { action: z - .enum(["create", "list", "get", "delete"]) + .enum(["create", "update", "list", "get", "delete"]) .describe("Operation to perform."), session_id: z .string() - .describe("Browser session ID. Required for get and delete actions.") + .describe( + "Browser session ID. Required for update, get, and delete actions.", + ) + .optional(), + start_url: z + .string() + .url() + .describe( + "(create) URL to open when the browser is created. Navigation is best-effort.", + ) + .optional(), + chrome_policy: z + .record(z.string(), z.unknown()) + .describe( + "(create) Chrome enterprise policy overrides. Kernel-managed policies such as extensions, proxy, CDP, and automation are blocked by the API.", + ) .optional(), headless: z .boolean() .describe("(create) Launch without GUI. Faster but no live view.") .optional(), + gpu: z + .boolean() + .describe( + "(create) Enable GPU acceleration. Requires Start-Up or Enterprise plan and headless=false.", + ) + .optional(), stealth: z .boolean() .describe("(create) Avoid bot detection. Recommended for scraping.") @@ -79,20 +214,36 @@ export function registerBrowserCapabilities(server: McpServer) { profile_name: z .string() .describe( - "(create) Profile name to load saved cookies/logins. Cannot use with profile_id.", + "(create, update) Profile name to load saved cookies/logins. Cannot use with profile_id.", ) .optional(), profile_id: z .string() - .describe("(create) Profile ID to load. Cannot use with profile_name.") + .describe( + "(create, update) Profile ID to load. Cannot use with profile_name.", + ) .optional(), save_profile_changes: z .boolean() - .describe("(create) Save session changes back to profile on close.") + .describe( + "(create, update) Save session changes back to profile on close.", + ) .optional(), proxy_id: z .string() - .describe("(create) Proxy ID for traffic routing.") + .describe( + "(create, update) Proxy ID for traffic routing. For update, omit to leave unchanged.", + ) + .optional(), + clear_proxy: z + .boolean() + .describe("(update) Remove the current proxy from the browser session.") + .optional(), + disable_default_proxy: z + .boolean() + .describe( + "(update) For stealth browsers, connect directly instead of using the default stealth proxy.", + ) .optional(), kiosk_mode: z .boolean() @@ -101,18 +252,24 @@ export function registerBrowserCapabilities(server: McpServer) { viewport_width: z .number() .describe( - "(create) Window width in pixels. Must pair with viewport_height.", + "(create, update) Window width in pixels. Must pair with viewport_height.", ) .optional(), viewport_height: z .number() .describe( - "(create) Window height in pixels. Must pair with viewport_width.", + "(create, update) Window height in pixels. Must pair with viewport_width.", ) .optional(), viewport_refresh_rate: z .number() - .describe("(create) Display refresh rate in Hz.") + .describe("(create, update) Display refresh rate in Hz.") + .optional(), + viewport_force: z + .boolean() + .describe( + "(update) Force viewport changes even when live view or recording is active.", + ) .optional(), extension_id: z .string() @@ -144,6 +301,32 @@ export function registerBrowserCapabilities(server: McpServer) { .number() .describe("(list) Pagination offset. Default 0.") .optional(), + telemetry_enabled: z + .boolean() + .describe( + "(create, update) Enable telemetry with VM defaults, or disable telemetry when false.", + ) + .optional(), + telemetry_console: z + .boolean() + .describe("(create, update) Enable or disable console telemetry.") + .optional(), + telemetry_network: z + .boolean() + .describe("(create, update) Enable or disable network telemetry.") + .optional(), + telemetry_page: z + .boolean() + .describe( + "(create, update) Enable or disable page lifecycle telemetry.", + ) + .optional(), + telemetry_interaction: z + .boolean() + .describe( + "(create, update) Enable or disable user interaction telemetry.", + ) + .optional(), }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -152,86 +335,36 @@ export function registerBrowserCapabilities(server: McpServer) { try { switch (params.action) { case "create": { - if (params.profile_name && params.profile_id) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both profile_name and profile_id.", - }, - ], - }; - } - if (params.extension_id && params.extension_name) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both extension_id and extension_name.", - }, - ], - }; - } - if ( - (params.viewport_width && !params.viewport_height) || - (!params.viewport_width && params.viewport_height) - ) { - return { - content: [ - { - type: "text", - text: "Error: viewport_width and viewport_height must be provided together.", - }, - ], - }; - } + const scopeError = actionFieldError(params, "create"); + if (scopeError) return textResponse(scopeError); - const createParams: Record = {}; + const createParams: BrowserCreateParams = {}; if (params.headless !== undefined) createParams.headless = params.headless; + if (params.gpu !== undefined) createParams.gpu = params.gpu; if (params.stealth !== undefined) createParams.stealth = params.stealth; if (params.timeout_seconds !== undefined) createParams.timeout_seconds = params.timeout_seconds; if (params.kiosk_mode !== undefined) createParams.kiosk_mode = params.kiosk_mode; + const startUrl = buildBrowserStartUrl(params.start_url); + if (startUrl !== undefined) createParams.start_url = startUrl; + if (params.chrome_policy) + createParams.chrome_policy = params.chrome_policy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; - if (params.profile_name || params.profile_id) { - createParams.profile = { - ...(params.profile_name && { name: params.profile_name }), - ...(params.profile_id && { id: params.profile_id }), - ...(params.save_profile_changes !== undefined && { - save_changes: params.save_profile_changes, - }), - }; - } - if (params.viewport_width && params.viewport_height) { - createParams.viewport = { - width: params.viewport_width, - height: params.viewport_height, - ...(params.viewport_refresh_rate && { - refresh_rate: params.viewport_refresh_rate, - }), - }; - } - if (params.extension_id || params.extension_name) { - createParams.extensions = [ - { - ...(params.extension_id && { id: params.extension_id }), - ...(params.extension_name && { name: params.extension_name }), - }, - ]; - } + const profile = buildBrowserProfile(params); + if (profile) createParams.profile = profile; + const viewport = buildBrowserViewport(params); + if (viewport) createParams.viewport = viewport; + const telemetry = buildTelemetry(params); + if (telemetry !== undefined) createParams.telemetry = telemetry; + const extensions = buildBrowserExtensions(params); + if (extensions) createParams.extensions = extensions; - const browser = await client.browsers.create( - createParams as Parameters[0], - ); + const browser = await client.browsers.create(createParams); if (!browser) - return { - content: [ - { type: "text", text: "Failed to create browser session" }, - ], - }; + return textResponse("Failed to create browser session"); let responseText = JSON.stringify(browser, null, 2); if (params.local_forward || params.remote_forward) { @@ -261,9 +394,54 @@ export function registerBrowserCapabilities(server: McpServer) { responseText += `\n\nNote: SSH connections alone don't count as browser activity. Set an appropriate timeout or keep the live view open to prevent cleanup.`; } - return { content: [{ type: "text", text: responseText }] }; + return textResponse(responseText); + } + case "update": { + const scopeError = actionFieldError(params, "update"); + if (scopeError) return textResponse(scopeError); + if (!params.session_id) + return textResponse( + "Error: session_id is required for update action.", + ); + if (params.proxy_id && params.clear_proxy) { + return textResponse( + "Error: Cannot specify both proxy_id and clear_proxy.", + ); + } + + const updateParams: BrowserUpdateParams = {}; + if (params.disable_default_proxy !== undefined) { + updateParams.disable_default_proxy = params.disable_default_proxy; + } + if (params.clear_proxy) { + updateParams.proxy_id = ""; + } else if (params.proxy_id !== undefined) { + updateParams.proxy_id = params.proxy_id; + } + const profile = buildBrowserProfile(params); + if (profile) updateParams.profile = profile; + const viewport = buildBrowserViewportUpdate(params); + if (viewport) updateParams.viewport = viewport; + const telemetry = buildTelemetry(params); + if (telemetry !== undefined) updateParams.telemetry = telemetry; + + if (Object.keys(updateParams).length === 0) { + return textResponse( + "Error: at least one update field is required.", + ); + } + + const browser = await client.browsers.update( + params.session_id, + updateParams, + ); + if (!browser) + return textResponse("Failed to update browser session"); + return jsonResponse(browser); } case "list": { + const scopeError = actionFieldError(params, "list"); + if (scopeError) return textResponse(scopeError); const page = await client.browsers.list({ ...(params.status && { status: params.status }), ...(params.limit !== undefined && { limit: params.limit }), @@ -272,79 +450,49 @@ export function registerBrowserCapabilities(server: McpServer) { const items = page .getPaginatedItems() .map((b) => ({ ...b, cdp_ws_url: undefined })); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No browsers found", - }, - ], - }; + return textResponse( + items.length > 0 + ? JSON.stringify( + { + items, + has_more: page.has_more, + next_offset: page.next_offset, + }, + null, + 2, + ) + : "No browsers found", + ); } case "get": { + const scopeError = actionFieldError(params, "get"); + if (scopeError) return textResponse(scopeError); if (!params.session_id) - return { - content: [ - { - type: "text", - text: "Error: session_id is required for get action.", - }, - ], - }; + return textResponse( + "Error: session_id is required for get action.", + ); const browser = await client.browsers.retrieve(params.session_id); if (!browser) - return { - content: [ - { - type: "text", - text: `Browser session "${params.session_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(browser, null, 2) }, - ], - }; + return textResponse( + `Browser session "${params.session_id}" not found`, + ); + return jsonResponse(browser); } case "delete": { + const scopeError = actionFieldError(params, "delete"); + if (scopeError) return textResponse(scopeError); if (!params.session_id) - return { - content: [ - { - type: "text", - text: "Error: session_id is required for delete action.", - }, - ], - }; + return textResponse( + "Error: session_id is required for delete action.", + ); await client.browsers.deleteByID(params.session_id); - return { - content: [ - { type: "text", text: "Browser session deleted successfully" }, - ], - }; + return textResponse("Browser session deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_browsers (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_browsers (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index b7d5312..7168913 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -1,12 +1,17 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + errorMessage, + jsonListResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerExtensionTools(server: McpServer) { // manage_extensions -- List and delete browser extensions server.tool( "manage_extensions", - 'Manage browser extensions uploaded to your organization. Use "list" to see all extensions or "delete" to remove one.', + 'Manage browser extensions uploaded to Kernel. Use "list" to see all extensions available to the current project or "delete" to remove one by ID or name.', { action: z.enum(["list", "delete"]).describe("Operation to perform."), id_or_name: z @@ -22,45 +27,20 @@ export function registerExtensionTools(server: McpServer) { switch (params.action) { case "list": { const extensions = await client.extensions.list(); - return { - content: [ - { - type: "text", - text: - extensions?.length > 0 - ? JSON.stringify(extensions, null, 2) - : "No extensions found", - }, - ], - }; + return jsonListResponse(extensions, "No extensions found"); } case "delete": { - if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for delete.", - }, - ], - }; + if (!params.id_or_name) { + return textResponse("Error: id_or_name is required for delete."); + } await client.extensions.delete(params.id_or_name); - return { - content: [ - { type: "text", text: "Extension deleted successfully" }, - ], - }; + return textResponse("Extension deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_extensions (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_extensions (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index d341c78..fd36272 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -1,10 +1,21 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; -async function listProfiles(client: KernelClient) { +type ProfileListParams = NonNullable< + Parameters[0] +>; + +async function listProfiles(client: KernelClient, query?: ProfileListParams) { const profiles: Awaited>[] = []; - for await (const profile of client.profiles.list()) { + for await (const profile of client.profiles.list(query)) { profiles.push(profile); } return profiles; @@ -17,67 +28,54 @@ export function registerProfileCapabilities(server: McpServer) { } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); - - if (uriString === "profiles://") { - // List all profiles - const profiles = await listProfiles(client); - return { - contents: [ - { - uri: "profiles://", - mimeType: "application/json", - text: - profiles.length > 0 - ? JSON.stringify(profiles, null, 2) - : "No profiles found", - }, - ], - }; - } else if (uriString.startsWith("profiles://")) { - // Get specific profile by name - const profileName = uriString.replace("profiles://", ""); - const profile = await client.profiles.retrieve(profileName); - - if (!profile) { - throw new Error(`Profile "${profileName}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(profile, null, 2), - }, - ], - }; - } + const profiles = await listProfiles(client); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + profiles.length > 0 + ? JSON.stringify(profiles, null, 2) + : "No profiles found", + }, + ], + }; + }); - throw new Error(`Invalid profile URI: ${uriString}`); + registerJsonResourceTemplate(server, { + name: "profile", + uriTemplate: "profiles://{profileName}", + variableName: "profileName", + resourceLabel: "Profile", + read: (client, profileName) => client.profiles.retrieve(profileName), }); server.tool( "manage_profiles", - 'Manage browser profiles that persist cookies, logins, and session data across browser sessions. Use action "setup" to create/update a profile with a guided live browser session, "list" to see all profiles, or "delete" to remove one.', + 'Manage browser profiles that persist cookies, logins, and session data across browser sessions. Use action "setup" to create/update a profile with a guided live browser session, "list" to search profiles with pagination, "get" to retrieve one, or "delete" to remove one.', { action: z - .enum(["setup", "list", "delete"]) + .enum(["setup", "list", "get", "delete"]) .describe("Operation to perform."), profile_name: z .string() - .describe( - "(setup, delete) Profile name. For setup: 1-255 chars. For delete: name of profile to remove.", - ) + .describe("(setup, get, delete) Profile name. For setup: 1-255 chars.") .optional(), profile_id: z .string() - .describe("(delete) Profile ID to delete. Alternative to profile_name.") + .describe("(get, delete) Profile ID. Alternative to profile_name.") .optional(), update_existing: z .boolean() .describe("(setup) If true, update existing profile. Default false.") .optional(), + query: z + .string() + .describe("(list) Search profiles by name or ID.") + .optional(), + limit: z.number().describe("(list) Max results per page.").optional(), + offset: z.number().describe("(list) Pagination offset.").optional(), }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -87,15 +85,10 @@ export function registerProfileCapabilities(server: McpServer) { switch (params.action) { case "setup": { if (!params.profile_name) - return { - content: [ - { - type: "text", - text: "Error: profile_name is required for setup.", - }, - ], - }; - const existingProfiles = await listProfiles(client); + return textResponse("Error: profile_name is required for setup."); + const existingProfiles = await listProfiles(client, { + query: params.profile_name, + }); const existingProfile = existingProfiles?.find( (p) => p.name === params.profile_name, ); @@ -104,24 +97,16 @@ export function registerProfileCapabilities(server: McpServer) { if (existingProfile) { if (!params.update_existing) { - return { - content: [ - { - type: "text", - text: `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, - }, - ], - }; + return textResponse( + `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, + ); } profile = existingProfile; } else { profile = await client.profiles.create({ name: params.profile_name, }); - if (!profile) - return { - content: [{ type: "text", text: "Failed to create profile" }], - }; + if (!profile) return textResponse("Failed to create profile"); isNewProfile = true; } @@ -131,83 +116,62 @@ export function registerProfileCapabilities(server: McpServer) { profile: { name: params.profile_name, save_changes: true }, }); if (!browser) - return { - content: [ - { - type: "text", - text: "Failed to create browser for profile setup", - }, - ], - }; + return textResponse("Failed to create browser for profile setup"); - return { - content: [ - { - type: "text", - text: - `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + - `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + - `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + - `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, - }, - ], - }; + return textResponse( + `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + + `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + + `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + + `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, + ); } case "list": { - const profiles = await listProfiles(client); - return { - content: [ - { - type: "text", - text: - profiles?.length > 0 - ? JSON.stringify(profiles, null, 2) - : "No profiles found. Use manage_profiles with action 'setup' to create one.", - }, - ], - }; + const page = await client.profiles.list({ + ...(params.query && { query: params.query }), + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }); + return paginatedJsonResponse( + page, + "No profiles found. Use manage_profiles with action 'setup' to create one.", + ); + } + case "get": { + if (params.profile_name && params.profile_id) { + return textResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); + } + const identifier = params.profile_name || params.profile_id; + if (!identifier) { + return textResponse( + "Error: profile_name or profile_id is required for get.", + ); + } + const profile = await client.profiles.retrieve(identifier); + return jsonResponse(profile); } case "delete": { if (params.profile_name && params.profile_id) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both profile_name and profile_id.", - }, - ], - }; + return textResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); } const identifier = params.profile_name || params.profile_id; if (!identifier) - return { - content: [ - { - type: "text", - text: "Error: profile_name or profile_id is required for delete.", - }, - ], - }; + return textResponse( + "Error: profile_name or profile_id is required for delete.", + ); await client.profiles.delete(identifier); - return { - content: [ - { - type: "text", - text: `Profile "${identifier}" deleted successfully.`, - }, - ], - }; + return textResponse( + `Profile "${identifier}" deleted successfully.`, + ); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_profiles (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_profiles (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index 1ea8c71..f0f357c 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -1,19 +1,35 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerProjectCapabilities(server: McpServer) { - // manage_projects -- Create, list, get, update, and delete organization projects + // manage_projects -- Create, list, get, update, delete, and manage organization project limits server.tool( "manage_projects", - 'Manage Kernel projects for resource isolation within an organization. Use "create" to create a project, "list" to discover projects, "get" to retrieve one, "update" to rename or archive one, or "delete" to remove an empty project.', + 'Manage Kernel projects for resource isolation within an organization. Use "create" to create a project, "list" to discover projects, "get" to retrieve one, "update" to rename or archive one, "delete" to remove an empty project, "get_limits" to inspect project caps, or "update_limits" to change project caps.', { action: z - .enum(["create", "list", "get", "update", "delete"]) + .enum([ + "create", + "list", + "get", + "update", + "delete", + "get_limits", + "update_limits", + ]) .describe("Operation to perform."), project_id: z .string() - .describe("Project ID. Required for get, update, and delete.") + .describe( + "Project ID. Required for get, update, delete, get_limits, and update_limits.", + ) .optional(), name: z.string().describe("(create, update) Project name.").optional(), status: z @@ -28,6 +44,27 @@ export function registerProjectCapabilities(server: McpServer) { .optional(), limit: z.number().describe("(list) Max results per page.").optional(), offset: z.number().describe("(list) Pagination offset.").optional(), + max_concurrent_invocations: z + .number() + .nullable() + .describe( + "(update_limits) Maximum concurrent app invocations for this project. Set 0 to remove the cap.", + ) + .optional(), + max_concurrent_sessions: z + .number() + .nullable() + .describe( + "(update_limits) Maximum concurrent browser sessions for this project. Set 0 to remove the cap.", + ) + .optional(), + max_pooled_sessions: z + .number() + .nullable() + .describe( + "(update_limits) Maximum pooled sessions capacity for this project. Set 0 to remove the cap.", + ) + .optional(), }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -37,18 +74,10 @@ export function registerProjectCapabilities(server: McpServer) { switch (params.action) { case "create": { if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for create." }, - ], - }; + return textResponse("Error: name is required for create."); } const project = await client.projects.create({ name: params.name }); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "list": { const page = await client.projects.list({ @@ -56,62 +85,23 @@ export function registerProjectCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ), - }, - ], - }; + return paginatedJsonResponse(page); } case "get": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for get.", - }, - ], - }; + return textResponse("Error: project_id is required for get."); } const project = await client.projects.retrieve(params.project_id); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "update": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for update.", - }, - ], - }; + return textResponse("Error: project_id is required for update."); } if (!params.name && !params.status) { - return { - content: [ - { - type: "text", - text: "Error: name or status is required for update.", - }, - ], - }; + return textResponse( + "Error: name or status is required for update.", + ); } const updateParams: Parameters[1] = {}; @@ -121,38 +111,62 @@ export function registerProjectCapabilities(server: McpServer) { params.project_id, updateParams, ); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "delete": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for delete.", - }, - ], - }; + return textResponse("Error: project_id is required for delete."); } await client.projects.delete(params.project_id); - return { - content: [{ type: "text", text: "Project deleted successfully" }], - }; + return textResponse("Project deleted successfully"); + } + case "get_limits": { + if (!params.project_id) { + return textResponse( + "Error: project_id is required for get_limits.", + ); + } + const limits = await client.projects.limits.retrieve( + params.project_id, + ); + return jsonResponse(limits); + } + case "update_limits": { + if (!params.project_id) { + return textResponse( + "Error: project_id is required for update_limits.", + ); + } + const updateParams: Parameters< + typeof client.projects.limits.update + >[1] = {}; + if (params.max_concurrent_invocations !== undefined) { + updateParams.max_concurrent_invocations = + params.max_concurrent_invocations; + } + if (params.max_concurrent_sessions !== undefined) { + updateParams.max_concurrent_sessions = + params.max_concurrent_sessions; + } + if (params.max_pooled_sessions !== undefined) { + updateParams.max_pooled_sessions = params.max_pooled_sessions; + } + if (Object.keys(updateParams).length === 0) { + return textResponse( + "Error: at least one limit field is required for update_limits.", + ); + } + const limits = await client.projects.limits.update( + params.project_id, + updateParams, + ); + return jsonResponse(limits); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_projects (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_projects (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index c41c5e3..7447032 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -1,17 +1,46 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + errorMessage, + jsonListResponse, + jsonResponse, + textResponse, +} from "@/lib/mcp/responses"; + +const httpUrlSchema = z + .string() + .url() + .refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }, + { message: "URL must use http or https." }, + ); export function registerProxyTools(server: McpServer) { - // manage_proxies -- Create, list, and delete proxy configurations + // manage_proxies -- Create, list, get, 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, 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, "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", "delete"]) + .enum(["create", "list", "get", "check", "delete"]) .describe("Operation to perform."), - proxy_id: z.string().describe("(delete) Proxy ID to delete.").optional(), + proxy_id: z + .string() + .describe("(get, check, delete) Proxy ID.") + .optional(), + check_url: httpUrlSchema + .describe( + "(check) Optional HTTP(S) URL to test through the proxy instead of Kernel's default check target.", + ) + .optional(), type: z .enum(["datacenter", "isp", "residential", "mobile", "custom"]) .describe("(create) Proxy type.") @@ -56,23 +85,14 @@ export function registerProxyTools(server: McpServer) { switch (params.action) { case "create": { if (!params.type) - return { - content: [ - { type: "text", text: "Error: type is required for create." }, - ], - }; + return textResponse("Error: type is required for create."); if ( params.type === "custom" && (!params.custom_host || !params.custom_port) ) { - return { - content: [ - { - type: "text", - text: "Error: custom_host and custom_port are required for custom proxy type.", - }, - ], - }; + return textResponse( + "Error: custom_host and custom_port are required for custom proxy type.", + ); } const createParams: Parameters[0] = params.type === "custom" @@ -102,53 +122,41 @@ export function registerProxyTools(server: McpServer) { }), }; const proxy = await client.proxies.create(createParams); - if (!proxy) - return { - content: [{ type: "text", text: "Failed to create proxy" }], - }; - return { - content: [{ type: "text", text: JSON.stringify(proxy, null, 2) }], - }; + if (!proxy) return textResponse("Failed to create proxy"); + return jsonResponse(proxy); } case "list": { const proxies = await client.proxies.list(); - return { - content: [ - { - type: "text", - text: - proxies?.length > 0 - ? JSON.stringify(proxies, null, 2) - : "No proxies found", - }, - ], - }; + return jsonListResponse(proxies, "No proxies found"); + } + case "get": { + if (!params.proxy_id) { + return textResponse("Error: proxy_id is required for get."); + } + const proxy = await client.proxies.retrieve(params.proxy_id); + return jsonResponse(proxy); + } + case "check": { + if (!params.proxy_id) { + return textResponse("Error: proxy_id is required for check."); + } + const result = await client.proxies.check( + params.proxy_id, + params.check_url ? { url: params.check_url } : undefined, + ); + return jsonResponse(result); } case "delete": { if (!params.proxy_id) - return { - content: [ - { - type: "text", - text: "Error: proxy_id is required for delete.", - }, - ], - }; + return textResponse("Error: proxy_id is required for delete."); await client.proxies.delete(params.proxy_id); - return { - content: [{ type: "text", text: "Proxy deleted successfully" }], - }; + return textResponse("Proxy deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_proxies (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_proxies (${params.action}): ${errorMessage(error)}`, + ); } }, );