From b76fb9d1a36a92c9b3c998ce1776da308b6e1842 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:04:04 +0000 Subject: [PATCH 1/4] Add Biome with biome-plugin-no-type-assertion Lint-only config (formatter off, no preset rules) so the only check is the no-type-assertion Grit plugin. Adds a root `pnpm lint` script. Co-authored-by: Anirudh Kamath --- README.md | 1 + biome.json | 26 +++++++++++++ package.json | 7 +++- pnpm-lock.yaml | 103 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 biome.json diff --git a/README.md b/README.md index 8785057..0b62762 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ MCP bearer authentication protects the backend; it is not web-user authenticatio ## Validation ```sh +pnpm lint # Biome, with biome-plugin-no-type-assertion: no `as` casts pnpm check pnpm test pnpm build diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..4229b1d --- /dev/null +++ b/biome.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "formatter": { + "enabled": false + }, + "assist": { + "enabled": false + }, + "linter": { + "enabled": true, + "rules": { + "preset": "none" + } + }, + "plugins": ["./node_modules/biome-plugin-no-type-assertion/no-type-assertion.grit"] +} diff --git a/package.json b/package.json index 78ef0e8..4ff7cbf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "build": "pnpm -r build", "check": "pnpm -r check", + "lint": "biome lint", "test": "pnpm -r --if-present test", "test:e2e": "pnpm --filter @browse-code-mode/mcp-server test:e2e", "dev": "node scripts/dev.mjs", @@ -21,7 +22,9 @@ "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", + "biome-plugin-no-type-assertion": "^1.1.1", + "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23fa8d8..e28266b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: ^2.5.14 + version: 2.5.14 '@types/node': specifier: ^22.19.0 version: 22.20.4 + biome-plugin-no-type-assertion: + specifier: ^1.1.1 + version: 1.1.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -364,6 +370,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'} @@ -1615,6 +1678,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + biome-plugin-no-type-assertion@1.1.1: + resolution: {integrity: sha512-5ZAygL1/+F5xg+KhXgbPTxWnw1ZGWWdny1fRtu9H0RoLzb9P3YHDpzOwUsAC5RLESkM6wnetuFUNU/FGvPqguw==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -3368,6 +3434,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)': @@ -4392,6 +4493,8 @@ snapshots: baseline-browser-mapping@2.11.25: {} + biome-plugin-no-type-assertion@1.1.1: {} + blake3-wasm@2.1.5: {} body-parser@2.3.0: From f659e3bbe05ed5a7ea5a02599f97aea8017f1897 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:04:04 +0000 Subject: [PATCH 2/4] Replace type assertions with zod validation and type guards - Parse untrusted JSON (HTTP bodies, CDP responses, provider APIs, sandbox bridge payloads, sessionStorage) with zod instead of casting. - Validate enum-like browse params (screenshot type, mouse button, load state, clip) with zod-backed helpers; export the allowed values from page.ts. - Unwrap the QuickJS variant with a runtime guard: its CJS typings disagree with the ESM runtime shape. - Let browser_run's input schema be the honest union of the two modes and narrow it where a single shape is needed (examples/02-mcp-server). - Drop casts TypeScript can already narrow (in-operator, AI SDK dynamic flag). Co-authored-by: Anirudh Kamath --- apps/web/src/lib/mcp.ts | 13 ++-- apps/web/src/routes/api.chat.ts | 28 +++++++-- apps/web/src/routes/index.tsx | 51 ++++++++------- examples/01-hello-world/index.ts | 8 +-- examples/02-mcp-server/index.ts | 8 ++- packages/mcp-server/src/app.ts | 12 ++-- packages/mcp-server/src/browse/cdp.ts | 18 +++--- packages/mcp-server/src/browse/driver.ts | 63 ++++++++++++++----- packages/mcp-server/src/browse/page.ts | 18 ++++-- packages/mcp-server/src/browse/providers.ts | 3 +- packages/mcp-server/src/core.ts | 6 +- packages/mcp-server/src/node/local-browser.ts | 15 +++-- packages/mcp-server/src/runtime-bundled.ts | 4 +- packages/mcp-server/src/sandbox/runner.ts | 21 ++++--- packages/mcp-server/src/sandbox/variant.ts | 21 +++++++ packages/mcp-server/src/worker.ts | 7 +-- packages/tools/src/browser.ts | 2 +- packages/tools/src/index.ts | 18 +++--- packages/tools/src/mastra.ts | 14 +++-- 19 files changed, 218 insertions(+), 112 deletions(-) create mode 100644 packages/mcp-server/src/sandbox/variant.ts diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts index 25b3c4a..2011381 100644 --- a/apps/web/src/lib/mcp.ts +++ b/apps/web/src/lib/mcp.ts @@ -7,12 +7,15 @@ 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 { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { dynamicTool, jsonSchema, type ToolSet } from "ai"; +import { z } from "zod"; import { mcpHeaders } from "./env.ts"; import { fetchBrowserApp } from "./browser-app.ts"; +const toolArgumentsSchema = z.record(z.unknown()); + export interface McpSession { tools: ToolSet; instructions: string | undefined; @@ -44,16 +47,16 @@ export async function openMcpSession(cdpUrl?: string): Promise { definition.name, dynamicTool({ description: definition.description ?? "", - inputSchema: jsonSchema(definition.inputSchema as JSONSchema7), + inputSchema: jsonSchema(definition.inputSchema), // The UI renders the raw MCP result; the model reads its text. execute: (input, { abortSignal }) => client.callTool( - { name: definition.name, arguments: input as Record }, + { name: definition.name, arguments: toolArgumentsSchema.parse(input) }, undefined, abortSignal ? { signal: abortSignal } : {}, ), toModelOutput: ({ output }) => { - const result = output as CallToolResult; + const result = CallToolResultSchema.parse(output); const value = result.content .map((part) => (part.type === "text" ? part.text : JSON.stringify(part))) .join("\n"); diff --git a/apps/web/src/routes/api.chat.ts b/apps/web/src/routes/api.chat.ts index e0aded6..2e2c7a4 100644 --- a/apps/web/src/routes/api.chat.ts +++ b/apps/web/src/routes/api.chat.ts @@ -4,8 +4,9 @@ import { convertToModelMessages, stepCountIs, streamText, - type UIMessage, + validateUIMessages, } from "ai"; +import { z } from "zod"; import { env } from "../lib/env.ts"; import { openMcpSession } from "../lib/mcp.ts"; @@ -24,6 +25,11 @@ The browser the user is watching is already configured, so never pass cdpUrl. When you have the answer, reply in one or two sentences. Do not narrate the code you just ran.`; +const chatBodySchema = z.object({ + messages: z.array(z.unknown()), + cdpUrl: z.string().optional(), +}); + export const Route = createFileRoute("/api/chat")({ server: { handlers: { @@ -35,16 +41,26 @@ export const Route = createFileRoute("/api/chat")({ ); } - const { messages, cdpUrl } = (await request.json()) as { - messages: UIMessage[]; - cdpUrl?: string; - }; - if (!cdpUrl || typeof cdpUrl !== "string") + const body = chatBodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) + return Response.json({ error: "Invalid chat request." }, { status: 400 }); + const { cdpUrl } = body.data; + if (!cdpUrl) return Response.json( { error: "Start a browser first." }, { status: 400 }, ); + let messages; + try { + messages = await validateUIMessages({ messages: body.data.messages }); + } catch (error) { + return Response.json( + { error: `Invalid chat messages: ${error instanceof Error ? error.message : String(error)}` }, + { status: 400 }, + ); + } + let session; try { session = await openMcpSession(cdpUrl); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e78492e..54b921a 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -3,21 +3,26 @@ import { createFileRoute } from "@tanstack/react-router"; import { DefaultChatTransport, type UIMessage } from "ai"; import { useEffect, useRef, useState } from "react"; import { Streamdown } from "streamdown"; +import { z } from "zod"; import "streamdown/styles.css"; export const Route = createFileRoute("/")({ component: Home }); -interface BrowserHandle { - provider: string; - cdpUrl: string; - sessionId?: string; - liveViewUrl?: string; -} +const browserHandleSchema = z.object({ + provider: z.string(), + cdpUrl: z.string(), + sessionId: z.string().optional(), + liveViewUrl: z.string().optional(), +}); + +type BrowserHandle = z.infer; + +const errorBodySchema = z.object({ error: z.string() }); async function responseError(response: Response): Promise { - const body = await response.json().catch(() => null) as { error?: string } | null; - return body?.error ?? `Browser request failed (HTTP ${response.status}).`; + const body = errorBodySchema.safeParse(await response.json().catch(() => null)); + return body.success ? body.data.error : `Browser request failed (HTTP ${response.status}).`; } function Home() { @@ -27,7 +32,7 @@ function Home() { useEffect(() => { try { const saved = sessionStorage.getItem("browse.browser"); - if (saved) setBrowser(JSON.parse(saved)); + if (saved) setBrowser(browserHandleSchema.parse(JSON.parse(saved))); } catch { sessionStorage.removeItem("browse.browser"); } @@ -42,7 +47,7 @@ function Home() { ...(browser ? { body: JSON.stringify(browser) } : {}), }); if (!response.ok) throw new Error(await responseError(response)); - const next: BrowserHandle | null = browser ? null : await response.json(); + const next = browser ? null : browserHandleSchema.parse(await response.json()); setBrowser(next); try { if (next) sessionStorage.setItem("browse.browser", JSON.stringify(next)); @@ -256,6 +261,15 @@ interface ToolCallView { errorText?: string; } +const toolPartSchema = z.object({ + type: z.string(), + toolName: z.string().optional(), + state: z.string().optional(), + input: z.unknown(), + output: z.unknown(), + errorText: z.string().optional(), +}); + /** * MCP tools arrive as dynamic tool parts. Narrow them here rather than trusting * a tool-specific part type that only exists for statically declared tools. @@ -263,14 +277,9 @@ interface ToolCallView { function asToolCall(part: UIMessage["parts"][number]): ToolCallView | null { if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) return null; - const record = part as unknown as { - type: string; - toolName?: string; - state?: string; - input?: unknown; - output?: unknown; - errorText?: string; - }; + const parsed = toolPartSchema.safeParse(part); + if (!parsed.success) return null; + const record = parsed.data; return { name: record.toolName ?? record.type.replace(/^tool-/, ""), state: record.state ?? "", @@ -305,7 +314,7 @@ function ToolCall({ call }: { call: ToolCallView }) { function codeOf(input: unknown): string { if (input && typeof input === "object" && "code" in input) { - const code = (input as { code?: unknown }).code; + const code = input.code; if (typeof code === "string") return code; } return input === undefined ? "" : stringify(input); @@ -317,12 +326,12 @@ function textOf(output: unknown): string { // MCP tool results arrive as content blocks; show the text the server rendered. if (typeof output === "object" && "content" in output) { - const content = (output as { content?: unknown }).content; + const { content } = output; if (Array.isArray(content)) { const text = content .map((block) => block && typeof block === "object" && "text" in block - ? String((block as { text: unknown }).text) + ? String(block.text) : "", ) .filter(Boolean) diff --git a/examples/01-hello-world/index.ts b/examples/01-hello-world/index.ts index f369836..e519af4 100644 --- a/examples/01-hello-world/index.ts +++ b/examples/01-hello-world/index.ts @@ -29,13 +29,13 @@ try { prompt, onStepFinish: ({ toolCalls, toolResults }) => { for (const call of toolCalls) { - if (call.toolName === "browser_run") { - console.log(`\n── browser_run ──\n${(call.input as { code: string }).code}`); + if (!call.dynamic && call.toolName === "browser_run") { + console.log(`\n── browser_run ──\n${call.input.code}`); } else console.log(`\n── ${call.toolName} ──`); } for (const result of toolResults) { - if (result.toolName === "browser_run") { - console.log(`→ ${(result.output as { text: string }).text.split("\n")[0]}`); + if (!result.dynamic && result.toolName === "browser_run") { + console.log(`→ ${result.output.text.split("\n")[0]}`); } } }, diff --git a/examples/02-mcp-server/index.ts b/examples/02-mcp-server/index.ts index ae5b477..75686e0 100644 --- a/examples/02-mcp-server/index.ts +++ b/examples/02-mcp-server/index.ts @@ -24,6 +24,12 @@ const host = "127.0.0.1"; const browser = providerBrowser(localProvider()); const toolkit = createBrowserToolkit({ browser }); const sessions = toolkit.sessions!; +const runShape = sessionRunShape(toolkit.run.inputSchema.shape); + +function sessionRunShape(shape: typeof toolkit.run.inputSchema.shape) { + if (!("sessionId" in shape)) throw new Error("With a browser, browser_run takes a sessionId."); + return shape; +} const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], @@ -45,7 +51,7 @@ function createMcpServer(): McpServer { toolkit.run.name, { description: toolkit.run.description, - inputSchema: toolkit.run.inputSchema.shape, + inputSchema: runShape, annotations: { readOnlyHint: false, openWorldHint: true }, }, async (input, { signal }) => { diff --git a/packages/mcp-server/src/app.ts b/packages/mcp-server/src/app.ts index b481d53..b5426f8 100644 --- a/packages/mcp-server/src/app.ts +++ b/packages/mcp-server/src/app.ts @@ -34,7 +34,7 @@ export interface AppDeps { } export interface App { - fetch: (request: Request, ...rest: unknown[]) => Response | Promise; + fetch: (request: Request) => Response | Promise; } export function createApp({ @@ -96,9 +96,9 @@ export function createApp({ }); app.post("/screen", async (c) => { - const body = (await c.req.json().catch(() => ({}))) as { cdpUrl?: string }; - const endpoint = body.cdpUrl ?? config.defaultCdpUrl; - if (!endpoint || typeof endpoint !== "string") + const body = screenBodySchema.safeParse(await c.req.json().catch(() => ({}))); + const endpoint = body.success ? (body.data.cdpUrl ?? config.defaultCdpUrl) : undefined; + if (!endpoint) return c.json({ error: "cdpUrl is required." }, 400); const base64 = await captureScreenshot(endpoint, { ...(connect ? { connect } : {}), @@ -246,9 +246,11 @@ export function createApp({ }); log(`stateless MCP endpoint mounted at ${config.endpoint}`); - return { fetch: app.fetch as App["fetch"] }; + return { fetch: (request) => app.fetch(request) }; } +const screenBodySchema = z.object({ cdpUrl: z.string().optional() }); + const browserHandleSchema = z.object({ provider: z.string().min(1), cdpUrl: z.string().min(1), diff --git a/packages/mcp-server/src/browse/cdp.ts b/packages/mcp-server/src/browse/cdp.ts index d310624..d7f9f48 100644 --- a/packages/mcp-server/src/browse/cdp.ts +++ b/packages/mcp-server/src/browse/cdp.ts @@ -8,8 +8,12 @@ * difference. */ +import { z } from "zod"; + const DEFAULT_TIMEOUT_MS = 5_000; +const versionSchema = z.object({ webSocketDebuggerUrl: z.string() }); + export interface ResolveOptions { timeoutMs?: number; signal?: AbortSignal; @@ -17,8 +21,8 @@ export interface ResolveOptions { /** Raised when a CDP endpoint cannot be reached or understood. */ export class CdpEndpointError extends Error { - constructor(message: string, options?: { cause?: unknown }) { - super(message, options as ErrorOptions); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = "CdpEndpointError"; } } @@ -44,15 +48,15 @@ export async function resolveCdpUrl( ); } - const payload = await fetchJson(new URL("/json/version", origin), timeoutMs, signal); - const webSocketDebuggerUrl = (payload as { webSocketDebuggerUrl?: unknown }) - .webSocketDebuggerUrl; - if (typeof webSocketDebuggerUrl !== "string") { + const payload = versionSchema.safeParse( + await fetchJson(new URL("/json/version", origin), timeoutMs, signal), + ); + if (!payload.success) { throw new CdpEndpointError( `${origin} answered /json/version without a webSocketDebuggerUrl. Is it a Chrome DevTools endpoint?`, ); } - return webSocketDebuggerUrl; + return payload.data.webSocketDebuggerUrl; } async function fetchJson( diff --git a/packages/mcp-server/src/browse/driver.ts b/packages/mcp-server/src/browse/driver.ts index 99894d7..aa113c6 100644 --- a/packages/mcp-server/src/browse/driver.ts +++ b/packages/mcp-server/src/browse/driver.ts @@ -12,11 +12,13 @@ */ import { NodeHtmlMarkdown } from "node-html-markdown"; +import { z } from "zod"; import { connectBrowser, + LOAD_STATES, + MOUSE_BUTTONS, type BrowserContext, type LoadState, - type MouseButton, type Page, } from "./page.ts"; import type { OpenSocket } from "./transport.ts"; @@ -336,14 +338,14 @@ const HANDLERS: Record = { // ----------------------------------------------------------------- runtime async screenshot(session, params) { const page = await session.activePage(); - const type = optStr(params, "type"); + const type = optEnum(params, "type", ["png", "jpeg"]); const quality = optNum(params, "quality"); - const clip = params.clip; + const clip = optClip(params); const buffer = await page.screenshot({ ...(optBool(params, "fullPage") === undefined ? {} : { fullPage: optBool(params, "fullPage") }), - ...(type === undefined ? {} : { type: type as "png" | "jpeg" }), + ...(type === undefined ? {} : { type }), ...(quality === undefined ? {} : { quality }), - ...(clip === undefined ? {} : { clip: clip as never }), + ...(clip === undefined ? {} : { clip }), }); if (optStr(params, "path")) throw new BrowseCommandError("Screenshot paths are unavailable; use the returned base64 data."); @@ -367,11 +369,11 @@ const HANDLERS: Record = { const timeoutMs = optNum(params, "timeoutMs"); if (type === "load") { - await page.waitForLoadState((arg as LoadState | undefined) ?? "load", timeoutMs); + await page.waitForLoadState(oneOf("arg", arg, LOAD_STATES) ?? "load", timeoutMs); } else if (type === "selector") { if (!arg) throw new BrowseCommandError("wait selector requires a target."); await page.waitForSelector(session.selector(arg), { - state: (optStr(params, "state") ?? "visible") as never, + state: optStr(params, "state") ?? "visible", timeout: timeoutMs ?? 30_000, }); } else if (type === "timeout") { @@ -400,10 +402,10 @@ const HANDLERS: Record = { const x = num(params, "x"); const y = num(params, "y"); await moveCursorOverlay(session, page, x, y); - const button = optStr(params, "button"); + const button = optEnum(params, "button", MOUSE_BUTTONS); const clickCount = optNum(params, "clickCount"); await page.click(x, y, { - ...(button === undefined ? {} : { button: button as MouseButton }), + ...(button === undefined ? {} : { button }), ...(clickCount === undefined ? {} : { clickCount }), }); return { clicked: true }; @@ -434,11 +436,11 @@ const HANDLERS: Record = { const toX = num(params, "toX"); const toY = num(params, "toY"); await moveCursorOverlay(session, page, fromX, fromY); - const button = optStr(params, "button"); + const button = optEnum(params, "button", MOUSE_BUTTONS); const steps = optNum(params, "steps"); const delay = optNum(params, "delay"); await page.dragAndDrop(fromX, fromY, toX, toY, { - ...(button === undefined ? {} : { button: button as MouseButton }), + ...(button === undefined ? {} : { button }), ...(steps === undefined ? {} : { steps }), ...(delay === undefined ? {} : { delay }), }); @@ -554,7 +556,7 @@ async function safeTitle(page: Page): Promise { function navigationOptions(params: Params): { timeout?: number; waitUntil?: LoadState } { const timeout = optNum(params, "timeoutMs"); - const waitUntil = optStr(params, "waitUntil") as LoadState | undefined; + const waitUntil = optEnum(params, "waitUntil", LOAD_STATES); return { ...(timeout === undefined ? {} : { timeout }), ...(waitUntil === undefined ? {} : { waitUntil }), @@ -662,6 +664,35 @@ function optStr(params: Params, key: string): string | undefined { return value; } +function optEnum( + params: Params, + key: string, + values: T, +): T[number] | undefined { + return oneOf(key, params[key], values); +} + +function oneOf( + key: string, + value: unknown, + values: T, +): T[number] | undefined { + const parsed = z.enum(values).optional().safeParse(value); + if (!parsed.success) { + throw new BrowseCommandError(`Expected one of ${values.join(", ")} for "${key}".`); + } + return parsed.data; +} + +const clipSchema = z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }); + +function optClip(params: Params): z.infer | undefined { + if (params.clip === undefined) return undefined; + const parsed = clipSchema.safeParse(params.clip); + if (!parsed.success) throw new BrowseCommandError(`Expected { x, y, width, height } numbers for "clip".`); + return parsed.data; +} + function num(params: Params, key: string): number { const value = params[key]; if (typeof value !== "number" || !Number.isFinite(value)) { @@ -688,9 +719,7 @@ function optBool(params: Params, key: string): boolean | undefined { function strArray(params: Params, key: string): string[] { const value = params[key]; - const list = Array.isArray(value) ? value : [value]; - if (list.length === 0 || list.some((entry) => typeof entry !== "string")) { - throw new BrowseCommandError(`Expected one or more strings for "${key}".`); - } - return list as string[]; + const list: unknown[] = Array.isArray(value) ? value : [value]; + if (list.length > 0 && list.every((entry) => typeof entry === "string")) return list; + throw new BrowseCommandError(`Expected one or more strings for "${key}".`); } diff --git a/packages/mcp-server/src/browse/page.ts b/packages/mcp-server/src/browse/page.ts index 89545a5..275d4a4 100644 --- a/packages/mcp-server/src/browse/page.ts +++ b/packages/mcp-server/src/browse/page.ts @@ -2,9 +2,12 @@ import { CdpConnection, openWebSocket, type OpenSocket } from "./transport.ts"; import { resolveCdpUrl } from "./cdp.ts"; import type { FrameTarget, ResolvedSelector } from "./selectors.ts"; +import { z } from "zod"; -export type LoadState = "load" | "domcontentloaded" | "networkidle"; -export type MouseButton = "left" | "middle" | "right"; +export const LOAD_STATES = ["load", "domcontentloaded", "networkidle"] as const; +export type LoadState = (typeof LOAD_STATES)[number]; +export const MOUSE_BUTTONS = ["left", "middle", "right"] as const; +export type MouseButton = (typeof MOUSE_BUTTONS)[number]; type NavigationOptions = { timeout?: number; waitUntil?: LoadState }; const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -521,14 +524,14 @@ export class Page { const { cssContentSize } = await this.send("Page.getLayoutMetrics"); clip = cssContentSize; } - const { data } = await this.send("Page.captureScreenshot", { + const response = await this.send("Page.captureScreenshot", { format: options.type ?? "png", ...(options.quality === undefined ? {} : { quality: options.quality }), ...(clip ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } : {}), }); - return data as string; + return screenshotSchema.parse(response).data; } async setViewportSize( width: number, @@ -808,10 +811,10 @@ export class Locator { centroid(): Promise<{ x: number; y: number }> { return this.withElement(async ({ sessionId, objectId }) => { await this.page.sendTo(sessionId, "DOM.scrollIntoViewIfNeeded", { objectId }).catch(() => {}); - const { quads } = await this.page + const response = await this.page .sendTo(sessionId, "DOM.getContentQuads", { objectId }) .catch(() => ({ quads: [] })); - const quad = (quads as number[][]).find((q) => area(q) > 0); + const quad = contentQuadsSchema.parse(response).quads.find((q) => area(q) > 0); if (!quad) throw new Error("Element is not visible"); const offset = await this.page.offsetOf(sessionId); return { @@ -822,6 +825,9 @@ export class Locator { } } +const screenshotSchema = z.object({ data: z.string() }); +const contentQuadsSchema = z.object({ quads: z.array(z.array(z.number())) }); + function area(quad: number[]): number { let sum = 0; for (let i = 0; i < 8; i += 2) { diff --git a/packages/mcp-server/src/browse/providers.ts b/packages/mcp-server/src/browse/providers.ts index dce8bd2..4885f39 100644 --- a/packages/mcp-server/src/browse/providers.ts +++ b/packages/mcp-server/src/browse/providers.ts @@ -1,4 +1,5 @@ /** Hosted lifecycles use fetch only and retain no process-local session state. */ +import { z } from "zod"; import { browserProvider, staticProvider, @@ -42,7 +43,7 @@ export function providerFromEnv( if (!response.ok) throw new Error(`${name} ${method} failed (HTTP ${response.status}).`); if (response.status === 204) return null; - return response.json() as Promise>; + return z.record(z.unknown()).parse(await response.json()); }; const field = (data: Record | null, key: string): string => { const value = data?.[key]; diff --git a/packages/mcp-server/src/core.ts b/packages/mcp-server/src/core.ts index 7479fb5..5c258c7 100644 --- a/packages/mcp-server/src/core.ts +++ b/packages/mcp-server/src/core.ts @@ -103,7 +103,7 @@ export interface RunOutput { } const connectOverCdp: NonNullable = (options) => - BrowserSession.connect(options as Parameters[0]); + BrowserSession.connect(options); /** `browser_run`'s input fields, as a zod raw shape. */ export function runInputShape(config: ServerConfig) { @@ -285,8 +285,8 @@ export async function captureScreenshot( ): Promise { const session = await connect({ cdpUrl, ...(signal ? { signal } : {}) }); try { - const result = (await session.run("screenshot", { type: "jpeg", quality: 70 })) as { base64: string }; - return result.base64; + const result = await session.run("screenshot", { type: "jpeg", quality: 70 }); + return z.object({ base64: z.string() }).parse(result).base64; } finally { await session.close(); } diff --git a/packages/mcp-server/src/node/local-browser.ts b/packages/mcp-server/src/node/local-browser.ts index 3ec7d47..5ca2c4f 100644 --- a/packages/mcp-server/src/node/local-browser.ts +++ b/packages/mcp-server/src/node/local-browser.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { createServer } from "node:net"; import { join } from "node:path"; +import { z } from "zod"; import type { BrowserHandle, BrowserProvider } from "../browse/provider.ts"; export async function cdpReady(port: number): Promise { @@ -12,8 +13,10 @@ export async function cdpReady(port: number): Promise { const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(500), }); - const info = (await response.json()) as { webSocketDebuggerUrl?: string }; - return response.ok && !!info.webSocketDebuggerUrl; + const info = z + .object({ webSocketDebuggerUrl: z.string().min(1) }) + .safeParse(await response.json()); + return response.ok && info.success; } catch { return false; } @@ -155,8 +158,12 @@ function freePort(): Promise { const server = createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { - const { port } = server.address() as { port: number }; - server.close(() => resolve(port)); + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(() => reject(new Error("Could not determine a free TCP port."))); + return; + } + server.close(() => resolve(address.port)); }); }); } diff --git a/packages/mcp-server/src/runtime-bundled.ts b/packages/mcp-server/src/runtime-bundled.ts index 0cf5d76..476fed6 100644 --- a/packages/mcp-server/src/runtime-bundled.ts +++ b/packages/mcp-server/src/runtime-bundled.ts @@ -1,5 +1,5 @@ import { newVariant, newQuickJSWASMModuleFromVariant } from "quickjs-emscripten-core"; -import variant from "@jitl/quickjs-wasmfile-release-sync"; +import { quickjsVariant } from "./sandbox/variant.ts"; import wasmBinary from "virtual:browse-wasm"; import { createApp } from "./app.ts"; import { loadConfig } from "./config.ts"; @@ -7,7 +7,7 @@ import { providerFromEnv } from "./browse/providers.ts"; import { createProgramRunner } from "./sandbox/runner.ts"; const run = createProgramRunner(() => newQuickJSWASMModuleFromVariant(newVariant( - variant as unknown as Exclude[0]>, { default: unknown }>, + quickjsVariant, { wasmBinary }, ))); diff --git a/packages/mcp-server/src/sandbox/runner.ts b/packages/mcp-server/src/sandbox/runner.ts index 5c3c078..0692d24 100644 --- a/packages/mcp-server/src/sandbox/runner.ts +++ b/packages/mcp-server/src/sandbox/runner.ts @@ -6,8 +6,9 @@ import { type QuickJSHandle, type QuickJSDeferredPromise, } from "quickjs-emscripten-core"; -import variant from "@jitl/quickjs-wasmfile-release-sync"; +import { quickjsVariant } from "./variant.ts"; import { transform } from "sucrase"; +import { z } from "zod"; import type { CommandRunner } from "../browse/driver.ts"; import { createHostFunctions, @@ -20,8 +21,12 @@ export interface RunLimits { memoryLimitBytes?: number; maxInterrupts?: number; } + +const logLevelSchema = z.enum(["info", "warn", "error"]); +const argsSchema = z.array(z.unknown()); + export interface LogEntry { - level: "info" | "warn" | "error"; + level: z.infer; /** Arguments as the program passed them. */ args: unknown[]; } @@ -64,9 +69,7 @@ export interface RunProgramOptions { export type LoadQuickJS = () => Promise; const loadQuickJS: LoadQuickJS = () => - newQuickJSWASMModuleFromVariant( - variant as unknown as Parameters[0], - ); + newQuickJSWASMModuleFromVariant(quickjsVariant); export const runProgram = (options: RunProgramOptions) => createProgramRunner(loadQuickJS)(options); @@ -131,7 +134,7 @@ export function createProgramRunner(load: LoadQuickJS) { }; if (++commands > maxCommands) return { error: vm.newError("Command budget exceeded.") }; - const args = JSON.parse(vm.getString(argsHandle)) as unknown[]; + const args = argsSchema.parse(JSON.parse(vm.getString(argsHandle))); const deferred = vm.newPromise(); deferreds.add(deferred); const task = Promise.resolve() @@ -176,9 +179,9 @@ export function createProgramRunner(load: LoadQuickJS) { logBytes += new TextEncoder().encode(json).byteLength; 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[], + const entry: LogEntry = { + level: logLevelSchema.parse(vm.getString(levelHandle)), + args: argsSchema.parse(JSON.parse(json)), }; logs.push(entry); options.onLog?.(entry); diff --git a/packages/mcp-server/src/sandbox/variant.ts b/packages/mcp-server/src/sandbox/variant.ts new file mode 100644 index 0000000..a6f3efa --- /dev/null +++ b/packages/mcp-server/src/sandbox/variant.ts @@ -0,0 +1,21 @@ +import variantExport from "@jitl/quickjs-wasmfile-release-sync"; +import type { QuickJSSyncVariant } from "quickjs-emscripten-core"; + +/** + * The package's typings describe its CommonJS build, so under NodeNext the + * default import is typed as `{ default: variant }` while ESM runtimes hand + * over the variant itself. Accept either shape. + */ +export const quickjsVariant: QuickJSSyncVariant = unwrapVariant(variantExport); + +function unwrapVariant(value: unknown): QuickJSSyncVariant { + if (isSyncVariant(value)) return value; + if (typeof value === "object" && value !== null && "default" in value && isSyncVariant(value.default)) { + return value.default; + } + throw new Error("@jitl/quickjs-wasmfile-release-sync did not export a sync QuickJS variant."); +} + +function isSyncVariant(value: unknown): value is QuickJSSyncVariant { + return typeof value === "object" && value !== null && "type" in value && value.type === "sync"; +} diff --git a/packages/mcp-server/src/worker.ts b/packages/mcp-server/src/worker.ts index aaf644b..45b5599 100644 --- a/packages/mcp-server/src/worker.ts +++ b/packages/mcp-server/src/worker.ts @@ -2,7 +2,7 @@ import { newVariant, newQuickJSWASMModuleFromVariant, } from "quickjs-emscripten-core"; -import variant from "@jitl/quickjs-wasmfile-release-sync"; +import { quickjsVariant } from "./sandbox/variant.ts"; import wasmModule from "../node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm"; import { createApp } from "./app.ts"; import { loadConfig } from "./config.ts"; @@ -14,10 +14,7 @@ import { providerFromEnv } from "./browse/providers.ts"; const run = createProgramRunner(() => newQuickJSWASMModuleFromVariant( newVariant( - variant as unknown as Exclude< - Awaited[0]>, - { default: unknown } - >, + quickjsVariant, { wasmModule }, ), ), diff --git a/packages/tools/src/browser.ts b/packages/tools/src/browser.ts index 9b8f7c2..7a0682a 100644 --- a/packages/tools/src/browser.ts +++ b/packages/tools/src/browser.ts @@ -41,7 +41,7 @@ export interface BrowserSession { export interface LiveView { /** The session's live view page, when the provider has one. */ - url?: string; + url?: string | undefined; /** The current page, as the model sees it. */ screenshot: { mediaType: "image/jpeg"; base64: string }; } diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index e7fd1a0..1973b9b 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -74,7 +74,7 @@ export interface BrowserEvent { export interface BrowserRunOutput { text: string; isError: boolean; - status: "completed" | "failed" | "interrupted"; + status: z.infer["status"]; value?: unknown; [key: string]: unknown; } @@ -88,6 +88,8 @@ export interface BrowserRunInput { timeoutMs?: number | undefined; } +const runStatusSchema = z.object({ status: z.enum(["completed", "failed", "interrupted"]) }); + /** A session as the model sees it: everything but the CDP credential. */ export type SessionSummary = Omit; @@ -150,14 +152,7 @@ export function createBrowserToolkit(options: BrowserToolsOptions = {}) { "recently started session is used, or a temporary browser when none is open." : undefined, ), - // Typed as the union of both modes' fields; at runtime only one of - // `cdpUrl` and `sessionId` is in the schema. - inputSchema: runInputSchema as unknown as z.ZodObject<{ - code: z.ZodString; - cdpUrl: z.ZodOptional; - sessionId: z.ZodOptional; - timeoutMs: z.ZodOptional; - }>, + inputSchema: runInputSchema, async execute( { sessionId, ...input }: BrowserRunInput, signal?: AbortSignal, @@ -178,7 +173,8 @@ export function createBrowserToolkit(options: BrowserToolsOptions = {}) { label, ...(notify ? { notify } : {}), }); - return { ...(structuredContent as { status: BrowserRunOutput["status"] }), text, isError }; + const { status } = runStatusSchema.parse(structuredContent); + return { ...structuredContent, status, text, isError }; }, }, sessions: browser @@ -246,7 +242,7 @@ function summarize({ cdpUrl: _, ...session }: BrowserSession): SessionSummary { } /** Hand the model the compact text, flagged as an error when the program failed. */ -export function toModelOutput(output: BrowserRunOutput) { +export function toModelOutput(output: Pick) { return { type: output.isError ? ("error-text" as const) : ("text" as const), value: output.text }; } diff --git a/packages/tools/src/mastra.ts b/packages/tools/src/mastra.ts index 3c89339..e42c625 100644 --- a/packages/tools/src/mastra.ts +++ b/packages/tools/src/mastra.ts @@ -12,13 +12,12 @@ */ import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; import { createBrowserToolkit, liveViewModelOutput, toModelOutput, - type BrowserRunOutput, - type LiveView, type Browser, type BrowserToolkit, type BrowserToolsOptions, @@ -27,6 +26,13 @@ import { export { instructions, type BrowserToolsOptions, type BrowserRunOutput } from "./index.ts"; +// Mastra types a tool's output only through `outputSchema`, so `toModelOutput` receives `unknown`. +const runOutputSchema = z.object({ text: z.string(), isError: z.boolean() }); +const liveViewSchema = z.object({ + url: z.string().optional(), + screenshot: z.object({ mediaType: z.literal("image/jpeg"), base64: z.string() }), +}); + type RunTools = ReturnType; type SessionTools = ReturnType; @@ -52,7 +58,7 @@ function runTools({ api, run }: BrowserToolkit) { description: run.description, inputSchema: run.inputSchema, execute: (input, { abortSignal }) => run.execute(input, abortSignal), - toModelOutput: (output) => toModelOutput(output as BrowserRunOutput), + toModelOutput: (output) => toModelOutput(runOutputSchema.parse(output)), }), }; } @@ -82,7 +88,7 @@ function sessionTools(sessions: SessionToolkit) { description: sessions.liveView.description, inputSchema: sessions.liveView.inputSchema, execute: (input, { abortSignal }) => sessions.liveView.execute(input, abortSignal), - toModelOutput: (output) => liveViewModelOutput(output as LiveView), + toModelOutput: (output) => liveViewModelOutput(liveViewSchema.parse(output)), }), }; } From 42d8a1f501105008d1ef6882c3a355750f1ad03c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:04:04 +0000 Subject: [PATCH 3/4] Replace type assertions in tests with zod parsing Test results are parsed with zod so a wrong shape fails loudly; Mastra tools get a real execute context (RequestContext + noopObserve) instead of {} as never. Co-authored-by: Anirudh Kamath --- packages/mcp-server/tests/browser.e2e.test.ts | 15 ++-- packages/mcp-server/tests/frames.e2e.test.ts | 3 +- .../mcp-server/tests/helpers/hard-pages.ts | 5 +- packages/mcp-server/tests/mcp.test.ts | 33 ++++---- packages/mcp-server/tests/runtime.e2e.test.ts | 7 +- packages/mcp-server/tests/sandbox.test.ts | 5 +- packages/tools/tests/tools.test.ts | 78 ++++++++++++------- 7 files changed, 88 insertions(+), 58 deletions(-) diff --git a/packages/mcp-server/tests/browser.e2e.test.ts b/packages/mcp-server/tests/browser.e2e.test.ts index 7713d2f..fba4b2f 100644 --- a/packages/mcp-server/tests/browser.e2e.test.ts +++ b/packages/mcp-server/tests/browser.e2e.test.ts @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { z } from "zod"; import { BrowserSession } from "../src/browse/driver.ts"; import { runProgram, type ProgramResult } from "../src/sandbox/runner.ts"; @@ -40,11 +41,13 @@ test("a program drives a real browser and returns only its result", { skip: !ena `); assert.equal(result.status, "completed", JSON.stringify(result.failedCall ?? result.error)); - const value = result.value as Record; + const value = z + .object({ url: z.string(), title: z.string(), heading: z.string(), treeLines: z.number() }) + .parse(result.value); assert.equal(value.url, "https://example.com/"); assert.equal(value.title, "Example Domain"); assert.equal(value.heading, "Example Domain"); - assert.ok((value.treeLines as number) > 3); + assert.ok(value.treeLines > 3); assert.equal(result.calls.length, 5); assert.ok(result.calls.every((call) => call.ok)); }); @@ -65,7 +68,7 @@ test("snapshot refs drive clicks within one program", { skip: !enabled }, async `); assert.equal(result.status, "completed", JSON.stringify(result.failedCall ?? result.error)); - const value = result.value as { clicked: boolean; url?: string }; + const value = z.object({ clicked: z.boolean(), url: z.string().optional() }).parse(result.value); assert.equal(value.clicked, true); assert.notEqual(value.url, "https://example.com/"); }); @@ -89,7 +92,7 @@ test("page state persists in the browser between programs", { skip: !enabled }, `); assert.equal(second.status, "completed", JSON.stringify(second.failedCall ?? second.error)); - assert.equal((second.value as { url: string }).url, "https://example.com/"); + assert.equal(z.object({ url: z.string() }).parse(second.value).url, "https://example.com/"); }); test("page JavaScript is reachable through eval", { skip: !enabled }, async () => { @@ -100,7 +103,7 @@ test("page JavaScript is reachable through eval", { skip: !enabled }, async () = `); assert.equal(result.status, "completed", JSON.stringify(result.failedCall ?? result.error)); - assert.ok((result.value as { paragraphs: number }).paragraphs >= 1); + assert.ok(z.object({ paragraphs: z.number() }).parse(result.value).paragraphs >= 1); }); test("tabs, viewport, and typing work end to end", { skip: !enabled }, async () => { @@ -115,6 +118,6 @@ test("tabs, viewport, and typing work end to end", { skip: !enabled }, async () `); assert.equal(result.status, "completed", JSON.stringify(result.failedCall ?? result.error)); - const value = result.value as { opened: number; after: number }; + const value = z.object({ opened: z.number(), after: z.number() }).parse(result.value); assert.equal(value.after, value.opened - 1); }); diff --git a/packages/mcp-server/tests/frames.e2e.test.ts b/packages/mcp-server/tests/frames.e2e.test.ts index 8693ca4..49116e2 100644 --- a/packages/mcp-server/tests/frames.e2e.test.ts +++ b/packages/mcp-server/tests/frames.e2e.test.ts @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { after, before, test } from "node:test"; +import { z } from "zod"; import { BrowserSession } from "../src/browse/driver.ts"; import { runProgram, type ProgramResult } from "../src/sandbox/runner.ts"; @@ -56,7 +57,7 @@ async function onHardPage(body: string): Promise { } test("the snapshot includes every frame, cross-site and nested ones too", { skip: !enabled }, async () => { - const tree = (await onHardPage(`return tree;`)) as string; + const tree = z.string().parse(await onHardPage(`return tree;`)); const refOf = (name: string) => /\[(\d+)-\d+\]/.exec(tree.split("\n").find((l) => l.includes(name)) ?? "")?.[1]; for (const name of [ diff --git a/packages/mcp-server/tests/helpers/hard-pages.ts b/packages/mcp-server/tests/helpers/hard-pages.ts index fefe935..0818db4 100644 --- a/packages/mcp-server/tests/helpers/hard-pages.ts +++ b/packages/mcp-server/tests/helpers/hard-pages.ts @@ -11,7 +11,6 @@ */ import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; export interface HardPages { /** Top page URL. */ @@ -106,7 +105,9 @@ export async function serveHardPages(): Promise { }); // Listen on all loopback names so both 127.0.0.1 and localhost resolve. await new Promise((resolve) => server.listen(0, resolve)); - port = (server.address() as AddressInfo).port; + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Fixture server has no TCP port."); + port = address.port; return { url: `http://127.0.0.1:${port}/`, close: () => diff --git a/packages/mcp-server/tests/mcp.test.ts b/packages/mcp-server/tests/mcp.test.ts index b200632..c308915 100644 --- a/packages/mcp-server/tests/mcp.test.ts +++ b/packages/mcp-server/tests/mcp.test.ts @@ -5,6 +5,7 @@ import { serve } from "@hono/node-server"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import { createApp } from "../src/app.ts"; import { loadConfig } from "../src/config.ts"; @@ -16,8 +17,8 @@ after(async () => { for (const close of cleanups) await close(); }); -async function startServer(env: NodeJS.ProcessEnv = {}, handler?: FakeHandler) { - const config = loadConfig({ BROWSE_CDP_URL: "ws://fake-browser", ...env } as NodeJS.ProcessEnv); +async function startServer(env: Record = {}, handler?: FakeHandler) { + const config = loadConfig({ BROWSE_CDP_URL: "ws://fake-browser", ...env }); const connections: FakeBrowser[] = []; const app = createApp({ @@ -63,9 +64,11 @@ async function startServer(env: NodeJS.ProcessEnv = {}, handler?: FakeHandler) { return { url, client, run, notifications, connections, transport }; } +const contentSchema = z.array(z.object({ type: z.string(), text: z.string().optional() })).optional(); +const structuredSchema = z.record(z.unknown()); + function textOf(result: { content?: unknown }): string { - const content = result.content as Array<{ type: string; text: string }> | undefined; - return content?.[0]?.text ?? ""; + return contentSchema.parse(result.content)?.[0]?.text ?? ""; } test("the server advertises exactly the code mode surface", async () => { @@ -112,7 +115,7 @@ test("a program runs and returns structured output", async () => { assert.equal(result.isError, false); assert.match(textOf(result), /^completed · 2 commands/); - const structured = result.structuredContent as Record; + const structured = structuredSchema.parse(result.structuredContent); assert.equal(structured.status, "completed"); assert.deepEqual(structured.value, { title: "fake-title" }); assert.equal(structured.cdpUrl, "ws://fake-browser"); @@ -121,7 +124,8 @@ test("a program runs and returns structured output", async () => { assert.ok( notifications.some( (notification) => - (notification as { cli?: string }).cli === "browse open https://example.com", + z.object({ cli: z.string() }).safeParse(notification).data?.cli === + "browse open https://example.com", ), ); @@ -155,11 +159,11 @@ test("the connection is released even when the program fails", async () => { test("a call naming its own cdpUrl overrides the default", async () => { const { run } = await startServer(); const result = await run(`return 1;`, { cdpUrl: "ws://other-browser" }); - assert.equal((result.structuredContent as { cdpUrl: string }).cdpUrl, "ws://other-browser"); + assert.equal(structuredSchema.parse(result.structuredContent).cdpUrl, "ws://other-browser"); }); test("a missing browser is reported instead of guessed", async () => { - const config = loadConfig({} as NodeJS.ProcessEnv); + const config = loadConfig({}); assert.equal(config.defaultCdpUrl, undefined); const app = createApp({ config, connect: async () => createFakeBrowser() }); @@ -196,10 +200,9 @@ test("a bearer token is required when one is configured", async () => { test("health reports the stateless deployment", async () => { const { url } = await startServer(); const origin = new URL(url).origin; - const health = (await (await fetch(`${origin}/health`)).json()) as { - stateless: boolean; - defaultCdpUrl: string; - }; + const health = z + .object({ stateless: z.boolean(), defaultCdpUrl: z.string() }) + .parse(await (await fetch(`${origin}/health`)).json()); assert.equal(health.stateless, true); assert.equal(health.defaultCdpUrl, "ws://fake-browser"); @@ -212,7 +215,7 @@ test("a provider supplies the browser and is released after the program", async const acquired: string[] = []; const released: string[] = []; - const config = loadConfig({} as NodeJS.ProcessEnv); + const config = loadConfig({}); const app = createApp({ config, provider: { @@ -253,7 +256,7 @@ 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(structuredSchema.parse(result.structuredContent).cdpUrl, "wss://cloud.example/session-1"); assert.deepEqual(acquired, ["session-1"]); assert.deepEqual(released, ["session-1"], "the session must be ended, not orphaned"); @@ -265,7 +268,7 @@ test("a provider supplies the browser and is released after the program", async }); test("a provider that cannot produce a browser reports why", async () => { - const config = loadConfig({} as NodeJS.ProcessEnv); + const config = loadConfig({}); const app = createApp({ config, provider: { diff --git a/packages/mcp-server/tests/runtime.e2e.test.ts b/packages/mcp-server/tests/runtime.e2e.test.ts index f4851db..e8548d2 100644 --- a/packages/mcp-server/tests/runtime.e2e.test.ts +++ b/packages/mcp-server/tests/runtime.e2e.test.ts @@ -4,6 +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 { z } from "zod"; import { createLocalBrowser, cdpReady } from "../src/node/local-browser.ts"; async function freePort(): Promise { @@ -73,7 +74,7 @@ test( } const create = await fetch(`${base}/browser`, { method: "POST" }); assert.equal(create.status, 201); - const browser = (await create.json()) as { cdpUrl: string }; + const browser = z.object({ cdpUrl: z.string() }).passthrough().parse(await create.json()); await client.connect( new StreamableHTTPClientTransport(new URL(`${base}/mcp`)), ); @@ -93,7 +94,7 @@ test( 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, { + assert.deepEqual(z.object({ value: z.unknown() }).parse(result.structuredContent).value, { answer: 42, title: { title: "Runtime" }, value: { value: "Cloudflare" }, @@ -101,7 +102,7 @@ test( const loop = await call("while (true) {}"); assert.equal(loop.isError, true); assert.equal( - (loop.structuredContent as { error: { name: string } }).error.name, + z.object({ error: z.object({ name: z.string() }) }).parse(loop.structuredContent).error.name, "RunTimeoutError", ); const screenshot = await fetch(`${base}/screen`, { diff --git a/packages/mcp-server/tests/sandbox.test.ts b/packages/mcp-server/tests/sandbox.test.ts index e1c3684..203512a 100644 --- a/packages/mcp-server/tests/sandbox.test.ts +++ b/packages/mcp-server/tests/sandbox.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { z } from "zod"; import { runProgram } from "../src/sandbox/runner.ts"; import { createFakeBrowser, echoHandler } from "./helpers/fake-browser.ts"; @@ -152,7 +153,7 @@ test("denied commands cannot be called", async () => { deny: ["browse.eval"], }); - const outcome = result.value as Record; + const outcome = z.record(z.string()).parse(result.value); assert.match(outcome.eval ?? "", /Unknown host function: browse\.eval/); assert.equal(outcome.click, "ran"); assert.deepEqual( @@ -176,7 +177,7 @@ test("an allowlist blocks every command outside it", async () => { allow: ["browse.open"], }); - const outcome = result.value as Record; + const outcome = z.record(z.string()).parse(result.value); assert.equal(outcome.open, "ran"); assert.match(outcome.mouseClick ?? "", /Unknown host function: mouse\.click/); assert.deepEqual( diff --git a/packages/tools/tests/tools.test.ts b/packages/tools/tests/tools.test.ts index 97dd7e9..8c59d06 100644 --- a/packages/tools/tests/tools.test.ts +++ b/packages/tools/tests/tools.test.ts @@ -1,22 +1,35 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { RequestContext } from "@mastra/core/request-context"; +import { noopObserve } from "@mastra/core/tools"; import { generateText, stepCountIs } from "ai"; import { MockLanguageModelV4 } from "ai/test"; +import { z } from "zod"; 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, - type SessionSummary, -} from "../src/index.ts"; +import { providerBrowser, type BrowserToolsOptions } from "../src/index.ts"; + +const runOutputSchema = z + .object({ + text: z.string(), + isError: z.boolean(), + status: z.enum(["completed", "failed", "interrupted"]), + value: z.unknown(), + }) + .passthrough(); +const liveViewSchema = z.object({ + url: z.string().optional(), + screenshot: z.object({ mediaType: z.literal("image/jpeg"), base64: z.string() }), +}); +const sessionListSchema = z.object({ sessions: z.array(z.object({ id: z.string() }).passthrough()) }); + +const mastraContext = () => ({ requestContext: new RequestContext(), observe: noopObserve }); /** Answers commands like a page titled "Fake"; no Chrome needed. */ function fakeBrowser() { - const state = { endpoints: [] as string[], closed: 0 }; + const state: { endpoints: string[]; closed: number } = { endpoints: [], closed: 0 }; const connect: NonNullable = async ({ cdpUrl }) => { state.endpoints.push(cdpUrl); return { @@ -40,7 +53,7 @@ const usage = { test("AI SDK: the model runs a program and reads the compact result text", async () => { const { state, connect } = fakeBrowser(); - const prompts: unknown[] = []; + const prompts: unknown[][] = []; const model = new MockLanguageModelV4({ doGenerate: async ({ prompt }) => { prompts.push(prompt); @@ -79,12 +92,12 @@ test("AI SDK: the model runs a program and reads the compact result text", async assert.deepEqual(state.endpoints, ["ws://fake"]); assert.equal(state.closed, 1); - const output = result.steps[0]!.toolResults[0]!.output as BrowserRunOutput; + const output = runOutputSchema.parse(result.steps[0]!.toolResults[0]!.output); assert.equal(output.status, "completed"); assert.equal(output.value, "Fake"); // The model sees the rendered text, not the JSON. - const toolMessage = JSON.stringify((prompts[1] as unknown[]).at(-1)); + const toolMessage = JSON.stringify(prompts[1]?.at(-1)); assert.match(toolMessage, /completed · 2 commands/); assert.match(toolMessage, /"type":"text"/); }); @@ -92,10 +105,12 @@ test("AI SDK: the model runs a program and reads the compact result text", async test("AI SDK: a failed program is reported to the model as an error", async () => { const { connect } = fakeBrowser(); const tools = aiSdkTools({ cdpUrl: "ws://fake", connect, env: {} }); - const output = (await tools.browser_run.execute!( - { code: `await browse.back();` }, - { toolCallId: "1", messages: [], context: {} }, - )) as BrowserRunOutput; + const output = runOutputSchema.parse( + await tools.browser_run.execute!( + { code: `await browse.back();` }, + { toolCallId: "1", messages: [], context: {} }, + ), + ); assert.equal(output.isError, true); assert.match(output.text, /unexpected command back/); const modelOutput = await tools.browser_run.toModelOutput!({ toolCallId: "1", input: { code: "" }, output }); @@ -107,12 +122,14 @@ 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(z.string().parse(await tools.browser_api.execute!({}, mastraContext())), /declare const browse/); - const output = (await tools.browser_run.execute!( - { code: `return (await browse.get("title")).title;` }, - {} as never, - )) as BrowserRunOutput; + const output = runOutputSchema.parse( + await tools.browser_run.execute!( + { code: `return (await browse.get("title")).title;` }, + mastraContext(), + ), + ); assert.equal(output.value, "Fake"); assert.equal(state.closed, 1); }); @@ -172,26 +189,25 @@ 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 = runOutputSchema.parse(await tools.browser_run.execute!({ code: `return 1;`, sessionId: "s1" }, options)); await tools.browser_run.execute!({ code: `return 1;` }, options); assert.match(first.text, /· session s1\n/); assert.doesNotMatch(JSON.stringify(first), /ws:\/\//); assert.deepEqual(state.endpoints, ["ws://session-1", "ws://session-2"]); - const unknown = (await tools.browser_run.execute!( - { code: `return 1;`, sessionId: "nope" }, - options, - )) as BrowserRunOutput; + const unknown = runOutputSchema.parse( + await tools.browser_run.execute!({ code: `return 1;`, sessionId: "nope" }, options), + ); assert.equal(unknown.isError, true); assert.match(unknown.text, /Unknown browser session: nope/); - const view = (await tools.browser_live_view!.execute!({ sessionId: "s1" }, options)) as LiveView; + const view = liveViewSchema.parse(await tools.browser_live_view!.execute!({ sessionId: "s1" }, options)); 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[] }; + const { sessions } = sessionListSchema.parse(await tools.browser_list_sessions!.execute!({}, options)); assert.deepEqual(sessions.map((s) => s.id), ["s2"]); assert.deepEqual(shutdowns, ["s1"]); }); @@ -210,7 +226,7 @@ test("with a Browser and no open session, browser_run uses a temporary one", asy }, }); const tools = mastraTools({ browser, connect, env: {} }); - await tools.browser_run.execute!({ code: `return 1;` }, {} as never); + await tools.browser_run.execute!({ code: `return 1;` }, mastraContext()); assert.deepEqual(lifecycle, ["create", "shutdown"]); assert.deepEqual(state.endpoints, ["ws://temporary"]); assert.deepEqual(await browser.listSessions(), []); @@ -219,5 +235,9 @@ 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({ + name: "unused", + create: async () => ({ provider: "unused", cdpUrl: "ws://unused" }), + shutdown: async () => {}, + }) }), /not both/); }); From ac13c2ef9fa0a3a62e5ae3083f56431924edde3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 22:38:23 +0000 Subject: [PATCH 4/4] CI: install dependencies before running Biome biome.json loads the no-type-assertion plugin from node_modules, which the standalone setup-biome binary does not have. --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e46d3d5..1609d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - # Picks up the Biome version pinned in pnpm-lock.yaml. - - uses: biomejs/setup-biome@v2 - - run: biome ci --error-on-warnings . + # biome.json loads biome-plugin-no-type-assertion from node_modules, so + # install dependencies rather than using a standalone Biome binary. + - uses: pnpm/setup@v3 + with: + runtime: node@22 + cache: true + require-lockfile: true + - run: pnpm exec biome ci --error-on-warnings . check: runs-on: ubuntu-latest