diff --git a/README.md b/README.md index aa76227..e779e59 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,9 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (15 total) +## Tools (16 total) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Four standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Five standalone tools handle high-frequency workflows. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. @@ -277,17 +277,22 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ ### Standalone tools -- `computer_action` - Mouse, keyboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, screenshot). +- `computer_action` - Mouse, keyboard, clipboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, read_clipboard, write_clipboard, screenshot). +- `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack. - `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. ## 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/bun.lock b/bun.lock index afb729d..8d0e7aa 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.58.0", + "@onkernel/sdk": "^0.60.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", @@ -145,7 +145,7 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], - "@onkernel/sdk": ["@onkernel/sdk@0.58.0", "", {}, "sha512-eJNydQ8WzZWq837EX+V2QMUhK4O8feVnj9EXZukOvOHXH9PfIr40IQXUxIZ1ic/s08witQ6hQmtlCqY7VeF2aQ=="], + "@onkernel/sdk": ["@onkernel/sdk@0.60.0", "", {}, "sha512-z9POhDK6uanj9FJ5GS/ff7MaqYjCOWP5605pbuUYEH9EuX5bthD89y/XPqwcC9xpgr9RLjlFVJ73ORAiAXfuPw=="], "@redis/bloom": ["@redis/bloom@5.6.0", "", { "peerDependencies": { "@redis/client": "^5.6.0" } }, "sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg=="], diff --git a/package.json b/package.json index d0961f8..beb5d8d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", - "@onkernel/sdk": "^0.58.0", + "@onkernel/sdk": "^0.60.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", diff --git a/src/lib/mcp/browser-config.ts b/src/lib/mcp/browser-config.ts new file mode 100644 index 0000000..35fe7aa --- /dev/null +++ b/src/lib/mcp/browser-config.ts @@ -0,0 +1,227 @@ +import type { KernelClient } from "@/lib/mcp/kernel-client"; + +type BrowserCreateParams = NonNullable< + Parameters[0] +>; +type BrowserUpdateParams = Parameters[1]; +type BrowserPoolCreateParams = Parameters< + KernelClient["browserPools"]["create"] +>[0]; +type BrowserPoolUpdateParams = Parameters< + KernelClient["browserPools"]["update"] +>[1]; + +export type BrowserProfileParams = { + profile_name?: string; + profile_id?: string; + 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 type BrowserCreateConfigParams = BrowserProfileParams & + BrowserExtensionParams & + BrowserViewportParams & { + start_url?: string; + }; + +export type BrowserUpdateConfigParams = BrowserProfileParams & + BrowserViewportUpdateParams; + +type BrowserProfileConfig = NonNullable< + | BrowserCreateParams["profile"] + | BrowserUpdateParams["profile"] + | BrowserPoolCreateParams["profile"] + | BrowserPoolUpdateParams["profile"] +>; + +type BrowserExtensionConfig = NonNullable< + | BrowserCreateParams["extensions"] + | BrowserPoolCreateParams["extensions"] + | BrowserPoolUpdateParams["extensions"] +>; + +type BrowserViewportConfig = NonNullable< + | BrowserCreateParams["viewport"] + | BrowserPoolCreateParams["viewport"] + | BrowserPoolUpdateParams["viewport"] +>; + +type BrowserViewportUpdateConfig = NonNullable; + +export type BrowserCreateConfig = Pick< + BrowserCreateParams, + "profile" | "extensions" | "viewport" | "start_url" +>; + +export type BrowserUpdateConfig = Pick< + BrowserUpdateParams, + "profile" | "viewport" +>; + +export type BrowserConfigResult = + | { ok: true; value: T } + | { ok: false; error: string }; + +function configValue(value: T): BrowserConfigResult { + return { ok: true, value }; +} + +function configError(message: string): BrowserConfigResult { + return { ok: false, error: `Error: ${message}` }; +} + +function buildBrowserStartUrl( + startUrl: string | undefined, +): BrowserConfigResult { + if (startUrl === undefined) return configValue(undefined); + + try { + new URL(startUrl); + } catch { + return configError("start_url must be a valid URL."); + } + + return configValue(startUrl); +} + +function buildBrowserProfile( + params: BrowserProfileParams, +): BrowserConfigResult { + if (params.profile_name && params.profile_id) { + return configError("Cannot specify both profile_name and profile_id."); + } + if ( + params.save_profile_changes !== undefined && + !params.profile_name && + !params.profile_id + ) { + return configError( + "profile_name or profile_id is required when save_profile_changes is set.", + ); + } + if (!params.profile_name && !params.profile_id) return configValue(undefined); + return configValue({ + ...(params.profile_name && { name: params.profile_name }), + ...(params.profile_id && { id: params.profile_id }), + ...(params.save_profile_changes !== undefined && { + save_changes: params.save_profile_changes, + }), + }); +} + +function buildBrowserExtensions( + params: BrowserExtensionParams, +): BrowserConfigResult { + if (params.extension_id && params.extension_name) { + return configError("Cannot specify both extension_id and extension_name."); + } + if (!params.extension_id && !params.extension_name) + return configValue(undefined); + return configValue([ + { + ...(params.extension_id && { id: params.extension_id }), + ...(params.extension_name && { name: params.extension_name }), + }, + ]); +} + +function buildBrowserViewport( + params: BrowserViewportParams, +): BrowserConfigResult { + const width = params.viewport_width; + const height = params.viewport_height; + const hasViewportOptions = + width !== undefined || + height !== undefined || + params.viewport_refresh_rate !== undefined; + + if (!hasViewportOptions) return configValue(undefined); + if (width === undefined || height === undefined) { + return configError( + "viewport_width and viewport_height must be provided together.", + ); + } + + return configValue({ + width, + height, + ...(params.viewport_refresh_rate !== undefined && { + refresh_rate: params.viewport_refresh_rate, + }), + }); +} + +function buildBrowserViewportUpdate( + params: BrowserViewportUpdateParams, +): BrowserConfigResult { + const viewport = buildBrowserViewport(params); + if (!viewport.ok) return viewport; + + if (!viewport.value) { + if (params.viewport_force !== undefined) { + return configError( + "viewport_width and viewport_height must be provided when viewport_force is set.", + ); + } + return configValue(undefined); + } + + return configValue({ + ...viewport.value, + ...(params.viewport_force !== undefined && { + force: params.viewport_force, + }), + }); +} + +export function buildBrowserCreateConfig( + params: BrowserCreateConfigParams, +): BrowserConfigResult { + const profile = buildBrowserProfile(params); + if (!profile.ok) return profile; + + const extensions = buildBrowserExtensions(params); + if (!extensions.ok) return extensions; + + const viewport = buildBrowserViewport(params); + if (!viewport.ok) return viewport; + + const startUrl = buildBrowserStartUrl(params.start_url); + if (!startUrl.ok) return startUrl; + + return configValue({ + ...(profile.value && { profile: profile.value }), + ...(extensions.value && { extensions: extensions.value }), + ...(viewport.value && { viewport: viewport.value }), + ...(startUrl.value !== undefined && { start_url: startUrl.value }), + }); +} + +export function buildBrowserUpdateConfig( + params: BrowserUpdateConfigParams, +): BrowserConfigResult { + const profile = buildBrowserProfile(params); + if (!profile.ok) return profile; + + const viewport = buildBrowserViewportUpdate(params); + if (!viewport.ok) return viewport; + + return configValue({ + ...(profile.value && { profile: profile.value }), + ...(viewport.value && { viewport: viewport.value }), + }); +} diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index d3fc9fc..555dd29 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -48,6 +48,7 @@ const standaloneToolsetAliases: Partial> = { search_docs: "docs", execute_playwright_code: "playwright", exec_command: "shell", + browser_utilities: "browser_curl", }; function isMcpToolset(value: string): value is McpToolset { diff --git a/src/lib/mcp/resource-templates.ts b/src/lib/mcp/resource-templates.ts new file mode 100644 index 0000000..839ac6e --- /dev/null +++ b/src/lib/mcp/resource-templates.ts @@ -0,0 +1,61 @@ +import { + ResourceTemplate, + type McpServer, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; + +type JsonResourceTemplateOptions = { + name: string; + uriTemplate: string; + variableName: string; + resourceLabel: string; + read: ( + client: KernelClient, + identifier: string, + ) => Promise; +}; + +function templateVariableValue( + variables: Record, + name: string, +) { + const value = variables[name]; + return Array.isArray(value) ? value[0] : value; +} + +export function registerJsonResourceTemplate( + server: McpServer, + options: JsonResourceTemplateOptions, +) { + server.resource( + options.name, + new ResourceTemplate(options.uriTemplate, { list: undefined }), + async (uri, variables, extra) => { + if (!extra.authInfo) { + throw new Error("Authentication required"); + } + + const identifier = templateVariableValue(variables, options.variableName); + if (!identifier) { + throw new Error(`Invalid ${options.resourceLabel} URI: ${uri}`); + } + + const client = createKernelClient(extra.authInfo.token); + const resource = await options.read(client, identifier); + + if (!resource) { + throw new Error(`${options.resourceLabel} "${identifier}" not found`); + } + + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify(resource, null, 2), + }, + ], + }; + }, + ); +} diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index 604ca07..4d99a18 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -4,6 +4,19 @@ type PaginatedPage = { next_offset?: number | null; }; +type JsonItemsResponseOptions = { + mapItem?: (item: T) => U; + note?: string; +}; + +type PaginatedJsonResponseOptions = JsonItemsResponseOptions; + +type ItemsJsonResponseOptions = JsonItemsResponseOptions & { + emptyText?: string; + has_more?: boolean | null; + next_offset?: number | null; +}; + export function textResponse(text: string) { return { content: [{ type: "text" as const, text }] }; } @@ -12,9 +25,30 @@ export function jsonResponse(value: unknown) { return textResponse(JSON.stringify(value, null, 2) ?? String(value)); } -export function paginatedJsonResponse(page: PaginatedPage) { +export function itemsJsonResponse( + items: T[], + options: ItemsJsonResponseOptions = {}, +) { + if (items.length === 0 && options.emptyText) { + return textResponse(options.emptyText); + } + return jsonResponse({ - items: page.getPaginatedItems(), + items: options.mapItem ? items.map(options.mapItem) : items, + has_more: options.has_more, + next_offset: options.next_offset, + ...(options.note && { note: options.note }), + }); +} + +export function paginatedJsonResponse( + page: PaginatedPage, + options: PaginatedJsonResponseOptions = {}, +) { + const { mapItem, note } = options; + return itemsJsonResponse(page.getPaginatedItems(), { + mapItem, + note, has_more: page.has_more, next_offset: page.next_offset, }); diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index 4e79664..dbf7c9e 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -4,15 +4,16 @@ export const paginationParams = { limit: z .number() .int() + .min(1) + .max(100) .describe( - "(list) Max results per page. Defaults to 20; API clamps to 1-100.", + "(list) Max results per page. Must be 1-100; API default varies by endpoint.", ) .optional(), offset: z .number() .int() - .describe( - "(list) Pagination offset. Defaults to 0; API clamps negatives to 0.", - ) + .min(0) + .describe("(list) Pagination offset. Must be 0 or greater.") .optional(), }; diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 25e317d..3b3cbe6 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -1,6 +1,15 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { + errorResponse, + jsonResponse, + paginatedJsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; export function registerAppCapabilities(server: McpServer) { server.resource("apps", "apps://", async (uri, extra) => { @@ -9,52 +18,35 @@ export function registerAppCapabilities(server: McpServer) { } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); + const appsPage = await client.apps.list(); + const items = appsPage.getPaginatedItems(); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + items.length > 0 ? JSON.stringify(items, null, 2) : "No apps found", + }, + ], + }; + }); - if (uriString === "apps://") { - // List all apps - const appsPage = await client.apps.list(); - const items = appsPage.getPaginatedItems(); - return { - contents: [ - { - uri: "apps://", - mimeType: "application/json", - text: - items.length > 0 - ? JSON.stringify(items, null, 2) - : "No apps found", - }, - ], - }; - } else if (uriString.startsWith("apps://")) { - // Get specific app by name - const appName = uriString.replace("apps://", ""); + registerJsonResourceTemplate(server, { + name: "app", + uriTemplate: "apps://{appName}", + variableName: "appName", + resourceLabel: "App", + read: async (client, appName) => { const appsPage = await client.apps.list({ app_name: appName }); - const app = appsPage.getPaginatedItems()[0]; - - if (!app) { - throw new Error(`App "${appName}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(app, null, 2), - }, - ], - }; - } - - throw new Error(`Invalid app URI: ${uriString}`); + return appsPage.getPaginatedItems()[0]; + }, }); // manage_apps -- List apps, invoke actions, manage deployments, check invocations server.tool( "manage_apps", - 'Manage Kernel apps, deployments, and invocations. Use "list_apps" to discover apps, "invoke" to execute an app action, "get_deployment"/"list_deployments" to check deployment status, or "get_invocation" to check action results.', + 'Manage Kernel apps when an agent needs to discover deployed app actions, invoke an app, or inspect deployment/invocation state. Use "list_apps" before invoking an unknown app, "invoke" to run an action, and get/list actions to inspect results.', { action: z .enum([ @@ -93,14 +85,7 @@ export function registerAppCapabilities(server: McpServer) { .string() .describe("(get_invocation) Invocation ID to retrieve.") .optional(), - limit: z - .number() - .describe("(list_apps, list_deployments) Max results. Default 50.") - .optional(), - offset: z - .number() - .describe("(list_apps, list_deployments) Pagination offset. Default 0.") - .optional(), + ...paginationParams, }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -115,37 +100,13 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No apps found", - }, - ], - }; + return paginatedJsonResponse(page); } case "invoke": { if (!params.app_name || !params.action_name) { - return { - content: [ - { - type: "text", - text: "Error: app_name and action_name are required for invoke.", - }, - ], - }; + return errorResponse( + "Error: app_name and action_name are required for invoke.", + ); } const invocation = await client.invocations.create({ app_name: params.app_name, @@ -154,28 +115,24 @@ export function registerAppCapabilities(server: McpServer) { version: params.version ?? "latest", async: true, }); - if (!invocation) throw new Error("Failed to create invocation"); + if (!invocation) + return errorResponse("Failed to create invocation"); const stream = await client.invocations.follow(invocation.id); let finalInvocation = invocation; for await (const evt of stream) { if (evt.event === "error") { - return { - content: [ + return errorResponse( + JSON.stringify( { - type: "text", - text: JSON.stringify( - { - status: "error", - invocation_id: invocation.id, - error: evt, - }, - null, - 2, - ), + status: "error", + invocation_id: invocation.id, + error: evt, }, - ], - }; + null, + 2, + ), + ); } if (evt.event === "invocation_state") { finalInvocation = evt.invocation || finalInvocation; @@ -186,39 +143,19 @@ export function registerAppCapabilities(server: McpServer) { break; } } - return { - content: [ - { - type: "text", - text: JSON.stringify(finalInvocation, null, 2), - }, - ], - }; + return jsonResponse(finalInvocation); } case "get_deployment": { if (!params.deployment_id) - return { - content: [ - { type: "text", text: "Error: deployment_id is required." }, - ], - }; + return errorResponse("Error: deployment_id is required."); const deployment = await client.deployments.retrieve( params.deployment_id, ); if (!deployment) - return { - content: [ - { - type: "text", - text: `Deployment "${params.deployment_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(deployment, null, 2) }, - ], - }; + return errorResponse( + `Deployment "${params.deployment_id}" not found`, + ); + return jsonResponse(deployment); } case "list_deployments": { const page = await client.deployments.list({ @@ -226,62 +163,23 @@ export function registerAppCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page.getPaginatedItems(); - return { - content: [ - { - type: "text", - text: - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No deployments found", - }, - ], - }; + return paginatedJsonResponse(page); } case "get_invocation": { if (!params.invocation_id) - return { - content: [ - { type: "text", text: "Error: invocation_id is required." }, - ], - }; + return errorResponse("Error: invocation_id is required."); const invocation = await client.invocations.retrieve( params.invocation_id, ); if (!invocation) - return { - content: [ - { - type: "text", - text: `Invocation "${params.invocation_id}" not found`, - }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(invocation, null, 2) }, - ], - }; + return errorResponse( + `Invocation "${params.invocation_id}" not found`, + ); + return jsonResponse(invocation); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_apps (${params.action}): ${error}`, - }, - ], - }; + return toolErrorResponse("manage_apps", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browser-curl.ts b/src/lib/mcp/tools/browser-curl.ts index 9cb8e1b..d38170b 100644 --- a/src/lib/mcp/tools/browser-curl.ts +++ b/src/lib/mcp/tools/browser-curl.ts @@ -1,21 +1,32 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; -import { errorResponse, jsonResponse } from "@/lib/mcp/responses"; +import { + errorResponse, + jsonResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; type BrowserCurlParams = Parameters[1]; -function curlUrlValidationError(url: string) { - const parsed = new URL(url); +function curlUrlError(url: string) { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "Error: url must be a valid URL."; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return "url must use http or https."; + return "Error: url must use http or https."; } + return undefined; } export function registerBrowserCurlTool(server: McpServer) { server.tool( "browser_curl", - "Send an HTTP request through an existing Kernel browser session's Chrome network stack.", + "Send an HTTP request through an existing Kernel browser session's Chrome network stack. Use when the request needs that browser session's cookies, proxy, network context, or origin behavior; do not use for general documentation lookup or web search.", { session_id: z.string().describe("Browser session ID."), url: z.string().url().describe("Target http or https URL."), @@ -38,6 +49,7 @@ export function registerBrowserCurlTool(server: McpServer) { timeout_ms: z .number() .int() + .min(1) .describe("Request timeout in milliseconds.") .optional(), }, @@ -46,19 +58,16 @@ export function registerBrowserCurlTool(server: McpServer) { const client = createKernelClient(extra.authInfo.token); try { - const curlRequest: { session_id: string } & BrowserCurlParams = params; - const { session_id, ...curlParams } = curlRequest; - const urlError = curlUrlValidationError(curlParams.url); - if (urlError) return errorResponse(`Error: ${urlError}`); + const { session_id, ...curlParams } = params satisfies { + session_id: string; + } & BrowserCurlParams; + const urlError = curlUrlError(curlParams.url); + if (urlError) return errorResponse(urlError); const response = await client.browsers.curl(session_id, curlParams); return jsonResponse(response); } catch (error) { - return errorResponse( - `Error in browser_curl: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + return toolErrorResponse("browser_curl", "request", error); } }, ); diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 8d07b5b..6128538 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -1,60 +1,196 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + buildBrowserCreateConfig, + type BrowserConfigResult, + type BrowserCreateConfigParams, +} from "@/lib/mcp/browser-config"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { + jsonResponse, + errorResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; + +type BrowserPoolCreateParams = Parameters< + KernelClient["browserPools"]["create"] +>[0]; +type BrowserPoolUpdateParams = Parameters< + KernelClient["browserPools"]["update"] +>[1]; +type BrowserPool = Awaited< + ReturnType +>; +type BrowserPoolAcquireResponse = Awaited< + ReturnType +>; + +type PoolConfigParams = BrowserCreateConfigParams & { + size?: number; + name?: string; + headless?: boolean; + stealth?: boolean; + timeout_seconds?: number; + proxy_id?: string; + fill_rate_per_minute?: number; + chrome_policy?: Record; + kiosk_mode?: boolean; +}; + +function buildPoolConfigParams( + params: PoolConfigParams, +): BrowserConfigResult { + const browserConfig = buildBrowserCreateConfig(params); + if (!browserConfig.ok) return browserConfig; + const chromePolicy = + params.chrome_policy && Object.keys(params.chrome_policy).length > 0 + ? params.chrome_policy + : undefined; + + return { + ok: true, + value: { + ...(params.size !== undefined && { size: params.size }), + ...(params.name && { name: params.name }), + ...(params.headless !== undefined && { headless: params.headless }), + ...(params.stealth !== undefined && { stealth: params.stealth }), + ...(params.timeout_seconds !== undefined && { + timeout_seconds: params.timeout_seconds, + }), + ...(params.proxy_id !== undefined && { proxy_id: params.proxy_id }), + ...(params.fill_rate_per_minute !== undefined && { + fill_rate_per_minute: params.fill_rate_per_minute, + }), + ...(chromePolicy && { chrome_policy: chromePolicy }), + ...(params.kiosk_mode !== undefined && { kiosk_mode: params.kiosk_mode }), + ...browserConfig.value, + }, + }; +} + +function buildPoolCreateParams( + params: PoolConfigParams, +): BrowserConfigResult { + if (params.size === undefined) { + return { ok: false, error: "Error: size is required for create." }; + } + + const config = buildPoolConfigParams(params); + if (!config.ok) return config; + + return { ok: true, value: { ...config.value, size: params.size } }; +} + +function buildPoolUpdateParams( + params: PoolConfigParams & { discard_all_idle?: boolean }, +): BrowserConfigResult { + const config = buildPoolConfigParams(params); + if (!config.ok) return config; + + return { + ok: true, + value: { + ...config.value, + ...(params.discard_all_idle !== undefined && { + discard_all_idle: params.discard_all_idle, + }), + }, + }; +} + +function summarizeBrowserPool(pool: BrowserPool) { + const config = pool.browser_pool_config; + return { + id: pool.id, + name: pool.name, + created_at: pool.created_at, + counts: { + size: config.size, + available: pool.available_count, + acquired: pool.acquired_count, + }, + config: { + headless: config.headless, + stealth: config.stealth, + kiosk_mode: config.kiosk_mode, + timeout_seconds: config.timeout_seconds, + fill_rate_per_minute: config.fill_rate_per_minute, + start_url: config.start_url, + profile: config.profile, + proxy_id: config.proxy_id, + viewport: config.viewport, + extensions: config.extensions, + chrome_policy_keys: config.chrome_policy + ? Object.keys(config.chrome_policy) + : undefined, + }, + }; +} + +function poolNextActions(pool: BrowserPool) { + return [ + `Use manage_browser_pools with action "acquire" and id_or_name "${pool.id}" to get a browser from this pool.`, + `Use manage_browser_pools with action "get" and id_or_name "${pool.id}" for full pool details.`, + ]; +} + +function summarizeAcquiredBrowser(browser: BrowserPoolAcquireResponse) { + return { + session_id: browser.session_id, + browser_live_view_url: browser.browser_live_view_url, + base_url: browser.base_url, + headless: browser.headless, + stealth: browser.stealth, + timeout_seconds: browser.timeout_seconds, + pool: browser.pool, + profile: browser.profile, + proxy_id: browser.proxy_id, + start_url: browser.start_url, + viewport: browser.viewport, + }; +} export function registerBrowserPoolCapabilities(server: McpServer) { - server.resource("browser_pools", "browser_pools://", async (uri, extra) => { + 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.map(summarizeBrowserPool), null, 2) + : "No browser pools found", + }, + ], + }; + }); - throw new Error(`Invalid browser pool URI: ${uriString}`); + registerJsonResourceTemplate(server, { + name: "browser_pool", + uriTemplate: "browser-pools://{idOrName}", + variableName: "idOrName", + resourceLabel: "Browser pool", + read: (client, idOrName) => client.browserPools.retrieve(idOrName), }); - // manage_browser_pools -- Create, list, get, delete, flush, acquire, and release browser pools + // manage_browser_pools -- Create, update, list, get, delete, flush, acquire, and release browser pools server.tool( "manage_browser_pools", - 'Manage pools of pre-warmed browser instances for fast acquisition. Use "create" to set up a pool, "list"/"get" to inspect pools, "acquire" to get a browser from a pool, "release" to return it, "flush" to destroy idle browsers, or "delete" to remove a pool.', + 'Manage pre-warmed browser pools when an agent needs fast browser acquisition or reusable session capacity. Use "list" for a compact pool inventory, "get" for full details, "acquire" before controlling a pooled browser, and "release" when the browser should return to the pool.', { action: z .enum([ "create", + "update", "list", "get", "delete", @@ -66,37 +202,117 @@ 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.") + .int() + .min(1) + .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.") + .int() + .min(1) + .describe( + "(create, update) Idle timeout for acquired browsers. Default 600.", + ) .optional(), profile_name: z .string() - .describe("(create) Profile to load into pool browsers.") + .describe( + "(create, update) Profile name to load into pool browsers. Cannot use with profile_id.", + ) + .optional(), + profile_id: z + .string() + .describe( + "(create, update) Profile ID to load into pool browsers. Cannot use with profile_name.", + ) + .optional(), + save_profile_changes: z + .boolean() + .describe( + "(create, update) Save browser changes back to the selected profile when sessions end.", + ) .optional(), proxy_id: z .string() - .describe("(create) Proxy for pool browsers.") + .describe("(create, update) Proxy for pool browsers.") .optional(), fill_rate_per_minute: z .number() - .describe("(create) Pool fill rate percentage per minute. Default 10%.") + .describe( + "(create, update) Pool fill rate percentage per minute. Default 10%.", + ) + .optional(), + start_url: z + .string() + .url() + .describe( + "(create, update) URL to open when a browser is warmed into the pool. Navigation is best-effort.", + ) + .optional(), + chrome_policy: z + .record(z.string(), z.unknown()) + .describe( + "(create, update) Chrome enterprise policy overrides for all browsers in the pool. Kernel-managed policies such as extensions, proxy, CDP, and automation are blocked by the API.", + ) + .optional(), + kiosk_mode: z + .boolean() + .describe("(create, update) Hide address bar/tabs in live view.") + .optional(), + extension_id: z + .string() + .describe("(create, update) Extension ID to load.") + .optional(), + extension_name: z + .string() + .describe("(create, update) Extension name to load.") + .optional(), + viewport_width: z + .number() + .int() + .min(1) + .describe( + "(create, update) Window width in pixels. Must pair with viewport_height.", + ) + .optional(), + viewport_height: z + .number() + .int() + .min(1) + .describe( + "(create, update) Window height in pixels. Must pair with viewport_width.", + ) + .optional(), + viewport_refresh_rate: z + .number() + .int() + .min(1) + .describe("(create, update) Display refresh rate in Hz.") + .optional(), + discard_all_idle: z + .boolean() + .describe( + "(update) Discard idle browsers and rebuild the pool immediately.", + ) .optional(), force: z .boolean() @@ -104,6 +320,8 @@ export function registerBrowserPoolCapabilities(server: McpServer) { .optional(), acquire_timeout_seconds: z .number() + .int() + .min(0) .describe("(acquire) Max seconds to wait for a browser.") .optional(), session_id: z @@ -122,127 +340,86 @@ 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, - }), + const createParams = buildPoolCreateParams(params); + if (!createParams.ok) return errorResponse(createParams.error); + + const pool = await client.browserPools.create(createParams.value); + if (!pool) return errorResponse("Failed to create browser pool"); + return jsonResponse({ + browser_pool: summarizeBrowserPool(pool), + next_actions: poolNextActions(pool), }); - if (!pool) - return { - content: [ - { type: "text", text: "Failed to create browser pool" }, - ], - }; - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; } - case "list": { - const pools = await client.browserPools.list(); - return { - content: [ - { - type: "text", - text: - pools?.length > 0 - ? JSON.stringify(pools, null, 2) - : "No browser pools found", - }, + case "update": { + if (!params.id_or_name) { + return errorResponse("Error: id_or_name is required for update."); + } + + const updateParams = buildPoolUpdateParams(params); + if (!updateParams.ok) return errorResponse(updateParams.error); + if (Object.keys(updateParams.value).length === 0) { + return errorResponse( + "Error: at least one update field is required.", + ); + } + + const pool = await client.browserPools.update( + params.id_or_name, + updateParams.value, + ); + if (!pool) return errorResponse("Failed to update browser pool"); + return jsonResponse({ + browser_pool: summarizeBrowserPool(pool), + next_actions: [ + ...poolNextActions(pool), + ...(params.discard_all_idle + ? [ + "discard_all_idle was requested; idle browsers may be rebuilt before the next acquire.", + ] + : []), ], - }; + }); + } + case "list": { + const pools = (await client.browserPools.list()) ?? []; + return pools.length > 0 + ? jsonResponse({ + items: pools.map(summarizeBrowserPool), + note: 'Use action "get" with id_or_name for full pool details.', + }) + : textResponse("No browser pools found"); } case "get": { if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for get.", - }, - ], - }; + return errorResponse("Error: id_or_name is required for get."); const pool = await client.browserPools.retrieve(params.id_or_name); if (!pool) - return { - content: [ - { - type: "text", - text: `Browser pool "${params.id_or_name}" not found`, - }, - ], - }; - return { - content: [{ type: "text", text: JSON.stringify(pool, null, 2) }], - }; + return errorResponse( + `Browser pool "${params.id_or_name}" not found`, + ); + return jsonResponse(pool); } case "delete": { if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for delete.", - }, - ], - }; + return errorResponse("Error: id_or_name is required for delete."); await client.browserPools.delete(params.id_or_name, { ...(params.force !== undefined && { force: params.force }), }); - return { - content: [ - { type: "text", text: "Browser pool deleted successfully" }, - ], - }; + return textResponse("Browser pool deleted successfully"); } case "flush": { if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for flush.", - }, - ], - }; + return errorResponse("Error: id_or_name is required for flush."); await client.browserPools.flush(params.id_or_name); - return { - content: [ - { - type: "text", - text: "Pool flushed successfully. All idle browsers destroyed.", - }, - ], - }; + return textResponse( + "Pool flushed successfully. All idle browsers destroyed.", + ); } case "acquire": { if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for acquire.", - }, - ], - }; + return errorResponse( + "Error: id_or_name is required for acquire.", + ); const browser = await client.browserPools.acquire( params.id_or_name, { @@ -252,59 +429,34 @@ export function registerBrowserPoolCapabilities(server: McpServer) { }, ); if (!browser) - return { - content: [ - { type: "text", text: "Failed to acquire browser from pool" }, - ], - }; - return { - content: [ - { type: "text", text: JSON.stringify(browser, null, 2) }, + return errorResponse("Failed to acquire browser from pool"); + return jsonResponse({ + browser: summarizeAcquiredBrowser(browser), + next_actions: [ + `Use computer_action with session_id "${browser.session_id}" to control this browser.`, + `When finished, use manage_browser_pools with action "release", id_or_name "${params.id_or_name}", and session_id "${browser.session_id}".`, + `Use manage_browsers with action "get" and session_id "${browser.session_id}" for full browser details.`, ], - }; + }); } case "release": { if (!params.id_or_name) - return { - content: [ - { - type: "text", - text: "Error: id_or_name is required for release.", - }, - ], - }; + return errorResponse( + "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 errorResponse( + "Error: session_id is required for release.", + ); await client.browserPools.release(params.id_or_name, { session_id: params.session_id, ...(params.reuse !== undefined && { reuse: params.reuse }), }); - 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 toolErrorResponse("manage_browser_pools", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index b6b7346..463bf23 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -1,30 +1,26 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { + buildBrowserCreateConfig, + buildBrowserUpdateConfig, + type BrowserConfigResult, +} from "@/lib/mcp/browser-config"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, + jsonResponse, + paginatedJsonResponse, textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; 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; @@ -40,77 +36,11 @@ const telemetryCategories = [ ["telemetry_interaction", "interaction"], ] as const; -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 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"] { +): BrowserConfigResult< + BrowserCreateParams["telemetry"] | BrowserUpdateParams["telemetry"] +> { const browser: NonNullable< NonNullable["browser"] > = {}; @@ -127,20 +57,72 @@ function buildTelemetry( } if (params.telemetry_enabled === false && hasEnabledBrowserCategories) { - throw new Error( - "telemetry_enabled=false cannot be combined with enabled telemetry categories.", - ); + return { + ok: false, + error: + "Error: telemetry_enabled=false cannot be combined with enabled telemetry categories.", + }; } if (params.telemetry_enabled === undefined && !hasBrowserCategories) { - return undefined; + return { ok: true, value: undefined }; } return { - ...(params.telemetry_enabled !== undefined && { - enabled: params.telemetry_enabled, - }), - ...(hasBrowserCategories && { browser }), + ok: true, + value: { + ...(params.telemetry_enabled !== undefined && { + enabled: params.telemetry_enabled, + }), + ...(hasBrowserCategories && { browser }), + }, + }; +} + +function browserSessionNextActions(sessionId: string) { + return [ + `Use computer_action with session_id "${sessionId}" to inspect or control the browser.`, + `Use manage_browsers with action "get" and session_id "${sessionId}" for full browser details.`, + `Use manage_browsers with action "delete" and session_id "${sessionId}" when the session is no longer needed.`, + ]; +} + +function buildSshPortForwardingInfo( + params: { local_forward?: string; remote_forward?: string }, + sessionId: string, +) { + if (!params.local_forward && !params.remote_forward) return undefined; + + const sshParts = ["kernel browsers ssh", sessionId]; + if (params.local_forward) sshParts.push(`-L ${params.local_forward}`); + if (params.remote_forward) sshParts.push(`-R ${params.remote_forward}`); + + const remotePort = params.remote_forward + ? params.remote_forward.split(":")[0] + : undefined; + const localPort = params.local_forward + ? params.local_forward.split(":")[0] + : undefined; + + return { + command: sshParts.join(" "), + prerequisites: [ + "Kernel CLI: https://kernel.sh/docs/reference/cli", + "websocat: brew install websocat on macOS", + ], + remote_forward: remotePort + ? { + browser_vm_url: `http://localhost:${remotePort}`, + next_action: `Once the user has the tunnel running, use execute_playwright_code to navigate the browser to http://localhost:${remotePort}.`, + } + : undefined, + local_forward: localPort + ? { + local_url: `http://localhost:${localPort}`, + note: `Services inside the browser VM are accessible locally at localhost:${localPort} once the tunnel is running.`, + } + : undefined, + note: "SSH connections alone do not count as browser activity. Set an appropriate timeout or keep the live view open to prevent cleanup.", }; } @@ -151,51 +133,34 @@ 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 server.tool( "manage_browsers", - 'Manage browser sessions in the Kernel platform. Use action "create" to launch a new browser, "update" to modify supported session settings, "list" to see existing sessions, "get" to retrieve details about a specific session, or "delete" to terminate one. Created browsers run in isolated VMs and support headless/stealth modes, profiles, proxies, viewports, extensions, Chrome policy overrides, telemetry, start URLs, and SSH tunneling.', + 'Manage browser sessions when an agent needs a live browser to inspect, automate, or debug web state. Use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, and "delete" when finished.', { action: z .enum(["create", "update", "list", "get", "delete"]) @@ -235,6 +200,9 @@ export function registerBrowserCapabilities(server: McpServer) { .optional(), timeout_seconds: z .number() + .int() + .min(10) + .max(259200) .describe( "(create) Inactivity timeout in seconds (max 259200 = 72h). Default 60.", ) @@ -279,18 +247,24 @@ export function registerBrowserCapabilities(server: McpServer) { .optional(), viewport_width: z .number() + .int() + .min(1) .describe( "(create, update) Window width in pixels. Must pair with viewport_height.", ) .optional(), viewport_height: z .number() + .int() + .min(1) .describe( "(create, update) Window height in pixels. Must pair with viewport_width.", ) .optional(), viewport_refresh_rate: z .number() + .int() + .min(1) .describe("(create, update) Display refresh rate in Hz.") .optional(), viewport_force: z @@ -321,14 +295,7 @@ export function registerBrowserCapabilities(server: McpServer) { .enum(["active", "deleted", "all"]) .describe('(list) Filter by status. Default "active".') .optional(), - limit: z - .number() - .describe("(list) Max results per page. Default 50.") - .optional(), - offset: z - .number() - .describe("(list) Pagination offset. Default 0.") - .optional(), + ...paginationParams, telemetry_enabled: z .boolean() .describe( @@ -363,17 +330,6 @@ export function registerBrowserCapabilities(server: McpServer) { try { switch (params.action) { case "create": { - if (params.profile_name && params.profile_id) { - return errorResponse( - "Error: Cannot specify both profile_name and profile_id.", - ); - } - if (params.extension_id && params.extension_name) { - return errorResponse( - "Error: Cannot specify both extension_id and extension_name.", - ); - } - const createParams: BrowserCreateParams = {}; if (params.headless !== undefined) createParams.headless = params.headless; @@ -384,69 +340,42 @@ 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; - if (params.chrome_policy) + if ( + params.chrome_policy && + Object.keys(params.chrome_policy).length > 0 + ) { createParams.chrome_policy = params.chrome_policy; + } if (params.proxy_id) createParams.proxy_id = params.proxy_id; - const profile = buildProfile(params); - if (profile) createParams.profile = profile; - const viewport = buildViewportBase(params); - if (viewport) createParams.viewport = viewport; + const browserConfig = buildBrowserCreateConfig(params); + if (!browserConfig.ok) return errorResponse(browserConfig.error); + Object.assign(createParams, browserConfig.value); 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 }), - }, - ]; - } + if (!telemetry.ok) return errorResponse(telemetry.error); + if (telemetry.value !== undefined) + createParams.telemetry = telemetry.value; const browser = await client.browsers.create(createParams); if (!browser) return errorResponse("Failed to create browser session"); - let responseText = JSON.stringify(browser, null, 2); - if (params.local_forward || params.remote_forward) { - const sshParts = ["kernel browsers ssh", browser.session_id]; - if (params.local_forward) - sshParts.push(`-L ${params.local_forward}`); - if (params.remote_forward) - sshParts.push(`-R ${params.remote_forward}`); - const sshCommand = sshParts.join(" "); - - const remotePort = params.remote_forward - ? params.remote_forward.split(":")[0] - : null; - const localPort = params.local_forward - ? params.local_forward.split(":")[0] - : null; - - responseText += `\n\n## SSH Port Forwarding\n\nRun this command in a terminal:\n\n\`\`\`bash\n${sshCommand}\n\`\`\`\n\nPrerequisites: [Kernel CLI](https://kernel.sh/docs/reference/cli) and [websocat](https://github.com/vi/websocat) (\`brew install websocat\` on macOS).`; - - if (remotePort) { - responseText += `\n\nThis forwards the user's local port to port ${remotePort} inside the browser VM. Once the user has the tunnel running, use execute_playwright_code to navigate the browser to http://localhost:${remotePort}`; - } - - if (localPort) { - responseText += `\n\nThis forwards port ${localPort} from the browser VM to the user's local machine. Once the user has the tunnel running, services inside the VM are accessible locally at localhost:${localPort}`; - } - - responseText += `\n\nNote: SSH connections alone don't count as browser activity. Set an appropriate timeout or keep the live view open to prevent cleanup.`; - } - return textResponse(responseText); + const sshPortForwarding = buildSshPortForwardingInfo( + params, + browser.session_id, + ); + return jsonResponse({ + browser, + next_actions: browserSessionNextActions(browser.session_id), + ...(sshPortForwarding && { + ssh_port_forwarding: sshPortForwarding, + }), + }); } case "update": { if (!params.session_id) return errorResponse( "Error: session_id is required for update action.", ); - if (params.profile_name && params.profile_id) { - return errorResponse( - "Error: Cannot specify both profile_name and profile_id.", - ); - } if (params.proxy_id && params.clear_proxy) { return errorResponse( "Error: Cannot specify both proxy_id and clear_proxy.", @@ -462,12 +391,13 @@ export function registerBrowserCapabilities(server: McpServer) { } else if (params.proxy_id !== undefined) { updateParams.proxy_id = params.proxy_id; } - const profile = buildProfile(params); - if (profile) updateParams.profile = profile; - const viewport = buildUpdateViewport(params); - if (viewport) updateParams.viewport = viewport; + const browserConfig = buildBrowserUpdateConfig(params); + if (!browserConfig.ok) return errorResponse(browserConfig.error); + Object.assign(updateParams, browserConfig.value); const telemetry = buildTelemetry(params); - if (telemetry !== undefined) updateParams.telemetry = telemetry; + if (!telemetry.ok) return errorResponse(telemetry.error); + if (telemetry.value !== undefined) + updateParams.telemetry = telemetry.value; if (Object.keys(updateParams).length === 0) { return errorResponse( @@ -481,7 +411,10 @@ export function registerBrowserCapabilities(server: McpServer) { ); if (!browser) return errorResponse("Failed to update browser session"); - return textResponse(JSON.stringify(browser, null, 2)); + return jsonResponse({ + browser, + next_actions: browserSessionNextActions(browser.session_id), + }); } case "list": { const page = await client.browsers.list({ @@ -489,22 +422,10 @@ export function registerBrowserCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), }); - const items = page - .getPaginatedItems() - .map((b) => ({ ...b, cdp_ws_url: undefined })); - return textResponse( - items.length > 0 - ? JSON.stringify( - { - items, - has_more: page.has_more, - next_offset: page.next_offset, - }, - null, - 2, - ) - : "No browsers found", - ); + return paginatedJsonResponse(page, { + mapItem: ({ cdp_ws_url: _cdpWsUrl, ...browser }) => browser, + note: 'Use action "get" with session_id for full browser details.', + }); } case "get": { if (!params.session_id) @@ -516,7 +437,7 @@ export function registerBrowserCapabilities(server: McpServer) { return errorResponse( `Browser session "${params.session_id}" not found`, ); - return textResponse(JSON.stringify(browser, null, 2)); + return jsonResponse(browser); } case "delete": { if (!params.session_id) diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 165642b..b94ff4b 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -1,7 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; -import { errorResponse, jsonResponse, textResponse } from "@/lib/mcp/responses"; +import { + errorResponse, + jsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; type ComputerClient = KernelClient["browsers"]["computer"]; type ComputerBatchAction = Parameters< @@ -31,7 +36,7 @@ const computerActionSchema = z.object({ y: z.number(), button: z.enum(["left", "right", "middle"]).optional(), click_type: z.enum(["down", "up", "click"]).optional(), - num_clicks: z.number().optional(), + num_clicks: z.number().int().min(1).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for click_mouse action.") @@ -47,7 +52,7 @@ const computerActionSchema = z.object({ type_text: z .object({ text: z.string(), - delay: z.number().optional(), + delay: z.number().int().min(0).optional(), }) .describe("Params for type_text action.") .optional(), @@ -56,7 +61,7 @@ const computerActionSchema = z.object({ keys: z .array(z.string()) .describe('X11 keysym names or combos like "Ctrl+t", "Return".'), - duration: z.number().optional(), + duration: z.number().int().min(0).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for press_key action.") @@ -77,9 +82,9 @@ const computerActionSchema = z.object({ .array(z.array(z.number())) .describe("Ordered [x,y] pairs, at least 2 points."), button: z.enum(["left", "middle", "right"]).optional(), - delay: z.number().optional(), - steps_per_segment: z.number().optional(), - step_delay_ms: z.number().optional(), + delay: z.number().int().min(0).optional(), + steps_per_segment: z.number().int().min(1).optional(), + step_delay_ms: z.number().int().min(0).optional(), hold_keys: z.array(z.string()).optional(), }) .describe("Params for drag_mouse action.") @@ -92,7 +97,7 @@ const computerActionSchema = z.object({ .optional(), sleep: z .object({ - duration_ms: z.number(), + duration_ms: z.number().int().min(0), }) .describe("Params for sleep action.") .optional(), @@ -108,8 +113,8 @@ const computerActionSchema = z.object({ .object({ x: z.number(), y: z.number(), - width: z.number(), - height: z.number(), + width: z.number().int().min(1), + height: z.number().int().min(1), }) .optional(), }) @@ -245,11 +250,12 @@ export function registerComputerActionTool(server: McpServer) { // computer_action -- Execute one or more computer actions on a browser session server.tool( "computer_action", - "Execute computer actions on a browser session. Pass a single action for simple operations (e.g. one click or one screenshot), or pass multiple actions to batch them into a single request for lower latency (e.g. click, type, press_key in one call). Use sleep actions between steps when the page needs time to react (e.g. after a click that triggers navigation or animation). IMPORTANT: Always include a screenshot as the last action so you can see the result of your actions. Action types: click_mouse, move_mouse, type_text, press_key, scroll, drag_mouse, set_cursor, sleep, write_clipboard, read_clipboard, screenshot, get_mouse_position. screenshot, get_mouse_position, and read_clipboard return data, so they must be the last action if included.", + "Execute computer actions on a browser session. Pass a single action for simple operations (e.g. one click or one screenshot), or pass multiple actions to batch them into a single request for lower latency (e.g. click, type, press_key in one call). Use sleep actions between steps when the page needs time to react (e.g. after a click that triggers navigation or animation). IMPORTANT: Always include a screenshot as the last action so you can see the result of your actions. Action types: click_mouse, move_mouse, type_text, press_key, scroll, drag_mouse, set_cursor, sleep, write_clipboard, read_clipboard, screenshot, get_mouse_position. screenshot, read_clipboard, and get_mouse_position return data, so they must be the last action if included.", { session_id: z.string().describe("Browser session ID."), actions: z .array(computerActionSchema) + .min(1) .describe( "Ordered list of actions. Use one action for simple operations or multiple for batched sequences.", ), @@ -340,9 +346,7 @@ export function registerComputerActionTool(server: McpServer) { `Executed ${executedActionCount} action(s) successfully`, ); } catch (error) { - return errorResponse( - `Error in computer_action: ${error instanceof Error ? error.message : String(error)}`, - ); + return toolErrorResponse("computer_action", "actions", error); } }, ); diff --git a/src/lib/mcp/tools/credentials.ts b/src/lib/mcp/tools/credentials.ts index 05146c2..933634e 100644 --- a/src/lib/mcp/tools/credentials.ts +++ b/src/lib/mcp/tools/credentials.ts @@ -116,7 +116,8 @@ export function registerCredentialTools(server: McpServer) { totp_secret: params.totp_secret, }), }); - if (!credential) return errorResponse("Failed to create credential"); + if (!credential) + return errorResponse("Failed to create credential"); return jsonResponse(credential); } case "update": { diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index d341c78..3ef18f4 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -1,15 +1,38 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; +import { + errorResponse, + itemsJsonResponse, + paginatedJsonResponse, + textResponse, + toolErrorResponse, +} from "@/lib/mcp/responses"; +import { paginationParams } from "@/lib/mcp/schemas"; -async function listProfiles(client: KernelClient) { - const profiles: Awaited>[] = []; - for await (const profile of client.profiles.list()) { +type ProfileListParams = NonNullable< + Parameters[0] +>; +type Profile = Awaited>; + +async function listProfiles(client: KernelClient, query?: ProfileListParams) { + const profiles: Profile[] = []; + for await (const profile of client.profiles.list(query)) { profiles.push(profile); } return profiles; } +function fullProfileListResponse(profiles: Profile[]) { + return itemsJsonResponse(profiles, { + has_more: false, + next_offset: null, + emptyText: + "No profiles found. Use manage_profiles with action 'setup' to create one.", + }); +} + export function registerProfileCapabilities(server: McpServer) { server.resource("profiles", "profiles://", async (uri, extra) => { if (!extra.authInfo) { @@ -17,49 +40,32 @@ export function registerProfileCapabilities(server: McpServer) { } const client = createKernelClient(extra.authInfo.token); - const uriString = uri.toString(); - - if (uriString === "profiles://") { - // List all profiles - const profiles = await listProfiles(client); - return { - contents: [ - { - uri: "profiles://", - mimeType: "application/json", - text: - profiles.length > 0 - ? JSON.stringify(profiles, null, 2) - : "No profiles found", - }, - ], - }; - } else if (uriString.startsWith("profiles://")) { - // Get specific profile by name - const profileName = uriString.replace("profiles://", ""); - const profile = await client.profiles.retrieve(profileName); - - if (!profile) { - throw new Error(`Profile "${profileName}" not found`); - } - - return { - contents: [ - { - uri: uriString, - mimeType: "application/json", - text: JSON.stringify(profile, null, 2), - }, - ], - }; - } + const profiles = await listProfiles(client); + return { + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: + profiles.length > 0 + ? JSON.stringify(profiles, null, 2) + : "No profiles found", + }, + ], + }; + }); - throw new Error(`Invalid profile URI: ${uriString}`); + registerJsonResourceTemplate(server, { + name: "profile", + uriTemplate: "profiles://{profileName}", + variableName: "profileName", + resourceLabel: "Profile", + read: (client, profileName) => client.profiles.retrieve(profileName), }); server.tool( "manage_profiles", - 'Manage browser profiles that persist cookies, logins, and session data across browser sessions. Use action "setup" to create/update a profile with a guided live browser session, "list" to see all profiles, or "delete" to remove one.', + 'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, and "delete" only when a profile should be removed.', { action: z .enum(["setup", "list", "delete"]) @@ -78,6 +84,11 @@ export function registerProfileCapabilities(server: McpServer) { .boolean() .describe("(setup) If true, update existing profile. Default false.") .optional(), + query: z + .string() + .describe("(list) Search profiles by name or ID.") + .optional(), + ...paginationParams, }, async (params, extra) => { if (!extra.authInfo) throw new Error("Authentication required"); @@ -87,14 +98,9 @@ export function registerProfileCapabilities(server: McpServer) { switch (params.action) { case "setup": { if (!params.profile_name) - return { - content: [ - { - type: "text", - text: "Error: profile_name is required for setup.", - }, - ], - }; + return errorResponse( + "Error: profile_name is required for setup.", + ); const existingProfiles = await listProfiles(client); const existingProfile = existingProfiles?.find( (p) => p.name === params.profile_name, @@ -104,24 +110,16 @@ export function registerProfileCapabilities(server: McpServer) { if (existingProfile) { if (!params.update_existing) { - return { - content: [ - { - type: "text", - text: `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, - }, - ], - }; + return errorResponse( + `Profile "${params.profile_name}" already exists (ID: ${existingProfile.id}). Set update_existing: true to update it, or choose a different name.`, + ); } profile = existingProfile; } else { profile = await client.profiles.create({ name: params.profile_name, }); - if (!profile) - return { - content: [{ type: "text", text: "Failed to create profile" }], - }; + if (!profile) return errorResponse("Failed to create profile"); isNewProfile = true; } @@ -131,83 +129,52 @@ export function registerProfileCapabilities(server: McpServer) { profile: { name: params.profile_name, save_changes: true }, }); if (!browser) - return { - content: [ - { - type: "text", - text: "Failed to create browser for profile setup", - }, - ], - }; + return errorResponse( + "Failed to create browser for profile setup", + ); - return { - content: [ - { - type: "text", - text: - `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + - `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + - `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + - `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, - }, - ], - }; + return textResponse( + `Profile "${params.profile_name}" ${isNewProfile ? "created" : "loaded for update"}.\n\n` + + `**Setup:** Open ${browser.browser_live_view_url} and sign into accounts to save.\n` + + `**When done:** Use manage_browsers with action "delete" and session_id "${browser.session_id}" to save the profile.\n\n` + + `Profile ID: ${profile.id} | Session ID: ${browser.session_id}`, + ); } case "list": { - const profiles = await listProfiles(client); - return { - content: [ - { - type: "text", - text: - profiles?.length > 0 - ? JSON.stringify(profiles, null, 2) - : "No profiles found. Use manage_profiles with action 'setup' to create one.", - }, - ], - }; + if (params.limit === undefined && params.offset === undefined) { + const profiles = await listProfiles( + client, + params.query ? { query: params.query } : undefined, + ); + return fullProfileListResponse(profiles); + } + + const page = await client.profiles.list({ + ...(params.query && { query: params.query }), + ...(params.limit !== undefined && { limit: params.limit }), + ...(params.offset !== undefined && { offset: params.offset }), + } satisfies ProfileListParams); + return paginatedJsonResponse(page); } case "delete": { if (params.profile_name && params.profile_id) { - return { - content: [ - { - type: "text", - text: "Error: Cannot specify both profile_name and profile_id.", - }, - ], - }; + return errorResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); } const identifier = params.profile_name || params.profile_id; if (!identifier) - return { - content: [ - { - type: "text", - text: "Error: profile_name or profile_id is required for delete.", - }, - ], - }; + return errorResponse( + "Error: profile_name or profile_id is required for delete.", + ); await client.profiles.delete(identifier); - return { - content: [ - { - type: "text", - text: `Profile "${identifier}" deleted successfully.`, - }, - ], - }; + return textResponse( + `Profile "${identifier}" deleted successfully.`, + ); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_profiles (${params.action}): ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; + return toolErrorResponse("manage_profiles", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/shell.ts b/src/lib/mcp/tools/shell.ts index 21d88e2..b8e9370 100644 --- a/src/lib/mcp/tools/shell.ts +++ b/src/lib/mcp/tools/shell.ts @@ -19,6 +19,8 @@ export function registerShellTool(server: McpServer) { cwd: z.string().describe("Working directory (absolute path).").optional(), timeout_sec: z .number() + .int() + .min(1) .describe("Max execution time in seconds.") .optional(), as_root: z.boolean().describe("Run with root privileges.").optional(),