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);
}
},
);