Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ Call `get_connection_context` before deciding whether to create or select a proj
- `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_browser_files` - Read, write, upload, download, and manage files in running browser VMs. Supports text and base64 input and returns binary downloads as embedded MCP resources.
- `manage_config_registry` - Look up current browser and proxy recommendations, start and inspect analyses, request cancellation, and list project configurations or analysis history.
- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
- `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan.
Expand Down
2 changes: 2 additions & 0 deletions src/lib/mcp/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const NON_AUTH_TOOLSETS = [
"browser_pools",
"config_registry",
"browser_curl",
"browser_files",
"proxies",
"extensions",
"apps",
Expand Down Expand Up @@ -211,6 +212,7 @@ describe("project selection registration", () => {
"manage_browsers",
"manage_browser_pools",
"browser_curl",
"manage_browser_files",
"manage_proxies",
"manage_extensions",
"manage_apps",
Expand Down
4 changes: 4 additions & 0 deletions src/lib/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerAPIKeyCapabilities } from "@/lib/mcp/tools/api-keys";
import { registerAppCapabilities } from "@/lib/mcp/tools/apps";
import { registerAuthConnectionTools } from "@/lib/mcp/tools/auth-connections";
import { registerAuthLoginApp } from "@/lib/mcp/tools/auth-login-app";
import { registerBrowserFileTools } from "@/lib/mcp/tools/browser-files";
import { registerBrowserPoolCapabilities } from "@/lib/mcp/tools/browser-pools";
import { registerBrowserCurlTool } from "@/lib/mcp/tools/browser-curl";
import { registerBrowserReplTool } from "@/lib/mcp/tools/browser-repl";
Expand Down Expand Up @@ -50,6 +51,7 @@ const mcpToolRegistrations = [
["browser_pools", registerBrowserPoolCapabilities],
["config_registry", registerConfigRegistryTools],
["browser_curl", registerBrowserCurlTool],
["browser_files", registerBrowserFileTools],
["proxies", registerProxyTools],
["extensions", registerExtensionTools],
["apps", registerAppCapabilities],
Expand Down Expand Up @@ -79,6 +81,8 @@ const standaloneToolsetAliases: Partial<Record<string, McpToolset>> = {
browser_repl: "repl",
exec_command: "shell",
browser_utilities: "browser_curl",
browser_fs: "browser_files",
manage_browser_files: "browser_files",
open_auth_login: "auth_connections",
};

Expand Down
1 change: 1 addition & 0 deletions src/lib/mcp/tool-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const KERNEL_MCP_TOOL_NAMES = [
"manage_api_keys",
"manage_apps",
"manage_auth_connections",
"manage_browser_files",
"manage_browser_pools",
"manage_browsers",
"manage_config_registry",
Expand Down
317 changes: 317 additions & 0 deletions src/lib/mcp/tools/browser-files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import { describe, expect, test } from "bun:test";
import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures";
import { registerBrowserFileTools } from "@/lib/mcp/tools/browser-files";

async function callBrowserFiles(fs: unknown, args: Record<string, unknown>) {
const { client, close } = await connectTestMcp(registerBrowserFileTools, {
browsers: { fs },
});
try {
return await client.callTool({
name: "manage_browser_files",
arguments: args,
});
} finally {
await close();
}
}

function text(result: Awaited<ReturnType<typeof callBrowserFiles>>) {
const [content] = result.content as Array<{ type: string; text?: string }>;
return content.type === "text" ? content.text : undefined;
}

describe("manage_browser_files", () => {
test("lists files", async () => {
const entries = [
{
is_dir: false,
mod_time: "2026-01-01T00:00:00Z",
mode: "-rw-r--r--",
name: "report.txt",
path: "/tmp/report.txt",
size_bytes: 6,
},
];
const fs = {
listFiles: async (sessionId: string, params: { path: string }) => {
expect(sessionId).toBe("session-1");
expect(params).toEqual({ path: "/tmp" });
return entries;
},
} as any;

const result = await callBrowserFiles(fs, {
action: "list",
session_id: "session-1",
path: "/tmp",
});

Comment thread
cursor[bot] marked this conversation as resolved.
expect(JSON.parse(text(result)!)).toEqual({ items: entries });
});

test("reads text without wrapping the contents", async () => {
const fs = {
readFile: async () => new Response("hello\nworld\n"),
} as any;

const result = await callBrowserFiles(fs, {
action: "read",
session_id: "session-1",
path: "/tmp/hello.txt",
});

expect(text(result)).toBe("hello\nworld\n");
});

test("returns binary downloads as embedded resources", async () => {
const fs = {
readFile: async () =>
new Response(new Uint8Array([0, 1, 2]), {
headers: { "content-type": "image/png" },
}),
} as any;

const result = await callBrowserFiles(fs, {
action: "download",
session_id: "session-1",
path: "/tmp/a file.png",
});

expect(result.content).toEqual([
{
type: "resource",
resource: {
uri: "kernel-browser-file://session-1/tmp/a%20file.png",
blob: "AAEC",
mimeType: "image/png",
},
},
]);
});

test("decodes base64 writes", async () => {
let written: Uint8Array | undefined;
const fs = {
writeFile: async (
sessionId: string,
contents: Uint8Array,
params: { path: string; mode?: string },
) => {
expect(sessionId).toBe("session-1");
expect(params).toEqual({ path: "/tmp/file.bin", mode: "0600" });
written = contents;
},
} as any;

const result = await callBrowserFiles(fs, {
action: "write",
session_id: "session-1",
path: "/tmp/file.bin",
content: "AAEC",
encoding: "base64",
mode: "0600",
});

expect([...written!]).toEqual([0, 1, 2]);
expect(text(result)).toBe("Wrote file /tmp/file.bin");
});

test("rejects malformed base64 before writing", async () => {
let called = false;
const fs = {
writeFile: async () => {
called = true;
},
} as any;

const result = await callBrowserFiles(fs, {
action: "write",
session_id: "session-1",
path: "/tmp/file.bin",
content: "not base64!",
encoding: "base64",
});

expect(called).toBe(false);
expect(result.isError).toBe(true);
expect(text(result)).toBe("Error: content is not valid base64.");
});

test("uploads multiple files", async () => {
let uploaded: any;
const fs = {
upload: async (sessionId: string, params: any) => {
expect(sessionId).toBe("session-1");
uploaded = params;
},
} as any;

const result = await callBrowserFiles(fs, {
action: "upload",
session_id: "session-1",
files: [
{ dest_path: "/tmp/one.txt", content: "one" },
{
dest_path: "/tmp/two.bin",
content: "dHdv",
encoding: "base64",
},
],
});

expect(uploaded.files.map((file: any) => file.dest_path)).toEqual([
"/tmp/one.txt",
"/tmp/two.bin",
]);
expect(await uploaded.files[0].file.text()).toBe("one");
expect(await uploaded.files[1].file.text()).toBe("two");
expect(text(result)).toBe("Uploaded 2 file(s)");
});

test("downloads directories as embedded zip resources", async () => {
const fs = {
downloadDirZip: async () => new Response(new Uint8Array([80, 75])),
} as any;

const result = await callBrowserFiles(fs, {
action: "download_dir_zip",
session_id: "session-1",
path: "/tmp/reports/",
});

expect((result.content as unknown[])[0]).toEqual({
type: "resource",
resource: {
uri: "kernel-browser-file://session-1/tmp/reports.zip",
blob: "UEs=",
mimeType: "application/zip",
},
});
});

test("uses an absolute resource path when downloading the root directory", async () => {
const fs = {
downloadDirZip: async () => new Response(new Uint8Array([80, 75])),
} as any;

const result = await callBrowserFiles(fs, {
action: "download_dir_zip",
session_id: "session-1",
path: "/",
});

expect((result.content as unknown[])[0]).toEqual({
type: "resource",
resource: {
uri: "kernel-browser-file://session-1/browser-files.zip",
blob: "UEs=",
mimeType: "application/zip",
},
});
});

test("routes filesystem mutations to the SDK", async () => {
const calls: Array<[string, unknown]> = [];
const fs = {
createDirectory: async (_id: string, params: unknown) =>
calls.push(["createDirectory", params]),
move: async (_id: string, params: unknown) =>
calls.push(["move", params]),
deleteFile: async (_id: string, params: unknown) =>
calls.push(["deleteFile", params]),
deleteDirectory: async (_id: string, params: unknown) =>
calls.push(["deleteDirectory", params]),
setFilePermissions: async (_id: string, params: unknown) =>
calls.push(["setFilePermissions", params]),
} as any;

await callBrowserFiles(fs, {
action: "create_directory",
session_id: "session-1",
path: "/tmp/new",
mode: "0755",
});
await callBrowserFiles(fs, {
action: "move",
session_id: "session-1",
src_path: "/tmp/old",
dest_path: "/tmp/new",
});
await callBrowserFiles(fs, {
action: "delete_file",
session_id: "session-1",
path: "/tmp/file",
});
await callBrowserFiles(fs, {
action: "delete_directory",
session_id: "session-1",
path: "/tmp/dir",
});
await callBrowserFiles(fs, {
action: "set_permissions",
session_id: "session-1",
path: "/tmp/file",
mode: "0640",
owner: "1000",
group: "1000",
});

expect(calls).toEqual([
["createDirectory", { path: "/tmp/new", mode: "0755" }],
["move", { src_path: "/tmp/old", dest_path: "/tmp/new" }],
["deleteFile", { path: "/tmp/file" }],
["deleteDirectory", { path: "/tmp/dir" }],
[
"setFilePermissions",
{ path: "/tmp/file", mode: "0640", owner: "1000", group: "1000" },
],
]);
});

test("reports missing action parameters without calling the SDK", async () => {
const fs = new Proxy(
{},
{
get: () => {
throw new Error("unexpected SDK call");
},
},
) as any;

const result = await callBrowserFiles(fs, {
action: "move",
session_id: "session-1",
src_path: "/tmp/source",
});

expect(result.isError).toBe(true);
expect(text(result)).toBe("Error: dest_path is required for move.");
});

test("rejects empty session IDs and paths before calling the SDK", async () => {
const fs = new Proxy(
{},
{
get: () => {
throw new Error("unexpected SDK call");
},
},
);

for (const args of [
{ action: "list", session_id: "", path: "/tmp" },
{ action: "list", session_id: "session-1", path: "" },
{
action: "move",
session_id: "session-1",
src_path: "",
dest_path: "/b",
},
]) {
const result = await callBrowserFiles(fs, args);
expect(result.isError).toBe(true);
expect(text(result)).not.toContain("unexpected SDK call");
}
});
});
Loading
Loading