MCP Server 接入多个 AI 客户端:配置差异与 10 个真实踩坑

近半年我把自研的一个代码库知识图谱 MCP Server 接进了三个日常使用的 AI 编程工具:Claude Code、Codex CLI、OpenCode。同一份二进制、同一个 stdio 协议,在三个客户端里的表现完全不一样------有两个能连上但工具列表是空的,有一个连上后第一次调用卡死 30 秒才超时。 MCP(Model Context Protocol)的协议本身很简单,难的是每个客户端对"怎么启动这个进程"的理解各不相同。这篇文章把配置文件位置、格式差异,以及我实际踩到的 10 个坑记下来。配置部分可以直接抄,坑的部分建议看完。


一、先把传输层搞清楚,否则所有报错都看不懂

MCP 有两种传输方式:

传输 用法 谁发起
stdio 本地可执行文件,客户端 spawn 子进程,通过 stdin/stdout 收发 客户端拉起进程
Streamable HTTP(含 SSE) 远程服务,客户端直接连 URL 客户端发 HTTP 请求

本地工具基本都是 stdio。这里有一个必须记住的细节:

stdio 上的消息是换行分隔的 JSON-RPC 2.0------一条消息一行 JSON,JSON 内部不能出现裸换行。

它不是 LSP 的 Content-Length: xxx\r\n\r\n 那套带长度前缀的帧格式。这一条决定了后面大部分"连不上"的根因:

  • server 往 stdout 打了一行 INFO: server starting... → 客户端读到非法 JSON → 直接断开
  • 用 JSON.stringify(obj, null, 2) 美化输出 → 变成多行 → 断开
  • 输出之后没有 flush → 客户端一直等 initialize 的响应 → 超时

第一条铁律:stdio server 的所有日志必须走 stderr,stdout 只能出现协议消息。


二、五个客户端的配置文件在哪

这是最容易浪费时间的地方------没有任何两个客户端用同一个路径。

客户端 配置文件 格式 作用域
Claude Code ~/.claude.json(user)/ 项目根 .mcp.json(project)/ 本地(local) JSON 三种作用域,同名会覆盖
Codex CLI ~/.codex/config.toml TOML 全局
Cursor ~/.cursor/mcp.json(全局)+ 项目 .cursor/mcp.json JSON 两级
OpenCode 项目根 opencode.json 或 ~/.config/opencode/opencode.json JSON 项目 / 全局
Windsurf ~/.codeium/windsurf/mcp_config.json JSON 全局
Claude Desktop(macOS) ~/Library/Application Support/Claude/claude_desktop_config.json JSON 全局

注意几个容易踩的点:

  • Codex 用的是 TOML,跟其它客户端的 JSON 完全不通用,不能复制粘贴
  • Claude Desktop 的配置在 Application Support 下面,不在 ~/.claude,很多人找错
  • Cursor / Windsurf 的项目级配置和全局配置同时存在时,优先级要自己验证

三、同一个 server,五种写法

假设 server 的绝对路径是 /Users/you/.local/bin/my-mcp,用 --stdio 启动。

Claude Code

推荐用命令行加,会自动写进对应作用域的配置文件:

bash 复制代码
# user 作用域:所有项目生效
claude mcp add my-mcp --scope user -- /Users/you/.local/bin/my-mcp --stdio

# 查看当前已注册的 server
claude mcp list

等价的手写配置(~/.claude.json):

json 复制代码
{
  "mcpServers": {
    "my-mcp": {
      "command": "/Users/you/.local/bin/my-mcp",
      "args": ["--stdio"],
      "env": {
        "MY_MCP_HOME": "/Users/you/.my-mcp"
      }
    }
  }
}

Codex CLI(TOML)

toml 复制代码
[mcp_servers.my-mcp]
command = "/Users/you/.local/bin/my-mcp"
args = ["--stdio"]
startup_timeout_sec = 60
tool_timeout_sec = 120

[mcp_servers.my-mcp.env]
MY_MCP_HOME = "/Users/you/.my-mcp"

startup_timeout_sec 单独提一句:如果 server 启动时要加载索引、连数据库,默认超时大概率不够,进程还没握手完就被判死了,日志里看不到任何有用的东西。

Cursor

~/.cursor/mcp.json:

json 复制代码
{
  "mcpServers": {
    "my-mcp": {
      "command": "/Users/you/.local/bin/my-mcp",
      "args": ["--stdio"],
      "env": {
        "MY_MCP_HOME": "/Users/you/.my-mcp"
      }
    }
  }
}

OpenCode

注意结构完全不同:顶层键是 mcp 而不是 mcpServers,command 是数组 ,环境变量字段叫 environment。

json 复制代码
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "my-mcp": {
      "type": "local",
      "command": ["/Users/you/.local/bin/my-mcp", "--stdio"],
      "enabled": true,
      "environment": {
        "MY_MCP_HOME": "/Users/you/.my-mcp"
      }
    }
  }
}

Windsurf

json 复制代码
{
  "mcpServers": {
    "my-mcp": {
      "command": "/Users/you/.local/bin/my-mcp",
      "args": ["--stdio"]
    }
  }
}

五种写法里,mcpServers 这个键名是事实上的通用约定(Claude Code、Cursor、Windsurf、Claude Desktop 都用它),Codex 和 OpenCode 是例外。这就是为什么"从博客复制一段配置"经常不生效。


四、10 个真实的坑

坑 1:客户端不继承你 shell 的 PATH

你在终端里敲 my-mcp 能跑,不代表客户端能跑。GUI 启动的客户端(尤其是 macOS 上从 Dock 打开的)拿到的 PATH 往往是系统默认值,没有 ~/.local/bin、没有 nvm 的 node 路径。

症状:进程根本没起来,客户端只显示"server failed",没有任何 stderr。

解法 :配置里的 command 一律写绝对路径。用 nvm 的话,npx 也要写绝对路径(/Users/you/.nvm/versions/node/v20.x/bin/npx)。

坑 2:环境变量也不会继承

同理,你 export 的 API key、数据库地址,子进程一个都看不到。

解法 :全部显式写进配置的 env / environment 字段。不要指望 server 自己去读 .env 文件------它的 cwd 根本不固定。

坑 3:stdout 被日志污染(最隐蔽)

server 自测一切正常,接进客户端就废。原因通常是某个第三方库默认往 stdout 打日志,或者你的框架在启动时打印了 banner。

解法:

  • 启动参数里强制把日志重定向到 stderr
  • 在 main 的最开头把 process.stdout 或 System.out 临时劫持到 stderr,只在写协议消息时用真正的 stdout
  • 关掉所有启动 banner(很多 CLI 框架默认是开的)

判断方法:手动把 server 的 stdout 重定向到文件,看里面除了 JSON 还有没有别的东西。

bash 复制代码
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.1"}}}' \
  | /Users/you/.local/bin/my-mcp --stdio 2>/tmp/mcp-err.log

输出的每一行都必须是合法 JSON。有任何一行不是,就是这个问题。

坑 4:cwd 是不确定的

客户端用什么工作目录拉起子进程,各家不一样,也不保证跟你项目根目录一致。任何相对路径(配置文件、索引目录、sqlite 文件)都会随机炸。

解法 :server 内部所有路径要么绝对化,要么从环境变量读,要么从 initialize 请求里带的 rootUri 推导。别用 .。

坑 5:Codex 的 TOML 和其它客户端的 JSON 不通用

这一条单独列出来,因为它浪费了我最久。从 Claude Code 通过了的配置,复制到 config.toml 里全是语法错误;而且 env 要写成子表或内联表,位置放错会静默失效------server 起来了,但拿不到环境变量,行为诡异。

解法:Codex 的配置单独维护一份,别想着复用。

坑 6:Claude Code 的三种作用域,同名会覆盖

Claude Code 有 local / project / user 三级,优先级是 local > project > user。你在项目里加过一次 local 配置,之后改 user 作用域的同名 server 是无效的------它一直被 local 那份覆盖。

症状 :改了配置、重启了、claude mcp list 里还是旧的启动参数。

解法 :先 claude mcp list 看清楚这个 name 绑在哪个 scope 上,用 claude mcp remove 删干净的再重加。

坑 7:工具名带前缀,权限规则要写全

暴露出去的工具,客户端看到的全名是 mcp__<server名>__<tool名>。你在权限配置、allowlist、hook 里写 query_graph 是不匹配的,必须写 mcp__my-mcp__query_graph。

症状:工具明明在列表里,调用时却一直被权限拦截,或者 hook 静默不触发。

坑 8:改配置不重启 = 没改;常驻进程还会抢端口

大部分客户端只在启动时读一次 MCP 配置,改完必须完全退出再开(macOS 上要 Cmd+Q,关窗口不算)。

另外,如果你的 server 是常驻 daemon(我这个就带一个本地 Web UI 跑在 127.0.0.1:9749),多开客户端时会抢同一个端口:

  • 第一个客户端把 daemon 拉起来
  • 第二个客户端启动时发现端口被占,直接报错退出
  • 表现是"在这个窗口能用,换个窗口就不能用"

解法:daemon 单例化------启动时先探端口,活着就直接连,死了才自己拉。别用"启动即绑定"的写法。

坑 9:工具描述写得像 API 文档,模型就不会用

这个坑不在配置里,但比配置坑更费时间:server 连上了,工具也在列表里,模型却宁愿反复 grep,一次都不调你的工具。

原因几乎总是 description 写错了。MCP 工具的 description 不是给人类看的接口文档,是给模型看的"什么时候该用我"的说明书。

对比一下:

arduino 复制代码
// 差:只说是什么
query_graph --- Execute a Cypher query against the knowledge graph

// 好:说清楚触发条件和边界
query_graph --- 用 Cypher 查询代码知识图谱。当需要回答"谁调用了这个方法"、
"这个类被哪些地方继承"、"改动会影响哪些模块"这类跨文件结构问题时优先使用,
结果比 grep 精确。只做纯文本搜索时不要用它。

三条经验:

  1. 写"什么时候用",不只写"是什么"。模型是靠场景匹配来选工具的
  2. 写"什么时候不要用"。负向描述对减少误调用最有效
  3. 参数描述里放真实示例值 ,别写 param: string 就完事

如果描述改好了模型还是不用,可以在客户端的全局指令文件里加一条硬规则------Claude Code 是全局 CLAUDE.md,Codex 是 AGENTS.md,写明"结构性查询优先用 xxx 工具"。这是有效但脆弱的做法:我遇到过工具自己的安装脚本会重写 AGENTS.md,手写的规则第二天就没了。所以规则要放在不会被安装器覆盖的位置,或者干脆把 description 改到模型不得不用。

另外,工具数量别贪多。十几个以上就会互相干扰,模型开始瞎选。能合并的合并,能删的删。

坑 10:多项目索引,别指望一个 daemon 管所有仓库

我本地索引了几个 Java 和 TS 工程,最初的设想是一个 daemon 管全部,结果撞了两个问题:

  1. 项目名是路径转换来的。换一次目录、加一层软链,项目名就变了,之前的索引查不到,报"项目不存在"
  2. 跨项目查询在当时的版本里有解析 bug,返回的结果会串项目

最后改成每个项目独立索引、查询时显式指定项目名,稳定得多。

结论:多项目场景下,"一个项目一个索引 + 显式指定"比"全局索引 + 自动匹配"可靠。自动匹配看起来很聪明,出问题时你根本不知道它匹配到了哪个仓库。


五、三分钟定位是谁的问题

按这个顺序排查,能覆盖 90% 的情况:

  1. 手动灌一条 initialize,看 server 自己活不活 上面那个 echo | server 的命令,能返回 {"jsonrpc":"2.0","id":1,"result":{...}} 就说明 server 没问题,问题在客户端配置。

  2. 看 stderr 手动跑的时候把 stderr 重定向到文件;客户端里跑的时候,基本看不到 stderr,所以第一步必须手动验证。

  3. 用官方 Inspector 连通

    bash 复制代码
    npx @modelcontextprotocol/inspector /Users/you/.local/bin/my-mcp --stdio

    能在 Inspector 里列出工具、能调用,就说明协议层完全正常,剩下的一定是客户端的启动环境(PATH / env / cwd)问题。

  4. 对比三个客户端的启动参数 把每个客户端实际用的 command、args、env 抄出来逐字对比,差异往往就在一个斜杠或者一个引号上。


六、一份可以直接抄的最小模板

关键只有四条,记住这四条,其它都是格式差异:

  1. command 用绝对路径
  2. 环境变量全部显式声明
  3. 日志只走 stderr
  4. 所有路径从 env 或 initialize 里取,不用相对路径
json 复制代码
{
  "mcpServers": {
    "my-mcp": {
      "command": "/absolute/path/to/my-mcp",
      "args": ["--stdio"],
      "env": {
        "MY_MCP_HOME": "/absolute/path/to/data",
        "LOG_LEVEL": "error"
      }
    }
  }
}

Codex 用户把上面这段翻译成 TOML 的 [mcp_servers.my-mcp],OpenCode 用户改成 mcp + 数组 command,其余字段照抄。


写在最后

MCP 现在是事实标准,各家的实现都在快速演进,配置文件格式很可能还会变。但跨客户端调试的思路是不变的:先证明 server 自己没问题(手动灌 initialize),再证明协议层没问题(Inspector),最后才去查客户端的启动环境。

顺序反了,就会在"是不是我的代码有 bug"上耗掉一整个周末。

如果你也在多客户端之间折腾同一份 server,欢迎在评论区说说你踩的坑------尤其是 Windows 上的路径和编码问题,那部分我没有实测,不敢乱写。

相关推荐
读完一本书1 小时前
AI 生成的图,为什么一眼就能看出是 AI?5 个破绽
人工智能·aigc
leeguoo1 小时前
让 Claude Code / Codex 用上你已登录的 Chrome
前端·chrome·ai编程
低调滴开发1 小时前
3 行代码写崩一个函数?切片才是 Rust 字符串处理的正确姿势
开发语言·后端·rust
代码丰1 小时前
跨系统传输大 JSON:直接压缩,还是把数据放进 Redis?
redis·后端·json
2601_962071001 小时前
Grok 4.6 与 Kimi K2.5 对比:编程、长文本与 API 成本怎么选?
人工智能
秦先生在广东1 小时前
用4条规矩驯服AI写代码
人工智能
Markus实验室1 小时前
一个任务烧了54亿token,我盯着账单看了十分钟
agent·ai编程
LeoLi6381 小时前
随机应变,不是见招拆招:AI 干 6 倍工时之后,我把它做成了三层降级链路
人工智能·程序员
秦先生在广东1 小时前
OpenBot让AI在本地当持久队友
人工智能