diff --git a/README.md b/README.md index 1081363..f7d5c71 100644 --- a/README.md +++ b/README.md @@ -324,11 +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. 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/register.test.ts b/src/lib/mcp/register.test.ts index c3284db..9a5c663 100644 --- a/src/lib/mcp/register.test.ts +++ b/src/lib/mcp/register.test.ts @@ -13,6 +13,7 @@ const NON_AUTH_TOOLSETS = [ "browser_pools", "config_registry", "browser_curl", + "browser_files", "proxies", "extensions", "apps", @@ -90,6 +91,7 @@ const EXPECTED_ANNOTATIONS: Record = { manage_projects: [false, true, false], manage_api_keys: [false, true, false], manage_browser_pools: [false, true, false], + manage_browser_files: [false, true, false], manage_config_registry: [false, true, true], browser_curl: [false, true, true], manage_proxies: [false, true, true], @@ -278,6 +280,7 @@ describe("project selection registration", () => { "manage_browsers", "manage_browser_pools", "browser_curl", + "manage_browser_files", "manage_proxies", "manage_extensions", "manage_apps", diff --git a/src/lib/mcp/register.ts b/src/lib/mcp/register.ts index 4fd7093..74492d4 100644 --- a/src/lib/mcp/register.ts +++ b/src/lib/mcp/register.ts @@ -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"; @@ -50,6 +51,7 @@ const mcpToolRegistrations = [ ["browser_pools", registerBrowserPoolCapabilities], ["config_registry", registerConfigRegistryTools], ["browser_curl", registerBrowserCurlTool], + ["browser_files", registerBrowserFileTools], ["proxies", registerProxyTools], ["extensions", registerExtensionTools], ["apps", registerAppCapabilities], @@ -79,6 +81,8 @@ const standaloneToolsetAliases: Partial> = { browser_repl: "repl", 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/tool-names.ts b/src/lib/mcp/tool-names.ts index e3427b7..67e68c8 100644 --- a/src/lib/mcp/tool-names.ts +++ b/src/lib/mcp/tool-names.ts @@ -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", 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 0000000..6d3aa1f --- /dev/null +++ b/src/lib/mcp/tools/browser-files.test.ts @@ -0,0 +1,543 @@ +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) { + const { client, close } = await connectTestMcp(registerBrowserFileTools, { + browsers: { fs }, + }); + try { + return await client.callTool({ + name: "manage_browser_files", + arguments: args, + }); + } finally { + await close(); + } +} + +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; +} + +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", + }); + + expect(JSON.parse(text(result)!)).toEqual({ items: entries }); + }); + + test("reads text without wrapping the contents", async () => { + const fs = { + fileInfo: fileInfo(12), + 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 octet-stream embedded resources", async () => { + const fs = { + fileInfo: fileInfo(3), + readFile: async () => + new Response(new Uint8Array([0, 1, 2]), { + headers: { "content-type": "application/octet-stream" }, + }), + } 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: "application/octet-stream", + }, + }, + ]); + }); + + test("decodes base64 writes", async () => { + let written: Uint8Array | undefined; + const fs = { + writeFile: async ( + 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; + + 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, number | undefined]> = []; + const fs = { + 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, { + 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" }, 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, + ], + ]); + }); + + 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"); + } + }); + + 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; + let retries: number | undefined; + const fs = { + downloadDirZip: async ( + _id: string, + _params: unknown, + options: { maxRetries?: number }, + ) => { + retries = options.maxRetries; + return 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(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.', + ); + }); + + 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 /", + ); + } + }); + + 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 new file mode 100644 index 0000000..b654fe7 --- /dev/null +++ b/src/lib/mcp/tools/browser-files.ts @@ -0,0 +1,427 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { toFile } from "@onkernel/sdk"; +import { z } from "zod"; +import { + defaultMcpDependencies, + type McpDependencies, +} from "@/lib/mcp/dependencies"; +import type { KernelClient } from "@/lib/mcp/kernel-client"; +import { + projectForOperation, + projectSelectionInputSchema, +} from "@/lib/mcp/project-selection"; +import { + errorResponse, + itemsJsonResponse, + jsonResponse, + textResponse, + 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; + +// 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( + "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({ + ...projectSelectionInputSchema(), + 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().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: absolutePathSchema() + .describe("(move) absolute source path.") + .optional(), + dest_path: absolutePathSchema() + .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. 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 + .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"]; +type NoRetryOptions = { maxRetries: 0; signal: AbortSignal }; + +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 zipResourcePath(path: string) { + const directoryPath = path.replace(/\/+$/, ""); + return `${directoryPath || "/browser-files"}.zip`; +} + +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, + }, + }, + ], + }; +} + +// 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, + noRetry: NoRetryOptions, +) { + const maxBytes = params.max_bytes ?? DEFAULT_MAX_BYTES; + + 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 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 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 || "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 }) }, + noRetry, + ); + 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 }, noRetry); + 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") }, + noRetry, + ); + 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 buffer = await boundedBuffer( + await fs.downloadDirZip(params.session_id, { path }, noRetry), + 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), + 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 }) }, + noRetry, + ); + 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 }, + 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 }, 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 }, noRetry); + 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 }), + }, + noRetry, + ); + return textResponse(`updated permissions for ${path}`); + } + } +} + +export function registerBrowserFileTools( + server: McpServer, + options: McpDependencies = { + ...defaultMcpDependencies, + }, +) { + server.registerTool( + "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. 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", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (params, ctx) => { + if (!ctx.http?.authInfo) throw new Error("Authentication required"); + const client = options.createKernelClient( + ctx.http.authInfo.token, + projectForOperation(ctx.http.authInfo, params), + ); + + try { + 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 61dcb5a..a422ea6 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