Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ jobs:

- name: Type check
run: bunx tsc --noEmit

- name: Test
run: bun test
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\""
},
Expand Down Expand Up @@ -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"
}
Expand Down
116 changes: 116 additions & 0 deletions src/lib/mcp/responses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/// <reference types="bun-types" />

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." },
]);
});
});
47 changes: 44 additions & 3 deletions src/lib/mcp/responses.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
APIConnectionError,
APIConnectionTimeoutError,
APIError,
APIUserAbortError,
} from "@onkernel/sdk";

type PaginatedPage<T> = {
getPaginatedItems(): T[];
has_more?: boolean | null;
Expand Down Expand Up @@ -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)}`,
);
}
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -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);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -206,7 +206,7 @@ export function registerAppCapabilities(server: McpServer) {
}
}
} catch (error) {
return toolErrorResponse("manage_apps", params.action, error);
throwToolError("manage_apps", params.action, error);
}
},
);
Expand Down
8 changes: 2 additions & 6 deletions src/lib/mcp/tools/auth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -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);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/browser-curl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KernelClient["browsers"]["curl"]>[1];
Expand Down Expand Up @@ -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);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/browser-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
errorResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -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);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/browsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";
import {
Expand Down Expand Up @@ -695,7 +695,7 @@ export function registerBrowserCapabilities(server: McpServer) {
}
}
} catch (error) {
return toolErrorResponse("manage_browsers", params.action, error);
throwToolError("manage_browsers", params.action, error);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/computer-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
errorResponse,
jsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";

type ComputerClient = KernelClient["browsers"]["computer"];
Expand Down Expand Up @@ -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);
}
},
);
Expand Down
8 changes: 2 additions & 6 deletions src/lib/mcp/tools/credential-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -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);
}
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/mcp/tools/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
jsonResponse,
paginatedJsonResponse,
textResponse,
toolErrorResponse,
throwToolError,
} from "@/lib/mcp/responses";
import { paginationParams } from "@/lib/mcp/schemas";

Expand Down Expand Up @@ -152,7 +152,7 @@ export function registerCredentialTools(server: McpServer) {
}
}
} catch (error) {
return toolErrorResponse("manage_credentials", params.action, error);
throwToolError("manage_credentials", params.action, error);
}
},
);
Expand Down
Loading
Loading