# browse-tool

MCP (Model Context Protocol) server for browser automation using Playwright. Provides comprehensive browser control, profile management, and LLM-driven automation capabilities.

## Features

- 37 browser automation tools for complete browser control
- Multi-browser support (Chromium, Firefox, WebKit)
- Profile management for persistent browser sessions
- Page registry for managing multiple tabs/windows
- LLM-controlled automation with pause/resume flow
- Session management for organized browser operations

## Installation

```bash
pnpm install
```

## Development

```bash
pnpm dev
```

## Build

```bash
pnpm build
```

## Test

```bash
pnpm test
```

## Usage with Claude Code

Add to your Claude Code configuration (`.mcp.json`):

```json
{
  "mcpServers": {
    "browse-tool": {
      "command": "npx",
      "args": ["browse-tool", "mcp-serve"]
    }
  }
}
```

### CLI Options

```bash
browse-tool mcp-serve [options]

Options:
  -t, --type <type>      Transport type: stdio, streamable-http (default: "stdio")
  -b, --browser <type>   Default browser: chromium, firefox, webkit (default: "chromium")
  --headless             Run browsers in headless mode
  --no-headless          Run browsers in headed mode (visible window)
  --host <host>          Default host for HTTP services (default: "localhost")
  --port <port>          Port for streamable HTTP transport (default: "3201")
  -p, --profile <name>   Default profile name for browser sessions
  --custom-tools <path>  Expose tools from a folder containing tools.yaml
  --snippets-dir <path>  Directory used by browser_run_code to save and load reusable snippets
```

### Streamable HTTP Transport

For clients that can connect over MCP streamable HTTP, start `mcp-serve` with:

```bash
browse-tool mcp-serve --type streamable-http --host 127.0.0.1 --port 3201
```

This exposes the MCP endpoint at `http://127.0.0.1:3201/mcp` and keeps the existing browse-tool HTTP service for browser execution on its own port.

This endpoint serves modern MCP `2026-07-28` only. Send `x-browse-owner: <stable-owner-id>` on **every** stateful MCP request, and send `x-profile: <profile-name>` on every request when pinning a profile. The owner is application routing identity, not authentication. Tool discovery works without an owner; tool calls without one return an error. Browser state survives individual HTTP requests and client disconnects, scoped to the owner and profile, and is cleaned up after inactivity or shutdown. Modern HTTP does not use `Mcp-Session-Id` or a legacy initialize request.

## Custom Tools

Custom tools are loaded from a folder containing:

- `tools.yaml`
- one or more `.ts` scripts referenced by the manifest

`tools.yaml` entries support:

- `name`
- `description`
- `script`
- `capabilities`
- `inputSchema`
- optional `suggestionActions`
  - a short text string describing what should be done next after the tool succeeds
  - this value is exposed in custom-tool discovery and injected into the tool execution result

If you start the MCP server with `--custom-tools <path>`, those tools are added to the MCP tool list automatically.

### Expose Custom Tools Through MCP

```bash
browse-tool mcp-serve --custom-tools scripts/automation/linkedin-prospecting
```

Claude Code example:

```json
{
  "mcpServers": {
    "browse-tool": {
      "command": "npx",
      "args": [
        "browse-tool",
        "mcp-serve",
        "--custom-tools",
        "scripts/automation/linkedin-prospecting"
      ]
    }
  }
}
```

### Flat CLI Commands

List tools from a custom-tool directory:

```bash
browse-tool list-custom-tools scripts/automation/linkedin-prospecting
```

Execute a custom tool:

```bash
browse-tool exec-custom-tool \
  scripts/automation/linkedin-prospecting \
  list_posts_from_linkedin_feed \
  '{"pageId":"page-2","maxPosts":3}'
```

### Notes

- The flat commands talk to the browse-tool HTTP server.
- Custom tools are not exposed to MCP unless `mcp-serve` is started with `--custom-tools`.
- For slow UI-driven tools, prefer bounded inputs such as `maxPosts` so test runs stay manageable.
- If a manifest defines `suggestionActions`, browse-tool injects that text into the custom tool execution result.

## Tool Reference

### Input Tools (8)

| Tool | Description |
|------|-------------|
| `browser_click` | Click on an element |
| `browser_fill` | Fill a form field with text (clears first) |
| `browser_type` | Type text into an element (appends) |
| `browser_hover` | Hover over an element |
| `browser_select` | Select an option from a dropdown |
| `browser_drag` | Drag from one element to another |
| `browser_press_key` | Press a keyboard key |
| `browser_upload_file` | Upload a file to a file input |

### Navigation Tools (5)

| Tool | Description |
|------|-------------|
| `browser_navigate` | Navigate to a URL |
| `browser_go_back` | Go back in browser history |
| `browser_go_forward` | Go forward in browser history |
| `browser_reload` | Reload the current page |
| `browser_wait_for` | Wait for an element or condition |

### Snapshot Tools (3)

| Tool | Description |
|------|-------------|
| `browser_screenshot` | Take a screenshot |
| `browser_pdf` | Generate PDF of the page |
| `browser_snapshot` | Get accessibility tree snapshot |

### Tab/Page Management Tools (4)

| Tool | Description |
|------|-------------|
| `browser_list_pages` | List all open pages |
| `browser_select_page` | Switch to a specific page |
| `browser_new_page` | Open a new page |
| `browser_close_page` | Close a page |

### Dialog/Network/Console Tools (7)

| Tool | Description |
|------|-------------|
| `browser_handle_dialog` | Handle JavaScript dialogs (alert, confirm, prompt) |
| `browser_list_network_requests` | List captured network requests |
| `browser_get_network_request` | Get details of a specific network request |
| `browser_list_console_messages` | List console messages |
| `browser_evaluate_script` | Execute JavaScript in the page |
| `browser_resize_page` | Resize the browser viewport |
| `browser_emulate` | Emulate device, geolocation, or network conditions |

### Code Tools (2)

| Tool | Description |
|------|-------------|
| `browser_run_code` | Execute inline code against `{ page, context, browser }`, optionally save it as a reusable snippet |
| `browser_list_snippets` | List saved snippets by name, description, and snippetPath |

### Testing/Tracing Tools (3)

| Tool | Description |
|------|-------------|
| `browser_expect` | Assert conditions on elements |
| `browser_start_trace` | Start Playwright tracing |
| `browser_stop_trace` | Stop tracing and save trace file |

### Profile Management Tools (4)

| Tool | Description |
|------|-------------|
| `browser_list_profiles` | List all saved profiles |
| `browser_create_profile` | Create a new browser profile |
| `browser_delete_profile` | Delete a profile |
| `browser_save_profile_state` | Save current browser state to profile |

## Saved Snippets

If you start `mcp-serve` or `http-serve` with `--snippets-dir <path>`, `browser_run_code` can persist inline snippets and later execute them again by `snippetPath`.

Save an inline snippet:

```json
{
  "tool": "browser_run_code",
  "arguments": {
    "pageId": "page-1",
    "code": "return await page.title();",
    "saveAs": {
      "name": "Get Page Title",
      "description": "Return the current page title"
    }
  }
}
```

List saved snippets:

```json
{
  "tool": "browser_list_snippets",
  "arguments": {}
}
```

Run a saved snippet:

```json
{
  "tool": "browser_run_code",
  "arguments": {
    "pageId": "page-1",
    "snippetPath": "get-page-title.ts"
  }
}
```

## Profile Management

Profiles allow you to persist browser state (cookies, localStorage, etc.) across sessions.

### Create a Profile

```json
{
  "tool": "browser_create_profile",
  "arguments": {
    "name": "my-profile",
    "browser": "chromium",
    "headless": false
  }
}
```

### Save Profile State

```json
{
  "tool": "browser_save_profile_state",
  "arguments": {
    "sessionId": "automation-1",
    "profileName": "my-profile"
  }
}
```

## Proxy Configs

`browser_launch` accepts an explicit proxy object, or `proxy: true` to load `session.yaml` from the proxy config directory.

```json
{
  "tool": "browser_launch",
  "arguments": {
    "profileName": "work",
    "proxy": true
  }
}
```

By default, proxy configs live beside the profiles directory. With the default profiles directory, this is `~/.browse-tool/proxy-config`.

Files are resolved in this order:

1. Explicit `proxy` object in the `browser_launch` arguments
2. `{proxy-config}/{profileName}.yaml`
3. `{proxy-config}/session.yaml` when `proxy: true` is used
4. `{proxy-config}/default.yaml`

Example YAML:

```yaml
server: http://proxy.example.com:8080
username: ${PROXY_USER}
password: ${PROXY_PASSWORD}
bypass: localhost,127.0.0.1
```

Set a custom directory with `--proxy-config-dir <path>` or `BROWSE_TOOL_PROXY_CONFIG_DIR`.

## Configuration

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `PLAYWRIGHT_PROFILES_DIR` | `~/.browse-tool/profiles` | Directory for storing browser profiles |
| `BROWSE_TOOL_PROXY_CONFIG_DIR` | beside profiles dir as `proxy-config` | Directory for browser proxy YAML configs |
| `PLAYWRIGHT_REGISTRY_DIR` | `.agiflow/registry` | Directory for HTTP server registry |
| `PLAYWRIGHT_PIDS_DIR` | `.pids` | Directory for tracking server PIDs |
| `PLAYWRIGHT_BROWSER_IDLE_TIMEOUT_MINUTES` | `30` | Minutes a browser may sit idle before it is closed |
| `BROWSE_TOOL_SERVER_IDLE_TIMEOUT_MINUTES` | `60` | Minutes the daemon may sit idle before it shuts itself down (`0` disables) |
| `NODE_ENV` | `development` | Runtime environment (`development`, `production`) |

### CLI Flags vs Environment Variables

CLI flags take precedence over environment variables. For example, `--profiles-dir /custom/path` overrides `PLAYWRIGHT_PROFILES_DIR`.

### Idle Cleanup

A background sweep runs every 60 seconds and tears down resources no agent is working on:

- **Pages** idle for more than 15 minutes are closed.
- **Browsers** idle for more than the browser idle timeout (default 30 minutes) are closed, including their whole process tree.
- **Browsers with no pages left** use the shorter 15-minute timeout.
- **Stealth-mode** Chrome processes are swept on the same browser timeout.
- **The daemon itself** exits once it has served no request and owns no browsers for the server idle timeout (default 60 minutes), releasing its port and process registry rows on the way out. Set `BROWSE_TOOL_SERVER_IDLE_TIMEOUT_MINUTES=0` to keep it running forever. Health checks do not count as requests: discovery polls them on daemons it never goes on to use.

Idle is measured from the last real agent activity (a tool call against the browser, an extension task result, or a recording chunk upload). An open extension WebSocket does **not** count as activity — the extension pings and reconnects on its own, so a live socket would otherwise keep a forgotten browser alive forever. An in-progress recording does keep its browser and pages alive, because each uploaded chunk counts as activity.

Set the timeout with `--idle-timeout <minutes>`, `commands.httpServe.idleTimeout` / `commands.mcpServe.idleTimeout` in `.browse-tool/config.json`, or `PLAYWRIGHT_BROWSER_IDLE_TIMEOUT_MINUTES`.

> **Note:** the HTTP daemon is long-lived and shared across worktrees, and it reads the idle timeout once at startup. A later client that attaches to an already-running daemon cannot change that daemon's timeout — restart the daemon (`browse-tool stop`) to apply a new value.

> **Note:** `browse-tool stop` only stops daemons registered under the current worktree, so it will not take browsers away from an agent working in another one. Use `browse-tool stop --all` when you do want to clear every worktree's daemon.

## Error Handling

### Common Failure Modes

| Failure | Cause | Recovery |
|---------|-------|----------|
| Browser launch failure | Missing browser binary or incompatible version | Run `npx playwright install chromium` to install browsers |
| Navigation timeout | Page took too long to load | Increase timeout via `browser_wait_for` or check network connectivity |
| Element not found | Selector did not match any element | Use `browser_snapshot` to inspect the current DOM tree |
| Profile not found | Referenced profile does not exist | Use `browser_list_profiles` to list available profiles |
| Session expired | Browser context was closed or crashed | Launch a new browser session with `browser_navigate` |
| Spec bundler build error | Syntax errors or unresolvable imports in spec file | Check the spec file for errors; see `SpecBundlerBuildError.recovery` |
| Spec bundler CLI error | `bun` not installed or not in PATH | Install bun: `curl -fsSL https://bun.sh/install \| bash` |

### Error Codes

All structured errors include a `code` field for programmatic handling and a `recovery` field with suggested resolution steps.

## Spec Bundler

The `SpecBundlerService` bundles Playwright spec files before execution, replacing `@playwright/test` imports with a lightweight stub module so specs can run inside the MCP server.

### `testInfo.attach` compatibility

`run_spec` provides a stable subset of Playwright `TestInfo` to test callbacks and per-test hooks:

```typescript
test('captures evidence', async ({ page }, testInfo) => {
  await testInfo.attach('dashboard', {
    body: await page.screenshot(),
    contentType: 'image/png',
  });
});
```

Attachments may use either `body` or `path`. The runner copies the evidence to its temporary attachment directory and returns `{ name, contentType, path }` metadata on the matching test result. Accessing an unsupported `TestInfo` property fails with `RUN_SPEC_UNSUPPORTED_TEST_INFO_CAPABILITY` so compatibility failures remain distinct from browser and product failures.

### Runtime Requirements

- **Bun runtime** (preferred): When the MCP server runs under Bun, `Bun.build()` is used directly.
- **Node.js runtime** (fallback): When running under Node.js, the service spawns `bun` as a CLI subprocess. Ensure `bun` is installed and accessible in `PATH`:
  ```bash
  curl -fsSL https://bun.sh/install | bash
  # or
  npm i -g bun
  ```

### Programmatic Usage

```typescript
import { SpecBundlerService } from 'browse-tool';

const bundler = new SpecBundlerService();
const { outputPath, cleanup } = await bundler.bundle('/path/to/my-spec.ts');
try {
  // Use the bundled output at outputPath
} finally {
  await cleanup();
}
```

### Error Handling

| Error Class | Code | When |
|-------------|------|------|
| `SpecBundlerBuildError` | `SPEC_BUNDLER_BUILD_FAILED` | Bundler reports syntax or resolution errors |
| `SpecBundlerNoOutputError` | `SPEC_BUNDLER_NO_OUTPUT` | Bundling produces no output file |
| `SpecBundlerCliError` | `SPEC_BUNDLER_CLI_FAILED` | `bun` CLI subprocess fails to execute |

## Architecture

```
browse-tool/
├── src/
│   ├── commands/        # CLI commands (mcp-serve)
│   ├── container/       # InversifyJS IoC container
│   ├── server/          # MCP server factory
│   ├── services/        # Core services
│   │   ├── AutomationRunner.ts   # Script execution
│   │   ├── BrowserService.ts     # Browser lifecycle
│   │   ├── PageRegistry.ts       # Page tracking
│   │   ├── PauseController.ts    # Pause/resume flow
│   │   ├── ProfileService.ts     # Profile management
│   │   └── SessionService.ts     # Session tracking
│   ├── tools/           # MCP tool implementations
│   ├── transports/      # Transport handlers (stdio, streamable HTTP)
│   └── types/           # TypeScript type definitions
├── tests/
│   ├── services/        # Service unit tests
│   ├── tools/           # Tool unit tests
│   ├── integration/     # Integration tests
│   └── fixtures/        # Test fixtures
└── README.md
```

## License

MIT
