前情提要
- MCP(Model Context Protocol)是一个开放协议,让外部服务(数据库、GitHub、浏览器……)以「工具」的形式接进 Agent
- 用
claude mcp add添加 Server,--scope决定作用域:local(只有你、只在这个项目)、project(写进仓库的.mcp.json,团队共享)、user(你所有的项目)- 对模型来说,MCP 的
read_query和内置的Read都只是工具,用法没区别- MCP 的几类风险:越权操作、来源不明、提示注入、上下文压力。所以不要「全都装上」,只装可信来源的 Server
关于 MCP,有两句常听到的话:
- 「对模型来说,MCP 工具和内置工具没区别」
- 「每个 Server 的工具说明都要进上下文,装多了会很挤;部分客户端会按需加载来缓解」
这一节在源码里验证第一句,再把第二句里那个「按需加载」讲清楚。代码都在 src/services/mcp/ 下,主要是 config.ts(配置)和 client.ts(连接和包装)。
一个 MCP Server 从配置到被模型调用,经过四步:合并配置 → 连接 → 包装成工具 → 调用和返回。
一、配置:从哪几个地方来,谁说了算

config.ts 里的 getClaudeCodeMcpConfigs() 负责把所有来源合并起来。规则有两条。
1. 有企业配置,就只用企业配置
// If an enterprise mcp config exists, do not use any others;
// this has exclusive control over all MCP servers
// (enterprise customers often do not want their users to be able to add their own MCP servers).
// 只要存在企业级 MCP 配置,其他来源一律不用。企业客户通常不希望员工自己乱加 MCP Server。2. 没有企业配置,按优先级合并
// Merge in order of precedence: plugin < user < project < local
const configs = Object.assign({}, 插件的, 用户级的, 已批准的项目级, 本地级)同名的 Server,后面的覆盖前面的:本地级(local)优先级最高,插件带的最低。插件带的 Server 如果和你手动配的是同一个(启动同一个命令、连同一个地址),还会被去重。
企业配置独占;其余按「插件 < 用户 < 项目 < 本地」合并,越靠近你的越优先。
二、项目里的 MCP 要你批准
注意上面那行代码里写的是「已批准的项目级」:
// Filter project servers to only include approved ones
for (const [name, config] of Object.entries(projectServers)) {
if (getProjectMcpServerStatus(name) === 'approved') {
approvedProjectServers[name] = config
}
}项目级配置写在仓库的 .mcp.json 里,跟着代码一起提交。想象一下:你 clone 了一个陌生仓库,里面的 .mcp.json 配了一个会在你机器上启动的进程。如果不经批准就连接,等于打开仓库就执行了别人的代码。
所以第一次遇到项目里的 MCP Server,Claude Code 会先问你。「来源不明」这个风险,代码里的第一道闸就在这里。
仓库里的 .mcp.json 是别人写的,不经你批准不会连接。
三、包装成普通工具

连上 Server 之后,client.ts 里的 fetchToolsForClient() 会问它有哪些工具,再把每个工具包装一下。简化后是这样:
// src/services/mcp/client.ts → fetchToolsForClient()(简化)
const result = await client.request({ method: 'tools/list' }) // 问 Server:你有哪些工具?
return result.tools.map(tool => ({
...MCPTool, // 套用通用的 MCP 工具模板
name: `mcp__${serverName}__${tool.name}`, // 改名:加上服务名前缀
prompt: () => 超过 2048 字符就截断(tool.description), // 工具说明
inputJSONSchema: tool.inputSchema, // 参数格式,原样交给模型
isReadOnly: () => tool.annotations?.readOnlyHint ?? false,
isConcurrencySafe: () => tool.annotations?.readOnlyHint ?? false, // 只读才能并行
checkPermissions: () => ({ behavior: 'passthrough' }), // 交给权限系统,没有规则就问你
call: (args) => 把调用转发给 Server, // 真正执行的是 Server
}))把它和第3节的 Tool 接口放在一起看:字段一模一样。
包装完之后,它就是一个普通的工具对象,和 Read、Bash 长得一样,走的也是第3节那条「七道关」的流水线:参数校验、Hook、权限、执行、结果处理。
所以那句话不只是对模型成立,对代码也成立:除了名字和转发,MCP 工具和内置工具几乎没有区别。
四、包装里的四个细节
1. 名字是 mcp__服务名__工具名
这就是为什么权限规则要写成 mcp__shop__read_query。第4节的匹配函数还支持只写服务名:
{ "permissions": { "deny": ["mcp__shop"] } }mcp__shop 会匹配 shop 这个 Server 的所有工具。再结合第4节讲的:deny 整个工具时,工具在发给模型之前就被删掉了。所以这样写的效果是:模型根本看不到这个 Server 的任何工具。
2. 默认每个工具都要问你
checkPermissions 返回 passthrough,意思是「我自己没意见,交给权限系统」。权限系统里没有对应的 allow 规则,就会走到第4节那张表的最后一步:问你。
这就是「第一次调用某个 MCP 工具时,Claude Code 会弹窗征求你的同意」的原因。
3. 「只读」是 Server 自己说的
readOnlyHint 是 MCP 协议里的一个标注,由 Server 作者声明「这个工具只读」。Claude Code 信了这个声明,就允许它和其他只读工具并行执行。