From 7e502774d5378fa1e8427e12c3eee1be894dd676 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:03:07 +0000 Subject: [PATCH 1/2] Expose browser filesystem through MCP --- README.md | 3 +- src/lib/mcp/register.test.ts | 1 + src/lib/mcp/register.ts | 4 + src/lib/mcp/tools/browser-files.test.ts | 256 ++++++++++++++++++++ src/lib/mcp/tools/browser-files.ts | 307 ++++++++++++++++++++++++ 5 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 src/lib/mcp/tools/browser-files.test.ts create mode 100644 src/lib/mcp/tools/browser-files.ts diff --git a/README.md b/README.md index de770803..752c2828 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (17 model-facing, plus 1 app-only helper) +## Tools (18 model-facing, plus 1 app-only helper) Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows. @@ -270,6 +270,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `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_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. - `manage_extensions` - List and delete uploaded browser extensions. diff --git a/src/lib/mcp/register.test.ts b/src/lib/mcp/register.test.ts index a842dfba..66ca4800 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -10,6 +10,7 @@ const NON_AUTH_TOOLSETS = [ "api_keys", "browser_pools", "browser_curl", + "browser_files", "proxies", "extensions", "apps", diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index c7b9b0c1..e894306c 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -4,6 +4,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 { registerBrowserCapabilities } from "@/lib/mcp/tools/browsers"; @@ -33,6 +34,7 @@ const mcpToolRegistrations = [ ["api_keys", registerAPIKeyCapabilities], ["browser_pools", registerBrowserPoolCapabilities], ["browser_curl", registerBrowserCurlTool], + ["browser_files", registerBrowserFileTools], ["proxies", registerProxyTools], ["extensions", registerExtensionTools], ["apps", registerAppCapabilities], @@ -56,6 +58,8 @@ const standaloneToolsetAliases: Partial> = { execute_playwright_code: "playwright", exec_command: "shell", browser_utilities: "browser_curl", + browser_fs: "browser_files", + manage_browser_files: "browser_files", open_auth_login: "auth_connections", }; diff --git a/src/lib/mcp/tools/browser-files.test.ts b/src/lib/mcp/tools/browser-files.test.ts new file mode 100644 index 00000000..9313156a --- /dev/null +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test"; +import { runBrowserFileAction } from "@/lib/mcp/tools/browser-files"; + +function text(result: Awaited>) { + return result.content[0].type === "text" ? result.content[0].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 runBrowserFileAction(fs, { + action: "list", + session_id: "session-1", + path: "/tmp", + }); + + 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 runBrowserFileAction(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 runBrowserFileAction(fs, { + action: "download", + session_id: "session-1", + path: "/tmp/a file.png", + }); + + expect(result).toEqual({ + content: [ + { + 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 runBrowserFileAction(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 runBrowserFileAction(fs, { + action: "write", + session_id: "session-1", + path: "/tmp/file.bin", + content: "not base64!", + encoding: "base64", + }); + + expect(called).toBe(false); + expect("isError" in result && 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 runBrowserFileAction(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 runBrowserFileAction(fs, { + action: "download_dir_zip", + session_id: "session-1", + path: "/tmp/reports/", + }); + + expect(result.content[0]).toEqual({ + type: "resource", + resource: { + uri: "kernel-browser-file://session-1/tmp/reports.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 runBrowserFileAction(fs, { + action: "create_directory", + session_id: "session-1", + path: "/tmp/new", + mode: "0755", + }); + await runBrowserFileAction(fs, { + action: "move", + session_id: "session-1", + src_path: "/tmp/old", + dest_path: "/tmp/new", + }); + await runBrowserFileAction(fs, { + action: "delete_file", + session_id: "session-1", + path: "/tmp/file", + }); + await runBrowserFileAction(fs, { + action: "delete_directory", + session_id: "session-1", + path: "/tmp/dir", + }); + await runBrowserFileAction(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 runBrowserFileAction(fs, { + action: "move", + session_id: "session-1", + src_path: "/tmp/source", + }); + + expect("isError" in result && result.isError).toBe(true); + expect(text(result)).toBe("Error: dest_path is required for move."); + }); +}); diff --git a/src/lib/mcp/tools/browser-files.ts b/src/lib/mcp/tools/browser-files.ts new file mode 100644 index 00000000..6999be7a --- /dev/null +++ b/src/lib/mcp/tools/browser-files.ts @@ -0,0 +1,307 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { toFile } from "@onkernel/sdk"; +import { z } from "zod"; +import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { + errorResponse, + itemsJsonResponse, + jsonResponse, + textResponse, + throwToolError, +} from "@/lib/mcp/responses"; + +const fileContentSchema = z.object({ + dest_path: z + .string() + .describe("Absolute destination path in the browser VM."), + content: z.string().describe("File contents, encoded according to encoding."), + encoding: z + .enum(["utf8", "base64"]) + .describe("Encoding of content. Defaults to utf8.") + .optional(), +}); + +const browserFileParamsSchema = z.object({ + action: z + .enum([ + "list", + "get_info", + "read", + "download", + "write", + "upload", + "upload_zip", + "download_dir_zip", + "create_directory", + "move", + "delete_file", + "delete_directory", + "set_permissions", + ]) + .describe("Filesystem operation to perform."), + session_id: z.string().describe("Browser session ID."), + path: z + .string() + .describe("Absolute file or directory path in the browser VM.") + .optional(), + src_path: z.string().describe("(move) Absolute source path.").optional(), + dest_path: z + .string() + .describe("(move, upload_zip) Absolute destination path.") + .optional(), + content: z + .string() + .describe("(write, upload_zip) Contents encoded according to encoding.") + .optional(), + encoding: z + .enum(["utf8", "base64"]) + .describe("(write, upload_zip) Encoding of content. Defaults to utf8.") + .optional(), + files: z + .array(fileContentSchema) + .min(1) + .describe("(upload) Files to upload in one request.") + .optional(), + mime_type: z + .string() + .describe( + "(download) MIME type for the returned embedded resource. Defaults to the API response type or application/octet-stream.", + ) + .optional(), + mode: z + .string() + .regex(/^[0-7]{3,4}$/) + .describe( + "(write, create_directory, set_permissions) Octal permission mode, such as 644 or 0755.", + ) + .optional(), + owner: z + .string() + .describe("(set_permissions) New owner username or UID.") + .optional(), + group: z + .string() + .describe("(set_permissions) New group name or GID.") + .optional(), +}); + +type BrowserFileParams = z.infer; +type BrowserFsClient = KernelClient["browsers"]["fs"]; + +function required(value: string | undefined, name: string, action: string) { + if (value !== undefined) return value; + return errorResponse(`Error: ${name} is required for ${action}.`); +} + +function decodeContent(content: string, encoding: "utf8" | "base64" = "utf8") { + if (encoding === "utf8") return Buffer.from(content, "utf8"); + + const normalized = content.replace(/\s/g, ""); + if ( + normalized.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + normalized, + ) + ) { + return undefined; + } + return Buffer.from(normalized, "base64"); +} + +function encodedPath(path: string) { + return path + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function embeddedFileResponse( + sessionId: string, + path: string, + buffer: Buffer, + mimeType: string, +) { + return { + content: [ + { + type: "resource" as const, + resource: { + uri: `kernel-browser-file://${encodeURIComponent(sessionId)}${encodedPath(path)}`, + blob: buffer.toString("base64"), + mimeType, + }, + }, + ], + }; +} + +async function responseBuffer(response: Response) { + return Buffer.from(await response.arrayBuffer()); +} + +export async function runBrowserFileAction( + fs: BrowserFsClient, + params: BrowserFileParams, +) { + switch (params.action) { + case "list": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const files = await fs.listFiles(params.session_id, { path }); + return itemsJsonResponse(files, { + emptyText: `No files found in ${path}`, + }); + } + case "get_info": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + return jsonResponse(await fs.fileInfo(params.session_id, { path })); + } + case "read": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const response = await fs.readFile(params.session_id, { path }); + return textResponse((await responseBuffer(response)).toString("utf8")); + } + case "download": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const response = await fs.readFile(params.session_id, { path }); + const buffer = await responseBuffer(response); + return embeddedFileResponse( + params.session_id, + path, + buffer, + params.mime_type || + response.headers.get("content-type") || + "application/octet-stream", + ); + } + case "write": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const content = required(params.content, "content", params.action); + if (typeof content !== "string") return content; + const decoded = decodeContent(content, params.encoding); + if (!decoded) return errorResponse("Error: content is not valid base64."); + await fs.writeFile(params.session_id, decoded, { + path, + ...(params.mode && { mode: params.mode }), + }); + return textResponse(`Wrote file ${path}`); + } + case "upload": { + if (!params.files) + return errorResponse("Error: files is required for upload."); + const files = []; + for (const file of params.files) { + const decoded = decodeContent(file.content, file.encoding); + if (!decoded) { + return errorResponse( + `Error: content for ${file.dest_path} is not valid base64.`, + ); + } + files.push({ + dest_path: file.dest_path, + file: await toFile(decoded, file.dest_path.split("/").pop()), + }); + } + await fs.upload(params.session_id, { files }); + return textResponse(`Uploaded ${files.length} file(s)`); + } + case "upload_zip": { + const destPath = required(params.dest_path, "dest_path", params.action); + if (typeof destPath !== "string") return destPath; + const content = required(params.content, "content", params.action); + if (typeof content !== "string") return content; + const decoded = decodeContent(content, params.encoding); + if (!decoded) return errorResponse("Error: content is not valid base64."); + await fs.uploadZip(params.session_id, { + dest_path: destPath, + zip_file: await toFile(decoded, "upload.zip"), + }); + return textResponse(`Uploaded and extracted archive to ${destPath}`); + } + case "download_dir_zip": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const response = await fs.downloadDirZip(params.session_id, { path }); + return embeddedFileResponse( + params.session_id, + `${path.replace(/\/$/, "")}.zip`, + await responseBuffer(response), + "application/zip", + ); + } + case "create_directory": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + await fs.createDirectory(params.session_id, { + path, + ...(params.mode && { mode: params.mode }), + }); + return textResponse(`Created directory ${path}`); + } + case "move": { + const srcPath = required(params.src_path, "src_path", params.action); + if (typeof srcPath !== "string") return srcPath; + const destPath = required(params.dest_path, "dest_path", params.action); + if (typeof destPath !== "string") return destPath; + await fs.move(params.session_id, { + src_path: srcPath, + dest_path: destPath, + }); + return textResponse(`Moved ${srcPath} to ${destPath}`); + } + case "delete_file": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + await fs.deleteFile(params.session_id, { path }); + return textResponse(`Deleted file ${path}`); + } + case "delete_directory": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + await fs.deleteDirectory(params.session_id, { path }); + return textResponse(`Deleted directory ${path}`); + } + case "set_permissions": { + const path = required(params.path, "path", params.action); + if (typeof path !== "string") return path; + const mode = required(params.mode, "mode", params.action); + if (typeof mode !== "string") return mode; + await fs.setFilePermissions(params.session_id, { + path, + mode, + ...(params.owner && { owner: params.owner }), + ...(params.group && { group: params.group }), + }); + return textResponse(`Updated permissions for ${path}`); + } + } +} + +export function registerBrowserFileTools(server: McpServer) { + server.tool( + "manage_browser_files", + 'Read, write, upload, download, and manage files in a running browser VM. Use "read" for text content and "download" for binary files returned as an embedded MCP resource. Local files must be supplied as utf8 or base64 content because the remote MCP server cannot access paths on the caller\'s machine.', + browserFileParamsSchema.shape, + { + title: "Manage browser VM files", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + async (params, extra) => { + if (!extra.authInfo) throw new Error("Authentication required"); + const client = createKernelClient(extra.authInfo.token); + + try { + return await runBrowserFileAction(client.browsers.fs, params); + } catch (error) { + throwToolError("manage_browser_files", params.action, error); + } + }, + ); +} From 34756cd566d906551e733ee24f5338cb82204a08 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:14:25 +0000 Subject: [PATCH 2/2] Handle root filesystem archive downloads --- src/lib/mcp/tools/browser-files.test.ts | 21 +++++++++++++++++++++ src/lib/mcp/tools/browser-files.ts | 10 ++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/tools/browser-files.test.ts b/src/lib/mcp/tools/browser-files.test.ts index 9313156a..bd0dd4d6 100644 --- a/src/lib/mcp/tools/browser-files.test.ts +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -176,6 +176,27 @@ describe("manage_browser_files", () => { }); }); + 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 runBrowserFileAction(fs, { + action: "download_dir_zip", + session_id: "session-1", + path: "/", + }); + + expect(result.content[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 = { diff --git a/src/lib/mcp/tools/browser-files.ts b/src/lib/mcp/tools/browser-files.ts index 6999be7a..5126d94a 100644 --- a/src/lib/mcp/tools/browser-files.ts +++ b/src/lib/mcp/tools/browser-files.ts @@ -109,12 +109,18 @@ function decodeContent(content: string, encoding: "utf8" | "base64" = "utf8") { } function encodedPath(path: string) { - return path + const absolutePath = path.startsWith("/") ? path : `/${path}`; + return absolutePath .split("/") .map((part) => encodeURIComponent(part)) .join("/"); } +function zipResourcePath(path: string) { + const directoryPath = path.replace(/\/+$/, ""); + return `${directoryPath || "/browser-files"}.zip`; +} + function embeddedFileResponse( sessionId: string, path: string, @@ -228,7 +234,7 @@ export async function runBrowserFileAction( const response = await fs.downloadDirZip(params.session_id, { path }); return embeddedFileResponse( params.session_id, - `${path.replace(/\/$/, "")}.zip`, + zipResourcePath(path), await responseBuffer(response), "application/zip", );