代码语言
选择后全站课程跟随

MCP:和内置工具没有区别

3,999 字 · 预计阅读 11 分钟

前情提要

  • MCP(Model Context Protocol)是一个开放协议,让外部服务(数据库、GitHub、浏览器……)以「工具」的形式接进 Agent
  • 用 claude mcp add 添加 Server,--scope 决定作用域:local(只有你、只在这个项目)、project(写进仓库的 .mcp.json,团队共享)、user(你所有的项目)
  • 对模型来说,MCP 的 read_query 和内置的 Read 都只是工具,用法没区别
  • MCP 的几类风险:越权操作、来源不明、提示注入、上下文压力。所以不要「全都装上」,只装可信来源的 Server

关于 MCP,有两句常听到的话:

  1. 「对模型来说,MCP 工具和内置工具没区别」
  2. 「每个 Server 的工具说明都要进上下文,装多了会很挤;部分客户端会按需加载来缓解」

这一节在源码里验证第一句,再把第二句里那个「按需加载」讲清楚。代码都在 src/services/mcp/ 下,主要是 config.ts(配置)和 client.ts(连接和包装)。

一个 MCP Server 从配置到被模型调用,经过四步:合并配置 → 连接 → 包装成工具 → 调用和返回。


一、配置:从哪几个地方来,谁说了算

MCP 配置来源有覆盖优先级

config.ts 里的 getClaudeCodeMcpConfigs() 负责把所有来源合并起来。规则有两条。

1. 有企业配置,就只用企业配置

typescript
// 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. 没有企业配置,按优先级合并

typescript
// Merge in order of precedence: plugin < user < project < local
const configs = Object.assign({}, 插件的, 用户级的, 已批准的项目级, 本地级)

同名的 Server,后面的覆盖前面的:本地级(local)优先级最高,插件带的最低。插件带的 Server 如果和你手动配的是同一个(启动同一个命令、连同一个地址),还会被去重。

企业配置独占;其余按「插件 < 用户 < 项目 < 本地」合并,越靠近你的越优先。


二、项目里的 MCP 要你批准

注意上面那行代码里写的是「已批准的项目级」:

typescript
// 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 是别人写的,不经你批准不会连接。


三、包装成普通工具

MCP 工具接入后呈现为统一工具接口

连上 Server 之后,client.ts 里的 fetchToolsForClient() 会问它有哪些工具,再把每个工具包装一下。简化后是这样:

typescript
// 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节的匹配函数还支持只写服务名:

json
{ "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 信了这个声明,就允许它和其他只读工具并行执行。

本文是会员内容

全文 3,999 字,已试读约 29%。 开通专业版可阅读全文,并解锁全部课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟期前 500 名自动升级永久卡