本文基于 Claude Code 实际使用经验,深入拆解 MCP 协议的调用原理。如果你正在用 Claude Code 或准备接入 MCP,这篇文章会帮你真正理解「LLM 是怎么调用外部工具的」。
前言:LLM 的能力边界问题
大语言模型很强,但有一个根本限制:它只能生成文本,不能直接操作外部世界。
它不能帮你查数据库,不能帮你调 GitLab API,不能帮你读写文件 ------ 除非有人把这些能力「接」给它。
过去的做法是各家各写各的插件系统(ChatGPT Plugins、LangChain Tools......),互不兼容。
MCP 就是为了解决这个问题而生的。
一、MCP 是什么
MCP(Model Context Protocol) 是 Anthropic 在 2024 年底提出的开放协议,一句话概括:
LLM 的 USB-C 接口 ------ 定义了一套标准协议,让任何 LLM 都能以统一方式调用任何外部工具。
就像 USB-C 统一了充电线,MCP 统一了 LLM 与外部工具的通信方式:
java
传统方式:每个工具写一套适配代码
LLM ←→ 自定义适配器A ←→ GitLab
LLM ←→ 自定义适配器B ←→ MySQL
LLM ←→ 自定义适配器C ←→ Slack
MCP 方式:一套协议,即插即用
LLM ←→ MCP Client ←→ MCP Server (GitLab)
←→ MCP Server (MySQL)
←→ MCP Server (Slack)
二、架构:三个角色
MCP 的架构非常清晰,只有三个角色:
scss
┌─────────────────────────────────────────────────┐
│ MCP Host (Claude Code) │
│ │
│ 用户提问 → LLM 推理 → 决定调用哪个工具 → 返回结果 │
│ ↕ │
│ MCP Client (内置) │
└────────┬──────────────┬───────────────────────────┘
│ stdio │ stdio
↓ ↓
┌────────────────┐ ┌─────────────────┐
│ MCP Server A │ │ MCP Server B │
│ (GitLab) │ │ (MySQL) │
│ │ │ │
│ 50+ 个工具 │ │ 8 个工具 │
└────┬───────────┘ └────┬────────────┘
↓ ↓
GitLab API MySQL DB
| 角色 | 职责 | 实际例子 |
|---|---|---|
| Host | 宿主应用,面向用户,内嵌 LLM | Claude Code、Cursor、Windsurf |
| Client | 内置于 Host,管理所有 Server 连接 | Claude Code 启动时自动创建 |
| Server | 独立进程,封装具体能力,暴露工具 | @zereight/mcp-gitlab、自建 Server |
核心设计思想:Host 和 Client 是 1:1 的,Client 和 Server 是 1:N 的。一个 Claude Code 实例可以同时连接多个 MCP Server。
三、完整调用流程:5 步拆解
下面用一个真实场景走一遍完整流程:用 Claude Code 查看 GitLab 仓库的 Merge Request。
Step 1:启动时注册 ------ "握手"
Claude Code 启动时,读取配置文件 ~/.claude.json:
json
{
"mcpServers": {
"gitlab": {
"command": "npx",
"args": ["-y", "@zereight/mcp-gitlab"],
"env": {
"GITLAB_API_URL": "https://gitlab.example.com/api/v4",
"GITLAB_TOKEN": "glpat-xxxxxxxx"
}
}
}
}
Claude Code 做了两件事:
1)启动子进程
bash
# Claude Code 内部执行
npx -y @zereight/mcp-gitlab
# 这个进程通过 stdin/stdout 与 Claude Code 通信
2)握手协商:获取工具列表
Client 向 Server 发送初始化请求,Server 返回它所提供的全部工具定义:
json
{
"tools": [
{
"name": "list_merge_requests",
"description": "List merge requests for a project",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string" },
"state": { "type": "string", "enum": ["opened", "closed", "merged"] }
},
"required": ["project_id"]
}
}
// ... 还有 50+ 个工具
]
}
这些工具被注册到 Claude 的可用工具列表中,统一加上命名空间前缀:
markdown
list_merge_requests → mcp__gitlab__list_merge_requests
create_issue → mcp__gitlab__create_issue
前缀规则 :
mcp__{server名}__{工具名},这就是你在 Claude Code 中看到的工具全名。
Step 2:用户提问,LLM 决策 ------ "选工具"
用户输入:
帮我看看项目 ID 为 2 的仓库最近有哪些打开的 MR
此时,LLM 收到的 prompt 中包含了所有可用工具的描述和参数定义(类似 OpenAI 的 Function Calling)。LLM 推理后,输出一段结构化的工具调用意图:
json
{
"tool": "mcp__gitlab__list_merge_requests",
"arguments": {
"project_id": "2",
"state": "opened"
}
}
这里有一个关键认知:
LLM 本身不执行任何调用。它只是输出了一段 JSON,表达"我认为应该调用这个工具,参数是这些"。真正执行调用的是 Claude Code。
这就像一个指挥官下达命令,但不亲自上阵。
Step 3:Claude Code 路由 ------ "找到对的 Server"
Claude Code 拿到 LLM 输出的工具调用意图后,解析前缀进行路由:
arduino
mcp__gitlab__list_merge_requests
│ │ │
│ │ └── 实际方法名:list_merge_requests
│ └── Server 名:gitlab → 定位到 gitlab 子进程
└── 标识:这是 MCP 工具(区别于内置工具)
然后通过 stdio 向 gitlab 子进程发送标准的 JSON-RPC 2.0 请求:
json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "list_merge_requests",
"arguments": {
"project_id": "2",
"state": "opened"
}
},
"id": 1
}
Step 4:MCP Server 执行 ------ "干活"
GitLab MCP Server 收到请求后,在内部将其转化为对 GitLab REST API 的调用:
ini
JSON-RPC 请求进来
↓
解析 name = "list_merge_requests", arguments = {project_id: "2", state: "opened"}
↓
构造 HTTP 请求:
GET https://gitlab.example.com/api/v4/projects/2/merge_requests?state=opened
Headers: PRIVATE-TOKEN: glpat-xxxxxxxx
↓
发送请求,接收 GitLab 响应
↓
封装为 JSON-RPC 响应格式返回
返回给 Claude Code:
json
{
"jsonrpc": "2.0",
"result": {
"content": [
{
"type": "text",
"text": "[{\"iid\": 5, \"title\": \"fix: 修复并发上传文件名冲突\", \"author\": {\"name\": \"zhangsan\"}, \"created_at\": \"2026-02-28\"}]"
}
]
},
"id": 1
}
Step 5:LLM 整合回答 ------ "翻译成人话"
Claude Code 把工具返回的结果注入到新一轮 prompt 中,LLM 基于结果生成自然语言回复:
项目 tongue_back 目前有 1 个打开的 MR:
MR 标题 作者 创建时间 !5 fix: 修复并发上传文件名冲突 zhangsan 2026-02-28
完整时序图
vbscript
用户 Claude Code LLM MCP Server GitLab
│ │ │ │ │
│── "查看 MR" ───→│ │ │ │
│ │── 组装 prompt ──→│ │ │
│ │ (含工具列表) │ │ │
│ │ │── 推理决策 │ │
│ │←─ 工具调用意图 ──│ │ │
│ │ │ │
│ │── JSON-RPC request ───────────────→│ │
│ │ │── HTTP GET ──→│
│ │ │←── JSON ──────│
│ │←── JSON-RPC response ──────────────│ │
│ │ │ │
│ │── 注入结果到 prompt → │ │
│ │ │── 生成自然语言 │ │
│←── 格式化回复 ──│←────────────────│ │ │
四、通信协议详解
传输层:stdio vs SSE
MCP 支持两种传输方式:
| 传输方式 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| stdio | 子进程,stdin/stdout 交换数据 | 简单可靠,无需网络 | 只能本地,1:1 | Claude Code 默认方式 |
| SSE | HTTP Server-Sent Events | 可远程,可共享 | 需要部署 HTTP 服务 | 团队共享 Server |
stdio 的完整生命周期:
c
Claude Code 进程启动
│
├── 读取 ~/.claude.json
├── 对每个 mcpServers 配置:
│ ├── fork 子进程(如 npx @zereight/mcp-gitlab)
│ ├── 建立 stdin/stdout 管道
│ └── 发送 initialize 请求,完成握手
│
├── 会话期间:按需通过 stdin 发请求,stdout 收响应
│
└── Claude Code 退出 → 所有 MCP Server 子进程随之终止
消息层:JSON-RPC 2.0
所有通信使用 JSON-RPC 2.0 协议,有三种消息类型:
json
// 1. 请求(Request)------ Client → Server
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "list_merge_requests", "arguments": {...} },
"id": 1
}
// 2. 响应(Response)------ Server → Client
{
"jsonrpc": "2.0",
"result": { "content": [...] },
"id": 1
}
// 3. 通知(Notification)------ 单向,无需响应
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
五、延迟加载:如何应对 50+ 个工具
一个 GitLab MCP Server 就暴露了 50+ 个工具。如果每个工具的完整 Schema 都塞进 LLM 的 prompt,会严重浪费 token。
Claude Code 的解决方案是 Deferred Tools(延迟加载):
scss
启动时:
只注册工具名列表(几十个名字,很省 token)
↓
需要时:
LLM 判断需要某类工具 → 调用 ToolSearch
↓
ToolSearch("gitlab merge request")
→ 返回匹配的工具 + 完整 Schema
→ 这些工具现在可用了
↓
调用时:
LLM 正常调用已加载的工具
好处:上下文窗口只加载当前需要的工具,而不是一股脑全塞进去。
六、MCP vs 直接调 API:该用哪个?
同样是"列出 GitLab 的 MR",对比两种方式:
方式 A:Bash + curl
bash
curl -s --header "PRIVATE-TOKEN: glpat-xxx" \
"https://gitlab.example.com/api/v4/projects/2/merge_requests?state=opened" \
| jq '.[] | {iid, title, author: .author.name}'
特点:
- LLM 需要知道 API 的 URL、参数格式、认证方式
- 认证 token 暴露在命令行中
- 返回原始 JSON,LLM 自己解析
- 灵活,但 LLM 容易拼错 URL 或参数
方式 B:MCP 工具
ini
mcp__gitlab__list_merge_requests(project_id="2", state="opened")
特点:
- LLM 只需填参数名和值,不用关心 API 细节
- 认证信息封装在 Server 环境变量中
- Server 内部做了错误处理和格式化
- 类型安全,参数有 Schema 校验
最佳实践:各取所长
| 操作 | 推荐方式 | 原因 |
|---|---|---|
git add / commit / push |
Bash | 本地 Git 操作,MCP 管不到 |
| 查看 / 创建 MR、Issue | MCP | 平台 API 操作,MCP 封装更好 |
| 数据库查询 | MCP | 连接管理、参数化查询更安全 |
| 系统命令、脚本 | Bash | MCP 不涉及本地系统操作 |
一句话:本地操作用 Bash,平台交互用 MCP。
七、动手写一个 MCP Server
理解了原理,写一个自己的 MCP Server 其实很简单。以 Python FastMCP 为例:
python
# my_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-tools")
@mcp.tool()
def query_patient(patient_id: str) -> dict:
"""根据患者 ID 查询诊断记录"""
import requests
resp = requests.get(f"http://localhost:8080/api/patient/{patient_id}")
return resp.json()
@mcp.tool()
def analyze_image(image_path: str) -> dict:
"""分析医学图像,返回 AI 诊断结果"""
import requests
with open(image_path, "rb") as f:
resp = requests.post("http://localhost:5000/predict", files={"image": f})
return resp.json()
if __name__ == "__main__":
mcp.run(transport="stdio")
配置到 ~/.claude.json:
json
{
"mcpServers": {
"my-tools": {
"command": "python",
"args": ["my_server.py"]
}
}
}
然后在 Claude Code 中就能直接说:
帮我查一下患者 P001 的诊断记录
Claude 会自动调用 mcp__my_tools__query_patient(patient_id="P001")。
八、核心设计要点总结
| 设计点 | 说明 |
|---|---|
| LLM 不直接调用 | LLM 只输出调用意图(JSON),Host 负责实际执行 |
| 权限控制在用户侧 | Claude Code 根据权限设置决定是否需要用户确认 |
| 工具延迟加载 | 避免几十个工具 Schema 撑爆上下文窗口 |
| 一个 Server 多个工具 | 一次连接,提供一组相关能力 |
| 协议标准化 | JSON-RPC 2.0 + MCP 规范,任何语言都能实现 Server |
| 传输可插拔 | stdio(本地)和 SSE(远程)可按需选择 |
写在最后
MCP 的本质是给 LLM 提供了一个标准化的工具调用协议。它不复杂,但解决了一个很实际的问题:让 AI 从"只能聊天"变成"能干活"。
如果你正在用 Claude Code,MCP 已经在替你工作了 ------ 每次你让它查 GitLab、读数据库,背后走的都是这套流程。
理解了原理,你就能:
- 更好地配置和调试 MCP Server
- 在合适的场景选择 MCP 还是 Bash
- 为自己的业务系统写 MCP Server,让 AI 真正成为你的工具
如果这篇文章对你有帮助,欢迎点赞收藏。关于 Claude Code 的更多实践,可以看我的其他文章。