Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/console-telemetry-tool.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ---------- | --------------------------------------- | -------------------------- |
Expand All @@ -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

Expand Down
117 changes: 117 additions & 0 deletions src/consoleTelemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Listener[]>();

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);
});
});
152 changes: 152 additions & 0 deletions src/consoleTelemetry.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
8 changes: 8 additions & 0 deletions src/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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`,
);
Expand Down
25 changes: 23 additions & 2 deletions src/tools/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading