内容
# 并行 Playwright MCP 服务器
一个 [MCP](https://modelcontextprotocol.io) 服务器,运行**并行、会话隔离的 Playwright 浏览器上下文**,允许多个代理同时驱动各自的浏览器,而不会发生冲突。
<p align="center">
<img src="https://raw.githubusercontent.com/dgutierrez1/concurrent-playwright-mcp/main/docs/demo.gif" alt="多个代理各自驱动独立的浏览器会话" width="900">
</p>
<p align="center"><em>多个代理,每个代理驱动其独立的浏览器会话 — 并行。</em></p>
## 该服务器解决的问题
官方 Playwright MCP 服务器(`@playwright/mcp`)默认驱动一个共享的浏览器上下文,因此并发客户端共享一个 cookie jar、存储和标签页。这适用于单个代理执行单个任务,但当需要**并行**工作时就会出现问题:
- 两个子代理同时导航会相互冲突,页面、cookie 和存储。
- “登录为用户 A”的流程和“登录为用户 B”的流程共享一个 cookie jar,因此第二个登录会覆盖第一个登录。
- 没有干净的方法为每个任务提供自己的沙盒并独立关闭。
该服务器解决了这个问题。每个会话都获得**自己的** `BrowserContext`(一个类似隐身的配置文件:隔离的 cookie、`localStorage`、缓存和标签页),由您选择的 `sessionId` 键控。会话共享一个浏览器进程以提高效率,但绝不会共享状态。头条保证通过真实浏览器测试和基准测试得到验证,断言**零跨会话冲突**。
| | `@playwright/mcp`(默认) | concurrent-playwright-mcp |
| --- | --- | --- |
| 并行会话 | 共享上下文 | 每个 `sessionId` 的隔离上下文 |
| Cookie / 存储 | 共享 | 每个会话隔离 |
| 独立关闭 | 否 | 每个会话的 `browser_close_session` |
| 资源限制 | 无 | 会话上限 + 可选空闲驱逐 |
### 为什么不使用 `@playwright/mcp --isolated`?
官方服务器也可以隔离 — 其 `--isolated` 标志为每个**连接**提供自己的上下文。区别在于模型:
- **可寻址会话。** 这里的隔离由您选择并传递给每个调用的 `sessionId` 键控,因此单个客户端可以打开和驱动**多个**隔离会话,并有意路由每个调用。而 `--isolated` 中,会话只是传输连接 — 您无法从一个客户端寻址 N 个并行上下文。
- **可持久化,非临时。** `--isolated` 在浏览器关闭时丢弃所有状态。这里,您可以 `browser_save_storage_state` 并恢复它(创建时的 `storageStatePath`),以在会话之间恢复身份验证的配置文件。(上游请求关闭的命名/持久化会话超出范围。)
- **每个会话一个轻量级上下文** — 不是进程或容器每个会话 — 因此许多会话共享一个 Chromium。
适用于单个浏览器;当多个代理或任务每个需要自己的隔离、可寻址会话时使用 — 尤其是在 HTTP 上。
## 架构
```
cli.ts 入口点:加载配置 → 选择传输
├─ config.ts 解析 + 验证环境变量到类型化的配置
├─ transport/stdio.ts 运行在标准输入/输出(默认)
├─ transport/http.ts 运行 Streamable HTTP:每个客户端的会话管理器,一个共享浏览器
├─ browser-provider.ts 共享的、延迟启动的、 memoized Browser(一个端口)
└─ server.ts MCP 边:验证输入(Zod),强制执行策略,映射错误
├─ policy/url-policy.ts 导航允许列表 + file:/data: 阻止(纯)
├─ policy/path-policy.ts 文件系统路径限制(纯)
├─ errors.ts 错误分类:SessionError 基础 + 机器可读代码
└─ session-manager.ts 在 BrowserProvider 上隔离会话(核心)
└─ session.ts 一个隔离的上下文:参考操作、捕获、存储状态
```
这是**六边形架构**:不受信任的输入在边缘(`server.ts`)验证,并作为类型化数据传递到内部,因此领域(`SessionManager`/`BrowserSession`)不关心传输或重新验证,并不知道 MCP。浏览器是一个端口(`BrowserProvider`)注入到管理器中,因此隔离保证与虚拟浏览器进行单元测试(快速,无 Chromium 在 CI 中),而一个有门控集成测试证明它对抗真正的 Chromium。提供者延迟启动并 memoized,因此突发并发 `createSession` 调用共享一个浏览器;在 HTTP 上,每个客户端获取自己的会话命名空间,同时仍然共享 Chromium。
## 安装
```bash
npm install -g concurrent-playwright-mcp
# 一次性:下载 Playwright 驱动的浏览器
npx playwright install chromium
```
> 安装软件包不会下载 Chromium — 运行 `npx playwright install chromium` 一次,或第一次 `browser_create_session` 调用将失败,并带有 Playwright 的提示。
或者,从源代码运行:
```bash
npm install
npm run setup:browser # playwright install chromium
npm run build
```
## 从 MCP 客户端使用
### 通过标准输入/输出(一个客户端,例如 Claude Desktop / Claude Code / Cursor)
将您的客户端指向二进制文件;它为该客户端启动一个服务器进程:
```jsonc
{
"mcpServers": {
"concurrent-playwright": {
"command": "npx",
"args": ["-y", "concurrent-playwright-mcp"],
"env": {
"PW_HEADLESS": "true",
"PW_MAX_SESSIONS": "20",
"PW_IDLE_TIMEOUT_MS": "300000",
},
},
},
}
```
### 通过 HTTP(多个远程/独立代理 → 一个服务器)
运行一个长期运行的服务器,让多个代理客户端连接到它。每个客户端获取**自己的隔离会话命名空间**(它无法看到或触摸其他客户端的会话),而所有客户端共享一个 Chromium 进程:
```bash
PW_TRANSPORT=http PW_PORT=3000 npx -y concurrent-playwright-mcp
# 客户端连接到 Streamable HTTP 端点 http://<host>:3000/
```
大多数客户端直接接受 HTTP MCP URL;例如:
```jsonc
{
"mcpServers": {
"concurrent-playwright": { "url": "http://localhost:3000/" },
},
}
```
> **HTTP 模式**没有内置身份验证。它默认绑定 `127.0.0.1` 并拒绝不匹配的 `Host` 头(DNS 重新绑定保护开启),因此本地网页无法驱动它。要为真正的远程客户端提供服务,请设置 `PW_HOST` 并将外部可见的主机添加到 `PW_ALLOWED_HOSTS`(例如 `PW_ALLOWED_HOSTS=mcp.example.com:3000`),并将其放在您自己的身份验证代理/网络控件后面 — 任何可以访问端口的人都可以驱动浏览器。
### 每个代理的会话模式
核心思想:**每个代理或任务使用自己的 `sessionId`**。创建它一次,然后传递给每个调用;会话永远不会共享 cookie、存储或标签页,因此并行工作不会冲突。按 `browser_snapshot` 返回的 `ref` id(可访问性树)定位元素,而不是原始 CSS:
```
browser_create_session { "sessionId": "userA", "viewport": { "width": 1440, "height": 900 } }
browser_create_session { "sessionId": "userB", "viewport": { "width": 375, "height": 812 } } # 并行,全隔离
browser_navigate { "sessionId": "userA", "url": "https://example.com" }
browser_snapshot { "sessionId": "userA" } # → 带有引用如 [ref=e7] 的 YAML
browser_click { "sessionId": "userA", "ref": "e7", "element": "Sign in button" }
browser_save_storage_state { "sessionId": "userA", "path": "userA.json" } # 稍后重用登录
browser_close_session { "sessionId": "userA" }
```
> 这些 `browser_… { … }` 行是**示例**,不是您输入的文本。该模型发出结构化的工具调用,客户端通过 MCP 路由它;您永远不会手写工具调用。
## 工作原理(与代理集成)
涉及三个参与者:
- **操作员(您):** 安装软件包 + Chromium 并添加上面的配置块。那是整个手动界面 — 您不需要枚举工具或编写工具调用。
- **MCP 客户端/框架**(Claude Code、Claude Desktop、Cursor、…):启动服务器(标准输入/输出)或连接到它(HTTP),执行 MCP 握手,调用 `tools/list` 以**自动发现工具和它们的模式**,并将它们呈现给模型。
- **LLM / 代理:** 在循环中驱动工具:分配 `sessionId` → `browser_create_session` → `browser_navigate` → `browser_snapshot`(读取页面,获取 `ref`)→ 按 `ref` 操作 → … → `browser_close_session`。
配置发生在**三个层级**:
| 层级 | 设置者 | 位置 | 示例 |
| --- | --- | --- | --- |
| 服务器策略和限制 | 操作员 | 客户端配置中的 `env`(标准输入/输出)或服务器的 `env`(HTTP) | `PW_HEADLESS`、`PW_MAX_SESSIONS`、`PW_ALLOWED_ORIGINS`、`PW_OUTPUT_DIR`、`PW_TRANSPORT` |
| 每个会话 | 代理 | `browser_create_session` 参数 | `sessionId`(必需)、`viewport`、`storageStatePath` |
| 每个调用 | 代理 | 每个工具的参数 | `url`、`ref` + `element`、`text`、`path`、… |
安全限制仅存在于**服务器层** — 代理无法扩大它们(它无法逃离 `PW_OUTPUT_DIR` 或绕过 `PW_ALLOWED_ORIGINS`)。每个调用的参数在边缘处验证,带有默认值,因此代理可以省略可选参数。
## 工具
代理通过 `tools/list` 发现这些工具(带有完整的 JSON 模式);此参考资料适用于人类集成者。可选参数用 `?` 标记。除 `browser_list_sessions` 外,每个工具都需要 `sessionId`。
**会话生命周期**
| 工具 | 参数(除 `sessionId` 外) | 返回值 |
| --- | --- | --- |
| `browser_create_session` | `viewport?`、`storageStatePath?` | 确认 |
| `browser_list_sessions` | —(不带 `sessionId`) | JSON 数组的活动会话 ID |
| `browser_close_session` | — | 确认 |
| `browser_save_storage_state` | `path` | 保存的 cookies+localStorage 的路径 |
**导航和检查**
| 工具 | 参数(除 `sessionId` 外) | 返回值 |
| --- | --- | --- |
| `browser_navigate` | `url` | 确认 |
| `browser_navigate_back` | — | 确认 |
| `browser_snapshot` | — | 带有 `ref` 的可访问性 YAML |
| `browser_screenshot` | `fullPage?`、`path?` | PNG 图片(+ 保存的文件如果有 `path`) |
| `browser_evaluate` | `script` | JSON 序列化的结果 |
| `browser_wait_for` | `selector`、`state?`、`timeout?` | 确认 |
| `browser_press_key` | `key` | 确认 |
| `browser_resize` | `width`、`height` | 确认 |
| `browser_console_messages` | `onlyErrors?` | JSON |
| `browser_network_requests` | — | JSON |
| `browser_handle_dialog` | `accept`、`promptText?` | 确认 |
| `browser_tabs` | `action`(`list`/`new`/`close`/`select`)、`index?` | 确认 / 标签列表 |
**元素操作** — 按 `ref` + `element`(来自最新 `browser_snapshot` 的人类描述)定位:
| 工具 | 参数(除 `sessionId` 外) |
| --- | --- |
| `browser_click` | `ref`、`element` |
| `browser_hover` | `ref`、`element` |
| `browser_type` | `ref`、`element`、`text` |
| `browser_select_option` | `ref`、`element`、`values` |
| `browser_file_upload` | `ref`、`element`、`paths` |
| `browser_drag` | `sourceRef`、`sourceElement`、`targetRef`、`targetElement` |
| `browser_fill_form` | `fields` — `{ ref, element, value }` 数组 |
## 配置(环境变量)
无效值(例如负数的 `PW_MAX_SESSIONS`)被拒绝,并在标准错误输出中显示警告,并使用默认值;有效的配置在启动时记录到标准错误输出中。
| 变量 | 默认值 | 含义 |
| --- | --- | --- |
| `PW_HEADLESS` | `true` | `false` 显示浏览器窗口 |
| `PW_MAX_SESSIONS` | `50` | 活动会话上限 |
| `PW_MAX_TABS` | `20` | 每个会话的标签页上限 |
| `PW_MAX_CAPTURE` | `1000` | 每个会话保留的最大控制台/网络条目 |
| `PW_IDLE_TIMEOUT_MS` | `0`(关闭) | 此长时间无使用则驱逐会话 |
| `PW_OUTPUT_DIR` | `./output` | 写入截图的目录(路径仅限于此) |
| `PW_UPLOAD_DIR` | 未设置(任意) | 限制 `browser_file_upload` 路径到此目录 |
| `PW_ALLOWED_ORIGINS` | 未设置(任意) | 导航的源允许列表 |
| `PW_ALLOW_FILE_URLS` | `false` | 允许 `file:`/`data:` 导航 |
| `PW_ACTION_TIMEOUT_MS` | `15000` | 元素交互的每个操作超时 |
| `PW_EXECUTABLE_PATH` | 未设置 | 使用特定的 Chromium 构建 |
| `PW_TRANSPORT` | `stdio` | `http` 通过 Streamable HTTP 服务 |
| `PW_HOST` | `127.0.0.1` | `http` 模式绑定的主机 |
| `PW_PORT` | `3000` | `http` 模式绑定的端口 |
| `PW_ALLOWED_HOSTS` | 未设置 | `http` 模式中接受的额外 `Host` 值(见下文) |
## 安全模型
该服务器是**双重用途**:它为 MCP 客户端提供真正的浏览器控制权。像对待**半可信**客户端一样对待客户端,并像对待**不受信任**的页面一样对待它访问的页面(一个敌对页面可以尝试操纵一个轻信的代理调用这些工具,并带有攻击者选择的参):
- **导航**(`browser_navigate`)默认阻止 `file:` 和 `data:` URL — 最尖锐的本地文件读取 / SSRF 矢量。设置 `PW_ALLOW_FILE_URLS=true` 以允许它们。默认情况下,它仍然允许任何 `http(s)` URL,包括内部服务和云元数据(`169.254.169.254`);设置 `PW_ALLOWED_ORIGINS` 以限制导航到允许列表中的源。
- **截图和存储状态**(`browser_screenshot` 带有 `path`、`browser_save_storage_state` 和 `storageStatePath` 创建时)仅限于 `PW_OUTPUT_DIR`;尝试逃离它的路径(通过 `..` 或绝对路径)被拒绝。存储状态文件包含 cookie 并且可能持有身份验证令牌 — 以适当的方式处理输出目录。
- **文件上传**(`browser_file_upload`)读取本地文件。默认情况下,允许任何路径;设置 `PW_UPLOAD_DIR` 以仅限上传到一个目录。
- **`browser_evaluate`** 在页面中运行任意 JavaScript(沙盒到页面,而不是 Node)。它是特权功能;导航允许列表是最有效的控制。
- **HTTP 模式**默认绑定 `127.0.0.1`,启用 DNS 重新绑定保护(拒绝意外的 `Host` 头),限制请求体大小和并发会话,并为每个客户端提供隔离的会话命名空间。它 **没有身份验证** — 在将它暴露给 localhost 以外的网络之前,请参阅下面的警告。
错误通过带稳定 `code` 前缀的 in-band(`isError)返回码(例如 `NAVIGATION_BLOCKED`、`PATH_NOT_ALLOWED`、`SESSION_NOT_FOUND`),永远不会通过 JSON-RPC 渠道抛出。
## 演示和基准测试
```bash
npm run demo # 两个隔离会话(桌面 + 移动设备)并行驱动一个站点
npm run benchmark # N 个并行会话,报告吞吐量 + 断言 0 次冲突
npm run benchmark 25
```
## 开发
```bash
npm run check # 类型检查 + lint + 格式检查 + 单元测试
npm run test:coverage # 单元测试 + 覆盖率(无需浏览器)
npm run test:integration # 有门控的真实 Chromium 测试:隔离 + 确定性,离线 e2e
# 旅程通过 MCP 服务器(需要 Chromium)。添加
# PW_HEADLESS=false 以观看并行隔离的窗口。
npm run build # tsup -> dist/(ESM + d.ts)
```
测试层级:快速单元测试(合并门控,无浏览器)→ 确定性真实 Chromium 集成测试和多个 e2e 旅程通过 MCP 服务器(由 `RUN_INTEGRATION=1` 启用,也在 CI 中运行)。
参见 [`AGENTS.md`](./AGENTS.md) 本仓库的工程约定。## 许可证
MIT