From 139eea16b64beb40c4e5251e5631b1d7c5989c4f Mon Sep 17 00:00:00 2001 From: Ritwij Aryan Parmar Date: Thu, 4 Jun 2026 11:37:00 -0400 Subject: [PATCH] feat: expose browser console telemetry --- .changeset/console-telemetry-tool.md | 5 + README.md | 17 ++- src/consoleTelemetry.test.ts | 117 +++++++++++++++++++++ src/consoleTelemetry.ts | 152 +++++++++++++++++++++++++++ src/sessionManager.ts | 8 ++ src/tools/__tests__/tools.test.ts | 25 ++++- src/tools/consoleLogs.ts | 73 +++++++++++++ src/tools/index.ts | 3 + src/types/types.ts | 2 + tests/smoke.test.ts | 3 +- 10 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 .changeset/console-telemetry-tool.md create mode 100644 src/consoleTelemetry.test.ts create mode 100644 src/consoleTelemetry.ts create mode 100644 src/tools/consoleLogs.ts diff --git a/.changeset/console-telemetry-tool.md b/.changeset/console-telemetry-tool.md new file mode 100644 index 00000000..a3ddcfc8 --- /dev/null +++ b/.changeset/console-telemetry-tool.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/mcp": minor +--- + +Add a `console_logs` debug tool that exposes browser console messages and page errors captured from the active session. diff --git a/README.md b/README.md index 90b7a109..caa030a1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This is a self-hostable version of the [Browserbase hosted MCP server](https://m ## Tools -This server exposes 6 tools that match the [hosted Browserbase MCP server](https://docs.browserbase.com/integrations/mcp/introduction): +This server exposes browser automation tools that match the [hosted Browserbase MCP server](https://docs.browserbase.com/integrations/mcp/introduction), plus a self-hosted debug tool for console telemetry: | Tool | Description | Input | | ---------- | --------------------------------------- | -------------------------- | @@ -20,6 +20,21 @@ This server exposes 6 tools that match the [hosted Browserbase MCP server](https | `act` | Perform an action on the page | `{ action: string }` | | `observe` | Observe actionable elements on the page | `{ instruction: string }` | | `extract` | Extract data from the page | `{ instruction?: string }` | +| `console_logs` | Read recent console messages and page errors from the active browser session | `{ limit?: number, types?: string[], clear?: boolean }` | + +### Console telemetry + +Self-hosted sessions capture browser `console` messages and `pageerror` events in a bounded in-memory buffer. Use `console_logs` after `navigate`, `act`, or `extract` to inspect client-side failures that would otherwise be hidden from the MCP client. + +Examples: + +```json +{ "limit": 20 } +``` + +```json +{ "types": ["error", "warning"], "clear": true } +``` ## How to Setup diff --git a/src/consoleTelemetry.test.ts b/src/consoleTelemetry.test.ts new file mode 100644 index 00000000..ee88a1cd --- /dev/null +++ b/src/consoleTelemetry.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { ConsoleTelemetry } from "./consoleTelemetry.js"; + +type Listener = (...args: unknown[]) => void; + +class FakePage { + listeners = new Map(); + + on(event: string, listener: Listener) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); + return this; + } + + off(event: string, listener: Listener) { + this.listeners.set( + event, + (this.listeners.get(event) ?? []).filter((item) => item !== listener), + ); + return this; + } + + emit(event: string, ...args: unknown[]) { + for (const listener of this.listeners.get(event) ?? []) { + listener(...args); + } + } +} + +function consoleMessage({ + type = "error", + text = "failed to load chunk", + url = "https://example.com/app.js", + lineNumber = 12, + columnNumber = 4, +} = {}) { + return { + type: () => type, + text: () => text, + location: () => ({ url, lineNumber, columnNumber }), + }; +} + +describe("ConsoleTelemetry", () => { + it("captures console messages and page errors with session context", () => { + const page = new FakePage(); + const telemetry = new ConsoleTelemetry({ + page: page as never, + sessionId: "mcp-session", + browserbaseSessionId: "bb-session", + }); + + telemetry.attach(); + page.emit("console", consoleMessage()); + page.emit("pageerror", new Error("hydration failed")); + + expect(telemetry.list()).toMatchObject([ + { + sessionId: "mcp-session", + browserbaseSessionId: "bb-session", + source: "console", + type: "error", + text: "failed to load chunk", + url: "https://example.com/app.js", + lineNumber: 12, + columnNumber: 4, + }, + { + sessionId: "mcp-session", + browserbaseSessionId: "bb-session", + source: "pageerror", + type: "error", + text: "hydration failed", + }, + ]); + }); + + it("filters by type and bounds retained entries", () => { + const page = new FakePage(); + const telemetry = new ConsoleTelemetry({ + page: page as never, + sessionId: "mcp-session", + browserbaseSessionId: "bb-session", + maxEntries: 2, + }); + + telemetry.attach(); + page.emit("console", consoleMessage({ type: "info", text: "first" })); + page.emit("console", consoleMessage({ type: "warning", text: "second" })); + page.emit("console", consoleMessage({ type: "error", text: "third" })); + + expect(telemetry.list().map((entry) => entry.text)).toEqual([ + "second", + "third", + ]); + expect(telemetry.list({ types: ["error"] })).toMatchObject([ + { type: "error", text: "third" }, + ]); + }); + + it("can clear and detach listeners", () => { + const page = new FakePage(); + const telemetry = new ConsoleTelemetry({ + page: page as never, + sessionId: "mcp-session", + browserbaseSessionId: "bb-session", + }); + telemetry.attach(); + telemetry.clear(); + telemetry.detach(); + page.emit("console", consoleMessage({ text: "after detach" })); + + expect(telemetry.list()).toEqual([]); + expect(page.listeners.get("console")).toHaveLength(0); + expect(page.listeners.get("pageerror")).toHaveLength(0); + }); +}); diff --git a/src/consoleTelemetry.ts b/src/consoleTelemetry.ts new file mode 100644 index 00000000..e66b94cd --- /dev/null +++ b/src/consoleTelemetry.ts @@ -0,0 +1,152 @@ +import type { Page } from "@browserbasehq/stagehand"; + +export type ConsoleTelemetryEntry = { + timestamp: string; + sessionId: string; + browserbaseSessionId: string; + source: "console" | "pageerror"; + type: string; + text: string; + url?: string; + lineNumber?: number; + columnNumber?: number; +}; + +type ConsoleMessageLike = { + type: () => string; + text: () => string; + location: () => { + url?: string; + lineNumber?: number; + columnNumber?: number; + }; +}; + +type PageWithTelemetryEvents = Page & { + on(event: "console", listener: (message: ConsoleMessageLike) => void): Page; + on(event: "pageerror", listener: (error: Error) => void): Page; + off?( + event: "console", + listener: (message: ConsoleMessageLike) => void, + ): Page; + off?(event: "pageerror", listener: (error: Error) => void): Page; + removeListener?( + event: "console", + listener: (message: ConsoleMessageLike) => void, + ): Page; + removeListener?(event: "pageerror", listener: (error: Error) => void): Page; +}; + +type PageEventRemover = { + (event: "console", listener: (message: ConsoleMessageLike) => void): Page; + (event: "pageerror", listener: (error: Error) => void): Page; +}; + +export class ConsoleTelemetry { + private entries: ConsoleTelemetryEntry[] = []; + private readonly page: PageWithTelemetryEvents; + private readonly maxEntries: number; + private readonly sessionId: string; + private readonly browserbaseSessionId: string; + private attached = false; + + private readonly consoleListener = (message: ConsoleMessageLike) => { + const location = message.location(); + this.push({ + source: "console", + type: message.type(), + text: message.text(), + url: location.url, + lineNumber: location.lineNumber, + columnNumber: location.columnNumber, + }); + }; + + private readonly pageErrorListener = (error: Error) => { + this.push({ + source: "pageerror", + type: "error", + text: error.message, + }); + }; + + constructor({ + page, + sessionId, + browserbaseSessionId, + maxEntries = 200, + }: { + page: Page; + sessionId: string; + browserbaseSessionId: string; + maxEntries?: number; + }) { + this.page = page as PageWithTelemetryEvents; + this.sessionId = sessionId; + this.browserbaseSessionId = browserbaseSessionId; + this.maxEntries = maxEntries; + } + + attach(): void { + if (this.attached) return; + this.page.on("console", this.consoleListener); + this.page.on("pageerror", this.pageErrorListener); + this.attached = true; + } + + detach(): void { + if (!this.attached) return; + const page = this.page as PageWithTelemetryEvents & { + off?: PageEventRemover; + removeListener?: PageEventRemover; + }; + + if (page.off) { + page.off("console", this.consoleListener); + page.off("pageerror", this.pageErrorListener); + } else if (page.removeListener) { + page.removeListener("console", this.consoleListener); + page.removeListener("pageerror", this.pageErrorListener); + } + this.attached = false; + } + + list({ + limit = 50, + types, + }: { + limit?: number; + types?: string[]; + } = {}): ConsoleTelemetryEntry[] { + const normalizedTypes = types?.map((type) => type.toLowerCase()); + const filtered = normalizedTypes?.length + ? this.entries.filter((entry) => + normalizedTypes.includes(entry.type.toLowerCase()), + ) + : this.entries; + + return filtered.slice(-limit); + } + + clear(): void { + this.entries = []; + } + + private push( + entry: Omit< + ConsoleTelemetryEntry, + "timestamp" | "sessionId" | "browserbaseSessionId" + >, + ): void { + this.entries.push({ + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + browserbaseSessionId: this.browserbaseSessionId, + ...entry, + }); + + if (this.entries.length > this.maxEntries) { + this.entries.splice(0, this.entries.length - this.maxEntries); + } + } +} diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 3b1f538b..6a693049 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -3,6 +3,7 @@ import type { Config } from "../config.d.ts"; import type { BrowserSession, CreateSessionParams } from "./types/types.js"; import { randomUUID } from "crypto"; +import { ConsoleTelemetry } from "./consoleTelemetry.js"; /** * Create a configured Stagehand instance @@ -189,7 +190,13 @@ export class SessionManager { page, sessionId: browserbaseSessionId, stagehand, + consoleTelemetry: new ConsoleTelemetry({ + page, + sessionId: newSessionId, + browserbaseSessionId, + }), }; + sessionObj.consoleTelemetry?.attach(); this.browsers.set(newSessionId, sessionObj); @@ -236,6 +243,7 @@ export class SessionManager { // Close Stagehand instance which handles browser cleanup if (session?.stagehand) { try { + session.consoleTelemetry?.detach(); process.stderr.write( `[SessionManager] Closing Stagehand for session: ${sessionIdToLog}\n`, ); diff --git a/src/tools/__tests__/tools.test.ts b/src/tools/__tests__/tools.test.ts index 9af1f4eb..c2005633 100644 --- a/src/tools/__tests__/tools.test.ts +++ b/src/tools/__tests__/tools.test.ts @@ -8,13 +8,14 @@ const EXPECTED_NAMES = [ "act", "observe", "extract", + "console_logs", ]; const tool = (name: string) => TOOLS.find((t) => t.schema.name === name)!; describe("TOOLS array", () => { - it("exports exactly 6 tools", () => { - expect(TOOLS).toHaveLength(6); + it("exports exactly 7 tools", () => { + expect(TOOLS).toHaveLength(7); }); it("has correct tool names matching hosted MCP", () => { @@ -98,6 +99,26 @@ describe("tool input schemas", () => { .success, ).toBe(true); }); + + it("console_logs accepts bounded debug filters", () => { + expect(tool("console_logs").schema.inputSchema.safeParse({}).success).toBe( + true, + ); + expect( + tool("console_logs").schema.inputSchema.safeParse({ limit: 25 }).success, + ).toBe(true); + expect( + tool("console_logs").schema.inputSchema.safeParse({ + limit: 201, + }).success, + ).toBe(false); + expect( + tool("console_logs").schema.inputSchema.safeParse({ + types: ["error", "warning"], + clear: true, + }).success, + ).toBe(true); + }); }); describe("tool capabilities", () => { diff --git a/src/tools/consoleLogs.ts b/src/tools/consoleLogs.ts new file mode 100644 index 00000000..46050ef7 --- /dev/null +++ b/src/tools/consoleLogs.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { Tool, ToolSchema, ToolResult } from "./tool.js"; +import type { Context } from "../context.js"; +import type { ToolActionResult } from "../types/types.js"; + +const ConsoleLogsInputSchema = z.object({ + limit: z.number().int().min(1).max(200).optional(), + types: z.array(z.string().min(1)).optional(), + clear: z.boolean().optional(), +}); + +type ConsoleLogsInput = z.infer; + +const consoleLogsSchema: ToolSchema = { + name: "console_logs", + description: + "Read recent browser console messages and page errors from the active session", + inputSchema: ConsoleLogsInputSchema, +}; + +async function handleConsoleLogs( + context: Context, + params: ConsoleLogsInput, +): Promise { + const action = async (): Promise => { + const session = await context + .getSessionManager() + .getSession(context.currentSessionId, context.config, false); + + if (!session) { + throw new Error("No active Browserbase session. Call start first."); + } + + const logs = session.consoleTelemetry?.list({ + limit: params.limit, + types: params.types, + }); + + if (params.clear) { + session.consoleTelemetry?.clear(); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + success: true, + data: { + sessionId: session.sessionId, + count: logs?.length ?? 0, + logs: logs ?? [], + cleared: params.clear ?? false, + }, + }), + }, + ], + }; + }; + + return { + action, + waitForNetwork: false, + }; +} + +const consoleLogsTool: Tool = { + capability: "core", + schema: consoleLogsSchema, + handle: handleConsoleLogs, +}; + +export default consoleLogsTool; diff --git a/src/tools/index.ts b/src/tools/index.ts index 901267fd..3f907bfb 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,6 +2,7 @@ import navigateTool from "./navigate.js"; import actTool from "./act.js"; import extractTool from "./extract.js"; import observeTool from "./observe.js"; +import consoleLogsTool from "./consoleLogs.js"; import sessionTools from "./session.js"; // Export individual tools @@ -9,6 +10,7 @@ export { default as navigateTool } from "./navigate.js"; export { default as actTool } from "./act.js"; export { default as extractTool } from "./extract.js"; export { default as observeTool } from "./observe.js"; +export { default as consoleLogsTool } from "./consoleLogs.js"; export { default as sessionTools } from "./session.js"; // Export all tools as array — matches hosted MCP server at mcp.browserbase.com @@ -18,6 +20,7 @@ export const TOOLS = [ actTool, observeTool, extractTool, + consoleLogsTool, ]; export const sessionManagementTools = sessionTools; diff --git a/src/types/types.ts b/src/types/types.ts index 7b634f7e..f6eda670 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -2,6 +2,7 @@ import type { Stagehand, Page } from "@browserbasehq/stagehand"; import { ImageContent, TextContent } from "@modelcontextprotocol/sdk/types.js"; import { Tool } from "../tools/tool.js"; import { InputType } from "../tools/tool.js"; +import type { ConsoleTelemetry } from "../consoleTelemetry.js"; export type StagehandSession = { id: string; // MCP-side ID @@ -25,6 +26,7 @@ export type BrowserSession = { page: Page; sessionId: string; stagehand: Stagehand; + consoleTelemetry?: ConsoleTelemetry; }; export type ToolActionResult = diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 1dc70c88..6f9551d8 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -9,6 +9,7 @@ const EXPECTED_TOOLS = [ "act", "observe", "extract", + "console_logs", ]; describe("MCP server smoke test", () => { @@ -30,7 +31,7 @@ describe("MCP server smoke test", () => { await client.connect(transport); const { tools } = await client.listTools(); - expect(tools).toHaveLength(6); + expect(tools).toHaveLength(7); const names = tools.map((t) => t.name).sort(); expect(names).toEqual([...EXPECTED_TOOLS].sort());