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/5] 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/5] 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", ); From 24ae9c87ecdb5eecdf4c2ba0dbafb2742cee4567 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:30:51 +0000 Subject: [PATCH 3/5] Align manage_browser_files with MCP tool conventions Require non-empty session IDs and paths, document which actions use path, inject the Kernel client factory, and test the tool through a real MCP client and server. --- src/lib/mcp/tools/browser-files.test.ts | 104 ++++++++++++++++-------- src/lib/mcp/tools/browser-files.ts | 32 ++++++-- 2 files changed, 97 insertions(+), 39 deletions(-) diff --git a/src/lib/mcp/tools/browser-files.test.ts b/src/lib/mcp/tools/browser-files.test.ts index bd0dd4d6..0cb9911b 100644 --- a/src/lib/mcp/tools/browser-files.test.ts +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -1,8 +1,24 @@ import { describe, expect, test } from "bun:test"; -import { runBrowserFileAction } from "@/lib/mcp/tools/browser-files"; +import { connectTestMcp } from "@/lib/mcp/mcp-test-fixtures"; +import { registerBrowserFileTools } from "@/lib/mcp/tools/browser-files"; -function text(result: Awaited>) { - return result.content[0].type === "text" ? result.content[0].text : undefined; +async function callBrowserFiles(fs: unknown, args: Record) { + 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>) { + const [content] = result.content as Array<{ type: string; text?: string }>; + return content.type === "text" ? content.text : undefined; } describe("manage_browser_files", () => { @@ -25,7 +41,7 @@ describe("manage_browser_files", () => { }, } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "list", session_id: "session-1", path: "/tmp", @@ -39,7 +55,7 @@ describe("manage_browser_files", () => { readFile: async () => new Response("hello\nworld\n"), } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "read", session_id: "session-1", path: "/tmp/hello.txt", @@ -56,24 +72,22 @@ describe("manage_browser_files", () => { }), } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(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", - }, + 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 () => { @@ -90,7 +104,7 @@ describe("manage_browser_files", () => { }, } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "write", session_id: "session-1", path: "/tmp/file.bin", @@ -111,7 +125,7 @@ describe("manage_browser_files", () => { }, } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "write", session_id: "session-1", path: "/tmp/file.bin", @@ -120,7 +134,7 @@ describe("manage_browser_files", () => { }); expect(called).toBe(false); - expect("isError" in result && result.isError).toBe(true); + expect(result.isError).toBe(true); expect(text(result)).toBe("Error: content is not valid base64."); }); @@ -133,7 +147,7 @@ describe("manage_browser_files", () => { }, } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "upload", session_id: "session-1", files: [ @@ -160,13 +174,13 @@ describe("manage_browser_files", () => { downloadDirZip: async () => new Response(new Uint8Array([80, 75])), } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "download_dir_zip", session_id: "session-1", path: "/tmp/reports/", }); - expect(result.content[0]).toEqual({ + expect((result.content as unknown[])[0]).toEqual({ type: "resource", resource: { uri: "kernel-browser-file://session-1/tmp/reports.zip", @@ -181,13 +195,13 @@ describe("manage_browser_files", () => { downloadDirZip: async () => new Response(new Uint8Array([80, 75])), } as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "download_dir_zip", session_id: "session-1", path: "/", }); - expect(result.content[0]).toEqual({ + expect((result.content as unknown[])[0]).toEqual({ type: "resource", resource: { uri: "kernel-browser-file://session-1/browser-files.zip", @@ -212,29 +226,29 @@ describe("manage_browser_files", () => { calls.push(["setFilePermissions", params]), } as any; - await runBrowserFileAction(fs, { + await callBrowserFiles(fs, { action: "create_directory", session_id: "session-1", path: "/tmp/new", mode: "0755", }); - await runBrowserFileAction(fs, { + await callBrowserFiles(fs, { action: "move", session_id: "session-1", src_path: "/tmp/old", dest_path: "/tmp/new", }); - await runBrowserFileAction(fs, { + await callBrowserFiles(fs, { action: "delete_file", session_id: "session-1", path: "/tmp/file", }); - await runBrowserFileAction(fs, { + await callBrowserFiles(fs, { action: "delete_directory", session_id: "session-1", path: "/tmp/dir", }); - await runBrowserFileAction(fs, { + await callBrowserFiles(fs, { action: "set_permissions", session_id: "session-1", path: "/tmp/file", @@ -265,13 +279,39 @@ describe("manage_browser_files", () => { }, ) as any; - const result = await runBrowserFileAction(fs, { + const result = await callBrowserFiles(fs, { action: "move", session_id: "session-1", src_path: "/tmp/source", }); - expect("isError" in result && result.isError).toBe(true); + 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"); + } + }); }); diff --git a/src/lib/mcp/tools/browser-files.ts b/src/lib/mcp/tools/browser-files.ts index 5c3f4042..2cb399b2 100644 --- a/src/lib/mcp/tools/browser-files.ts +++ b/src/lib/mcp/tools/browser-files.ts @@ -1,7 +1,11 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { toFile } from "@onkernel/sdk"; import { z } from "zod"; -import { createKernelClient, type KernelClient } from "@/lib/mcp/kernel-client"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; +import type { KernelClient } from "@/lib/mcp/kernel-client"; import { projectForOperation, projectSelectionInputSchema, @@ -17,6 +21,7 @@ import { const fileContentSchema = z.object({ dest_path: z .string() + .min(1) .describe("Absolute destination path in the browser VM."), content: z.string().describe("File contents, encoded according to encoding."), encoding: z @@ -44,14 +49,22 @@ const browserFileParamsSchema = z.object({ "set_permissions", ]) .describe("Filesystem operation to perform."), - session_id: z.string().describe("Browser session ID."), + session_id: z.string().min(1).describe("Browser session ID."), path: z .string() - .describe("Absolute file or directory path in the browser VM.") + .min(1) + .describe( + "(list, get_info, read, download, write, download_dir_zip, create_directory, delete_file, delete_directory, set_permissions) Absolute file or directory path in the browser VM.", + ) + .optional(), + src_path: z + .string() + .min(1) + .describe("(move) Absolute source path.") .optional(), - src_path: z.string().describe("(move) Absolute source path.").optional(), dest_path: z .string() + .min(1) .describe("(move, upload_zip) Absolute destination path.") .optional(), content: z @@ -150,7 +163,7 @@ async function responseBuffer(response: Response) { return Buffer.from(await response.arrayBuffer()); } -export async function runBrowserFileAction( +async function runBrowserFileAction( fs: BrowserFsClient, params: BrowserFileParams, ) { @@ -292,7 +305,12 @@ export async function runBrowserFileAction( } } -export function registerBrowserFileTools(server: McpServer) { +export function registerBrowserFileTools( + server: McpServer, + options: McpDependencies = { + ...defaultMcpDependencies, + }, +) { server.registerTool( "manage_browser_files", { @@ -309,7 +327,7 @@ export function registerBrowserFileTools(server: McpServer) { }, async (params, ctx) => { if (!ctx.http?.authInfo) throw new Error("Authentication required"); - const client = createKernelClient( + const client = options.createKernelClient( ctx.http.authInfo.token, projectForOperation(ctx.http.authInfo, params), ); From 909312075ee32510dd705928ddcfeac33387bc8f Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:50:49 +0000 Subject: [PATCH 4/5] Bound browser file reads and tighten manage_browser_files inputs Refuse read, download, and download_dir_zip results over max_bytes (default 10 MiB, maximum 25 MiB): read and download check file size first, and directory archives stop streaming at the cap. Require absolute paths, disable retries for mutating actions, accept session names, flag binary text reads, and document that mime_type is the only source of a download's type. --- README.md | 4 +- src/lib/mcp/tools/browser-files.test.ts | 241 ++++++++++++++++++++++-- src/lib/mcp/tools/browser-files.ts | 193 +++++++++++++------ src/lib/mcp/tools/browsers.ts | 2 +- 4 files changed, 364 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index a7963e58..f7d5c715 100644 --- a/README.md +++ b/README.md @@ -324,12 +324,12 @@ Inputs that mirror the API's nested `proxy`, `network`, `browser`, and proxy `co ### manage\_\* tools -- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies (`proxy` by id, name, or mode), create-only per-host proxy routes (`network.proxy_routes`) and private-host routing (`network.private_hosts`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. +- `manage_browsers` - Create, update, list, get, and delete browser sessions, and read archived telemetry for active or deleted sessions. Supports headless mode, site-compatibility settings, profiles, proxies (`proxy` by id, name, or mode), create-only per-host proxy routes (`network.proxy_routes`) and private-host routing (`network.private_hosts`), viewports, extensions, names and tags, and SSH tunneling. The browser tools (`manage_browsers`, `computer_action`, `execute_playwright_code`, `browser_repl`, `exec_command`, `browser_curl`, `manage_browser_files`, `manage_replays`, `webmcp`) accept a live session's name in place of its `session_id`; deleted sessions, and `manage_browser_pools` release, take the ID only. - `manage_profiles` - Setup (with guided live browser session), search/list with pagination, get, and delete browser profiles for persisting cookies and logins. - `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_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. Paths must be absolute. `read`, `download`, and `download_dir_zip` refuse files or archives over `max_bytes` (default 10 MiB, maximum 25 MiB) instead of truncating them; mutating actions are never retried. - `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). List filters by exact `name` or a `query` substring. - `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. diff --git a/src/lib/mcp/tools/browser-files.test.ts b/src/lib/mcp/tools/browser-files.test.ts index 6dfd5755..1c018ff4 100644 --- a/src/lib/mcp/tools/browser-files.test.ts +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -16,6 +16,10 @@ async function callBrowserFiles(fs: unknown, args: Record) { } } +function fileInfo(sizeBytes: number) { + return async () => ({ size_bytes: sizeBytes }); +} + function text(result: Awaited>) { const [content] = result.content as Array<{ type: string; text?: string }>; return content.type === "text" ? content.text : undefined; @@ -52,6 +56,7 @@ describe("manage_browser_files", () => { test("reads text without wrapping the contents", async () => { const fs = { + fileInfo: fileInfo(12), readFile: async () => new Response("hello\nworld\n"), } as any; @@ -64,11 +69,12 @@ describe("manage_browser_files", () => { expect(text(result)).toBe("hello\nworld\n"); }); - test("returns binary downloads as embedded resources", async () => { + test("returns binary downloads as octet-stream embedded resources", async () => { const fs = { + fileInfo: fileInfo(3), readFile: async () => new Response(new Uint8Array([0, 1, 2]), { - headers: { "content-type": "image/png" }, + headers: { "content-type": "application/octet-stream" }, }), } as any; @@ -84,7 +90,7 @@ describe("manage_browser_files", () => { resource: { uri: "kernel-browser-file://session-1/tmp/a%20file.png", blob: "AAEC", - mimeType: "image/png", + mimeType: "application/octet-stream", }, }, ]); @@ -97,9 +103,11 @@ describe("manage_browser_files", () => { sessionId: string, contents: Uint8Array, params: { path: string; mode?: string }, + options: { maxRetries?: number }, ) => { expect(sessionId).toBe("session-1"); expect(params).toEqual({ path: "/tmp/file.bin", mode: "0600" }); + expect(options.maxRetries).toBe(0); written = contents; }, } as any; @@ -212,18 +220,33 @@ describe("manage_browser_files", () => { }); test("routes filesystem mutations to the SDK", async () => { - const calls: Array<[string, unknown]> = []; + const calls: Array<[string, unknown, number | undefined]> = []; 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]), + createDirectory: async ( + _id: string, + params: unknown, + options: { maxRetries?: number }, + ) => calls.push(["createDirectory", params, options.maxRetries]), + move: async ( + _id: string, + params: unknown, + options: { maxRetries?: number }, + ) => calls.push(["move", params, options.maxRetries]), + deleteFile: async ( + _id: string, + params: unknown, + options: { maxRetries?: number }, + ) => calls.push(["deleteFile", params, options.maxRetries]), + deleteDirectory: async ( + _id: string, + params: unknown, + options: { maxRetries?: number }, + ) => calls.push(["deleteDirectory", params, options.maxRetries]), + setFilePermissions: async ( + _id: string, + params: unknown, + options: { maxRetries?: number }, + ) => calls.push(["setFilePermissions", params, options.maxRetries]), } as any; await callBrowserFiles(fs, { @@ -258,13 +281,14 @@ describe("manage_browser_files", () => { }); 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" }], + ["createDirectory", { path: "/tmp/new", mode: "0755" }, 0], + ["move", { src_path: "/tmp/old", dest_path: "/tmp/new" }, 0], + ["deleteFile", { path: "/tmp/file" }, 0], + ["deleteDirectory", { path: "/tmp/dir" }, 0], [ "setFilePermissions", { path: "/tmp/file", mode: "0640", owner: "1000", group: "1000" }, + 0, ], ]); }); @@ -314,4 +338,185 @@ describe("manage_browser_files", () => { expect(text(result)).not.toContain("unexpected SDK call"); } }); + + test("uses mime_type for downloads", async () => { + const fs = { + fileInfo: fileInfo(3), + readFile: async () => new Response(new Uint8Array([0, 1, 2])), + } as any; + + const result = await callBrowserFiles(fs, { + action: "download", + session_id: "session-1", + path: "/tmp/a.png", + mime_type: "image/png", + }); + + expect((result.content as unknown[])[0]).toEqual({ + type: "resource", + resource: { + uri: "kernel-browser-file://session-1/tmp/a.png", + blob: "AAEC", + mimeType: "image/png", + }, + }); + }); + + test("flags binary content returned by read", async () => { + const fs = { + fileInfo: fileInfo(3), + readFile: async () => new Response(new Uint8Array([0xff, 0xfe, 0x00])), + } as any; + + const result = await callBrowserFiles(fs, { + action: "read", + session_id: "session-1", + path: "/tmp/a.bin", + }); + + expect(text(result)).toEndWith( + '(note: this file appears binary; use "download" to get its bytes.)', + ); + }); + + test.each(["read", "download"])( + "refuses to %s files over max_bytes without reading them", + async (action) => { + let read = false; + const fs = { + fileInfo: fileInfo(11 * 1024 * 1024), + readFile: async () => { + read = true; + return new Response(""); + }, + } as any; + + const result = await callBrowserFiles(fs, { + action, + session_id: "session-1", + path: "/tmp/big.log", + }); + + expect(read).toBe(false); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + 'error: /tmp/big.log (11534336 bytes) exceeds max_bytes (10485760). use "list" or "get_info" to find a smaller file, or exec_command to split, compress, or filter it first.', + ); + }, + ); + + test("refuses a file that grows past max_bytes after the size check", async () => { + const fs = { + fileInfo: fileInfo(4), + readFile: async () => new Response("grown"), + } as any; + + const result = await callBrowserFiles(fs, { + action: "read", + session_id: "session-1", + path: "/tmp/growing.log", + max_bytes: 4, + }); + + expect(result.isError).toBe(true); + expect(text(result)).toStartWith( + "error: /tmp/growing.log exceeds max_bytes (4).", + ); + }); + + test("stops reading directory archives at max_bytes", async () => { + let cancelled = false; + const fs = { + downloadDirZip: async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(3)); + }, + cancel() { + cancelled = true; + }, + }), + ), + } as any; + + const result = await callBrowserFiles(fs, { + action: "download_dir_zip", + session_id: "session-1", + path: "/home/kernel", + max_bytes: 8, + }); + + expect(cancelled).toBe(true); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + 'error: the archive of /home/kernel exceeds max_bytes (8). use "list" to pick a smaller subdirectory, or exec_command to build a smaller archive.', + ); + }); + + test("uploads and extracts zip archives", async () => { + let uploaded: any; + let retries: number | undefined; + const fs = { + uploadZip: async ( + sessionId: string, + params: unknown, + options: { maxRetries?: number }, + ) => { + expect(sessionId).toBe("session-1"); + uploaded = params; + retries = options.maxRetries; + }, + } as any; + + const result = await callBrowserFiles(fs, { + action: "upload_zip", + session_id: "session-1", + dest_path: "/tmp/extracted", + content: "UEs=", + encoding: "base64", + }); + + expect(uploaded.dest_path).toBe("/tmp/extracted"); + expect(uploaded.zip_file.name).toBe("upload.zip"); + expect([...new Uint8Array(await uploaded.zip_file.arrayBuffer())]).toEqual([ + 80, 75, + ]); + expect(retries).toBe(0); + expect(text(result)).toBe( + "uploaded and extracted archive to /tmp/extracted", + ); + }); + + test("rejects relative paths before calling the SDK", async () => { + const fs = new Proxy( + {}, + { + get: () => { + throw new Error("unexpected SDK call"); + }, + }, + ); + + for (const args of [ + { action: "read", session_id: "session-1", path: "tmp/a.txt" }, + { + action: "move", + session_id: "session-1", + src_path: "/a", + dest_path: "b", + }, + { + action: "upload", + session_id: "session-1", + files: [{ dest_path: "a.txt", content: "a" }], + }, + ]) { + const result = await callBrowserFiles(fs, args); + expect(result.isError).toBe(true); + expect(text(result)).toContain( + "must be an absolute path starting with /", + ); + } + }); }); diff --git a/src/lib/mcp/tools/browser-files.ts b/src/lib/mcp/tools/browser-files.ts index ccca11af..2771e7c0 100644 --- a/src/lib/mcp/tools/browser-files.ts +++ b/src/lib/mcp/tools/browser-files.ts @@ -18,11 +18,19 @@ import { throwToolError, } from "@/lib/mcp/responses"; +// Responses are base64-encoded into a single JSON-RPC message, so reads are +// capped well below what an MCP client will accept in one result. +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +const MAX_MAX_BYTES = 25 * 1024 * 1024; + +const absolutePathSchema = z + .string() + .regex(/^\//, "must be an absolute path starting with /"); + const fileContentSchema = z.object({ - dest_path: z - .string() - .min(1) - .describe("absolute destination path in the browser vm."), + dest_path: absolutePathSchema.describe( + "absolute destination path in the browser vm.", + ), content: z.string().describe("file contents, encoded according to encoding."), encoding: z .enum(["utf8", "base64"]) @@ -49,22 +57,16 @@ const browserFileParamsSchema = z.object({ "set_permissions", ]) .describe("filesystem operation to perform."), - session_id: z.string().min(1).describe("browser session id."), - path: z - .string() - .min(1) + session_id: z.string().min(1).describe("browser session id or name."), + path: absolutePathSchema .describe( "(list, get_info, read, download, write, download_dir_zip, create_directory, delete_file, delete_directory, set_permissions) absolute file or directory path in the browser vm.", ) .optional(), - src_path: z - .string() - .min(1) + src_path: absolutePathSchema .describe("(move) absolute source path.") .optional(), - dest_path: z - .string() - .min(1) + dest_path: absolutePathSchema .describe("(move, upload_zip) absolute destination path.") .optional(), content: z @@ -83,7 +85,16 @@ const browserFileParamsSchema = z.object({ mime_type: z .string() .describe( - "(download) mime type for the returned embedded resource. defaults to the api response type or application/octet-stream.", + "(download) mime type for the returned embedded resource. the browser vm always serves files as application/octet-stream, so this is the only way to set a real type. defaults to application/octet-stream.", + ) + .optional(), + max_bytes: z + .number() + .int() + .min(1) + .max(MAX_MAX_BYTES) + .describe( + `(read, download, download_dir_zip) maximum bytes to return. defaults to ${DEFAULT_MAX_BYTES} (10 mib), up to ${MAX_MAX_BYTES} (25 mib). larger files and archives are refused, not truncated.`, ) .optional(), mode: z @@ -105,6 +116,7 @@ const browserFileParamsSchema = z.object({ type BrowserFileParams = z.infer; type BrowserFsClient = KernelClient["browsers"]["fs"]; +type MutationOptions = { maxRetries: 0; signal: AbortSignal }; function required(value: string | undefined, name: string, action: string) { if (value !== undefined) return value; @@ -127,8 +139,7 @@ function decodeContent(content: string, encoding: "utf8" | "base64" = "utf8") { } function encodedPath(path: string) { - const absolutePath = path.startsWith("/") ? path : `/${path}`; - return absolutePath + return path .split("/") .map((part) => encodeURIComponent(part)) .join("/"); @@ -159,14 +170,62 @@ function embeddedFileResponse( }; } -async function responseBuffer(response: Response) { - return Buffer.from(await response.arrayBuffer()); +// Returns undefined once the body exceeds maxBytes, without buffering the rest. +async function boundedBuffer(response: Response, maxBytes: number) { + if (Number(response.headers.get("content-length")) > maxBytes) { + await response.body?.cancel(); + return undefined; + } + if (!response.body) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + return Buffer.concat(chunks); +} + +function fileTooLarge(path: string, maxBytes: number, sizeBytes?: number) { + const size = sizeBytes === undefined ? "" : ` (${sizeBytes} bytes)`; + return errorResponse( + `error: ${path}${size} exceeds max_bytes (${maxBytes}). use "list" or "get_info" to find a smaller file, or exec_command to split, compress, or filter it first.`, + ); +} + +async function readCapped( + fs: BrowserFsClient, + sessionId: string, + path: string, + maxBytes: number, +) { + const info = await fs.fileInfo(sessionId, { path }); + if (info.size_bytes > maxBytes) { + return fileTooLarge(path, maxBytes, info.size_bytes); + } + // The file can grow between the size check and the read. + const buffer = await boundedBuffer( + await fs.readFile(sessionId, { path }), + maxBytes, + ); + return buffer ?? fileTooLarge(path, maxBytes); } async function runBrowserFileAction( fs: BrowserFsClient, params: BrowserFileParams, + mutation: MutationOptions, ) { + const maxBytes = params.max_bytes ?? DEFAULT_MAX_BYTES; + switch (params.action) { case "list": { const path = required(params.path, "path", params.action); @@ -184,21 +243,25 @@ async function runBrowserFileAction( 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")); + const buffer = await readCapped(fs, params.session_id, path, maxBytes); + if (!Buffer.isBuffer(buffer)) return buffer; + const text = buffer.toString("utf8"); + return textResponse( + text.includes("\uFFFD") + ? `${text}\n\n(note: this file appears binary; use "download" to get its bytes.)` + : text, + ); } 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); + const buffer = await readCapped(fs, params.session_id, path, maxBytes); + if (!Buffer.isBuffer(buffer)) return buffer; return embeddedFileResponse( params.session_id, path, buffer, - params.mime_type || - response.headers.get("content-type") || - "application/octet-stream", + params.mime_type || "application/octet-stream", ); } case "write": { @@ -208,10 +271,12 @@ async function runBrowserFileAction( 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 }), - }); + await fs.writeFile( + params.session_id, + decoded, + { path, ...(params.mode && { mode: params.mode }) }, + mutation, + ); return textResponse(`wrote file ${path}`); } case "upload": { @@ -230,7 +295,7 @@ async function runBrowserFileAction( file: await toFile(decoded, file.dest_path.split("/").pop()), }); } - await fs.upload(params.session_id, { files }); + await fs.upload(params.session_id, { files }, mutation); return textResponse(`uploaded ${files.length} file(s)`); } case "upload_zip": { @@ -240,30 +305,40 @@ async function runBrowserFileAction( 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"), - }); + await fs.uploadZip( + params.session_id, + { dest_path: destPath, zip_file: await toFile(decoded, "upload.zip") }, + mutation, + ); 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 }); + const buffer = await boundedBuffer( + await fs.downloadDirZip(params.session_id, { path }), + maxBytes, + ); + if (!buffer) { + return errorResponse( + `error: the archive of ${path} exceeds max_bytes (${maxBytes}). use "list" to pick a smaller subdirectory, or exec_command to build a smaller archive.`, + ); + } return embeddedFileResponse( params.session_id, zipResourcePath(path), - await responseBuffer(response), + buffer, "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 }), - }); + await fs.createDirectory( + params.session_id, + { path, ...(params.mode && { mode: params.mode }) }, + mutation, + ); return textResponse(`created directory ${path}`); } case "move": { @@ -271,22 +346,23 @@ async function runBrowserFileAction( 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, - }); + await fs.move( + params.session_id, + { src_path: srcPath, dest_path: destPath }, + mutation, + ); 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 }); + await fs.deleteFile(params.session_id, { path }, mutation); 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 }); + await fs.deleteDirectory(params.session_id, { path }, mutation); return textResponse(`deleted directory ${path}`); } case "set_permissions": { @@ -294,12 +370,16 @@ async function runBrowserFileAction( 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 }), - }); + await fs.setFilePermissions( + params.session_id, + { + path, + mode, + ...(params.owner && { owner: params.owner }), + ...(params.group && { group: params.group }), + }, + mutation, + ); return textResponse(`updated permissions for ${path}`); } } @@ -315,7 +395,7 @@ export function registerBrowserFileTools( "manage_browser_files", { description: - '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.', + '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. reads and downloads are capped by max_bytes (10 mib by default). local files must be supplied as utf8 or base64 content because the remote mcp server cannot access paths on the caller\'s machine.', inputSchema: browserFileParamsSchema, annotations: { title: "manage browser vm files", @@ -333,7 +413,10 @@ export function registerBrowserFileTools( ); try { - return await runBrowserFileAction(client.browsers.fs, params); + return await runBrowserFileAction(client.browsers.fs, params, { + maxRetries: 0, + signal: ctx.mcpReq.signal, + }); } catch (error) { throwToolError("manage_browser_files", params.action, error); } diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index 61dcb5ac..a422ea63 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -459,7 +459,7 @@ export function registerBrowserCapabilities( name: z .string() .describe( - "(create, update) human-readable session name, unique among active sessions in the project. 1-255 chars of letters, digits, '.', '_' or '-', and not a cuid-like id. while the session is live it can be passed as session_id to the browser tools (manage_browsers, computer_action, execute_playwright_code, browser_repl, exec_command, browser_curl, manage_replays, webmcp). on update, an empty string clears the name.", + "(create, update) human-readable session name, unique among active sessions in the project. 1-255 chars of letters, digits, '.', '_' or '-', and not a cuid-like id. while the session is live it can be passed as session_id to the browser tools (manage_browsers, computer_action, execute_playwright_code, browser_repl, exec_command, browser_curl, manage_browser_files, manage_replays, webmcp). on update, an empty string clears the name.", ) .optional(), tags: z From 0e07de8b3b8db96c3e4a2842a6c78dc9528b6fba Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:58:54 +0000 Subject: [PATCH 5/5] Build path schemas per field and disable archive download retries --- src/lib/mcp/tools/browser-files.test.ts | 27 ++++++++++++++++-- src/lib/mcp/tools/browser-files.ts | 38 +++++++++++++------------ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/lib/mcp/tools/browser-files.test.ts b/src/lib/mcp/tools/browser-files.test.ts index 1c018ff4..6d3aa1f0 100644 --- a/src/lib/mcp/tools/browser-files.test.ts +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -426,9 +426,15 @@ describe("manage_browser_files", () => { test("stops reading directory archives at max_bytes", async () => { let cancelled = false; + let retries: number | undefined; const fs = { - downloadDirZip: async () => - new Response( + downloadDirZip: async ( + _id: string, + _params: unknown, + options: { maxRetries?: number }, + ) => { + retries = options.maxRetries; + return new Response( new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(3)); @@ -437,7 +443,8 @@ describe("manage_browser_files", () => { cancelled = true; }, }), - ), + ); + }, } as any; const result = await callBrowserFiles(fs, { @@ -448,6 +455,7 @@ describe("manage_browser_files", () => { }); expect(cancelled).toBe(true); + expect(retries).toBe(0); expect(result.isError).toBe(true); expect(text(result)).toBe( 'error: the archive of /home/kernel exceeds max_bytes (8). use "list" to pick a smaller subdirectory, or exec_command to build a smaller archive.', @@ -519,4 +527,17 @@ describe("manage_browser_files", () => { ); } }); + + test("advertises inline path schemas", async () => { + const { client, close } = await connectTestMcp( + registerBrowserFileTools, + {}, + ); + try { + const { tools } = await client.listTools(); + expect(JSON.stringify(tools[0].inputSchema)).not.toContain("$ref"); + } finally { + await close(); + } + }); }); diff --git a/src/lib/mcp/tools/browser-files.ts b/src/lib/mcp/tools/browser-files.ts index 2771e7c0..b654fe7b 100644 --- a/src/lib/mcp/tools/browser-files.ts +++ b/src/lib/mcp/tools/browser-files.ts @@ -23,12 +23,14 @@ import { const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; const MAX_MAX_BYTES = 25 * 1024 * 1024; -const absolutePathSchema = z - .string() - .regex(/^\//, "must be an absolute path starting with /"); +// A factory, so each field gets its own schema instance and the advertised +// JSON schema inlines it instead of emitting a $ref. +function absolutePathSchema() { + return z.string().regex(/^\//, "must be an absolute path starting with /"); +} const fileContentSchema = z.object({ - dest_path: absolutePathSchema.describe( + dest_path: absolutePathSchema().describe( "absolute destination path in the browser vm.", ), content: z.string().describe("file contents, encoded according to encoding."), @@ -58,15 +60,15 @@ const browserFileParamsSchema = z.object({ ]) .describe("filesystem operation to perform."), session_id: z.string().min(1).describe("browser session id or name."), - path: absolutePathSchema + path: absolutePathSchema() .describe( "(list, get_info, read, download, write, download_dir_zip, create_directory, delete_file, delete_directory, set_permissions) absolute file or directory path in the browser vm.", ) .optional(), - src_path: absolutePathSchema + src_path: absolutePathSchema() .describe("(move) absolute source path.") .optional(), - dest_path: absolutePathSchema + dest_path: absolutePathSchema() .describe("(move, upload_zip) absolute destination path.") .optional(), content: z @@ -116,7 +118,7 @@ const browserFileParamsSchema = z.object({ type BrowserFileParams = z.infer; type BrowserFsClient = KernelClient["browsers"]["fs"]; -type MutationOptions = { maxRetries: 0; signal: AbortSignal }; +type NoRetryOptions = { maxRetries: 0; signal: AbortSignal }; function required(value: string | undefined, name: string, action: string) { if (value !== undefined) return value; @@ -222,7 +224,7 @@ async function readCapped( async function runBrowserFileAction( fs: BrowserFsClient, params: BrowserFileParams, - mutation: MutationOptions, + noRetry: NoRetryOptions, ) { const maxBytes = params.max_bytes ?? DEFAULT_MAX_BYTES; @@ -275,7 +277,7 @@ async function runBrowserFileAction( params.session_id, decoded, { path, ...(params.mode && { mode: params.mode }) }, - mutation, + noRetry, ); return textResponse(`wrote file ${path}`); } @@ -295,7 +297,7 @@ async function runBrowserFileAction( file: await toFile(decoded, file.dest_path.split("/").pop()), }); } - await fs.upload(params.session_id, { files }, mutation); + await fs.upload(params.session_id, { files }, noRetry); return textResponse(`uploaded ${files.length} file(s)`); } case "upload_zip": { @@ -308,7 +310,7 @@ async function runBrowserFileAction( await fs.uploadZip( params.session_id, { dest_path: destPath, zip_file: await toFile(decoded, "upload.zip") }, - mutation, + noRetry, ); return textResponse(`uploaded and extracted archive to ${destPath}`); } @@ -316,7 +318,7 @@ async function runBrowserFileAction( const path = required(params.path, "path", params.action); if (typeof path !== "string") return path; const buffer = await boundedBuffer( - await fs.downloadDirZip(params.session_id, { path }), + await fs.downloadDirZip(params.session_id, { path }, noRetry), maxBytes, ); if (!buffer) { @@ -337,7 +339,7 @@ async function runBrowserFileAction( await fs.createDirectory( params.session_id, { path, ...(params.mode && { mode: params.mode }) }, - mutation, + noRetry, ); return textResponse(`created directory ${path}`); } @@ -349,20 +351,20 @@ async function runBrowserFileAction( await fs.move( params.session_id, { src_path: srcPath, dest_path: destPath }, - mutation, + noRetry, ); 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 }, mutation); + await fs.deleteFile(params.session_id, { path }, noRetry); 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 }, mutation); + await fs.deleteDirectory(params.session_id, { path }, noRetry); return textResponse(`deleted directory ${path}`); } case "set_permissions": { @@ -378,7 +380,7 @@ async function runBrowserFileAction( ...(params.owner && { owner: params.owner }), ...(params.group && { group: params.group }), }, - mutation, + noRetry, ); return textResponse(`updated permissions for ${path}`); }