From 51087f5cad2ea281e00db224b18703be0d145a23 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/14] 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 9f2a3a5fa27b7d2d2297e934dd9d150c04a4ea36 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/14] 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 de97ef492c99a1c3eb90b5913d8422e68787dbec 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/14] 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 a25eecf2355756c708159b89040cd09b7f155140 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/14] 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 176493274469be8bb0105287a620ee5980216caa 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/14] 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 161148c..22cf0d2 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -3,6 +3,7 @@ import { registerKernelPrompts } from "@/lib/mcp/prompts"; import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys"; import { registerAppCapabilities } from "@/lib/mcp/tools/apps"; import { registerBrowserPoolCapabilities } from "@/lib/mcp/tools/browser-pools"; +import { registerBrowserUtilityTools } from "@/lib/mcp/tools/browser-utilities"; import { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; import { registerComputerActionTool } from "@/lib/mcp/tools/computer-action"; import { registerDocsTools } from "@/lib/mcp/tools/docs"; @@ -21,6 +22,7 @@ export function registerMcpCapabilities(server: McpServer) { registerProjectCapabilities(server); registerAPIKeyCapabilities(server); registerBrowserPoolCapabilities(server); + registerBrowserUtilityTools(server); registerProxyTools(server); registerExtensionTools(server); registerAppCapabilities(server); diff --git a/src/lib/mcp/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 a5909ffa6136911af0d2ffe59861d986874feab4 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/14] 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 ea9503f6eec47b294f5ef629fd36912122dbe905 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/14] 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 692e1569e1e204413b8706ee624e7a13e4cb611a 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/14] 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 55d4fca6ddf486bd3f999f8359ca83ca30e7f91b 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/14] Share MCP browser config and response helpers --- src/lib/mcp/browser-config.ts | 102 ++++++++++ src/lib/mcp/responses.ts | 11 ++ src/lib/mcp/tools/api-keys.ts | 94 ++-------- src/lib/mcp/tools/browser-pools.ts | 148 +++------------ src/lib/mcp/tools/browser-utilities.ts | 23 +-- src/lib/mcp/tools/browsers.ts | 248 +++++-------------------- src/lib/mcp/tools/projects.ts | 99 ++-------- 7 files changed, 227 insertions(+), 498 deletions(-) create mode 100644 src/lib/mcp/browser-config.ts create mode 100644 src/lib/mcp/responses.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/responses.ts b/src/lib/mcp/responses.ts new file mode 100644 index 0000000..81a073e --- /dev/null +++ b/src/lib/mcp/responses.ts @@ -0,0 +1,11 @@ +export function textResponse(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +export function jsonResponse(value: unknown) { + return textResponse(JSON.stringify(value, null, 2)); +} + +export function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/lib/mcp/tools/api-keys.ts b/src/lib/mcp/tools/api-keys.ts index ec7bd26..4e6c4af 100644 --- a/src/lib/mcp/tools/api-keys.ts +++ b/src/lib/mcp/tools/api-keys.ts @@ -1,6 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; export function registerAPIKeyCapabilities(server: McpServer) { // manage_api_keys -- Create, list, get, update, and delete Kernel API keys @@ -44,11 +45,7 @@ export function registerAPIKeyCapabilities(server: McpServer) { switch (params.action) { case "create": { if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for create." }, - ], - }; + return textResponse("Error: name is required for create."); } const createParams: Parameters[0] = { name: params.name, @@ -60,11 +57,7 @@ export function registerAPIKeyCapabilities(server: McpServer) { createParams.days_to_expire = params.days_to_expire; } const apiKey = await client.apiKeys.create(createParams); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "list": { const page = await client.apiKeys.list({ @@ -72,94 +65,43 @@ export function registerAPIKeyCapabilities(server: McpServer) { ...(params.offset !== undefined && { offset: params.offset }), }); const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ), - }, - ], - }; + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); } case "get": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for get.", - }, - ], - }; + return textResponse("Error: api_key_id is required for get."); } const apiKey = await client.apiKeys.retrieve(params.api_key_id); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "update": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for update.", - }, - ], - }; + return textResponse("Error: api_key_id is required for update."); } if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for update." }, - ], - }; + return textResponse("Error: name is required for update."); } const apiKey = await client.apiKeys.update(params.api_key_id, { name: params.name, }); - return { - content: [ - { type: "text", text: JSON.stringify(apiKey, null, 2) }, - ], - }; + return jsonResponse(apiKey); } case "delete": { if (!params.api_key_id) { - return { - content: [ - { - type: "text", - text: "Error: api_key_id is required for delete.", - }, - ], - }; + return textResponse("Error: api_key_id is required for delete."); } await client.apiKeys.delete(params.api_key_id); - return { - content: [{ type: "text", text: "API key deleted successfully" }], - }; + return textResponse("API key deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_api_keys (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_api_keys (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/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)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index 1ea8c71..b2da763 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -1,6 +1,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; export function registerProjectCapabilities(server: McpServer) { // manage_projects -- Create, list, get, update, and delete organization projects @@ -37,18 +38,10 @@ export function registerProjectCapabilities(server: McpServer) { switch (params.action) { case "create": { if (!params.name) { - return { - content: [ - { type: "text", text: "Error: name is required for create." }, - ], - }; + return textResponse("Error: name is required for create."); } const project = await client.projects.create({ name: params.name }); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "list": { const page = await client.projects.list({ @@ -57,61 +50,27 @@ export function registerProjectCapabilities(server: McpServer) { ...(params.offset !== undefined && { offset: params.offset }), }); const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ), - }, - ], - }; + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); } case "get": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for get.", - }, - ], - }; + return textResponse("Error: project_id is required for get."); } const project = await client.projects.retrieve(params.project_id); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "update": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for update.", - }, - ], - }; + return textResponse("Error: project_id is required for update."); } if (!params.name && !params.status) { - return { - content: [ - { - type: "text", - text: "Error: name or status is required for update.", - }, - ], - }; + return textResponse( + "Error: name or status is required for update.", + ); } const updateParams: Parameters[1] = {}; @@ -121,38 +80,20 @@ export function registerProjectCapabilities(server: McpServer) { params.project_id, updateParams, ); - return { - content: [ - { type: "text", text: JSON.stringify(project, null, 2) }, - ], - }; + return jsonResponse(project); } case "delete": { if (!params.project_id) { - return { - content: [ - { - type: "text", - text: "Error: project_id is required for delete.", - }, - ], - }; + return textResponse("Error: project_id is required for delete."); } await client.projects.delete(params.project_id); - return { - content: [{ type: "text", text: "Project deleted successfully" }], - }; + return textResponse("Project deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_projects (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_projects (${params.action}): ${errorMessage(error)}`, + ); } }, ); From 5b1841ae88d01900ad737e26bb4d5e6d0eb846ce 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/14] 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 c1074ec4fb73430f45b8f3c7b6cfe36368d7530e 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/14] 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 7f34311..b4aa003 100644 --- a/README.md +++ b/README.md @@ -277,10 +277,14 @@ Each Kernel feature has a single `manage_*` tool with an `action` parameter, kee ## Resources -- `browsers://` - Access browser sessions (list all or get specific session) -- `browser_pools://` - Access browser pools (list all or get specific pool) -- `profiles://` - Access browser profiles (list all or get specific profile) -- `apps://` - Access deployed apps (list all or get specific app) +- `browsers://` - List browser sessions +- `browser-pools://` - List browser pools +- `profiles://` - List browser profiles +- `apps://` - List deployed apps +- `browsers://{session_id}` - Access one browser session +- `browser-pools://{id_or_name}` - Access one browser pool +- `profiles://{profile_name}` - Access one browser profile +- `apps://{app_name}` - Access one deployed app ## Prompts diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts 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 c6e642767b0605e4f406321da2eb4a10d02d3a18 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/14] 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 0f021bbc38c7d0b5208c4d2a3c8f37c73e21f561 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:04:41 -0400 Subject: [PATCH 13/14] Add admin cleanup MCP passthroughs Expose the remaining small SDK passthroughs for project limits, deployment deletion/version filters, proxy checks, and paginated profile lookup while keeping the handlers on shared response helpers for clearer agent output. --- README.md | 15 ++- src/lib/mcp/tools/apps.ts | 175 +++++++++++--------------------- src/lib/mcp/tools/extensions.ts | 36 ++----- src/lib/mcp/tools/profiles.ts | 168 ++++++++++++++---------------- src/lib/mcp/tools/projects.ts | 81 ++++++++++++++- src/lib/mcp/tools/proxies.ts | 115 +++++++++++---------- 6 files changed, 292 insertions(+), 298 deletions(-) diff --git a/README.md b/README.md index b4aa003..6a2ef94 100644 --- a/README.md +++ b/README.md @@ -255,21 +255,26 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (10 total) +## Tools (15 total) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Four standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Seven standalone tools handle high-frequency workflows. ### manage\_\* tools - `manage_browsers` - Create, list, get, and delete browser sessions. Supports headless/stealth modes, profiles, proxies, viewports, extensions, and SSH tunneling. -- `manage_profiles` - Setup (with guided live browser session), list, and delete browser profiles for persisting cookies and logins. +- `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. +- `manage_projects` - Create, list, get, update, and delete organization projects. Inspect and update per-project resource limits. +- `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. - `manage_browser_pools` - Create, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools. -- `manage_proxies` - Create, list, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). +- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom). - `manage_extensions` - List and delete uploaded browser extensions. -- `manage_apps` - List apps, invoke actions, get/list deployments, and get invocation results. +- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. ### Standalone tools +- `browser_curl` - Run an HTTP request from inside a browser session's network context. +- `read_browser_clipboard` - Read clipboard text from a browser session. +- `write_browser_clipboard` - Write clipboard text to a browser session. - `computer_action` - Mouse, keyboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, screenshot). - `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 292c0b6..c90f4fc 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -2,6 +2,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"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -38,7 +39,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, deployments, and invocations. Use "list_apps" to discover apps, "invoke" to execute an app action, "get_deployment"/"list_deployments" to check deployment status, "delete_deployment" to remove a deployment, or "get_invocation" to check action results.', { action: z .enum([ @@ -46,6 +47,7 @@ export function registerAppCapabilities(server: McpServer) { "invoke", "get_deployment", "list_deployments", + "delete_deployment", "get_invocation", ]) .describe("Operation to perform."), @@ -58,9 +60,10 @@ export function registerAppCapabilities(server: McpServer) { version: z .string() .describe( - "(list_apps, invoke) App version filter. Defaults to 'latest' for invoke.", + "(list_apps, invoke, list_deployments) App version filter. Defaults to 'latest' for invoke. Deployment version filtering requires app_name.", ) .optional(), + query: z.string().describe("(list_apps) Search apps by name.").optional(), action_name: z .string() .describe("(invoke) Action to execute within the app.") @@ -71,7 +74,7 @@ export function registerAppCapabilities(server: McpServer) { .optional(), deployment_id: z .string() - .describe("(get_deployment) Deployment ID to retrieve.") + .describe("(get_deployment, delete_deployment) Deployment ID.") .optional(), invocation_id: z .string() @@ -96,40 +99,23 @@ export function registerAppCapabilities(server: McpServer) { const page = await client.apps.list({ ...(params.app_name && { app_name: params.app_name }), ...(params.version && { version: params.version }), + ...(params.query && { query: params.query }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No apps found", - }, - ], - }; + if (items.length === 0) return textResponse("No apps found"); + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); } case "invoke": { if (!params.app_name || !params.action_name) { - return { - content: [ - { - type: "text", - text: "Error: app_name and action_name are required for invoke.", - }, - ], - }; + return textResponse( + "Error: app_name and action_name are required for invoke.", + ); } const invocation = await client.invocations.create({ app_name: params.app_name, @@ -144,22 +130,11 @@ export function registerAppCapabilities(server: McpServer) { let finalInvocation = invocation; for await (const evt of stream) { if (evt.event === "error") { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "error", - invocation_id: invocation.id, - error: evt, - }, - null, - 2, - ), - }, - ], - }; + return jsonResponse({ + status: "error", + invocation_id: invocation.id, + error: evt, + }); } if (evt.event === "invocation_state") { finalInvocation = evt.invocation || finalInvocation; @@ -170,102 +145,68 @@ export function registerAppCapabilities(server: McpServer) { break; } } - return { - content: [ - { - type: "text", - text: JSON.stringify(finalInvocation, null, 2), - }, - ], - }; + return jsonResponse(finalInvocation); } case "get_deployment": { if (!params.deployment_id) - return { - content: [ - { type: "text", text: "Error: deployment_id is required." }, - ], - }; + return textResponse("Error: deployment_id is required."); const deployment = await client.deployments.retrieve( params.deployment_id, ); if (!deployment) - return { - content: [ - { - type: "text", - text: `Deployment "${params.deployment_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(deployment, null, 2) }, - ], - }; + return textResponse( + `Deployment "${params.deployment_id}" not found`, + ); + return jsonResponse(deployment); } case "list_deployments": { + if (params.version && !params.app_name) { + return textResponse( + "Error: app_name is required when filtering deployments by version.", + ); + } const page = await client.deployments.list({ ...(params.app_name && { app_name: params.app_name }), + ...(params.version && { app_version: params.version }), ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No deployments found", - }, - ], - }; + if (items.length === 0) return textResponse("No deployments found"); + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); + } + case "delete_deployment": { + if (!params.deployment_id) { + return textResponse( + "Error: deployment_id is required for delete_deployment.", + ); + } + await client.deployments.delete(params.deployment_id); + return textResponse( + `Deployment "${params.deployment_id}" deleted successfully.`, + ); } case "get_invocation": { if (!params.invocation_id) - return { - content: [ - { type: "text", text: "Error: invocation_id is required." }, - ], - }; + return textResponse("Error: invocation_id is required."); const invocation = await client.invocations.retrieve( params.invocation_id, ); if (!invocation) - return { - content: [ - { - type: "text", - text: `Invocation "${params.invocation_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(invocation, null, 2) }, - ], - }; + return textResponse( + `Invocation "${params.invocation_id}" not found`, + ); + return jsonResponse(invocation); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_apps (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_apps (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index b7d5312..4fb3f00 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -1,12 +1,13 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, textResponse } from "@/lib/mcp/responses"; export function registerExtensionTools(server: McpServer) { // manage_extensions -- List and delete browser extensions server.tool( "manage_extensions", - 'Manage browser extensions uploaded to your organization. Use "list" to see all extensions or "delete" to remove one.', + 'Manage browser extensions uploaded to Kernel. Use "list" to see all extensions available to the current project or "delete" to remove one by ID or name.', { action: z.enum(["list", "delete"]).describe("Operation to perform."), id_or_name: z @@ -22,17 +23,11 @@ export function registerExtensionTools(server: McpServer) { switch (params.action) { case "list": { const extensions = await client.extensions.list(); - return { - content: [ - { - type: "text", - text: - extensions?.length > 0 - ? JSON.stringify(extensions, null, 2) - : "No extensions found", - }, - ], - }; + return textResponse( + extensions?.length > 0 + ? JSON.stringify(extensions, null, 2) + : "No extensions found", + ); } case "delete": { if (!params.id_or_name) @@ -45,22 +40,13 @@ export function registerExtensionTools(server: McpServer) { ], }; await client.extensions.delete(params.id_or_name); - return { - content: [ - { type: "text", text: "Extension deleted successfully" }, - ], - }; + return textResponse("Extension deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_extensions (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_extensions (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index 36fef40..bc8303f 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -2,10 +2,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; -async function listProfiles(client: KernelClient) { +type ProfileListParams = NonNullable< + Parameters[0] +>; + +async function listProfiles(client: KernelClient, query?: ProfileListParams) { const profiles: Awaited>[] = []; - for await (const profile of client.profiles.list()) { + for await (const profile of client.profiles.list(query)) { profiles.push(profile); } return profiles; @@ -43,25 +48,29 @@ 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 that persist cookies, logins, and session data across browser sessions. Use action "setup" to create/update a profile with a guided live browser session, "list" to search profiles with pagination, "get" to retrieve one, or "delete" to remove one.', { action: z - .enum(["setup", "list", "delete"]) + .enum(["setup", "list", "get", "delete"]) .describe("Operation to perform."), profile_name: z .string() - .describe( - "(setup, delete) Profile name. For setup: 1-255 chars. For delete: name of profile to remove.", - ) + .describe("(setup, get, delete) Profile name. For setup: 1-255 chars.") .optional(), profile_id: z .string() - .describe("(delete) Profile ID to delete. Alternative to profile_name.") + .describe("(get, delete) Profile ID. Alternative to profile_name.") .optional(), update_existing: z .boolean() .describe("(setup) If true, update existing profile. Default false.") .optional(), + query: z + .string() + .describe("(list) Search profiles by name or ID.") + .optional(), + limit: z.number().describe("(list) Max results per page.").optional(), + offset: z.number().describe("(list) Pagination offset.").optional(), }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -71,15 +80,10 @@ export function registerProfileCapabilities(server: McpServer) { switch (params.action) { case "setup": { if (!params.profile_name) - return { - content: [ - { - type: "text", - text: "Error: profile_name is required for setup.", - }, - ], - }; - const existingProfiles = await listProfiles(client); + return textResponse("Error: profile_name is required for setup."); + const existingProfiles = await listProfiles(client, { + query: params.profile_name, + }); const existingProfile = existingProfiles?.find( (p) => p.name === params.profile_name, ); @@ -88,24 +92,16 @@ export function registerProfileCapabilities(server: McpServer) { if (existingProfile) { if (!params.update_existing) { - return { - content: [ - { - type: "text", - text: `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, - }, - ], - }; + return textResponse( + `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, + ); } profile = existingProfile; } else { profile = await client.profiles.create({ name: params.profile_name, }); - if (!profile) - return { - content: [{ type: "text", text: "Failed to create profile" }], - }; + if (!profile) return textResponse("Failed to create profile"); isNewProfile = true; } @@ -115,83 +111,69 @@ export function registerProfileCapabilities(server: McpServer) { profile: { name: params.profile_name, save_changes: true }, }); if (!browser) - return { - content: [ - { - type: "text", - text: "Failed to create browser for profile setup", - }, - ], - }; + return textResponse("Failed to create browser for profile setup"); - return { - content: [ - { - type: "text", - text: - `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + - `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + - `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + - `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, - }, - ], - }; + return textResponse( + `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + + `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + + `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + + `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, + ); } case "list": { - const profiles = await listProfiles(client); - return { - content: [ - { - type: "text", - text: - profiles?.length > 0 - ? JSON.stringify(profiles, null, 2) - : "No profiles found. Use manage_profiles with action 'setup' to create one.", - }, - ], - }; + const page = await client.profiles.list({ + ...(params.query && { query: params.query }), + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + }); + const items = page.getPaginatedItems(); + if (items.length === 0) { + return textResponse( + "No profiles found. Use manage_profiles with action 'setup' to create one.", + ); + } + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); + } + case "get": { + if (params.profile_name && params.profile_id) { + return textResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); + } + const identifier = params.profile_name || params.profile_id; + if (!identifier) { + return textResponse( + "Error: profile_name or profile_id is required for get.", + ); + } + const profile = await client.profiles.retrieve(identifier); + return jsonResponse(profile); } case "delete": { if (params.profile_name && params.profile_id) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both profile_name and profile_id.", - }, - ], - }; + return textResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); } const identifier = params.profile_name || params.profile_id; if (!identifier) - return { - content: [ - { - type: "text", - text: "Error: profile_name or profile_id is required for delete.", - }, - ], - }; + return textResponse( + "Error: profile_name or profile_id is required for delete.", + ); await client.profiles.delete(identifier); - return { - content: [ - { - type: "text", - text: `Profile "${identifier}" deleted successfully.`, - }, - ], - }; + return textResponse( + `Profile "${identifier}" deleted successfully.`, + ); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_profiles (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return textResponse( + `Error in manage_profiles (${params.action}): ${errorMessage(error)}`, + ); } }, ); diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index b2da763..efd837e 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -4,17 +4,27 @@ import { createKernelClient } from "@/lib/mcp/kernel-client"; import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; export function registerProjectCapabilities(server: McpServer) { - // manage_projects -- Create, list, get, update, and delete organization projects + // manage_projects -- Create, list, get, update, delete, and manage organization project limits server.tool( "manage_projects", - 'Manage Kernel projects for resource isolation within an organization. Use "create" to create a project, "list" to discover projects, "get" to retrieve one, "update" to rename or archive one, or "delete" to remove an empty project.', + 'Manage Kernel projects for resource isolation within an organization. Use "create" to create a project, "list" to discover projects, "get" to retrieve one, "update" to rename or archive one, "delete" to remove an empty project, "get_limits" to inspect project caps, or "update_limits" to change project caps.', { action: z - .enum(["create", "list", "get", "update", "delete"]) + .enum([ + "create", + "list", + "get", + "update", + "delete", + "get_limits", + "update_limits", + ]) .describe("Operation to perform."), project_id: z .string() - .describe("Project ID. Required for get, update, and delete.") + .describe( + "Project ID. Required for get, update, delete, get_limits, and update_limits.", + ) .optional(), name: z.string().describe("(create, update) Project name.").optional(), status: z @@ -29,6 +39,27 @@ export function registerProjectCapabilities(server: McpServer) { .optional(), limit: z.number().describe("(list) Max results per page.").optional(), offset: z.number().describe("(list) Pagination offset.").optional(), + max_concurrent_invocations: z + .number() + .nullable() + .describe( + "(update_limits) Maximum concurrent app invocations for this project. Set 0 to remove the cap.", + ) + .optional(), + max_concurrent_sessions: z + .number() + .nullable() + .describe( + "(update_limits) Maximum concurrent browser sessions for this project. Set 0 to remove the cap.", + ) + .optional(), + max_pooled_sessions: z + .number() + .nullable() + .describe( + "(update_limits) Maximum pooled sessions capacity for this project. Set 0 to remove the cap.", + ) + .optional(), }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -89,6 +120,48 @@ export function registerProjectCapabilities(server: McpServer) { await client.projects.delete(params.project_id); return textResponse("Project deleted successfully"); } + case "get_limits": { + if (!params.project_id) { + return textResponse( + "Error: project_id is required for get_limits.", + ); + } + const limits = await client.projects.limits.retrieve( + params.project_id, + ); + return jsonResponse(limits); + } + case "update_limits": { + if (!params.project_id) { + return textResponse( + "Error: project_id is required for update_limits.", + ); + } + const updateParams: Parameters< + typeof client.projects.limits.update + >[1] = {}; + if (params.max_concurrent_invocations !== undefined) { + updateParams.max_concurrent_invocations = + params.max_concurrent_invocations; + } + if (params.max_concurrent_sessions !== undefined) { + updateParams.max_concurrent_sessions = + params.max_concurrent_sessions; + } + if (params.max_pooled_sessions !== undefined) { + updateParams.max_pooled_sessions = params.max_pooled_sessions; + } + if (Object.keys(updateParams).length === 0) { + return textResponse( + "Error: at least one limit field is required for update_limits.", + ); + } + const limits = await client.projects.limits.update( + params.project_id, + updateParams, + ); + return jsonResponse(limits); + } } } catch (error) { return textResponse( diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index c41c5e3..6486246 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -1,17 +1,41 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; + +const httpUrlSchema = z + .string() + .url() + .refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }, + { message: "URL must use http or https." }, + ); export function registerProxyTools(server: McpServer) { - // manage_proxies -- Create, list, and delete proxy configurations + // manage_proxies -- Create, list, get, check, and delete proxy configurations server.tool( "manage_proxies", - 'Manage proxy configurations for routing browser traffic. Use "create" to add a proxy, "list" to see all proxies, or "delete" to remove one. Proxy quality for bot detection avoidance, best to worst: mobile > residential > ISP > datacenter.', + 'Manage proxy configurations for routing browser traffic. Use "create" to add a proxy, "list" to see all proxies, "get" to retrieve one, "check" to test connectivity (optionally against a target URL), or "delete" to remove one. Proxy quality for bot detection avoidance, best to worst: mobile > residential > ISP > datacenter.', { action: z - .enum(["create", "list", "delete"]) + .enum(["create", "list", "get", "check", "delete"]) .describe("Operation to perform."), - proxy_id: z.string().describe("(delete) Proxy ID to delete.").optional(), + proxy_id: z + .string() + .describe("(get, check, delete) Proxy ID.") + .optional(), + check_url: httpUrlSchema + .describe( + "(check) Optional HTTP(S) URL to test through the proxy instead of Kernel's default check target.", + ) + .optional(), type: z .enum(["datacenter", "isp", "residential", "mobile", "custom"]) .describe("(create) Proxy type.") @@ -56,23 +80,14 @@ export function registerProxyTools(server: McpServer) { switch (params.action) { case "create": { if (!params.type) - return { - content: [ - { type: "text", text: "Error: type is required for create." }, - ], - }; + return textResponse("Error: type is required for create."); if ( params.type === "custom" && (!params.custom_host || !params.custom_port) ) { - return { - content: [ - { - type: "text", - text: "Error: custom_host and custom_port are required for custom proxy type.", - }, - ], - }; + return textResponse( + "Error: custom_host and custom_port are required for custom proxy type.", + ); } const createParams: Parameters[0] = params.type === "custom" @@ -102,53 +117,45 @@ export function registerProxyTools(server: McpServer) { }), }; const proxy = await client.proxies.create(createParams); - if (!proxy) - return { - content: [{ type: "text", text: "Failed to create proxy" }], - }; - return { - content: [{ type: "text", text: JSON.stringify(proxy, null, 2) }], - }; + if (!proxy) return textResponse("Failed to create proxy"); + return jsonResponse(proxy); } case "list": { const proxies = await client.proxies.list(); - return { - content: [ - { - type: "text", - text: - proxies?.length > 0 - ? JSON.stringify(proxies, null, 2) - : "No proxies found", - }, - ], - }; + return textResponse( + proxies?.length > 0 + ? JSON.stringify(proxies, null, 2) + : "No proxies found", + ); + } + case "get": { + if (!params.proxy_id) { + return textResponse("Error: proxy_id is required for get."); + } + const proxy = await client.proxies.retrieve(params.proxy_id); + return jsonResponse(proxy); + } + case "check": { + if (!params.proxy_id) { + return textResponse("Error: proxy_id is required for check."); + } + const result = await client.proxies.check( + params.proxy_id, + params.check_url ? { url: params.check_url } : undefined, + ); + return jsonResponse(result); } case "delete": { if (!params.proxy_id) - return { - content: [ - { - type: "text", - text: "Error: proxy_id is required for delete.", - }, - ], - }; + return textResponse("Error: proxy_id is required for delete."); await client.proxies.delete(params.proxy_id); - return { - content: [{ type: "text", text: "Proxy deleted successfully" }], - }; + return textResponse("Proxy deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_proxies (${params.action}): ${error}`, - }, - ], - }; + return textResponse( + `Error in manage_proxies (${params.action}): ${errorMessage(error)}`, + ); } }, ); From 8fbfcf4aa186c8586f775526b755496f67ab2652 Mon Sep 17 00:00:00 2001 From: Ilyaas Kapadia <86218345+IlyaasK@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:35:24 -0400 Subject: [PATCH 14/14] Deduplicate MCP response formatting Add shared list and pagination response helpers for MCP tool output. Use them across the PR 113 admin parity handlers so the new actions do not keep repeating hand-built JSON/text response boilerplate. --- src/lib/mcp/responses.ts | 26 ++++++++++++++++++++++++++ src/lib/mcp/tools/apps.ts | 23 ++++++++--------------- src/lib/mcp/tools/extensions.ts | 24 +++++++++--------------- src/lib/mcp/tools/profiles.ts | 22 ++++++++++------------ src/lib/mcp/tools/projects.ts | 14 +++++++------- src/lib/mcp/tools/proxies.ts | 13 +++++++------ 6 files changed, 67 insertions(+), 55 deletions(-) diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index 81a073e..582f531 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -6,6 +6,32 @@ export function jsonResponse(value: unknown) { return textResponse(JSON.stringify(value, null, 2)); } +export function jsonListResponse( + items: readonly T[] | null | undefined, + emptyText: string, +) { + return items && items.length > 0 + ? jsonResponse(items) + : textResponse(emptyText); +} + +export function paginatedJsonResponse( + page: { + getPaginatedItems(): T[]; + has_more?: boolean | null; + next_offset?: number | null; + }, + emptyText?: string, +) { + const items = page.getPaginatedItems(); + if (items.length === 0 && emptyText) return textResponse(emptyText); + return jsonResponse({ + items, + has_more: page.has_more, + next_offset: page.next_offset, + }); +} + export function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index c90f4fc..f3d94a5 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -2,7 +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 { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -103,13 +108,7 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - if (items.length === 0) return textResponse("No apps found"); - return jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }); + return paginatedJsonResponse(page, "No apps found"); } case "invoke": { if (!params.app_name || !params.action_name) { @@ -171,13 +170,7 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - if (items.length === 0) return textResponse("No deployments found"); - return jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }); + return paginatedJsonResponse(page, "No deployments found"); } case "delete_deployment": { if (!params.deployment_id) { diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index 4fb3f00..7168913 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -1,7 +1,11 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; -import { errorMessage, textResponse } from "@/lib/mcp/responses"; +import { + errorMessage, + jsonListResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerExtensionTools(server: McpServer) { // manage_extensions -- List and delete browser extensions @@ -23,22 +27,12 @@ export function registerExtensionTools(server: McpServer) { switch (params.action) { case "list": { const extensions = await client.extensions.list(); - return textResponse( - extensions?.length > 0 - ? JSON.stringify(extensions, null, 2) - : "No extensions found", - ); + return jsonListResponse(extensions, "No extensions found"); } case "delete": { - if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for delete.", - }, - ], - }; + if (!params.id_or_name) { + return textResponse("Error: id_or_name is required for delete."); + } await client.extensions.delete(params.id_or_name); return textResponse("Extension deleted successfully"); } diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index bc8303f..fd36272 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -2,7 +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 { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; type ProfileListParams = NonNullable< Parameters[0] @@ -126,17 +131,10 @@ export function registerProfileCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - if (items.length === 0) { - return textResponse( - "No profiles found. Use manage_profiles with action 'setup' to create one.", - ); - } - return jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }); + return paginatedJsonResponse( + page, + "No profiles found. Use manage_profiles with action 'setup' to create one.", + ); } case "get": { if (params.profile_name && params.profile_id) { diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index efd837e..f0f357c 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -1,7 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; -import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; +import { + errorMessage, + jsonResponse, + paginatedJsonResponse, + textResponse, +} from "@/lib/mcp/responses"; export function registerProjectCapabilities(server: McpServer) { // manage_projects -- Create, list, get, update, delete, and manage organization project limits @@ -80,12 +85,7 @@ export function registerProjectCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return jsonResponse({ - items, - has_more: page.has_more, - next_offset: page.next_offset, - }); + return paginatedJsonResponse(page); } case "get": { if (!params.project_id) { diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 6486246..7447032 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -1,7 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; -import { errorMessage, jsonResponse, textResponse } from "@/lib/mcp/responses"; +import { + errorMessage, + jsonListResponse, + jsonResponse, + textResponse, +} from "@/lib/mcp/responses"; const httpUrlSchema = z .string() @@ -122,11 +127,7 @@ export function registerProxyTools(server: McpServer) { } case "list": { const proxies = await client.proxies.list(); - return textResponse( - proxies?.length > 0 - ? JSON.stringify(proxies, null, 2) - : "No proxies found", - ); + return jsonListResponse(proxies, "No proxies found"); } case "get": { if (!params.proxy_id) {