我想让 AI 查网页、检查页面时,自己还能继续干活。之前用这套工具,却经常得停下来处理浏览器。
之前遇到了什么问题
- 每开一个新的 AI 会话,Chrome 就弹出一次连接授权: "Allow remote debugging?"。
- AI 打开网页时,会抢占前台标签页,从而影响正常使用,每次都会被打断干扰。
- 中间试过独立 Chrome,但网站登录状态和日常浏览器分开了。
- 项目配置写进个人绝对路径,提交给别人以后无法直接使用。
配好以后是什么效果
| 操作 | 配好后的表现 |
|---|---|
| 让 AI 打开网页 | 在日常 Chrome 中创建后台标签页,原标签页保持选中 |
| 再开一个 AI 会话 | 复用已经授权的浏览器连接 |
| 继续使用浏览器 | 当前页面、地址栏和页面焦点保持不变 |
| 提交项目代码 | 项目配置里不出现自己的 Node 路径和用户目录 |
首次建立 Chrome 连接时仍需点一次允许。Chrome 关闭、连接断开或常驻进程重启后,也可能再次要求授权。
下面的操作只走一条路线:macOS + Codex CLI + 日常正在使用的 Chrome。Claude Code 等客户端放在文末的可选部分,先不用看。
Windows 用户可参考配置思路,命令和路径需按 Windows 调整;文末的 quiet 启动器尚未在 Windows 实测。
这次实际使用 Node.js 22.21.1、Codex 0.164.0 和官方包 1.12.0。文末脚本按 1.12.0 实现,复现时先保持这个版本。
Step 1:检查电脑上的工具
打开终端,依次执行:
node --version
npm --version
codex --version
三条命令都应该返回版本号。这次 Node.js 的结果是 v22.21.1。
如果提示 command not found,先安装对应工具,再继续。这里默认你已经能正常使用 Codex。
再到 Chrome 菜单里的“关于 Google Chrome”查看版本。本文使用的 autoConnect 方式要求 Chrome 144 及以上版本。官方连接说明
后面的命令都在同一个终端操作。涉及项目文件时,先让终端进入你准备使用 Codex 的项目目录。
这一关通过的标志:Node、npm、Codex 都能显示版本,Chrome 版本符合要求。
Step 2:备份配置,卸载旧包
先退出其他正在调用浏览器的 AI 会话,保留终端和日常 Chrome。
在终端执行下面的备份命令。不存在的文件会跳过,备份目录会显示在终端里:
chrome_mcp_backup_dir="$HOME/chrome-mcp-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$chrome_mcp_backup_dir"
if [ -f "$HOME/.codex/config.toml" ]; then
cp "$HOME/.codex/config.toml" "$chrome_mcp_backup_dir/codex-user-config.toml"
fi
if [ -f ".codex/config.toml" ]; then
cp ".codex/config.toml" "$chrome_mcp_backup_dir/codex-project-config.toml"
fi
if [ -f ".mcp.json" ]; then
cp ".mcp.json" "$chrome_mcp_backup_dir/project-mcp.json"
fi
if [ -f "$HOME/.config/cd4a/config.json" ]; then
cp "$HOME/.config/cd4a/config.json" "$chrome_mcp_backup_dir/cd4a-config.json"
fi
if [ -f "$HOME/.codex/AGENTS.md" ]; then
cp "$HOME/.codex/AGENTS.md" "$chrome_mcp_backup_dir/codex-AGENTS.md"
fi
echo "$chrome_mcp_backup_dir"
如果以前装过官方 CLI,先停止它的旧常驻进程:
if command -v chrome-devtools >/dev/null 2>&1; then
chrome-devtools stop
fi
停止进程这一步只用于本次重装,日常使用不要每次执行。
接着卸载旧包:
npm uninstall -g chrome-devtools-mcp
再检查:
npm ls -g chrome-devtools-mcp --depth=0
显示 (empty) 或没有列出这个包,说明当前 npm 环境中的全局包已经卸载。
这里不需要删除整个 npm 缓存,也不需要删除 Chrome 的浏览器资料。我们这次旧全局包是 1.9.0,部分进程却来自 npx 启动的其他版本,所以还要继续清理下一步的旧入口。
Step 3:移除旧的 Chrome MCP 配置
先移除 Codex 用户配置里的旧注册:
codex mcp remove chrome-devtools
如果提示没有找到这个服务器,可以继续。
再用编辑器检查项目中的两个文件。没有这些文件就跳过:
| 文件 | 要处理的内容 |
|---|---|
.codex/config.toml | 删除 chrome-devtools 的 MCP 配置块,保留其他公共设置 |
.mcp.json | 删除 mcpServers 中的 chrome-devtools 条目,保留其他服务器 |
旧配置常见的启动部分是这样:
{
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
}
如果之前已经改过,也可能指向旧的 9333 端口,或者包含个人启动器路径。这些 Chrome MCP 项目条目都一起移除,稍后统一放进用户配置。
编辑 JSON 后检查括号和逗号。如果 .mcp.json 原来只有 Chrome 一个服务器,清理后的内容可以是:
{
"mcpServers": {}
}
如果还装了 Claude Code 插件市场中的 Chrome DevTools 插件,先在该客户端的插件管理器中移除旧入口。本教程使用 npm 加本地启动器这一套安装方式。
这一关通过的标志:项目两份文件里都没有 Chrome MCP 的个人配置,其他 MCP 设置仍然保留。
Step 4:安装官方包
在终端执行:
npm install -g chrome-devtools-mcp@1.12.0
安装后执行:
chrome-devtools-mcp --version
应该看到:
1.12.0
这个包同时提供 MCP 服务和 chrome-devtools CLI。官方安装说明
Step 5:安装官方 Skill
Skill 会告诉 AI 什么时候使用浏览器工具,以及怎么操作。官方包里已经带了这七个 Skill:
chrome-devtools
chrome-devtools-cli
a11y-debugging
cookie-debugging
debug-optimize-lcp
memory-leak-debugging
troubleshooting
先把旧的同名 Skill 目录或链接移到备份目录。下面只处理这七个名字,备份位置会打印出来:
chrome_mcp_skill_backup_dir="$HOME/chrome-mcp-skills-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$chrome_mcp_skill_backup_dir"
for skill in chrome-devtools chrome-devtools-cli a11y-debugging \
cookie-debugging debug-optimize-lcp memory-leak-debugging troubleshooting
do
for agent in agents codex claude
do
chrome_mcp_skill_path="$HOME/.$agent/skills/$skill"
if [ -e "$chrome_mcp_skill_path" ] || [ -L "$chrome_mcp_skill_path" ]; then
mv "$chrome_mcp_skill_path" "$chrome_mcp_skill_backup_dir/$agent-$skill"
fi
done
done
echo "$chrome_mcp_skill_backup_dir"
然后执行安装。下面统一存一份内容,再给 Codex 和 Claude Code 建立链接;后面仍然只配置 Codex:
chrome_mcp_package_dir="$(npm root -g)/chrome-devtools-mcp"
mkdir -p "$HOME/.agents/skills"
mkdir -p "$HOME/.codex/skills"
mkdir -p "$HOME/.claude/skills"
for skill in chrome-devtools chrome-devtools-cli a11y-debugging \
cookie-debugging debug-optimize-lcp memory-leak-debugging troubleshooting
do
cp -R "$chrome_mcp_package_dir/skills/$skill" "$HOME/.agents/skills/"
ln -s "$HOME/.agents/skills/$skill" "$HOME/.codex/skills/$skill"
ln -s "$HOME/.agents/skills/$skill" "$HOME/.claude/skills/$skill"
done
检查其中一个文件:
ls "$HOME/.codex/skills/chrome-devtools/SKILL.md"
能够显示这个文件路径,没有“文件不存在”的提示,就可以继续。
Step 6:开启日常 Chrome 的远程调试
保持你平时使用的 Chrome 打开。在地址栏输入:
chrome://inspect/#remote-debugging
勾选远程调试开关。这里操作的是日常 Chrome,保留它原有的窗口和登录状态。
此时先完成开关设置,等 Step 10 首次连接时,再处理 Chrome 的连接授权。
这一关通过的标志:远程调试开关已开启,日常 Chrome 保持运行。
Step 7:保存安静模式启动器
这一步用到我们额外写的 quiet 脚本。它会让不同 AI 会话共用官方 CLI 的常驻连接,并强制后台开页。
先在终端创建目录,再打开文件:
mkdir -p "$HOME/.local/bin"
nano "$HOME/.local/bin/chrome-devtools-mcp-quiet"
把文末附录 C 的完整代码复制进去,包含第一行 #!/usr/bin/env node 和最后一行 server.stdin.end();。不用修改代码里的路径。
在 nano 中按 Control + O,再按回车保存,最后按 Control + X 退出。这里按 Control 键,不是 Command 键。保存完回到这一步继续。
在终端检查脚本:
node --check "$HOME/.local/bin/chrome-devtools-mcp-quiet"
node "$HOME/.local/bin/chrome-devtools-mcp-quiet" --version
第一条正常时没有输出,第二条应输出 1.12.0。如果提示找不到文件,先检查保存路径和文件名,不要继续填写 MCP 配置。
Step 8:填写 Codex 用户配置
先查出本机路径:
command -v node
echo "$HOME"
把两行结果留在终端里,下一步需要用到。
打开 Codex 用户配置:
mkdir -p "$HOME/.codex"
nano "$HOME/.codex/config.toml"
在文件中加入下面这一块。原文件里有其他设置时,保留它们;不要再留第二块同名的 Chrome MCP 配置。
[mcp_servers.chrome-devtools]
command = "/你的Node绝对路径"
args = [
"/你的用户目录/.local/bin/chrome-devtools-mcp-quiet",
"--autoConnect"
]
startup_timeout_sec = 30
tool_timeout_sec = 120
default_tools_approval_mode = "approve"
只需要替换两处:
| 示例中的内容 | 替换成什么 |
|---|---|
/你的Node绝对路径 | command -v node 输出的完整内容 |
/你的用户目录 | echo "$HOME" 输出的完整内容,后面的 /.local/bin/chrome-devtools-mcp-quiet 保留 |
两处路径外面的英文双引号都保留。保存方法仍是 Control + O、回车、Control + X。
approve 只控制 Codex 是否确认工具调用,Chrome 自己的连接授权仍由 Chrome 决定。OpenAI 官方 MCP 配置说明
接着打开官方 CLI 共用的配置:
mkdir -p "$HOME/.config/cd4a"
nano "$HOME/.config/cd4a/config.json"
新文件填写:
{
"autoConnect": true
}
如果已有内容,保留与本流程无关的设置,移除旧的 browserUrl、wsEndpoint、userDataDir、headless 连接或启动设置,改用上面的 autoConnect。
保存后执行:
codex mcp get chrome-devtools --json
输出的 command 应指向你填写的 Node,args 应包含 quiet 启动器和 --autoConnect。
个人路径保留在用户目录里。Step 3 已移除项目中的同名配置,所以项目会继承这里的设置。Codex 没有自动加载 config.local.toml 的配置层,不需要再新建这个文件。Codex 配置层级
Step 9:告诉 Codex 日常怎么使用
打开全局操作规则文件:
nano "$HOME/.codex/AGENTS.md"
如果里面已经有旧的 Chrome 专项规则,用下面这段替换;没有就追加到末尾。其他规则保留:
## Chrome DevTools 后台操作
- 浏览器任务默认使用已配置的 chrome-devtools MCP。
- 连接日常正在使用的 Chrome,不启动独立 Chrome,不连接旧的 9333 实例。
- 新标签页传 background: true,切换 MCP 上下文时传 bringToFront: false。
- 只操作本次任务的标签页,不导航、刷新或输入用户正在使用的标签页。
- quiet 启动器复用官方 CLI 的常驻连接。除用户明确要求重启,禁止按任务调用 chrome-devtools start 或 stop。
- 使用官方 CLI 创建新页时也要加 --background true。
- Chrome 连接授权由用户确认。只有用户明确要求展示页面时,才可通过官方 CLI 显式选择前台页面。
保存退出。这段规则用于防止 AI 绕开已配置的 MCP,或者每次任务都重启常驻进程。
Step 10:重新打开 Codex,完成首次连接
把正在使用的旧 Codex 会话退出,再从项目目录重新启动 Codex,让它加载新的配置和 Skill。Chrome 保持打开,不需要重启。
codex
发送这句话进行首次验证:
如果 Chrome 弹出“Allow remote debugging?”,点击一次 Allow。这是为新常驻连接确认授权。
随后检查四件事:
- Codex 返回打开成功,能看到页面标题或 Page ID。
- 不抢占你的标签页, 并成功打开nuxt.com。
- 继续后台操作 打开 nuxt docs。
Step 11:再开新会话,验证是否还会反复授权
先在终端执行:
chrome-devtools status
记下输出里的 pid,它是当前常驻进程的编号。
然后按顺序操作:
- 退出刚才的 Codex 测试会话。
- 重新启动 Codex,开启一个新会话。
- 再让它后台打开网页。
- 再执行一次
chrome-devtools status。
通过的标志是:这次没有再次要求 Chrome 授权,pid 与前一次相同,浏览器原标签页也没有被切走。
我们实际还测试过多个会话连续开页、切换任务上下文,以及关闭全部测试会话后再建立新会话。新会话复用了原来的常驻进程,当前标签页、地址栏和页面焦点都保持不变。
做到这里,主流程结束。以后正常使用 Codex 即可,不需要每次先手动启动 CLI 守护进程。Chrome 或连接确实重启后,仍可能需要重新允许一次。
没通过时,先检查对应步骤
| 现象 | 先回到哪里检查 |
|---|---|
| 找不到 node、npm 或 codex | Step 1,工具是否已经安装并能显示版本 |
| 提示找不到 quiet 启动器 | Step 7,文件名、保存位置和代码是否完整 |
| MCP 无法启动 | Step 8,Node 路径是否正确,TOML 引号和括号是否完整 |
| 又打开了独立 Chrome | Step 3、6、8,旧项目配置是否移除、远程调试是否开启、是否仍有旧连接设置 |
| 每个新会话仍然要授权 | Step 9、11,是否反复执行 start/stop,是否用了仍持有旧配置的客户端 |
| 新网页仍抢当前标签 | Step 8、9、10,实际调用的是否为 quiet 启动器,而不是另一套浏览器工具 |
总结
这次重装,我们清掉了旧包和重复的 MCP 入口,装回官方包与 Skill,再让 Codex 通过 quiet 启动器复用同一条 Chrome 连接。个人路径放进用户级配置,项目文件就不用跟着每个人的电脑改来改去。
反复授权靠复用常驻连接来减少,抢标签页则由 quiet 启动器强制后台操作来处理。Codex 的工具自动批准要单独配置;它和 Chrome 的连接授权是两回事,首次连接仍然需要自己点一次允许。
配完后,按 Step 11 再开一个新会话验证:能后台打开网页、原标签页保持选中,而且复用原进程时没有再次弹出连接授权,才算达到了这次配置的目标。日常保持 Chrome 运行,不要每次任务都执行 start/stop。如果浏览器或连接重启了,再按提示允许一次即可。
附录 A:使用 Claude Code 时再补这些配置
只用 Codex 的读者可以跳过。先完成前面的安装与 Chrome 设置,再为 Claude Code 指向同一个启动器。
在 ~/.claude.json 的 mcpServers 中合并下面的条目,两个路径按 Step 8 的方法替换:
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "/你的Node绝对路径",
"args": [
"/你的用户目录/.local/bin/chrome-devtools-mcp-quiet",
"--autoConnect"
]
}
}
}
再向 ~/.claude/settings.json 合并以下字段,已有数组里的其他条目也保留:
{
"permissions": {
"allow": [
"mcp__chrome-devtools__*"
]
},
"enabledMcpjsonServers": [
"chrome-devtools"
]
}
把 Step 9 的后台使用规则也追加到 ~/.claude/CLAUDE.md,然后重新打开 Claude Code。
Claude 的本地项目覆盖通过 --scope local 配置,实际保存在 ~/.claude.json 对应的项目条目里。这里采用用户级配置,没有另建 .mcp.local.json。Claude 配置作用域
如果之前在 Claude、Pi 等其他客户端里注册过旧的 Chrome MCP,也需要清理它们的旧入口。我们这次同步了 Pi 的配置和 Chrome 对应缓存;OpenCode 原来禁用,更新参数后仍保留禁用状态。
附录 B:遇到 max_threads 报错时再看
这项与浏览器安装无关,不需要为了安装 MCP 主动添加。
如果旧 Codex 配置里原来有:
[agents]
max_threads = 6
max_depth = 1
在本次使用的新版配置中,改成:
[agents]
max_concurrent_threads_per_session = 6
max_depth = 1
旧字段被当作自定义 agent 名称,要求值为对象,因而出现“数字 6 不是 object”的报错。我们在修配置时一并处理了它。
附录 C:完整 quiet 启动器
这份代码供 Step 7 使用。复制代码块里的全部内容,不要把代码块外面的说明一起复制进去。
官方包提供浏览器工具、CLI 和 Skill;下面的适配代码把 MCP 请求转交给官方 CLI 常驻进程,同时强制后台开页。它会复用已经授权的连接,不会跳过 Chrome 的权限检查。官方 CLI 说明
脚本使用 1.12.0 包内模块。将来升级包时,需要同时检查脚本和 Skill。
#!/usr/bin/env node
import {
spawn,
spawnSync,
execFileSync,
} from 'node:child_process';
import { createInterface } from 'node:readline';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
const siblingRoot = join(
dirname(process.execPath),
'../lib/node_modules/chrome-devtools-mcp/build/src',
);
const root = fs.existsSync(
join(siblingRoot, 'bin/chrome-devtools-mcp.js'),
)
? siblingRoot
: join(
execFileSync('npm', ['root', '-g'], {
encoding: 'utf8',
}).trim(),
'chrome-devtools-mcp/build/src',
);
const upstream = join(root, 'bin/chrome-devtools-mcp.js');
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('--version')) {
const result = spawnSync(
process.execPath,
[upstream, ...args],
{ stdio: 'inherit' },
);
process.exit(result.status ?? 1);
}
const { startDaemon, sendCommand } = await import(
pathToFileURL(join(root, 'daemon/client.js'))
);
const { isDaemonRunning, getRuntimeHome } = await import(
pathToFileURL(join(root, 'daemon/utils.js'))
);
let daemonReady;
async function ensureDaemon() {
if (isDaemonRunning('')) {
return startDaemon([], '');
}
const runtime = getRuntimeHome('');
fs.mkdirSync(runtime, { recursive: true, mode: 0o700 });
const lock = join(runtime, 'quiet-start.lock');
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (isDaemonRunning('')) {
return startDaemon([], '');
}
try {
const fd = fs.openSync(lock, 'wx', 0o600);
fs.writeSync(fd, String(process.pid));
fs.closeSync(fd);
} catch (error) {
if (error.code !== 'EEXIST') throw error;
try {
const owner = Number(fs.readFileSync(lock, 'utf8'));
if (owner > 0) {
try {
process.kill(owner, 0);
} catch (error) {
if (error.code === 'ESRCH') fs.unlinkSync(lock);
}
}
} catch {}
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
try {
await startDaemon(['--autoConnect', '--viaCli'], '');
return;
} finally {
fs.unlinkSync(lock);
}
}
throw new Error('启动共享 Chrome DevTools 进程超时。');
}
async function callSharedTool(message) {
if (daemonReady && !isDaemonRunning('')) {
daemonReady = undefined;
}
daemonReady ??= ensureDaemon().catch(error => {
daemonReady = undefined;
throw error;
});
await daemonReady;
const response = await sendCommand(
{
method: 'invoke_tool',
tool: message.params.name,
args: message.params.arguments,
},
'',
120000,
);
if (!response.success) {
throw new Error(response.error);
}
return JSON.parse(response.result);
}
const server = spawn(
process.execPath,
[upstream, ...args],
{ stdio: ['pipe', 'pipe', 'inherit'] },
);
server.on('error', error => {
console.error(error.message);
process.exit(1);
});
server.stdin.on('error', error => {
if (error.code !== 'EPIPE') console.error(error.message);
});
function quietRequest(message) {
if (message.method !== 'tools/call') return;
const params = message.params;
if (!params) return;
params.arguments ??= {};
if (params.name === 'new_page') {
params.arguments.background = true;
}
if (params.name === 'select_page') {
params.arguments.bringToFront = false;
}
}
function quietSchema(message) {
for (const tool of message.result?.tools ?? []) {
const properties = tool.inputSchema?.properties;
if (tool.name === 'new_page' && properties?.background) {
properties.background.default = true;
properties.background.const = true;
properties.background.description =
'本机安静模式:始终在后台创建标签页。';
}
if (tool.name === 'select_page' && properties?.bringToFront) {
properties.bringToFront.default = false;
properties.bringToFront.const = false;
properties.bringToFront.description =
'本机安静模式:只切换任务上下文,不激活标签页。';
}
}
}
createInterface({ input: server.stdout }).on('line', line => {
try {
const message = JSON.parse(line);
quietSchema(message);
process.stdout.write(JSON.stringify(message) + '\n');
} catch {
console.error(line);
}
});
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
process.on(signal, () => server.kill(signal));
}
server.on('exit', (code, signal) => {
process.exit(code ?? (signal ? 1 : 0));
});
const input = createInterface({ input: process.stdin });
for await (const line of input) {
let message;
try {
message = JSON.parse(line);
} catch {
server.stdin.write(line + '\n');
continue;
}
quietRequest(message);
if (message.method === 'tools/call') {
void callSharedTool(message)
.then(result => {
process.stdout.write(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result,
}) + '\n',
);
})
.catch(error => {
process.stdout.write(
JSON.stringify({
jsonrpc: '2.0',
id: message.id,
result: {
isError: true,
content: [{
type: 'text',
text: error.message,
}],
},
}) + '\n',
);
});
continue;
}
server.stdin.write(JSON.stringify(message) + '\n');
}
server.stdin.end();