diff --git a/README.md b/README.md index e779e59..3102bff 100644 --- a/README.md +++ b/README.md @@ -264,13 +264,13 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ ### 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. Create returns the plaintext key once. - `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_projects` - Create, list, get, update, and delete organization projects. -- `manage_api_keys` - Create, list, get, update, and delete Kernel API keys. Create returns the plaintext key once. +- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. - `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index 4d99a18..eafb72f 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -29,15 +29,17 @@ export function itemsJsonResponse( items: T[], options: ItemsJsonResponseOptions = {}, ) { - if (items.length === 0 && options.emptyText) { - return textResponse(options.emptyText); - } + // Keep the response shape uniform JSON for every list outcome. When empty, + // surface emptyText as a `note` (e.g. setup guidance) rather than swapping to + // a plain-text body, so agents always get { items, has_more, next_offset }. + const note = + items.length === 0 ? (options.emptyText ?? options.note) : options.note; return jsonResponse({ items: options.mapItem ? items.map(options.mapItem) : items, has_more: options.has_more, next_offset: options.next_offset, - ...(options.note && { note: options.note }), + ...(note && { note }), }); } diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 850f39a..8c969a1 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -6,6 +6,7 @@ import { errorResponse, jsonResponse, paginatedJsonResponse, + textResponse, toolErrorResponse, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -45,7 +46,7 @@ export function registerAppCapabilities(server: McpServer) { // manage_apps -- List apps, invoke actions, manage deployments, check invocations server.tool( "manage_apps", - 'Manage Kernel apps 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.', + '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, get/list actions to inspect results, and "delete_deployment" to remove a deployment.', { action: z .enum([ @@ -53,6 +54,7 @@ export function registerAppCapabilities(server: McpServer) { "invoke", "get_deployment", "list_deployments", + "delete_deployment", "get_invocation", ]) .describe("Operation to perform."), @@ -65,9 +67,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.") @@ -78,7 +81,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() @@ -89,7 +92,7 @@ export function registerAppCapabilities(server: McpServer) { { title: "Manage Kernel apps and invocations", readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, @@ -103,6 +106,7 @@ 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 }), }); @@ -164,13 +168,30 @@ export function registerAppCapabilities(server: McpServer) { return jsonResponse(deployment); } case "list_deployments": { + if (params.version && !params.app_name) { + return errorResponse( + "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 }), }); return paginatedJsonResponse(page); } + case "delete_deployment": { + if (!params.deployment_id) { + return errorResponse( + "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 errorResponse("Error: invocation_id is required."); diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index 83d52cf..5928607 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -1,12 +1,18 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + errorResponse, + itemsJsonResponse, + textResponse, + toolErrorResponse, +} 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 @@ -29,45 +35,22 @@ 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 itemsJsonResponse(extensions ?? [], { + has_more: false, + next_offset: null, + emptyText: "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 errorResponse("Error: id_or_name is required for delete."); + } await client.extensions.delete(params.id_or_name); - return { - content: [ - { type: "text", text: "Extension deleted successfully" }, - ], - }; + return textResponse("Extension deleted successfully"); } } } catch (error) { - return { - content: [ - { - type: "text", - text: `Error in manage_extensions (${params.action}): ${error}`, - }, - ], - }; + return toolErrorResponse("manage_extensions", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index 655b2ed..f0b30a4 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -5,6 +5,7 @@ import { registerJsonResourceTemplate } from "@/lib/mcp/resource-templates"; import { errorResponse, itemsJsonResponse, + jsonResponse, paginatedJsonResponse, textResponse, toolErrorResponse, @@ -24,12 +25,15 @@ async function listProfiles(client: KernelClient, query?: ProfileListParams) { return profiles; } -function fullProfileListResponse(profiles: Profile[]) { +function fullProfileListResponse(profiles: Profile[], query?: string) { return itemsJsonResponse(profiles, { has_more: false, next_offset: null, - emptyText: - "No profiles found. Use manage_profiles with action 'setup' to create one.", + // A search that matches nothing shouldn't claim the inventory is empty or + // suggest setup — other profiles may exist that just don't match the query. + emptyText: query + ? `No profiles match "${query}".` + : "No profiles found. Use manage_profiles with action 'setup' to create one.", }); } @@ -65,20 +69,18 @@ export function registerProfileCapabilities(server: McpServer) { server.tool( "manage_profiles", - 'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, and "delete" only when a profile should be removed.', + 'Manage browser profiles when an agent needs persistent cookies, login state, or reusable browser state. Use "setup" for a guided login session, "list" to find a profile, "get" to retrieve one, and "delete" only when a profile should be removed.', { 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() @@ -108,6 +110,9 @@ export function registerProfileCapabilities(server: McpServer) { return errorResponse( "Error: profile_name is required for setup.", ); + // Scan all profiles for an exact name match: the list `query` is a + // search and may not reliably return an exact-named profile, which + // would let setup create a duplicate. const existingProfiles = await listProfiles(client); const existingProfile = existingProfiles?.find( (p) => p.name === params.profile_name, @@ -153,7 +158,7 @@ export function registerProfileCapabilities(server: McpServer) { client, params.query ? { query: params.query } : undefined, ); - return fullProfileListResponse(profiles); + return fullProfileListResponse(profiles, params.query); } const page = await client.profiles.list({ @@ -161,7 +166,35 @@ export function registerProfileCapabilities(server: McpServer) { ...(params.limit !== undefined && { limit: params.limit }), ...(params.offset !== undefined && { offset: params.offset }), } satisfies ProfileListParams); - return paginatedJsonResponse(page); + // On the first page of a search with no results, note the empty + // match so agents can tell a failed search from an empty org. Skip + // it past offset 0, where an empty page may just be beyond the + // matches rather than a true miss. + const emptySearch = + params.query && + !params.offset && + page.getPaginatedItems().length === 0; + return paginatedJsonResponse( + page, + emptySearch + ? { note: `No profiles match "${params.query}".` } + : {}, + ); + } + case "get": { + if (params.profile_name && params.profile_id) { + return errorResponse( + "Error: Cannot specify both profile_name and profile_id.", + ); + } + const identifier = params.profile_name || params.profile_id; + if (!identifier) { + return errorResponse( + "Error: profile_name or profile_id is required for get.", + ); + } + const profile = await client.profiles.retrieve(identifier); + return jsonResponse(profile); } case "delete": { if (params.profile_name && params.profile_id) { diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index f7eda73..5bb7239 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -11,17 +11,27 @@ import { import { paginationParams } from "@/lib/mcp/schemas"; 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 @@ -35,6 +45,30 @@ export function registerProjectCapabilities(server: McpServer) { ) .optional(), ...paginationParams, + max_concurrent_invocations: z + .number() + .int() + .min(0) + .describe( + "(update_limits) Maximum concurrent app invocations for this project. Set 0 to remove the cap.", + ) + .optional(), + max_concurrent_sessions: z + .number() + .int() + .min(0) + .describe( + "(update_limits) Maximum concurrent browser sessions for this project. Set 0 to remove the cap.", + ) + .optional(), + max_pooled_sessions: z + .number() + .int() + .min(0) + .describe( + "(update_limits) Maximum pooled sessions capacity for this project. Set 0 to remove the cap.", + ) + .optional(), }, { title: "Manage Kernel projects", @@ -97,6 +131,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 errorResponse( + "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 errorResponse( + "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 errorResponse( + "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 toolErrorResponse("manage_projects", params.action, error); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 94b5725..e2b4b74 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -1,17 +1,47 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { createKernelClient } from "@/lib/mcp/kernel-client"; +import { + errorResponse, + itemsJsonResponse, + jsonResponse, + textResponse, + toolErrorResponse, +} 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.") @@ -63,23 +93,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 errorResponse("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 errorResponse( + "Error: custom_host and custom_port are required for custom proxy type.", + ); } const createParams: Parameters[0] = params.type === "custom" @@ -109,53 +130,43 @@ 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 errorResponse("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 itemsJsonResponse(proxies ?? [], { + has_more: false, + next_offset: null, + emptyText: "No proxies found", + }); + } + case "get": { + if (!params.proxy_id) { + return errorResponse("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 errorResponse("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 errorResponse("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 toolErrorResponse("manage_proxies", params.action, error); } }, );