# Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .dockerignore ├── .gitattributes ├── .github │ └── workflows │ ├── ci.yml │ └── publish-mcp.yml ├── .gitignore ├── .husky │ └── pre-commit ├── .npmrc ├── assets │ ├── browserbase-mcp.png │ ├── cover.png │ └── smithery.jpg ├── CHANGELOG.md ├── cli.js ├── config.d.ts ├── Dockerfile ├── eslint.config.js ├── evals │ ├── mcp-eval-basic.config.json │ ├── mcp-eval-minimal.config.json │ ├── mcp-eval.config.json │ └── run-evals.ts ├── index.d.ts ├── index.js ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.md ├── server.json ├── smithery.config.js ├── smithery.yaml ├── src │ ├── config.ts │ ├── context.ts │ ├── index.ts │ ├── mcp │ │ ├── resources.ts │ │ └── sampling.ts │ ├── program.ts │ ├── server.ts │ ├── sessionManager.ts │ ├── tools │ │ ├── act.ts │ │ ├── extract.ts │ │ ├── index.ts │ │ ├── navigate.ts │ │ ├── observe.ts │ │ ├── screenshot.ts │ │ ├── session.ts │ │ ├── tool.ts │ │ └── url.ts │ ├── transport.ts │ └── types │ └── types.ts ├── tests │ └── .gitkeep └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- ``` ``` -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- ``` package-lock.json linguist-generated=true ``` -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- ``` # Use npm for package management engine-strict=true ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ # Build output dist/ # Tests tests/ evals/ # Git .git/ .gitignore # Documentation *.md !README.md assets/ # CI/CD .github/ # IDE .vscode/ .idea/ # Logs *.log npm-debug.log* # Environment .env .env.local # Misc .DS_Store *.swp *.swo *~ ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* build/ gcp-oauth.keys.json .*-server-credentials.json # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ .DS_Store # Smithery /.smithery # MCP Registry https://github.com/modelcontextprotocol/registry .mcpregistry_github_token .mcpregistry_registry_token ``` -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- ```markdown # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Browserbase MCP Server [](https://smithery.ai/server/@browserbasehq/mcp-browserbase)  [The Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) is an open protocol that enables seamless integration between LLM applications and external data sources and tools. Whether you're building an AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to connect LLMs with the context they need. This server provides cloud browser automation capabilities using [Browserbase](https://www.browserbase.com/) and [Stagehand](https://github.com/browserbase/stagehand). It enables LLMs to interact with web pages, take screenshots, extract information, and perform automated actions with atomic precision. ## Features | Feature | Description | | ------------------ | ----------------------------------------------------------- | | Browser Automation | Control and orchestrate cloud browsers via Browserbase | | Data Extraction | Extract structured data from any webpage | | Web Interaction | Navigate, click, and fill forms with ease | | Screenshots | Capture full-page and element screenshots | | Model Flexibility | Supports multiple models (OpenAI, Claude, Gemini, and more) | | Vision Support | Use annotated screenshots for complex DOMs | | Session Management | Create, manage, and close browser sessions | ## How to Setup ### Quickstarts: #### Add to Cursor Copy and Paste this link in your Browser: ```text cursor://anysphere.cursor-deeplink/mcp/install?name=browserbase&config=eyJjb21tYW5kIjoibnB4IEBicm93c2VyYmFzZWhxL21jcCIsImVudiI6eyJCUk9XU0VSQkFTRV9BUElfS0VZIjoiIiwiQlJPV1NFUkJBU0VfUFJPSkVDVF9JRCI6IiIsIkdFTUlOSV9BUElfS0VZIjoiIn19 ``` We currently support 2 transports for our MCP server, STDIO and SHTTP. We recommend you use SHTTP with our remote hosted url to take advantage of the server at full capacity. ## SHTTP: To use the Browserbase MCP Server through our remote hosted URL, add the following to your configuration. Go to [smithery.ai](https://smithery.ai/server/@browserbasehq/mcp-browserbase) and enter your API keys and configuration to get a remote hosted URL. When using our remote hosted server, we provide the LLM costs for Gemini, the [best performing model](https://www.stagehand.dev/evals) in [Stagehand](https://www.stagehand.dev).  If your client supports SHTTP: ```json { "mcpServers": { "browserbase": { "url": "your-smithery-url.com" } } } ``` If your client doesn't support SHTTP: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": ["mcp-remote", "your-smithery-url.com"] } } } ``` ## STDIO: You can either use our Server hosted on NPM or run it completely locally by cloning this repo. > **❗️ Important:** If you want to use a different model you have to add --modelName to the args and provide that respective key as an arg. More info below. ### To run on NPM (Recommended) Go into your MCP Config JSON and add the Browserbase Server: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": ["@browserbasehq/mcp-server-browserbase"], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` That's it! Reload your MCP client and Claude will be able to use Browserbase. ### To run 100% local: #### Option 1: Direct installation ```bash # Clone the Repo git clone https://github.com/browserbase/mcp-server-browserbase.git cd mcp-server-browserbase # Install the dependencies and build the project npm install && npm run build ``` #### Option 2: Docker ```bash # Clone the Repo git clone https://github.com/browserbase/mcp-server-browserbase.git cd mcp-server-browserbase # Build the Docker image docker build -t mcp-browserbase . ``` Then in your MCP Config JSON run the server. To run locally we can use STDIO or self-host SHTTP. ### STDIO: #### Using Direct Installation To your MCP Config JSON file add the following: ```json { "mcpServers": { "browserbase": { "command": "node", "args": ["/path/to/mcp-server-browserbase/cli.js"], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` #### Using Docker To your MCP Config JSON file add the following: ```json { "mcpServers": { "browserbase": { "command": "docker", "args": [ "run", "--rm", "-i", "-e", "BROWSERBASE_API_KEY", "-e", "BROWSERBASE_PROJECT_ID", "-e", "GEMINI_API_KEY", "mcp-browserbase" ], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` Then reload your MCP client and you should be good to go! ## Configuration The Browserbase MCP server accepts the following command-line flags: | Flag | Description | | -------------------------- | --------------------------------------------------------------------------- | | `--proxies` | Enable Browserbase proxies for the session | | `--advancedStealth` | Enable Browserbase Advanced Stealth (Only for Scale Plan Users) | | `--keepAlive` | Enable Browserbase Keep Alive Session | | `--contextId <contextId>` | Specify a Browserbase Context ID to use | | `--persist` | Whether to persist the Browserbase context (default: true) | | `--port <port>` | Port to listen on for HTTP/SHTTP transport | | `--host <host>` | Host to bind server to (default: localhost, use 0.0.0.0 for all interfaces) | | `--cookies [json]` | JSON array of cookies to inject into the browser | | `--browserWidth <width>` | Browser viewport width (default: 1024) | | `--browserHeight <height>` | Browser viewport height (default: 768) | | `--modelName <model>` | The model to use for Stagehand (default: gemini-2.0-flash) | | `--modelApiKey <key>` | API key for the custom model provider (required when using custom models) | | `--experimental` | Enable experimental features (default: false) | These flags can be passed directly to the CLI or configured in your MCP configuration file. ### NOTE: Currently, these flags can only be used with the local server (npx @browserbasehq/mcp-server-browserbase or Docker). ### Using Configuration Flags with Docker When using Docker, you can pass configuration flags as additional arguments after the image name. Here's an example with the `--proxies` flag: ```json { "mcpServers": { "browserbase": { "command": "docker", "args": [ "run", "--rm", "-i", "-e", "BROWSERBASE_API_KEY", "-e", "BROWSERBASE_PROJECT_ID", "-e", "GEMINI_API_KEY", "mcp-browserbase", "--proxies" ], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` You can also run the Docker container directly from the command line: ```bash docker run --rm -i \ -e BROWSERBASE_API_KEY=your_api_key \ -e BROWSERBASE_PROJECT_ID=your_project_id \ -e GEMINI_API_KEY=your_gemini_key \ mcp-browserbase --proxies ``` ## Configuration Examples ### Proxies Here are our docs on [Proxies](https://docs.browserbase.com/features/proxies). To use proxies, set the --proxies flag in your MCP Config: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": ["@browserbasehq/mcp-server-browserbase", "--proxies"], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` ### Advanced Stealth Here are our docs on [Advanced Stealth](https://docs.browserbase.com/features/stealth-mode#advanced-stealth-mode). To use advanced stealth, set the --advancedStealth flag in your MCP Config: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": ["@browserbasehq/mcp-server-browserbase", "--advancedStealth"], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` ### Contexts Here are our docs on [Contexts](https://docs.browserbase.com/features/contexts) To use contexts, set the --contextId flag in your MCP Config: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": [ "@browserbasehq/mcp-server-browserbase", "--contextId", "<YOUR_CONTEXT_ID>" ], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` ### Browser Viewport Sizing The default viewport sizing for a browser session is 1024 x 768. You can adjust the Browser viewport sizing with browserWidth and browserHeight flags. Here's how to use it for custom browser sizing. We recommend to stick with 16:9 aspect ratios (ie: 1920 x 1080, 1280 x 720, 1024 x 768) ```json { "mcpServers": { "browserbase": { "command": "npx", "args": [ "@browserbasehq/mcp-server-browserbase", "--browserHeight 1080", "--browserWidth 1920" ], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "", "GEMINI_API_KEY": "" } } } } ``` ### Model Configuration Stagehand defaults to using Google's Gemini 2.0 Flash model, but you can configure it to use other models like GPT-4o, Claude, or other providers. **Important**: When using any custom model (non-default), you must provide your own API key for that model provider using the `--modelApiKey` flag. Here's how to configure different models: ```json { "mcpServers": { "browserbase": { "command": "npx", "args": [ "@browserbasehq/mcp-server-browserbase", "--modelName", "anthropic/claude-3-5-sonnet-latest", "--modelApiKey", "your-anthropic-api-key" ], "env": { "BROWSERBASE_API_KEY": "", "BROWSERBASE_PROJECT_ID": "" } } } } ``` _Note: The model must be supported in Stagehand. Check out the docs [here](https://docs.stagehand.dev/examples/custom_llms#supported-llms). When using any custom model, you must provide your own API key for that provider._ ### Resources The server provides access to screenshot resources: 1. **Screenshots** (`screenshot://<screenshot-name>`) - PNG images of captured screenshots ## Key Features - **AI-Powered Automation**: Natural language commands for web interactions - **Multi-Model Support**: Works with OpenAI, Claude, Gemini, and more - **Screenshot Capture**: Full-page and element-specific screenshots - **Data Extraction**: Intelligent content extraction from web pages - **Proxy Support**: Enterprise-grade proxy capabilities - **Stealth Mode**: Advanced anti-detection features - **Context Persistence**: Maintain authentication and state across sessions For more information about the Model Context Protocol, visit: - [MCP Documentation](https://modelcontextprotocol.io/docs) - [MCP Specification](https://spec.modelcontextprotocol.io/) For the official MCP Docs: - [Browserbase MCP](https://docs.browserbase.com/integrations/mcp/introduction) ## License Licensed under the Apache 2.0 License. Copyright 2025 Browserbase, Inc. ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml runtime: "typescript" ``` -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import "./dist/program.js"; ``` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- ```javascript import { createServer } from "./dist/index.js"; export default { createServer }; ``` -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- ```typescript import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { Config } from "./config"; export declare function createServer(config?: Config): Promise<Server>; export {}; ``` -------------------------------------------------------------------------------- /smithery.config.js: -------------------------------------------------------------------------------- ```javascript /** * @type {import('esbuild').BuildOptions} */ export default { esbuild: { // Mark playwright-core as external to prevent bundling // This avoids the relative path resolution issue in Docker external: ["playwright-core"], }, }; ``` -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "dist", "rootDir": "src", "noErrorTruncation": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown # @browserbasehq/mcp-server-browserbase ## 2.2.0 ### Minor Changes - Remove multisession tools, remove prompts sampling, simplify tool descriptions for better context, add support if google apikey set, latest version of stagehand, remove custom availmodelschema to use stagehand model type instead. ## 2.1.3 ### Patch Changes - Adding docker deployment support ## 2.1.2 ### Patch Changes - fixing screenshot map behavior ## 2.1.1 ### Patch Changes - adding MCP server to official registry ## 2.1.0 ### Minor Changes - adding changesets, MCP UI for session create ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:22-alpine AS builder RUN corepack enable WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --ignore-scripts COPY . . RUN pnpm run build && \ pnpm prune --prod --ignore-scripts FROM gcr.io/distroless/nodejs22-debian12 LABEL io.modelcontextprotocol.server.name="io.github.browserbase/mcp-server-browserbase" WORKDIR /app COPY --from=builder /app/package.json ./ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/cli.js ./cli.js COPY --from=builder /app/index.js ./index.js CMD ["cli.js"] ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; export class ServerList { private _servers: Server[] = []; private _serverFactory: () => Promise<Server>; constructor(serverFactory: () => Promise<Server>) { this._serverFactory = serverFactory; } async create() { const server = await this._serverFactory(); this._servers.push(server); return server; } async close(server: Server) { await server.close(); const index = this._servers.indexOf(server); if (index !== -1) this._servers.splice(index, 1); } async closeAll() { await Promise.all(this._servers.map((server) => server.close())); } } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import js from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; import { defineConfig } from "eslint/config"; export default defineConfig([ { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], ignores: ["dist/**/*"], }, { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], languageOptions: { globals: { ...globals.browser, ...globals.node } }, ignores: ["dist/**/*"], }, ...tseslint.configs.recommended, { files: ["src/types/**/*.ts", "src/mcp/**/*.ts"], rules: { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/ban-ts-comment": "off", }, }, { ignores: ["dist/**/*", "node_modules/**/*", ".smithery/**/*"], }, ]); ``` -------------------------------------------------------------------------------- /src/tools/tool.ts: -------------------------------------------------------------------------------- ```typescript import type { ImageContent, TextContent, } from "@modelcontextprotocol/sdk/types.js"; import type { z } from "zod"; import type { Context } from "../context.js"; export type ToolSchema<Input extends InputType> = { name: string; description: string; inputSchema: Input; }; // Export InputType export type InputType = z.Schema; export type ToolActionResult = | { content?: (ImageContent | TextContent)[] } | undefined | void; export type ToolResult = { action?: () => Promise<ToolActionResult>; waitForNetwork: boolean; }; export type Tool<Input extends InputType = InputType> = { capability: string; schema: ToolSchema<Input>; handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>; }; export function defineTool<Input extends InputType>( tool: Tool<Input>, ): Tool<Input> { return tool; } export {}; // Ensure this is treated as a module ``` -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- ```typescript import navigateTool from "./navigate.js"; import actTool from "./act.js"; import extractTool from "./extract.js"; import observeTool from "./observe.js"; import screenshotTool from "./screenshot.js"; import sessionTools from "./session.js"; import getUrlTool from "./url.js"; // Export individual tools 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 screenshotTool } from "./screenshot.js"; export { default as sessionTools } from "./session.js"; export { default as getUrlTool } from "./url.js"; // Export all tools as array export const TOOLS = [ ...sessionTools, navigateTool, actTool, extractTool, observeTool, screenshotTool, getUrlTool, ]; export const sessionManagementTools = sessionTools; ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: pull_request: branches: [main] push: branches: [main] jobs: test: name: Test and Lint runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "22" cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run linting run: pnpm lint - name: Check formatting run: pnpm format - name: Build project run: pnpm build - name: Run evaluation tests env: BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }} BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: | pnpm evals ``` -------------------------------------------------------------------------------- /src/mcp/sampling.ts: -------------------------------------------------------------------------------- ```typescript /** * Sampling module for the Browserbase MCP server * Implements sampling capability to request LLM completions from clients * Docs: https://modelcontextprotocol.io/docs/concepts/sampling */ /** * Sampling capability configuration * This indicates that the server can request LLM completions */ export const SAMPLING_CAPABILITY = {}; /** * Note: Sampling in MCP is initiated BY the server TO the client. * The server sends sampling/createMessage requests to ask the client * for LLM completions. This is useful for intelligent browser automation * where the server needs AI assistance to analyze pages and make decisions. * * Currently, sampling support depends on the MCP client implementation. * Not all clients support sampling yet. (ie claude desktop) */ /** * Type definitions for sampling messages */ export type SamplingMessage = { role: "user" | "assistant"; content: { type: "text" | "image"; text?: string; data?: string; // base64 for images mimeType?: string; }; }; ``` -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- ```typescript import type { Stagehand, Browser, Page } from "@browserbasehq/stagehand"; import { ImageContent, TextContent } from "@modelcontextprotocol/sdk/types.js"; import { Tool } from "../tools/tool.js"; import { InputType } from "../tools/tool.js"; export type StagehandSession = { id: string; // MCP-side ID stagehand: Stagehand; // owns the Browserbase session page: Page; browser: Browser; created: number; metadata?: Record<string, any>; // optional extras (proxy, contextId, bbSessionId) }; export type CreateSessionParams = { apiKey?: string; projectId?: string; modelName?: string; modelApiKey?: string; browserbaseSessionID?: string; browserbaseSessionCreateParams?: any; meta?: Record<string, any>; }; export type BrowserSession = { browser: Browser; page: Page; sessionId: string; stagehand: Stagehand; }; export type ToolActionResult = | { content?: (ImageContent | TextContent)[] } | undefined | void; // Type for the tools array used in MCP server registration export type MCPTool = Tool<InputType>; export type MCPToolsArray = MCPTool[]; ``` -------------------------------------------------------------------------------- /.github/workflows/publish-mcp.yml: -------------------------------------------------------------------------------- ```yaml name: Publish to MCP Registry on: push: tags: ["v*"] jobs: publish: runs-on: ubuntu-latest permissions: id-token: write # Required for OIDC authentication contents: read steps: - name: Checkout code uses: actions/checkout@v5 - name: Setup pnpm uses: pnpm/action-setup@v3 with: version: 10.12.4 - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: "lts/*" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test --if-present - name: Build package run: pnpm build --if-present - name: Publish to npm run: pnpm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Install MCP Publisher run: | curl -L "https://github.com/modelcontextprotocol/registry/releases/download/latest/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher - name: Login to MCP Registry run: ./mcp-publisher login github-oidc - name: Publish to MCP Registry run: ./mcp-publisher publish ``` -------------------------------------------------------------------------------- /src/tools/url.ts: -------------------------------------------------------------------------------- ```typescript 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"; /** * Stagehand Get URL * * This tool is used to get the current URL of the browser page. */ // Empty schema since getting URL doesn't require any input const GetUrlInputSchema = z.object({}); type GetUrlInput = z.infer<typeof GetUrlInputSchema>; const getUrlSchema: ToolSchema<typeof GetUrlInputSchema> = { name: "browserbase_stagehand_get_url", description: "Return the current page URL (full URL with query/fragment).", inputSchema: GetUrlInputSchema, }; async function handleGetUrl( context: Context, // eslint-disable-next-line @typescript-eslint/no-unused-vars params: GetUrlInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const stagehand = await context.getStagehand(); // Get the current URL from the Playwright page const currentUrl = stagehand.page.url(); return { content: [ { type: "text", text: currentUrl, }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get current URL: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const getUrlTool: Tool<typeof GetUrlInputSchema> = { capability: "core", schema: getUrlSchema, handle: handleGetUrl, }; export default getUrlTool; ``` -------------------------------------------------------------------------------- /src/tools/navigate.ts: -------------------------------------------------------------------------------- ```typescript 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 NavigateInputSchema = z.object({ url: z.string().describe("The URL to navigate to"), }); type NavigateInput = z.infer<typeof NavigateInputSchema>; const navigateSchema: ToolSchema<typeof NavigateInputSchema> = { name: "browserbase_stagehand_navigate", description: `Navigate to a URL in the browser. Only use this tool with URLs you're confident will work and be up to date. Otherwise, use https://google.com as the starting point`, inputSchema: NavigateInputSchema, }; async function handleNavigate( context: Context, params: NavigateInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const stagehand = await context.getStagehand(); const page = await context.getActivePage(); if (!page) { throw new Error("No active page available"); } await page.goto(params.url, { waitUntil: "domcontentloaded" }); const sessionId = stagehand.browserbaseSessionID; if (!sessionId) { throw new Error("No Browserbase session ID available"); } return { content: [ { type: "text", text: `Navigated to: ${params.url}`, }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to navigate: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const navigateTool: Tool<typeof NavigateInputSchema> = { capability: "core", schema: navigateSchema, handle: handleNavigate, }; export default navigateTool; ``` -------------------------------------------------------------------------------- /evals/mcp-eval-minimal.config.json: -------------------------------------------------------------------------------- ```json { "passThreshold": 0.7, "server": { "transport": "stdio", "command": "node", "args": ["./cli.js"], "env": { "BROWSERBASE_API_KEY": "${BROWSERBASE_API_KEY}", "BROWSERBASE_PROJECT_ID": "${BROWSERBASE_PROJECT_ID}", "GEMINI_API_KEY": "${GEMINI_API_KEY}" } }, "timeout": 60000, "llmJudge": false, "workflows": [ { "name": "smoke-test-navigation", "description": "Quick test to verify basic navigation works", "steps": [ { "user": "Open a browser and go to example.org", "expectedState": "session created" }, { "user": "Close the browser", "expectedState": "closed successfully via Stagehand" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_session_close" ] }, { "name": "smoke-test-extraction", "description": "Quick test to verify data extraction works", "steps": [ { "user": "Navigate to example.org and extract the page title", "expectedState": "Example Domain" }, { "user": "Close the session", "expectedState": "closed successfully via Stagehand" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_extract", "browserbase_session_close" ] }, { "name": "smoke-test-url-tools", "description": "Quick test to verify URL retrieval tools work", "steps": [ { "user": "Create a browser session, navigate to example.org, get the current URL, and close the session", "expectedState": "example.org" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_get_url", "browserbase_session_close" ] } ] } ``` -------------------------------------------------------------------------------- /src/tools/act.ts: -------------------------------------------------------------------------------- ```typescript 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"; /** * Stagehand Act * Docs: https://docs.stagehand.dev/basics/act * * This tool is used to perform actions on a web page. */ const ActInputSchema = z.object({ action: z.string().describe( `The action to perform. Should be as atomic and specific as possible, i.e. 'Click the sign in button' or 'Type 'hello' into the search input'.`, ), variables: z .object({}) .optional() .describe( `Variables used in the action template. ONLY use variables if you're dealing with sensitive data or dynamic content. When using variables, you MUST have the variable key in the action template. ie: {"action": "Fill in the password", "variables": {"password": "123456"}}`, ), }); type ActInput = z.infer<typeof ActInputSchema>; const actSchema: ToolSchema<typeof ActInputSchema> = { name: "browserbase_stagehand_act", description: `Perform a single action on the page (e.g., click, type).`, inputSchema: ActInputSchema, }; async function handleAct( context: Context, params: ActInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const stagehand = await context.getStagehand(); await stagehand.page.act({ action: params.action, variables: params.variables, }); return { content: [ { type: "text", text: `Action performed: ${params.action}`, }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to perform action: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const actTool: Tool<typeof ActInputSchema> = { capability: "core", schema: actSchema, handle: handleAct, }; export default actTool; ``` -------------------------------------------------------------------------------- /src/tools/extract.ts: -------------------------------------------------------------------------------- ```typescript 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"; /** * Stagehand Extract * Docs: https://docs.stagehand.dev/basics/extract * * This tool is used to extract structured information and text content from a web page. * * We currently don't support the client providing a zod schema for the extraction. */ const ExtractInputSchema = z.object({ instruction: z.string().describe( `The specific instruction for what information to extract from the current page. Be as detailed and specific as possible about what you want to extract. For example: 'Extract all product names and prices from the listing page'.The more specific your instruction, the better the extraction results will be.`, ), }); type ExtractInput = z.infer<typeof ExtractInputSchema>; const extractSchema: ToolSchema<typeof ExtractInputSchema> = { name: "browserbase_stagehand_extract", description: `Extract structured data or text from the current page using an instruction.`, inputSchema: ExtractInputSchema, }; async function handleExtract( context: Context, params: ExtractInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const stagehand = await context.getStagehand(); const extraction = await stagehand.page.extract(params.instruction); return { content: [ { type: "text", text: `Extracted content:\n${JSON.stringify(extraction, null, 2)}`, }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to extract content: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const extractTool: Tool<typeof ExtractInputSchema> = { capability: "core", schema: extractSchema, handle: handleExtract, }; export default extractTool; ``` -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", "name": "io.github.browserbase/mcp-server-browserbase", "description": "MCP server for AI web browser automation using Browserbase and Stagehand", "status": "active", "repository": { "url": "https://github.com/browserbase/mcp-server-browserbase", "source": "github" }, "version": "2.2.0", "packages": [ { "registry_type": "npm", "registry_base_url": "https://registry.npmjs.org", "identifier": "@browserbasehq/mcp-server-browserbase", "version": "2.2.0", "transport": { "type": "stdio" }, "environment_variables": [ { "description": "Your Browserbase API key", "is_required": true, "format": "string", "is_secret": true, "name": "BROWSERBASE_API_KEY" }, { "description": "Your Browserbase Project ID", "is_required": true, "format": "string", "is_secret": false, "name": "BROWSERBASE_PROJECT_ID" }, { "description": "Your Gemini API key (default model)", "is_required": true, "format": "string", "is_secret": true, "name": "GEMINI_API_KEY" } ] }, { "registry_type": "oci", "identifier": "browserbasehq/mcp-server-browserbase", "version": "2.2.0", "runtime_hint": "docker", "environment_variables": [ { "description": "Your Browserbase API key", "is_required": true, "format": "string", "is_secret": true, "name": "BROWSERBASE_API_KEY" }, { "description": "Your Browserbase Project ID", "is_required": true, "format": "string", "is_secret": false, "name": "BROWSERBASE_PROJECT_ID" }, { "description": "Your Gemini API key (default model)", "is_required": true, "format": "string", "is_secret": true, "name": "GEMINI_API_KEY" } ], "transport": { "type": "stdio" } } ] } ``` -------------------------------------------------------------------------------- /src/mcp/resources.ts: -------------------------------------------------------------------------------- ```typescript /** * Resources module for the Browserbase MCP server * Contains resources definitions and handlers for resource-related requests * Docs: https://modelcontextprotocol.io/docs/concepts/resources */ // Define the resources export const RESOURCES = []; // Define the resource templates export const RESOURCE_TEMPLATES = []; // Store screenshots in a map export const screenshots = new Map<string, string>(); // Track screenshots by session so we can purge them on session end // key: sessionId (internal/current session id), value: set of screenshot names const sessionIdToScreenshotNames = new Map<string, Set<string>>(); export function registerScreenshot( sessionId: string, name: string, base64: string, ) { screenshots.set(name, base64); let set = sessionIdToScreenshotNames.get(sessionId); if (!set) { set = new Set(); sessionIdToScreenshotNames.set(sessionId, set); } set.add(name); } export function clearScreenshotsForSession(sessionId: string) { const set = sessionIdToScreenshotNames.get(sessionId); if (set) { for (const name of set) { screenshots.delete(name); } sessionIdToScreenshotNames.delete(sessionId); } } export function clearAllScreenshots() { screenshots.clear(); sessionIdToScreenshotNames.clear(); } /** * Handle listing resources request * @returns A list of available resources including screenshots */ export function listResources() { return { resources: [ ...Array.from(screenshots.keys()).map((name) => ({ uri: `screenshot://${name}`, mimeType: "image/png", name: `Screenshot: ${name}`, })), ], }; } /** * Handle listing resource templates request * @returns An empty resource templates list response */ export function listResourceTemplates() { return { resourceTemplates: [] }; } /** * Read a resource by its URI * @param uri The URI of the resource to read * @returns The resource content or throws if not found */ export function readResource(uri: string) { if (uri.startsWith("screenshot://")) { const name = uri.split("://")[1]; const screenshot = screenshots.get(name); if (screenshot) { return { contents: [ { uri, mimeType: "image/png", blob: screenshot, }, ], }; } } throw new Error(`Resource not found: ${uri}`); } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@browserbasehq/mcp-server-browserbase", "version": "2.2.0", "description": "MCP server for AI web browser automation using Browserbase and Stagehand", "mcpName": "io.github.browserbase/mcp-server-browserbase", "license": "Apache-2.0", "author": "Browserbase, Inc. (https://www.browserbase.com/)", "homepage": "https://www.browserbase.com", "bugs": "https://github.com/modelcontextprotocol/servers/issues", "type": "module", "main": "./cli.js", "module": "./src/index.ts", "bin": { "mcp-server-browserbase": "cli.js" }, "files": [ "assets", "README.md", "dist", "cli.js", "index.d.ts", "index.js", "config.d.ts" ], "scripts": { "build": "tsc && shx chmod +x dist/*.js", "prepare": "husky && pnpm build", "watch": "tsc --watch", "smithery": "npx @smithery/cli dev src/index.ts", "inspector": "npx @modelcontextprotocol/inspector build/index.js", "evals": "tsx evals/run-evals.ts run --config evals/mcp-eval-basic.config.json && tsx evals/run-evals.ts run --config evals/mcp-eval-minimal.config.json && tsx evals/run-evals.ts run --config evals/mcp-eval.config.json", "lint": "eslint . --ext .ts", "format": "prettier --write .", "clean": "rm -rf dist", "prepublishOnly": "pnpm clean && pnpm build", "pre-commit": "pnpm lint-staged", "changeset": "changeset", "version:packages": "changeset version", "release": "changeset publish" }, "lint-staged": { "*.{js,jsx,ts,tsx,json,css,scss,md}": [ "prettier --write .", "eslint --fix" ] }, "dependencies": { "@browserbasehq/sdk": "^2.6.0", "@browserbasehq/stagehand": "^2.5.2", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "^1.13.1", "commander": "^14.0.0", "dotenv": "^16.4.6", "mcpvals": "^0.0.3", "zod": "^3.25.67" }, "devDependencies": { "@changesets/cli": "^2.29.6", "@eslint/js": "^9.29.0", "@smithery/cli": "^1.2.15", "chalk": "^5.3.0", "eslint": "^9.29.0", "eslint-plugin-react": "^7.37.5", "globals": "^16.2.0", "husky": "^9.1.7", "lint-staged": "^16.1.2", "playwright-core": "^1.53.2", "prettier": "^3.6.1", "shx": "^0.3.4", "tsx": "^4.20.3", "typescript": "^5.6.2", "typescript-eslint": "^8.35.0" }, "publishConfig": { "access": "public" }, "packageManager": "[email protected]+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" } ``` -------------------------------------------------------------------------------- /src/tools/screenshot.ts: -------------------------------------------------------------------------------- ```typescript 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"; import { registerScreenshot } from "../mcp/resources.js"; /** * Screenshot * Docs: https://playwright.dev/docs/screenshots * * This tool is used to take a screenshot of the current page. */ const ScreenshotInputSchema = z.object({ name: z.string().optional().describe("The name of the screenshot"), }); type ScreenshotInput = z.infer<typeof ScreenshotInputSchema>; const screenshotSchema: ToolSchema<typeof ScreenshotInputSchema> = { name: "browserbase_screenshot", description: `Capture a full-page screenshot and return it (and save as a resource).`, inputSchema: ScreenshotInputSchema, }; async function handleScreenshot( context: Context, params: ScreenshotInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const page = await context.getActivePage(); if (!page) { throw new Error("No active page available"); } // We're taking a full page screenshot to give context of the entire page, similar to a snapshot const screenshotBuffer = await page.screenshot({ fullPage: true, }); // Convert buffer to base64 string and store in memory const screenshotBase64 = screenshotBuffer.toString("base64"); const name = params.name ? `screenshot-${params.name}-${new Date() .toISOString() .replace(/:/g, "-")}` : `screenshot-${new Date().toISOString().replace(/:/g, "-")}` + context.config.browserbaseProjectId; // Associate with current mcp session id and store in memory /src/mcp/resources.ts const sessionId = context.currentSessionId; registerScreenshot(sessionId, name, screenshotBase64); // Notify the client that the resources changed const serverInstance = context.getServer(); if (serverInstance) { serverInstance.notification({ method: "notifications/resources/list_changed", }); } return { content: [ { type: "text", text: `Screenshot taken with name: ${name}`, }, { type: "image", data: screenshotBase64, mimeType: "image/png", }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to take screenshot: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const screenshotTool: Tool<typeof ScreenshotInputSchema> = { capability: "core", schema: screenshotSchema, handle: handleScreenshot, }; export default screenshotTool; ``` -------------------------------------------------------------------------------- /src/tools/observe.ts: -------------------------------------------------------------------------------- ```typescript 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"; /** * Stagehand Observe * Docs: https://docs.stagehand.dev/basics/observe * * This tool is used to observe and identify specific interactive elements on a web page. * You can optionally choose to have the observe tool return an action to perform on the element. */ const ObserveInputSchema = z.object({ instruction: z.string().describe( `Detailed instruction for what specific elements or components to observe on the web page. This instruction must be extremely specific and descriptive. For example: 'Find the red login button in the top right corner', 'Locate the search input field with placeholder text', or 'Identify all clickable product cards on the page'. The more specific and detailed your instruction, the better the observation results will be. Avoid generic instructions like 'find buttons' or 'see elements'. Instead, describe the visual characteristics, location, text content, or functionality of the elements you want to observe. This tool is designed to help you identify interactive elements that you can later use with the act tool for performing actions like clicking, typing, or form submission.`, ), returnAction: z .boolean() .optional() .describe( `Whether to return the action to perform on the element. If true, the action will be returned as a string. If false, the action will not be returned.`, ), }); type ObserveInput = z.infer<typeof ObserveInputSchema>; const observeSchema: ToolSchema<typeof ObserveInputSchema> = { name: "browserbase_stagehand_observe", description: `Find interactive elements on the page from an instruction; optionally return an action.`, inputSchema: ObserveInputSchema, }; async function handleObserve( context: Context, params: ObserveInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const stagehand = await context.getStagehand(); const observations = await stagehand.page.observe({ instruction: params.instruction, returnAction: params.returnAction, }); return { content: [ { type: "text", text: `Observations: ${JSON.stringify(observations)}`, }, ], }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); throw new Error(`Failed to observe: ${errorMsg}`); } }; return { action, waitForNetwork: false, }; } const observeTool: Tool<typeof ObserveInputSchema> = { capability: "core", schema: observeSchema, handle: handleObserve, }; export default observeTool; ``` -------------------------------------------------------------------------------- /config.d.ts: -------------------------------------------------------------------------------- ```typescript import type { Cookie } from "playwright-core"; import type { AvailableModelSchema } from "@browserbasehq/stagehand"; export type Config = { /** * Browserbase API Key to authenticate requests */ browserbaseApiKey: string; /** * Browserbase Project ID associated with the API key */ browserbaseProjectId: string; /** * Whether or not to use Browserbase proxies * https://docs.browserbase.com/features/proxies * * @default false */ proxies?: boolean; /** * Use advanced stealth mode. Only available to Browserbase Scale Plan users. * * @default false */ advancedStealth?: boolean; /** * Whether or not to keep the Browserbase session alive * * @default false */ keepAlive?: boolean; /** * Potential Browserbase Context to use * Would be a context ID */ context?: { /** * The ID of the context to use */ contextId?: string; /** * Whether or not to persist the context * * @default true */ persist?: boolean; }; /** * The viewport of the browser * @default { browserWidth: 1024, browserHeight: 768 } */ viewPort?: { /** * The width of the browser */ browserWidth?: number; /** * The height of the browser */ browserHeight?: number; }; /** * Cookies to inject into the Browserbase context * Format: Array of cookie objects with name, value, domain, and optional path, expires, httpOnly, secure, sameSite */ cookies?: Cookie[]; /** * Server configuration for MCP transport layer * * Controls how the MCP server binds and listens for connections. * When port is specified, the server will start an SHTTP transport. * When both port and host are undefined, the server uses stdio transport. * * Security considerations: * - Use localhost (default) for local development * - Use 0.0.0.0 only when you need external access and have proper security measures * - Consider firewall rules and network security when exposing the server */ server?: { /** * The port to listen on for SHTTP or MCP transport. * If undefined, uses stdio transport instead of HTTP. * * @example 3000 */ port?: number; /** * The host to bind the server to. * * @default "localhost" - Only accepts local connections * @example "0.0.0.0" - Accepts connections from any interface (use with caution) */ host?: string; }; /** * The Model that Stagehand uses * Available models: OpenAI, Claude, Gemini, Cerebras, Groq, and other providers * * @default "gemini-2.0-flash" */ modelName?: z.infer<typeof AvailableModelSchema>; /** * API key for the custom model provider * Required when using a model other than the default gemini-2.0-flash */ modelApiKey?: string; /** * Enable experimental features * * @default false */ experimental?: boolean; }; ``` -------------------------------------------------------------------------------- /src/program.ts: -------------------------------------------------------------------------------- ```typescript import { program } from "commander"; import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; import createServerFunction from "./index.js"; import { ServerList } from "./server.js"; import { startHttpTransport, startStdioTransport } from "./transport.js"; import { resolveConfig } from "./config.js"; let __filename: string; let __dirname: string; try { // Try ES modules first __filename = fileURLToPath(import.meta.url); __dirname = path.dirname(__filename); } catch { // Fallback for CommonJS or when import.meta is not available __filename = (globalThis as { __filename: string }).__filename || process.cwd() + "/dist/program.js"; __dirname = path.dirname(__filename); } // Load package.json using fs const packageJSONPath = path.resolve(__dirname, "../package.json"); const packageJSONBuffer = fs.readFileSync(packageJSONPath); const packageJSON = JSON.parse(packageJSONBuffer.toString()); program .version("Version " + packageJSON.version) .name(packageJSON.name) .option("--browserbaseApiKey <key>", "The Browserbase API Key to use") .option("--browserbaseProjectId <id>", "The Browserbase Project ID to use") .option("--proxies", "Use Browserbase proxies.") .option( "--advancedStealth", "Use advanced stealth mode. Only available to Browserbase Scale Plan users.", ) .option("--contextId <contextId>", "Browserbase Context ID to use.") .option( "--persist [boolean]", "Whether to persist the Browserbase context", true, ) .option("--port <port>", "Port to listen on for SHTTP transport.") .option( "--host <host>", "Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.", ) .option( "--cookies [json]", 'JSON array of cookies to inject into the browser. Format: [{"name":"cookie1","value":"val1","domain":"example.com"}, ...]', ) .option("--browserWidth <width>", "Browser width to use for the browser.") .option("--browserHeight <height>", "Browser height to use for the browser.") .option( "--modelName <model>", "The model to use for Stagehand (default: gemini-2.0-flash)", ) .option( "--modelApiKey <key>", "API key for the custom model provider (required when using custom models)", ) .option("--keepAlive", "Enable Browserbase Keep Alive Session") .option("--experimental", "Enable experimental features") .action(async (options) => { const config = await resolveConfig(options); const serverList = new ServerList(async () => createServerFunction({ config: config, }), ); setupExitWatchdog(serverList); if (options.port) startHttpTransport(+options.port, options.host, serverList); else await startStdioTransport(serverList, config); }); function setupExitWatchdog(serverList: ServerList) { const handleExit = async () => { setTimeout(() => process.exit(0), 15000); try { // SessionManager within each server handles session cleanup await serverList.closeAll(); } catch (error) { console.error("Error during cleanup:", error); } process.exit(0); }; process.stdin.on("close", handleExit); process.on("SIGINT", handleExit); process.on("SIGTERM", handleExit); } program.parse(process.argv); ``` -------------------------------------------------------------------------------- /evals/mcp-eval.config.json: -------------------------------------------------------------------------------- ```json { "passThreshold": 0.7, "server": { "transport": "stdio", "command": "node", "args": ["./cli.js"], "env": { "BROWSERBASE_API_KEY": "${BROWSERBASE_API_KEY}", "BROWSERBASE_PROJECT_ID": "${BROWSERBASE_PROJECT_ID}", "GEMINI_API_KEY": "${GEMINI_API_KEY}" } }, "timeout": 180000, "llmJudge": false, "workflows": [ { "name": "basic-navigation-test", "description": "Test basic browser navigation functionality", "steps": [ { "user": "Create a browser session, navigate to https://example.com, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_session_close" ] }, { "name": "search-and-extract-test", "description": "Test navigation, search interaction, and data extraction", "steps": [ { "user": "Create a browser session, navigate to https://example.com, extract the page title, and close the session", "expectedState": "Example Domain" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_extract", "browserbase_session_close" ] }, { "name": "observe-and-interact-test", "description": "Test element observation and interaction capabilities", "steps": [ { "user": "Create a browser session, navigate to https://example.com, observe the page elements, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_observe", "browserbase_session_close" ] }, { "name": "screenshot-test", "description": "Test screenshot functionality", "steps": [ { "user": "Create a browser session, navigate to https://example.com, take a screenshot, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_screenshot", "browserbase_session_close" ] }, { "name": "form-interaction-test", "description": "Test form filling and submission capabilities", "steps": [ { "user": "Create a browser session, navigate to https://httpbin.org/forms/post, fill in the customer name field with 'TestUser', and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_act", "browserbase_session_close" ] }, { "name": "error-handling-test", "description": "Test error handling for invalid operations", "steps": [ { "user": "Create a browser session and try to navigate to an invalid URL like 'invalid-url-test'", "expectedState": "error" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate" ] } ] } ``` -------------------------------------------------------------------------------- /src/transport.ts: -------------------------------------------------------------------------------- ```typescript import http from "node:http"; import assert from "node:assert"; import crypto from "node:crypto"; import { ServerList } from "./server.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { Config } from "../config.d.ts"; export async function startStdioTransport( serverList: ServerList, config?: Config, ) { // Check if we're using the default model without an API key if (config) { const modelName = config.modelName || "gemini-2.0-flash"; const hasModelApiKey = config.modelApiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (modelName.includes("gemini") && !hasModelApiKey) { console.error( `Need to set GEMINI_API_KEY or GOOGLE_API_KEY in your environment variables`, ); } } const server = await serverList.create(); await server.connect(new StdioServerTransport()); } async function handleStreamable( req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>, ) { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId) { const transport = sessions.get(sessionId); if (!transport) { res.statusCode = 404; res.end("Session not found"); return; } return await transport.handleRequest(req, res); } if (req.method === "POST") { const sessionId = crypto.randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, }); sessions.set(sessionId, transport); transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); }; const server = await serverList.create(); await server.connect(transport); return await transport.handleRequest(req, res); } res.statusCode = 400; res.end("Invalid request"); } export function startHttpTransport( port: number, hostname: string | undefined, serverList: ServerList, ) { // In-memory Map of SHTTP sessions const streamableSessions = new Map<string, StreamableHTTPServerTransport>(); const httpServer = http.createServer(async (req, res) => { if (!req.url) { res.statusCode = 400; res.end("Bad request: missing URL"); return; } const url = new URL(`http://localhost${req.url}`); if (url.pathname.startsWith("/mcp")) await handleStreamable(req, res, serverList, streamableSessions); }); httpServer.listen(port, hostname, () => { const address = httpServer.address(); assert(address, "Could not bind server socket"); let url: string; if (typeof address === "string") { url = address; } else { const resolvedPort = address.port; let resolvedHost = address.family === "IPv4" ? address.address : `[${address.address}]`; if (resolvedHost === "0.0.0.0" || resolvedHost === "[::]") resolvedHost = "localhost"; url = `http://${resolvedHost}:${resolvedPort}`; } const message = [ `Listening on ${url}`, "Put this in your client config:", JSON.stringify( { mcpServers: { browserbase: { url: `${url}/mcp`, }, }, }, undefined, 2, ), "If your client supports streamable HTTP, you can use the /mcp endpoint instead.", ].join("\n"); console.log(message); }); } ``` -------------------------------------------------------------------------------- /evals/mcp-eval-basic.config.json: -------------------------------------------------------------------------------- ```json { "passThreshold": 0.7, "server": { "transport": "stdio", "command": "node", "args": ["./cli.js"], "env": { "BROWSERBASE_API_KEY": "${BROWSERBASE_API_KEY}", "BROWSERBASE_PROJECT_ID": "${BROWSERBASE_PROJECT_ID}", "GEMINI_API_KEY": "${GEMINI_API_KEY}" } }, "timeout": 180000, "llmJudge": false, "workflows": [ { "name": "basic-navigation-test", "description": "Test basic browser navigation functionality", "steps": [ { "user": "Create a browser session, navigate to https://example.com, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_session_close" ] }, { "name": "search-and-extract-test", "description": "Test navigation, search interaction, and data extraction", "steps": [ { "user": "Create a browser session, navigate to https://example.com, extract the page title, and close the session", "expectedState": "Example Domain" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_extract", "browserbase_session_close" ] }, { "name": "observe-and-interact-test", "description": "Test element observation and interaction capabilities", "steps": [ { "user": "Create a browser session, navigate to https://example.com, observe the page elements, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_observe", "browserbase_session_close" ] }, { "name": "screenshot-test", "description": "Test screenshot functionality", "steps": [ { "user": "Create a browser session, navigate to https://example.com, take a screenshot, and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_screenshot", "browserbase_session_close" ] }, { "name": "form-interaction-test", "description": "Test form filling and submission capabilities", "steps": [ { "user": "Create a browser session, navigate to https://httpbin.org/forms/post, fill in the customer name field with 'TestUser', and close the session", "expectedState": "closed" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_act", "browserbase_session_close" ] }, { "name": "error-handling-test", "description": "Test error handling for invalid operations", "steps": [ { "user": "Create a browser session and try to navigate to an invalid URL like 'invalid-url-test'", "expectedState": "error" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate" ] }, { "name": "url-retrieval-test", "description": "Test URL retrieval functionality", "steps": [ { "user": "Create a browser session, navigate to https://example.com, get the current URL to verify navigation, and close the session", "expectedState": "https://example.com" } ], "expectTools": [ "browserbase_session_create", "browserbase_stagehand_navigate", "browserbase_stagehand_get_url", "browserbase_session_close" ] } ] } ``` -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- ```typescript import type { Stagehand } from "@browserbasehq/stagehand"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { Config } from "../config.d.ts"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { listResources, readResource } from "./mcp/resources.js"; import { SessionManager } from "./sessionManager.js"; import type { MCPTool, BrowserSession } from "./types/types.js"; /** * MCP Server Context * * Central controller that connects the MCP server infrastructure with browser automation capabilities, * managing server instances, browser sessions, tool execution, and resource access. */ export class Context { public readonly config: Config; private server: Server; private sessionManager: SessionManager; // currentSessionId is a getter that delegates to SessionManager to ensure synchronization // This prevents desync between Context and SessionManager session tracking public get currentSessionId(): string { return this.sessionManager.getActiveSessionId(); } constructor(server: Server, config: Config, contextId?: string) { this.server = server; this.config = config; this.sessionManager = new SessionManager(contextId); } public getServer(): Server { return this.server; } public getSessionManager(): SessionManager { return this.sessionManager; } /** * Gets the Stagehand instance for the current session from SessionManager */ public async getStagehand( sessionId: string = this.currentSessionId, ): Promise<Stagehand> { const session = await this.sessionManager.getSession( sessionId, this.config, ); if (!session) { throw new Error(`No session found for ID: ${sessionId}`); } return session.stagehand; } public async getActivePage(): Promise<BrowserSession["page"] | null> { // Get page from session manager const session = await this.sessionManager.getSession( this.currentSessionId, this.config, ); if (session && session.page && !session.page.isClosed()) { return session.page; } return null; } public async getActiveBrowser( createIfMissing: boolean = true, ): Promise<BrowserSession["browser"] | null> { const session = await this.sessionManager.getSession( this.currentSessionId, this.config, createIfMissing, ); if (!session || !session.browser || !session.browser.isConnected()) { return null; } return session.browser; } async run(tool: MCPTool, args: unknown): Promise<CallToolResult> { try { console.error( `Executing tool: ${tool.schema.name} with args: ${JSON.stringify(args)}`, ); // Check if this tool has a handle method (new tool system) if ("handle" in tool && typeof tool.handle === "function") { const toolResult = await tool.handle(this, args); if (toolResult?.action) { const actionResult = await toolResult.action(); const content = actionResult?.content || []; return { content: Array.isArray(content) ? content : [{ type: "text", text: "Action completed successfully." }], isError: false, }; } else { return { content: [ { type: "text", text: `${tool.schema.name} completed successfully.`, }, ], isError: false, }; } } else { // Fallback for any legacy tools without handle method throw new Error( `Tool ${tool.schema.name} does not have a handle method`, ); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error( `Tool ${tool.schema?.name || "unknown"} failed: ${errorMessage}`, ); return { content: [{ type: "text", text: `Error: ${errorMessage}` }], isError: true, }; } } /** * List resources * Documentation: https://modelcontextprotocol.io/docs/concepts/resources */ listResources() { return listResources(); } /** * Read a resource by URI * Documentation: https://modelcontextprotocol.io/docs/concepts/resources */ readResource(uri: string) { return readResource(uri); } } ``` -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- ```typescript import type { Cookie } from "playwright-core"; import type { Config } from "../config.d.ts"; import { z } from "zod"; import { AvailableModelSchema } from "@browserbasehq/stagehand"; export type ToolCapability = "core" | string; // Define Command Line Options Structure export type CLIOptions = { proxies?: boolean; advancedStealth?: boolean; contextId?: string; persist?: boolean; port?: number; host?: string; cookies?: Cookie[]; browserWidth?: number; browserHeight?: number; modelName?: z.infer<typeof AvailableModelSchema>; modelApiKey?: string; keepAlive?: boolean; experimental?: boolean; }; // Default Configuration Values const defaultConfig: Config = { browserbaseApiKey: process.env.BROWSERBASE_API_KEY ?? "", browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID ?? "", proxies: false, server: { port: undefined, host: undefined, }, viewPort: { browserWidth: 1024, browserHeight: 768, }, cookies: undefined, modelName: "gemini-2.0-flash", // Default Model }; // Resolve final configuration by merging defaults, file config, and CLI options export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> { const cliConfig = await configFromCLIOptions(cliOptions); // Order: Defaults < File Config < CLI Overrides const mergedConfig = mergeConfig(defaultConfig, cliConfig); // --- Add Browserbase Env Vars --- if (!mergedConfig.modelApiKey) { mergedConfig.modelApiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; } // -------------------------------- // Basic validation for Browserbase keys - provide dummy values if not set if (!mergedConfig.browserbaseApiKey) { console.warn( "Warning: BROWSERBASE_API_KEY environment variable not set. Using dummy value.", ); mergedConfig.browserbaseApiKey = "dummy-browserbase-api-key"; } if (!mergedConfig.browserbaseProjectId) { console.warn( "Warning: BROWSERBASE_PROJECT_ID environment variable not set. Using dummy value.", ); mergedConfig.browserbaseProjectId = "dummy-browserbase-project-id"; } if (!mergedConfig.modelApiKey) { console.warn( "Warning: MODEL_API_KEY environment variable not set. Using dummy value.", ); mergedConfig.modelApiKey = "dummy-api-key"; } return mergedConfig; } // Create Config structure based on CLI options export async function configFromCLIOptions( cliOptions: CLIOptions, ): Promise<Config> { return { browserbaseApiKey: process.env.BROWSERBASE_API_KEY ?? "", browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID ?? "", server: { port: cliOptions.port, host: cliOptions.host, }, proxies: cliOptions.proxies, context: { contextId: cliOptions.contextId, persist: cliOptions.persist, }, viewPort: { browserWidth: cliOptions.browserWidth, browserHeight: cliOptions.browserHeight, }, advancedStealth: cliOptions.advancedStealth, cookies: cliOptions.cookies, modelName: cliOptions.modelName, modelApiKey: cliOptions.modelApiKey, keepAlive: cliOptions.keepAlive, experimental: cliOptions.experimental, }; } // Helper function to merge config objects, excluding undefined values function pickDefined<T extends object>(obj: T | undefined): Partial<T> { if (!obj) return {}; return Object.fromEntries( Object.entries(obj).filter(([, v]) => v !== undefined), ) as Partial<T>; } // Merge two configuration objects (overrides takes precedence) function mergeConfig(base: Config, overrides: Config): Config { const baseFiltered = pickDefined(base); const overridesFiltered = pickDefined(overrides); // Create the result object const result = { ...baseFiltered } as Config; // For each property in overrides for (const [key, value] of Object.entries(overridesFiltered)) { if (key === "context" && value && result.context) { // Special handling for context object to ensure deep merge result.context = { ...result.context, ...(value as Config["context"]), }; } else if ( value && typeof value === "object" && !Array.isArray(value) && result[key as keyof Config] && typeof result[key as keyof Config] === "object" ) { // Deep merge for other nested objects result[key as keyof Config] = { ...(result[key as keyof Config] as object), ...value, } as unknown; } else { // Simple override for primitives, arrays, etc. result[key as keyof Config] = value as unknown; } } return result; } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import * as dotenv from "dotenv"; dotenv.config(); import { randomUUID } from "crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { MCPToolsArray } from "./types/types.js"; import { Context } from "./context.js"; import type { Config } from "../config.d.ts"; import { TOOLS } from "./tools/index.js"; import { AvailableModelSchema } from "@browserbasehq/stagehand"; import { RESOURCE_TEMPLATES } from "./mcp/resources.js"; import { ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const cookieSchema = z.object({ name: z.string(), value: z.string(), domain: z.string(), path: z.string().optional(), expires: z.number().optional(), httpOnly: z.boolean().optional(), secure: z.boolean().optional(), sameSite: z.enum(["Strict", "Lax", "None"]).optional(), }); // Configuration schema for Smithery - matches existing Config interface export const configSchema = z .object({ browserbaseApiKey: z.string().describe("The Browserbase API Key to use"), browserbaseProjectId: z .string() .describe("The Browserbase Project ID to use"), proxies: z .boolean() .optional() .describe("Whether or not to use Browserbase proxies"), advancedStealth: z .boolean() .optional() .describe( "Use advanced stealth mode. Only available to Browserbase Scale Plan users", ), keepAlive: z .boolean() .optional() .describe("Whether or not to keep the Browserbase session alive"), context: z .object({ contextId: z .string() .optional() .describe("The ID of the context to use"), persist: z .boolean() .optional() .describe("Whether or not to persist the context"), }) .optional(), viewPort: z .object({ browserWidth: z .number() .optional() .describe("The width of the browser"), browserHeight: z .number() .optional() .describe("The height of the browser"), }) .optional(), cookies: z .array(cookieSchema) .optional() .describe("Cookies to inject into the Browserbase context"), server: z .object({ port: z .number() .optional() .describe("The port to listen on for SHTTP or MCP transport"), host: z .string() .optional() .describe( "The host to bind the server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces", ), }) .optional(), modelName: AvailableModelSchema.optional().describe( "The model to use for Stagehand (default: gemini-2.0-flash)", ), // Already an existing Zod Enum modelApiKey: z .string() .optional() .describe( "API key for the custom model provider. Required when using a model other than the default gemini-2.0-flash", ), experimental: z .boolean() .optional() .describe("Enable experimental Stagehand features"), }) .refine( (data) => { // If a non-default model is explicitly specified, API key is required if (data.modelName && data.modelName !== "gemini-2.0-flash") { return data.modelApiKey !== undefined && data.modelApiKey.length > 0; } return true; }, { message: "modelApiKey is required when specifying a custom model", path: ["modelApiKey"], }, ); // Default function for Smithery export default function ({ config }: { config: z.infer<typeof configSchema> }) { if (!config.browserbaseApiKey) { throw new Error("browserbaseApiKey is required"); } if (!config.browserbaseProjectId) { throw new Error("browserbaseProjectId is required"); } const server = new McpServer({ name: "Browserbase MCP Server", version: "2.2.0", description: "Cloud browser automation server powered by Browserbase and Stagehand. Enables LLMs to navigate websites, interact with elements, extract data, and capture screenshots using natural language commands.", capabilities: { resources: { subscribe: true, listChanged: true, }, }, }); const internalConfig: Config = config as Config; // Create the context, passing server instance and config const contextId = randomUUID(); const context = new Context(server.server, internalConfig, contextId); server.server.registerCapabilities({ resources: { subscribe: true, listChanged: true, }, }); // Add resource handlers server.server.setRequestHandler(ListResourcesRequestSchema, async () => { return context.listResources(); }); server.server.setRequestHandler( ReadResourceRequestSchema, async (request) => { return context.readResource(request.params.uri); }, ); server.server.setRequestHandler( ListResourceTemplatesRequestSchema, async () => { return { resourceTemplates: RESOURCE_TEMPLATES }; }, ); const tools: MCPToolsArray = [...TOOLS]; // Register each tool with the Smithery server tools.forEach((tool) => { if (tool.schema.inputSchema instanceof z.ZodObject) { server.tool( tool.schema.name, tool.schema.description, tool.schema.inputSchema.shape, async (params: z.infer<typeof tool.schema.inputSchema>) => { try { const result = await context.run(tool, params); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write( `[Smithery Error] ${new Date().toISOString()} Error running tool ${tool.schema.name}: ${errorMessage}\n`, ); throw new Error( `Failed to run tool '${tool.schema.name}': ${errorMessage}`, ); } }, ); } else { console.warn( `Tool "${tool.schema.name}" has an input schema that is not a ZodObject. Schema type: ${tool.schema.inputSchema.constructor.name}`, ); } }); return server.server; } ``` -------------------------------------------------------------------------------- /evals/run-evals.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env tsx import { Command } from "commander"; import * as fs from "fs/promises"; import * as path from "path"; import { evaluate } from "mcpvals"; import os from "os"; import chalk from "chalk"; // Load environment variables from .env file import { config } from "dotenv"; config(); // Types for evaluation results interface EvaluationResult { workflowName: string; passed: boolean; overallScore: number; results: Array<{ metric: string; passed: boolean; score: number; details: string; metadata?: Record<string, unknown>; }>; } interface EvaluationReport { config: Record<string, unknown>; evaluations: EvaluationResult[]; passed: boolean; timestamp: Date; } interface TestResult { config: string; passed: boolean; score: number; duration: number; workflows: { name: string; passed: boolean; score: number; }[]; } interface EvalConfig { workflows: Array<{ name?: string }>; passThreshold?: number; [key: string]: unknown; } const program = new Command(); program .name("browserbase-mcp-evals") .description("Run evaluation tests for Browserbase MCP Server") .version("1.0.0"); program .command("run") .description("Run evaluation tests") .option( "-c, --config <path>", "Config file path", "./evals/mcp-eval.config.json", ) .option("-d, --debug", "Enable debug output") .option("-j, --json", "Output results as JSON") .option("-l, --llm", "Enable LLM judge") .option("-o, --output <path>", "Save results to file") .option( "-p, --pass-threshold <number>", "Minimum average score (0-1) required to pass. Can also be set via EVAL_PASS_THRESHOLD env var.", ) .option("-t, --timeout <ms>", "Override timeout in milliseconds") .action(async (options) => { try { const startTime = Date.now(); // Check for required environment variables const requiredEnvVars = [ "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", ]; const missingVars = requiredEnvVars.filter((v) => !process.env[v]); if (missingVars.length > 0) { console.error( chalk.red( `Missing required environment variables: ${missingVars.join(", ")}`, ), ); console.error( chalk.yellow("Please set them before running the tests."), ); console.error(chalk.yellow("Example:")); for (const missingVar of missingVars) { switch (missingVar) { case "BROWSERBASE_API_KEY": console.error( chalk.yellow( " export BROWSERBASE_API_KEY='your_api_key_here'", ), ); break; case "BROWSERBASE_PROJECT_ID": console.error( chalk.yellow( " export BROWSERBASE_PROJECT_ID='your_project_id_here'", ), ); break; case "ANTHROPIC_API_KEY": console.error( chalk.yellow( " export ANTHROPIC_API_KEY='sk-ant-your_key_here'", ), ); break; case "GEMINI_API_KEY": console.error( chalk.yellow(" export GEMINI_API_KEY='your_gemini_key_here'"), ); break; } } process.exit(1); } // Check for LLM judge requirements if (options.llm && !process.env.OPENAI_API_KEY) { console.error( chalk.red("LLM judge requires OPENAI_API_KEY environment variable"), ); process.exit(1); } // Resolve config path const configPath = path.resolve(options.config); // Load config to get workflow count for display const configContent = await fs.readFile(configPath, "utf-8"); const config: EvalConfig = JSON.parse(configContent); console.log(chalk.blue(`Running evaluation tests from: ${configPath}`)); console.log(chalk.gray(`Workflows to test: ${config.workflows.length}`)); // Prepare evaluation options const evalOptions = { debug: options.debug, reporter: (options.json ? "json" : "console") as | "json" | "console" | "junit" | undefined, llmJudge: options.llm, timeout: options.timeout ? parseInt(options.timeout) : undefined, }; console.log( chalk.yellow( "Parallel mode: splitting workflows and running concurrently", ), ); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-evals-")); const workflowFiles: string[] = []; for (let i = 0; i < config.workflows.length; i++) { const wf = config.workflows[i]; const wfConfig = { ...config, workflows: [wf] }; const wfPath = path.join( tmpDir, `workflow-${i}-${(wf.name || "unnamed").replace(/[^a-z0-9_-]/gi, "_")}.json`, ); await fs.writeFile(wfPath, JSON.stringify(wfConfig, null, 2)); workflowFiles.push(wfPath); } const reports: EvaluationReport[] = await Promise.all( workflowFiles.map((wfPath) => evaluate(wfPath, evalOptions)), ); // Aggregate results const allEvaluations = reports.flatMap((r) => r.evaluations); const duration = Date.now() - startTime; // Determine pass/fail based on threshold instead of strict all-pass const avgScore = allEvaluations.length === 0 ? 0 : allEvaluations.reduce((sum, e) => sum + e.overallScore, 0) / allEvaluations.length; const thresholdFromEnv = (process.env.EVAL_PASS_THRESHOLD || process.env.PASS_THRESHOLD) ?? ""; const thresholdFromCli = options.passThreshold ?? ""; const thresholdFromConfig = typeof config.passThreshold === "number" ? String(config.passThreshold) : ""; const threshold = (() => { const raw = String( thresholdFromCli || thresholdFromEnv || thresholdFromConfig, ).trim(); const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) return 0.6; // default lowered threshold return parsed; })(); const passed = avgScore >= threshold; const finalReport: EvaluationReport = { config: { parallel: true, source: configPath }, evaluations: allEvaluations, passed, timestamp: new Date(), }; const finalResult: TestResult = { config: configPath, passed, score: avgScore, duration, workflows: allEvaluations.map((e) => ({ name: e.workflowName, passed: e.passed, score: e.overallScore, })), }; // Best-effort cleanup try { await Promise.all(workflowFiles.map((f) => fs.unlink(f))); await fs.rmdir(tmpDir); } catch { // ignore cleanup errors } // Output results if (options.json) { console.log(JSON.stringify(finalResult, null, 2)); } else { console.log( chalk.green( `\nTest execution completed in ${(finalResult.duration / 1000).toFixed(2)}s`, ), ); console.log( chalk.gray( `Threshold for pass: ${threshold.toFixed(2)} | Average score: ${finalResult.score.toFixed(3)}`, ), ); console.log( chalk[finalResult.passed ? "green" : "red"]( `Overall result: ${finalResult.passed ? "PASSED" : "FAILED"} (${(finalResult.score * 100).toFixed(1)}%)`, ), ); } // Save to file if requested if (options.output) { await fs.writeFile( options.output, JSON.stringify(finalReport, null, 2), ); console.log(chalk.gray(`Results saved to: ${options.output}`)); } process.exit(finalResult.passed ? 0 : 1); } catch (error) { console.error("Error running evaluation tests:", error); process.exit(1); } }); program.parse(); ``` -------------------------------------------------------------------------------- /src/tools/session.ts: -------------------------------------------------------------------------------- ```typescript 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"; import { Browserbase } from "@browserbasehq/sdk"; import { createUIResource } from "@mcp-ui/server"; import type { BrowserSession } from "../types/types.js"; import { TextContent } from "@modelcontextprotocol/sdk/types.js"; // --- Tool: Create Session --- const CreateSessionInputSchema = z.object({ // Keep sessionId optional sessionId: z .string() .optional() .describe( "Optional session ID to use/reuse. If not provided or invalid, a new session is created.", ), }); type CreateSessionInput = z.infer<typeof CreateSessionInputSchema>; const createSessionSchema: ToolSchema<typeof CreateSessionInputSchema> = { name: "browserbase_session_create", description: "Create or reuse a Browserbase browser session and set it as active.", inputSchema: CreateSessionInputSchema, }; // Handle function for CreateSession using SessionManager async function handleCreateSession( context: Context, params: CreateSessionInput, ): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { try { const sessionManager = context.getSessionManager(); const config = context.config; // Get config from context let targetSessionId: string; // Session ID Strategy: Use raw sessionId for both internal tracking and Browserbase operations // Default session uses generated ID with timestamp/UUID, user sessions use provided ID as-is if (params.sessionId) { targetSessionId = params.sessionId; process.stderr.write( `[tool.createSession] Attempting to create/assign session with specified ID: ${targetSessionId}\n`, ); } else { targetSessionId = sessionManager.getDefaultSessionId(); } let session: BrowserSession; const defaultSessionId = sessionManager.getDefaultSessionId(); if (targetSessionId === defaultSessionId) { session = await sessionManager.ensureDefaultSessionInternal(config); } else { // When user provides a sessionId, we want to resume that Browserbase session // Note: targetSessionId is used for internal tracking in SessionManager // while params.sessionId is the Browserbase session ID to resume session = await sessionManager.createNewBrowserSession( targetSessionId, // Internal session ID for tracking config, params.sessionId, // Browserbase session ID to resume ); } if ( !session || !session.browser || !session.page || !session.sessionId || !session.stagehand ) { throw new Error( `SessionManager failed to return a valid session object with actualSessionId for ID: ${targetSessionId}`, ); } // Note: No need to set context.currentSessionId - SessionManager handles this // and context.currentSessionId is a getter that delegates to SessionManager const bb = new Browserbase({ apiKey: config.browserbaseApiKey, }); const browserbaseSessionId = session.stagehand.browserbaseSessionID; if (!browserbaseSessionId) { throw new Error( "Browserbase session ID not found in Stagehand instance", ); } const debugUrl = (await bb.sessions.debug(browserbaseSessionId)) .debuggerFullscreenUrl; return { content: [ { type: "text", text: `Browserbase Live Session View URL: https://www.browserbase.com/sessions/${browserbaseSessionId}`, }, { type: "text", text: `Browserbase Live Debugger URL: ${debugUrl}`, }, createUIResource({ uri: "ui://analytics-dashboard/main", content: { type: "externalUrl", iframeUrl: debugUrl }, encoding: "text", }) as unknown as TextContent, ], }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); process.stderr.write( `[tool.createSession] Action failed: ${errorMessage}\n`, ); // Re-throw to be caught by Context.run's error handling for actions throw new Error(`Failed to create Browserbase session: ${errorMessage}`); } }; // Return the ToolResult structure expected by Context.run return { action: action, waitForNetwork: false, }; } // Define tool using handle const createSessionTool: Tool<typeof CreateSessionInputSchema> = { capability: "core", // Add capability schema: createSessionSchema, handle: handleCreateSession, }; // --- Tool: Close Session --- const CloseSessionInputSchema = z.object({}); const closeSessionSchema: ToolSchema<typeof CloseSessionInputSchema> = { name: "browserbase_session_close", description: "Close the current Browserbase session and reset the active context.", inputSchema: CloseSessionInputSchema, }; async function handleCloseSession(context: Context): Promise<ToolResult> { const action = async (): Promise<ToolActionResult> => { // Store the current session ID before cleanup const previousSessionId = context.currentSessionId; let cleanupSuccessful = false; let cleanupErrorMessage = ""; // Step 1: Get session info before cleanup let browserbaseSessionId: string | undefined; const sessionManager = context.getSessionManager(); try { const session = await sessionManager.getSession( previousSessionId, context.config, false, ); if (session && session.stagehand) { // Store the actual Browserbase session ID for the replay URL browserbaseSessionId = session.sessionId; // cleanupSession handles both closing Stagehand and cleanup (idempotent) await sessionManager.cleanupSession(previousSessionId); cleanupSuccessful = true; } else { process.stderr.write( `[tool.closeSession] No session found for ID: ${previousSessionId || "default/unknown"}\n`, ); } } catch (error: unknown) { cleanupErrorMessage = error instanceof Error ? error.message : String(error); process.stderr.write( `[tool.closeSession] Error cleaning up session (ID was ${previousSessionId || "default/unknown"}): ${cleanupErrorMessage}\n`, ); } // Step 2: SessionManager automatically resets to default on cleanup // Context.currentSessionId getter will reflect the new active session const oldContextSessionId = previousSessionId; process.stderr.write( `[tool.closeSession] Session context reset to default. Previous context session ID was ${oldContextSessionId || "default/unknown"}.\n`, ); // Step 3: Determine the result message const defaultSessionId = sessionManager.getDefaultSessionId(); if (cleanupErrorMessage && !cleanupSuccessful) { throw new Error( `Failed to cleanup session (session ID was ${previousSessionId || "default/unknown"}). Error: ${cleanupErrorMessage}. Session context has been reset to default.`, ); } if (cleanupSuccessful) { let successMessage = `Browserbase session (${previousSessionId || "default"}) closed successfully. Context reset to default.`; if (browserbaseSessionId && previousSessionId !== defaultSessionId) { successMessage += ` View replay at https://www.browserbase.com/sessions/${browserbaseSessionId}`; } return { content: [{ type: "text", text: successMessage }] }; } // No session was found let infoMessage = "No active session found to close. Session context has been reset to default."; if (previousSessionId && previousSessionId !== defaultSessionId) { infoMessage = `No active session found for session ID '${previousSessionId}'. The context has been reset to default.`; } return { content: [{ type: "text", text: infoMessage }] }; }; return { action: action, waitForNetwork: false, }; } const closeSessionTool: Tool<typeof CloseSessionInputSchema> = { capability: "core", schema: closeSessionSchema, handle: handleCloseSession, }; export default [createSessionTool, closeSessionTool]; ``` -------------------------------------------------------------------------------- /src/sessionManager.ts: -------------------------------------------------------------------------------- ```typescript import { BrowserContext, Stagehand } from "@browserbasehq/stagehand"; import type { Config } from "../config.d.ts"; import type { Cookie } from "playwright-core"; import { clearScreenshotsForSession } from "./mcp/resources.js"; import type { BrowserSession, CreateSessionParams } from "./types/types.js"; import { randomUUID } from "crypto"; /** * Create a configured Stagehand instance * This is used internally by SessionManager to initialize browser sessions */ export const createStagehandInstance = async ( config: Config, params: CreateSessionParams = {}, sessionId: string, ): Promise<Stagehand> => { const apiKey = params.apiKey || config.browserbaseApiKey; const projectId = params.projectId || config.browserbaseProjectId; if (!apiKey || !projectId) { throw new Error("Browserbase API Key and Project ID are required"); } const stagehand = new Stagehand({ env: "BROWSERBASE", apiKey, projectId, modelName: params.modelName || config.modelName || "gemini-2.0-flash", modelClientOptions: { apiKey: config.modelApiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY, }, ...(params.browserbaseSessionID && { browserbaseSessionID: params.browserbaseSessionID, }), experimental: config.experimental ?? false, browserbaseSessionCreateParams: { projectId, proxies: config.proxies, keepAlive: config.keepAlive ?? false, browserSettings: { viewport: { width: config.viewPort?.browserWidth ?? 1024, height: config.viewPort?.browserHeight ?? 768, }, context: config.context?.contextId ? { id: config.context?.contextId, persist: config.context?.persist ?? true, } : undefined, advancedStealth: config.advancedStealth ?? undefined, }, userMetadata: { mcp: "true", }, }, logger: (logLine) => { console.error(`Stagehand[${sessionId}]: ${logLine.message}`); }, }); await stagehand.init(); return stagehand; }; /** * SessionManager manages browser sessions and tracks active/default sessions. * * Session ID Strategy: * - Default session: Uses generated ID with timestamp and UUID for uniqueness * - User sessions: Uses raw sessionId provided by user (no suffix added) * - All sessions stored in this.browsers Map with their internal ID as key * * Note: Context.currentSessionId is a getter that delegates to this.getActiveSessionId() * to ensure session tracking stays synchronized. */ export class SessionManager { private browsers: Map<string, BrowserSession>; private defaultBrowserSession: BrowserSession | null; private readonly defaultSessionId: string; private activeSessionId: string; // Mutex to prevent race condition when multiple calls try to create default session simultaneously private defaultSessionCreationPromise: Promise<BrowserSession> | null = null; // Track sessions currently being cleaned up to prevent concurrent cleanup private cleaningUpSessions: Set<string> = new Set(); constructor(contextId?: string) { this.browsers = new Map(); this.defaultBrowserSession = null; const uniqueId = randomUUID(); this.defaultSessionId = `browserbase_session_${contextId || "default"}_${Date.now()}_${uniqueId}`; this.activeSessionId = this.defaultSessionId; } getDefaultSessionId(): string { return this.defaultSessionId; } /** * Sets the active session ID. * @param id The ID of the session to set as active. */ setActiveSessionId(id: string): void { if (this.browsers.has(id)) { this.activeSessionId = id; } else if (id === this.defaultSessionId) { // Allow setting to default ID even if session doesn't exist yet // (it will be created on first use via ensureDefaultSessionInternal) this.activeSessionId = id; } else { process.stderr.write( `[SessionManager] WARN - Set active session failed for non-existent ID: ${id}\n`, ); } } /** * Gets the active session ID. * @returns The active session ID. */ getActiveSessionId(): string { return this.activeSessionId; } /** * Adds cookies to a browser context * @param context Playwright browser context * @param cookies Array of cookies to add */ async addCookiesToContext( context: BrowserContext, cookies: Cookie[], ): Promise<void> { if (!cookies || cookies.length === 0) { return; } try { process.stderr.write( `[SessionManager] Adding ${cookies.length} cookies to browser context\n`, ); // Injecting cookies into the Browser Context await context.addCookies(cookies); process.stderr.write( `[SessionManager] Successfully added cookies to browser context\n`, ); } catch (error) { process.stderr.write( `[SessionManager] Error adding cookies to browser context: ${ error instanceof Error ? error.message : String(error) }\n`, ); } } /** * Creates a new Browserbase session using Stagehand. * @param newSessionId - Internal session ID for tracking in SessionManager * @param config - Configuration object * @param resumeSessionId - Optional Browserbase session ID to resume/reuse */ async createNewBrowserSession( newSessionId: string, config: Config, resumeSessionId?: string, ): Promise<BrowserSession> { if (!config.browserbaseApiKey) { throw new Error("Browserbase API Key is missing in the configuration."); } if (!config.browserbaseProjectId) { throw new Error( "Browserbase Project ID is missing in the configuration.", ); } try { process.stderr.write( `[SessionManager] ${resumeSessionId ? "Resuming" : "Creating"} Stagehand session ${newSessionId}...\n`, ); // Create and initialize Stagehand instance using shared function const stagehand = await createStagehandInstance( config, { ...(resumeSessionId && { browserbaseSessionID: resumeSessionId }), }, newSessionId, ); // Get the page and browser from Stagehand const page = stagehand.page; const browser = page.context().browser(); if (!browser) { throw new Error("Failed to get browser from Stagehand page context"); } const browserbaseSessionId = stagehand.browserbaseSessionID; if (!browserbaseSessionId) { throw new Error( "Browserbase session ID is required but was not returned by Stagehand", ); } process.stderr.write( `[SessionManager] Stagehand initialized with Browserbase session: ${browserbaseSessionId}\n`, ); process.stderr.write( `[SessionManager] Browserbase Live Debugger URL: https://www.browserbase.com/sessions/${browserbaseSessionId}\n`, ); // Set up disconnect handler browser.on("disconnected", () => { process.stderr.write( `[SessionManager] Disconnected: ${newSessionId}\n`, ); this.browsers.delete(newSessionId); if ( this.defaultBrowserSession && this.defaultBrowserSession.browser === browser ) { process.stderr.write( `[SessionManager] Disconnected (default): ${newSessionId}\n`, ); this.defaultBrowserSession = null; // Reset active session to default ID since default session needs recreation this.setActiveSessionId(this.defaultSessionId); } if ( this.activeSessionId === newSessionId && newSessionId !== this.defaultSessionId ) { process.stderr.write( `[SessionManager] WARN - Active session disconnected, resetting to default: ${newSessionId}\n`, ); this.setActiveSessionId(this.defaultSessionId); } // Purge any screenshots associated with both internal and Browserbase IDs try { clearScreenshotsForSession(newSessionId); const bbId = browserbaseSessionId; if (bbId) { clearScreenshotsForSession(bbId); } } catch (err) { process.stderr.write( `[SessionManager] WARN - Failed to clear screenshots on disconnect for ${newSessionId}: ${ err instanceof Error ? err.message : String(err) }\n`, ); } }); // Add cookies to the context if they are provided in the config if ( config.cookies && Array.isArray(config.cookies) && config.cookies.length > 0 ) { await this.addCookiesToContext( page.context() as BrowserContext, config.cookies, ); } const sessionObj: BrowserSession = { browser, page, sessionId: browserbaseSessionId, stagehand, }; this.browsers.set(newSessionId, sessionObj); if (newSessionId === this.defaultSessionId) { this.defaultBrowserSession = sessionObj; } this.setActiveSessionId(newSessionId); process.stderr.write( `[SessionManager] Session created and active: ${newSessionId}\n`, ); return sessionObj; } catch (creationError) { const errorMessage = creationError instanceof Error ? creationError.message : String(creationError); process.stderr.write( `[SessionManager] Creating session ${newSessionId} failed: ${errorMessage}\n`, ); throw new Error( `Failed to create/connect session ${newSessionId}: ${errorMessage}`, ); } } private async closeBrowserGracefully( session: BrowserSession | undefined | null, sessionIdToLog: string, ): Promise<void> { // Check if this session is already being cleaned up if (this.cleaningUpSessions.has(sessionIdToLog)) { process.stderr.write( `[SessionManager] Session ${sessionIdToLog} is already being cleaned up, skipping.\n`, ); return; } // Mark session as being cleaned up this.cleaningUpSessions.add(sessionIdToLog); try { // Close Stagehand instance which handles browser cleanup if (session?.stagehand) { try { process.stderr.write( `[SessionManager] Closing Stagehand for session: ${sessionIdToLog}\n`, ); await session.stagehand.close(); process.stderr.write( `[SessionManager] Successfully closed Stagehand and browser for session: ${sessionIdToLog}\n`, ); // After close, purge any screenshots associated with both internal and Browserbase IDs try { clearScreenshotsForSession(sessionIdToLog); const bbId = session?.stagehand?.browserbaseSessionID; if (bbId) { clearScreenshotsForSession(bbId); } } catch (err) { process.stderr.write( `[SessionManager] WARN - Failed to clear screenshots after close for ${sessionIdToLog}: ${ err instanceof Error ? err.message : String(err) }\n`, ); } } catch (closeError) { process.stderr.write( `[SessionManager] WARN - Error closing Stagehand for session ${sessionIdToLog}: ${ closeError instanceof Error ? closeError.message : String(closeError) }\n`, ); } } } finally { // Always remove from cleanup tracking set this.cleaningUpSessions.delete(sessionIdToLog); } } // Internal function to ensure default session // Uses a mutex pattern to prevent race conditions when multiple calls happen concurrently async ensureDefaultSessionInternal(config: Config): Promise<BrowserSession> { // If a creation is already in progress, wait for it instead of starting a new one if (this.defaultSessionCreationPromise) { process.stderr.write( `[SessionManager] Default session creation already in progress, waiting...\n`, ); return await this.defaultSessionCreationPromise; } const sessionId = this.defaultSessionId; let needsReCreation = false; if (!this.defaultBrowserSession) { needsReCreation = true; process.stderr.write( `[SessionManager] Default session ${sessionId} not found, creating.\n`, ); } else if ( !this.defaultBrowserSession.browser.isConnected() || this.defaultBrowserSession.page.isClosed() ) { needsReCreation = true; process.stderr.write( `[SessionManager] Default session ${sessionId} is stale, recreating.\n`, ); await this.closeBrowserGracefully(this.defaultBrowserSession, sessionId); this.defaultBrowserSession = null; this.browsers.delete(sessionId); } if (needsReCreation) { // Set the mutex promise before starting creation this.defaultSessionCreationPromise = (async () => { try { this.defaultBrowserSession = await this.createNewBrowserSession( sessionId, config, ); return this.defaultBrowserSession; } catch (creationError) { // Error during initial creation or recreation process.stderr.write( `[SessionManager] Initial/Recreation attempt for default session ${sessionId} failed. Error: ${ creationError instanceof Error ? creationError.message : String(creationError) }\n`, ); // Attempt one more time after a failure process.stderr.write( `[SessionManager] Retrying creation of default session ${sessionId} after error...\n`, ); try { this.defaultBrowserSession = await this.createNewBrowserSession( sessionId, config, ); return this.defaultBrowserSession; } catch (retryError) { const finalErrorMessage = retryError instanceof Error ? retryError.message : String(retryError); process.stderr.write( `[SessionManager] Failed to recreate default session ${sessionId} after retry: ${finalErrorMessage}\n`, ); throw new Error( `Failed to ensure default session ${sessionId} after initial error and retry: ${finalErrorMessage}`, ); } } finally { // Clear the mutex after creation completes or fails this.defaultSessionCreationPromise = null; } })(); return await this.defaultSessionCreationPromise; } // If we reached here, the existing default session is considered okay. this.setActiveSessionId(sessionId); // Ensure default is marked active return this.defaultBrowserSession!; // Non-null assertion: logic ensures it's not null here } // Get a specific session by ID async getSession( sessionId: string, config: Config, createIfMissing: boolean = true, ): Promise<BrowserSession | null> { if (sessionId === this.defaultSessionId && createIfMissing) { try { return await this.ensureDefaultSessionInternal(config); } catch { process.stderr.write( `[SessionManager] Failed to get default session due to error in ensureDefaultSessionInternal for ${sessionId}. See previous messages for details.\n`, ); return null; } } // For non-default sessions process.stderr.write(`[SessionManager] Getting session: ${sessionId}\n`); const sessionObj = this.browsers.get(sessionId); if (!sessionObj) { process.stderr.write( `[SessionManager] WARN - Session not found in map: ${sessionId}\n`, ); return null; } // Validate the found session if (!sessionObj.browser.isConnected() || sessionObj.page.isClosed()) { process.stderr.write( `[SessionManager] WARN - Found session ${sessionId} is stale, removing.\n`, ); await this.closeBrowserGracefully(sessionObj, sessionId); this.browsers.delete(sessionId); if (this.activeSessionId === sessionId) { process.stderr.write( `[SessionManager] WARN - Invalidated active session ${sessionId}, resetting to default.\n`, ); this.setActiveSessionId(this.defaultSessionId); } return null; } // Session appears valid, make it active this.setActiveSessionId(sessionId); process.stderr.write( `[SessionManager] Using valid session: ${sessionId}\n`, ); return sessionObj; } /** * Clean up a session by closing the browser and removing it from tracking. * This method handles both closing Stagehand and cleanup, and is idempotent. * * @param sessionId The session ID to clean up */ async cleanupSession(sessionId: string): Promise<void> { process.stderr.write( `[SessionManager] Cleaning up session: ${sessionId}\n`, ); // Get the session to close it gracefully const session = this.browsers.get(sessionId); if (session) { await this.closeBrowserGracefully(session, sessionId); } // Remove from browsers map this.browsers.delete(sessionId); // Always purge screenshots for this (internal) session id try { clearScreenshotsForSession(sessionId); } catch (err) { process.stderr.write( `[SessionManager] WARN - Failed to clear screenshots during cleanup for ${sessionId}: ${ err instanceof Error ? err.message : String(err) }\n`, ); } // Clear default session reference if this was the default if (sessionId === this.defaultSessionId && this.defaultBrowserSession) { this.defaultBrowserSession = null; } // Reset active session to default if this was the active one if (this.activeSessionId === sessionId) { process.stderr.write( `[SessionManager] Cleaned up active session ${sessionId}, resetting to default.\n`, ); this.setActiveSessionId(this.defaultSessionId); } } // Function to close all managed browser sessions gracefully async closeAllSessions(): Promise<void> { process.stderr.write(`[SessionManager] Closing all sessions...\n`); const closePromises: Promise<void>[] = []; for (const [id, session] of this.browsers.entries()) { process.stderr.write(`[SessionManager] Closing session: ${id}\n`); closePromises.push( // Use the helper for consistent logging/error handling this.closeBrowserGracefully(session, id), ); } try { await Promise.all(closePromises); } catch { // Individual errors are caught and logged by closeBrowserGracefully process.stderr.write( `[SessionManager] WARN - Some errors occurred during batch session closing. See individual messages.\n`, ); } this.browsers.clear(); this.defaultBrowserSession = null; this.setActiveSessionId(this.defaultSessionId); // Reset active session to default process.stderr.write(`[SessionManager] All sessions closed and cleared.\n`); } } ```