From fb74cadd3747296f18cc97c12ec64b2db0b1a894 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 14:54:41 +0000 Subject: [PATCH 1/6] Add Biome for linting and formatting Co-authored-by: Anirudh Kamath --- biome.json | 42 ++++++++++++++++++++++ package.json | 10 ++++-- pnpm-lock.yaml | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f056836 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!**/worker-configuration.d.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package.json b/package.json index 78ef0e8..c529182 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "packageManager": "pnpm@11.10.0", "scripts": { "build": "pnpm -r build", - "check": "pnpm -r check", + "check": "biome check . && pnpm -r check", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", "test": "pnpm -r --if-present test", "test:e2e": "pnpm --filter @browse-code-mode/mcp-server test:e2e", "dev": "node scripts/dev.mjs", @@ -21,7 +24,8 @@ "example": "node scripts/example.mjs" }, "devDependencies": { - "typescript": "^5.9.3", - "@types/node": "^22.19.0" + "@biomejs/biome": "2.5.14", + "@types/node": "^22.19.0", + "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23fa8d8..8432ba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: 2.5.14 + version: 2.5.14 '@types/node': specifier: ^22.19.0 version: 22.20.4 @@ -364,6 +367,63 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.14': + resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.14': + resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.14': + resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.14': + resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.14': + resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.14': + resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.14': + resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.14': + resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.14': + resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -3368,6 +3428,41 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@biomejs/biome@2.5.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.14 + '@biomejs/cli-darwin-x64': 2.5.14 + '@biomejs/cli-linux-arm64': 2.5.14 + '@biomejs/cli-linux-arm64-musl': 2.5.14 + '@biomejs/cli-linux-x64': 2.5.14 + '@biomejs/cli-linux-x64-musl': 2.5.14 + '@biomejs/cli-win32-arm64': 2.5.14 + '@biomejs/cli-win32-x64': 2.5.14 + + '@biomejs/cli-darwin-arm64@2.5.14': + optional: true + + '@biomejs/cli-darwin-x64@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64@2.5.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-x64@2.5.14': + optional: true + + '@biomejs/cli-win32-arm64@2.5.14': + optional: true + + '@biomejs/cli-win32-x64@2.5.14': + optional: true + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260921.1)': From 21e0974fd02441397c87ffd1d7992f5eab89c70c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 14:54:42 +0000 Subject: [PATCH 2/6] Apply Biome formatting and safe lint fixes Co-authored-by: Anirudh Kamath --- README.md | 4 + apps/web/src/lib/browser-api.ts | 26 ++- apps/web/src/lib/browser-app.ts | 10 +- apps/web/src/lib/mcp.ts | 5 +- apps/web/src/routes/__root.tsx | 9 +- apps/web/src/routes/api.chat.ts | 12 +- apps/web/src/routes/index.tsx | 59 +++--- apps/web/src/styles.css | 51 ++++- apps/web/tests/browser-api.test.ts | 61 ++++-- apps/web/vite.config.ts | 39 ++-- examples/01-hello-world/index.ts | 4 +- examples/02-mcp-server/index.ts | 10 +- packages/mcp-server/api/index.ts | 4 +- packages/mcp-server/src/app.ts | 50 +++-- packages/mcp-server/src/browse/cdp.ts | 3 +- packages/mcp-server/src/browse/commands.ts | 33 +-- packages/mcp-server/src/browse/driver.ts | 45 +++- packages/mcp-server/src/browse/dts.ts | 17 +- .../mcp-server/src/browse/host-functions.ts | 10 +- packages/mcp-server/src/browse/page.ts | 130 ++++++------ packages/mcp-server/src/browse/provider.ts | 4 +- packages/mcp-server/src/browse/providers.ts | 27 +-- packages/mcp-server/src/browse/transport.ts | 31 +-- packages/mcp-server/src/core.ts | 38 ++-- packages/mcp-server/src/index.ts | 33 ++- packages/mcp-server/src/mcp/server.ts | 7 +- packages/mcp-server/src/node/local-browser.ts | 39 ++-- packages/mcp-server/src/runtime-bundled.ts | 21 +- packages/mcp-server/src/runtime.ts | 2 +- packages/mcp-server/src/sandbox/runner.ts | 150 ++++++-------- packages/mcp-server/src/worker.ts | 23 +- packages/mcp-server/tests/browser.e2e.test.ts | 6 +- packages/mcp-server/tests/commands.test.ts | 16 +- packages/mcp-server/tests/frames.e2e.test.ts | 38 +++- packages/mcp-server/tests/lifecycle.test.ts | 33 +-- packages/mcp-server/tests/mcp.test.ts | 24 ++- packages/mcp-server/tests/runtime.e2e.test.ts | 196 ++++++++---------- packages/mcp-server/tests/sandbox.test.ts | 13 +- packages/mcp-server/tsconfig.json | 12 +- packages/tools/package.json | 8 +- packages/tools/src/ai-sdk.ts | 16 +- packages/tools/src/browser.ts | 2 +- packages/tools/src/index.ts | 43 ++-- packages/tools/src/mastra.ts | 20 +- packages/tools/tests/browser.test.ts | 12 +- packages/tools/tests/tools.test.ts | 63 ++++-- packages/tools/tsconfig.json | 12 +- scripts/chrome.mjs | 4 +- scripts/dev.mjs | 50 ++--- 49 files changed, 804 insertions(+), 721 deletions(-) diff --git a/README.md b/README.md index 8785057..fb864ca 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,10 @@ pnpm --filter @browse-code-mode/mcp-server test:worker pnpm test:e2e # existing browser on BROWSE_CDP_URL or port 9222 ``` +`pnpm check` runs [Biome](https://biomejs.dev) (lint, formatting, import order) +before type-checking every package. `pnpm lint:fix` applies Biome's safe fixes +and formatting; `pnpm format` only formats. + The runtime suites start an isolated Chrome and exercise real MCP execution, TypeScript, snapshot-ref clicks, loop interruption, screenshots, and lifecycle cleanup against Node and local workerd. They shut down their test processes. diff --git a/apps/web/src/lib/browser-api.ts b/apps/web/src/lib/browser-api.ts index 4837842..16584b4 100644 --- a/apps/web/src/lib/browser-api.ts +++ b/apps/web/src/lib/browser-api.ts @@ -1,12 +1,15 @@ -import { mcpHeaders } from "./env.ts"; import { fetchBrowserApp } from "./browser-app.ts"; +import { mcpHeaders } from "./env.ts"; /** Browser API calls are bounded; navigation/HMR cancellation is not a 500. */ -export async function proxyBrowserRequest(request: Request, path: "/browser" | "/screen", dispatch = fetchBrowserApp): Promise { +export async function proxyBrowserRequest( + request: Request, + path: "/browser" | "/screen", + dispatch = fetchBrowserApp, +): Promise { try { - const body = path === "/screen" || request.method === "DELETE" - ? await request.text() - : undefined; + const body = + path === "/screen" || request.method === "DELETE" ? await request.text() : undefined; const response = await dispatch(new URL(path, "http://browse.internal"), { method: request.method, headers: { ...mcpHeaders(), "content-type": "application/json" }, @@ -26,10 +29,13 @@ export async function proxyBrowserRequest(request: Request, path: "/browser" | " } catch (error) { if (request.signal.aborted) return new Response(null, { status: 499 }); const timedOut = error instanceof Error && error.name === "TimeoutError"; - return Response.json({ - error: timedOut - ? "The browser service took too long to respond. Try again." - : "Cannot reach the browser service. Make sure pnpm dev is running, then try again.", - }, { status: timedOut ? 504 : 502 }); + return Response.json( + { + error: timedOut + ? "The browser service took too long to respond. Try again." + : "Cannot reach the browser service. Make sure pnpm dev is running, then try again.", + }, + { status: timedOut ? 504 : 502 }, + ); } } diff --git a/apps/web/src/lib/browser-app.ts b/apps/web/src/lib/browser-app.ts index 877e9d0..5c06fdc 100644 --- a/apps/web/src/lib/browser-app.ts +++ b/apps/web/src/lib/browser-app.ts @@ -1,9 +1,13 @@ import { createConfiguredApp } from "@browse-code-mode/mcp-server/runtime"; /** Request-local Hono app; no listener, loopback HTTP, or stored browser session. */ -export async function fetchBrowserApp(input: RequestInfo | URL, init?: RequestInit): Promise { - return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" }) - .fetch(new Request(input, init)); +export async function fetchBrowserApp( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + return createConfiguredApp({ ...process.env, MCP_ENDPOINT: "/mcp" }).fetch( + new Request(input, init), + ); } /** Mount the complete Hono router beneath /api/browse. */ diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts index 25b3c4a..233c3a4 100644 --- a/apps/web/src/lib/mcp.ts +++ b/apps/web/src/lib/mcp.ts @@ -8,10 +8,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { dynamicTool, jsonSchema, type JSONSchema7, type ToolSet } from "ai"; - -import { mcpHeaders } from "./env.ts"; +import { dynamicTool, type JSONSchema7, jsonSchema, type ToolSet } from "ai"; import { fetchBrowserApp } from "./browser-app.ts"; +import { mcpHeaders } from "./env.ts"; export interface McpSession { tools: ToolSet; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 873fe36..f07023a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; +import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; import styles from "../styles.css?url"; @@ -12,7 +12,12 @@ export const Route = createRootRoute({ links: [{ rel: "stylesheet", href: styles }], }), component: RootDocument, - notFoundComponent: () =>

Page not found

Return to browser
, + notFoundComponent: () => ( +
+

Page not found

+ Return to browser +
+ ), }); function RootDocument() { diff --git a/apps/web/src/routes/api.chat.ts b/apps/web/src/routes/api.chat.ts index e0aded6..150f084 100644 --- a/apps/web/src/routes/api.chat.ts +++ b/apps/web/src/routes/api.chat.ts @@ -1,11 +1,6 @@ import { anthropic } from "@ai-sdk/anthropic"; import { createFileRoute } from "@tanstack/react-router"; -import { - convertToModelMessages, - stepCountIs, - streamText, - type UIMessage, -} from "ai"; +import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai"; import { env } from "../lib/env.ts"; import { openMcpSession } from "../lib/mcp.ts"; @@ -40,10 +35,7 @@ export const Route = createFileRoute("/api/chat")({ cdpUrl?: string; }; if (!cdpUrl || typeof cdpUrl !== "string") - return Response.json( - { error: "Start a browser first." }, - { status: 400 }, - ); + return Response.json({ error: "Start a browser first." }, { status: 400 }); let session; try { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e78492e..6274f5b 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -16,7 +16,7 @@ interface BrowserHandle { } async function responseError(response: Response): Promise { - const body = await response.json().catch(() => null) as { error?: string } | null; + const body = (await response.json().catch(() => null)) as { error?: string } | null; return body?.error ?? `Browser request failed (HTTP ${response.status}).`; } @@ -47,7 +47,9 @@ function Home() { try { if (next) sessionStorage.setItem("browse.browser", JSON.stringify(next)); else sessionStorage.removeItem("browse.browser"); - } catch { /* A blocked storage API must not prevent using the browser. */ } + } catch { + /* A blocked storage API must not prevent using the browser. */ + } } catch (error) { setError(error instanceof Error ? error.message : String(error)); } finally { @@ -56,28 +58,31 @@ function Home() { }; return (
-
+
- {browser ? Browser connected : null} - {error ?
{error}
: null} + {browser ? ( + + Browser connected + + ) : null} + {error ? ( +
+ {error} +
+ ) : null}
{browser ? ( ) : ( -
Start a browser to begin.
+
+ Start a browser to begin. +
)}
- {browser ? ( - - ) : ( -
- )} + {browser ? :
}
); } @@ -154,9 +159,19 @@ function LiveView({ browser }: { browser: BrowserHandle }) { {src ? ( ) : ( -
{error ? "Browser preview unavailable" : "Waiting for the browser…"}
+
+ {error ? "Browser preview unavailable" : "Waiting for the browser…"} +
)} - {error ?
{error} Retrying…
: null} + {error ? ( +
+ {error} Retrying… +
+ ) : null}
); } @@ -207,11 +222,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { placeholder="Ask the browser to do something" rows={1} /> -
@@ -261,8 +272,7 @@ interface ToolCallView { * a tool-specific part type that only exists for statically declared tools. */ function asToolCall(part: UIMessage["parts"][number]): ToolCallView | null { - if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) - return null; + if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) return null; const record = part as unknown as { type: string; toolName?: string; @@ -284,8 +294,7 @@ function ToolCall({ call }: { call: ToolCallView }) { const code = codeOf(call.input); const output = call.errorText ?? textOf(call.output); const failed = Boolean(call.errorText) || call.state === "output-error"; - const running = - call.state === "input-streaming" || call.state === "input-available"; + const running = call.state === "input-streaming" || call.state === "input-available"; return (
:first-child { margin-top: 0; } -.markdown > :last-child { margin-bottom: 0; } +.markdown > :first-child { + margin-top: 0; +} +.markdown > :last-child { + margin-bottom: 0; +} /* A wide table or code fence scrolls itself rather than widening the column. */ .markdown table, @@ -142,7 +166,9 @@ body { font: inherit; } -.composer textarea:focus { outline: 1px solid var(--accent); } +.composer textarea:focus { + outline: 1px solid var(--accent); +} .composer button { align-self: flex-end; @@ -155,7 +181,10 @@ body { cursor: pointer; } -.composer button:disabled { opacity: 0.5; cursor: default; } +.composer button:disabled { + opacity: 0.5; + cursor: default; +} .error { margin: 0 16px 12px; diff --git a/apps/web/tests/browser-api.test.ts b/apps/web/tests/browser-api.test.ts index 7f640c6..71f49ba 100644 --- a/apps/web/tests/browser-api.test.ts +++ b/apps/web/tests/browser-api.test.ts @@ -11,10 +11,20 @@ test("TanStack mount exposes the Hono router and preserves authentication", asyn const previous = process.env.MCP_AUTH_TOKEN; try { process.env.MCP_AUTH_TOKEN = "test-token"; - assert.equal((await mountedBrowserApp(new Request("http://localhost/api/browse/health"))).status, 401); - assert.equal((await mountedBrowserApp(new Request("http://localhost/api/browse/health", { - headers: { authorization: "Bearer test-token" }, - }))).status, 200); + assert.equal( + (await mountedBrowserApp(new Request("http://localhost/api/browse/health"))).status, + 401, + ); + assert.equal( + ( + await mountedBrowserApp( + new Request("http://localhost/api/browse/health", { + headers: { authorization: "Bearer test-token" }, + }), + ) + ).status, + 200, + ); } finally { if (previous === undefined) delete process.env.MCP_AUTH_TOKEN; else process.env.MCP_AUTH_TOKEN = previous; @@ -25,7 +35,9 @@ test("chat discovers tools through in-process MCP without an HTTP server", async const session = await openMcpSession(); try { assert.deepEqual(Object.keys(session.tools).sort(), ["browser_api", "browser_run"]); - } finally { await session.close(); } + } finally { + await session.close(); + } }); test("cancelled preview body reads return 499 instead of an unhandled 500", async (t) => { @@ -39,15 +51,27 @@ test("cancelled preview body reads return 499 instead of an unhandled 500", asyn }); return new Response(stream); }); - const response = await proxyBrowserRequest(new Request("http://localhost/api/screen", { - method: "POST", body: '{"cdpUrl":"9222"}', signal: controller.signal, - }), "/screen", dispatch); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/screen", { + method: "POST", + body: '{"cdpUrl":"9222"}', + signal: controller.signal, + }), + "/screen", + dispatch, + ); assert.equal(response.status, 499); }); test("an unreachable backend returns a useful start error", async (t) => { - const dispatch = t.mock.fn(async () => { throw new TypeError("fetch failed"); }); - const response = await proxyBrowserRequest(new Request("http://localhost/api/browser", { method: "POST" }), "/browser", dispatch); + const dispatch = t.mock.fn(async () => { + throw new TypeError("fetch failed"); + }); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/browser", { method: "POST" }), + "/browser", + dispatch, + ); assert.equal(response.status, 502); assert.match((await response.json()).error, /pnpm dev/); }); @@ -55,11 +79,22 @@ test("an unreachable backend returns a useful start error", async (t) => { test("successful create and preview responses preserve their bodies", async (t) => { const handle = { provider: "cdp", cdpUrl: "9222" }; const fetch = t.mock.fn(async () => Response.json(handle, { status: 201 })); - const response = await proxyBrowserRequest(new Request("http://localhost/api/browser", { method: "POST" }), "/browser", fetch); + const response = await proxyBrowserRequest( + new Request("http://localhost/api/browser", { method: "POST" }), + "/browser", + fetch, + ); assert.equal(response.status, 201); assert.deepEqual(await response.json(), handle); - fetch.mock.mockImplementation(async () => new Response(new Uint8Array([255, 216, 255]), { headers: { "content-type": "image/jpeg" } })); - const preview = await proxyBrowserRequest(new Request("http://localhost/api/screen", { method: "POST", body: JSON.stringify(handle) }), "/screen", fetch); + fetch.mock.mockImplementation( + async () => + new Response(new Uint8Array([255, 216, 255]), { headers: { "content-type": "image/jpeg" } }), + ); + const preview = await proxyBrowserRequest( + new Request("http://localhost/api/screen", { method: "POST", body: JSON.stringify(handle) }), + "/screen", + fetch, + ); assert.equal(preview.headers.get("content-type"), "image/jpeg"); assert.deepEqual(new Uint8Array(await preview.arrayBuffer()), new Uint8Array([255, 216, 255])); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8979a54..a78f369 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,12 +1,11 @@ -import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; - -import { defineConfig, loadEnv } from "vite"; -import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; +import { cloudflare } from "@cloudflare/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import { cloudflare } from "@cloudflare/vite-plugin"; +import react from "@vitejs/plugin-react"; import { nitro } from "nitro/vite"; +import { defineConfig, loadEnv } from "vite"; const workspaceRoot = fileURLToPath(new URL("../..", import.meta.url)); @@ -21,28 +20,36 @@ export default defineConfig(({ mode, command }) => { return { resolve: { - alias: [{ - find: "@browse-code-mode/mcp-server/runtime", - replacement: mode === "cloudflare" - ? "@browse-code-mode/mcp-server/worker-runtime" - : "@browse-code-mode/mcp-server/bundled-runtime", - }], + alias: [ + { + find: "@browse-code-mode/mcp-server/runtime", + replacement: + mode === "cloudflare" + ? "@browse-code-mode/mcp-server/worker-runtime" + : "@browse-code-mode/mcp-server/bundled-runtime", + }, + ], }, server: { port: 3000, strictPort: true }, plugins: [ { name: "browse-node-wasm", - resolveId(id) { if (id === "virtual:browse-wasm") return "\0browse-wasm"; }, + resolveId(id) { + if (id === "virtual:browse-wasm") return "\0browse-wasm"; + }, load(id) { if (id !== "\0browse-wasm") return; - const binary = readFileSync(new URL("../../packages/mcp-server/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", import.meta.url)); + const binary = readFileSync( + new URL( + "../../packages/mcp-server/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", + import.meta.url, + ), + ); return `export default Uint8Array.from(atob(${JSON.stringify(binary.toString("base64"))}), c => c.charCodeAt(0)).buffer;`; }, }, tailwindcss(), - ...(mode === "cloudflare" - ? [cloudflare({ viteEnvironment: { name: "ssr" } })] - : []), + ...(mode === "cloudflare" ? [cloudflare({ viteEnvironment: { name: "ssr" } })] : []), tanstackStart(), ...(mode === "cloudflare" || command !== "build" ? [] diff --git a/examples/01-hello-world/index.ts b/examples/01-hello-world/index.ts index f369836..816a7b8 100644 --- a/examples/01-hello-world/index.ts +++ b/examples/01-hello-world/index.ts @@ -6,10 +6,10 @@ */ import { anthropic } from "@ai-sdk/anthropic"; -import { generateText, stepCountIs } from "ai"; +import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; import { browserTools, instructions } from "@browse-code-mode/tools/ai-sdk"; import { providerBrowser } from "@browse-code-mode/tools/browser"; -import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; +import { generateText, stepCountIs } from "ai"; const prompt = process.argv[2] ?? "What's the weather in San Francisco right now?"; diff --git a/examples/02-mcp-server/index.ts b/examples/02-mcp-server/index.ts index ae5b477..3a0541c 100644 --- a/examples/02-mcp-server/index.ts +++ b/examples/02-mcp-server/index.ts @@ -10,11 +10,10 @@ */ import { createServer } from "node:http"; - +import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; +import { createBrowserToolkit, providerBrowser } from "@browse-code-mode/tools"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { createBrowserToolkit, providerBrowser } from "@browse-code-mode/tools"; -import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; const port = Number(process.env.PORT ?? 3000); const host = "127.0.0.1"; @@ -56,7 +55,10 @@ function createMcpServer(): McpServer { server.registerTool( sessions.start.name, - { description: sessions.start.description, annotations: { readOnlyHint: false, openWorldHint: true } }, + { + description: sessions.start.description, + annotations: { readOnlyHint: false, openWorldHint: true }, + }, async ({ signal }) => json(await sessions.start.execute(signal)), ); diff --git a/packages/mcp-server/api/index.ts b/packages/mcp-server/api/index.ts index 23b9dfe..b5ab561 100644 --- a/packages/mcp-server/api/index.ts +++ b/packages/mcp-server/api/index.ts @@ -1,8 +1,8 @@ -import { handle } from "hono/vercel"; import { Hono } from "hono"; +import { handle } from "hono/vercel"; import { createApp } from "../src/app.ts"; -import { loadConfig } from "../src/config.ts"; import { providerFromEnv } from "../src/browse/providers.ts"; +import { loadConfig } from "../src/config.ts"; const app = createApp({ config: loadConfig(process.env), provider: providerFromEnv(process.env) }); const router = new Hono(); diff --git a/packages/mcp-server/src/app.ts b/packages/mcp-server/src/app.ts index b481d53..c694211 100644 --- a/packages/mcp-server/src/app.ts +++ b/packages/mcp-server/src/app.ts @@ -10,17 +10,16 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { Hono } from "hono"; import { z } from "zod"; -import { staticProvider } from "./browse/provider.ts"; import { BrowserSession } from "./browse/driver.ts"; -import { captureScreenshot } from "./core.ts"; - -import { renderApiDts, renderCheatsheet, PROGRAM_GUIDE } from "./browse/dts.ts"; +import { PROGRAM_GUIDE, renderApiDts, renderCheatsheet } from "./browse/dts.ts"; +import { staticProvider } from "./browse/provider.ts"; import type { ServerConfig } from "./config.ts"; +import { captureScreenshot } from "./core.ts"; import { + type CreateServerOptions, createMcpServer, SERVER_NAME, SERVER_VERSION, - type CreateServerOptions, } from "./mcp/server.ts"; export interface AppDeps { @@ -48,9 +47,21 @@ export function createApp({ app.onError((error, c) => { if (c.req.raw.signal.aborted) return new Response(null, { status: 499 }); if (error.name === "NoBrowserError") - return c.json({ error: "No browser configured. Run pnpm dev locally, or configure BROWSE_PROVIDER and its credentials." }, 503); + return c.json( + { + error: + "No browser configured. Run pnpm dev locally, or configure BROWSE_PROVIDER and its credentials.", + }, + 503, + ); log(`Browser operation failed: ${error.message}`); - return c.json({ error: "Browser operation failed. Check the server logs and browser provider configuration." }, 500); + return c.json( + { + error: + "Browser operation failed. Check the server logs and browser provider configuration.", + }, + 500, + ); }); // ---------------------------------------------------------------- middleware @@ -84,11 +95,8 @@ export function createApp({ }); app.delete("/browser", async (c) => { - const parsed = browserHandleSchema.safeParse( - await c.req.json().catch(() => null), - ); - if (!parsed.success) - return c.json({ error: "A browser handle is required." }, 400); + const parsed = browserHandleSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "A browser handle is required." }, 400); if (parsed.data.provider !== provider.name) return c.json({ error: "Wrong browser provider." }, 400); await provider.shutdown(parsed.data); @@ -127,11 +135,7 @@ export function createApp({ /** Inspect any local or hosted CDP browser without HTTP discovery endpoints. */ app.get("/browser", async (c) => { const endpoint = c.req.query("cdpUrl") ?? config.defaultCdpUrl; - if (!endpoint) - return c.json( - { error: "No cdpUrl given and no default configured." }, - 400, - ); + if (!endpoint) return c.json({ error: "No cdpUrl given and no default configured." }, 400); try { const session = await (connect ?? BrowserSession.connect)({ @@ -146,10 +150,7 @@ export function createApp({ await session.close(); } } catch (error) { - return c.json( - { error: error instanceof Error ? error.message : String(error) }, - 502, - ); + return c.json({ error: error instanceof Error ? error.message : String(error) }, 502); } }); @@ -178,8 +179,7 @@ export function createApp({ jsonrpc: "2.0", error: { code: -32000, - message: - "This server is stateless. Send requests as POSTs; no session is kept.", + message: "This server is stateless. Send requests as POSTs; no session is kept.", }, id: null, }, @@ -265,9 +265,7 @@ async function closeQuietly( } function exposedOption(config: ServerConfig): { exposed?: readonly string[] } { - return config.allowCommands.length > 0 - ? { exposed: config.allowCommands } - : {}; + return config.allowCommands.length > 0 ? { exposed: config.allowCommands } : {}; } /** Constant-time-ish comparison, to keep token checks from leaking length. */ diff --git a/packages/mcp-server/src/browse/cdp.ts b/packages/mcp-server/src/browse/cdp.ts index d310624..3e9c75e 100644 --- a/packages/mcp-server/src/browse/cdp.ts +++ b/packages/mcp-server/src/browse/cdp.ts @@ -45,8 +45,7 @@ export async function resolveCdpUrl( } const payload = await fetchJson(new URL("/json/version", origin), timeoutMs, signal); - const webSocketDebuggerUrl = (payload as { webSocketDebuggerUrl?: unknown }) - .webSocketDebuggerUrl; + const webSocketDebuggerUrl = (payload as { webSocketDebuggerUrl?: unknown }).webSocketDebuggerUrl; if (typeof webSocketDebuggerUrl !== "string") { throw new CdpEndpointError( `${origin} answered /json/version without a webSocketDebuggerUrl. Is it a Chrome DevTools endpoint?`, diff --git a/packages/mcp-server/src/browse/commands.ts b/packages/mcp-server/src/browse/commands.ts index e609564..bbb8358 100644 --- a/packages/mcp-server/src/browse/commands.ts +++ b/packages/mcp-server/src/browse/commands.ts @@ -149,8 +149,7 @@ export const COMMANDS: CommandSpec[] = [ fn: "snapshot", wire: "snapshot", cli: "browse snapshot", - summary: - "Capture the accessibility tree and refresh the `@ref` map used by element commands.", + summary: "Capture the accessibility tree and refresh the `@ref` map used by element commands.", options: [ { name: "full", @@ -244,9 +243,7 @@ export const COMMANDS: CommandSpec[] = [ cli: "browse highlight ", summary: "Flash a visual highlight around an element.", args: [{ name: "target", param: "selector", type: "string", doc: SELECTOR_DOC }], - options: [ - { name: "durationMs", type: "number", doc: "Defaults to 2000." }, - ], + options: [{ name: "durationMs", type: "number", doc: "Defaults to 2000." }], returns: "{ highlighted: true }", }, @@ -324,8 +321,8 @@ export const COMMANDS: CommandSpec[] = [ name: "frame", type: "string", doc: - "Run inside an iframe instead of the top page: a snapshot frame index (`\"2\"` for refs `[2-…]`), " + - "the iframe's ref (`\"@0-10\"`), or an iframe selector (`\"#checkout\"`, `\"#outer >> iframe\"`). " + + 'Run inside an iframe instead of the top page: a snapshot frame index (`"2"` for refs `[2-…]`), ' + + 'the iframe\'s ref (`"@0-10"`), or an iframe selector (`"#checkout"`, `"#outer >> iframe"`). ' + "Cross-site frames work too.", }, ], @@ -364,9 +361,7 @@ export const COMMANDS: CommandSpec[] = [ { name: "width", type: "number" }, { name: "height", type: "number" }, ], - options: [ - { name: "scale", type: "number", doc: "Device pixel ratio. Defaults to 1." }, - ], + options: [{ name: "scale", type: "number", doc: "Device pixel ratio. Defaults to 1." }], returns: "{ viewport: { height: number; width: number } }", }, { @@ -483,8 +478,7 @@ export const COMMANDS: CommandSpec[] = [ cli: "browse tab new [url]", summary: "Open a new tab and make it active.", args: [{ name: "url", type: "string", optional: true }], - returns: - "{ active: true; index: number; targetId?: string; title?: string; url: string }", + returns: "{ active: true; index: number; targetId?: string; title?: string; url: string }", }, { group: "tab", @@ -500,8 +494,7 @@ export const COMMANDS: CommandSpec[] = [ doc: "Prefer the `targetId` from `tab.list()`; indexes shift as tabs open and close.", }, ], - returns: - "{ index: number; switched: true; targetId?: string; title?: string; url: string }", + returns: "{ index: number; switched: true; targetId?: string; title?: string; url: string }", }, { group: "tab", @@ -509,11 +502,8 @@ export const COMMANDS: CommandSpec[] = [ wire: "tab.close", cli: "browse tab close [tab]", summary: "Close a tab. Defaults to the active tab; refuses to close the last one.", - args: [ - { name: "tab", type: "string | number", optional: true, coerce: "string" }, - ], - returns: - "{ closed: true; index: number; selectedTargetId?: string; targetId?: string }", + args: [{ name: "tab", type: "string | number", optional: true, coerce: "string" }], + returns: "{ closed: true; index: number; selectedTargetId?: string; targetId?: string }", }, // ------------------------------------------------------------------- session @@ -608,10 +598,7 @@ export function commandGroups(): GroupName[] { * Build driver params from the positional arguments and trailing options object * a sandbox program passed, following the spec's arg/option declarations. */ -export function buildParams( - spec: CommandSpec, - args: readonly unknown[], -): Record { +export function buildParams(spec: CommandSpec, args: readonly unknown[]): Record { const params: Record = {}; const argSpecs = spec.args ?? []; diff --git a/packages/mcp-server/src/browse/driver.ts b/packages/mcp-server/src/browse/driver.ts index 99894d7..17ca2c7 100644 --- a/packages/mcp-server/src/browse/driver.ts +++ b/packages/mcp-server/src/browse/driver.ts @@ -13,17 +13,21 @@ import { NodeHtmlMarkdown } from "node-html-markdown"; import { - connectBrowser, type BrowserContext, + connectBrowser, type LoadState, type MouseButton, type Page, } from "./page.ts"; +import { + emptyRefMaps, + type FrameTarget, + type RefMaps, + type ResolvedSelector, + resolveSelector, +} from "./selectors.ts"; import type { OpenSocket } from "./transport.ts"; - -import { emptyRefMaps, resolveSelector, type FrameTarget, type RefMaps, type ResolvedSelector } from "./selectors.ts"; - /** Raised for command-level failures, mirroring the CLI's error surface. */ export class BrowseCommandError extends Error { readonly code: string | undefined; @@ -76,7 +80,12 @@ export class BrowserSession implements CommandRunner { } /** Attach to a browser that already exists. */ - static async connect({ cdpUrl, signal, onEvent, openSocket }: ConnectOptions): Promise { + static async connect({ + cdpUrl, + signal, + onEvent, + openSocket, + }: ConnectOptions): Promise { const context = await connectBrowser(cdpUrl, signal, openSocket); onEvent?.("attached over CDP"); return new BrowserSession(context, cdpUrl); @@ -89,7 +98,10 @@ export class BrowserSession implements CommandRunner { async run(command: string, params: Params): Promise { const handler = HANDLERS[command]; if (!handler) throw new BrowseCommandError(`Unknown command "${command}".`); - if (["open", "reload", "back", "forward", "tab.new", "tab.switch", "tab.close"].includes(command)) this.setRefMaps({}); + if ( + ["open", "reload", "back", "forward", "tab.new", "tab.switch", "tab.close"].includes(command) + ) + this.setRefMaps({}); return handler(this, params); } @@ -340,13 +352,18 @@ const HANDLERS: Record = { const quality = optNum(params, "quality"); const clip = params.clip; const buffer = await page.screenshot({ - ...(optBool(params, "fullPage") === undefined ? {} : { fullPage: optBool(params, "fullPage") }), + ...(optBool(params, "fullPage") === undefined + ? {} + : { fullPage: optBool(params, "fullPage") }), ...(type === undefined ? {} : { type: type as "png" | "jpeg" }), ...(quality === undefined ? {} : { quality }), ...(clip === undefined ? {} : { clip: clip as never }), }); - if (optStr(params, "path")) throw new BrowseCommandError("Screenshot paths are unavailable; use the returned base64 data."); + if (optStr(params, "path")) + throw new BrowseCommandError( + "Screenshot paths are unavailable; use the returned base64 data.", + ); return { base64: buffer }; }, @@ -377,7 +394,9 @@ const HANDLERS: Record = { } else if (type === "timeout") { const ms = Number(arg ?? 0); if (!Number.isInteger(ms) || ms < 0) { - throw new BrowseCommandError("wait timeout requires a non-negative integer of milliseconds."); + throw new BrowseCommandError( + "wait timeout requires a non-negative integer of milliseconds.", + ); } await page.waitForTimeout(ms); } else { @@ -487,7 +506,13 @@ const HANDLERS: Record = { const active = await context.activePage(); const resolved = tab ? await resolveTab(session, tab) - : { index: Math.max(0, pages.findIndex((p) => p.pageId === active?.pageId)), page: active ?? pages[0]! }; + : { + index: Math.max( + 0, + pages.findIndex((p) => p.pageId === active?.pageId), + ), + page: active ?? pages[0]!, + }; const closedTargetId = resolved.page.pageId; await resolved.page.close(); diff --git a/packages/mcp-server/src/browse/dts.ts b/packages/mcp-server/src/browse/dts.ts index aecb1b9..2db011c 100644 --- a/packages/mcp-server/src/browse/dts.ts +++ b/packages/mcp-server/src/browse/dts.ts @@ -6,7 +6,7 @@ * types, and the sandbox globals at the same time. */ -import { COMMANDS, commandGroups, SHARED_TYPES, type CommandSpec } from "./commands.ts"; +import { COMMANDS, type CommandSpec, commandGroups, SHARED_TYPES } from "./commands.ts"; export interface RenderOptions { /** Restrict output to these `group.fn` keys. Defaults to all. */ @@ -15,7 +15,8 @@ export interface RenderOptions { const GROUP_DOCS: Record = { browse: "Top-level `browse` commands: navigation, snapshots, elements, and page state.", - mouse: "Raw coordinate input (`browse mouse ...`). Prefer refs when the element is in a snapshot.", + mouse: + "Raw coordinate input (`browse mouse ...`). Prefer refs when the element is in a snapshot.", tab: "Tab management (`browse tab ...`).", network: "Network capture (`browse network ...`).", }; @@ -89,7 +90,9 @@ export function renderCheatsheet(options: RenderOptions = {}): string { if (specs.length === 0) continue; lines.push(`## ${group}`); for (const spec of specs) { - lines.push(`- \`${spec.group}.${spec.fn}${signature(spec)}\` — ${spec.summary} (\`${spec.cli}\`)`); + lines.push( + `- \`${spec.group}.${spec.fn}${signature(spec)}\` — ${spec.summary} (\`${spec.cli}\`)`, + ); } lines.push(""); } @@ -115,9 +118,7 @@ function docComment(spec: CommandSpec): string { const lines: string[] = [spec.summary, "", `CLI: \`${spec.cli}\``]; const documented = [ - ...(spec.args ?? []) - .filter((arg) => arg.doc) - .map((arg) => `@param ${arg.name} ${arg.doc}`), + ...(spec.args ?? []).filter((arg) => arg.doc).map((arg) => `@param ${arg.name} ${arg.doc}`), ...(spec.options ?? []) .filter((option) => option.doc) .map((option) => `@param options.${option.name} ${option.doc}`), @@ -134,9 +135,7 @@ function signature(spec: CommandSpec): string { ); if (spec.options?.length) { - const fields = spec.options - .map((option) => `${option.name}?: ${option.type}`) - .join("; "); + const fields = spec.options.map((option) => `${option.name}?: ${option.type}`).join("; "); params.push(`options?: { ${fields} }`); } diff --git a/packages/mcp-server/src/browse/host-functions.ts b/packages/mcp-server/src/browse/host-functions.ts index 56b11f2..54f2faf 100644 --- a/packages/mcp-server/src/browse/host-functions.ts +++ b/packages/mcp-server/src/browse/host-functions.ts @@ -12,8 +12,8 @@ type HostFunctions = Record Promi import { buildParams, COMMANDS, - commandGroups, type CommandSpec, + commandGroups, type GroupName, } from "./commands.ts"; import type { CommandRunner } from "./driver.ts"; @@ -71,7 +71,8 @@ export function createHostFunctions(options: HostFunctionOptions): HostFunctionB const key = `${spec.group}.${spec.fn}`; if (!isPermitted(key, options)) continue; exposed.push(key); - hostFunctions[spec.group]![spec.fn] = (...args: unknown[]) => invoke(spec, args, calls, counter, options); + hostFunctions[spec.group]![spec.fn] = (...args: unknown[]) => + invoke(spec, args, calls, counter, options); } return { hostFunctions, calls, exposed }; @@ -146,10 +147,7 @@ function isPermitted(key: string, options: HostFunctionOptions): boolean { * Render a call as the shell command it stands for, so a program's trace reads * like the `browse` invocations an agent would otherwise have typed. */ -export function formatCall( - spec: CommandSpec, - params: Record, -): string { +export function formatCall(spec: CommandSpec, params: Record): string { const words = cliWords(spec.cli); const positional = (spec.args ?? []) .map((arg) => params[arg.param ?? arg.name]) diff --git a/packages/mcp-server/src/browse/page.ts b/packages/mcp-server/src/browse/page.ts index 89545a5..9aa601b 100644 --- a/packages/mcp-server/src/browse/page.ts +++ b/packages/mcp-server/src/browse/page.ts @@ -1,13 +1,13 @@ /** Direct CDP implementation: no browser extension, filesystem, or Node APIs. */ -import { CdpConnection, openWebSocket, type OpenSocket } from "./transport.ts"; + import { resolveCdpUrl } from "./cdp.ts"; import type { FrameTarget, ResolvedSelector } from "./selectors.ts"; +import { CdpConnection, type OpenSocket, openWebSocket } from "./transport.ts"; export type LoadState = "load" | "domcontentloaded" | "networkidle"; export type MouseButton = "left" | "middle" | "right"; type NavigationOptions = { timeout?: number; waitUntil?: LoadState }; -const pause = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function connectBrowser( cdpUrl: string, @@ -57,8 +57,7 @@ export class BrowserContext { const selected = pages.find((p) => p.pageId === this.selected); if (selected) return selected; for (const page of pages) { - if (await page.evaluate("document.hasFocus()").catch(() => false)) - return page; + if (await page.evaluate("document.hasFocus()").catch(() => false)) return page; } return pages[0]; } @@ -93,12 +92,7 @@ export class Page { /** Each session's frames → the id of their main-world execution context. */ private worlds = new Map>(); private runtimeEnabled = new Map>(); - constructor( - cdp: CdpConnection, - id: string, - sessionId: string, - signal?: AbortSignal, - ) { + constructor(cdp: CdpConnection, id: string, sessionId: string, signal?: AbortSignal) { this.cdp = cdp; this.pageId = id; this.sessionId = sessionId; @@ -127,10 +121,7 @@ export class Page { if (method === "Network.requestWillBeSent") { this.requests.add(params.requestId); this.networkChangedAt = Date.now(); - } else if ( - method === "Network.loadingFinished" || - method === "Network.loadingFailed" - ) { + } else if (method === "Network.loadingFinished" || method === "Network.loadingFailed") { this.requests.delete(params.requestId); this.networkChangedAt = Date.now(); } @@ -168,8 +159,7 @@ export class Page { } private ownsSession(session: string | undefined): boolean { if (session === this.sessionId) return true; - for (const frame of this.oopifs.values()) - if (frame.sessionId === session) return true; + for (const frame of this.oopifs.values()) if (frame.sessionId === session) return true; return false; } private trackWorld(session: string, method: string, params: any) { @@ -207,7 +197,9 @@ export class Page { awaitPromise: true, }); if (response.exceptionDetails) - throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text); + throw new Error( + response.exceptionDetails.exception?.description ?? response.exceptionDetails.text, + ); return response.result.value; } /** Wait until every nested iframe target has been attached. */ @@ -230,7 +222,9 @@ export class Page { const entry = [...this.oopifs].find(([, frame]) => frame.sessionId === sessionId); if (!entry) throw new Error("The element's frame is no longer attached."); const [frameId, { parentSessionId }] = entry; - const { backendNodeId } = await this.sendTo(parentSessionId, "DOM.getFrameOwner", { frameId }); + const { backendNodeId } = await this.sendTo(parentSessionId, "DOM.getFrameOwner", { + frameId, + }); const { model } = await this.sendTo(parentSessionId, "DOM.getBoxModel", { backendNodeId }); x += model.content[0]; y += model.content[1]; @@ -247,11 +241,18 @@ export class Page { ]; const perSession = await Promise.all( sessions.map(async ({ sessionId, parentSessionId }) => { - const { frameTree } = await this.sendTo(sessionId, "Page.getFrameTree").catch(() => ({ frameTree: undefined })); + const { frameTree } = await this.sendTo(sessionId, "Page.getFrameTree").catch(() => ({ + frameTree: undefined, + })); if (!frameTree) return []; const found: SnapshotFrame[] = []; const walk = (node: any, root: boolean) => { - found.push({ frameId: node.frame.id, sessionId, ownerSession: root ? parentSessionId : sessionId, nodes: [] }); + found.push({ + frameId: node.frame.id, + sessionId, + ownerSession: root ? parentSessionId : sessionId, + nodes: [], + }); for (const child of node.childFrames ?? []) walk(child, false); }; walk(frameTree, true); @@ -263,9 +264,13 @@ export class Page { frames.map(async (frame) => { // A frame can navigate or detach mid-snapshot; leave it out rather than fail. const [tree, owner] = await Promise.all([ - this.sendTo(frame.sessionId, "Accessibility.getFullAXTree", { frameId: frame.frameId }).catch(() => undefined), + this.sendTo(frame.sessionId, "Accessibility.getFullAXTree", { + frameId: frame.frameId, + }).catch(() => undefined), frame.ownerSession - ? this.sendTo(frame.ownerSession, "DOM.getFrameOwner", { frameId: frame.frameId }).catch(() => undefined) + ? this.sendTo(frame.ownerSession, "DOM.getFrameOwner", { + frameId: frame.frameId, + }).catch(() => undefined) : undefined, ]); frame.nodes = tree?.nodes ?? []; @@ -282,8 +287,7 @@ export class Page { }); if (response.exceptionDetails) throw new Error( - response.exceptionDetails.exception?.description ?? - response.exceptionDetails.text, + response.exceptionDetails.exception?.description ?? response.exceptionDetails.text, ); return response.result.value; } @@ -305,8 +309,7 @@ export class Page { private async history(delta: number, options: NavigationOptions) { const history = await this.send("Page.getNavigationHistory"); const entry = history.entries[history.currentIndex + delta]; - if (entry) - await this.send("Page.navigateToHistoryEntry", { entryId: entry.id }); + if (entry) await this.send("Page.navigateToHistoryEntry", { entryId: entry.id }); await this.waitForLoadState(options.waitUntil ?? "load", options.timeout); } goBack(options: NavigationOptions = {}) { @@ -326,18 +329,13 @@ export class Page { const started = Date.now(); return this.poll( async () => - this.requests.size === 0 && - Date.now() - Math.max(started, this.networkChangedAt) >= 500, + this.requests.size === 0 && Date.now() - Math.max(started, this.networkChangedAt) >= 500, timeout, ); } await this.poll(async () => { - const ready = await this.evaluate("document.readyState").catch( - () => "loading", - ); - return state === "domcontentloaded" - ? ready !== "loading" - : ready === "complete"; + const ready = await this.evaluate("document.readyState").catch(() => "loading"); + return state === "domcontentloaded" ? ready !== "loading" : ready === "complete"; }, timeout); } async poll(check: () => Promise, timeout: number) { @@ -379,8 +377,7 @@ export class Page { const parts = combo.split("+"); const key = parts.pop()!; const modifiers = parts.reduce( - (n, p) => - n | ({ Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 }[p] ?? 0), + (n, p) => n | ({ Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 }[p] ?? 0), 0, ); const codes: Record = { @@ -399,12 +396,7 @@ export class Page { PageDown: 34, }; const code = codes[key] ?? key.toUpperCase().charCodeAt(0); - const text = - key === "Enter" - ? "\r" - : key.length === 1 && !(modifiers & 7) - ? key - : undefined; + const text = key === "Enter" ? "\r" : key.length === 1 && !(modifiers & 7) ? key : undefined; await this.send("Input.dispatchKeyEvent", { type: "keyDown", key, @@ -419,10 +411,7 @@ export class Page { windowsVirtualKeyCode: code, }); } - async type( - text: string, - options?: { delay?: number; withMistakes?: boolean }, - ) { + async type(text: string, options?: { delay?: number; withMistakes?: boolean }) { if (options?.delay || options?.withMistakes) for (const char of text) { if (options.withMistakes && Math.random() < 0.08) { @@ -438,10 +427,7 @@ export class Page { async click( x: number, y: number, - { - button = "left", - clickCount = 1, - }: { button?: MouseButton; clickCount?: number } = {}, + { button = "left", clickCount = 1 }: { button?: MouseButton; clickCount?: number } = {}, ) { await this.hover(x, y); await this.send("Input.dispatchMouseEvent", { @@ -524,9 +510,7 @@ export class Page { const { data } = await this.send("Page.captureScreenshot", { format: options.type ?? "png", ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip - ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } - : {}), + ...(clip ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } : {}), }); return data as string; } @@ -578,7 +562,8 @@ export class Page { if (shown && ref) { // Resolve refs using backend node IDs, never a server-side page registry. xpathMap[ref] = `backend=${node.backendDOMNodeId}`; - const url = node.properties?.find((p: { name: string }) => p.name === "url")?.value?.value; + const url = node.properties?.find((p: { name: string }) => p.name === "url")?.value + ?.value; if (url) urlMap[ref] = url; } if (shown) @@ -588,7 +573,8 @@ export class Page { const childDepth = depth + (shown ? 1 : 0); for (const child of node.childIds ?? []) visit(byId.get(child), childDepth, node.ignored ? parentName : name); - const inner = node.backendDOMNodeId && hosted.get(`${frame.sessionId}:${node.backendDOMNodeId}`); + const inner = + node.backendDOMNodeId && hosted.get(`${frame.sessionId}:${node.backendDOMNodeId}`); if (inner) renderFrame(inner, childDepth); }; for (const node of frame.nodes) @@ -681,7 +667,9 @@ export class Locator { if (index === hops.length - 1) return { sessionId, objectId: result.objectId }; // Step into the iframe this hop matched. - const { node } = await this.page.sendTo(sessionId, "DOM.describeNode", { objectId: result.objectId }); + const { node } = await this.page.sendTo(sessionId, "DOM.describeNode", { + objectId: result.objectId, + }); await this.page.sendTo(sessionId, "Runtime.releaseObject", { objectId: result.objectId }); if (!node.frameId) throw new Error(`"${hop}" is not an iframe, so ">>" cannot enter it.`); await this.page.settleFrames(); @@ -692,10 +680,14 @@ export class Locator { contextId = undefined; } else { // Same process: an isolated world shares the frame's DOM. - ({ executionContextId: contextId } = await this.page.sendTo(sessionId, "Page.createIsolatedWorld", { - frameId: node.frameId, - worldName: "browse", - })); + ({ executionContextId: contextId } = await this.page.sendTo( + sessionId, + "Page.createIsolatedWorld", + { + frameId: node.frameId, + worldName: "browse", + }, + )); } } return null; @@ -732,7 +724,9 @@ export class Locator { awaitPromise: true, }); if (result.exceptionDetails) - throw new Error(result.exceptionDetails.exception?.description ?? "Element operation failed."); + throw new Error( + result.exceptionDetails.exception?.description ?? "Element operation failed.", + ); return result.result.value; } finally { await this.page @@ -782,19 +776,13 @@ export class Locator { ); } textContent(): Promise { - return this.apply( - "if (!el) throw new Error('Element not found'); return el.textContent;", - ); + return this.apply("if (!el) throw new Error('Element not found'); return el.textContent;"); } innerHtml(): Promise { - return this.apply( - "if (!el) throw new Error('Element not found'); return el.innerHTML;", - ); + return this.apply("if (!el) throw new Error('Element not found'); return el.innerHTML;"); } inputValue(): Promise { - return this.apply( - "if (!el) throw new Error('Element not found'); return el.value;", - ); + return this.apply("if (!el) throw new Error('Element not found'); return el.value;"); } isVisible(): Promise { return this.apply( diff --git a/packages/mcp-server/src/browse/provider.ts b/packages/mcp-server/src/browse/provider.ts index 8bea218..4ccb927 100644 --- a/packages/mcp-server/src/browse/provider.ts +++ b/packages/mcp-server/src/browse/provider.ts @@ -46,9 +46,7 @@ export function staticProvider(defaultCdpUrl?: string): BrowserProvider { /** Adapter for vendor SDKs or REST APIs; no SDK enters the core runtime. */ export function browserProvider(options: { name: string; - create: ( - options: CreateBrowserOptions, - ) => Promise>; + create: (options: CreateBrowserOptions) => Promise>; shutdown: (browser: BrowserHandle) => Promise; }): BrowserProvider { return { diff --git a/packages/mcp-server/src/browse/providers.ts b/packages/mcp-server/src/browse/providers.ts index dce8bd2..03c1901 100644 --- a/packages/mcp-server/src/browse/providers.ts +++ b/packages/mcp-server/src/browse/providers.ts @@ -1,17 +1,10 @@ /** Hosted lifecycles use fetch only and retain no process-local session state. */ -import { - browserProvider, - staticProvider, - type BrowserProvider, -} from "./provider.ts"; +import { type BrowserProvider, browserProvider, staticProvider } from "./provider.ts"; type Env = Record; type Fetch = typeof fetch; -export function providerFromEnv( - env: Env, - request: Fetch = fetch, -): BrowserProvider { +export function providerFromEnv(env: Env, request: Fetch = fetch): BrowserProvider { const name = env.BROWSE_PROVIDER ?? "cdp"; if (name === "cdp") return staticProvider(env.BROWSE_CDP_URL); const timeout = Number(env.BROWSER_TIMEOUT_SECONDS ?? 600); @@ -39,15 +32,13 @@ export function providerFromEnv( : AbortSignal.timeout(30_000), }); if (deleting && [404, 410].includes(response.status)) return null; - if (!response.ok) - throw new Error(`${name} ${method} failed (HTTP ${response.status}).`); + if (!response.ok) throw new Error(`${name} ${method} failed (HTTP ${response.status}).`); if (response.status === 204) return null; return response.json() as Promise>; }; const field = (data: Record | null, key: string): string => { const value = data?.[key]; - if (typeof value !== "string" || !value) - throw new Error(`${name} returned no ${key}.`); + if (typeof value !== "string" || !value) throw new Error(`${name} returned no ${key}.`); return value; }; if (name === "browserbase") { @@ -60,9 +51,7 @@ export function providerFromEnv( "POST", headers, { - ...(env.BROWSERBASE_PROJECT_ID - ? { projectId: env.BROWSERBASE_PROJECT_ID } - : {}), + ...(env.BROWSERBASE_PROJECT_ID ? { projectId: env.BROWSERBASE_PROJECT_ID } : {}), keepAlive: true, timeout, }, @@ -74,8 +63,7 @@ export function providerFromEnv( }; }, async shutdown(browser) { - if (!browser.sessionId) - throw new Error("Browserbase sessionId is required."); + if (!browser.sessionId) throw new Error("Browserbase sessionId is required."); await api( `https://api.browserbase.com/v1/sessions/${encodeURIComponent(browser.sessionId)}`, "POST", @@ -108,8 +96,7 @@ export function providerFromEnv( }; }, async shutdown(browser) { - if (!browser.sessionId) - throw new Error("Kernel sessionId is required."); + if (!browser.sessionId) throw new Error("Kernel sessionId is required."); await api( `https://api.onkernel.com/browsers/${encodeURIComponent(browser.sessionId)}`, "DELETE", diff --git a/packages/mcp-server/src/browse/transport.ts b/packages/mcp-server/src/browse/transport.ts index 1a68728..d601717 100644 --- a/packages/mcp-server/src/browse/transport.ts +++ b/packages/mcp-server/src/browse/transport.ts @@ -1,17 +1,11 @@ /** Web-standard CDP transport. Cloudflare supplies a fetch-upgrade socket factory. */ -export type OpenSocket = ( - url: string, - signal?: AbortSignal, -) => Promise; +export type OpenSocket = (url: string, signal?: AbortSignal) => Promise; export const openWebSocket: OpenSocket = (url, signal) => new Promise((resolve, reject) => { signal?.throwIfAborted(); const socket = new WebSocket(url); - const timer = setTimeout( - () => fail(new Error("CDP connection timed out.")), - 10_000, - ); + const timer = setTimeout(() => fail(new Error("CDP connection timed out.")), 10_000); const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); @@ -31,11 +25,9 @@ export const openWebSocket: OpenSocket = (url, signal) => }, { once: true }, ); - socket.addEventListener( - "error", - () => fail(new Error("CDP WebSocket connection failed.")), - { once: true }, - ); + socket.addEventListener("error", () => fail(new Error("CDP WebSocket connection failed.")), { + once: true, + }); }); type Pending = { @@ -49,9 +41,7 @@ export class CdpConnection { private pending = new Map(); private nextId = 0; private signal: AbortSignal | undefined; - private listeners = new Set< - (method: string, params: any, sessionId?: string) => void - >(); + private listeners = new Set<(method: string, params: any, sessionId?: string) => void>(); private abort = () => this.close(); constructor(socket: WebSocket, signal?: AbortSignal) { this.socket = socket; @@ -80,14 +70,9 @@ export class CdpConnection { this.listeners.add(listener); } - send( - method: string, - params: object = {}, - sessionId?: string, - ): Promise { + send(method: string, params: object = {}, sessionId?: string): Promise { this.signal?.throwIfAborted(); - if (this.socket.readyState !== 1) - return Promise.reject(new Error("CDP connection is closed.")); + if (this.socket.readyState !== 1) return Promise.reject(new Error("CDP connection is closed.")); return new Promise((resolve, reject) => { const id = ++this.nextId; const timer = setTimeout(() => { diff --git a/packages/mcp-server/src/core.ts b/packages/mcp-server/src/core.ts index 7479fb5..bfbc13d 100644 --- a/packages/mcp-server/src/core.ts +++ b/packages/mcp-server/src/core.ts @@ -10,25 +10,21 @@ import { z } from "zod"; import { COMMANDS } from "./browse/commands.ts"; import { BrowserSession, type CommandRunner } from "./browse/driver.ts"; -import { - staticProvider, - type BrowserHandle, - type BrowserProvider, -} from "./browse/provider.ts"; import { PROGRAM_GUIDE, renderApiDts, renderCheatsheet } from "./browse/dts.ts"; +import { type BrowserHandle, type BrowserProvider, staticProvider } from "./browse/provider.ts"; import type { ServerConfig } from "./config.ts"; -import { runProgram, type ProgramResult } from "./sandbox/runner.ts"; +import { type ProgramResult, runProgram } from "./sandbox/runner.ts"; -export { loadConfig, type ServerConfig } from "./config.ts"; +export { PROGRAM_GUIDE, renderApiDts, renderCheatsheet } from "./browse/dts.ts"; export { - browserProvider, - staticProvider, - NoBrowserError, type BrowserHandle, type BrowserProvider, + browserProvider, + NoBrowserError, + staticProvider, } from "./browse/provider.ts"; export { providerFromEnv } from "./browse/providers.ts"; -export { PROGRAM_GUIDE, renderApiDts, renderCheatsheet } from "./browse/dts.ts"; +export { loadConfig, type ServerConfig } from "./config.ts"; export type { ProgramResult, RunLimits } from "./sandbox/runner.ts"; export const INSTRUCTIONS = @@ -157,9 +153,10 @@ export function runToolDescription(config: ServerConfig, browserLine?: string): `read, filter — into one program and return just the data you need. Intermediate page`, `content stays in the sandbox instead of entering your context.`, ``, - browserLine ?? (config.defaultCdpUrl - ? `Browser: the CDP endpoint at \`${config.defaultCdpUrl}\`. Pass \`cdpUrl\` to drive a different one.` - : "Browser: pass `cdpUrl` to reuse a browser, or omit it to create a temporary browser with the configured provider."), + browserLine ?? + (config.defaultCdpUrl + ? `Browser: the CDP endpoint at \`${config.defaultCdpUrl}\`. Pass \`cdpUrl\` to drive a different one.` + : "Browser: pass `cdpUrl` to reuse a browser, or omit it to create a temporary browser with the configured provider."), ``, `Each program attaches to the browser, runs, and detaches. Page state (tabs,`, `cookies, scroll) belongs to that browser and persists; snapshot refs do not,`, @@ -214,9 +211,7 @@ export async function executeBrowserRun( let lease: BrowserHandle; try { - lease = cdpUrl - ? { provider: "cdp", cdpUrl } - : await provider.create(signal ? { signal } : {}); + lease = cdpUrl ? { provider: "cdp", cdpUrl } : await provider.create(signal ? { signal } : {}); } catch (error) { return failure(describeError(error)); } @@ -281,11 +276,16 @@ export async function executeBrowserRun( /** Attach, take one JPEG of the current page, detach. Returns base64. */ export async function captureScreenshot( cdpUrl: string, - { connect = connectOverCdp, signal }: { connect?: ToolDeps["connect"]; signal?: AbortSignal | undefined } = {}, + { + connect = connectOverCdp, + signal, + }: { connect?: ToolDeps["connect"]; signal?: AbortSignal | undefined } = {}, ): Promise { const session = await connect({ cdpUrl, ...(signal ? { signal } : {}) }); try { - const result = (await session.run("screenshot", { type: "jpeg", quality: 70 })) as { base64: string }; + const result = (await session.run("screenshot", { type: "jpeg", quality: 70 })) as { + base64: string; + }; return result.base64; } finally { await session.close(); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index e48de37..c065cea 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,8 +6,8 @@ import { serve } from "@hono/node-server"; import { createApp } from "./app.ts"; -import { loadConfig } from "./config.ts"; import { providerFromEnv } from "./browse/providers.ts"; +import { loadConfig } from "./config.ts"; const command = process.argv[2]; if (command && command !== "dev" && command !== "serve") { @@ -17,9 +17,7 @@ if (command && command !== "dev" && command !== "serve") { // Local process ownership is confined to CLI dev, never the fetch application. const startup = new AbortController(); let local: import("./node/local-browser.ts").LocalBrowser | undefined; -let starting: - | Promise - | undefined; +let starting: Promise | undefined; let stopping = false; const stop = async () => { if (stopping) return; @@ -66,22 +64,17 @@ try { }), }); - server = serve( - { fetch: app.fetch, hostname: config.host, port: config.port }, - (info) => { - log(`listening on http://${config.host}:${info.port}${config.endpoint}`); - log( - config.defaultCdpUrl - ? `default browser: ${config.defaultCdpUrl}` - : "no BROWSE_CDP_URL set: callers must pass cdpUrl per request", - ); - if (!config.authToken) { - log( - "no MCP_AUTH_TOKEN set: every caller on this host can drive the browser", - ); - } - }, - ); + server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => { + log(`listening on http://${config.host}:${info.port}${config.endpoint}`); + log( + config.defaultCdpUrl + ? `default browser: ${config.defaultCdpUrl}` + : "no BROWSE_CDP_URL set: callers must pass cdpUrl per request", + ); + if (!config.authToken) { + log("no MCP_AUTH_TOKEN set: every caller on this host can drive the browser"); + } + }); server.on("error", async (error) => { console.error(error); diff --git a/packages/mcp-server/src/mcp/server.ts b/packages/mcp-server/src/mcp/server.ts index 221686c..f5f96a7 100644 --- a/packages/mcp-server/src/mcp/server.ts +++ b/packages/mcp-server/src/mcp/server.ts @@ -11,20 +11,19 @@ */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - +import { renderApiDts } from "../browse/dts.ts"; import { API_TOOL_DESCRIPTION, - INSTRUCTIONS, executeBrowserRun, + INSTRUCTIONS, renderBrowserApi, renderGuide, runInputShape, runToolDescription, type ToolDeps, } from "../core.ts"; -import { renderApiDts } from "../browse/dts.ts"; -export { EXAMPLE_PROGRAM, type Connection } from "../core.ts"; +export { type Connection, EXAMPLE_PROGRAM } from "../core.ts"; export const SERVER_NAME = "browse-code-mode"; export const SERVER_VERSION = "0.1.0"; diff --git a/packages/mcp-server/src/node/local-browser.ts b/packages/mcp-server/src/node/local-browser.ts index 3ec7d47..47a05c0 100644 --- a/packages/mcp-server/src/node/local-browser.ts +++ b/packages/mcp-server/src/node/local-browser.ts @@ -1,9 +1,9 @@ /** Node-only dev lifecycle. Never import this from a deployed fetch handler. */ -import { spawn, type ChildProcess } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { createServer } from "node:net"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import type { BrowserHandle, BrowserProvider } from "../browse/provider.ts"; @@ -29,11 +29,7 @@ function findChrome(): string { ] : process.platform === "win32" ? ["C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"] - : [ - "/usr/bin/google-chrome", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - ]; + : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"]; const executable = candidates.find(existsSync); if (!executable) throw new Error("Chrome not found. Set CHROME_PATH."); return executable; @@ -55,17 +51,14 @@ export async function createLocalBrowser({ headless?: boolean; signal?: AbortSignal; } = {}): Promise { - if (!Number.isInteger(port) || port < 1 || port > 65535) - throw new Error("Invalid CHROME_PORT."); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid CHROME_PORT."); signal?.throwIfAborted(); const browser = { provider: "cdp", cdpUrl: String(port) }; - if (await cdpReady(port)) - return { browser, owned: false, async shutdown() {} }; + if (await cdpReady(port)) return { browser, owned: false, async shutdown() {} }; const executable = findChrome(); const temporary = !process.env.CHROME_USER_DATA_DIR; const profile = - process.env.CHROME_USER_DATA_DIR ?? - (await mkdtemp(join(tmpdir(), "browse-cdp-"))); + process.env.CHROME_USER_DATA_DIR ?? (await mkdtemp(join(tmpdir(), "browse-cdp-"))); const child = spawn( executable, [ @@ -91,11 +84,7 @@ export async function createLocalBrowser({ let shutdownPromise: Promise | undefined; const shutdown = (): Promise => (shutdownPromise ??= (async () => { - if ( - child.exitCode === null && - child.signalCode === null && - !launchError - ) { + if (child.exitCode === null && child.signalCode === null && !launchError) { child.kill("SIGTERM"); const timer = setTimeout(() => child.kill("SIGKILL"), 3000); await exited; @@ -111,8 +100,7 @@ export async function createLocalBrowser({ if (launchError) throw launchError; if (child.exitCode !== null || child.signalCode !== null) throw new Error("Chrome exited before CDP was ready."); - if (await cdpReady(port)) - return { browser, owned: true, process: child, shutdown }; + if (await cdpReady(port)) return { browser, owned: true, process: child, shutdown }; await new Promise((resolve) => setTimeout(resolve, 150)); } throw new Error(`Chrome did not expose CDP on ${port}.`); @@ -132,13 +120,20 @@ export async function createLocalBrowser({ export function localProvider({ port = Number(process.env.CHROME_PORT ?? 9222), headless = process.env.CHROME_HEADLESS !== "0", -}: { port?: number; headless?: boolean } = {}): BrowserProvider { +}: { + port?: number; + headless?: boolean; +} = {}): BrowserProvider { const running = new Map(); return { name: "local", async create({ signal } = {}) { const target = running.has(String(port)) ? await freePort() : port; - const local = await createLocalBrowser({ port: target, headless, ...(signal ? { signal } : {}) }); + const local = await createLocalBrowser({ + port: target, + headless, + ...(signal ? { signal } : {}), + }); running.set(local.browser.cdpUrl, local); return { provider: "local", cdpUrl: local.browser.cdpUrl, sessionId: local.browser.cdpUrl }; }, diff --git a/packages/mcp-server/src/runtime-bundled.ts b/packages/mcp-server/src/runtime-bundled.ts index 0cf5d76..3d5191a 100644 --- a/packages/mcp-server/src/runtime-bundled.ts +++ b/packages/mcp-server/src/runtime-bundled.ts @@ -1,15 +1,22 @@ -import { newVariant, newQuickJSWASMModuleFromVariant } from "quickjs-emscripten-core"; -import variant from "@jitl/quickjs-wasmfile-release-sync"; import wasmBinary from "virtual:browse-wasm"; +import variant from "@jitl/quickjs-wasmfile-release-sync"; +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; import { createApp } from "./app.ts"; -import { loadConfig } from "./config.ts"; import { providerFromEnv } from "./browse/providers.ts"; +import { loadConfig } from "./config.ts"; import { createProgramRunner } from "./sandbox/runner.ts"; -const run = createProgramRunner(() => newQuickJSWASMModuleFromVariant(newVariant( - variant as unknown as Exclude[0]>, { default: unknown }>, - { wasmBinary }, -))); +const run = createProgramRunner(() => + newQuickJSWASMModuleFromVariant( + newVariant( + variant as unknown as Exclude< + Awaited[0]>, + { default: unknown } + >, + { wasmBinary }, + ), + ), +); export function createConfiguredApp(env: Record) { return createApp({ config: loadConfig(env), provider: providerFromEnv(env), run }); diff --git a/packages/mcp-server/src/runtime.ts b/packages/mcp-server/src/runtime.ts index 183341f..e12aeb5 100644 --- a/packages/mcp-server/src/runtime.ts +++ b/packages/mcp-server/src/runtime.ts @@ -1,6 +1,6 @@ import { createApp } from "./app.ts"; -import { loadConfig } from "./config.ts"; import { providerFromEnv } from "./browse/providers.ts"; +import { loadConfig } from "./config.ts"; export function createConfiguredApp(env: Record) { return createApp({ config: loadConfig(env), provider: providerFromEnv(env) }); diff --git a/packages/mcp-server/src/sandbox/runner.ts b/packages/mcp-server/src/sandbox/runner.ts index 5c3c078..cb5bd4f 100644 --- a/packages/mcp-server/src/sandbox/runner.ts +++ b/packages/mcp-server/src/sandbox/runner.ts @@ -1,18 +1,16 @@ /** QuickJS/WASM sandbox shared by Node, Vercel and Cloudflare Workers. * Only JSON values cross the boundary; the guest has no host globals. */ + +import variant from "@jitl/quickjs-wasmfile-release-sync"; import { newQuickJSWASMModuleFromVariant, - type QuickJSWASMModule, - type QuickJSHandle, type QuickJSDeferredPromise, + type QuickJSHandle, + type QuickJSWASMModule, } from "quickjs-emscripten-core"; -import variant from "@jitl/quickjs-wasmfile-release-sync"; import { transform } from "sucrase"; import type { CommandRunner } from "../browse/driver.ts"; -import { - createHostFunctions, - type CommandCall, -} from "../browse/host-functions.ts"; +import { type CommandCall, createHostFunctions } from "../browse/host-functions.ts"; export interface RunLimits { timeoutMs?: number; maxBridgeRequests?: number; @@ -68,8 +66,7 @@ const loadQuickJS: LoadQuickJS = () => variant as unknown as Parameters[0], ); -export const runProgram = (options: RunProgramOptions) => - createProgramRunner(loadQuickJS)(options); +export const runProgram = (options: RunProgramOptions) => createProgramRunner(loadQuickJS)(options); export function createProgramRunner(load: LoadQuickJS) { return async (options: RunProgramOptions): Promise => { @@ -89,9 +86,7 @@ export function createProgramRunner(load: LoadQuickJS) { }); const module = await load(); const runtime = module.newRuntime(); - runtime.setMemoryLimit( - options.limits?.memoryLimitBytes ?? 32 * 1024 * 1024, - ); + runtime.setMemoryLimit(options.limits?.memoryLimitBytes ?? 32 * 1024 * 1024); runtime.setMaxStackSize(512 * 1024); let interrupts = 0; let budgetExceeded = false; @@ -99,9 +94,7 @@ export function createProgramRunner(load: LoadQuickJS) { // bounds synchronous loops there, without needing a worker thread. runtime.setInterruptHandler(() => { budgetExceeded = ++interrupts > (options.limits?.maxInterrupts ?? 10_000); - return ( - budgetExceeded || Date.now() >= deadline || !!options.signal?.aborted - ); + return budgetExceeded || Date.now() >= deadline || !!options.signal?.aborted; }); const vm = runtime.newContext(); let alive = true; @@ -115,67 +108,57 @@ export function createProgramRunner(load: LoadQuickJS) { throw new Error("Result exceeds maxResultBytes."); return vm.newString(json); }; - const host = vm.newFunction( - "__host", - (groupHandle, fnHandle, argsHandle) => { - const group = vm.getString(groupHandle); - const fn = vm.getString(fnHandle); - const functions = Object.hasOwn(hostFunctions, group) - ? hostFunctions[group] - : undefined; - const callback = - functions && Object.hasOwn(functions, fn) ? functions[fn] : undefined; - if (!callback) - return { - error: vm.newError(`Unknown host function: ${group}.${fn}`), - }; - if (++commands > maxCommands) - return { error: vm.newError("Command budget exceeded.") }; - const args = JSON.parse(vm.getString(argsHandle)) as unknown[]; - const deferred = vm.newPromise(); - deferreds.add(deferred); - const task = Promise.resolve() - .then(() => { - if (alive) return callback(...args); - }) - .then( - (value) => { - if (!alive) return; - const handle = encode(value); - deferred.resolve(handle); - handle.dispose(); - }, - () => { - if (!alive) return; - const error = vm.newError("Host function failed."); - deferred.reject(error); - error.dispose(); - }, - ) - .catch((error) => { + const host = vm.newFunction("__host", (groupHandle, fnHandle, argsHandle) => { + const group = vm.getString(groupHandle); + const fn = vm.getString(fnHandle); + const functions = Object.hasOwn(hostFunctions, group) ? hostFunctions[group] : undefined; + const callback = functions && Object.hasOwn(functions, fn) ? functions[fn] : undefined; + if (!callback) + return { + error: vm.newError(`Unknown host function: ${group}.${fn}`), + }; + if (++commands > maxCommands) return { error: vm.newError("Command budget exceeded.") }; + const args = JSON.parse(vm.getString(argsHandle)) as unknown[]; + const deferred = vm.newPromise(); + deferreds.add(deferred); + const task = Promise.resolve() + .then(() => { + if (alive) return callback(...args); + }) + .then( + (value) => { if (!alive) return; - const handle = vm.newError( - error instanceof Error ? error.message : String(error), - ); - deferred.reject(handle); + const handle = encode(value); + deferred.resolve(handle); handle.dispose(); - }) - .finally(() => { - pending.delete(task); - if (alive) { - deferred.dispose(); - deferreds.delete(deferred); - } - }); - pending.add(task); - return deferred.handle; - }, - ); + }, + () => { + if (!alive) return; + const error = vm.newError("Host function failed."); + deferred.reject(error); + error.dispose(); + }, + ) + .catch((error) => { + if (!alive) return; + const handle = vm.newError(error instanceof Error ? error.message : String(error)); + deferred.reject(handle); + handle.dispose(); + }) + .finally(() => { + pending.delete(task); + if (alive) { + deferred.dispose(); + deferreds.delete(deferred); + } + }); + pending.add(task); + return deferred.handle; + }); const log = vm.newFunction("__log", (levelHandle, argsHandle) => { const json = vm.getString(argsHandle); logBytes += new TextEncoder().encode(json).byteLength; - if (logBytes > maxBytes) - return { error: vm.newError("Log output exceeds maxResultBytes.") }; + if (logBytes > maxBytes) return { error: vm.newError("Log output exceeds maxResultBytes.") }; const entry = { level: vm.getString(levelHandle) as LogEntry["level"], args: JSON.parse(json) as unknown[], @@ -192,10 +175,10 @@ export function createProgramRunner(load: LoadQuickJS) { let abort: (() => void) | undefined; try { options.signal?.throwIfAborted(); - const source = transform( - `async function __program() {\n${options.code}\n}\n__program()`, - { transforms: ["typescript"], filePath: "program.ts" }, - ).code; + const source = transform(`async function __program() {\n${options.code}\n}\n__program()`, { + transforms: ["typescript"], + filePath: "program.ts", + }).code; const setup = vm.evalCode(` for (const group of ["browse", "mouse", "tab"]) { globalThis[group] = new Proxy({}, { get(_, fn) { @@ -216,8 +199,7 @@ export function createProgramRunner(load: LoadQuickJS) { ), Math.max(1, deadline - Date.now()), ); - abort = () => - reject(options.signal?.reason ?? new Error("Program aborted.")); + abort = () => reject(options.signal?.reason ?? new Error("Program aborted.")); options.signal?.addEventListener("abort", abort, { once: true }); }); while (true) { @@ -232,10 +214,7 @@ export function createProgramRunner(load: LoadQuickJS) { if (state.type === "fulfilled") { const value = vm.dump(state.value); state.value.dispose(); - if (pending.size) - throw new Error( - "Program returned with unawaited browser commands.", - ); + if (pending.size) throw new Error("Program returned with unawaited browser commands."); const check = encode(value); check.dispose(); return { @@ -253,8 +232,7 @@ export function createProgramRunner(load: LoadQuickJS) { name: detail.name ?? "Error", }); } - if (!pending.size) - throw new Error("Program left an unresolved promise."); + if (!pending.size) throw new Error("Program left an unresolved promise."); await Promise.race([stopped, ...pending]); } } catch (error) { @@ -263,11 +241,7 @@ export function createProgramRunner(load: LoadQuickJS) { return { status: "failed", error: { - name: timedOut - ? "RunTimeoutError" - : error instanceof Error - ? error.name - : "Error", + name: timedOut ? "RunTimeoutError" : error instanceof Error ? error.name : "Error", message: timedOut ? "Program exceeded its time or instruction budget." : error instanceof Error diff --git a/packages/mcp-server/src/worker.ts b/packages/mcp-server/src/worker.ts index aaf644b..f939149 100644 --- a/packages/mcp-server/src/worker.ts +++ b/packages/mcp-server/src/worker.ts @@ -1,15 +1,12 @@ -import { - newVariant, - newQuickJSWASMModuleFromVariant, -} from "quickjs-emscripten-core"; import variant from "@jitl/quickjs-wasmfile-release-sync"; +import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"; import wasmModule from "../node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm"; import { createApp } from "./app.ts"; -import { loadConfig } from "./config.ts"; import { BrowserSession } from "./browse/driver.ts"; -import { createProgramRunner } from "./sandbox/runner.ts"; -import type { OpenSocket } from "./browse/transport.ts"; import { providerFromEnv } from "./browse/providers.ts"; +import type { OpenSocket } from "./browse/transport.ts"; +import { loadConfig } from "./config.ts"; +import { createProgramRunner } from "./sandbox/runner.ts"; const run = createProgramRunner(() => newQuickJSWASMModuleFromVariant( @@ -35,12 +32,12 @@ const openSocket: OpenSocket = async (url, signal) => { }; export function createConfiguredApp(env: Record) { - return createApp({ - config: loadConfig({ ...env }), - run, - provider: providerFromEnv({ ...env }), - connect: (options) => BrowserSession.connect({ ...options, openSocket }), - }); + return createApp({ + config: loadConfig({ ...env }), + run, + provider: providerFromEnv({ ...env }), + connect: (options) => BrowserSession.connect({ ...options, openSocket }), + }); } export default { diff --git a/packages/mcp-server/tests/browser.e2e.test.ts b/packages/mcp-server/tests/browser.e2e.test.ts index 7713d2f..60ff8ac 100644 --- a/packages/mcp-server/tests/browser.e2e.test.ts +++ b/packages/mcp-server/tests/browser.e2e.test.ts @@ -11,7 +11,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { BrowserSession } from "../src/browse/driver.ts"; -import { runProgram, type ProgramResult } from "../src/sandbox/runner.ts"; +import { type ProgramResult, runProgram } from "../src/sandbox/runner.ts"; const enabled = process.env.BROWSE_E2E === "1"; const CDP_URL = process.env.BROWSE_CDP_URL ?? "9222"; @@ -26,7 +26,9 @@ async function run(code: string): Promise { } } -test("a program drives a real browser and returns only its result", { skip: !enabled }, async () => { +test("a program drives a real browser and returns only its result", { + skip: !enabled, +}, async () => { const result = await run(` await browse.open("https://example.com"); const { tree } = await browse.snapshot(); diff --git a/packages/mcp-server/tests/commands.test.ts b/packages/mcp-server/tests/commands.test.ts index de7fb83..b82b167 100644 --- a/packages/mcp-server/tests/commands.test.ts +++ b/packages/mcp-server/tests/commands.test.ts @@ -1,14 +1,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { - buildParams, - COMMANDS, - findCommand, - WIRE_COMMANDS, -} from "../src/browse/commands.ts"; -import { formatCall } from "../src/browse/host-functions.ts"; +import { buildParams, COMMANDS, findCommand, WIRE_COMMANDS } from "../src/browse/commands.ts"; import { renderApiDts, renderCheatsheet } from "../src/browse/dts.ts"; +import { formatCall } from "../src/browse/host-functions.ts"; test("every driver command is reachable from the sandbox", () => { const wired = new Set(COMMANDS.map((spec) => spec.wire)); @@ -93,7 +88,12 @@ test("optional arguments may be omitted", () => { test("calls render as the shell command they stand for", () => { const cases: Array<[string, string, unknown[], string]> = [ ["browse", "click", ["@0-12"], "browse click @0-12"], - ["browse", "fill", ["#q", "hi there", { pressEnter: true }], 'browse fill #q "hi there" --press-enter'], + [ + "browse", + "fill", + ["#q", "hi there", { pressEnter: true }], + 'browse fill #q "hi there" --press-enter', + ], ["browse", "snapshot", [{ maxDepth: 4 }], "browse snapshot --max-depth 4"], ["mouse", "scroll", [10, 20, 0, 600], "browse mouse scroll 10 20 0 600"], ["tab", "list", [], "browse tab list"], diff --git a/packages/mcp-server/tests/frames.e2e.test.ts b/packages/mcp-server/tests/frames.e2e.test.ts index 8693ca4..2170fa1 100644 --- a/packages/mcp-server/tests/frames.e2e.test.ts +++ b/packages/mcp-server/tests/frames.e2e.test.ts @@ -10,8 +10,8 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { BrowserSession } from "../src/browse/driver.ts"; -import { runProgram, type ProgramResult } from "../src/sandbox/runner.ts"; -import { serveHardPages, type HardPages } from "./helpers/hard-pages.ts"; +import { type ProgramResult, runProgram } from "../src/sandbox/runner.ts"; +import { type HardPages, serveHardPages } from "./helpers/hard-pages.ts"; const enabled = process.env.BROWSE_E2E === "1"; const CDP_URL = process.env.BROWSE_CDP_URL ?? "9222"; @@ -55,9 +55,12 @@ async function onHardPage(body: string): Promise { return result.value; } -test("the snapshot includes every frame, cross-site and nested ones too", { skip: !enabled }, async () => { +test("the snapshot includes every frame, cross-site and nested ones too", { + skip: !enabled, +}, async () => { const tree = (await onHardPage(`return tree;`)) as string; - const refOf = (name: string) => /\[(\d+)-\d+\]/.exec(tree.split("\n").find((l) => l.includes(name)) ?? "")?.[1]; + const refOf = (name: string) => + /\[(\d+)-\d+\]/.exec(tree.split("\n").find((l) => l.includes(name)) ?? "")?.[1]; for (const name of [ "Top button", @@ -67,12 +70,20 @@ test("the snapshot includes every frame, cross-site and nested ones too", { skip "Button in shadow frame", "Open shadow button", "Closed shadow button", - ]) assert.ok(refOf(name) !== undefined, `${name} missing from:\n${tree}`); + ]) + assert.ok(refOf(name) !== undefined, `${name} missing from:\n${tree}`); // The frame index in a ref says which document the element belongs to. assert.equal(refOf("Top button"), "0"); assert.equal(refOf("Open shadow button"), "0"); - const frames = new Set(["Same-origin button", "Cross-site button", "Nested frame button", "Button in shadow frame"].map(refOf)); + const frames = new Set( + [ + "Same-origin button", + "Cross-site button", + "Nested frame button", + "Button in shadow frame", + ].map(refOf), + ); assert.equal(frames.size, 4); assert.ok(![...frames].includes("0")); @@ -103,7 +114,9 @@ test("refs click inside every frame and shadow root", { skip: !enabled }, async ]); }); -test("fill, select, read, and type work in cross-site and same-origin frames", { skip: !enabled }, async () => { +test("fill, select, read, and type work in cross-site and same-origin frames", { + skip: !enabled, +}, async () => { const value = await onHardPage(` await browse.fill(ref("Cross-site input"), "filled"); await browse.select(ref("Same-origin select"), ["b"]); @@ -122,7 +135,12 @@ test("fill, select, read, and type work in cross-site and same-origin frames", { crossText: "Text inside the cross-site frame", crossValue: "filled", typed: "typed", - events: ["change:cross-input=filled", "change:same-select=b", "change:cross-input=", "click:cross-input"], + events: [ + "change:cross-input=filled", + "change:same-select=b", + "change:cross-input=", + "click:cross-input", + ], }); }); @@ -144,7 +162,9 @@ test("CSS pierces open shadow roots and hops into frames with >>", { skip: !enab }); }); -test("get box returns page coordinates that mouse.click can use, even in a nested cross-site frame", { skip: !enabled }, async () => { +test("get box returns page coordinates that mouse.click can use, even in a nested cross-site frame", { + skip: !enabled, +}, async () => { const events = await onHardPage(` const { x, y } = await browse.get("box", ref("Nested frame button")); await mouse.click(x, y); diff --git a/packages/mcp-server/tests/lifecycle.test.ts b/packages/mcp-server/tests/lifecycle.test.ts index a76df58..e957132 100644 --- a/packages/mcp-server/tests/lifecycle.test.ts +++ b/packages/mcp-server/tests/lifecycle.test.ts @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createApp } from "../src/app.ts"; -import { loadConfig } from "../src/config.ts"; import { browserProvider, staticProvider } from "../src/browse/provider.ts"; import { providerFromEnv } from "../src/browse/providers.ts"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { loadConfig } from "../src/config.ts"; import { createMcpServer } from "../src/mcp/server.ts"; test("provider shutdown still runs when CDP disconnect throws", async () => { @@ -13,10 +13,18 @@ test("provider shutdown still runs when CDP disconnect throws", async () => { const server = createMcpServer({ config: loadConfig(), provider: browserProvider({ - name: "test", create: async () => ({ cdpUrl: "wss://example.test" }), - shutdown: async () => { stopped = true; }, + name: "test", + create: async () => ({ cdpUrl: "wss://example.test" }), + shutdown: async () => { + stopped = true; + }, + }), + connect: async () => ({ + run: async () => null, + close: async () => { + throw new Error("disconnect failed"); + }, }), - connect: async () => ({ run: async () => null, close: async () => { throw new Error("disconnect failed"); } }), }); const client = new Client({ name: "cleanup-test", version: "1" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -26,7 +34,10 @@ test("provider shutdown still runs when CDP disconnect throws", async () => { const result = await client.callTool({ name: "browser_run", arguments: { code: "return 1;" } }); assert.equal(result.isError, true); assert.equal(stopped, true); - } finally { await client.close(); await server.close(); } + } finally { + await client.close(); + await server.close(); + } }); test("a serialized handle can be shut down on a fresh instance", async () => { @@ -43,9 +54,7 @@ test("a serialized handle can be shut down on a fresh instance", async () => { }, }); const create = createApp({ config: loadConfig(), provider: provider() }); - const result = await create.fetch( - new Request("http://test/browser", { method: "POST" }), - ); + const result = await create.fetch(new Request("http://test/browser", { method: "POST" })); assert.equal(result.status, 201); const handle = await result.json(); const fresh = createApp({ config: loadConfig(), provider: provider() }); @@ -101,9 +110,7 @@ for (const name of ["kernel", "browserbase"]) { const body = JSON.parse(String(calls[0]?.init?.body)); assert.equal(name === "kernel" ? body.timeout_seconds : body.timeout, 600); if (name === "browserbase") assert.equal(body.keepAlive, true); - await providerFromEnv(env, request).shutdown( - JSON.parse(JSON.stringify(handle)), - ); + await providerFromEnv(env, request).shutdown(JSON.parse(JSON.stringify(handle))); assert.match(calls[1]!.url, /\/one$/); assert.equal(calls[1]?.init?.method, name === "kernel" ? "DELETE" : "POST"); }); diff --git a/packages/mcp-server/tests/mcp.test.ts b/packages/mcp-server/tests/mcp.test.ts index b200632..8369484 100644 --- a/packages/mcp-server/tests/mcp.test.ts +++ b/packages/mcp-server/tests/mcp.test.ts @@ -8,7 +8,12 @@ import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/type import { createApp } from "../src/app.ts"; import { loadConfig } from "../src/config.ts"; -import { createFakeBrowser, echoHandler, type FakeBrowser, type FakeHandler } from "./helpers/fake-browser.ts"; +import { + createFakeBrowser, + echoHandler, + type FakeBrowser, + type FakeHandler, +} from "./helpers/fake-browser.ts"; const cleanups: Array<() => Promise> = []; @@ -81,10 +86,10 @@ test("the server advertises exactly the code mode surface", async () => { assert.ok(run?.inputSchema.properties && "cdpUrl" in run.inputSchema.properties); const { resources } = await client.listResources(); - assert.deepEqual( - resources.map((resource) => resource.uri).sort(), - ["browse://api.d.ts", "browse://guide.md"], - ); + assert.deepEqual(resources.map((resource) => resource.uri).sort(), [ + "browse://api.d.ts", + "browse://guide.md", + ]); }); test("the transport is stateless: no session id is issued", async () => { @@ -227,7 +232,9 @@ test("a provider supplies the browser and is released after the program", async liveViewUrl: `https://cloud.example/watch/${sessionId}`, }; }, - async shutdown(browser) { released.push(browser.sessionId!); }, + async shutdown(browser) { + released.push(browser.sessionId!); + }, }, connect: async () => createFakeBrowser(), }); @@ -253,7 +260,10 @@ test("a provider supplies the browser and is released after the program", async }); assert.equal(result.isError, false); - assert.equal((result.structuredContent as { cdpUrl: string }).cdpUrl, "wss://cloud.example/session-1"); + assert.equal( + (result.structuredContent as { cdpUrl: string }).cdpUrl, + "wss://cloud.example/session-1", + ); assert.deepEqual(acquired, ["session-1"]); assert.deepEqual(released, ["session-1"], "the session must be ended, not orphaned"); diff --git a/packages/mcp-server/tests/runtime.e2e.test.ts b/packages/mcp-server/tests/runtime.e2e.test.ts index f4851db..11e4fff 100644 --- a/packages/mcp-server/tests/runtime.e2e.test.ts +++ b/packages/mcp-server/tests/runtime.e2e.test.ts @@ -4,7 +4,7 @@ import { createServer } from "node:net"; import { test } from "node:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { createLocalBrowser, cdpReady } from "../src/node/local-browser.ts"; +import { cdpReady, createLocalBrowser } from "../src/node/local-browser.ts"; async function freePort(): Promise { const server = createServer(); @@ -15,74 +15,61 @@ async function freePort(): Promise { return address.port; } -test( - "deployed runtime executes a program and leaves borrowed Chrome alive", - { timeout: 90_000 }, - async () => { - const chromePort = await freePort(); - const port = await freePort(); - const local = await createLocalBrowser({ port: chromePort }); - const worker = process.env.BROWSE_RUNTIME === "worker"; - const child = spawn( - worker ? "pnpm" : process.execPath, - worker - ? [ - "exec", - "wrangler", - "dev", - "--port", - String(port), - "--var", - `BROWSE_CDP_URL:${chromePort}`, - ] - : ["src/index.ts", "dev"], - { - cwd: new URL("..", import.meta.url), - env: { - ...process.env, - PORT: String(port), - CHROME_PORT: String(chromePort), - MCP_AUTH_TOKEN: "", - BROWSE_PROVIDER: "cdp", - }, - stdio: "pipe", - detached: process.platform !== "win32", +test("deployed runtime executes a program and leaves borrowed Chrome alive", { + timeout: 90_000, +}, async () => { + const chromePort = await freePort(); + const port = await freePort(); + const local = await createLocalBrowser({ port: chromePort }); + const worker = process.env.BROWSE_RUNTIME === "worker"; + const child = spawn( + worker ? "pnpm" : process.execPath, + worker + ? ["exec", "wrangler", "dev", "--port", String(port), "--var", `BROWSE_CDP_URL:${chromePort}`] + : ["src/index.ts", "dev"], + { + cwd: new URL("..", import.meta.url), + env: { + ...process.env, + PORT: String(port), + CHROME_PORT: String(chromePort), + MCP_AUTH_TOKEN: "", + BROWSE_PROVIDER: "cdp", }, - ); - let output = ""; - child.stdout.on("data", (data) => { - output += data; - }); - child.stderr.on("data", (data) => { - output += data; - }); - const exit = new Promise((resolve) => - child.once("exit", () => resolve()), - ); - const client = new Client({ name: "runtime-test", version: "1" }); - const base = `http://127.0.0.1:${port}`; - try { - const deadline = Date.now() + 30_000; - while (true) { - if (Date.now() >= deadline || child.exitCode !== null) - throw new Error(`Runtime did not start: ${output}`); - try { - if ((await fetch(`${base}/health`)).ok) break; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 100)); - } - const create = await fetch(`${base}/browser`, { method: "POST" }); - assert.equal(create.status, 201); - const browser = (await create.json()) as { cdpUrl: string }; - await client.connect( - new StreamableHTTPClientTransport(new URL(`${base}/mcp`)), - ); - const call = (code: string) => - client.callTool({ - name: "browser_run", - arguments: { code, cdpUrl: browser.cdpUrl }, - }); - const result = await call(` + stdio: "pipe", + detached: process.platform !== "win32", + }, + ); + let output = ""; + child.stdout.on("data", (data) => { + output += data; + }); + child.stderr.on("data", (data) => { + output += data; + }); + const exit = new Promise((resolve) => child.once("exit", () => resolve())); + const client = new Client({ name: "runtime-test", version: "1" }); + const base = `http://127.0.0.1:${port}`; + try { + const deadline = Date.now() + 30_000; + while (true) { + if (Date.now() >= deadline || child.exitCode !== null) + throw new Error(`Runtime did not start: ${output}`); + try { + if ((await fetch(`${base}/health`)).ok) break; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const create = await fetch(`${base}/browser`, { method: "POST" }); + assert.equal(create.status, 201); + const browser = (await create.json()) as { cdpUrl: string }; + await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`))); + const call = (code: string) => + client.callTool({ + name: "browser_run", + arguments: { code, cdpUrl: browser.cdpUrl }, + }); + const result = await call(` await browse.open("data:text/html,Runtime

Hello

"); await browse.fill("#name", "Cloudflare"); const {tree} = await browse.snapshot(); @@ -92,41 +79,40 @@ test( const answer: number = 42; return {answer, title: await browse.get("title"), value: await browse.get("value", "#name")}; `); - assert.equal(result.isError, false, JSON.stringify(result)); - assert.deepEqual((result.structuredContent as { value: unknown }).value, { - answer: 42, - title: { title: "Runtime" }, - value: { value: "Cloudflare" }, - }); - const loop = await call("while (true) {}"); - assert.equal(loop.isError, true); - assert.equal( - (loop.structuredContent as { error: { name: string } }).error.name, - "RunTimeoutError", - ); - const screenshot = await fetch(`${base}/screen`, { - method: "POST", - body: JSON.stringify(browser), - }); - assert.equal(screenshot.status, 200); - assert.equal(screenshot.headers.get("content-type"), "image/jpeg"); - assert.ok((await screenshot.arrayBuffer()).byteLength > 100); - const shutdown = await fetch(`${base}/browser`, { - method: "DELETE", - body: JSON.stringify(browser), - }); - assert.equal(shutdown.status, 204); - } finally { - await client.close().catch(() => {}); - if (child.pid && child.exitCode === null) { - if (process.platform === "win32") child.kill("SIGTERM"); - else process.kill(-child.pid, "SIGTERM"); - await exit; - } - const borrowedSurvived = await cdpReady(chromePort); - await local.shutdown(); - assert.equal(borrowedSurvived, true); - assert.equal(await cdpReady(chromePort), false); + assert.equal(result.isError, false, JSON.stringify(result)); + assert.deepEqual((result.structuredContent as { value: unknown }).value, { + answer: 42, + title: { title: "Runtime" }, + value: { value: "Cloudflare" }, + }); + const loop = await call("while (true) {}"); + assert.equal(loop.isError, true); + assert.equal( + (loop.structuredContent as { error: { name: string } }).error.name, + "RunTimeoutError", + ); + const screenshot = await fetch(`${base}/screen`, { + method: "POST", + body: JSON.stringify(browser), + }); + assert.equal(screenshot.status, 200); + assert.equal(screenshot.headers.get("content-type"), "image/jpeg"); + assert.ok((await screenshot.arrayBuffer()).byteLength > 100); + const shutdown = await fetch(`${base}/browser`, { + method: "DELETE", + body: JSON.stringify(browser), + }); + assert.equal(shutdown.status, 204); + } finally { + await client.close().catch(() => {}); + if (child.pid && child.exitCode === null) { + if (process.platform === "win32") child.kill("SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); + await exit; } - }, -); + const borrowedSurvived = await cdpReady(chromePort); + await local.shutdown(); + assert.equal(borrowedSurvived, true); + assert.equal(await cdpReady(chromePort), false); + } +}); diff --git a/packages/mcp-server/tests/sandbox.test.ts b/packages/mcp-server/tests/sandbox.test.ts index e1c3684..8660680 100644 --- a/packages/mcp-server/tests/sandbox.test.ts +++ b/packages/mcp-server/tests/sandbox.test.ts @@ -5,21 +5,28 @@ import { runProgram } from "../src/sandbox/runner.ts"; import { createFakeBrowser, echoHandler } from "./helpers/fake-browser.ts"; test("TypeScript is stripped inside the sandbox boundary", async () => { - const result = await runProgram({ code: "const value: number = 42; return value;", session: createFakeBrowser() }); + const result = await runProgram({ + code: "const value: number = 42; return value;", + session: createFakeBrowser(), + }); assert.equal(result.value, 42); }); test("a timeout disposes pending host promises without crashing the runtime", async () => { const result = await runProgram({ code: 'return await browse.get("url");', - session: createFakeBrowser(() => new Promise(() => {})), limits: { timeoutMs: 30 }, + session: createFakeBrowser(() => new Promise(() => {})), + limits: { timeoutMs: 30 }, }); assert.equal(result.status, "failed"); assert.equal(result.error?.name, "RunTimeoutError"); }); test("host prototype members are not sandbox capabilities", async () => { - const result = await runProgram({ code: 'return await __host("constructor", "constructor", "[]");', session: createFakeBrowser() }); + const result = await runProgram({ + code: 'return await __host("constructor", "constructor", "[]");', + session: createFakeBrowser(), + }); assert.equal(result.status, "failed"); assert.match(result.error?.message ?? "", /Unknown host function/); }); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json index e8241d2..486440a 100644 --- a/packages/mcp-server/tsconfig.json +++ b/packages/mcp-server/tsconfig.json @@ -1,14 +1,10 @@ { "compilerOptions": { "target": "ES2023", - "lib": [ - "ES2023", "DOM" - ], + "lib": ["ES2023", "DOM"], "module": "NodeNext", "moduleResolution": "nodenext", - "types": [ - "node" - ], + "types": ["node"], "outDir": "dist", "rootDir": "src", "strict": true, @@ -25,7 +21,5 @@ "isolatedModules": true, "rewriteRelativeImportExtensions": true }, - "include": [ - "src/**/*.ts" - ] + "include": ["src/**/*.ts"] } diff --git a/packages/tools/package.json b/packages/tools/package.json index 94e175e..399cc8c 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -26,8 +26,12 @@ "ai": "^7.0.0" }, "peerDependenciesMeta": { - "@mastra/core": { "optional": true }, - "ai": { "optional": true } + "@mastra/core": { + "optional": true + }, + "ai": { + "optional": true + } }, "devDependencies": { "@mastra/core": "1.67.0", diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index bc7af9f..526680a 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -15,24 +15,28 @@ import { tool } from "ai"; import { - createBrowserToolkit, - liveViewModelOutput, - toModelOutput, type Browser, type BrowserToolkit, type BrowserToolsOptions, + createBrowserToolkit, + liveViewModelOutput, type SessionToolkit, + toModelOutput, } from "./index.ts"; -export { instructions, type BrowserToolsOptions, type BrowserRunOutput } from "./index.ts"; +export { type BrowserRunOutput, type BrowserToolsOptions, instructions } from "./index.ts"; type RunTools = ReturnType; type SessionTools = ReturnType; /** With a `browser`, the session tools join `browser_api` and `browser_run`. */ -export function browserTools(options: BrowserToolsOptions & { browser: Browser }): RunTools & SessionTools; +export function browserTools( + options: BrowserToolsOptions & { browser: Browser }, +): RunTools & SessionTools; export function browserTools(options?: BrowserToolsOptions): RunTools; -export function browserTools(options: BrowserToolsOptions = {}): RunTools | (RunTools & SessionTools) { +export function browserTools( + options: BrowserToolsOptions = {}, +): RunTools | (RunTools & SessionTools) { const toolkit = createBrowserToolkit(options); const tools = runTools(toolkit); return toolkit.sessions ? { ...tools, ...sessionTools(toolkit.sessions) } : tools; diff --git a/packages/tools/src/browser.ts b/packages/tools/src/browser.ts index 9b8f7c2..58b9918 100644 --- a/packages/tools/src/browser.ts +++ b/packages/tools/src/browser.ts @@ -23,8 +23,8 @@ */ import { - captureScreenshot, type BrowserProvider, + captureScreenshot, type ToolDeps, } from "@browse-code-mode/mcp-server/core"; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index e7fd1a0..96899ad 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -7,36 +7,36 @@ * `./ai-sdk` and `./mastra` wrap this toolkit in each framework's tool type. */ -import { z } from "zod"; import { API_TOOL_DESCRIPTION, - INSTRUCTIONS, + type BrowserProvider, executeBrowserRun, + INSTRUCTIONS, loadConfig, providerFromEnv, renderBrowserApi, runInputShape, runToolDescription, staticProvider, - type BrowserProvider, type ToolDeps, } from "@browse-code-mode/mcp-server/core"; +import { z } from "zod"; import type { Browser, BrowserSession, LiveView } from "./browser.ts"; export { + type BrowserHandle, + type BrowserProvider, browserProvider, providerFromEnv, staticProvider, - type BrowserHandle, - type BrowserProvider, } from "@browse-code-mode/mcp-server/core"; export { - providerBrowser, type Browser, type BrowserSession, type LiveView, type ProviderBrowser, + providerBrowser, } from "./browser.ts"; export interface BrowserToolsOptions { @@ -115,7 +115,10 @@ export function createBrowserToolkit(options: BrowserToolsOptions = {}) { : undefined; const sessionIdInput = z.object({ - sessionId: z.string().min(1).describe("A session id from `browser_start` or `browser_list_sessions`."), + sessionId: z + .string() + .min(1) + .describe("A session id from `browser_start` or `browser_list_sessions`."), }); const runShape = runInputShape(config); const runInputSchema = browser @@ -168,7 +171,12 @@ export function createBrowserToolkit(options: BrowserToolsOptions = {}) { const session = sessionId ? sessions.find((s) => s.id === sessionId) : sessions.at(-1); if (sessionId && !session) { const text = `Unknown browser session: ${sessionId}. Call browser_list_sessions for the open ones.`; - return { text, isError: true, status: "failed", error: { name: "Error", message: text } }; + return { + text, + isError: true, + status: "failed", + error: { name: "Error", message: text }, + }; } if (session) input.cdpUrl = session.cdpUrl; label = session ? `session ${session.id}` : "temporary session"; @@ -216,8 +224,10 @@ export function createBrowserToolkit(options: BrowserToolsOptions = {}) { "See a browser session as it is now: a screenshot of the current page, " + "plus a URL a person can open to watch it live when the provider has one.", inputSchema: sessionIdInput, - execute: ({ sessionId }: { sessionId: string }, signal?: AbortSignal): Promise => - browser.liveView(sessionId, signal ? { signal } : {}), + execute: ( + { sessionId }: { sessionId: string }, + signal?: AbortSignal, + ): Promise => browser.liveView(sessionId, signal ? { signal } : {}), }, } : undefined, @@ -255,8 +265,17 @@ export function liveViewModelOutput({ url, screenshot }: LiveView) { return { type: "content" as const, value: [ - { type: "text" as const, text: url ? `Live view: ${url}` : "No live view URL for this session; screenshot of the current page:" }, - { type: "file" as const, mediaType: screenshot.mediaType, data: { type: "data" as const, data: screenshot.base64 } }, + { + type: "text" as const, + text: url + ? `Live view: ${url}` + : "No live view URL for this session; screenshot of the current page:", + }, + { + type: "file" as const, + mediaType: screenshot.mediaType, + data: { type: "data" as const, data: screenshot.base64 }, + }, ], }; } diff --git a/packages/tools/src/mastra.ts b/packages/tools/src/mastra.ts index 3c89339..7d267e6 100644 --- a/packages/tools/src/mastra.ts +++ b/packages/tools/src/mastra.ts @@ -14,26 +14,30 @@ import { createTool } from "@mastra/core/tools"; import { - createBrowserToolkit, - liveViewModelOutput, - toModelOutput, - type BrowserRunOutput, - type LiveView, type Browser, + type BrowserRunOutput, type BrowserToolkit, type BrowserToolsOptions, + createBrowserToolkit, + type LiveView, + liveViewModelOutput, type SessionToolkit, + toModelOutput, } from "./index.ts"; -export { instructions, type BrowserToolsOptions, type BrowserRunOutput } from "./index.ts"; +export { type BrowserRunOutput, type BrowserToolsOptions, instructions } from "./index.ts"; type RunTools = ReturnType; type SessionTools = ReturnType; /** With a `browser`, the session tools join `browser_api` and `browser_run`. */ -export function browserTools(options: BrowserToolsOptions & { browser: Browser }): RunTools & SessionTools; +export function browserTools( + options: BrowserToolsOptions & { browser: Browser }, +): RunTools & SessionTools; export function browserTools(options?: BrowserToolsOptions): RunTools; -export function browserTools(options: BrowserToolsOptions = {}): RunTools | (RunTools & SessionTools) { +export function browserTools( + options: BrowserToolsOptions = {}, +): RunTools | (RunTools & SessionTools) { const toolkit = createBrowserToolkit(options); const tools = runTools(toolkit); return toolkit.sessions ? { ...tools, ...sessionTools(toolkit.sessions) } : tools; diff --git a/packages/tools/tests/browser.test.ts b/packages/tools/tests/browser.test.ts index 32d2f39..0d3f1f1 100644 --- a/packages/tools/tests/browser.test.ts +++ b/packages/tools/tests/browser.test.ts @@ -25,7 +25,10 @@ test("hosted sessions are created on start and released once on close", async (t requests.push(`${init.method} ${url}`); if (!url.endsWith("/sessions")) return Response.json({}); created += 1; - return Response.json({ id: `session-${created}`, connectUrl: `wss://browserbase/session-${created}` }); + return Response.json({ + id: `session-${created}`, + connectUrl: `wss://browserbase/session-${created}`, + }); }); const browser = providerBrowser( @@ -52,7 +55,12 @@ test("live view returns a screenshot, plus the provider's URL when it has one", { name: "test", async create() { - return { provider: "test", cdpUrl: "ws://watched", sessionId: "s1", liveViewUrl: "https://live/s1" }; + return { + provider: "test", + cdpUrl: "ws://watched", + sessionId: "s1", + liveViewUrl: "https://live/s1", + }; }, async shutdown() {}, }, diff --git a/packages/tools/tests/tools.test.ts b/packages/tools/tests/tools.test.ts index 97dd7e9..f733687 100644 --- a/packages/tools/tests/tools.test.ts +++ b/packages/tools/tests/tools.test.ts @@ -5,14 +5,14 @@ import { generateText, stepCountIs } from "ai"; import { MockLanguageModelV4 } from "ai/test"; import { browserTools as aiSdkTools } from "../src/ai-sdk.ts"; -import { browserTools as mastraTools } from "../src/mastra.ts"; import { - providerBrowser, type BrowserRunOutput, type BrowserToolsOptions, type LiveView, + providerBrowser, type SessionSummary, } from "../src/index.ts"; +import { browserTools as mastraTools } from "../src/mastra.ts"; /** Answers commands like a page titled "Fake"; no Chrome needed. */ function fakeBrowser() { @@ -46,14 +46,16 @@ test("AI SDK: the model runs a program and reads the compact result text", async prompts.push(prompt); if (prompts.length === 1) { return { - content: [{ - type: "tool-call", - toolCallId: "call-1", - toolName: "browser_run", - input: JSON.stringify({ - code: `await browse.open("https://example.com"); return (await browse.get("title")).title;`, - }), - }], + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "browser_run", + input: JSON.stringify({ + code: `await browse.open("https://example.com"); return (await browse.get("title")).title;`, + }), + }, + ], finishReason: { unified: "tool-calls", raw: undefined }, usage, warnings: [], @@ -98,7 +100,11 @@ test("AI SDK: a failed program is reported to the model as an error", async () = )) as BrowserRunOutput; assert.equal(output.isError, true); assert.match(output.text, /unexpected command back/); - const modelOutput = await tools.browser_run.toModelOutput!({ toolCallId: "1", input: { code: "" }, output }); + const modelOutput = await tools.browser_run.toModelOutput!({ + toolCallId: "1", + input: { code: "" }, + output, + }); assert.equal(modelOutput.type, "error-text"); }); @@ -107,7 +113,10 @@ test("Mastra: tools share ids, descriptions, and execution", async () => { const tools = mastraTools({ cdpUrl: "ws://fake", connect, env: {} }); assert.equal(tools.browser_run.id, "browser_run"); assert.match(tools.browser_run.description, /Evaluate a TypeScript program/); - assert.match(await tools.browser_api.execute!({}, {} as never) as string, /declare const browse/); + assert.match( + (await tools.browser_api.execute!({}, {} as never)) as string, + /declare const browse/, + ); const output = (await tools.browser_run.execute!( { code: `return (await browse.get("title")).title;` }, @@ -134,7 +143,10 @@ test("without cdpUrl, each program leases and releases a provider browser", asyn }, }, }); - await tools.browser_run.execute!({ code: `return 1;` }, { toolCallId: "1", messages: [], context: {} }); + await tools.browser_run.execute!( + { code: `return 1;` }, + { toolCallId: "1", messages: [], context: {} }, + ); assert.deepEqual(lifecycle, ["create", "shutdown"]); assert.deepEqual(state.endpoints, ["ws://leased"]); }); @@ -172,7 +184,10 @@ test("with a Browser, the model starts a session, drives it by id, watches it, a assert.deepEqual(Object.keys(started).sort(), ["id", "provider", "startedAt"]); await tools.browser_start!.execute!({}, options); - const first = (await tools.browser_run.execute!({ code: `return 1;`, sessionId: "s1" }, options)) as BrowserRunOutput; + const first = (await tools.browser_run.execute!( + { code: `return 1;`, sessionId: "s1" }, + options, + )) as BrowserRunOutput; await tools.browser_run.execute!({ code: `return 1;` }, options); assert.match(first.text, /· session s1\n/); assert.doesNotMatch(JSON.stringify(first), /ws:\/\//); @@ -186,13 +201,22 @@ test("with a Browser, the model starts a session, drives it by id, watches it, a assert.match(unknown.text, /Unknown browser session: nope/); const view = (await tools.browser_live_view!.execute!({ sessionId: "s1" }, options)) as LiveView; - const viewOutput = await tools.browser_live_view!.toModelOutput!({ toolCallId: "1", input: { sessionId: "s1" }, output: view }); + const viewOutput = await tools.browser_live_view!.toModelOutput!({ + toolCallId: "1", + input: { sessionId: "s1" }, + output: view, + }); assert.equal(viewOutput.type, "content"); assert.match(JSON.stringify(viewOutput), /"mediaType":"image\/jpeg".*"SlBFRw=="/); await tools.browser_stop!.execute!({ sessionId: "s1" }, options); - const { sessions } = (await tools.browser_list_sessions!.execute!({}, options)) as { sessions: SessionSummary[] }; - assert.deepEqual(sessions.map((s) => s.id), ["s2"]); + const { sessions } = (await tools.browser_list_sessions!.execute!({}, options)) as { + sessions: SessionSummary[]; + }; + assert.deepEqual( + sessions.map((s) => s.id), + ["s2"], + ); assert.deepEqual(shutdowns, ["s1"]); }); @@ -219,5 +243,8 @@ test("with a Browser and no open session, browser_run uses a temporary one", asy test("without a Browser there are no session tools", () => { const tools = aiSdkTools({ cdpUrl: "ws://fake", env: {} }); assert.deepEqual(Object.keys(tools).sort(), ["browser_api", "browser_run"]); - assert.throws(() => aiSdkTools({ cdpUrl: "ws://fake", browser: providerBrowser({} as never) }), /not both/); + assert.throws( + () => aiSdkTools({ cdpUrl: "ws://fake", browser: providerBrowser({} as never) }), + /not both/, + ); }); diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index 31f4bb8..3a11ed5 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -1,14 +1,10 @@ { "compilerOptions": { "target": "ES2023", - "lib": [ - "ES2023", "DOM" - ], + "lib": ["ES2023", "DOM"], "module": "NodeNext", "moduleResolution": "nodenext", - "types": [ - "node" - ], + "types": ["node"], "rootDir": ".", "strict": true, "exactOptionalPropertyTypes": true, @@ -23,7 +19,5 @@ "isolatedModules": true, "rewriteRelativeImportExtensions": true }, - "include": [ - "src/**/*.ts", "tests/**/*.ts", "../mcp-server/src/**/*.d.ts" - ] + "include": ["src/**/*.ts", "tests/**/*.ts", "../mcp-server/src/**/*.d.ts"] } diff --git a/scripts/chrome.mjs b/scripts/chrome.mjs index 3ffcad6..4175c6d 100755 --- a/scripts/chrome.mjs +++ b/scripts/chrome.mjs @@ -16,9 +16,7 @@ process.on("SIGINT", () => void stop()); process.on("SIGTERM", () => void stop()); try { const browser = await starting; - console.log( - `CDP browser on ${browser.browser.cdpUrl} (${browser.owned ? "owned" : "borrowed"})`, - ); + console.log(`CDP browser on ${browser.browser.cdpUrl} (${browser.owned ? "owned" : "borrowed"})`); if (!browser.owned) process.exit(0); browser.process?.once("exit", async () => { await browser.shutdown(); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index de414f2..ef7ce2e 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -129,12 +129,10 @@ async function shutdown(code = 0) { startup.abort(); await Promise.all( children.map(async ({ child }) => { - if (!child.pid || child.exitCode !== null || child.signalCode !== null) - return; + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; const kill = (signal) => { try { - if (process.platform !== "win32" && child.pid) - process.kill(-child.pid, signal); + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal); else child.kill(signal); } catch {} }; @@ -145,8 +143,7 @@ async function shutdown(code = 0) { clearTimeout(timer); }), ); - const browser = - localBrowser ?? (await startingBrowser?.catch(() => undefined)); + const browser = localBrowser ?? (await startingBrowser?.catch(() => undefined)); await browser?.shutdown(); process.exit(code); } @@ -157,9 +154,7 @@ process.on("SIGTERM", () => shutdown(0)); // ------------------------------------------------------------- 0. preflight // A stale server on either port would let this script report success while the // UI talked to yesterday's code, so refuse to start rather than guess. -for (const [label, port] of [ - ["web app", WEB_PORT], -]) { +for (const [label, port] of [["web app", WEB_PORT]]) { if (await portInUse(port)) { log("dev", `Port ${port} is already in use, so the ${label} cannot start.`); log( @@ -182,10 +177,7 @@ try { await localBrowser.shutdown(); process.exit(0); } - log( - "chrome", - `${localBrowser.owned ? "started" : "borrowed"} browser on :${CHROME_PORT}`, - ); + log("chrome", `${localBrowser.owned ? "started" : "borrowed"} browser on :${CHROME_PORT}`); localBrowser.process?.once("exit", () => { void shutdown(1); }); @@ -197,26 +189,16 @@ try { { // ----------------------------------------------------------------- 3. web app if (!process.env.ANTHROPIC_API_KEY) { - log( - "web", - "ANTHROPIC_API_KEY is not set — the UI will load but chat will fail.", - ); + log("web", "ANTHROPIC_API_KEY is not set — the UI will load but chat will fail."); } - run( - "web", - "pnpm", - ["--filter", "@browse-code-mode/web", "dev", "--port", String(WEB_PORT)], - { - env: { - BROWSE_PROVIDER: "cdp", - PORT: String(WEB_PORT), - BROWSE_CDP_URL: String(CHROME_PORT), - }, + run("web", "pnpm", ["--filter", "@browse-code-mode/web", "dev", "--port", String(WEB_PORT)], { + env: { + BROWSE_PROVIDER: "cdp", + PORT: String(WEB_PORT), + BROWSE_CDP_URL: String(CHROME_PORT), }, - ); - if ( - !(await waitForHttp(`http://localhost:${WEB_PORT}/`, 60_000, "web app")) - ) { + }); + if (!(await waitForHttp(`http://localhost:${WEB_PORT}/`, 60_000, "web app"))) { shutdown(1); } @@ -225,11 +207,7 @@ try { log("dev", `ready — ${url}`); if (process.env.NO_OPEN !== "1") { const opener = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; + process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; spawn(opener, [url], { stdio: "ignore", detached: true }).unref(); } } From 6037cdc464b94c70aa6cfc635d8212c0514bbd35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 15:01:39 +0000 Subject: [PATCH 3/6] Fix Biome lint diagnostics Replace non-null assertions with explicit narrowing, type the CDP accessibility and frame-tree payloads that snapshots read, and route remaining untyped CDP JSON through one documented CdpPayload alias. Add a button type, and split message part rendering so the index key lives in one place. Co-authored-by: Anirudh Kamath --- apps/web/src/routes/api.chat.ts | 4 +- apps/web/src/routes/index.tsx | 56 +++++++------ examples/02-mcp-server/index.ts | 9 ++- packages/mcp-server/src/app.ts | 9 +-- packages/mcp-server/src/browse/driver.ts | 18 ++--- .../mcp-server/src/browse/host-functions.ts | 5 +- packages/mcp-server/src/browse/page.ts | 78 ++++++++++++------- packages/mcp-server/src/browse/selectors.ts | 3 +- packages/mcp-server/src/browse/transport.ts | 15 +++- packages/mcp-server/tests/frames.e2e.test.ts | 3 +- packages/mcp-server/tests/lifecycle.test.ts | 6 +- packages/mcp-server/tests/mcp.test.ts | 4 +- packages/tools/src/index.ts | 7 +- packages/tools/tests/tools.test.ts | 59 ++++++++------ 14 files changed, 168 insertions(+), 108 deletions(-) diff --git a/apps/web/src/routes/api.chat.ts b/apps/web/src/routes/api.chat.ts index 150f084..3253095 100644 --- a/apps/web/src/routes/api.chat.ts +++ b/apps/web/src/routes/api.chat.ts @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from "ai"; import { env } from "../lib/env.ts"; -import { openMcpSession } from "../lib/mcp.ts"; +import { type McpSession, openMcpSession } from "../lib/mcp.ts"; const SYSTEM = `You drive a real web browser by writing TypeScript programs and running them with the browser_run tool. @@ -37,7 +37,7 @@ export const Route = createFileRoute("/api/chat")({ if (!cdpUrl || typeof cdpUrl !== "string") return Response.json({ error: "Start a browser first." }, { status: 400 }); - let session; + let session: McpSession; try { session = await openMcpSession(cdpUrl); } catch (error) { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 6274f5b..3abb655 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -60,7 +60,7 @@ function Home() {
- {browser ? ( @@ -187,6 +187,7 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { // Scroll the list itself. `scrollIntoView` walks up the ancestor chain and // will scroll the document too, which drags the whole two-pane layout. + // biome-ignore lint/correctness/useExhaustiveDependencies: the dependencies are the triggers; the effect reads only the ref. useEffect(() => { const list = listRef.current; if (list) list.scrollTop = list.scrollHeight; @@ -233,32 +234,41 @@ function Chat({ cdpUrl }: { cdpUrl: string }) { function Message({ message }: { message: UIMessage }) { return (
- {message.parts.map((part, index) => { - if (part.type === "text") { - if (!part.text.trim()) return null; - // Assistant replies use tables and lists, so they render as markdown; - // a user's own words are shown verbatim rather than reinterpreted as - // markup. Streamdown rather than react-markdown because it renders - // markdown that is still arriving, so a half-written table or fence - // does not flash as raw text mid-stream. - return message.role === "assistant" ? ( -
- {part.text} -
- ) : ( -
- {part.text} -
- ); - } - - const call = asToolCall(part); - return call ? : null; - })} + {message.parts.map((part, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: parts only append and text parts have no id, so the index is stable. + + ))}
); } +function MessagePart({ + role, + part, +}: { + role: UIMessage["role"]; + part: UIMessage["parts"][number]; +}) { + if (part.type === "text") { + if (!part.text.trim()) return null; + // Assistant replies use tables and lists, so they render as markdown; + // a user's own words are shown verbatim rather than reinterpreted as + // markup. Streamdown rather than react-markdown because it renders + // markdown that is still arriving, so a half-written table or fence + // does not flash as raw text mid-stream. + return role === "assistant" ? ( +
+ {part.text} +
+ ) : ( +
{part.text}
+ ); + } + + const call = asToolCall(part); + return call ? : null; +} + interface ToolCallView { name: string; state: string; diff --git a/examples/02-mcp-server/index.ts b/examples/02-mcp-server/index.ts index 3a0541c..7d40401 100644 --- a/examples/02-mcp-server/index.ts +++ b/examples/02-mcp-server/index.ts @@ -11,7 +11,11 @@ import { createServer } from "node:http"; import { localProvider } from "@browse-code-mode/mcp-server/local-browser"; -import { createBrowserToolkit, providerBrowser } from "@browse-code-mode/tools"; +import { + createBrowserToolkit, + providerBrowser, + type SessionToolkit, +} from "@browse-code-mode/tools"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -22,7 +26,8 @@ const host = "127.0.0.1"; // Sessions outlive each stateless request because they live in this process. const browser = providerBrowser(localProvider()); const toolkit = createBrowserToolkit({ browser }); -const sessions = toolkit.sessions!; +if (!toolkit.sessions) throw new Error("A toolkit created with a browser has session tools."); +const sessions: SessionToolkit = toolkit.sessions; const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], diff --git a/packages/mcp-server/src/app.ts b/packages/mcp-server/src/app.ts index c694211..128aed4 100644 --- a/packages/mcp-server/src/app.ts +++ b/packages/mcp-server/src/app.ts @@ -187,15 +187,12 @@ export function createApp({ ); } + const cdpUrl = c.req.header("x-browse-cdp-url"); const server = createMcpServer({ - config: c.req.header("x-browse-cdp-url") - ? { ...config, defaultCdpUrl: c.req.header("x-browse-cdp-url")! } - : config, + config: cdpUrl ? { ...config, defaultCdpUrl: cdpUrl } : config, ...(run ? { run } : {}), ...(connect ? { connect } : {}), - provider: c.req.header("x-browse-cdp-url") - ? staticProvider(c.req.header("x-browse-cdp-url")) - : provider, + provider: cdpUrl ? staticProvider(cdpUrl) : provider, }); // No `sessionIdGenerator`: the transport runs in stateless mode. const transport = new WebStandardStreamableHTTPServerTransport(); diff --git a/packages/mcp-server/src/browse/driver.ts b/packages/mcp-server/src/browse/driver.ts index 17ca2c7..ee2f674 100644 --- a/packages/mcp-server/src/browse/driver.ts +++ b/packages/mcp-server/src/browse/driver.ts @@ -511,8 +511,9 @@ const HANDLERS: Record = { 0, pages.findIndex((p) => p.pageId === active?.pageId), ), - page: active ?? pages[0]!, + page: active ?? pages[0], }; + if (!resolved.page) throw new BrowseCommandError("No tab is open."); const closedTargetId = resolved.page.pageId; await resolved.page.close(); @@ -563,10 +564,11 @@ async function resolveTab( } const index = pages.findIndex((page) => page.pageId === tab); - if (index === -1) { + const page = pages[index]; + if (!page) { throw new BrowseCommandError(`Tab "${tab}" was not found. Call tab.list() for current tabs.`); } - return { index, page: pages[index]! }; + return { index, page }; } async function safeTitle(page: Page): Promise { @@ -632,17 +634,15 @@ export function formatTree( if (filter) { const match = matcher(filter); const keep = new Set(); - const ancestors: number[] = []; + const ancestors: Array<{ index: number; depth: number }> = []; lines.forEach((line, index) => { const depth = indentWidth(line); - while (ancestors.length > 0 && indentWidth(lines[ancestors.at(-1)!]!) >= depth) { - ancestors.pop(); - } + while ((ancestors.at(-1)?.depth ?? -1) >= depth) ancestors.pop(); if (match(line)) { keep.add(index); - for (const ancestor of ancestors) keep.add(ancestor); + for (const ancestor of ancestors) keep.add(ancestor.index); } - ancestors.push(index); + ancestors.push({ index, depth }); }); lines = lines.filter((_, index) => keep.has(index)); } diff --git a/packages/mcp-server/src/browse/host-functions.ts b/packages/mcp-server/src/browse/host-functions.ts index 54f2faf..93b0fbf 100644 --- a/packages/mcp-server/src/browse/host-functions.ts +++ b/packages/mcp-server/src/browse/host-functions.ts @@ -71,8 +71,9 @@ export function createHostFunctions(options: HostFunctionOptions): HostFunctionB const key = `${spec.group}.${spec.fn}`; if (!isPermitted(key, options)) continue; exposed.push(key); - hostFunctions[spec.group]![spec.fn] = (...args: unknown[]) => - invoke(spec, args, calls, counter, options); + const group = hostFunctions[spec.group] ?? {}; + hostFunctions[spec.group] = group; + group[spec.fn] = (...args: unknown[]) => invoke(spec, args, calls, counter, options); } return { hostFunctions, calls, exposed }; diff --git a/packages/mcp-server/src/browse/page.ts b/packages/mcp-server/src/browse/page.ts index 9aa601b..3724ff5 100644 --- a/packages/mcp-server/src/browse/page.ts +++ b/packages/mcp-server/src/browse/page.ts @@ -2,7 +2,7 @@ import { resolveCdpUrl } from "./cdp.ts"; import type { FrameTarget, ResolvedSelector } from "./selectors.ts"; -import { CdpConnection, type OpenSocket, openWebSocket } from "./transport.ts"; +import { CdpConnection, type CdpPayload, type OpenSocket, openWebSocket } from "./transport.ts"; export type LoadState = "load" | "domcontentloaded" | "networkidle"; export type MouseButton = "left" | "middle" | "right"; @@ -102,7 +102,7 @@ export class Page { if (params.targetInfo?.type !== "iframe") return; this.oopifs.set(params.targetInfo.targetId, { sessionId: params.sessionId, - parentSessionId: session!, + parentSessionId: session, }); // A cross-site frame can hold cross-site frames of its own. this.watch(this.autoAttach(params.sessionId)); @@ -114,7 +114,7 @@ export class Page { return; } if (method.startsWith("Runtime.executionContext") && this.ownsSession(session)) { - this.trackWorld(session!, method, params); + this.trackWorld(session, method, params); return; } if (session !== sessionId) return; @@ -157,14 +157,17 @@ export class Page { const tracked = pending.catch(() => {}).finally(() => this.attaching.delete(tracked)); this.attaching.add(tracked); } - private ownsSession(session: string | undefined): boolean { + private ownsSession(session: string | undefined): session is string { if (session === this.sessionId) return true; for (const frame of this.oopifs.values()) if (frame.sessionId === session) return true; return false; } - private trackWorld(session: string, method: string, params: any) { + private trackWorld(session: string, method: string, params: CdpPayload) { let worlds = this.worlds.get(session); - if (!worlds) this.worlds.set(session, (worlds = new Map())); + if (!worlds) { + worlds = new Map(); + this.worlds.set(session, worlds); + } if (method === "Runtime.executionContextsCleared") return worlds.clear(); if (method === "Runtime.executionContextDestroyed") { for (const [frameId, id] of worlds) @@ -180,7 +183,7 @@ export class Page { * only reports context ids after Runtime.enable, so enable it per session on * first use; existing contexts arrive before enable returns. */ - async evaluateIn(frame: FrameTarget, expression: string): Promise { + async evaluateIn(frame: FrameTarget, expression: string): Promise { let enabling = this.runtimeEnabled.get(frame.sessionId); if (!enabling) { enabling = this.sendTo(frame.sessionId, "Runtime.enable"); @@ -246,7 +249,7 @@ export class Page { })); if (!frameTree) return []; const found: SnapshotFrame[] = []; - const walk = (node: any, root: boolean) => { + const walk = (node: FrameTreeNode, root: boolean) => { found.push({ frameId: node.frame.id, sessionId, @@ -279,7 +282,7 @@ export class Page { ); return frames; } - async evaluate(expression: string): Promise { + async evaluate(expression: string): Promise { const response = await this.send("Runtime.evaluate", { expression, returnByValue: true, @@ -375,7 +378,7 @@ export class Page { } async keyPress(combo: string) { const parts = combo.split("+"); - const key = parts.pop()!; + const key = parts.pop() ?? ""; const modifiers = parts.reduce( (n, p) => n | ({ Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Shift: 8 }[p] ?? 0), 0, @@ -549,8 +552,8 @@ export class Page { const renderFrame = (frame: SnapshotFrame, depth: number) => { const index = nextFrame++; frameMap[index] = { sessionId: frame.sessionId, frameId: frame.frameId }; - const byId = new Map(frame.nodes.map((node: any) => [node.nodeId, node])); - const visit = (node: any, depth: number, parentName: string | undefined) => { + const byId = new Map(frame.nodes.map((node) => [node.nodeId, node])); + const visit = (node: AXNode | undefined, depth: number, parentName: string | undefined) => { if (!node) return; const role = node.role?.value ?? "node"; const name = node.name?.value ?? ""; @@ -562,9 +565,8 @@ export class Page { if (shown && ref) { // Resolve refs using backend node IDs, never a server-side page registry. xpathMap[ref] = `backend=${node.backendDOMNodeId}`; - const url = node.properties?.find((p: { name: string }) => p.name === "url")?.value - ?.value; - if (url) urlMap[ref] = url; + const url = node.properties?.find((p) => p.name === "url")?.value?.value; + if (typeof url === "string" && url) urlMap[ref] = url; } if (shown) lines.push( @@ -593,9 +595,31 @@ interface SnapshotFrame { /** Session holding the