diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6b331d..1f26374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,6 @@ jobs: - name: Type check run: bunx tsc --noEmit + + - name: Test + run: bun test diff --git a/bun.lock b/bun.lock index 98bc3f1..7db18e2 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "bun-types": "^1.3.14", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", }, @@ -229,6 +230,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/package.json b/package.json index 57f008c..77aa08b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "build": "next build", "start": "next start -p 3002", "lint": "next lint", + "test": "bun test", "format": "prettier --write \"**/*.{ts,js,json,md}\"", "format:check": "prettier --check \"**/*.{ts,js,json,md}\"" }, @@ -61,6 +62,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "bun-types": "^1.3.14", "postcss": "^8.5.6", "tailwindcss": "^4.1.11" } diff --git a/src/lib/mcp/responses.test.ts b/src/lib/mcp/responses.test.ts new file mode 100644 index 0000000..b13332e --- /dev/null +++ b/src/lib/mcp/responses.test.ts @@ -0,0 +1,116 @@ +/// + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + APIUserAbortError, +} from "@onkernel/sdk"; +import { describe, expect, test } from "bun:test"; + +import { errorResponse, throwToolError } from "@/lib/mcp/responses"; + +function apiError(status: number, message: string) { + return APIError.generate(status, undefined, message, new Headers()); +} + +function caught(error: unknown) { + try { + throwToolError("manage_browsers", "get", error); + } catch (thrown) { + return thrown as Error; + } + throw new Error("throwToolError did not throw"); +} + +describe("throwToolError classification", () => { + test("names Kernel API failures after their status", () => { + expect(caught(apiError(404, "not found")).name).toBe("KernelApiError404"); + expect(caught(apiError(429, "too many requests")).name).toBe( + "KernelApiError429", + ); + expect(caught(apiError(502, "bad gateway")).name).toBe("KernelApiError502"); + }); + + test("names transport failures without relying on class names", () => { + // The SDK's error classes are minified in the production bundle, so + // constructor.name reads as a mangled identifier there. These come from + // instanceof checks instead. + expect(caught(new APIConnectionTimeoutError({})).name).toBe( + "KernelApiTimeout", + ); + expect( + caught(new APIConnectionError({ message: "socket hang up" })).name, + ).toBe("KernelApiConnectionError"); + expect(caught(new APIUserAbortError({})).name).toBe("KernelApiAborted"); + }); + + test("falls back to a generic name for everything else", () => { + expect(caught(new Error("boom")).name).toBe("Error"); + expect(caught(new TypeError("bad arg")).name).toBe("TypeError"); + expect(caught("plain string").name).toBe("Error"); + }); + + test("keeps the message the tool already produced", () => { + expect(caught(apiError(404, "not found")).message).toBe( + "Error in manage_browsers (get): 404 not found", + ); + expect(caught("plain string").message).toBe( + "Error in manage_browsers (get): plain string", + ); + }); +}); + +describe("what the client receives", () => { + async function callTool(name: string) { + const server = new McpServer({ name: "test", version: "0.0.0" }); + + server.tool("api_failure", {}, async () => { + throwToolError( + "manage_browsers", + "get", + apiError(404, "browser session not found"), + ); + }); + + server.tool("input_guard", {}, async () => + errorResponse("Error: session_id is required for get action."), + ); + + const client = new Client({ name: "test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + + const result = await client.callTool({ name, arguments: {} }); + await client.close(); + return result; + } + + test("a thrown API failure still arrives as an isError text result", async () => { + const result = await callTool("api_failure"); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: "text", + text: "Error in manage_browsers (get): 404 browser session not found", + }, + ]); + }); + + test("input guards are unchanged", async () => { + const result = await callTool("input_guard"); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: "text", text: "Error: session_id is required for get action." }, + ]); + }); +}); diff --git a/src/lib/mcp/responses.ts b/src/lib/mcp/responses.ts index c97f76c..1481dac 100644 --- a/src/lib/mcp/responses.ts +++ b/src/lib/mcp/responses.ts @@ -1,3 +1,10 @@ +import { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + APIUserAbortError, +} from "@onkernel/sdk"; + type PaginatedPage = { getPaginatedItems(): T[]; has_more?: boolean | null; @@ -64,12 +71,46 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } -export function toolErrorResponse( +// Named after what the API said, so a stale session id (404) is distinguishable from an +// org hitting its limits (429) or a fault on our side (5xx). Status codes only; the +// message stays out. +class ToolCallError extends Error { + constructor(name: string, message: string) { + super(message); + this.name = name; + } +} + +function errorName(error: unknown) { + if (error instanceof APIError) { + if (typeof error.status === "number") { + return `KernelApiError${error.status}`; + } + // No status means the request never got a response. Classified by instance + // rather than class name, which the production bundle minifies. + if (error instanceof APIConnectionTimeoutError) return "KernelApiTimeout"; + if (error instanceof APIConnectionError) return "KernelApiConnectionError"; + if (error instanceof APIUserAbortError) return "KernelApiAborted"; + return "KernelApiError"; + } + return error instanceof Error ? error.name : "Error"; +} + +/** + * Fails a tool call that a Kernel API request rejected. + * + * Throws rather than returning an isError result: analytics reads the error category + * from a thrown error's name, while a returned result only ever coerces to a generic + * "Error". The MCP SDK turns the throw back into the same isError text result the client + * saw before, so agents see no difference. + */ +export function throwToolError( toolName: string, action: string, error: unknown, -) { - return errorResponse( +): never { + throw new ToolCallError( + errorName(error), `Error in ${toolName} (${action}): ${errorMessage(error)}`, ); } diff --git a/src/lib/mcp/tools/api-keys.ts b/src/lib/mcp/tools/api-keys.ts index 9c34f2a..8690a03 100644 --- a/src/lib/mcp/tools/api-keys.ts +++ b/src/lib/mcp/tools/api-keys.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -107,7 +107,7 @@ export function registerAPIKeyCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_api_keys", params.action, error); + throwToolError("manage_api_keys", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/apps.ts b/src/lib/mcp/tools/apps.ts index 8c969a1..e395fc7 100644 --- a/src/lib/mcp/tools/apps.ts +++ b/src/lib/mcp/tools/apps.ts @@ -7,7 +7,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -206,7 +206,7 @@ export function registerAppCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_apps", params.action, error); + throwToolError("manage_apps", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/auth-connections.ts b/src/lib/mcp/tools/auth-connections.ts index 73206f1..70e8bee 100644 --- a/src/lib/mcp/tools/auth-connections.ts +++ b/src/lib/mcp/tools/auth-connections.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -255,11 +255,7 @@ export function registerAuthConnectionTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse( - "manage_auth_connections", - params.action, - error, - ); + throwToolError("manage_auth_connections", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browser-curl.ts b/src/lib/mcp/tools/browser-curl.ts index c7a2687..eb297cf 100644 --- a/src/lib/mcp/tools/browser-curl.ts +++ b/src/lib/mcp/tools/browser-curl.ts @@ -4,7 +4,7 @@ import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; import { errorResponse, jsonResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; type BrowserCurlParams = Parameters[1]; @@ -74,7 +74,7 @@ export function registerBrowserCurlTool(server: McpServer) { const response = await client.browsers.curl(session_id, curlParams); return jsonResponse(response); } catch (error) { - return toolErrorResponse("browser_curl", "request", error); + throwToolError("browser_curl", "request", error); } }, ); diff --git a/src/lib/mcp/tools/browser-pools.ts b/src/lib/mcp/tools/browser-pools.ts index 20ced89..ae6072f 100644 --- a/src/lib/mcp/tools/browser-pools.ts +++ b/src/lib/mcp/tools/browser-pools.ts @@ -12,7 +12,7 @@ import { errorResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -474,7 +474,7 @@ export function registerBrowserPoolCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_browser_pools", params.action, error); + throwToolError("manage_browser_pools", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 6865fe2..3fe0781 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -13,7 +13,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; import { @@ -695,7 +695,7 @@ export function registerBrowserCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_browsers", params.action, error); + throwToolError("manage_browsers", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/computer-action.ts b/src/lib/mcp/tools/computer-action.ts index 58291da..9a2100b 100644 --- a/src/lib/mcp/tools/computer-action.ts +++ b/src/lib/mcp/tools/computer-action.ts @@ -5,7 +5,7 @@ import { errorResponse, jsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; type ComputerClient = KernelClient["browsers"]["computer"]; @@ -353,7 +353,7 @@ export function registerComputerActionTool(server: McpServer) { `Executed ${executedActionCount} action(s) successfully`, ); } catch (error) { - return toolErrorResponse("computer_action", "actions", error); + throwToolError("computer_action", "actions", error); } }, ); diff --git a/src/lib/mcp/tools/credential-providers.ts b/src/lib/mcp/tools/credential-providers.ts index e13f05e..9a7b744 100644 --- a/src/lib/mcp/tools/credential-providers.ts +++ b/src/lib/mcp/tools/credential-providers.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -164,11 +164,7 @@ export function registerCredentialProviderTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse( - "manage_credential_providers", - params.action, - error, - ); + throwToolError("manage_credential_providers", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/credentials.ts b/src/lib/mcp/tools/credentials.ts index cb7f2c3..04a865d 100644 --- a/src/lib/mcp/tools/credentials.ts +++ b/src/lib/mcp/tools/credentials.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -152,7 +152,7 @@ export function registerCredentialTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_credentials", params.action, error); + throwToolError("manage_credentials", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/extensions.ts b/src/lib/mcp/tools/extensions.ts index f20d831..320472d 100644 --- a/src/lib/mcp/tools/extensions.ts +++ b/src/lib/mcp/tools/extensions.ts @@ -5,7 +5,7 @@ import { errorResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -53,7 +53,7 @@ export function registerExtensionTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_extensions", params.action, error); + throwToolError("manage_extensions", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/profiles.ts b/src/lib/mcp/tools/profiles.ts index f0b30a4..c444a3e 100644 --- a/src/lib/mcp/tools/profiles.ts +++ b/src/lib/mcp/tools/profiles.ts @@ -8,7 +8,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -214,7 +214,7 @@ export function registerProfileCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_profiles", params.action, error); + throwToolError("manage_profiles", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/projects.ts b/src/lib/mcp/tools/projects.ts index 5bb7239..f8af0e0 100644 --- a/src/lib/mcp/tools/projects.ts +++ b/src/lib/mcp/tools/projects.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -175,7 +175,7 @@ export function registerProjectCapabilities(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_projects", params.action, error); + throwToolError("manage_projects", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/proxies.ts b/src/lib/mcp/tools/proxies.ts index 632ed10..974503f 100644 --- a/src/lib/mcp/tools/proxies.ts +++ b/src/lib/mcp/tools/proxies.ts @@ -6,7 +6,7 @@ import { jsonResponse, paginatedJsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; import { paginationParams } from "@/lib/mcp/schemas"; @@ -169,7 +169,7 @@ export function registerProxyTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_proxies", params.action, error); + throwToolError("manage_proxies", params.action, error); } }, ); diff --git a/src/lib/mcp/tools/replays.ts b/src/lib/mcp/tools/replays.ts index d754a16..12c728a 100644 --- a/src/lib/mcp/tools/replays.ts +++ b/src/lib/mcp/tools/replays.ts @@ -6,7 +6,7 @@ import { itemsJsonResponse, jsonResponse, textResponse, - toolErrorResponse, + throwToolError, } from "@/lib/mcp/responses"; export function registerReplayTools(server: McpServer) { @@ -90,7 +90,7 @@ export function registerReplayTools(server: McpServer) { } } } catch (error) { - return toolErrorResponse("manage_replays", params.action, error); + throwToolError("manage_replays", params.action, error); } }, );