playwright-mcp-server
# playwright-mcp-server
[](https://github.com/doucej/playwright-mcp-server/actions/workflows/ci.yml)
[](LICENSE)
[](https://nodejs.org)
A stateful [MCP](https://modelcontextprotocol.io) server that wraps [Playwright](https://playwright.dev/) for browser automation.
- **Stateful** — single Chromium browser/context/page shared across all tool calls
- **Persistent** — session state (cookies, localStorage, active URL) survives server restarts
- **Resilient** — structured MCP error results on every tool call; browser crash auto-recovery
- **Tested** — 49 unit tests, no browser required to run the test suite
---
## Quick start
```bash
git clone https://github.com/doucej/playwright-mcp-server.git
cd playwright-mcp-server
npm install
npx playwright install chromium
npm run build
npm start
# [playwright-mcp] Server ready — listening on stdio.
```
---
## MCP client configuration
### opencode (`~/.config/opencode/opencode.json`)
```json
{
"mcp": {
"playwright": {
"type": "local",
"command": ["node", "/path/to/playwright-mcp-server/dist/server.js"],
"enabled": true
}
}
}
```
### Claude Desktop (`claude_desktop_config.json`)
```json
{
"mcpServers": {
"playwright": {
"command": "node",
"args": ["/path/to/playwright-mcp-server/dist/server.js"]
}
}
}
```
### Hermes Agent (`config.yaml`)
```yaml
mcp_servers:
playwright:
command: "node"
args: ["/path/to/playwright-mcp-server/dist/server.js"]
env:
STATE_DIR: "/path/to/playwright-mcp-server/state"
timeout: 120
connect_timeout: 30
```
---
## Available tools
| Tool | Required args | Optional args | Description |
|------|--------------|---------------|-------------|
| `open_page` | `url` | — | Navigate to a URL |
| `go_back` | — | — | Browser back |
| `go_forward` | — | — | Browser forward |
| `reload` | — | — | Reload current page |
| `get_url` | — | — | Return current URL |
| `click` | `selector` | — | Click element |
| `type` | `selector`, `text` | `delay` | Type into element (appends) |
| `fill` | `selector`, `value` | — | Fill input (replaces existing value) |
| `press` | `selector`, `key` | — | Press keyboard key |
| `hover` | `selector` | — | Hover over element |
| `select_option` | `selector`, `value` | — | Select `<select>` option |
| `get_text` | `selector` | — | Get visible text content |
| `get_attribute` | `selector`, `attribute` | — | Get element attribute value |
| `get_html` | — | `selector` | Get inner HTML (default: `body`) |
| `evaluate` | `script` | — | Execute JavaScript, return JSON |
| `wait_for_selector` | `selector` | `timeout`, `state` | Wait for element |
| `screenshot` | — | `full_page`, `selector` | Capture PNG screenshot |
| `save_state` | — | — | Persist session to disk |
| `load_state` | — | — | Restore session from disk |
---
## State persistence
`save_state` (and graceful shutdown via SIGINT/SIGTERM) writes `state/session.json` containing Playwright's `storageState` (cookies + localStorage for all visited origins) plus the current page URL.
On the next startup the session is rehydrated automatically — `storageState` is passed to `newContext()` and the page navigates back to the saved URL.
Override the state directory:
```bash
STATE_DIR=/tmp/my-session npm start
```
---
## Project structure
```
src/
server.ts MCP server entrypoint — stdio transport, startup, graceful shutdown
session.ts PlaywrightSession — lazy init, crash recovery, state save/restore
persistence.ts Read/write state/session.json; STATE_DIR override
tools.ts All 19 tool definitions + handlers; Zod validation; handle() wrapper
types.ts SessionState / StorageState interfaces
tests/
persistence.test.ts 7 unit tests — no browser required
session.test.ts 11 unit tests — Playwright mocked with vi.hoisted
tools.test.ts 31 unit tests — session mocked inline
state/ Created at runtime; holds session.json (git-ignored)
dist/ TypeScript compile output (git-ignored)
```
---
## Development
```bash
npm run dev # run with tsx (no compile step)
npm test # run test suite (49 tests, no browser needed)
npm run typecheck # type-check without emitting
npm run build # compile TypeScript → dist/
```
---
## Architecture
**Singleton session** — the MCP server is a long-running stdio process; a single `PlaywrightSession` instance guarantees exactly one browser across all tool dispatches.
**Error isolation** — every tool handler runs inside the `handle()` wrapper (`src/tools.ts`). Both Zod validation errors and Playwright runtime exceptions are caught and returned as `{ isError: true }` MCP results. The server never throws.
**Crash recovery** — `ensureInitialized()` checks `browser.isConnected()` and `page.isClosed()` before every call. If the browser has crashed it relaunches and restores from the last persisted state.
---
## License
[MIT](LICENSE)
TDQS
Scored across 19 tools
Each tool targets a distinct browser action: navigation, input, extraction, evaluation, and state management. Type and fill are clearly differentiated by append vs. replace, and other tools like get_text and get_html have distinct return types.
All names use lowercase snake_case with imperative verbs. Most follow a verb_noun pattern (open_page, get_text), but a few are bare verbs (click, reload, hover), which is a minor deviation rather than a major inconsistency.
With 19 tools, the set is on the heavier side and crosses into the 'feels heavy' range. While browser automation justifies many actions, some tools like save_state and load_state seem auxiliary and could potentially be combined or omitted.
The set covers core browser workflows: navigation, interaction, extraction, waiting, screenshots, and state persistence. Minor gaps exist (e.g., no explicit drag-and-drop or dialog handling), but these can be worked around using evaluate or other tools.