Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/lib/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,16 +46,16 @@ export async function openMcpSession(cdpUrl?: string): Promise<McpSession> {
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<string, unknown> },
{ 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");
Expand Down
37 changes: 30 additions & 7 deletions apps/web/src/routes/api.chat.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand All @@ -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 {
Expand Down
53 changes: 30 additions & 23 deletions apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof browserHandleSchema>;

const errorBodySchema = z.object({ error: z.string() });

async function responseError(response: Response): Promise<string> {
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() {
Expand All @@ -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");
}
Expand All @@ -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));
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-progress tool calls hidden

Medium Severity

toolPartSchema treats input and output as required, but AI SDK dynamic-tool parts omit those keys while a call is streaming or waiting (input-streaming / input-available). asToolCall then fails safeParse and returns null, so in-progress browser_run rows never render and the existing running state is unused until the call finishes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac13c2e. Configure here.

const record = parsed.data;
return {
name: record.toolName ?? record.type.replace(/^tool-/, ""),
state: record.state ?? "",
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@
"organizeImports": "on"
}
}
}
},
"plugins": ["./node_modules/biome-plugin-no-type-assertion/no-type-assertion.grit"]
}
8 changes: 4 additions & 4 deletions examples/01-hello-world/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`);
}
}
},
Expand Down
8 changes: 7 additions & 1 deletion examples/02-mcp-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }],
Expand All @@ -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 }) => {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
13 changes: 7 additions & 6 deletions packages/mcp-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface AppDeps {
}

export interface App {
fetch: (request: Request, ...rest: unknown[]) => Response | Promise<Response>;
fetch: (request: Request) => Response | Promise<Response>;
}

export function createApp({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 11 additions & 6 deletions packages/mcp-server/src/browse/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@
* 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;
}

/** 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";
}
}
Expand All @@ -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(
Expand Down
Loading
Loading