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 diff --git a/README.md b/README.md index 9c58f64..30877de 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: format, recommended rules, and biome-plugin-no-type-assertion (no `as` casts) pnpm check pnpm test pnpm build diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts index 233c3a4..dcb5b3c 100644 --- a/apps/web/src/lib/mcp.ts +++ b/apps/web/src/lib/mcp.ts @@ -7,11 +7,14 @@ 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, type JSONSchema7, jsonSchema, type ToolSet } from "ai"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { dynamicTool, jsonSchema, type ToolSet } from "ai"; +import { z } from "zod"; import { fetchBrowserApp } from "./browser-app.ts"; import { mcpHeaders } from "./env.ts"; +const toolArgumentsSchema = z.record(z.unknown()); + export interface McpSession { tools: ToolSet; instructions: string | undefined; @@ -43,16 +46,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 3253095..85eba8c 100644 --- a/apps/web/src/routes/api.chat.ts +++ b/apps/web/src/routes/api.chat.ts @@ -1,6 +1,13 @@ 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, + validateUIMessages, +} from "ai"; +import { z } from "zod"; import { env } from "../lib/env.ts"; import { type McpSession, openMcpSession } from "../lib/mcp.ts"; @@ -19,6 +26,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: { @@ -30,12 +42,23 @@ export const Route = createFileRoute("/api/chat")({ ); } - const { messages, cdpUrl } = (await request.json()) as { - messages: UIMessage[]; - cdpUrl?: string; - }; - if (!cdpUrl || typeof cdpUrl !== "string") - return Response.json({ error: "Start a browser first." }, { status: 400 }); + 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: UIMessage[]; + 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: McpSession; try { diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 3abb655..beeb64b 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)); @@ -277,20 +282,24 @@ 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. */ 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 ?? "", @@ -324,7 +333,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); @@ -336,13 +345,11 @@ 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) - : "", + block && typeof block === "object" && "text" in block ? String(block.text) : "", ) .filter(Boolean) .join("\n"); diff --git a/biome.json b/biome.json index f056836..2bbe958 100644 --- a/biome.json +++ b/biome.json @@ -38,5 +38,6 @@ "organizeImports": "on" } } - } + }, + "plugins": ["./node_modules/biome-plugin-no-type-assertion/no-type-assertion.grit"] } diff --git a/examples/01-hello-world/index.ts b/examples/01-hello-world/index.ts index 816a7b8..b02d502 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 7d40401..91e8e1d 100644 --- a/examples/02-mcp-server/index.ts +++ b/examples/02-mcp-server/index.ts @@ -28,6 +28,12 @@ const browser = providerBrowser(localProvider()); const toolkit = createBrowserToolkit({ browser }); if (!toolkit.sessions) throw new Error("A toolkit created with a browser has session tools."); const sessions: SessionToolkit = 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) }], @@ -49,7 +55,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/package.json b/package.json index bddae41..827586f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@biomejs/biome": "2.5.14", "@types/node": "^22.19.0", + "biome-plugin-no-type-assertion": "1.1.1", "simple-git-hooks": "2.14.0", "typescript": "^5.9.3" } diff --git a/packages/mcp-server/src/app.ts b/packages/mcp-server/src/app.ts index 128aed4..88fa513 100644 --- a/packages/mcp-server/src/app.ts +++ b/packages/mcp-server/src/app.ts @@ -33,7 +33,7 @@ export interface AppDeps { } export interface App { - fetch: (request: Request, ...rest: unknown[]) => Response | Promise; + fetch: (request: Request) => Response | Promise; } export function createApp({ @@ -104,10 +104,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") - return c.json({ error: "cdpUrl is required." }, 400); + 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 } : {}), signal: c.req.raw.signal, @@ -243,9 +242,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 3e9c75e..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,14 +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 ee2f674..408943f 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 { type BrowserContext, connectBrowser, + LOAD_STATES, type LoadState, - type MouseButton, + MOUSE_BUTTONS, type Page, } from "./page.ts"; import { @@ -348,16 +350,16 @@ 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")) @@ -384,11 +386,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") { @@ -419,10 +421,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 }; @@ -453,11 +455,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 }), }); @@ -581,7 +583,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 }), @@ -687,6 +689,41 @@ 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)) { @@ -713,9 +750,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 3724ff5..c4c66eb 100644 --- a/packages/mcp-server/src/browse/page.ts +++ b/packages/mcp-server/src/browse/page.ts @@ -1,11 +1,14 @@ /** Direct CDP implementation: no browser extension, filesystem, or Node APIs. */ +import { z } from "zod"; import { resolveCdpUrl } from "./cdp.ts"; import type { FrameTarget, ResolvedSelector } from "./selectors.ts"; import { CdpConnection, type CdpPayload, type OpenSocket, openWebSocket } from "./transport.ts"; -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)); @@ -510,12 +513,12 @@ 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, @@ -618,7 +621,17 @@ interface FrameTreeNode { } /** CDP's `DOM.Quad`: four corners as x/y pairs, clockwise. */ -type Quad = [number, number, number, number, number, number, number, number]; +const quadSchema = z.tuple([ + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), +]); +type Quad = z.infer; /** Where a resolved element lives: its frame's session and a remote object. */ interface ElementHandle { @@ -820,10 +833,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 Quad[]).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 [x1, y1, x2, y2, x3, y3, x4, y4] = quad; const offset = await this.page.offsetOf(sessionId); @@ -835,6 +848,9 @@ export class Locator { } } +const screenshotSchema = z.object({ data: z.string() }); +const contentQuadsSchema = z.object({ quads: z.array(quadSchema) }); + function area([x1, y1, x2, y2, x3, y3, x4, y4]: Quad): number { return ( Math.abs(x1 * y2 - x2 * y1 + x2 * y3 - x3 * y2 + x3 * y4 - x4 * y3 + x4 * y1 - x1 * y4) / 2 diff --git a/packages/mcp-server/src/browse/providers.ts b/packages/mcp-server/src/browse/providers.ts index 03c1901..f6052b9 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 { type BrowserProvider, browserProvider, staticProvider } from "./provider.ts"; type Env = Record; @@ -34,7 +35,7 @@ export function providerFromEnv(env: Env, request: Fetch = fetch): BrowserProvid if (deleting && [404, 410].includes(response.status)) return null; 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 bfbc13d..edd3e4e 100644 --- a/packages/mcp-server/src/core.ts +++ b/packages/mcp-server/src/core.ts @@ -99,7 +99,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) { @@ -283,10 +283,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 47a05c0..d38753e 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 { createServer } from "node:net"; import { tmpdir } from "node:os"; 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; } @@ -150,8 +153,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 3d5191a..721a823 100644 --- a/packages/mcp-server/src/runtime-bundled.ts +++ b/packages/mcp-server/src/runtime-bundled.ts @@ -1,21 +1,13 @@ 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 { providerFromEnv } from "./browse/providers.ts"; import { loadConfig } from "./config.ts"; import { createProgramRunner } from "./sandbox/runner.ts"; +import { quickjsVariant } from "./sandbox/variant.ts"; const run = createProgramRunner(() => - newQuickJSWASMModuleFromVariant( - newVariant( - variant as unknown as Exclude< - Awaited[0]>, - { default: unknown } - >, - { wasmBinary }, - ), - ), + newQuickJSWASMModuleFromVariant(newVariant(quickjsVariant, { wasmBinary })), ); export function createConfiguredApp(env: Record) { diff --git a/packages/mcp-server/src/sandbox/runner.ts b/packages/mcp-server/src/sandbox/runner.ts index cb5bd4f..ca8dbb4 100644 --- a/packages/mcp-server/src/sandbox/runner.ts +++ b/packages/mcp-server/src/sandbox/runner.ts @@ -1,7 +1,5 @@ /** 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 QuickJSDeferredPromise, @@ -9,8 +7,10 @@ import { type QuickJSWASMModule, } from "quickjs-emscripten-core"; import { transform } from "sucrase"; +import { z } from "zod"; import type { CommandRunner } from "../browse/driver.ts"; import { type CommandCall, createHostFunctions } from "../browse/host-functions.ts"; +import { quickjsVariant } from "./variant.ts"; export interface RunLimits { timeoutMs?: number; maxBridgeRequests?: number; @@ -18,8 +18,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[]; } @@ -61,10 +65,7 @@ export interface RunProgramOptions { } export type LoadQuickJS = () => Promise; -const loadQuickJS: LoadQuickJS = () => - newQuickJSWASMModuleFromVariant( - variant as unknown as Parameters[0], - ); +const loadQuickJS: LoadQuickJS = () => newQuickJSWASMModuleFromVariant(quickjsVariant); export const runProgram = (options: RunProgramOptions) => createProgramRunner(loadQuickJS)(options); @@ -118,7 +119,7 @@ export function createProgramRunner(load: LoadQuickJS) { 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 args = argsSchema.parse(JSON.parse(vm.getString(argsHandle))); const deferred = vm.newPromise(); deferreds.add(deferred); const task = Promise.resolve() @@ -159,9 +160,9 @@ export function createProgramRunner(load: LoadQuickJS) { const json = vm.getString(argsHandle); 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..1436445 --- /dev/null +++ b/packages/mcp-server/src/sandbox/variant.ts @@ -0,0 +1,26 @@ +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 f939149..3c6adb3 100644 --- a/packages/mcp-server/src/worker.ts +++ b/packages/mcp-server/src/worker.ts @@ -1,4 +1,3 @@ -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"; @@ -7,17 +6,10 @@ import { providerFromEnv } from "./browse/providers.ts"; import type { OpenSocket } from "./browse/transport.ts"; import { loadConfig } from "./config.ts"; import { createProgramRunner } from "./sandbox/runner.ts"; +import { quickjsVariant } from "./sandbox/variant.ts"; const run = createProgramRunner(() => - newQuickJSWASMModuleFromVariant( - newVariant( - variant as unknown as Exclude< - Awaited[0]>, - { default: unknown } - >, - { wasmModule }, - ), - ), + newQuickJSWASMModuleFromVariant(newVariant(quickjsVariant, { wasmModule })), ); const openSocket: OpenSocket = async (url, signal) => { diff --git a/packages/mcp-server/tests/browser.e2e.test.ts b/packages/mcp-server/tests/browser.e2e.test.ts index 60ff8ac..65aec1f 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 { type ProgramResult, runProgram } from "../src/sandbox/runner.ts"; @@ -42,11 +43,13 @@ test("a program drives a real browser and returns only its result", { `); 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)); }); @@ -67,7 +70,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/"); }); @@ -91,7 +94,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 () => { @@ -102,7 +105,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 () => { @@ -117,6 +120,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 550aeb7..4580413 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 { type ProgramResult, runProgram } from "../src/sandbox/runner.ts"; @@ -58,7 +59,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]; diff --git a/packages/mcp-server/tests/helpers/hard-pages.ts b/packages/mcp-server/tests/helpers/hard-pages.ts index fefe935..86b51dd 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,10 @@ 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 4c1485a..90cf48b 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"; @@ -21,8 +22,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({ @@ -68,9 +69,13 @@ 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 () => { @@ -117,7 +122,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"); @@ -126,7 +131,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", ), ); @@ -160,11 +166,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() }); @@ -201,10 +207,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"); @@ -217,7 +222,7 @@ test("a provider supplies the browser and is released after the program", async const acquired: string[] = []; const released: Array = []; - const config = loadConfig({} as NodeJS.ProcessEnv); + const config = loadConfig({}); const app = createApp({ config, provider: { @@ -261,7 +266,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, + structuredSchema.parse(result.structuredContent).cdpUrl, "wss://cloud.example/session-1", ); assert.deepEqual(acquired, ["session-1"]); @@ -275,7 +280,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 11e4fff..7a3fe38 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 { cdpReady, createLocalBrowser } from "../src/node/local-browser.ts"; async function freePort(): Promise { @@ -62,7 +63,10 @@ test("deployed runtime executes a program and leaves borrowed Chrome alive", { } 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`))); const call = (code: string) => client.callTool({ @@ -80,7 +84,7 @@ test("deployed runtime executes a program and leaves borrowed Chrome alive", { 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" }, @@ -88,7 +92,7 @@ test("deployed runtime executes a program and leaves borrowed Chrome alive", { 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 8660680..9342328 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"; @@ -159,7 +160,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( @@ -183,7 +184,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/src/browser.ts b/packages/tools/src/browser.ts index 58b9918..57418d4 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 e2e96a8..86b28f2 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; @@ -154,14 +156,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, @@ -187,7 +182,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 @@ -257,7 +253,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 7d267e6..719fe8b 100644 --- a/packages/tools/src/mastra.ts +++ b/packages/tools/src/mastra.ts @@ -12,14 +12,13 @@ */ import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; import { type Browser, - type BrowserRunOutput, type BrowserToolkit, type BrowserToolsOptions, createBrowserToolkit, - type LiveView, liveViewModelOutput, type SessionToolkit, toModelOutput, @@ -27,6 +26,13 @@ import { export { type BrowserRunOutput, type BrowserToolsOptions, instructions } 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; @@ -56,7 +62,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)), }), }; } @@ -86,7 +92,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)), }), }; } diff --git a/packages/tools/tests/tools.test.ts b/packages/tools/tests/tools.test.ts index a6b3117..7355340 100644 --- a/packages/tools/tests/tools.test.ts +++ b/packages/tools/tests/tools.test.ts @@ -1,22 +1,37 @@ 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 { - type BrowserRunOutput, - type BrowserToolsOptions, - type LiveView, - providerBrowser, - type SessionSummary, -} from "../src/index.ts"; +import { type BrowserToolsOptions, providerBrowser } from "../src/index.ts"; import { browserTools as mastraTools } from "../src/mastra.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 +55,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); @@ -83,12 +98,12 @@ test("AI SDK: the model runs a program and reads the compact result text", async const toolResult = result.steps[0]?.toolResults[0]; assert.ok(toolResult); - const output = toolResult.output as BrowserRunOutput; + const output = runOutputSchema.parse(toolResult.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"/); }); @@ -101,10 +116,9 @@ test("AI SDK: a failed program is reported to the model as an error", async () = env: {}, }).browser_run; assert.ok(toModelOutput); - const output = (await execute( - { code: `await browse.back();` }, - { toolCallId: "1", messages: [], context: {} }, - )) as BrowserRunOutput; + const output = runOutputSchema.parse( + await execute({ code: `await browse.back();` }, { toolCallId: "1", messages: [], context: {} }), + ); assert.equal(output.isError, true); assert.match(output.text, /unexpected command back/); const modelOutput = await toModelOutput({ @@ -122,14 +136,16 @@ test("Mastra: tools share ids, descriptions, and execution", async () => { assert.match(tools.browser_run.description, /Evaluate a TypeScript program/); assert.ok(tools.browser_api.execute && tools.browser_run.execute); assert.match( - (await tools.browser_api.execute({}, {} as never)) as string, + 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); }); @@ -201,20 +217,19 @@ 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 start({}, options); - const first = (await run({ code: `return 1;`, sessionId: "s1" }, options)) as BrowserRunOutput; + const first = runOutputSchema.parse(await run({ code: `return 1;`, sessionId: "s1" }, options)); await run({ 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 run( - { code: `return 1;`, sessionId: "nope" }, - options, - )) as BrowserRunOutput; + const unknown = runOutputSchema.parse( + await run({ code: `return 1;`, sessionId: "nope" }, options), + ); assert.equal(unknown.isError, true); assert.match(unknown.text, /Unknown browser session: nope/); - const view = (await liveView.execute({ sessionId: "s1" }, options)) as LiveView; + const view = liveViewSchema.parse(await liveView.execute({ sessionId: "s1" }, options)); const viewOutput = await liveView.toModelOutput({ toolCallId: "1", input: { sessionId: "s1" }, @@ -224,9 +239,7 @@ test("with a Browser, the model starts a session, drives it by id, watches it, a assert.match(JSON.stringify(viewOutput), /"mediaType":"image\/jpeg".*"SlBFRw=="/); await stop({ sessionId: "s1" }, options); - const { sessions } = (await list({}, options)) as { - sessions: SessionSummary[]; - }; + const { sessions } = sessionListSchema.parse(await list({}, options)); assert.deepEqual( sessions.map((s) => s.id), ["s2"], @@ -249,7 +262,7 @@ test("with a Browser and no open session, browser_run uses a temporary one", asy }); const tools = mastraTools({ browser, connect, env: {} }); assert.ok(tools.browser_run.execute); - 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(), []); @@ -259,7 +272,15 @@ 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) }), + () => + aiSdkTools({ + cdpUrl: "ws://fake", + browser: providerBrowser({ + name: "unused", + create: async () => ({ provider: "unused", cdpUrl: "ws://unused" }), + shutdown: async () => {}, + }), + }), /not both/, ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddddb3c..c490655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@types/node': specifier: ^22.19.0 version: 22.20.4 + biome-plugin-no-type-assertion: + specifier: 1.1.1 + version: 1.1.1 simple-git-hooks: specifier: 2.14.0 version: 2.14.0 @@ -1678,6 +1681,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==} @@ -4494,6 +4500,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: