chrome-devtools-mcp 总弹授权、抢标签页?我是这样一步步配好的

0 阅读12分钟

我想让 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 实现,复现时先保持这个版本。

a0.gif

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 保持运行。

image.png

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 配置。

image.png

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 配置层级

image.png

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,或者每次任务都重启常驻进程。

image.png

Step 10:重新打开 Codex,完成首次连接

把正在使用的旧 Codex 会话退出,再从项目目录重新启动 Codex,让它加载新的配置和 Skill。Chrome 保持打开,不需要重启。

codex

发送这句话进行首次验证:

打开 nuxt.com,然后找到docs,打开。

如果 Chrome 弹出“Allow remote debugging?”,点击一次 Allow。这是为新常驻连接确认授权。

随后检查四件事:

  1. Codex 返回打开成功,能看到页面标题或 Page ID。
  2. 不抢占你的标签页, 并成功打开nuxt.com。
  3. 继续后台操作 打开 nuxt docs。

image.png

Step 11:再开新会话,验证是否还会反复授权

先在终端执行:

chrome-devtools status

记下输出里的 pid,它是当前常驻进程的编号。

然后按顺序操作:

  1. 退出刚才的 Codex 测试会话。
  2. 重新启动 Codex,开启一个新会话。
  3. 再让它后台打开网页。
  4. 再执行一次 chrome-devtools status。

通过的标志是:这次没有再次要求 Chrome 授权,pid 与前一次相同,浏览器原标签页也没有被切走。

我们实际还测试过多个会话连续开页、切换任务上下文,以及关闭全部测试会话后再建立新会话。新会话复用了原来的常驻进程,当前标签页、地址栏和页面焦点都保持不变。

image.png

做到这里,主流程结束。以后正常使用 Codex 即可,不需要每次先手动启动 CLI 守护进程。Chrome 或连接确实重启后,仍可能需要重新允许一次。

没通过时,先检查对应步骤

现象先回到哪里检查
找不到 node、npm 或 codexStep 1,工具是否已经安装并能显示版本
提示找不到 quiet 启动器Step 7,文件名、保存位置和代码是否完整
MCP 无法启动Step 8,Node 路径是否正确,TOML 引号和括号是否完整
又打开了独立 ChromeStep 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 原来禁用,更新参数后仍保留禁用状态。

image.png

附录 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();