跳到主要内容

数据截至 (上游 commit 7e0457a7cbf8)

空壳解剖:入口、发行包与容器化

这章讲什么:@playwright/mcp 这个 npm 包彻底拆开——它一共发几个文件、每个入口做什么、为什么没有 build 步骤、以及容器镜像是怎么搭的。读完你会知道:这个仓库的工程含量不在浏览器能力上,而在「怎么把别人的能力干净地发出去」。


1. 先看结论:发行包只有 6 个文件

.npmignore 用的是反向允许清单——第一行全排除,后面逐条放行(.npmignore:1-6):

**/*
!README.md
!LICENSE
!cli.js
!index.*
!config.d.ts

加上 npm 强制包含的 package.json,发出去的 tarball 内容是:

文件作用
package.json依赖、binexports、版本
cli.js命令行入口
index.js库入口(CommonJS)
index.d.ts库入口的类型
config.d.tsConfig 类型定义
README.md / LICENSE文档与许可

没有 dist/,没有编译产物,没有 src/ package.json:26 里的 build 脚本字面就是 "build": "echo OK"——CI 里那一步 npm run build(.github/workflows/ci.yml:41-42)是个占位。

这套写法的好处:新增任何文件默认不会被误发,除非有人显式加一行 !。相比「列出要排除什么」的常规写法,漏掉的风险从「忘了排除」变成「忘了放行」——后者会在使用时立刻暴露,前者可能悄悄泄漏源码或密钥。


2. 两个入口,各转发一件事

2.1 结构图

package.json
├─ "bin": { "playwright-mcp": "cli.js" } ← 命令行走这条
└─ "exports": { ".": index.js / index.d.ts } ← import 走这条

cli.js ──┬── argv 含 install-browser? ──是──► libCli.decorateProgram → 装浏览器

└── 否 ──► tools.decorateMCPCommand(program, version) → parseAsync

index.js ──► module.exports = { createConnection: tools.createConnection }

2.2 CLI 入口:一次特判 + 一次转发

cli.js 一共只有两个动作。第一个是特判 install-browser(cli.js:22-27):

if (process.argv.includes('install-browser')) {
const argv = process.argv.map(arg => arg === 'install-browser' ? 'install' : arg);
libCli.decorateProgram(program);
void program.parseAsync(argv);
return;
}

这段在干嘛:把用户敲的 install-browser 改写成 Playwright 自己的 install 子命令,然后交给 libCli.decorateProgram 去装配 Playwright 的安装命令。为什么要改名?因为 MCP 命令本身也有自己的一套参数,一个裸 install 容易和主命令冲突;对外用 install-browser 更明确,对内再还原。tests/cli.spec.ts:22-24 就守着这条路径:执行 node cli.js install-browser --help,输出里必须含 install

第二个动作是主路径(cli.js:29-33):

const packageJSON = require('./package.json');
const p = program.version('Version ' + packageJSON.version).name('Playwright MCP');
tools.decorateMCPCommand(p, packageJSON.version);
void program.parseAsync(process.argv);

重点看这三行的分工: 壳负责提供版本号和命令名,上游的 decorateMCPCommand 负责往这个 program 上挂所有选项和 action。也就是说,那 46 个 CLI 选项(见 03 章)一个都不在这个仓库里定义。

所有依赖都来自两个 bundle(cli.js:20-21):

const { program } = require('playwright-core/lib/utilsBundle');
const { tools, libCli } = require('playwright-core/lib/coreBundle');

coreBundle 是 playwright-core 打包好的产物路径。这个仓库对 MCP SDK 的依赖只出现在 devDependencies(package.json:40)——运行时不直接依赖 @modelcontextprotocol/sdk,协议实现被上游 bundle 吞掉了。

2.3 库入口:一行导出

index.js:18-19 全部内容就是:

const { tools } = require('playwright-core/lib/coreBundle');
module.exports = { createConnection: tools.createConnection };

类型签名在 index.d.ts:21:

export declare function createConnection(
config?: Config,
contextGetter?: () => Promise<BrowserContext>
): Promise<Server>;

三个要点:

  • 返回的是 MCP SDK 的 Server 对象,所以 transport 完全由调用方决定;
  • 第二个参数 contextGetter 允许自带浏览器上下文——不用它拉起浏览器,你给它一个已有的 BrowserContext;
  • 类型从 ./config 引入(index.d.ts:18),这就是 config.d.ts 必须进发行包的原因。

README 的程序化用法示例把它接到 SSE transport 上(README.md:840-857):

const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport);

2.4 一个被测试钉死的兼容性细节

tests/library.spec.ts:20-27 有一个专门的回归测试:写一个 .cjs 文件,用动态 import('@playwright/mcp') 拿到模块再调 createConnection(),断言输出 OK。测试标题直接写着「library can be used from CommonJS」,并挂了 issue 链接 microsoft/playwright-mcp#456

为什么值得一个专门测试: 这个包是 CommonJS(index.jsmodule.exports),但 exports 字段里同时声明了 typesdefault(package.json:32-38)。ESM/CJS 双向互操作是 npm 生态最容易悄悄坏掉的地方之一,而它坏掉时不会报编译错——只会在用户运行时炸。用一个跑真 node 子进程的测试守住,是最便宜的办法。


3. 传输层:两种接法

本仓库看不到 transport 的实现,但从配置和测试能确定对外有两种接法。

接法客户端怎么配依据
stdio(默认)command: npx,args: ["@playwright/mcp@latest"]README.md:32-46;测试也是 StdioClientTransportnode cli.js(tests/fixtures.ts:202-215)
HTTPserver 侧加 --port 8931,客户端配 url: http://localhost:8931/mcpREADME.md:781-795

README 给出了一个具体的「什么时候必须用 HTTP」的理由(README.md:779-780):当你要在没有显示器的机器上跑有头浏览器,或者从 IDE 的 worker 进程里跑时,应该在一个有 DISPLAY 的环境里单独起 server,再用 --port 让客户端连过来。

HTTP 模式还额外解锁两个配置项:server.allowedHosts 用于 DNS rebinding 防护(注释明确说「这不是为了 CORS」,config.d.ts:123-127),sharedBrowserContext 让所有连接过来的 HTTP 客户端复用同一个浏览器上下文(config.d.ts:144-147)。


4. 容器化:四阶段,把浏览器下载和源码变更解耦

4.1 阶段图

怎么读:从左到右是继承关系,最右边是最终镜像。

node:22-bookworm-slim


[base] npm ci --omit=dev + playwright-core install-deps chromium
│ │
│ └────────► [browser] npx playwright-core install --no-shell chromium
│ │
└──► [builder] npm ci │ COPY --from=browser
(完整依赖,COPY 源码) ▼
[runtime] chown node_modules → USER node
COPY cli.js package.json
ENTRYPOINT node /app/cli.js --headless …

4.2 每阶段在解什么问题

base(Dockerfile:8-21) 只装生产依赖和 Playwright 的系统依赖。用了 --mount=type=cache 缓存 npm、--mount=type=bind 挂载 package*.json 而不是 COPY——后者意味着这层的缓存键只看依赖清单,源码怎么改都不会让它失效。

browser(Dockerfile:42-44) 单独一层下载 Chromium。Dockerfile 里的注释把动机写得很清楚(Dockerfile:39-41):浏览器只在依赖或系统依赖变化时才重新下载,只改源码时缓存可复用。浏览器二进制有几百 MB,这一层的价值很实在。

builder(Dockerfile:26-34) 装完整依赖并 COPY 源码。有意思的是最终镜像并没有 COPY --from=builder——它继承的是 base。所以这一层在当前 Dockerfile 里更像是给构建期/测试期用的中间产物。

runtime(Dockerfile:49-67) 做三件事:把 node_modules 的属主改成非 root 的 node 用户并切过去(Dockerfile:56-58)、只 COPY cli.jspackage.json(Dockerfile:61)、把工作目录设成用户家目录并注明「当前工作目录必须可写,因为 MCP 可能要在里面建默认输出目录」(Dockerfile:63-64)。

4.3 入口点把三个选项焊死了

ENTRYPOINT ["node", "/app/cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]

Dockerfile:66-67 的注释解释了原因:只跑 headless 和 chromium,因为其他浏览器需要这个镜像里没有的额外依赖。README 也在 Docker 小节顶部重申了这条限制(README.md:804)。

注意 --no-sandbox:在容器里禁用 Chromium 沙箱是常见做法(容器本身提供隔离),但这意味着镜像的安全性完全依赖容器边界,不能再指望浏览器进程沙箱。

4.4 长驻服务的跑法

README 给了一个绕过 ENTRYPOINT 的写法(README.md:820-828):用 --entrypoint node 覆盖,然后手动传 /app/cli.js --headless --browser chromium --no-sandbox --port 8931 --host 0.0.0.0。这样容器变成一个长驻 HTTP 服务,而不是让每个 MCP client 各 spawn 一个容器。


5. 版本身份:三个文件必须同版本

这个包的版本号出现在三个地方,而且必须一致:

位置字段值(本 commit)
package.json:3version0.0.79
server.json:8version0.0.79
server.json:12packages[0].version0.0.79

server.json 是给 MCP Registry 用的元数据(server.json:2 指向官方 schema),里面声明包名 @playwright/mcp、registry 类型 npm、传输方式 stdio(server.json:9-17)。package.json:14 里还有一个 mcpName: io.github.microsoft/playwright-mcp,和 server.json:3name 一致——这是 registry 的所有权校验用的。

一致性不是靠人记,是靠发布流水线里一段内联 Node 脚本硬校验的(.github/workflows/publish.yml:81-98):它比对 server.versionpackages[].versionpackage.jsonversion,任一不匹配就打印全部 mismatch 然后 exit(1)

发布相关的完整流程见 05 章


6. 这一章的可借鉴点

  1. 反向允许清单的 .npmignore —— 默认拒绝,显式放行。适合任何「发行物应该很小」的包。
  2. 入口只做转发,版本号由壳提供 —— 壳与实现解耦后,壳几乎不需要改动;上游加了新选项,壳自动继承。
  3. 给 CJS/ESM 互操作写一个跑真 node 的回归测试 —— 成本一个文件,防的是最难排查的一类线上故障(tests/library.spec.ts)。
  4. 把重下载物单独做一层 —— Docker 里凡是「几百 MB 且不随源码变」的东西(浏览器、模型、数据集),都值得独立阶段 + COPY --from
  5. 多处版本号 → 一段 CI 硬校验 —— 不要靠 release checklist,靠脚本。

7. 代码地图

主题文件路径符号名 / 锚点
install-browser 特判cli.jslibCli.decorateProgram
MCP 命令装配与解析cli.jstools.decorateMCPCommandprogram.parseAsync
bundle 来源cli.jsplaywright-core/lib/utilsBundleplaywright-core/lib/coreBundle
库入口导出index.jscreateConnection
库入口类型index.d.tscreateConnectionConfigBrowserContext
bin / exports 声明package.jsonbinexportsmcpName
发行文件允许清单.npmignore**/* + ! 放行行
容器构建四阶段DockerfilebasebuilderbrowserENTRYPOINT
Registry 元数据server.jsonnamepackagestransport
CJS 互操作回归tests/library.spec.tstest('library can be used from CommonJS')
install-browser 回归tests/cli.spec.tstest('install-browser --help')
版本一致性校验.github/workflows/publish.ymlValidate server.json version matches package.json