From bd034e469e3002f3d30c6e470a764730ae9387ed Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 17:45:21 -0400 Subject: [PATCH 01/20] Add browser session parity options --- src/lib/mcp/tools/browsers.ts | 328 +++++++++++++++++++++++++++++----- 1 file changed, 288 insertions(+), 40 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index e2fd24e..2915726 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,6 +1,143 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; + +type BrowserCreateParams = NonNullable< + Parameters[0] +>; +type BrowserUpdateParams = Parameters[1]; + +type ProfileParams = { + profile_name?: string; + profile_id?: string; + save_profile_changes?: boolean; +}; + +type ViewportParams = { + viewport_width?: number; + viewport_height?: number; + viewport_refresh_rate?: number; + viewport_force?: boolean; +}; + +type TelemetryParams = { + telemetry_enabled?: boolean; + telemetry_console?: boolean; + telemetry_network?: boolean; + telemetry_page?: boolean; + telemetry_interaction?: boolean; +}; + +const telemetryCategories = [ + ["telemetry_console", "console"], + ["telemetry_network", "network"], + ["telemetry_page", "page"], + ["telemetry_interaction", "interaction"], +] as const; + +const createOnlyFields = [ + "start_url", + "chrome_policy", + "gpu", + "headless", + "stealth", + "timeout_seconds", + "kiosk_mode", +] as const; + +const updateOnlyFields = [ + "clear_proxy", + "disable_default_proxy", + "viewport_force", +] as const; + +function textResponse(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function buildProfile(params: ProfileParams): BrowserCreateParams["profile"] { + 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, + }), + }; +} + +function buildViewport( + params: ViewportParams, + options?: { includeForce?: boolean }, +): BrowserCreateParams["viewport"] | BrowserUpdateParams["viewport"] { + const hasWidth = params.viewport_width !== undefined; + const hasHeight = params.viewport_height !== undefined; + const hasViewportOptions = + hasWidth || + hasHeight || + params.viewport_refresh_rate !== undefined || + (options?.includeForce && params.viewport_force !== undefined); + + if (!hasViewportOptions) return undefined; + if (!hasWidth || !hasHeight) { + throw new Error( + "viewport_width and viewport_height must be provided together.", + ); + } + + return { + width: params.viewport_width!, + height: params.viewport_height!, + ...(params.viewport_refresh_rate !== undefined && { + refresh_rate: params.viewport_refresh_rate, + }), + ...(options?.includeForce && + params.viewport_force !== undefined && { force: params.viewport_force }), + }; +} + +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) => { @@ -53,19 +190,40 @@ export function registerBrowserCapabilities(server: McpServer) { // manage_browsers -- Create, 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.") @@ -92,7 +250,19 @@ export function registerBrowserCapabilities(server: McpServer) { .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() @@ -112,7 +282,13 @@ export function registerBrowserCapabilities(server: McpServer) { .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 +320,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"); @@ -172,48 +374,35 @@ export function registerBrowserCapabilities(server: McpServer) { ], }; } - 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 updateOnlyField = updateOnlyFields.find( + (field) => params[field] !== undefined, + ); + if (updateOnlyField) { + return textResponse( + `Error: ${updateOnlyField} is only supported for update.`, + ); } - 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; + if (params.start_url) createParams.start_url = params.start_url; + 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, - }), - }; - } + const profile = buildProfile(params); + if (profile) createParams.profile = profile; + const viewport = buildViewport(params); + if (viewport) createParams.viewport = viewport; + const telemetry = buildTelemetry(params); + if (telemetry !== undefined) createParams.telemetry = telemetry; if (params.extension_id || params.extension_name) { createParams.extensions = [ { @@ -223,9 +412,7 @@ export function registerBrowserCapabilities(server: McpServer) { ]; } - const browser = await client.browsers.create( - createParams as Parameters[0], - ); + const browser = await client.browsers.create(createParams); if (!browser) return { content: [ @@ -263,6 +450,67 @@ export function registerBrowserCapabilities(server: McpServer) { } return { content: [{ type: "text", text: responseText }] }; } + case "update": { + if (!params.session_id) + return textResponse( + "Error: session_id is required for update action.", + ); + if (params.profile_name && params.profile_id) { + return textResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); + } + if (params.extension_id || params.extension_name) { + return textResponse( + "Error: extensions can only be loaded during create.", + ); + } + const createOnlyField = createOnlyFields.find( + (field) => params[field] !== undefined, + ); + if (createOnlyField) { + return textResponse( + `Error: ${createOnlyField} is only supported for create.`, + ); + } + 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 = buildProfile(params); + if (profile) updateParams.profile = profile; + const viewport = buildViewport(params, { includeForce: true }); + 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, + ); + return { + content: [ + { type: "text", text: JSON.stringify(browser, null, 2) }, + ], + }; + } case "list": { const page = await client.browsers.list({ ...(params.status && { status: params.status }), From a689dcc7b6db29803485b484cf824d4889dcb682 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 18:03:31 -0400 Subject: [PATCH 02/20] Clean up browser tool action validation --- src/lib/mcp/tools/browsers.ts | 199 +++++++++++++++++++++++++--------- 1 file changed, 150 insertions(+), 49 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 2915726..dd8266b 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -28,33 +28,112 @@ type TelemetryParams = { telemetry_interaction?: boolean; }; -const telemetryCategories = [ - ["telemetry_console", "console"], - ["telemetry_network", "network"], - ["telemetry_page", "page"], - ["telemetry_interaction", "interaction"], -] as const; +type BrowserAction = "create" | "update" | "list" | "get" | "delete"; -const createOnlyFields = [ +const scopedBrowserFields = [ + "session_id", "start_url", "chrome_policy", - "gpu", "headless", + "gpu", "stealth", "timeout_seconds", - "kiosk_mode", -] as const; - -const updateOnlyFields = [ + "profile_name", + "profile_id", + "save_profile_changes", + "proxy_id", "clear_proxy", "disable_default_proxy", + "kiosk_mode", + "viewport_width", + "viewport_height", + "viewport_refresh_rate", "viewport_force", + "extension_id", + "extension_name", + "local_forward", + "remote_forward", + "status", + "limit", + "offset", + "telemetry_enabled", + "telemetry_console", + "telemetry_network", + "telemetry_page", + "telemetry_interaction", +] as const; + +type BrowserToolField = (typeof scopedBrowserFields)[number]; + +const createActions: readonly BrowserAction[] = ["create"]; +const updateActions: readonly BrowserAction[] = ["update"]; +const createUpdateActions: readonly BrowserAction[] = ["create", "update"]; + +const browserFieldScopes: Record = { + session_id: ["update", "get", "delete"], + 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: ["list"], + limit: ["list"], + offset: ["list"], + telemetry_enabled: createUpdateActions, + telemetry_console: createUpdateActions, + telemetry_network: createUpdateActions, + telemetry_page: createUpdateActions, + telemetry_interaction: createUpdateActions, +}; + +const telemetryCategories = [ + ["telemetry_console", "console"], + ["telemetry_network", "network"], + ["telemetry_page", "page"], + ["telemetry_interaction", "interaction"], ] as const; function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } +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 buildProfile(params: ProfileParams): BrowserCreateParams["profile"] { if ( params.save_profile_changes !== undefined && @@ -75,17 +154,15 @@ function buildProfile(params: ProfileParams): BrowserCreateParams["profile"] { }; } -function buildViewport( +function buildViewportBase( params: ViewportParams, - options?: { includeForce?: boolean }, -): BrowserCreateParams["viewport"] | BrowserUpdateParams["viewport"] { - const hasWidth = params.viewport_width !== undefined; - const hasHeight = params.viewport_height !== undefined; +): NonNullable | undefined { + const width = params.viewport_width; + const height = params.viewport_height; + const hasWidth = width !== undefined; + const hasHeight = height !== undefined; const hasViewportOptions = - hasWidth || - hasHeight || - params.viewport_refresh_rate !== undefined || - (options?.includeForce && params.viewport_force !== undefined); + hasWidth || hasHeight || params.viewport_refresh_rate !== undefined; if (!hasViewportOptions) return undefined; if (!hasWidth || !hasHeight) { @@ -95,13 +172,39 @@ function buildViewport( } return { - width: params.viewport_width!, - height: params.viewport_height!, + width, + height, ...(params.viewport_refresh_rate !== undefined && { refresh_rate: params.viewport_refresh_rate, }), - ...(options?.includeForce && - params.viewport_force !== undefined && { force: params.viewport_force }), + }; +} + +function buildCreateViewport( + params: ViewportParams, +): BrowserCreateParams["viewport"] { + return buildViewportBase(params); +} + +function buildUpdateViewport( + params: ViewportParams, +): BrowserUpdateParams["viewport"] { + const viewport = buildViewportBase(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, + }), }; } @@ -187,7 +290,7 @@ export function registerBrowserCapabilities(server: McpServer) { throw new Error(`Invalid browser URI: ${uriString}`); }); - // 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, "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.', @@ -237,16 +340,20 @@ 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() @@ -271,13 +378,13 @@ 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 @@ -354,6 +461,8 @@ export function registerBrowserCapabilities(server: McpServer) { try { switch (params.action) { case "create": { + const scopeError = actionFieldError(params, "create"); + if (scopeError) return textResponse(scopeError); if (params.profile_name && params.profile_id) { return { content: [ @@ -374,14 +483,6 @@ export function registerBrowserCapabilities(server: McpServer) { ], }; } - const updateOnlyField = updateOnlyFields.find( - (field) => params[field] !== undefined, - ); - if (updateOnlyField) { - return textResponse( - `Error: ${updateOnlyField} is only supported for update.`, - ); - } const createParams: BrowserCreateParams = {}; if (params.headless !== undefined) @@ -399,7 +500,7 @@ export function registerBrowserCapabilities(server: McpServer) { if (params.proxy_id) createParams.proxy_id = params.proxy_id; const profile = buildProfile(params); if (profile) createParams.profile = profile; - const viewport = buildViewport(params); + const viewport = buildCreateViewport(params); if (viewport) createParams.viewport = viewport; const telemetry = buildTelemetry(params); if (telemetry !== undefined) createParams.telemetry = telemetry; @@ -451,6 +552,8 @@ export function registerBrowserCapabilities(server: McpServer) { return { content: [{ type: "text", text: 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.", @@ -465,14 +568,6 @@ export function registerBrowserCapabilities(server: McpServer) { "Error: extensions can only be loaded during create.", ); } - const createOnlyField = createOnlyFields.find( - (field) => params[field] !== undefined, - ); - if (createOnlyField) { - return textResponse( - `Error: ${createOnlyField} is only supported for create.`, - ); - } if (params.proxy_id && params.clear_proxy) { return textResponse( "Error: Cannot specify both proxy_id and clear_proxy.", @@ -490,7 +585,7 @@ export function registerBrowserCapabilities(server: McpServer) { } const profile = buildProfile(params); if (profile) updateParams.profile = profile; - const viewport = buildViewport(params, { includeForce: true }); + const viewport = buildUpdateViewport(params); if (viewport) updateParams.viewport = viewport; const telemetry = buildTelemetry(params); if (telemetry !== undefined) updateParams.telemetry = telemetry; @@ -512,6 +607,8 @@ export function registerBrowserCapabilities(server: McpServer) { }; } 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 }), @@ -541,6 +638,8 @@ export function registerBrowserCapabilities(server: McpServer) { }; } case "get": { + const scopeError = actionFieldError(params, "get"); + if (scopeError) return textResponse(scopeError); if (!params.session_id) return { content: [ @@ -567,6 +666,8 @@ export function registerBrowserCapabilities(server: McpServer) { }; } case "delete": { + const scopeError = actionFieldError(params, "delete"); + if (scopeError) return textResponse(scopeError); if (!params.session_id) return { content: [ From bbadbaf2f9522f102b1d4055c4f7ea471fddb867 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Fri, 29 May 2026 19:00:48 -0400 Subject: [PATCH 03/20] Derive browser field scopes from map --- src/lib/mcp/tools/browsers.ts | 55 +++++++++-------------------------- 1 file changed, 14 insertions(+), 41 deletions(-) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index dd8266b..82c6066 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -30,47 +30,14 @@ type TelemetryParams = { type BrowserAction = "create" | "update" | "list" | "get" | "delete"; -const scopedBrowserFields = [ - "session_id", - "start_url", - "chrome_policy", - "headless", - "gpu", - "stealth", - "timeout_seconds", - "profile_name", - "profile_id", - "save_profile_changes", - "proxy_id", - "clear_proxy", - "disable_default_proxy", - "kiosk_mode", - "viewport_width", - "viewport_height", - "viewport_refresh_rate", - "viewport_force", - "extension_id", - "extension_name", - "local_forward", - "remote_forward", - "status", - "limit", - "offset", - "telemetry_enabled", - "telemetry_console", - "telemetry_network", - "telemetry_page", - "telemetry_interaction", -] as const; - -type BrowserToolField = (typeof scopedBrowserFields)[number]; - 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: Record = { - session_id: ["update", "get", "delete"], +const browserFieldScopes = { + session_id: sessionIdActions, start_url: createActions, chrome_policy: createActions, headless: createActions, @@ -92,15 +59,21 @@ const browserFieldScopes: Record = { extension_name: createActions, local_forward: createActions, remote_forward: createActions, - status: ["list"], - limit: ["list"], - offset: ["list"], + 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"], From 6498c245a6c5a16bc340bc77c0bbc9ab96fcaa19 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:48:35 -0400 Subject: [PATCH 04/20] Guard empty browser update response --- src/lib/mcp/tools/browsers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 82c6066..3ea284d 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -573,6 +573,8 @@ export function registerBrowserCapabilities(server: McpServer) { params.session_id, updateParams, ); + if (!browser) + return textResponse("Failed to update browser session"); return { content: [ { type: "text", text: JSON.stringify(browser, null, 2) }, From 9b8716e0e1eefb1f0029f7515afab0e6be9fab1d Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:53:04 -0400 Subject: [PATCH 05/20] Add browser utility MCP tool --- src/lib/mcp/register.ts | 2 + src/lib/mcp/tools/browser-utilities.ts | 154 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/lib/mcp/tools/browser-utilities.ts diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index ced49a9..5a3d7cf 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"; @@ -22,6 +23,7 @@ const mcpToolRegistrations = [ ["projects", registerProjectCapabilities], ["api_keys", registerAPIKeyCapabilities], ["browser_pools", registerBrowserPoolCapabilities], + ["browser_utilities", registerBrowserUtilityTools], ["proxies", registerProxyTools], ["extensions", registerExtensionTools], ["apps", registerAppCapabilities], diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-utilities.ts new file mode 100644 index 0000000..03f50f5 --- /dev/null +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -0,0 +1,154 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; + +type BrowserCurlParams = Parameters[1]; + +type BrowserUtilityAction = "curl" | "read_clipboard" | "write_clipboard"; + +const curlActions: readonly BrowserUtilityAction[] = ["curl"]; +const writeClipboardActions: readonly BrowserUtilityAction[] = [ + "write_clipboard", +]; + +const utilityFieldScopes = { + session_id: ["curl", "read_clipboard", "write_clipboard"], + url: curlActions, + method: curlActions, + headers: curlActions, + body: curlActions, + response_encoding: curlActions, + timeout_ms: curlActions, + text: writeClipboardActions, +} satisfies Record; + +type BrowserUtilityField = keyof typeof utilityFieldScopes; + +const utilityFields = Object.keys(utilityFieldScopes) as BrowserUtilityField[]; + +function textResponse(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function actionFieldError( + params: Partial>, + action: BrowserUtilityAction, +) { + const unsupportedField = utilityFields.find( + (field) => + params[field] !== undefined && + !utilityFieldScopes[field].includes(action), + ); + + return unsupportedField + ? `Error: ${unsupportedField} is only supported for ${utilityFieldScopes[ + unsupportedField + ].join(", ")}.` + : undefined; +} + +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_utilities", + 'Run browser-scoped utilities against an existing Kernel browser session. Use action "curl" to send an HTTP request through Chrome\'s network stack, "read_clipboard" to read browser clipboard text, or "write_clipboard" to write browser clipboard text.', + { + action: z + .enum(["curl", "read_clipboard", "write_clipboard"]) + .describe("Utility operation to perform."), + session_id: z.string().describe("Browser session ID."), + url: z + .string() + .url() + .describe("(curl) Target http or https URL.") + .optional(), + method: z + .enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) + .describe("(curl) HTTP method. Defaults to GET.") + .optional(), + headers: z + .record(z.string(), z.string()) + .describe("(curl) Custom headers merged with browser defaults.") + .optional(), + body: z + .string() + .describe("(curl) Request body for POST, PUT, or PATCH requests.") + .optional(), + response_encoding: z + .enum(["utf8", "base64"]) + .describe( + "(curl) Response body encoding. Use base64 for binary content.", + ) + .optional(), + timeout_ms: z + .number() + .describe("(curl) Request timeout in milliseconds.") + .optional(), + text: z + .string() + .describe("(write_clipboard) Text to write to the browser clipboard.") + .optional(), + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + const scopeError = actionFieldError(params, params.action); + if (scopeError) return textResponse(scopeError); + + switch (params.action) { + case "curl": { + if (!params.url) return textResponse("Error: url is required."); + validateCurlUrl(params.url); + + const curlParams: BrowserCurlParams = { + url: params.url, + ...(params.method !== undefined && { method: params.method }), + ...(params.headers !== undefined && { headers: params.headers }), + ...(params.body !== undefined && { body: params.body }), + ...(params.response_encoding !== undefined && { + response_encoding: params.response_encoding, + }), + ...(params.timeout_ms !== undefined && { + timeout_ms: params.timeout_ms, + }), + }; + const response = await client.browsers.curl( + params.session_id, + curlParams, + ); + return textResponse(JSON.stringify(response, null, 2)); + } + case "read_clipboard": { + const response = await client.browsers.computer.readClipboard( + params.session_id, + ); + return textResponse(JSON.stringify(response, null, 2)); + } + case "write_clipboard": { + if (params.text === undefined) { + return textResponse("Error: text is required."); + } + await client.browsers.computer.writeClipboard(params.session_id, { + text: params.text, + }); + return textResponse("Clipboard updated successfully"); + } + } + } catch (error) { + return textResponse( + `Error in browser_utilities (${params.action}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, + ); +} From 2c91a8a791f55e40a9694826bdf87c650c678279 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:07:10 -0400 Subject: [PATCH 06/20] Split browser utility MCP tools --- src/lib/mcp/tools/browser-utilities.ts | 178 +++++++++++-------------- 1 file changed, 77 insertions(+), 101 deletions(-) diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-utilities.ts index 03f50f5..a0f0b44 100644 --- a/src/lib/mcp/tools/browser-utilities.ts +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -4,49 +4,10 @@ import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; type BrowserCurlParams = Parameters[1]; -type BrowserUtilityAction = "curl" | "read_clipboard" | "write_clipboard"; - -const curlActions: readonly BrowserUtilityAction[] = ["curl"]; -const writeClipboardActions: readonly BrowserUtilityAction[] = [ - "write_clipboard", -]; - -const utilityFieldScopes = { - session_id: ["curl", "read_clipboard", "write_clipboard"], - url: curlActions, - method: curlActions, - headers: curlActions, - body: curlActions, - response_encoding: curlActions, - timeout_ms: curlActions, - text: writeClipboardActions, -} satisfies Record; - -type BrowserUtilityField = keyof typeof utilityFieldScopes; - -const utilityFields = Object.keys(utilityFieldScopes) as BrowserUtilityField[]; - function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } -function actionFieldError( - params: Partial>, - action: BrowserUtilityAction, -) { - const unsupportedField = utilityFields.find( - (field) => - params[field] !== undefined && - !utilityFieldScopes[field].includes(action), - ); - - return unsupportedField - ? `Error: ${unsupportedField} is only supported for ${utilityFieldScopes[ - unsupportedField - ].join(", ")}.` - : undefined; -} - function validateCurlUrl(url: string) { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -56,43 +17,30 @@ function validateCurlUrl(url: string) { export function registerBrowserUtilityTools(server: McpServer) { server.tool( - "browser_utilities", - 'Run browser-scoped utilities against an existing Kernel browser session. Use action "curl" to send an HTTP request through Chrome\'s network stack, "read_clipboard" to read browser clipboard text, or "write_clipboard" to write browser clipboard text.', + "browser_curl", + "Send an HTTP request through an existing Kernel browser session's Chrome network stack.", { - action: z - .enum(["curl", "read_clipboard", "write_clipboard"]) - .describe("Utility operation to perform."), session_id: z.string().describe("Browser session ID."), - url: z - .string() - .url() - .describe("(curl) Target http or https URL.") - .optional(), + url: z.string().url().describe("Target http or https URL."), method: z .enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) - .describe("(curl) HTTP method. Defaults to GET.") + .describe("HTTP method. Defaults to GET.") .optional(), headers: z .record(z.string(), z.string()) - .describe("(curl) Custom headers merged with browser defaults.") + .describe("Custom headers merged with browser defaults.") .optional(), body: z .string() - .describe("(curl) Request body for POST, PUT, or PATCH requests.") + .describe("Request body for POST, PUT, or PATCH requests.") .optional(), response_encoding: z .enum(["utf8", "base64"]) - .describe( - "(curl) Response body encoding. Use base64 for binary content.", - ) + .describe("Response body encoding. Use base64 for binary content.") .optional(), timeout_ms: z .number() - .describe("(curl) Request timeout in milliseconds.") - .optional(), - text: z - .string() - .describe("(write_clipboard) Text to write to the browser clipboard.") + .describe("Request timeout in milliseconds.") .optional(), }, async (params, extra) => { @@ -100,51 +48,79 @@ export function registerBrowserUtilityTools(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - const scopeError = actionFieldError(params, params.action); - if (scopeError) return textResponse(scopeError); + validateCurlUrl(params.url); + + const curlParams: BrowserCurlParams = { + url: params.url, + ...(params.method !== undefined && { method: params.method }), + ...(params.headers !== undefined && { headers: params.headers }), + ...(params.body !== undefined && { body: params.body }), + ...(params.response_encoding !== undefined && { + response_encoding: params.response_encoding, + }), + ...(params.timeout_ms !== undefined && { + timeout_ms: params.timeout_ms, + }), + }; + const response = await client.browsers.curl( + params.session_id, + curlParams, + ); + return textResponse(JSON.stringify(response, null, 2)); + } catch (error) { + return textResponse( + `Error in browser_curl: ${ + error instanceof Error ? error.message : String(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 textResponse(JSON.stringify(response, null, 2)); + } catch (error) { + return textResponse( + `Error in read_browser_clipboard: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, + ); - switch (params.action) { - case "curl": { - if (!params.url) return textResponse("Error: url is required."); - validateCurlUrl(params.url); + 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); - const curlParams: BrowserCurlParams = { - url: params.url, - ...(params.method !== undefined && { method: params.method }), - ...(params.headers !== undefined && { headers: params.headers }), - ...(params.body !== undefined && { body: params.body }), - ...(params.response_encoding !== undefined && { - response_encoding: params.response_encoding, - }), - ...(params.timeout_ms !== undefined && { - timeout_ms: params.timeout_ms, - }), - }; - const response = await client.browsers.curl( - params.session_id, - curlParams, - ); - return textResponse(JSON.stringify(response, null, 2)); - } - case "read_clipboard": { - const response = await client.browsers.computer.readClipboard( - params.session_id, - ); - return textResponse(JSON.stringify(response, null, 2)); - } - case "write_clipboard": { - if (params.text === undefined) { - return textResponse("Error: text is required."); - } - await client.browsers.computer.writeClipboard(params.session_id, { - text: params.text, - }); - return textResponse("Clipboard updated successfully"); - } - } + try { + await client.browsers.computer.writeClipboard(params.session_id, { + text: params.text, + }); + return textResponse("Clipboard updated successfully"); } catch (error) { return textResponse( - `Error in browser_utilities (${params.action}): ${ + `Error in write_browser_clipboard: ${ error instanceof Error ? error.message : String(error) }`, ); From a2e930bc953970f90bd10a493836b17e3d5bdce3 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:06:28 -0400 Subject: [PATCH 07/20] Simplify browser curl params --- src/lib/mcp/tools/browser-utilities.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-utilities.ts index a0f0b44..62a9e4e 100644 --- a/src/lib/mcp/tools/browser-utilities.ts +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -48,24 +48,12 @@ export function registerBrowserUtilityTools(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - validateCurlUrl(params.url); + const { session_id, ...curlParams } = params satisfies { + session_id: string; + } & BrowserCurlParams; + validateCurlUrl(curlParams.url); - const curlParams: BrowserCurlParams = { - url: params.url, - ...(params.method !== undefined && { method: params.method }), - ...(params.headers !== undefined && { headers: params.headers }), - ...(params.body !== undefined && { body: params.body }), - ...(params.response_encoding !== undefined && { - response_encoding: params.response_encoding, - }), - ...(params.timeout_ms !== undefined && { - timeout_ms: params.timeout_ms, - }), - }; - const response = await client.browsers.curl( - params.session_id, - curlParams, - ); + const response = await client.browsers.curl(session_id, curlParams); return textResponse(JSON.stringify(response, null, 2)); } catch (error) { return textResponse( From 4e867c826bd941a2d19cb1d8f6c3b68d5d4d4e8c Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:19:30 -0400 Subject: [PATCH 08/20] Add browser pool parity fields --- src/lib/mcp/tools/browser-pools.ts | 497 +++++++++++++++++++++-------- 1 file changed, 365 insertions(+), 132 deletions(-) diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 8d07b5b..5a3898f 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -1,6 +1,228 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; + +type BrowserPoolCreateParams = Parameters< + KernelClient["browserPools"]["create"] +>[0]; +type BrowserPoolUpdateParams = Parameters< + KernelClient["browserPools"]["update"] +>[1]; + +type BrowserPoolAction = + | "create" + | "update" + | "list" + | "get" + | "delete" + | "flush" + | "acquire" + | "release"; + +type ProfileParams = { + profile_name?: string; + profile_id?: string; + save_profile_changes?: boolean; +}; + +type ExtensionParams = { + extension_id?: string; + extension_name?: string; +}; + +type ViewportParams = { + viewport_width?: number; + viewport_height?: number; + viewport_refresh_rate?: number; +}; + +type PoolConfigParams = ProfileParams & + ExtensionParams & + ViewportParams & { + 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 createActions: readonly BrowserPoolAction[] = ["create"]; +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 textResponse(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +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 buildProfile( + params: ProfileParams, +): BrowserPoolCreateParams["profile"] { + 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, + }), + }; +} + +function buildExtensions( + params: ExtensionParams, +): BrowserPoolCreateParams["extensions"] { + 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 }), + }, + ]; +} + +function buildViewport( + params: ViewportParams, +): BrowserPoolCreateParams["viewport"] { + const width = params.viewport_width; + const height = params.viewport_height; + const hasWidth = width !== undefined; + const hasHeight = height !== undefined; + const hasViewportOptions = + hasWidth || hasHeight || params.viewport_refresh_rate !== undefined; + + if (!hasViewportOptions) return undefined; + if (!hasWidth || !hasHeight) { + 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, + }), + }; +} + +function buildPoolConfigParams( + params: PoolConfigParams, +): BrowserPoolCreateParams { + if (params.size === undefined) { + throw new Error("size is required for create and update."); + } + + const profile = buildProfile(params); + const extensions = buildExtensions(params); + const viewport = buildViewport(params); + + return { + 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, + }), + ...(params.start_url !== undefined && { start_url: params.start_url }), + ...(params.chrome_policy !== undefined && { + chrome_policy: params.chrome_policy, + }), + ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), + ...(extensions && { extensions }), + ...(viewport && { viewport }), + }; +} export function registerBrowserPoolCapabilities(server: McpServer) { server.resource("browser_pools", "browser_pools://", async (uri, extra) => { @@ -47,14 +269,15 @@ export function registerBrowserPoolCapabilities(server: McpServer) { throw new Error(`Invalid browser pool URI: ${uriString}`); }); - // 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 +289,106 @@ 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() + .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,41 +414,42 @@ 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" }, - ], - }; + const scopeError = actionFieldError(params, "create"); + if (scopeError) return textResponse(scopeError); + + const pool = await client.browserPools.create( + buildPoolConfigParams(params), + ); + if (!pool) return textResponse("Failed to create browser pool"); + return { + content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], + }; + } + 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: BrowserPoolUpdateParams = + buildPoolConfigParams(params); + if (params.discard_all_idle !== undefined) { + updateParams.discard_all_idle = params.discard_all_idle; + } + + const pool = await client.browserPools.update( + params.id_or_name, + updateParams, + ); return { content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], }; } case "list": { + const scopeError = actionFieldError(params, "list"); + if (scopeError) return textResponse(scopeError); + const pools = await client.browserPools.list(); return { content: [ @@ -171,78 +464,44 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }; } 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 textResponse( + `Browser pool "${params.id_or_name}" not found`, + ); return { content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], }; } 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,11 +511,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }, ); if (!browser) - return { - content: [ - { type: "text", text: "Failed to acquire browser from pool" }, - ], - }; + return textResponse("Failed to acquire browser from pool"); return { content: [ { type: "text", text: JSON.stringify(browser, null, 2) }, @@ -264,47 +519,25 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }; } 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}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); } }, ); From 8a9eaad6512888898924b75053e7f1f6548bece4 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:42:51 -0400 Subject: [PATCH 09/20] Share MCP browser config and response helpers --- src/lib/mcp/browser-config.ts | 102 ++++++++++ src/lib/mcp/tools/browser-pools.ts | 148 +++------------ src/lib/mcp/tools/browser-utilities.ts | 23 +-- src/lib/mcp/tools/browsers.ts | 248 +++++-------------------- 4 files changed, 178 insertions(+), 343 deletions(-) create mode 100644 src/lib/mcp/browser-config.ts diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts new file mode 100644 index 0000000..104d800 --- /dev/null +++ b/src/lib/mcp/browser-config.ts @@ -0,0 +1,102 @@ +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 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/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 5a3898f..7ca4b77 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -1,6 +1,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { + buildBrowserExtensions, + buildBrowserProfile, + buildBrowserViewport, + type BrowserExtensionParams, + type BrowserProfileParams, + type BrowserViewportParams, +} from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; type BrowserPoolCreateParams = Parameters< KernelClient["browserPools"]["create"] @@ -19,26 +28,9 @@ type BrowserPoolAction = | "acquire" | "release"; -type ProfileParams = { - profile_name?: string; - profile_id?: string; - save_profile_changes?: boolean; -}; - -type ExtensionParams = { - extension_id?: string; - extension_name?: string; -}; - -type ViewportParams = { - viewport_width?: number; - viewport_height?: number; - viewport_refresh_rate?: number; -}; - -type PoolConfigParams = ProfileParams & - ExtensionParams & - ViewportParams & { +type PoolConfigParams = BrowserProfileParams & + BrowserExtensionParams & + BrowserViewportParams & { size?: number; name?: string; headless?: boolean; @@ -99,10 +91,6 @@ const scopedBrowserPoolFields = Object.keys( browserPoolFieldScopes, ) as BrowserPoolToolField[]; -function textResponse(text: string) { - return { content: [{ type: "text" as const, text }] }; -} - function formatActionScope(field: BrowserPoolToolField) { return browserPoolFieldScopes[field].join(", "); } @@ -124,72 +112,6 @@ function actionFieldError( : undefined; } -function buildProfile( - params: ProfileParams, -): BrowserPoolCreateParams["profile"] { - 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, - }), - }; -} - -function buildExtensions( - params: ExtensionParams, -): BrowserPoolCreateParams["extensions"] { - 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 }), - }, - ]; -} - -function buildViewport( - params: ViewportParams, -): BrowserPoolCreateParams["viewport"] { - const width = params.viewport_width; - const height = params.viewport_height; - const hasWidth = width !== undefined; - const hasHeight = height !== undefined; - const hasViewportOptions = - hasWidth || hasHeight || params.viewport_refresh_rate !== undefined; - - if (!hasViewportOptions) return undefined; - if (!hasWidth || !hasHeight) { - 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, - }), - }; -} - function buildPoolConfigParams( params: PoolConfigParams, ): BrowserPoolCreateParams { @@ -197,9 +119,9 @@ function buildPoolConfigParams( throw new Error("size is required for create and update."); } - const profile = buildProfile(params); - const extensions = buildExtensions(params); - const viewport = buildViewport(params); + const profile = buildBrowserProfile(params); + const extensions = buildBrowserExtensions(params); + const viewport = buildBrowserViewport(params); return { size: params.size, @@ -421,9 +343,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { buildPoolConfigParams(params), ); if (!pool) return textResponse("Failed to create browser pool"); - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + return jsonResponse(pool); } case "update": { const scopeError = actionFieldError(params, "update"); @@ -442,26 +362,18 @@ export function registerBrowserPoolCapabilities(server: McpServer) { params.id_or_name, updateParams, ); - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + 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"); @@ -473,9 +385,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { return textResponse( `Browser pool "${params.id_or_name}" not found`, ); - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + return jsonResponse(pool); } case "delete": { const scopeError = actionFieldError(params, "delete"); @@ -512,11 +422,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { ); if (!browser) return textResponse("Failed to acquire browser from pool"); - return { - content: [ - { type: "text", text: JSON.stringify(browser, null, 2) }, - ], - }; + return jsonResponse(browser); } case "release": { const scopeError = actionFieldError(params, "release"); @@ -534,9 +440,9 @@ export function registerBrowserPoolCapabilities(server: McpServer) { } } catch (error) { return textResponse( - `Error in manage_browser_pools (${params.action}): ${ - error instanceof Error ? error.message : String(error) - }`, + `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 index 62a9e4e..193b77e 100644 --- a/src/lib/mcp/tools/browser-utilities.ts +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -1,13 +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 { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; type BrowserCurlParams = Parameters[1]; -function textResponse(text: string) { - return { content: [{ type: "text" as const, text }] }; -} - function validateCurlUrl(url: string) { const parsed = new URL(url); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -54,13 +51,9 @@ export function registerBrowserUtilityTools(server: McpServer) { validateCurlUrl(curlParams.url); const response = await client.browsers.curl(session_id, curlParams); - return textResponse(JSON.stringify(response, null, 2)); + return jsonResponse(response); } catch (error) { - return textResponse( - `Error in browser_curl: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + return textResponse(`Error in browser_curl: ${errorMessage(error)}`); } }, ); @@ -79,12 +72,10 @@ export function registerBrowserUtilityTools(server: McpServer) { const response = await client.browsers.computer.readClipboard( params.session_id, ); - return textResponse(JSON.stringify(response, null, 2)); + return jsonResponse(response); } catch (error) { return textResponse( - `Error in read_browser_clipboard: ${ - error instanceof Error ? error.message : String(error) - }`, + `Error in read_browser_clipboard: ${errorMessage(error)}`, ); } }, @@ -108,9 +99,7 @@ export function registerBrowserUtilityTools(server: McpServer) { return textResponse("Clipboard updated successfully"); } catch (error) { return textResponse( - `Error in write_browser_clipboard: ${ - error instanceof Error ? error.message : String(error) - }`, + `Error in write_browser_clipboard: ${errorMessage(error)}`, ); } }, diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 3ea284d..18cf03d 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,25 +1,19 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { + buildBrowserExtensions, + buildBrowserProfile, + buildBrowserViewport, + buildBrowserViewportUpdate, +} from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; type BrowserCreateParams = NonNullable< Parameters[0] >; type BrowserUpdateParams = Parameters[1]; -type ProfileParams = { - profile_name?: string; - profile_id?: string; - save_profile_changes?: boolean; -}; - -type ViewportParams = { - viewport_width?: number; - viewport_height?: number; - viewport_refresh_rate?: number; - viewport_force?: boolean; -}; - type TelemetryParams = { telemetry_enabled?: boolean; telemetry_console?: boolean; @@ -82,10 +76,6 @@ const telemetryCategories = [ ["telemetry_interaction", "interaction"], ] as const; -function textResponse(text: string) { - return { content: [{ type: "text" as const, text }] }; -} - function formatActionScope(field: BrowserToolField) { return browserFieldScopes[field].join(", "); } @@ -107,80 +97,6 @@ function actionFieldError( : undefined; } -function buildProfile(params: ProfileParams): BrowserCreateParams["profile"] { - 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, - }), - }; -} - -function buildViewportBase( - params: ViewportParams, -): NonNullable | undefined { - const width = params.viewport_width; - const height = params.viewport_height; - const hasWidth = width !== undefined; - const hasHeight = height !== undefined; - const hasViewportOptions = - hasWidth || hasHeight || params.viewport_refresh_rate !== undefined; - - if (!hasViewportOptions) return undefined; - if (!hasWidth || !hasHeight) { - 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, - }), - }; -} - -function buildCreateViewport( - params: ViewportParams, -): BrowserCreateParams["viewport"] { - return buildViewportBase(params); -} - -function buildUpdateViewport( - params: ViewportParams, -): BrowserUpdateParams["viewport"] { - const viewport = buildViewportBase(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, - }), - }; -} - function buildTelemetry( params: TelemetryParams, ): BrowserCreateParams["telemetry"] | BrowserUpdateParams["telemetry"] { @@ -436,26 +352,6 @@ export function registerBrowserCapabilities(server: McpServer) { case "create": { const scopeError = actionFieldError(params, "create"); if (scopeError) return textResponse(scopeError); - 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.", - }, - ], - }; - } const createParams: BrowserCreateParams = {}; if (params.headless !== undefined) @@ -471,28 +367,18 @@ export function registerBrowserCapabilities(server: McpServer) { if (params.chrome_policy) createParams.chrome_policy = params.chrome_policy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; - const profile = buildProfile(params); + const profile = buildBrowserProfile(params); if (profile) createParams.profile = profile; - const viewport = buildCreateViewport(params); + const viewport = buildBrowserViewport(params); if (viewport) createParams.viewport = viewport; const telemetry = buildTelemetry(params); if (telemetry !== undefined) createParams.telemetry = telemetry; - if (params.extension_id || params.extension_name) { - createParams.extensions = [ - { - ...(params.extension_id && { id: params.extension_id }), - ...(params.extension_name && { name: params.extension_name }), - }, - ]; - } + const extensions = buildBrowserExtensions(params); + if (extensions) createParams.extensions = extensions; 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) { @@ -522,7 +408,7 @@ 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"); @@ -531,16 +417,6 @@ export function registerBrowserCapabilities(server: McpServer) { return textResponse( "Error: session_id is required for update action.", ); - if (params.profile_name && params.profile_id) { - return textResponse( - "Error: Cannot specify both profile_name and profile_id.", - ); - } - if (params.extension_id || params.extension_name) { - return textResponse( - "Error: extensions can only be loaded during create.", - ); - } if (params.proxy_id && params.clear_proxy) { return textResponse( "Error: Cannot specify both proxy_id and clear_proxy.", @@ -556,9 +432,9 @@ export function registerBrowserCapabilities(server: McpServer) { } else if (params.proxy_id !== undefined) { updateParams.proxy_id = params.proxy_id; } - const profile = buildProfile(params); + const profile = buildBrowserProfile(params); if (profile) updateParams.profile = profile; - const viewport = buildUpdateViewport(params); + const viewport = buildBrowserViewportUpdate(params); if (viewport) updateParams.viewport = viewport; const telemetry = buildTelemetry(params); if (telemetry !== undefined) updateParams.telemetry = telemetry; @@ -575,11 +451,7 @@ export function registerBrowserCapabilities(server: McpServer) { ); if (!browser) return textResponse("Failed to update browser session"); - return { - content: [ - { type: "text", text: JSON.stringify(browser, null, 2) }, - ], - }; + return jsonResponse(browser); } case "list": { const scopeError = actionFieldError(params, "list"); @@ -592,83 +464,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)}`, + ); } }, ); From 91c1f002f798992f3c4752a3f874aa643fd8ff16 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:00:39 -0400 Subject: [PATCH 10/20] Tighten browser pool start URL schema --- src/lib/mcp/tools/browser-pools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 7ca4b77..afb89bc 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -43,7 +43,6 @@ type PoolConfigParams = BrowserProfileParams & kiosk_mode?: boolean; }; -const createActions: readonly BrowserPoolAction[] = ["create"]; const updateActions: readonly BrowserPoolAction[] = ["update"]; const createUpdateActions: readonly BrowserPoolAction[] = ["create", "update"]; const idOrNameActions: readonly BrowserPoolAction[] = [ @@ -268,6 +267,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), start_url: z .string() + .url() .describe( "(create, update) URL to open when a browser is warmed into the pool. Navigation is best-effort.", ) From ea927af36c5d60e062d1700d24613ce6c1fdaca6 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:49:46 -0400 Subject: [PATCH 11/20] Clean up MCP resource handlers Move item resource reads into shared resource templates, keep collection resources list-only, and document the browser-pools URI shape so agent-facing resources are predictable. --- README.md | 12 ++++-- src/lib/mcp/browser-config.ts | 12 ++++++ src/lib/mcp/resource-templates.ts | 61 ++++++++++++++++++++++++++++ src/lib/mcp/tools/apps.ts | 60 ++++++++++------------------ src/lib/mcp/tools/browser-pools.ts | 62 ++++++++++++----------------- src/lib/mcp/tools/browsers.ts | 64 ++++++++++++------------------ src/lib/mcp/tools/profiles.ts | 58 ++++++++++----------------- 7 files changed, 174 insertions(+), 155 deletions(-) create mode 100644 src/lib/mcp/resource-templates.ts diff --git a/README.md b/README.md index 7b51ada..7de520f 100644 --- a/README.md +++ b/README.md @@ -281,10 +281,14 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ ## 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 index 104d800..c859d13 100644 --- a/src/lib/mcp/browser-config.ts +++ b/src/lib/mcp/browser-config.ts @@ -19,6 +19,18 @@ 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."); 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/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 25e317d..292c0b6 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.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 { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -9,46 +10,29 @@ 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 diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index afb89bc..3dd4b65 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -3,12 +3,14 @@ import { z } from "zod"; 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< @@ -121,6 +123,7 @@ function buildPoolConfigParams( const profile = buildBrowserProfile(params); const extensions = buildBrowserExtensions(params); const viewport = buildBrowserViewport(params); + const startUrl = buildBrowserStartUrl(params.start_url); return { size: params.size, @@ -135,7 +138,7 @@ function buildPoolConfigParams( ...(params.fill_rate_per_minute !== undefined && { fill_rate_per_minute: params.fill_rate_per_minute, }), - ...(params.start_url !== undefined && { start_url: params.start_url }), + ...(startUrl !== undefined && { start_url: startUrl }), ...(params.chrome_policy !== undefined && { chrome_policy: params.chrome_policy, }), @@ -146,48 +149,33 @@ function buildPoolConfigParams( } 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, update, list, get, delete, flush, acquire, and release browser pools diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 18cf03d..f452d86 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -3,10 +3,12 @@ import { z } from "zod"; 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< @@ -138,45 +140,28 @@ 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, update, list, get, and delete browser sessions @@ -363,7 +348,8 @@ export function registerBrowserCapabilities(server: McpServer) { createParams.timeout_seconds = params.timeout_seconds; if (params.kiosk_mode !== undefined) createParams.kiosk_mode = params.kiosk_mode; - if (params.start_url) createParams.start_url = params.start_url; + 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; diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index d341c78..36fef40 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -1,6 +1,7 @@ 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"; async function listProfiles(client: KernelClient) { const profiles: Awaited>[] = []; @@ -17,44 +18,27 @@ 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( From 680ef3bb3b4c4ee76312b39d3e913328d2b5c10b Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:10:34 -0400 Subject: [PATCH 12/20] Allow partial browser pool updates Keep browser pool create validation strict while allowing update-only fields such as discard_all_idle to be sent without redundantly supplying size. --- src/lib/mcp/tools/browser-pools.ts | 46 ++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 3dd4b65..a74771c 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -19,6 +19,9 @@ type BrowserPoolCreateParams = Parameters< type BrowserPoolUpdateParams = Parameters< KernelClient["browserPools"]["update"] >[1]; +type BrowserPoolUpdateBody = Omit & { + size?: BrowserPoolUpdateParams["size"]; +}; type BrowserPoolAction = | "create" @@ -115,18 +118,14 @@ function actionFieldError( function buildPoolConfigParams( params: PoolConfigParams, -): BrowserPoolCreateParams { - if (params.size === undefined) { - throw new Error("size is required for create and update."); - } - +): BrowserPoolUpdateBody { const profile = buildBrowserProfile(params); const extensions = buildBrowserExtensions(params); const viewport = buildBrowserViewport(params); const startUrl = buildBrowserStartUrl(params.start_url); return { - size: params.size, + ...(params.size !== undefined && { size: params.size }), ...(params.name && { name: params.name }), ...(params.headless !== undefined && { headless: params.headless }), ...(params.stealth !== undefined && { stealth: params.stealth }), @@ -148,6 +147,27 @@ function buildPoolConfigParams( }; } +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) => { if (!extra.authInfo) { @@ -328,7 +348,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { if (scopeError) return textResponse(scopeError); const pool = await client.browserPools.create( - buildPoolConfigParams(params), + buildPoolCreateParams(params), ); if (!pool) return textResponse("Failed to create browser pool"); return jsonResponse(pool); @@ -340,15 +360,17 @@ export function registerBrowserPoolCapabilities(server: McpServer) { return textResponse("Error: id_or_name is required for update."); } - const updateParams: BrowserPoolUpdateParams = - buildPoolConfigParams(params); - if (params.discard_all_idle !== undefined) { - updateParams.discard_all_idle = params.discard_all_idle; + 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, + updateParams as BrowserPoolUpdateParams, ); return jsonResponse(pool); } From 5d4a8e66125711b5ed23ae8bf08f7d4438739e69 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:52:39 -0400 Subject: [PATCH 13/20] Align browser tools with MCP responses --- src/lib/mcp/tools/browser-pools.ts | 12 ++++++------ src/lib/mcp/tools/browser-utilities.ts | 16 ++++++++-------- src/lib/mcp/tools/browsers.ts | 10 ++++++---- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index a74771c..6dc25ec 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -11,7 +11,11 @@ import { } 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"; +import { + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; type BrowserPoolCreateParams = Parameters< KernelClient["browserPools"]["create"] @@ -449,11 +453,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { } } } catch (error) { - return textResponse( - `Error in manage_browser_pools (${params.action}): ${errorMessage( - error, - )}`, - ); + return toolErrorResponse("manage_browser_pools", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-utilities.ts index 193b77e..1d54b42 100644 --- a/src/lib/mcp/tools/browser-utilities.ts +++ b/src/lib/mcp/tools/browser-utilities.ts @@ -1,7 +1,11 @@ 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"; +import { + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; type BrowserCurlParams = Parameters[1]; @@ -53,7 +57,7 @@ export function registerBrowserUtilityTools(server: McpServer) { const response = await client.browsers.curl(session_id, curlParams); return jsonResponse(response); } catch (error) { - return textResponse(`Error in browser_curl: ${errorMessage(error)}`); + return toolErrorResponse("browser_curl", "request", error); } }, ); @@ -74,9 +78,7 @@ export function registerBrowserUtilityTools(server: McpServer) { ); return jsonResponse(response); } catch (error) { - return textResponse( - `Error in read_browser_clipboard: ${errorMessage(error)}`, - ); + return toolErrorResponse("read_browser_clipboard", "read", error); } }, ); @@ -98,9 +100,7 @@ export function registerBrowserUtilityTools(server: McpServer) { }); return textResponse("Clipboard updated successfully"); } catch (error) { - return textResponse( - `Error in write_browser_clipboard: ${errorMessage(error)}`, - ); + return toolErrorResponse("write_browser_clipboard", "write", error); } }, ); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index f452d86..12f9017 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -9,7 +9,11 @@ import { } 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"; +import { + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; type BrowserCreateParams = NonNullable< Parameters[0] @@ -490,9 +494,7 @@ export function registerBrowserCapabilities(server: McpServer) { } } } catch (error) { - return textResponse( - `Error in manage_browsers (${params.action}): ${errorMessage(error)}`, - ); + return toolErrorResponse("manage_browsers", params.action, error); } }, ); From 43487a030249c87080f45d6823c21eff0735b8da Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:02:41 -0400 Subject: [PATCH 14/20] Use SDK 0.60 browser pool update types --- bun.lock | 4 ++-- package.json | 2 +- src/lib/mcp/tools/browser-pools.ts | 10 +++------- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 32f8101..1c143f3 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.58.0", + "@onkernel/sdk": "^0.60.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", @@ -145,7 +145,7 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q=="], - "@onkernel/sdk": ["@onkernel/sdk@0.58.0", "", {}, "sha512-eJNydQ8WzZWq837EX+V2QMUhK4O8feVnj9EXZukOvOHXH9PfIr40IQXUxIZ1ic/s08witQ6hQmtlCqY7VeF2aQ=="], + "@onkernel/sdk": ["@onkernel/sdk@0.60.0", "", {}, "sha512-z9POhDK6uanj9FJ5GS/ff7MaqYjCOWP5605pbuUYEH9EuX5bthD89y/XPqwcC9xpgr9RLjlFVJ73ORAiAXfuPw=="], "@redis/bloom": ["@redis/bloom@5.6.0", "", { "peerDependencies": { "@redis/client": "^5.6.0" } }, "sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg=="], diff --git a/package.json b/package.json index dca4937..03aafba 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.58.0", + "@onkernel/sdk": "^0.60.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 6dc25ec..5ec804c 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -23,9 +23,6 @@ type BrowserPoolCreateParams = Parameters< type BrowserPoolUpdateParams = Parameters< KernelClient["browserPools"]["update"] >[1]; -type BrowserPoolUpdateBody = Omit & { - size?: BrowserPoolUpdateParams["size"]; -}; type BrowserPoolAction = | "create" @@ -122,7 +119,7 @@ function actionFieldError( function buildPoolConfigParams( params: PoolConfigParams, -): BrowserPoolUpdateBody { +): BrowserPoolUpdateParams { const profile = buildBrowserProfile(params); const extensions = buildBrowserExtensions(params); const viewport = buildBrowserViewport(params); @@ -163,7 +160,7 @@ function buildPoolCreateParams( function buildPoolUpdateParams( params: PoolConfigParams & { discard_all_idle?: boolean }, -) { +): BrowserPoolUpdateParams { return { ...buildPoolConfigParams(params), ...(params.discard_all_idle !== undefined && { @@ -371,10 +368,9 @@ export function registerBrowserPoolCapabilities(server: McpServer) { ); } - // 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, + updateParams, ); return jsonResponse(pool); } From dcf37326f04e7488282a2f0fd2e0ef8cf2e3cffa Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:17:17 -0400 Subject: [PATCH 15/20] Address browser pool Cursor findings --- README.md | 7 +- src/lib/mcp/browser-config.ts | 103 ++++++--- src/lib/mcp/register.ts | 5 +- .../{browser-utilities.ts => browser-curl.ts} | 64 ++---- src/lib/mcp/tools/browser-pools.ts | 199 ++++++------------ src/lib/mcp/tools/browsers.ts | 151 +++++-------- src/lib/mcp/tools/computer-action.ts | 159 ++++++++++---- 7 files changed, 330 insertions(+), 358 deletions(-) rename src/lib/mcp/tools/{browser-utilities.ts => browser-curl.ts} (54%) diff --git a/README.md b/README.md index 7de520f..54a3f18 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,9 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (12 total) +## Tools (13 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. Five standalone tools handle high-frequency workflows. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. @@ -274,7 +274,8 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ ### Standalone tools -- `computer_action` - Mouse, keyboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, screenshot). +- `computer_action` - Mouse, keyboard, clipboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, read_clipboard, write_clipboard, screenshot). +- `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack. - `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. - `search_docs` - Search Kernel platform documentation and guides. diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts index c859d13..b6735ad 100644 --- a/src/lib/mcp/browser-config.ts +++ b/src/lib/mcp/browser-config.ts @@ -19,55 +19,103 @@ export type BrowserViewportUpdateParams = BrowserViewportParams & { viewport_force?: boolean; }; -export function buildBrowserStartUrl(startUrl: string | undefined) { - if (startUrl === undefined) return undefined; +type BrowserProfileConfig = + | { + id?: string; + name?: string; + save_changes?: boolean; + } + | undefined; + +type BrowserExtensionConfig = + | Array<{ + id?: string; + name?: string; + }> + | undefined; + +type BrowserViewportConfig = + | { + width: number; + height: number; + refresh_rate?: number; + } + | undefined; + +type BrowserViewportUpdateConfig = + | (NonNullable & { force?: boolean }) + | undefined; + +export type BrowserConfigResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +function configValue(value: T): BrowserConfigResult { + return { ok: true, value }; +} + +function configError(message: string): BrowserConfigResult { + return { ok: false, error: `Error: ${message}` }; +} + +export function buildBrowserStartUrl( + startUrl: string | undefined, +): BrowserConfigResult { + if (startUrl === undefined) return configValue(undefined); try { new URL(startUrl); } catch { - throw new Error("start_url must be a valid URL."); + return configError("start_url must be a valid URL."); } - return startUrl; + return configValue(startUrl); } -export function buildBrowserProfile(params: BrowserProfileParams) { +export function buildBrowserProfile( + params: BrowserProfileParams, +): BrowserConfigResult { if (params.profile_name && params.profile_id) { - throw new Error("Cannot specify both profile_name and profile_id."); + return configError("Cannot specify both profile_name and profile_id."); } if ( params.save_profile_changes !== undefined && !params.profile_name && !params.profile_id ) { - throw new Error( + return configError( "profile_name or profile_id is required when save_profile_changes is set.", ); } - if (!params.profile_name && !params.profile_id) return undefined; - return { + if (!params.profile_name && !params.profile_id) return configValue(undefined); + return configValue({ ...(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) { +export function buildBrowserExtensions( + params: BrowserExtensionParams, +): BrowserConfigResult { if (params.extension_id && params.extension_name) { - throw new Error("Cannot specify both extension_id and extension_name."); + return configError("Cannot specify both extension_id and extension_name."); } - if (!params.extension_id && !params.extension_name) return undefined; - return [ + if (!params.extension_id && !params.extension_name) + return configValue(undefined); + return configValue([ { ...(params.extension_id && { id: params.extension_id }), ...(params.extension_name && { name: params.extension_name }), }, - ]; + ]); } -export function buildBrowserViewport(params: BrowserViewportParams) { +export function buildBrowserViewport( + params: BrowserViewportParams, +): BrowserConfigResult { const width = params.viewport_width; const height = params.viewport_height; const hasViewportOptions = @@ -75,40 +123,41 @@ export function buildBrowserViewport(params: BrowserViewportParams) { height !== undefined || params.viewport_refresh_rate !== undefined; - if (!hasViewportOptions) return undefined; + if (!hasViewportOptions) return configValue(undefined); if (width === undefined || height === undefined) { - throw new Error( + return configError( "viewport_width and viewport_height must be provided together.", ); } - return { + return configValue({ width, height, ...(params.viewport_refresh_rate !== undefined && { refresh_rate: params.viewport_refresh_rate, }), - }; + }); } export function buildBrowserViewportUpdate( params: BrowserViewportUpdateParams, -) { +): BrowserConfigResult { const viewport = buildBrowserViewport(params); + if (!viewport.ok) return viewport; - if (!viewport) { + if (!viewport.value) { if (params.viewport_force !== undefined) { - throw new Error( + return configError( "viewport_width and viewport_height must be provided when viewport_force is set.", ); } - return undefined; + return configValue(undefined); } - return { - ...viewport, + return configValue({ + ...viewport.value, ...(params.viewport_force !== undefined && { force: params.viewport_force, }), - }; + }); } diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 5a3d7cf..0aeec54 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -2,8 +2,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerKernelPrompts } from "@/lib/mcp/prompts"; import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys"; import { registerAppCapabilities } from "@/lib/mcp/tools/apps"; +import { registerBrowserCurlTool } from "@/lib/mcp/tools/browser-curl"; 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"; @@ -23,7 +23,7 @@ const mcpToolRegistrations = [ ["projects", registerProjectCapabilities], ["api_keys", registerAPIKeyCapabilities], ["browser_pools", registerBrowserPoolCapabilities], - ["browser_utilities", registerBrowserUtilityTools], + ["browser_curl", registerBrowserCurlTool], ["proxies", registerProxyTools], ["extensions", registerExtensionTools], ["apps", registerAppCapabilities], @@ -42,6 +42,7 @@ const standaloneToolsetAliases: Partial> = { search_docs: "docs", execute_playwright_code: "playwright", exec_command: "shell", + browser_utilities: "browser_curl", }; function isMcpToolset(value: string): value is McpToolset { diff --git a/src/lib/mcp/tools/browser-utilities.ts b/src/lib/mcp/tools/browser-curl.ts similarity index 54% rename from src/lib/mcp/tools/browser-utilities.ts rename to src/lib/mcp/tools/browser-curl.ts index 1d54b42..01822f6 100644 --- a/src/lib/mcp/tools/browser-utilities.ts +++ b/src/lib/mcp/tools/browser-curl.ts @@ -2,21 +2,28 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { + errorResponse, jsonResponse, - textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; type BrowserCurlParams = Parameters[1]; -function validateCurlUrl(url: string) { - const parsed = new URL(url); +function curlUrlError(url: string) { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "Error: url must be a valid URL."; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("url must use http or https."); + return "Error: url must use http or https."; } + return undefined; } -export function registerBrowserUtilityTools(server: McpServer) { +export function registerBrowserCurlTool(server: McpServer) { server.tool( "browser_curl", "Send an HTTP request through an existing Kernel browser session's Chrome network stack.", @@ -41,6 +48,7 @@ export function registerBrowserUtilityTools(server: McpServer) { .optional(), timeout_ms: z .number() + .int() .describe("Request timeout in milliseconds.") .optional(), }, @@ -52,7 +60,8 @@ export function registerBrowserUtilityTools(server: McpServer) { const { session_id, ...curlParams } = params satisfies { session_id: string; } & BrowserCurlParams; - validateCurlUrl(curlParams.url); + const urlError = curlUrlError(curlParams.url); + if (urlError) return errorResponse(urlError); const response = await client.browsers.curl(session_id, curlParams); return jsonResponse(response); @@ -61,47 +70,4 @@ export function registerBrowserUtilityTools(server: McpServer) { } }, ); - - 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 toolErrorResponse("read_browser_clipboard", "read", 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 toolErrorResponse("write_browser_clipboard", "write", error); - } - }, - ); } diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 5ec804c..3f2bc67 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -8,11 +8,13 @@ import { type BrowserExtensionParams, type BrowserProfileParams, type BrowserViewportParams, + type BrowserConfigResult, } from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { jsonResponse, + errorResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; @@ -49,123 +51,71 @@ type PoolConfigParams = BrowserProfileParams & 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, -): BrowserPoolUpdateParams { +): BrowserConfigResult { const profile = buildBrowserProfile(params); + if (!profile.ok) return profile; const extensions = buildBrowserExtensions(params); + if (!extensions.ok) return extensions; const viewport = buildBrowserViewport(params); + if (!viewport.ok) return viewport; const startUrl = buildBrowserStartUrl(params.start_url); + if (!startUrl.ok) return startUrl; 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 }), + ok: true, + value: { + ...(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.value && { profile: profile.value }), + ...(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.value !== undefined && { start_url: startUrl.value }), + ...(params.chrome_policy !== undefined && { + chrome_policy: params.chrome_policy, + }), + ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), + ...(extensions.value && { extensions: extensions.value }), + ...(viewport.value && { viewport: viewport.value }), + }, }; } function buildPoolCreateParams( params: PoolConfigParams, -): BrowserPoolCreateParams { +): BrowserConfigResult { if (params.size === undefined) { - throw new Error("size is required for create."); + return { ok: false, error: "Error: size is required for create." }; } - return { ...buildPoolConfigParams(params), size: params.size }; + const config = buildPoolConfigParams(params); + if (!config.ok) return config; + + return { ok: true, value: { ...config.value, size: params.size } }; } function buildPoolUpdateParams( params: PoolConfigParams & { discard_all_idle?: boolean }, -): BrowserPoolUpdateParams { +): BrowserConfigResult { + const config = buildPoolConfigParams(params); + if (!config.ok) return config; + return { - ...buildPoolConfigParams(params), - ...(params.discard_all_idle !== undefined && { - discard_all_idle: params.discard_all_idle, - }), + ok: true, + value: { + ...config.value, + ...(params.discard_all_idle !== undefined && { + discard_all_idle: params.discard_all_idle, + }), + }, }; } @@ -345,39 +295,34 @@ export function registerBrowserPoolCapabilities(server: McpServer) { try { switch (params.action) { case "create": { - const scopeError = actionFieldError(params, "create"); - if (scopeError) return textResponse(scopeError); + const createParams = buildPoolCreateParams(params); + if (!createParams.ok) return errorResponse(createParams.error); - const pool = await client.browserPools.create( - buildPoolCreateParams(params), - ); - if (!pool) return textResponse("Failed to create browser pool"); + const pool = await client.browserPools.create(createParams.value); + if (!pool) return errorResponse("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."); + return errorResponse("Error: id_or_name is required for update."); } const updateParams = buildPoolUpdateParams(params); - if (Object.keys(updateParams).length === 0) { - return textResponse( + if (!updateParams.ok) return errorResponse(updateParams.error); + if (Object.keys(updateParams.value).length === 0) { + return errorResponse( "Error: at least one update field is required.", ); } const pool = await client.browserPools.update( params.id_or_name, - updateParams, + updateParams.value, ); + if (!pool) return errorResponse("Failed to update browser pool"); return jsonResponse(pool); } case "list": { - const scopeError = actionFieldError(params, "list"); - if (scopeError) return textResponse(scopeError); - const pools = await client.browserPools.list(); return textResponse( pools?.length > 0 @@ -386,42 +331,36 @@ export function registerBrowserPoolCapabilities(server: McpServer) { ); } case "get": { - const scopeError = actionFieldError(params, "get"); - if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return textResponse("Error: id_or_name is required for get."); + return errorResponse("Error: id_or_name is required for get."); const pool = await client.browserPools.retrieve(params.id_or_name); if (!pool) - return textResponse( + return errorResponse( `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 textResponse("Error: id_or_name is required for delete."); + return errorResponse("Error: id_or_name is required for delete."); await client.browserPools.delete(params.id_or_name, { ...(params.force !== undefined && { force: params.force }), }); return textResponse("Browser pool deleted successfully"); } case "flush": { - const scopeError = actionFieldError(params, "flush"); - if (scopeError) return textResponse(scopeError); if (!params.id_or_name) - return textResponse("Error: id_or_name is required for flush."); + return errorResponse("Error: id_or_name is required for flush."); await client.browserPools.flush(params.id_or_name); 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 textResponse("Error: id_or_name is required for acquire."); + return errorResponse( + "Error: id_or_name is required for acquire.", + ); const browser = await client.browserPools.acquire( params.id_or_name, { @@ -431,16 +370,18 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }, ); if (!browser) - return textResponse("Failed to acquire browser from pool"); + return errorResponse("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 textResponse("Error: id_or_name is required for release."); + return errorResponse( + "Error: id_or_name is required for release.", + ); if (!params.session_id) - return textResponse("Error: session_id is required for release."); + return errorResponse( + "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 }), diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 12f9017..8ecd305 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -6,10 +6,12 @@ import { buildBrowserStartUrl, buildBrowserViewport, buildBrowserViewportUpdate, + type BrowserConfigResult, } from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { + errorResponse, jsonResponse, textResponse, toolErrorResponse, @@ -30,51 +32,6 @@ type TelemetryParams = { 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"], @@ -82,58 +39,46 @@ const telemetryCategories = [ ["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"] { +): BrowserConfigResult< + BrowserCreateParams["telemetry"] | BrowserUpdateParams["telemetry"] +> { const browser: NonNullable< NonNullable["browser"] > = {}; let hasBrowserCategories = false; + let hasEnabledBrowserCategories = false; for (const [paramKey, category] of telemetryCategories) { const enabled = params[paramKey]; if (enabled !== undefined) { browser[category] = { enabled }; hasBrowserCategories = true; + if (enabled) hasEnabledBrowserCategories = true; } } - if (params.telemetry_enabled === false && hasBrowserCategories) { - throw new Error( - "telemetry_enabled=false cannot be combined with telemetry category settings.", - ); + if (params.telemetry_enabled === false && hasEnabledBrowserCategories) { + return { + ok: false, + error: + "Error: telemetry_enabled=false cannot be combined with enabled telemetry categories.", + }; } if (params.telemetry_enabled === undefined && !hasBrowserCategories) { - return undefined; + return { ok: true, value: undefined }; } return { - ...(params.telemetry_enabled !== undefined && { - enabled: params.telemetry_enabled, - }), - ...(hasBrowserCategories && { browser }), + ok: true, + value: { + ...(params.telemetry_enabled !== undefined && { + enabled: params.telemetry_enabled, + }), + ...(hasBrowserCategories && { browser }), + }, }; } @@ -339,9 +284,6 @@ export function registerBrowserCapabilities(server: McpServer) { try { switch (params.action) { case "create": { - const scopeError = actionFieldError(params, "create"); - if (scopeError) return textResponse(scopeError); - const createParams: BrowserCreateParams = {}; if (params.headless !== undefined) createParams.headless = params.headless; @@ -353,22 +295,29 @@ export function registerBrowserCapabilities(server: McpServer) { 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 (!startUrl.ok) return errorResponse(startUrl.error); + if (startUrl.value !== undefined) + createParams.start_url = startUrl.value; if (params.chrome_policy) createParams.chrome_policy = params.chrome_policy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; const profile = buildBrowserProfile(params); - if (profile) createParams.profile = profile; + if (!profile.ok) return errorResponse(profile.error); + if (profile.value) createParams.profile = profile.value; const viewport = buildBrowserViewport(params); - if (viewport) createParams.viewport = viewport; + if (!viewport.ok) return errorResponse(viewport.error); + if (viewport.value) createParams.viewport = viewport.value; const telemetry = buildTelemetry(params); - if (telemetry !== undefined) createParams.telemetry = telemetry; + if (!telemetry.ok) return errorResponse(telemetry.error); + if (telemetry.value !== undefined) + createParams.telemetry = telemetry.value; const extensions = buildBrowserExtensions(params); - if (extensions) createParams.extensions = extensions; + if (!extensions.ok) return errorResponse(extensions.error); + if (extensions.value) createParams.extensions = extensions.value; const browser = await client.browsers.create(createParams); if (!browser) - return textResponse("Failed to create browser session"); + return errorResponse("Failed to create browser session"); let responseText = JSON.stringify(browser, null, 2); if (params.local_forward || params.remote_forward) { @@ -401,14 +350,12 @@ export function registerBrowserCapabilities(server: McpServer) { return textResponse(responseText); } case "update": { - const scopeError = actionFieldError(params, "update"); - if (scopeError) return textResponse(scopeError); if (!params.session_id) - return textResponse( + return errorResponse( "Error: session_id is required for update action.", ); if (params.proxy_id && params.clear_proxy) { - return textResponse( + return errorResponse( "Error: Cannot specify both proxy_id and clear_proxy.", ); } @@ -423,14 +370,18 @@ export function registerBrowserCapabilities(server: McpServer) { updateParams.proxy_id = params.proxy_id; } const profile = buildBrowserProfile(params); - if (profile) updateParams.profile = profile; + if (!profile.ok) return errorResponse(profile.error); + if (profile.value) updateParams.profile = profile.value; const viewport = buildBrowserViewportUpdate(params); - if (viewport) updateParams.viewport = viewport; + if (!viewport.ok) return errorResponse(viewport.error); + if (viewport.value) updateParams.viewport = viewport.value; const telemetry = buildTelemetry(params); - if (telemetry !== undefined) updateParams.telemetry = telemetry; + if (!telemetry.ok) return errorResponse(telemetry.error); + if (telemetry.value !== undefined) + updateParams.telemetry = telemetry.value; if (Object.keys(updateParams).length === 0) { - return textResponse( + return errorResponse( "Error: at least one update field is required.", ); } @@ -440,12 +391,10 @@ export function registerBrowserCapabilities(server: McpServer) { updateParams, ); if (!browser) - return textResponse("Failed to update browser session"); + return errorResponse("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 }), @@ -469,24 +418,20 @@ export function registerBrowserCapabilities(server: McpServer) { ); } case "get": { - const scopeError = actionFieldError(params, "get"); - if (scopeError) return textResponse(scopeError); if (!params.session_id) - return textResponse( + return errorResponse( "Error: session_id is required for get action.", ); const browser = await client.browsers.retrieve(params.session_id); if (!browser) - return textResponse( + return errorResponse( `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 textResponse( + return errorResponse( "Error: session_id is required for delete action.", ); await client.browsers.deleteByID(params.session_id); diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 7efba31..1d4e53c 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -1,12 +1,49 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { + errorResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; + +type ComputerBatchAction = Parameters< + KernelClient["browsers"]["computer"]["batch"] +>[1]["actions"][number]; + +type ComputerToolAction = + | ComputerBatchAction + | { + type: + | ComputerBatchAction["type"] + | "screenshot" + | "get_mouse_position" + | "read_clipboard" + | "write_clipboard"; + screenshot?: { + region?: { + x: number; + y: number; + width: number; + height: number; + }; + }; + write_clipboard?: { text: string }; + }; + +function isResultAction(action: ComputerToolAction) { + return ( + action.type === "screenshot" || + action.type === "get_mouse_position" || + action.type === "read_clipboard" + ); +} export function registerComputerActionTool(server: McpServer) { // computer_action -- Execute one or more computer actions on a browser session server.tool( "computer_action", - "Execute computer actions on a browser session. Pass a single action for simple operations (e.g. one click or one screenshot), or pass multiple actions to batch them into a single request for lower latency (e.g. click, type, press_key in one call). Use sleep actions between steps when the page needs time to react (e.g. after a click that triggers navigation or animation). IMPORTANT: Always include a screenshot as the last action so you can see the result of your actions. Action types: click_mouse, move_mouse, type_text, press_key, scroll, drag_mouse, set_cursor, sleep, screenshot, get_mouse_position. screenshot and get_mouse_position return data, so they must be the last action if included.", + "Execute computer actions on a browser session. Pass a single action for simple operations (e.g. one click or one screenshot), or pass multiple actions to batch them into a single request for lower latency (e.g. click, type, press_key in one call). Use sleep actions between steps when the page needs time to react (e.g. after a click that triggers navigation or animation). IMPORTANT: Always include a screenshot as the last action so you can see the result of your actions. Action types: click_mouse, move_mouse, type_text, press_key, scroll, drag_mouse, set_cursor, sleep, write_clipboard, read_clipboard, screenshot, get_mouse_position. screenshot, read_clipboard, and get_mouse_position return data, so they must be the last action if included.", { session_id: z.string().describe("Browser session ID."), actions: z @@ -22,6 +59,8 @@ export function registerComputerActionTool(server: McpServer) { "drag_mouse", "set_cursor", "sleep", + "write_clipboard", + "read_clipboard", "screenshot", "get_mouse_position", ]) @@ -120,8 +159,15 @@ export function registerComputerActionTool(server: McpServer) { "Params for screenshot action. Omit or pass {} for full-page screenshot.", ) .optional(), + write_clipboard: z + .object({ + text: z.string(), + }) + .describe("Params for write_clipboard action.") + .optional(), }), ) + .min(1) .describe( "Ordered list of actions. Use one action for simple operations or multiple for batched sequences.", ), @@ -131,42 +177,60 @@ export function registerComputerActionTool(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - const lastAction = actions[actions.length - 1]; + const toolActions = actions as ComputerToolAction[]; + const lastAction = toolActions[toolActions.length - 1]; const hasTrailingScreenshot = lastAction?.type === "screenshot"; + const hasTrailingReadClipboard = lastAction?.type === "read_clipboard"; const hasTrailingGetPosition = lastAction?.type === "get_mouse_position"; const hasTrailingSpecial = - hasTrailingScreenshot || hasTrailingGetPosition; + hasTrailingScreenshot || + hasTrailingReadClipboard || + hasTrailingGetPosition; - // Validate: screenshot/get_mouse_position can only be the last action - for (let i = 0; i < actions.length - 1; i++) { - if ( - actions[i].type === "screenshot" || - actions[i].type === "get_mouse_position" - ) { - return { - content: [ - { - type: "text", - text: `Error: ${actions[i].type} must be the last action in the sequence.`, - }, - ], - }; + for (let i = 0; i < toolActions.length - 1; i++) { + if (isResultAction(toolActions[i])) { + return errorResponse( + `Error: ${toolActions[i].type} must be the last action in the sequence.`, + ); } } - const batchActions = hasTrailingSpecial - ? actions.slice(0, -1) - : actions; + const leadingActions = hasTrailingSpecial + ? toolActions.slice(0, -1) + : toolActions; + let executedActionCount = 0; + let batchActions: ComputerBatchAction[] = []; - if (batchActions.length > 0) { + async function flushBatchActions() { + if (batchActions.length === 0) return; await client.browsers.computer.batch(session_id, { - actions: batchActions as Parameters< - typeof client.browsers.computer.batch - >[1]["actions"], + actions: batchActions, }); + executedActionCount += batchActions.length; + batchActions = []; + } + + for (const action of leadingActions) { + if (action.type === "write_clipboard") { + await flushBatchActions(); + if (!action.write_clipboard) { + return errorResponse( + "Error: write_clipboard params are required for write_clipboard action.", + ); + } + await client.browsers.computer.writeClipboard(session_id, { + text: action.write_clipboard.text, + }); + executedActionCount += 1; + continue; + } + + batchActions.push(action as ComputerBatchAction); } + await flushBatchActions(); + if (hasTrailingScreenshot) { const screenshotParams = lastAction.screenshot; const screenshotOpts = screenshotParams?.region @@ -186,10 +250,10 @@ export function registerComputerActionTool(server: McpServer) { | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = []; - if (batchActions.length > 0) { + if (executedActionCount > 0) { content.push({ type: "text", - text: `Executed ${batchActions.length} action(s), then captured screenshot.`, + text: `Executed ${executedActionCount} action(s), then captured screenshot.`, }); } content.push({ @@ -206,14 +270,31 @@ export function registerComputerActionTool(server: McpServer) { return { content }; } + if (hasTrailingReadClipboard) { + const clipboard = + await client.browsers.computer.readClipboard(session_id); + const content: Array<{ type: "text"; text: string }> = []; + if (executedActionCount > 0) { + content.push({ + type: "text", + text: `Executed ${executedActionCount} action(s), then read clipboard.`, + }); + } + content.push({ + type: "text", + text: JSON.stringify(clipboard, null, 2), + }); + return { content }; + } + if (hasTrailingGetPosition) { const position = await client.browsers.computer.getMousePosition(session_id); const content: Array<{ type: "text"; text: string }> = []; - if (batchActions.length > 0) { + if (executedActionCount > 0) { content.push({ type: "text", - text: `Executed ${batchActions.length} action(s).`, + text: `Executed ${executedActionCount} action(s).`, }); } content.push({ @@ -223,23 +304,11 @@ export function registerComputerActionTool(server: McpServer) { return { content }; } - return { - content: [ - { - type: "text", - text: `Executed ${actions.length} action(s) successfully`, - }, - ], - }; + return textResponse( + `Executed ${executedActionCount} action(s) successfully`, + ); } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in computer_action: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return toolErrorResponse("computer_action", "actions", error); } }, ); From 1331dab64ecbd260731053a144a0952e0159c4da Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:28:13 -0400 Subject: [PATCH 16/20] Simplify browser MCP review fixes --- src/lib/mcp/browser-config.ts | 119 ++++++++---- src/lib/mcp/tools/apps.ts | 159 +++++----------- src/lib/mcp/tools/browser-pools.ts | 49 ++--- src/lib/mcp/tools/browsers.ts | 32 +--- src/lib/mcp/tools/computer-action.ts | 271 ++++++++++++++++----------- src/lib/mcp/tools/profiles.ts | 120 ++++-------- 6 files changed, 356 insertions(+), 394 deletions(-) diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts index b6735ad..797e5ac 100644 --- a/src/lib/mcp/browser-config.ts +++ b/src/lib/mcp/browser-config.ts @@ -19,32 +19,45 @@ export type BrowserViewportUpdateParams = BrowserViewportParams & { viewport_force?: boolean; }; -type BrowserProfileConfig = - | { - id?: string; - name?: string; - save_changes?: boolean; - } - | undefined; - -type BrowserExtensionConfig = - | Array<{ - id?: string; - name?: string; - }> - | undefined; - -type BrowserViewportConfig = - | { - width: number; - height: number; - refresh_rate?: number; - } - | undefined; +export type BrowserCreateConfigParams = BrowserProfileParams & + BrowserExtensionParams & + BrowserViewportParams & { + start_url?: string; + }; + +export type BrowserUpdateConfigParams = BrowserProfileParams & + BrowserViewportUpdateParams; + +type BrowserProfileConfig = { + id?: string; + name?: string; + save_changes?: boolean; +}; + +type BrowserExtensionConfig = Array<{ + id?: string; + name?: string; +}>; + +type BrowserViewportConfig = { + width: number; + height: number; + refresh_rate?: number; +}; + +type BrowserViewportUpdateConfig = BrowserViewportConfig & { force?: boolean }; -type BrowserViewportUpdateConfig = - | (NonNullable & { force?: boolean }) - | undefined; +export type BrowserCreateConfig = { + profile?: BrowserProfileConfig; + extensions?: BrowserExtensionConfig; + viewport?: BrowserViewportConfig; + start_url?: string; +}; + +export type BrowserUpdateConfig = { + profile?: BrowserProfileConfig; + viewport?: BrowserViewportUpdateConfig; +}; export type BrowserConfigResult = | { ok: true; value: T } @@ -58,7 +71,7 @@ function configError(message: string): BrowserConfigResult { return { ok: false, error: `Error: ${message}` }; } -export function buildBrowserStartUrl( +function buildBrowserStartUrl( startUrl: string | undefined, ): BrowserConfigResult { if (startUrl === undefined) return configValue(undefined); @@ -72,9 +85,9 @@ export function buildBrowserStartUrl( return configValue(startUrl); } -export function buildBrowserProfile( +function buildBrowserProfile( params: BrowserProfileParams, -): BrowserConfigResult { +): BrowserConfigResult { if (params.profile_name && params.profile_id) { return configError("Cannot specify both profile_name and profile_id."); } @@ -97,9 +110,9 @@ export function buildBrowserProfile( }); } -export function buildBrowserExtensions( +function buildBrowserExtensions( params: BrowserExtensionParams, -): BrowserConfigResult { +): BrowserConfigResult { if (params.extension_id && params.extension_name) { return configError("Cannot specify both extension_id and extension_name."); } @@ -113,9 +126,9 @@ export function buildBrowserExtensions( ]); } -export function buildBrowserViewport( +function buildBrowserViewport( params: BrowserViewportParams, -): BrowserConfigResult { +): BrowserConfigResult { const width = params.viewport_width; const height = params.viewport_height; const hasViewportOptions = @@ -139,9 +152,9 @@ export function buildBrowserViewport( }); } -export function buildBrowserViewportUpdate( +function buildBrowserViewportUpdate( params: BrowserViewportUpdateParams, -): BrowserConfigResult { +): BrowserConfigResult { const viewport = buildBrowserViewport(params); if (!viewport.ok) return viewport; @@ -161,3 +174,41 @@ export function buildBrowserViewportUpdate( }), }); } + +export function buildBrowserCreateConfig( + params: BrowserCreateConfigParams, +): BrowserConfigResult { + const profile = buildBrowserProfile(params); + if (!profile.ok) return profile; + + const extensions = buildBrowserExtensions(params); + if (!extensions.ok) return extensions; + + const viewport = buildBrowserViewport(params); + if (!viewport.ok) return viewport; + + const startUrl = buildBrowserStartUrl(params.start_url); + if (!startUrl.ok) return startUrl; + + return configValue({ + ...(profile.value && { profile: profile.value }), + ...(extensions.value && { extensions: extensions.value }), + ...(viewport.value && { viewport: viewport.value }), + ...(startUrl.value !== undefined && { start_url: startUrl.value }), + }); +} + +export function buildBrowserUpdateConfig( + params: BrowserUpdateConfigParams, +): BrowserConfigResult { + const profile = buildBrowserProfile(params); + if (!profile.ok) return profile; + + const viewport = buildBrowserViewportUpdate(params); + if (!viewport.ok) return viewport; + + return configValue({ + ...(profile.value && { profile: profile.value }), + ...(viewport.value && { viewport: viewport.value }), + }); +} diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 292c0b6..e8b43fe 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -2,6 +2,12 @@ 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 { + errorResponse, + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -100,36 +106,19 @@ export function registerAppCapabilities(server: McpServer) { ...(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 items.length > 0 + ? jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }) + : textResponse("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 errorResponse( + "Error: app_name and action_name are required for invoke.", + ); } const invocation = await client.invocations.create({ app_name: params.app_name, @@ -138,28 +127,24 @@ export function registerAppCapabilities(server: McpServer) { version: params.version ?? "latest", async: true, }); - if (!invocation) throw new Error("Failed to create invocation"); + if (!invocation) + return errorResponse("Failed to create invocation"); const stream = await client.invocations.follow(invocation.id); let finalInvocation = invocation; for await (const evt of stream) { if (evt.event === "error") { - return { - content: [ + return errorResponse( + JSON.stringify( { - type: "text", - text: JSON.stringify( - { - status: "error", - invocation_id: invocation.id, - error: evt, - }, - null, - 2, - ), + status: "error", + invocation_id: invocation.id, + error: evt, }, - ], - }; + null, + 2, + ), + ); } if (evt.event === "invocation_state") { finalInvocation = evt.invocation || finalInvocation; @@ -170,39 +155,19 @@ 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 errorResponse("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 errorResponse( + `Deployment "${params.deployment_id}" not found`, + ); + return jsonResponse(deployment); } case "list_deployments": { const page = await client.deployments.list({ @@ -211,61 +176,29 @@ export function registerAppCapabilities(server: McpServer) { ...(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 items.length > 0 + ? jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }) + : textResponse("No deployments found"); } case "get_invocation": { if (!params.invocation_id) - return { - content: [ - { type: "text", text: "Error: invocation_id is required." }, - ], - }; + return errorResponse("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 errorResponse( + `Invocation "${params.invocation_id}" not found`, + ); + return jsonResponse(invocation); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_apps (${params.action}): ${error}`, - }, - ], - }; + return toolErrorResponse("manage_apps", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 3f2bc67..50964f8 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -1,14 +1,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { - buildBrowserExtensions, - buildBrowserProfile, - buildBrowserStartUrl, - buildBrowserViewport, - type BrowserExtensionParams, - type BrowserProfileParams, - type BrowserViewportParams, + buildBrowserCreateConfig, type BrowserConfigResult, + type BrowserCreateConfigParams, } from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; @@ -36,32 +31,23 @@ type BrowserPoolAction = | "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; - }; +type PoolConfigParams = BrowserCreateConfigParams & { + size?: number; + name?: string; + headless?: boolean; + stealth?: boolean; + timeout_seconds?: number; + proxy_id?: string; + fill_rate_per_minute?: number; + chrome_policy?: Record; + kiosk_mode?: boolean; +}; function buildPoolConfigParams( params: PoolConfigParams, ): BrowserConfigResult { - const profile = buildBrowserProfile(params); - if (!profile.ok) return profile; - const extensions = buildBrowserExtensions(params); - if (!extensions.ok) return extensions; - const viewport = buildBrowserViewport(params); - if (!viewport.ok) return viewport; - const startUrl = buildBrowserStartUrl(params.start_url); - if (!startUrl.ok) return startUrl; + const browserConfig = buildBrowserCreateConfig(params); + if (!browserConfig.ok) return browserConfig; return { ok: true, @@ -73,18 +59,15 @@ function buildPoolConfigParams( ...(params.timeout_seconds !== undefined && { timeout_seconds: params.timeout_seconds, }), - ...(profile.value && { profile: profile.value }), ...(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.value !== undefined && { start_url: startUrl.value }), ...(params.chrome_policy !== undefined && { chrome_policy: params.chrome_policy, }), ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), - ...(extensions.value && { extensions: extensions.value }), - ...(viewport.value && { viewport: viewport.value }), + ...browserConfig.value, }, }; } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 8ecd305..74c70cd 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,11 +1,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { - buildBrowserExtensions, - buildBrowserProfile, - buildBrowserStartUrl, - buildBrowserViewport, - buildBrowserViewportUpdate, + buildBrowserCreateConfig, + buildBrowserUpdateConfig, type BrowserConfigResult, } from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; @@ -294,26 +291,16 @@ export function registerBrowserCapabilities(server: McpServer) { 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.ok) return errorResponse(startUrl.error); - if (startUrl.value !== undefined) - createParams.start_url = startUrl.value; if (params.chrome_policy) createParams.chrome_policy = params.chrome_policy; if (params.proxy_id) createParams.proxy_id = params.proxy_id; - const profile = buildBrowserProfile(params); - if (!profile.ok) return errorResponse(profile.error); - if (profile.value) createParams.profile = profile.value; - const viewport = buildBrowserViewport(params); - if (!viewport.ok) return errorResponse(viewport.error); - if (viewport.value) createParams.viewport = viewport.value; + const browserConfig = buildBrowserCreateConfig(params); + if (!browserConfig.ok) return errorResponse(browserConfig.error); + Object.assign(createParams, browserConfig.value); const telemetry = buildTelemetry(params); if (!telemetry.ok) return errorResponse(telemetry.error); if (telemetry.value !== undefined) createParams.telemetry = telemetry.value; - const extensions = buildBrowserExtensions(params); - if (!extensions.ok) return errorResponse(extensions.error); - if (extensions.value) createParams.extensions = extensions.value; const browser = await client.browsers.create(createParams); if (!browser) @@ -369,12 +356,9 @@ export function registerBrowserCapabilities(server: McpServer) { } else if (params.proxy_id !== undefined) { updateParams.proxy_id = params.proxy_id; } - const profile = buildBrowserProfile(params); - if (!profile.ok) return errorResponse(profile.error); - if (profile.value) updateParams.profile = profile.value; - const viewport = buildBrowserViewportUpdate(params); - if (!viewport.ok) return errorResponse(viewport.error); - if (viewport.value) updateParams.viewport = viewport.value; + const browserConfig = buildBrowserUpdateConfig(params); + if (!browserConfig.ok) return errorResponse(browserConfig.error); + Object.assign(updateParams, browserConfig.value); const telemetry = buildTelemetry(params); if (!telemetry.ok) return errorResponse(telemetry.error); if (telemetry.value !== undefined) diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 1d4e53c..47a0ef4 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -11,27 +11,39 @@ type ComputerBatchAction = Parameters< KernelClient["browsers"]["computer"]["batch"] >[1]["actions"][number]; -type ComputerToolAction = - | ComputerBatchAction - | { - type: - | ComputerBatchAction["type"] - | "screenshot" - | "get_mouse_position" - | "read_clipboard" - | "write_clipboard"; - screenshot?: { - region?: { - x: number; - y: number; - width: number; - height: number; - }; - }; - write_clipboard?: { text: string }; +type ComputerToolAction = Partial> & { + type: + | ComputerBatchAction["type"] + | "screenshot" + | "get_mouse_position" + | "read_clipboard" + | "write_clipboard"; + screenshot?: { + region?: { + x: number; + y: number; + width: number; + height: number; }; + }; + write_clipboard?: { text: string }; +}; + +type ComputerBatchToolAction = ComputerToolAction & { + type: ComputerBatchAction["type"]; +}; -function isResultAction(action: ComputerToolAction) { +type ComputerResultAction = ComputerToolAction & { + type: "screenshot" | "get_mouse_position" | "read_clipboard"; +}; + +type ComputerExecutionResult = + | { ok: true; executedActionCount: number } + | { ok: false; error: string }; + +function isResultAction( + action: ComputerToolAction, +): action is ComputerResultAction { return ( action.type === "screenshot" || action.type === "get_mouse_position" || @@ -39,6 +51,104 @@ function isResultAction(action: ComputerToolAction) { ); } +function isBatchAction( + action: ComputerToolAction, +): action is ComputerBatchToolAction { + return action.type !== "write_clipboard" && !isResultAction(action); +} + +function toBatchAction(action: ComputerBatchToolAction): ComputerBatchAction { + switch (action.type) { + case "click_mouse": + return { type: action.type, click_mouse: action.click_mouse }; + case "move_mouse": + return { type: action.type, move_mouse: action.move_mouse }; + case "type_text": + return { type: action.type, type_text: action.type_text }; + case "press_key": + return { type: action.type, press_key: action.press_key }; + case "scroll": + return { type: action.type, scroll: action.scroll }; + case "drag_mouse": + return { type: action.type, drag_mouse: action.drag_mouse }; + case "set_cursor": + return { type: action.type, set_cursor: action.set_cursor }; + case "sleep": + return { type: action.type, sleep: action.sleep }; + } +} + +function splitTrailingResultAction(actions: ComputerToolAction[]): { + leadingActions: ComputerToolAction[]; + resultAction?: ComputerResultAction; +} { + const lastAction = actions[actions.length - 1]; + const resultAction = isResultAction(lastAction) ? lastAction : undefined; + return { + leadingActions: resultAction ? actions.slice(0, -1) : actions, + resultAction, + }; +} + +async function executeLeadingComputerActions( + client: KernelClient, + sessionId: string, + actions: ComputerToolAction[], +): Promise { + let executedActionCount = 0; + let batchActions: ComputerBatchAction[] = []; + + async function flushBatchActions() { + if (batchActions.length === 0) return; + await client.browsers.computer.batch(sessionId, { actions: batchActions }); + executedActionCount += batchActions.length; + batchActions = []; + } + + for (const action of actions) { + if (isResultAction(action)) { + return { + ok: false, + error: `Error: ${action.type} must be the last action in the sequence.`, + }; + } + + if (action.type === "write_clipboard") { + await flushBatchActions(); + if (!action.write_clipboard) { + return { + ok: false, + error: + "Error: write_clipboard params are required for write_clipboard action.", + }; + } + await client.browsers.computer.writeClipboard(sessionId, { + text: action.write_clipboard.text, + }); + executedActionCount += 1; + continue; + } + + if (isBatchAction(action)) { + batchActions.push(toBatchAction(action)); + } + } + + await flushBatchActions(); + return { ok: true, executedActionCount }; +} + +function actionCountPrefix(executedActionCount: number, suffix: string) { + return executedActionCount > 0 + ? [ + { + type: "text" as const, + text: `Executed ${executedActionCount} action(s)${suffix}`, + }, + ] + : []; +} + export function registerComputerActionTool(server: McpServer) { // computer_action -- Execute one or more computer actions on a browser session server.tool( @@ -177,62 +287,18 @@ export function registerComputerActionTool(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - const toolActions = actions as ComputerToolAction[]; - const lastAction = toolActions[toolActions.length - 1]; - const hasTrailingScreenshot = lastAction?.type === "screenshot"; - const hasTrailingReadClipboard = lastAction?.type === "read_clipboard"; - const hasTrailingGetPosition = - lastAction?.type === "get_mouse_position"; - const hasTrailingSpecial = - hasTrailingScreenshot || - hasTrailingReadClipboard || - hasTrailingGetPosition; - - for (let i = 0; i < toolActions.length - 1; i++) { - if (isResultAction(toolActions[i])) { - return errorResponse( - `Error: ${toolActions[i].type} must be the last action in the sequence.`, - ); - } - } - - const leadingActions = hasTrailingSpecial - ? toolActions.slice(0, -1) - : toolActions; - let executedActionCount = 0; - let batchActions: ComputerBatchAction[] = []; - - async function flushBatchActions() { - if (batchActions.length === 0) return; - await client.browsers.computer.batch(session_id, { - actions: batchActions, - }); - executedActionCount += batchActions.length; - batchActions = []; - } - - for (const action of leadingActions) { - if (action.type === "write_clipboard") { - await flushBatchActions(); - if (!action.write_clipboard) { - return errorResponse( - "Error: write_clipboard params are required for write_clipboard action.", - ); - } - await client.browsers.computer.writeClipboard(session_id, { - text: action.write_clipboard.text, - }); - executedActionCount += 1; - continue; - } - - batchActions.push(action as ComputerBatchAction); - } - - await flushBatchActions(); + const toolActions: ComputerToolAction[] = actions; + const { leadingActions, resultAction } = + splitTrailingResultAction(toolActions); + const execution = await executeLeadingComputerActions( + client, + session_id, + leadingActions, + ); + if (!execution.ok) return errorResponse(execution.error); - if (hasTrailingScreenshot) { - const screenshotParams = lastAction.screenshot; + if (resultAction?.type === "screenshot") { + const screenshotParams = resultAction.screenshot; const screenshotOpts = screenshotParams?.region ? { region: screenshotParams.region } : undefined; @@ -250,12 +316,12 @@ export function registerComputerActionTool(server: McpServer) { | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = []; - if (executedActionCount > 0) { - content.push({ - type: "text", - text: `Executed ${executedActionCount} action(s), then captured screenshot.`, - }); - } + content.push( + ...actionCountPrefix( + execution.executedActionCount, + ", then captured screenshot.", + ), + ); content.push({ type: "text", text: viewport @@ -270,42 +336,33 @@ export function registerComputerActionTool(server: McpServer) { return { content }; } - if (hasTrailingReadClipboard) { + if (resultAction?.type === "read_clipboard") { const clipboard = await client.browsers.computer.readClipboard(session_id); - const content: Array<{ type: "text"; text: string }> = []; - if (executedActionCount > 0) { - content.push({ - type: "text", - text: `Executed ${executedActionCount} action(s), then read clipboard.`, - }); - } - content.push({ - type: "text", - text: JSON.stringify(clipboard, null, 2), - }); - return { content }; + return { + content: [ + ...actionCountPrefix( + execution.executedActionCount, + ", then read clipboard.", + ), + { type: "text", text: JSON.stringify(clipboard, null, 2) }, + ], + }; } - if (hasTrailingGetPosition) { + if (resultAction?.type === "get_mouse_position") { const position = await client.browsers.computer.getMousePosition(session_id); - const content: Array<{ type: "text"; text: string }> = []; - if (executedActionCount > 0) { - content.push({ - type: "text", - text: `Executed ${executedActionCount} action(s).`, - }); - } - content.push({ - type: "text", - text: JSON.stringify(position, null, 2), - }); - return { content }; + return { + content: [ + ...actionCountPrefix(execution.executedActionCount, "."), + { type: "text", text: JSON.stringify(position, null, 2) }, + ], + }; } return textResponse( - `Executed ${executedActionCount} action(s) successfully`, + `Executed ${execution.executedActionCount} action(s) successfully`, ); } catch (error) { return toolErrorResponse("computer_action", "actions", error); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index 36fef40..d208077 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -2,6 +2,12 @@ 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 { + errorResponse, + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; async function listProfiles(client: KernelClient) { const profiles: Awaited>[] = []; @@ -71,14 +77,9 @@ 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.", - }, - ], - }; + return errorResponse( + "Error: profile_name is required for setup.", + ); const existingProfiles = await listProfiles(client); const existingProfile = existingProfiles?.find( (p) => p.name === params.profile_name, @@ -88,24 +89,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 errorResponse( + `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 errorResponse("Failed to create profile"); isNewProfile = true; } @@ -115,83 +108,44 @@ 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 errorResponse( + "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.", - }, - ], - }; + return profiles.length > 0 + ? jsonResponse(profiles) + : textResponse( + "No profiles found. Use manage_profiles with action 'setup' to create one.", + ); } case "delete": { if (params.profile_name && params.profile_id) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both profile_name and profile_id.", - }, - ], - }; + return errorResponse( + "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 errorResponse( + "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 toolErrorResponse("manage_profiles", params.action, error); } }, ); From db71a9bb5a274510a9a3cf962ee7b5a9fd018d90 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:43:51 -0400 Subject: [PATCH 17/20] Improve MCP agent ergonomics --- src/lib/mcp/responses.ts | 19 +++- src/lib/mcp/schemas.ts | 9 +- src/lib/mcp/tools/apps.ts | 35 ++----- src/lib/mcp/tools/browser-curl.ts | 3 +- src/lib/mcp/tools/browser-pools.ts | 111 ++++++++++++++++++++-- src/lib/mcp/tools/browsers.ts | 135 ++++++++++++++++----------- src/lib/mcp/tools/computer-action.ts | 18 ++-- src/lib/mcp/tools/profiles.ts | 29 ++++-- src/lib/mcp/tools/shell.ts | 2 + 9 files changed, 247 insertions(+), 114 deletions(-) diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index 604ca07..c6f3475 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -4,6 +4,12 @@ type PaginatedPage = { next_offset?: number | null; }; +type PaginatedJsonResponseOptions = { + mapItem?: (item: T) => U; + note?: string; + emptyText?: string; +}; + export function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } @@ -12,11 +18,20 @@ export function jsonResponse(value: unknown) { return textResponse(JSON.stringify(value, null, 2) ?? String(value)); } -export function paginatedJsonResponse(page: PaginatedPage) { +export function paginatedJsonResponse( + page: PaginatedPage, + options: PaginatedJsonResponseOptions = {}, +) { + const items = page.getPaginatedItems(); + if (items.length === 0 && options.emptyText) { + return textResponse(options.emptyText); + } + return jsonResponse({ - items: page.getPaginatedItems(), + items: options.mapItem ? items.map(options.mapItem) : items, has_more: page.has_more, next_offset: page.next_offset, + ...(options.note && { note: options.note }), }); } diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index 4e79664..dbf7c9e 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -4,15 +4,16 @@ export const paginationParams = { limit: z .number() .int() + .min(1) + .max(100) .describe( - "(list) Max results per page. Defaults to 20; API clamps to 1-100.", + "(list) Max results per page. Must be 1-100; API default varies by endpoint.", ) .optional(), offset: z .number() .int() - .describe( - "(list) Pagination offset. Defaults to 0; API clamps negatives to 0.", - ) + .min(0) + .describe("(list) Pagination offset. Must be 0 or greater.") .optional(), }; diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index e8b43fe..c2f40ee 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -5,9 +5,11 @@ import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, jsonResponse, + paginatedJsonResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -44,7 +46,7 @@ export function registerAppCapabilities(server: McpServer) { // 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 when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. Use "list_apps" before invoking an unknown app, "invoke" to run an action, and get/list actions to inspect results.', { action: z .enum([ @@ -83,14 +85,7 @@ export function registerAppCapabilities(server: McpServer) { .string() .describe("(get_invocation) Invocation ID to retrieve.") .optional(), - limit: z - .number() - .describe("(list_apps, list_deployments) Max results. Default 50.") - .optional(), - offset: z - .number() - .describe("(list_apps, list_deployments) Pagination offset. Default 0.") - .optional(), + ...paginationParams, }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -105,14 +100,9 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return items.length > 0 - ? jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }) - : textResponse("No apps found"); + return paginatedJsonResponse(page, { + emptyText: "No apps found", + }); } case "invoke": { if (!params.app_name || !params.action_name) { @@ -175,14 +165,9 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return items.length > 0 - ? jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }) - : textResponse("No deployments found"); + return paginatedJsonResponse(page, { + emptyText: "No deployments found", + }); } case "get_invocation": { if (!params.invocation_id) diff --git a/src/lib/mcp/tools/browser-curl.ts b/src/lib/mcp/tools/browser-curl.ts index 01822f6..d38170b 100644 --- a/src/lib/mcp/tools/browser-curl.ts +++ b/src/lib/mcp/tools/browser-curl.ts @@ -26,7 +26,7 @@ function curlUrlError(url: string) { export function registerBrowserCurlTool(server: McpServer) { server.tool( "browser_curl", - "Send an HTTP request through an existing Kernel browser session's Chrome network stack.", + "Send an HTTP request through an existing Kernel browser session's Chrome network stack. Use when the request needs that browser session's cookies, proxy, network context, or origin behavior; do not use for general documentation lookup or web search.", { session_id: z.string().describe("Browser session ID."), url: z.string().url().describe("Target http or https URL."), @@ -49,6 +49,7 @@ export function registerBrowserCurlTool(server: McpServer) { timeout_ms: z .number() .int() + .min(1) .describe("Request timeout in milliseconds.") .optional(), }, diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 50964f8..413b9eb 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -20,6 +20,12 @@ type BrowserPoolCreateParams = Parameters< type BrowserPoolUpdateParams = Parameters< KernelClient["browserPools"]["update"] >[1]; +type BrowserPool = Awaited< + ReturnType +>; +type BrowserPoolAcquireResponse = Awaited< + ReturnType +>; type BrowserPoolAction = | "create" @@ -102,6 +108,58 @@ function buildPoolUpdateParams( }; } +function summarizeBrowserPool(pool: BrowserPool) { + const config = pool.browser_pool_config; + return { + id: pool.id, + name: pool.name, + created_at: pool.created_at, + counts: { + size: config.size, + available: pool.available_count, + acquired: pool.acquired_count, + }, + config: { + headless: config.headless, + stealth: config.stealth, + kiosk_mode: config.kiosk_mode, + timeout_seconds: config.timeout_seconds, + fill_rate_per_minute: config.fill_rate_per_minute, + start_url: config.start_url, + profile: config.profile, + proxy_id: config.proxy_id, + viewport: config.viewport, + extensions: config.extensions, + chrome_policy_keys: config.chrome_policy + ? Object.keys(config.chrome_policy) + : undefined, + }, + }; +} + +function poolNextActions(pool: BrowserPool) { + return [ + `Use manage_browser_pools with action "acquire" and id_or_name "${pool.id}" to get a browser from this pool.`, + `Use manage_browser_pools with action "get" and id_or_name "${pool.id}" for full pool details.`, + ]; +} + +function summarizeAcquiredBrowser(browser: BrowserPoolAcquireResponse) { + return { + session_id: browser.session_id, + browser_live_view_url: browser.browser_live_view_url, + base_url: browser.base_url, + headless: browser.headless, + stealth: browser.stealth, + timeout_seconds: browser.timeout_seconds, + pool: browser.pool, + profile: browser.profile, + proxy_id: browser.proxy_id, + start_url: browser.start_url, + viewport: browser.viewport, + }; +} + export function registerBrowserPoolCapabilities(server: McpServer) { server.resource("browser_pools", "browser-pools://", async (uri, extra) => { if (!extra.authInfo) { @@ -117,7 +175,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { mimeType: "application/json", text: pools && pools.length > 0 - ? JSON.stringify(pools, null, 2) + ? JSON.stringify(pools.map(summarizeBrowserPool), null, 2) : "No browser pools found", }, ], @@ -135,7 +193,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { // 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, "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.', + 'Manage pre-warmed browser pools when an agent needs fast browser acquisition or reusable session capacity. Use "list" for a compact pool inventory, "get" for full details, "acquire" before controlling a pooled browser, and "release" when the browser should return to the pool.', { action: z .enum([ @@ -157,6 +215,8 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), size: z .number() + .int() + .min(1) .describe( "(create, update) Number of browsers to maintain in the pool.", ) @@ -175,6 +235,8 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), timeout_seconds: z .number() + .int() + .min(1) .describe( "(create, update) Idle timeout for acquired browsers. Default 600.", ) @@ -234,18 +296,24 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), viewport_width: z .number() + .int() + .min(1) .describe( "(create, update) Window width in pixels. Must pair with viewport_height.", ) .optional(), viewport_height: z .number() + .int() + .min(1) .describe( "(create, update) Window height in pixels. Must pair with viewport_width.", ) .optional(), viewport_refresh_rate: z .number() + .int() + .min(1) .describe("(create, update) Display refresh rate in Hz.") .optional(), discard_all_idle: z @@ -260,6 +328,8 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), acquire_timeout_seconds: z .number() + .int() + .min(0) .describe("(acquire) Max seconds to wait for a browser.") .optional(), session_id: z @@ -283,7 +353,10 @@ export function registerBrowserPoolCapabilities(server: McpServer) { const pool = await client.browserPools.create(createParams.value); if (!pool) return errorResponse("Failed to create browser pool"); - return jsonResponse(pool); + return jsonResponse({ + browser_pool: summarizeBrowserPool(pool), + next_actions: poolNextActions(pool), + }); } case "update": { if (!params.id_or_name) { @@ -303,15 +376,26 @@ export function registerBrowserPoolCapabilities(server: McpServer) { updateParams.value, ); if (!pool) return errorResponse("Failed to update browser pool"); - return jsonResponse(pool); + return jsonResponse({ + browser_pool: summarizeBrowserPool(pool), + next_actions: [ + ...poolNextActions(pool), + ...(params.discard_all_idle + ? [ + "discard_all_idle was requested; idle browsers may be rebuilt before the next acquire.", + ] + : []), + ], + }); } case "list": { const pools = await client.browserPools.list(); - return textResponse( - pools?.length > 0 - ? JSON.stringify(pools, null, 2) - : "No browser pools found", - ); + return pools.length > 0 + ? jsonResponse({ + items: pools.map(summarizeBrowserPool), + note: 'Use action "get" with id_or_name for full pool details.', + }) + : textResponse("No browser pools found"); } case "get": { if (!params.id_or_name) @@ -354,7 +438,14 @@ export function registerBrowserPoolCapabilities(server: McpServer) { ); if (!browser) return errorResponse("Failed to acquire browser from pool"); - return jsonResponse(browser); + return jsonResponse({ + browser: summarizeAcquiredBrowser(browser), + next_actions: [ + `Use computer_action with session_id "${browser.session_id}" to control this browser.`, + `When finished, use manage_browser_pools with action "release", id_or_name "${params.id_or_name}", and session_id "${browser.session_id}".`, + `Use manage_browsers with action "get" and session_id "${browser.session_id}" for full browser details.`, + ], + }); } case "release": { if (!params.id_or_name) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 74c70cd..c95f26c 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -10,9 +10,11 @@ import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, jsonResponse, + paginatedJsonResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; type BrowserCreateParams = NonNullable< Parameters[0] @@ -79,6 +81,53 @@ function buildTelemetry( }; } +function browserSessionNextActions(sessionId: string) { + return [ + `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, + `Use manage_browsers with action "get" and session_id "${sessionId}" for full browser details.`, + `Use manage_browsers with action "delete" and session_id "${sessionId}" when the session is no longer needed.`, + ]; +} + +function buildSshPortForwardingInfo( + params: { local_forward?: string; remote_forward?: string }, + sessionId: string, +) { + if (!params.local_forward && !params.remote_forward) return undefined; + + const sshParts = ["kernel browsers ssh", sessionId]; + if (params.local_forward) sshParts.push(`-L ${params.local_forward}`); + if (params.remote_forward) sshParts.push(`-R ${params.remote_forward}`); + + const remotePort = params.remote_forward + ? params.remote_forward.split(":")[0] + : undefined; + const localPort = params.local_forward + ? params.local_forward.split(":")[0] + : undefined; + + return { + command: sshParts.join(" "), + prerequisites: [ + "Kernel CLI: https://kernel.sh/docs/reference/cli", + "websocat: brew install websocat on macOS", + ], + remote_forward: remotePort + ? { + browser_vm_url: `http://localhost:${remotePort}`, + next_action: `Once the user has the tunnel running, use execute_playwright_code to navigate the browser to http://localhost:${remotePort}.`, + } + : undefined, + local_forward: localPort + ? { + local_url: `http://localhost:${localPort}`, + note: `Services inside the browser VM are accessible locally at localhost:${localPort} once the tunnel is running.`, + } + : undefined, + note: "SSH connections alone do not count as browser activity. Set an appropriate timeout or keep the live view open to prevent cleanup.", + }; +} + export function registerBrowserCapabilities(server: McpServer) { server.resource("browsers", "browsers://", async (uri, extra) => { if (!extra.authInfo) { @@ -113,7 +162,7 @@ export function registerBrowserCapabilities(server: McpServer) { // 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, "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.', + 'Manage browser sessions when an agent needs a live browser to inspect, automate, or debug web state. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, and "delete" when finished.', { action: z .enum(["create", "update", "list", "get", "delete"]) @@ -153,6 +202,9 @@ export function registerBrowserCapabilities(server: McpServer) { .optional(), timeout_seconds: z .number() + .int() + .min(10) + .max(259200) .describe( "(create) Inactivity timeout in seconds (max 259200 = 72h). Default 60.", ) @@ -197,18 +249,24 @@ export function registerBrowserCapabilities(server: McpServer) { .optional(), viewport_width: z .number() + .int() + .min(1) .describe( "(create, update) Window width in pixels. Must pair with viewport_height.", ) .optional(), viewport_height: z .number() + .int() + .min(1) .describe( "(create, update) Window height in pixels. Must pair with viewport_width.", ) .optional(), viewport_refresh_rate: z .number() + .int() + .min(1) .describe("(create, update) Display refresh rate in Hz.") .optional(), viewport_force: z @@ -239,14 +297,7 @@ export function registerBrowserCapabilities(server: McpServer) { .enum(["active", "deleted", "all"]) .describe('(list) Filter by status. Default "active".') .optional(), - limit: z - .number() - .describe("(list) Max results per page. Default 50.") - .optional(), - offset: z - .number() - .describe("(list) Pagination offset. Default 0.") - .optional(), + ...paginationParams, telemetry_enabled: z .boolean() .describe( @@ -306,35 +357,17 @@ export function registerBrowserCapabilities(server: McpServer) { if (!browser) return errorResponse("Failed to create browser session"); - let responseText = JSON.stringify(browser, null, 2); - if (params.local_forward || params.remote_forward) { - const sshParts = ["kernel browsers ssh", browser.session_id]; - if (params.local_forward) - sshParts.push(`-L ${params.local_forward}`); - if (params.remote_forward) - sshParts.push(`-R ${params.remote_forward}`); - const sshCommand = sshParts.join(" "); - - const remotePort = params.remote_forward - ? params.remote_forward.split(":")[0] - : null; - const localPort = params.local_forward - ? params.local_forward.split(":")[0] - : null; - - responseText += `\n\n## SSH Port Forwarding\n\nRun this command in a terminal:\n\n\`\`\`bash\n${sshCommand}\n\`\`\`\n\nPrerequisites: [Kernel CLI](https://kernel.sh/docs/reference/cli) and [websocat](https://github.com/vi/websocat) (\`brew install websocat\` on macOS).`; - - if (remotePort) { - responseText += `\n\nThis forwards the user's local port to port ${remotePort} inside the browser VM. Once the user has the tunnel running, use execute_playwright_code to navigate the browser to http://localhost:${remotePort}`; - } - - if (localPort) { - responseText += `\n\nThis forwards port ${localPort} from the browser VM to the user's local machine. Once the user has the tunnel running, services inside the VM are accessible locally at localhost:${localPort}`; - } - - 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 textResponse(responseText); + const sshPortForwarding = buildSshPortForwardingInfo( + params, + browser.session_id, + ); + return jsonResponse({ + browser, + next_actions: browserSessionNextActions(browser.session_id), + ...(sshPortForwarding && { + ssh_port_forwarding: sshPortForwarding, + }), + }); } case "update": { if (!params.session_id) @@ -376,7 +409,10 @@ export function registerBrowserCapabilities(server: McpServer) { ); if (!browser) return errorResponse("Failed to update browser session"); - return jsonResponse(browser); + return jsonResponse({ + browser, + next_actions: browserSessionNextActions(browser.session_id), + }); } case "list": { const page = await client.browsers.list({ @@ -384,22 +420,11 @@ export function registerBrowserCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page - .getPaginatedItems() - .map((b) => ({ ...b, cdp_ws_url: undefined })); - return textResponse( - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No browsers found", - ); + return paginatedJsonResponse(page, { + mapItem: ({ cdp_ws_url: _cdpWsUrl, ...browser }) => browser, + note: 'Use action "get" with session_id for full browser details.', + emptyText: "No browsers found", + }); } case "get": { if (!params.session_id) diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 47a0ef4..b8e664f 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -181,7 +181,7 @@ export function registerComputerActionTool(server: McpServer) { y: z.number(), button: z.enum(["left", "right", "middle"]).optional(), click_type: z.enum(["down", "up", "click"]).optional(), - num_clicks: z.number().optional(), + num_clicks: z.number().int().min(1).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for click_mouse action.") @@ -197,7 +197,7 @@ export function registerComputerActionTool(server: McpServer) { type_text: z .object({ text: z.string(), - delay: z.number().optional(), + delay: z.number().int().min(0).optional(), }) .describe("Params for type_text action.") .optional(), @@ -208,7 +208,7 @@ export function registerComputerActionTool(server: McpServer) { .describe( 'X11 keysym names or combos like "Ctrl+t", "Return".', ), - duration: z.number().optional(), + duration: z.number().int().min(0).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for press_key action.") @@ -235,9 +235,9 @@ export function registerComputerActionTool(server: McpServer) { .array(z.array(z.number())) .describe("Ordered [x,y] pairs, at least 2 points."), button: z.enum(["left", "middle", "right"]).optional(), - delay: z.number().optional(), - steps_per_segment: z.number().optional(), - step_delay_ms: z.number().optional(), + delay: z.number().int().min(0).optional(), + steps_per_segment: z.number().int().min(1).optional(), + step_delay_ms: z.number().int().min(0).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for drag_mouse action.") @@ -250,7 +250,7 @@ export function registerComputerActionTool(server: McpServer) { .optional(), sleep: z .object({ - duration_ms: z.number(), + duration_ms: z.number().int().min(0), }) .describe("Params for sleep action.") .optional(), @@ -260,8 +260,8 @@ export function registerComputerActionTool(server: McpServer) { .object({ x: z.number(), y: z.number(), - width: z.number(), - height: z.number(), + width: z.number().int().min(1), + height: z.number().int().min(1), }) .optional(), }) diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index d208077..cfe9294 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -4,10 +4,15 @@ import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, - jsonResponse, + paginatedJsonResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; + +type ProfileListParams = NonNullable< + Parameters[0] +>; async function listProfiles(client: KernelClient) { const profiles: Awaited>[] = []; @@ -49,7 +54,7 @@ export function registerProfileCapabilities(server: McpServer) { 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 when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, and "delete" only when a profile should be removed.', { action: z .enum(["setup", "list", "delete"]) @@ -68,6 +73,11 @@ export function registerProfileCapabilities(server: McpServer) { .boolean() .describe("(setup) If true, update existing profile. Default false.") .optional(), + query: z + .string() + .describe("(list) Search profiles by name or ID.") + .optional(), + ...paginationParams, }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -120,12 +130,15 @@ export function registerProfileCapabilities(server: McpServer) { ); } case "list": { - const profiles = await listProfiles(client); - return profiles.length > 0 - ? jsonResponse(profiles) - : textResponse( - "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 }), + } satisfies ProfileListParams); + return paginatedJsonResponse(page, { + emptyText: + "No profiles found. Use manage_profiles with action 'setup' to create one.", + }); } case "delete": { if (params.profile_name && params.profile_id) { diff --git a/src/lib/mcp/tools/shell.ts b/src/lib/mcp/tools/shell.ts index 21d88e2..b8e9370 100644 --- a/src/lib/mcp/tools/shell.ts +++ b/src/lib/mcp/tools/shell.ts @@ -19,6 +19,8 @@ export function registerShellTool(server: McpServer) { cwd: z.string().describe("Working directory (absolute path).").optional(), timeout_sec: z .number() + .int() + .min(1) .describe("Max execution time in seconds.") .optional(), as_root: z.boolean().describe("Run with root privileges.").optional(), From ca5d4556bb7cae65698f295b3141c503527e9301 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:36:49 -0400 Subject: [PATCH 18/20] Fix browser pool review findings --- src/lib/mcp/tools/browser-pools.ts | 10 ++++++---- src/lib/mcp/tools/profiles.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 413b9eb..bb390d8 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -54,6 +54,10 @@ function buildPoolConfigParams( ): BrowserConfigResult { const browserConfig = buildBrowserCreateConfig(params); if (!browserConfig.ok) return browserConfig; + const chromePolicy = + params.chrome_policy && Object.keys(params.chrome_policy).length > 0 + ? params.chrome_policy + : undefined; return { ok: true, @@ -69,9 +73,7 @@ function buildPoolConfigParams( ...(params.fill_rate_per_minute !== undefined && { fill_rate_per_minute: params.fill_rate_per_minute, }), - ...(params.chrome_policy !== undefined && { - chrome_policy: params.chrome_policy, - }), + ...(chromePolicy && { chrome_policy: chromePolicy }), ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), ...browserConfig.value, }, @@ -389,7 +391,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }); } case "list": { - const pools = await client.browserPools.list(); + const pools = (await client.browserPools.list()) ?? []; return pools.length > 0 ? jsonResponse({ items: pools.map(summarizeBrowserPool), diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index cfe9294..d0de792 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -13,15 +13,30 @@ import { paginationParams } from "@/lib/mcp/schemas"; type ProfileListParams = NonNullable< Parameters[0] >; +type Profile = Awaited>; -async function listProfiles(client: KernelClient) { - const profiles: Awaited>[] = []; - for await (const profile of client.profiles.list()) { +async function listProfiles(client: KernelClient, query?: ProfileListParams) { + const profiles: Profile[] = []; + for await (const profile of client.profiles.list(query)) { profiles.push(profile); } return profiles; } +function fullProfileListResponse(profiles: Profile[]) { + return paginatedJsonResponse( + { + getPaginatedItems: () => profiles, + has_more: false, + next_offset: null, + }, + { + emptyText: + "No profiles found. Use manage_profiles with action 'setup' to create one.", + }, + ); +} + export function registerProfileCapabilities(server: McpServer) { server.resource("profiles", "profiles://", async (uri, extra) => { if (!extra.authInfo) { @@ -130,6 +145,14 @@ export function registerProfileCapabilities(server: McpServer) { ); } case "list": { + if (params.limit === undefined && params.offset === undefined) { + const profiles = await listProfiles( + client, + params.query ? { query: params.query } : undefined, + ); + return fullProfileListResponse(profiles); + } + const page = await client.profiles.list({ ...(params.query && { query: params.query }), ...(params.limit !== undefined && { limit: params.limit }), From 8dd29c8ee2a662c3dcfb8878ec882395f33be3f8 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:57:46 -0400 Subject: [PATCH 19/20] Fix PR 112 review follow-ups Resolve the active Cursor profile pagination bug by preserving structured JSON for explicit empty pages, restore the cleaner computer_action structure from main with clipboard support, derive browser config payload types from the SDK, and remove stale dead types. --- src/lib/mcp/browser-config.ts | 73 +++-- src/lib/mcp/responses.ts | 25 +- src/lib/mcp/tools/browser-pools.ts | 10 - src/lib/mcp/tools/browsers.ts | 8 +- src/lib/mcp/tools/computer-action.ts | 467 +++++++++++++-------------- src/lib/mcp/tools/profiles.ts | 23 +- 6 files changed, 300 insertions(+), 306 deletions(-) diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts index 797e5ac..35fe7aa 100644 --- a/src/lib/mcp/browser-config.ts +++ b/src/lib/mcp/browser-config.ts @@ -1,3 +1,16 @@ +import type { KernelClient } from "@/lib/mcp/kernel-client"; + +type BrowserCreateParams = NonNullable< + Parameters[0] +>; +type BrowserUpdateParams = Parameters[1]; +type BrowserPoolCreateParams = Parameters< + KernelClient["browserPools"]["create"] +>[0]; +type BrowserPoolUpdateParams = Parameters< + KernelClient["browserPools"]["update"] +>[1]; + export type BrowserProfileParams = { profile_name?: string; profile_id?: string; @@ -28,36 +41,36 @@ export type BrowserCreateConfigParams = BrowserProfileParams & export type BrowserUpdateConfigParams = BrowserProfileParams & BrowserViewportUpdateParams; -type BrowserProfileConfig = { - id?: string; - name?: string; - save_changes?: boolean; -}; - -type BrowserExtensionConfig = Array<{ - id?: string; - name?: string; -}>; - -type BrowserViewportConfig = { - width: number; - height: number; - refresh_rate?: number; -}; - -type BrowserViewportUpdateConfig = BrowserViewportConfig & { force?: boolean }; - -export type BrowserCreateConfig = { - profile?: BrowserProfileConfig; - extensions?: BrowserExtensionConfig; - viewport?: BrowserViewportConfig; - start_url?: string; -}; - -export type BrowserUpdateConfig = { - profile?: BrowserProfileConfig; - viewport?: BrowserViewportUpdateConfig; -}; +type BrowserProfileConfig = NonNullable< + | BrowserCreateParams["profile"] + | BrowserUpdateParams["profile"] + | BrowserPoolCreateParams["profile"] + | BrowserPoolUpdateParams["profile"] +>; + +type BrowserExtensionConfig = NonNullable< + | BrowserCreateParams["extensions"] + | BrowserPoolCreateParams["extensions"] + | BrowserPoolUpdateParams["extensions"] +>; + +type BrowserViewportConfig = NonNullable< + | BrowserCreateParams["viewport"] + | BrowserPoolCreateParams["viewport"] + | BrowserPoolUpdateParams["viewport"] +>; + +type BrowserViewportUpdateConfig = NonNullable; + +export type BrowserCreateConfig = Pick< + BrowserCreateParams, + "profile" | "extensions" | "viewport" | "start_url" +>; + +export type BrowserUpdateConfig = Pick< + BrowserUpdateParams, + "profile" | "viewport" +>; export type BrowserConfigResult = | { ok: true; value: T } diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index c6f3475..e0079df 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -10,6 +10,11 @@ type PaginatedJsonResponseOptions = { emptyText?: string; }; +type ItemsJsonResponseOptions = PaginatedJsonResponseOptions & { + has_more?: boolean | null; + next_offset?: number | null; +}; + export function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } @@ -18,20 +23,30 @@ export function jsonResponse(value: unknown) { return textResponse(JSON.stringify(value, null, 2) ?? String(value)); } -export function paginatedJsonResponse( - page: PaginatedPage, - options: PaginatedJsonResponseOptions = {}, +export function itemsJsonResponse( + items: T[], + options: ItemsJsonResponseOptions = {}, ) { - const items = page.getPaginatedItems(); if (items.length === 0 && options.emptyText) { return textResponse(options.emptyText); } return jsonResponse({ items: options.mapItem ? items.map(options.mapItem) : items, + has_more: options.has_more, + next_offset: options.next_offset, + ...(options.note && { note: options.note }), + }); +} + +export function paginatedJsonResponse( + page: PaginatedPage, + options: PaginatedJsonResponseOptions = {}, +) { + return itemsJsonResponse(page.getPaginatedItems(), { + ...options, has_more: page.has_more, next_offset: page.next_offset, - ...(options.note && { note: options.note }), }); } diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index bb390d8..6128538 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -27,16 +27,6 @@ type BrowserPoolAcquireResponse = Awaited< ReturnType >; -type BrowserPoolAction = - | "create" - | "update" - | "list" - | "get" - | "delete" - | "flush" - | "acquire" - | "release"; - type PoolConfigParams = BrowserCreateConfigParams & { size?: number; name?: string; diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index c95f26c..a2ac686 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -29,8 +29,6 @@ type TelemetryParams = { telemetry_interaction?: boolean; }; -type BrowserAction = "create" | "update" | "list" | "get" | "delete"; - const telemetryCategories = [ ["telemetry_console", "console"], ["telemetry_network", "network"], @@ -342,8 +340,12 @@ export function registerBrowserCapabilities(server: McpServer) { createParams.timeout_seconds = params.timeout_seconds; if (params.kiosk_mode !== undefined) createParams.kiosk_mode = params.kiosk_mode; - if (params.chrome_policy) + if ( + params.chrome_policy && + Object.keys(params.chrome_policy).length > 0 + ) { createParams.chrome_policy = params.chrome_policy; + } if (params.proxy_id) createParams.proxy_id = params.proxy_id; const browserConfig = buildBrowserCreateConfig(params); if (!browserConfig.ok) return errorResponse(browserConfig.error); diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index b8e664f..b94ff4b 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -3,152 +3,249 @@ import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { errorResponse, + jsonResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; +type ComputerClient = KernelClient["browsers"]["computer"]; type ComputerBatchAction = Parameters< - KernelClient["browsers"]["computer"]["batch"] + ComputerClient["batch"] >[1]["actions"][number]; -type ComputerToolAction = Partial> & { - type: - | ComputerBatchAction["type"] - | "screenshot" - | "get_mouse_position" - | "read_clipboard" - | "write_clipboard"; - screenshot?: { - region?: { - x: number; - y: number; - width: number; - height: number; - }; - }; - write_clipboard?: { text: string }; -}; +const computerActionSchema = z.object({ + type: z + .enum([ + "click_mouse", + "move_mouse", + "type_text", + "press_key", + "scroll", + "drag_mouse", + "set_cursor", + "sleep", + "write_clipboard", + "read_clipboard", + "screenshot", + "get_mouse_position", + ]) + .describe("Action type."), + click_mouse: z + .object({ + x: z.number(), + y: z.number(), + button: z.enum(["left", "right", "middle"]).optional(), + click_type: z.enum(["down", "up", "click"]).optional(), + num_clicks: z.number().int().min(1).optional(), + hold_keys: z.array(z.string()).optional(), + }) + .describe("Params for click_mouse action.") + .optional(), + move_mouse: z + .object({ + x: z.number(), + y: z.number(), + hold_keys: z.array(z.string()).optional(), + }) + .describe("Params for move_mouse action.") + .optional(), + type_text: z + .object({ + text: z.string(), + delay: z.number().int().min(0).optional(), + }) + .describe("Params for type_text action.") + .optional(), + press_key: z + .object({ + keys: z + .array(z.string()) + .describe('X11 keysym names or combos like "Ctrl+t", "Return".'), + duration: z.number().int().min(0).optional(), + hold_keys: z.array(z.string()).optional(), + }) + .describe("Params for press_key action.") + .optional(), + scroll: z + .object({ + x: z.number(), + y: z.number(), + delta_x: z.number().describe("Positive=right, negative=left.").optional(), + delta_y: z.number().describe("Positive=down, negative=up.").optional(), + hold_keys: z.array(z.string()).optional(), + }) + .describe("Params for scroll action.") + .optional(), + drag_mouse: z + .object({ + path: z + .array(z.array(z.number())) + .describe("Ordered [x,y] pairs, at least 2 points."), + button: z.enum(["left", "middle", "right"]).optional(), + delay: z.number().int().min(0).optional(), + steps_per_segment: z.number().int().min(1).optional(), + step_delay_ms: z.number().int().min(0).optional(), + hold_keys: z.array(z.string()).optional(), + }) + .describe("Params for drag_mouse action.") + .optional(), + set_cursor: z + .object({ + hidden: z.boolean(), + }) + .describe("Params for set_cursor action.") + .optional(), + sleep: z + .object({ + duration_ms: z.number().int().min(0), + }) + .describe("Params for sleep action.") + .optional(), + write_clipboard: z + .object({ + text: z.string(), + }) + .describe("Params for write_clipboard action.") + .optional(), + screenshot: z + .object({ + region: z + .object({ + x: z.number(), + y: z.number(), + width: z.number().int().min(1), + height: z.number().int().min(1), + }) + .optional(), + }) + .describe( + "Params for screenshot action. Omit or pass {} for full-page screenshot.", + ) + .optional(), +}); -type ComputerBatchToolAction = ComputerToolAction & { - type: ComputerBatchAction["type"]; -}; - -type ComputerResultAction = ComputerToolAction & { +type ComputerActionParams = z.infer; +type TerminalAction = ComputerActionParams & { type: "screenshot" | "get_mouse_position" | "read_clipboard"; }; - -type ComputerExecutionResult = +type WriteClipboardAction = ComputerActionParams & { type: "write_clipboard" }; +type PrefixExecutionResult = | { ok: true; executedActionCount: number } | { ok: false; error: string }; -function isResultAction( - action: ComputerToolAction, -): action is ComputerResultAction { +function isTerminalAction( + action: ComputerActionParams | undefined, +): action is TerminalAction { return ( - action.type === "screenshot" || - action.type === "get_mouse_position" || - action.type === "read_clipboard" + action?.type === "screenshot" || + action?.type === "get_mouse_position" || + action?.type === "read_clipboard" ); } -function isBatchAction( - action: ComputerToolAction, -): action is ComputerBatchToolAction { - return action.type !== "write_clipboard" && !isResultAction(action); +function isWriteClipboardAction( + action: ComputerActionParams, +): action is WriteClipboardAction { + return action.type === "write_clipboard"; } -function toBatchAction(action: ComputerBatchToolAction): ComputerBatchAction { +function isBatchAction( + action: ComputerActionParams, +): action is ComputerActionParams & ComputerBatchAction { switch (action.type) { case "click_mouse": - return { type: action.type, click_mouse: action.click_mouse }; case "move_mouse": - return { type: action.type, move_mouse: action.move_mouse }; case "type_text": - return { type: action.type, type_text: action.type_text }; case "press_key": - return { type: action.type, press_key: action.press_key }; case "scroll": - return { type: action.type, scroll: action.scroll }; case "drag_mouse": - return { type: action.type, drag_mouse: action.drag_mouse }; case "set_cursor": - return { type: action.type, set_cursor: action.set_cursor }; case "sleep": - return { type: action.type, sleep: action.sleep }; + return true; + default: + return false; } } -function splitTrailingResultAction(actions: ComputerToolAction[]): { - leadingActions: ComputerToolAction[]; - resultAction?: ComputerResultAction; -} { - const lastAction = actions[actions.length - 1]; - const resultAction = isResultAction(lastAction) ? lastAction : undefined; - return { - leadingActions: resultAction ? actions.slice(0, -1) : actions, - resultAction, - }; +function terminalActionPlacementError(actions: ComputerActionParams[]) { + for (let i = 0; i < actions.length - 1; i++) { + if (isTerminalAction(actions[i])) { + return `Error: ${actions[i].type} must be the last action in the sequence.`; + } + } } -async function executeLeadingComputerActions( - client: KernelClient, +function executionSummaryContent(executedActionCount: number) { + if (executedActionCount === 0) return []; + + return [ + { + type: "text" as const, + text: `Executed ${executedActionCount} action(s).`, + }, + ]; +} + +async function flushBatchActions( + computer: ComputerClient, sessionId: string, - actions: ComputerToolAction[], -): Promise { - let executedActionCount = 0; - let batchActions: ComputerBatchAction[] = []; + batchActions: ComputerBatchAction[], +) { + if (batchActions.length === 0) return 0; - async function flushBatchActions() { - if (batchActions.length === 0) return; - await client.browsers.computer.batch(sessionId, { actions: batchActions }); - executedActionCount += batchActions.length; - batchActions = []; - } + const actions = [...batchActions]; + await computer.batch(sessionId, { actions }); + batchActions.length = 0; + return actions.length; +} - for (const action of actions) { - if (isResultAction(action)) { - return { - ok: false, - error: `Error: ${action.type} must be the last action in the sequence.`, - }; - } +async function executeComputerActionPrefix( + computer: ComputerClient, + sessionId: string, + actions: ComputerActionParams[], +): Promise { + const batchActions: ComputerBatchAction[] = []; + let executedActionCount = 0; - if (action.type === "write_clipboard") { - await flushBatchActions(); - if (!action.write_clipboard) { + for (const action of actions) { + if (isWriteClipboardAction(action)) { + const text = action.write_clipboard?.text; + if (text === undefined) { return { ok: false, - error: - "Error: write_clipboard params are required for write_clipboard action.", + error: "Error: write_clipboard action requires write_clipboard.text.", }; } - await client.browsers.computer.writeClipboard(sessionId, { - text: action.write_clipboard.text, - }); + + executedActionCount += await flushBatchActions( + computer, + sessionId, + batchActions, + ); + await computer.writeClipboard(sessionId, { text }); executedActionCount += 1; continue; } if (isBatchAction(action)) { - batchActions.push(toBatchAction(action)); + batchActions.push(action); + continue; } + + return { + ok: false, + error: `Error: ${action.type} must be the last action in the sequence.`, + }; } - await flushBatchActions(); + executedActionCount += await flushBatchActions( + computer, + sessionId, + batchActions, + ); return { ok: true, executedActionCount }; } -function actionCountPrefix(executedActionCount: number, suffix: string) { - return executedActionCount > 0 - ? [ - { - type: "text" as const, - text: `Executed ${executedActionCount} action(s)${suffix}`, - }, - ] - : []; -} - export function registerComputerActionTool(server: McpServer) { // computer_action -- Execute one or more computer actions on a browser session server.tool( @@ -157,126 +254,7 @@ export function registerComputerActionTool(server: McpServer) { { session_id: z.string().describe("Browser session ID."), actions: z - .array( - z.object({ - type: z - .enum([ - "click_mouse", - "move_mouse", - "type_text", - "press_key", - "scroll", - "drag_mouse", - "set_cursor", - "sleep", - "write_clipboard", - "read_clipboard", - "screenshot", - "get_mouse_position", - ]) - .describe("Action type."), - click_mouse: z - .object({ - x: z.number(), - y: z.number(), - button: z.enum(["left", "right", "middle"]).optional(), - click_type: z.enum(["down", "up", "click"]).optional(), - num_clicks: z.number().int().min(1).optional(), - hold_keys: z.array(z.string()).optional(), - }) - .describe("Params for click_mouse action.") - .optional(), - move_mouse: z - .object({ - x: z.number(), - y: z.number(), - hold_keys: z.array(z.string()).optional(), - }) - .describe("Params for move_mouse action.") - .optional(), - type_text: z - .object({ - text: z.string(), - delay: z.number().int().min(0).optional(), - }) - .describe("Params for type_text action.") - .optional(), - press_key: z - .object({ - keys: z - .array(z.string()) - .describe( - 'X11 keysym names or combos like "Ctrl+t", "Return".', - ), - duration: z.number().int().min(0).optional(), - hold_keys: z.array(z.string()).optional(), - }) - .describe("Params for press_key action.") - .optional(), - scroll: z - .object({ - x: z.number(), - y: z.number(), - delta_x: z - .number() - .describe("Positive=right, negative=left.") - .optional(), - delta_y: z - .number() - .describe("Positive=down, negative=up.") - .optional(), - hold_keys: z.array(z.string()).optional(), - }) - .describe("Params for scroll action.") - .optional(), - drag_mouse: z - .object({ - path: z - .array(z.array(z.number())) - .describe("Ordered [x,y] pairs, at least 2 points."), - button: z.enum(["left", "middle", "right"]).optional(), - delay: z.number().int().min(0).optional(), - steps_per_segment: z.number().int().min(1).optional(), - step_delay_ms: z.number().int().min(0).optional(), - hold_keys: z.array(z.string()).optional(), - }) - .describe("Params for drag_mouse action.") - .optional(), - set_cursor: z - .object({ - hidden: z.boolean(), - }) - .describe("Params for set_cursor action.") - .optional(), - sleep: z - .object({ - duration_ms: z.number().int().min(0), - }) - .describe("Params for sleep action.") - .optional(), - screenshot: z - .object({ - region: z - .object({ - x: z.number(), - y: z.number(), - width: z.number().int().min(1), - height: z.number().int().min(1), - }) - .optional(), - }) - .describe( - "Params for screenshot action. Omit or pass {} for full-page screenshot.", - ) - .optional(), - write_clipboard: z - .object({ - text: z.string(), - }) - .describe("Params for write_clipboard action.") - .optional(), - }), - ) + .array(computerActionSchema) .min(1) .describe( "Ordered list of actions. Use one action for simple operations or multiple for batched sequences.", @@ -287,18 +265,24 @@ export function registerComputerActionTool(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - const toolActions: ComputerToolAction[] = actions; - const { leadingActions, resultAction } = - splitTrailingResultAction(toolActions); - const execution = await executeLeadingComputerActions( - client, + const placementError = terminalActionPlacementError(actions); + if (placementError) return errorResponse(placementError); + + const terminalAction = isTerminalAction(actions[actions.length - 1]) + ? actions[actions.length - 1] + : undefined; + const prefixActions = terminalAction ? actions.slice(0, -1) : actions; + const prefixResult = await executeComputerActionPrefix( + client.browsers.computer, session_id, - leadingActions, + prefixActions, ); - if (!execution.ok) return errorResponse(execution.error); + if (!prefixResult.ok) return errorResponse(prefixResult.error); + + const { executedActionCount } = prefixResult; - if (resultAction?.type === "screenshot") { - const screenshotParams = resultAction.screenshot; + if (terminalAction?.type === "screenshot") { + const screenshotParams = terminalAction.screenshot; const screenshotOpts = screenshotParams?.region ? { region: screenshotParams.region } : undefined; @@ -316,12 +300,12 @@ export function registerComputerActionTool(server: McpServer) { | { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = []; - content.push( - ...actionCountPrefix( - execution.executedActionCount, - ", then captured screenshot.", - ), - ); + if (executedActionCount > 0) { + content.push({ + type: "text", + text: `Executed ${executedActionCount} action(s), then captured screenshot.`, + }); + } content.push({ type: "text", text: viewport @@ -336,33 +320,30 @@ export function registerComputerActionTool(server: McpServer) { return { content }; } - if (resultAction?.type === "read_clipboard") { - const clipboard = - await client.browsers.computer.readClipboard(session_id); + if (terminalAction?.type === "get_mouse_position") { + const position = + await client.browsers.computer.getMousePosition(session_id); return { content: [ - ...actionCountPrefix( - execution.executedActionCount, - ", then read clipboard.", - ), - { type: "text", text: JSON.stringify(clipboard, null, 2) }, + ...executionSummaryContent(executedActionCount), + ...jsonResponse(position).content, ], }; } - if (resultAction?.type === "get_mouse_position") { - const position = - await client.browsers.computer.getMousePosition(session_id); + if (terminalAction?.type === "read_clipboard") { + const response = + await client.browsers.computer.readClipboard(session_id); return { content: [ - ...actionCountPrefix(execution.executedActionCount, "."), - { type: "text", text: JSON.stringify(position, null, 2) }, + ...executionSummaryContent(executedActionCount), + ...jsonResponse(response).content, ], }; } return textResponse( - `Executed ${execution.executedActionCount} action(s) successfully`, + `Executed ${executedActionCount} action(s) successfully`, ); } catch (error) { return toolErrorResponse("computer_action", "actions", error); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index d0de792..3ef18f4 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -4,6 +4,7 @@ import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, + itemsJsonResponse, paginatedJsonResponse, textResponse, toolErrorResponse, @@ -24,17 +25,12 @@ async function listProfiles(client: KernelClient, query?: ProfileListParams) { } function fullProfileListResponse(profiles: Profile[]) { - return paginatedJsonResponse( - { - getPaginatedItems: () => profiles, - has_more: false, - next_offset: null, - }, - { - emptyText: - "No profiles found. Use manage_profiles with action 'setup' to create one.", - }, - ); + return itemsJsonResponse(profiles, { + has_more: false, + next_offset: null, + emptyText: + "No profiles found. Use manage_profiles with action 'setup' to create one.", + }); } export function registerProfileCapabilities(server: McpServer) { @@ -158,10 +154,7 @@ export function registerProfileCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), } satisfies ProfileListParams); - return paginatedJsonResponse(page, { - emptyText: - "No profiles found. Use manage_profiles with action 'setup' to create one.", - }); + return paginatedJsonResponse(page); } case "delete": { if (params.profile_name && params.profile_id) { From f438766fee032002a8e11e024bc73540f62299e1 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:05:00 -0400 Subject: [PATCH 20/20] Keep paginated MCP responses structured Remove emptyText from paginated response options so explicit empty pages always return items, has_more, and next_offset. Keep empty guidance only on non-paginated item responses and update app/browser list callers accordingly. --- src/lib/mcp/responses.ts | 12 ++++++++---- src/lib/mcp/tools/apps.ts | 8 ++------ src/lib/mcp/tools/browsers.ts | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index e0079df..4d99a18 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -4,13 +4,15 @@ type PaginatedPage = { next_offset?: number | null; }; -type PaginatedJsonResponseOptions = { +type JsonItemsResponseOptions = { mapItem?: (item: T) => U; note?: string; - emptyText?: string; }; -type ItemsJsonResponseOptions = PaginatedJsonResponseOptions & { +type PaginatedJsonResponseOptions = JsonItemsResponseOptions; + +type ItemsJsonResponseOptions = JsonItemsResponseOptions & { + emptyText?: string; has_more?: boolean | null; next_offset?: number | null; }; @@ -43,8 +45,10 @@ export function paginatedJsonResponse( page: PaginatedPage, options: PaginatedJsonResponseOptions = {}, ) { + const { mapItem, note } = options; return itemsJsonResponse(page.getPaginatedItems(), { - ...options, + mapItem, + note, has_more: page.has_more, next_offset: page.next_offset, }); diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index c2f40ee..3b3cbe6 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -100,9 +100,7 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - return paginatedJsonResponse(page, { - emptyText: "No apps found", - }); + return paginatedJsonResponse(page); } case "invoke": { if (!params.app_name || !params.action_name) { @@ -165,9 +163,7 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - return paginatedJsonResponse(page, { - emptyText: "No deployments found", - }); + return paginatedJsonResponse(page); } case "get_invocation": { if (!params.invocation_id) diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index a2ac686..463bf23 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -425,7 +425,6 @@ export function registerBrowserCapabilities(server: McpServer) { return paginatedJsonResponse(page, { mapItem: ({ cdp_ws_url: _cdpWsUrl, ...browser }) => browser, note: 'Use action "get" with session_id for full browser details.', - emptyText: "No browsers found", }); } case "get": {