MCP (Model Context Protocol) 系统详解
MCP 是 Anthropic 2024 年推出、现已被 Cursor / Cline / Continue / Zed / Goose 等多家产品采纳的 agent 工具生态事实标准。 理解它既是做 AI agent 开发必过的一关,也是简历上最硬的 20% 差异化知识点。本文从 JSON-RPC 底层一路讲到实战写一个 MCP server。
01MCP 是什么,为何是行业标准
MCP = Model Context Protocol。它解决一个核心问题:LLM 客户端(Claude Code、Cursor、ChatGPT Desktop...)如何统一地连接到任意工具服务(GitHub、Slack、数据库、搜索引擎...)。
1.1 MCP 要解决的"N×M 问题"
1.2 类比:MCP 之于 LLM 工具 = LSP 之于编辑器语言
LSP(Language Server Protocol)让 VSCode / Vim / Emacs / IntelliJ 能共用一套"语言服务器"。 MCP 用同样的思路让任意 LLM 客户端能共用一套"工具服务器"。
1.3 为什么简历要写它
- 标准化:Anthropic、Google、Microsoft 都在支持
- 可写可调:官方 SDK 有 TypeScript / Python / Go
- 面试热点:涉及 JSON-RPC、OAuth、异步消息、schema 设计
- 工程密度高:auth.ts 单文件 88KB,有大量生产级决策
02协议基础 — JSON-RPC 2.0
MCP 底层是 JSON-RPC 2.0,一个极其轻量的 RPC 协议。三种消息类型、一个 id 字段,就把所有交互都统一了。
2.1 三种消息类型
// Request(请求) — 必须带 id
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
// Response(响应) — 必须带对应 id
{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [...] } }
// 或错误响应
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } }
// Notification(通知) — 无 id,无需响应
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }
2.2 MCP 定义的方法清单
| 方法 | 方向 | 用途 |
|---|---|---|
initialize | client → server | 握手,交换 capabilities |
notifications/initialized | client → server | 握手完成通知 |
tools/list | client → server | 列出工具 |
tools/call | client → server | 调用工具 |
resources/list | client → server | 列出资源 |
resources/read | client → server | 读取资源内容 |
prompts/list | client → server | 列出 prompt 模板 |
prompts/get | client → server | 实例化 prompt |
sampling/createMessage | server → client | server 请求 client 调 LLM |
elicitation/create | server → client | server 向 client 索要缺失参数 |
notifications/tools/list_changed | server → client | 工具列表变化 |
notifications/resources/updated | server → client | 资源内容更新 |
2.3 握手序列
03三种传输层对比
JSON-RPC 消息通过什么物理通道传输?MCP 支持 3+ 种,Claude Code 都实现了(src/services/mcp/types.ts:24)。
3.1 传输类型对照(types.ts)
| Transport | 进程模型 | 网络 | 适合场景 |
|---|---|---|---|
stdio | 子进程 | 无(管道) | 本地工具、CLI 集成、最安全 |
sse | 远端服务 | HTTP + Server-Sent Events | 旧版远程 server(向后兼容) |
http | 远端服务 | HTTP(streamable) | 现代远程 server(推荐) |
ws | 远端服务 | WebSocket | 低延迟双向 |
sse-ide | IDE 插件 | localhost | 内部:IDE 扩展暴露工具 |
ws-ide | IDE 插件 | localhost | 内部:WebSocket IDE |
sdk | 同进程 | 无 | in-process MCP(SDK 宿主) |
claudeai-proxy | Claude.ai 转发 | HTTP | 内部:订阅用户用 |
3.2 stdio 详解(最常见)
// ~/.claude/mcp.json 配置
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": { "LOG_LEVEL": "debug" }
}
}
}
Claude Code 启动时会 spawn('npx', args, { env }) 起子进程,通过 stdin/stdout 交换 JSON-RPC 消息,以换行 \n 分隔。
3.3 SSE vs streamable HTTP 区别
| 方面 | SSE(旧) | streamable HTTP(新) |
|---|---|---|
| 连接数 | 2 个(GET 长连接 + POST 短连接) | 1 个(可复用) |
| 请求-响应 | 分两条路径 | 同一个 HTTP 请求内完成 |
| 代理友好度 | 差(SSE 易被代理超时关) | 好(普通 HTTP) |
| 推荐程度 | legacy | 新项目 |
3.4 Claude Code 的长连接特殊处理
// client.ts:648 — SSE EventSource 的 fetch 不能用 timeout wrapper
// 因为 EventSource 是长连接,60 秒 timeout 会杀掉它
transportOptions.eventSourceInit = {
fetch: async (url, init) => {
const tokens = await authProvider.tokens()
if (tokens) authHeaders.Authorization = `Bearer ${tokens.access_token}`
return fetch(url, { ...init, headers: { ...init.headers, ...authHeaders } })
},
}
// POST 则使用带 timeout 的 wrapFetchWithTimeout
04连接生命周期
每个 MCP server 都是一个有限状态机,Claude Code 在 src/services/mcp/types.ts:180-226 定义了 5 种状态。
4.1 五种状态
4.2 ConnectedMCPServer 完整结构
// types.ts:180
type ConnectedMCPServer = {
client: Client // MCP SDK Client 实例
name: string // server 配置里的 key
type: 'connected'
capabilities: ServerCapabilities // server 声明的能力
serverInfo?: { name: string, version: string }
instructions?: string // server 的 system 级提示
config: ScopedMcpServerConfig // 原始配置 + 来源 scope
cleanup: () => Promise<void> // 关闭连接
}
4.3 状态转换触发点
| 转换 | 触发源 | 代码位置 |
|---|---|---|
| pending → connected | connectToServer 握手成功 | client.ts:595 |
| pending → failed | 任意非 401 异常 | catch 分支 |
| pending → needs-auth | 401 + OAuth 配置 | auth.ts:340 |
| connected → needs-auth | 工具调用遇 401 | McpAuthError 抛出后 |
| needs-auth → connected | 用户完成 OAuth 后重连 | reconnectMcpServerImpl |
05配置 7 层来源
MCP server 配置可以来自 7 个不同地方,按优先级合并(src/services/mcp/types.ts:10)。
5.1 ConfigScope 7 层
// types.ts:10
ConfigScope = 'local' // ~/.claude/mcp.json 用户全局
| 'user' // 用户级 settings
| 'project' // .claude/mcp.json 项目级
| 'dynamic' // agent 运行时 inline 定义
| 'enterprise' // 企业级(MDM)
| 'claudeai' // Claude.ai 集成
| 'managed' // 托管策略
5.2 合并优先级(由低到高)
- local:用户个人的
~/.claude/mcp.json - user:
~/.claude/settings.json里的mcpServers - project:项目目录
.claude/mcp.json(团队共享) - dynamic:agent 在 frontmatter 里 inline 定义的 MCP server,用完即销毁
- claudeai:Claude.ai 订阅用户的托管 MCP 集成
- managed:
managed-settings.json的托管配置 - enterprise(最高):MDM 推送的策略,用户无法覆盖
5.3 策略过滤 — allowlist / denylist
// config.ts:364 — 企业可以全局禁用某些 server
function isMcpServerDenied(name, config, denylist) {
// 按名字匹配 / 按 URL pattern 匹配
if (denylist.names?.includes(name)) return true
const url = getServerUrl(config)
if (url && denylist.urlPatterns?.some(p => urlMatchesPattern(url, p))) return true
return false
}
5.4 env 变量展开
// config.ts:556 — env 值里支持 ${VAR_NAME} 引用
{
"command": "node",
"args": ["server.js"],
"env": {
"API_KEY": "${MY_API_KEY}", // ← 从 process.env 展开
"DB_URL": "postgres://..."
}
}
06connectToServer 的 memoize
connectToServer 被 lodash memoize 包装,cache key 是 name + JSON.stringify(config)。这个看似简单的优化解决了一个关键工程问题。
6.1 为什么要 memoize
// client.ts:581
export function getServerCacheKey(name, serverRef) {
return `${name}-${jsonStringify(serverRef)}`
}
export const connectToServer = memoize(
async (name, serverRef, serverStats) => { ... },
getServerCacheKey
)
github server。
如果每个 agent 各自 spawn 一次 npx,会有:
- 同一 OAuth token 的三次并行 refresh
- 子进程开销(npx 冷启动 ~2s)×3
- 文件锁 / 端口冲突
6.2 反面:什么时候必须新建连接
agent frontmatter 里写的 inline server(scope='dynamic')会带上 agent 特定的配置。这种情况 cacheKey 不同,会新建一个连接,跟着 agent 生命周期走 —— 这在 multi-agent 篇的 initializeAgentMcpServers 里已经讲过。
6.3 clearServerCache 的用法
// client.ts:1650
export async function clearServerCache(name: string, config?: ScopedMcpServerConfig) {
// 先关闭该连接的所有资源
// 然后 connectToServer.cache.delete(key)
// 下一次 connectToServer 调用会重新建立连接
}
用途:OAuth token 更新后、用户改动配置后、reconnect 命令等。
07OAuth + DCR 完整流程
远端 MCP server 要做 OAuth 认证。Claude Code 用的是 OAuth 2.1 + PKCE + Dynamic Client Registration 组合。整个 auth.ts 有 88KB,处理各种边缘情况。
7.1 完整流程
7.2 PKCE 为什么重要
没有 PKCE(Proof Key for Code Exchange),攻击者可以拦截 authorization code 换 token。PKCE 让 client 在 /authorize 时发送 code_challenge = SHA256(random_verifier),在 /token 时发送原始 code_verifier。服务端校验后才发 token。
7.3 DCR(动态客户端注册)
传统 OAuth 要求 client 预先在 provider 那注册拿 client_id。但 MCP 是动态发现的 —— 用户今天才知道要用这个 server。DCR(RFC 7591)允许 client 自动 POST 到 /register 端点动态拿 client_id,解决这个 chicken-and-egg 问题。
7.4 Token 刷新
// ClaudeAuthProvider(auth.ts:1376)是 MCP SDK 的 OAuthClientProvider 实现
class ClaudeAuthProvider implements OAuthClientProvider {
async tokens(): Promise<OAuthTokens | undefined>
async saveTokens(tokens): Promise<void>
async redirectToAuthorization(url): Promise<void> // 打开浏览器
async saveCodeVerifier(verifier): Promise<void>
async codeVerifier(): Promise<string>
}
Token 用本地系统 keychain 保存(macOS Keychain / Windows Credential / libsecret),不写明文文件。
7.5 Step-Up Authentication
某些 API 需要"加强认证"。server 返回 403 + insufficient_scope,client 需要重新走一次 OAuth 但带上 scope=higher。wrapFetchWithStepUpDetection(auth.ts:1354) 在 fetch 层做拦截。
08原语 1 — tool
Tool 是最常用的原语,90% 的 MCP server 只提供 tool。
8.1 tool 的生命周期
- discover:
tools/list返回 [{name, description, inputSchema}] - normalize:服务端工具名可能含非法字符,client 做规范化(normalization.ts)。规则:name →
mcp__<server>__<normalizedName> - register:包装成 Claude Code 的 Tool 接口,加入 tool pool
- call:
tools/call传入 {name, arguments},返回 {content, isError}
8.2 名字规范化
// 服务端原始名
"create-issue" (含连字符)
"github.search" (含点)
// 经过规范化
"mcp__github__create_issue"
"mcp__github__github_search"
// 规则:下划线化,加前缀,保留原名供反查
8.3 Content 类型
// tool 结果可以是多种内容类型混合
MCPToolResult = {
content: Array<
| { type: 'text', text: string }
| { type: 'image', data: string, mimeType: string } // base64
| { type: 'resource', resource: { uri, mimeType, text? / blob? } }
>
isError?: boolean
_meta?: Record<string, unknown> // 非模型可见元数据
}
8.4 client 侧 transformResultContent
// client.ts:2480 — 处理 resource 嵌套、blob 持久化
export async function transformResultContent(content, toolName, serverName) {
// 1. text 直接透传
// 2. image 检查 size < 限制,否则截断
// 3. resource(blob) 先落盘到 /tmp,content 替换为 "[saved to /tmp/xxx]"
// 4. resource(text) 内嵌
}
09原语 2 — resource
Resource 是 server 暴露给 client 的"可读内容",用 URI 标识。像 REST 的 GET,只是没有 body。
9.1 例子
// filesystem MCP server 暴露文件为 resource
{ uri: "file:///Users/me/project/README.md", name: "README", mimeType: "text/markdown" }
{ uri: "file:///Users/me/project/src/index.ts", ... }
// PostgreSQL MCP 暴露表
{ uri: "postgres://users", name: "users table", mimeType: "application/json" }
// Git MCP 暴露 branch
{ uri: "git://branch/main", name: "main branch", ... }
9.2 URI template(RFC 6570)
server 可以返回 URI template,client 按模板填参:
{ uriTemplate: "file:///{path}" }
// client 填参数:file:///Users/me/specific.txt
9.3 订阅变化
// client 订阅
{ method: "resources/subscribe", params: { uri: "file:///watched/log.txt" } }
// server 内容变化时推送 notification
{ method: "notifications/resources/updated", params: { uri: "file:///watched/log.txt" } }
// client 收到通知后重新 resources/read
9.4 Resource 在 Claude Code 的使用
Claude Code 通过 prefetchAllMcpResources(client.ts:2410)在连接建立后预取资源列表,展示在 UI 里供用户 @ 引用。
10原语 3 — prompt
Prompt 是 server 提供的"prompt 模板",类似 slash command。用户/agent 用参数实例化,得到完整消息。
10.1 例子
// Git MCP server 提供 prompt
{
name: "review_pr",
description: "Review a pull request for issues",
arguments: [
{ name: "pr_number", required: true },
{ name: "focus", required: false }
]
}
// client 调用:
{ method: "prompts/get", params: { name: "review_pr", arguments: { pr_number: "123" } } }
// server 返回实例化后的消息:
{
messages: [
{ role: "user", content: { type: "text", text: "Review PR #123 carefully..." } }
]
}
10.2 为什么单独的原语
11原语 4 — sampling(反向调用)
Sampling 是 MCP 最反直觉的原语:server 请求 client 用 LLM。方向反了。
11.1 场景
// 一个"代码审查" MCP server 的内部逻辑:
// 1. 用户调工具 reviewCode(code)
// 2. server 自己需要调 LLM 分析(但 server 没有 API key!)
// 3. server 发 sampling/createMessage 给 client:
{
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: "Analyze: ..." }],
maxTokens: 2000,
systemPrompt: "You are a code reviewer..."
}
}
// 4. client 用自己的 LLM 跑(消耗用户 API 配额)
// 5. 结果返回给 server
// 6. server 综合后返回给调用工具的那一方
11.2 为什么有这个
- server 不需要自己持有 LLM 凭证
- 用户可以控制模型选择、成本、cache
- 隐私:敏感数据不需要发给 server 再发给 LLM
12Elicitation 动态参数
工具调用时 server 发现参数不全,可以向 client 反问要缺失的参数。
12.1 流程
// 模型调:createFile({ path: "foo.ts" }) — 少了 content 参数
// server 不是返回 error,而是:
{
method: "elicitation/create",
params: {
message: "What should be the file content?",
requestedSchema: { type: "object", properties: { content: { type: "string" } } }
}
}
// client 收到后:
// - 打开一个对话框让用户填(UI mode)
// - 或传给模型让它填(auto mode)
// 然后发回 server,server 继续执行工具
12.2 URL Elicitation 特例
// client.ts:2815 — 专门处理"打开 URL 让用户点击"型 elicitation
async function callMCPToolWithUrlElicitationRetry({ ..., handleElicitation }) {
for (let attempt = 0; ; attempt++) {
try { return await callToolFn(...) }
catch (error) {
if (!(error instanceof McpError) ||
error.code !== ErrorCode.UrlElicitationRequired) throw error
if (attempt >= MAX_URL_ELICITATION_RETRIES) throw error
await handleElicitation(serverName, params, signal)
// 然后循环重试,希望这次 server 不再需要
}
}
}
13通知系统
server 可以主动通知 client 状态变化,无需 client 轮询。
13.1 主要通知类型
| 通知 | 触发 | Claude Code 响应 |
|---|---|---|
tools/list_changed | server 动态加减工具 | 重新 tools/list,更新 tool pool |
resources/list_changed | 资源列表变 | 重新 resources/list |
resources/updated | 订阅的资源内容变 | 通知订阅方,重新 resources/read |
prompts/list_changed | prompt 变化 | 刷新 prompts 列表 |
progress | 长任务进度 | onProgress 回调更新 UI |
log/message | server 打日志 | 记录到 client 调试日志 |
13.2 progress 通知的特殊处理
// server 在执行 reviewCode 时每完成一个文件发一次 progress
{ method: "notifications/progress", params: {
progressToken: "req-42", // 关联到哪个 request
progress: 30, total: 100,
message: "Reviewed src/foo.ts"
}}
// client 通过 onProgress 回调显示进度条
14错误恢复
MCP 的错误有三个层级:传输层、协议层、应用层。每种都有不同恢复策略。
14.1 传输层错误
| 错误 | 原因 | 恢复 |
|---|---|---|
| ECONNRESET | 连接断 | reconnectMcpServerImpl 重连 |
| ETIMEDOUT | 超时 | 指数退避重试 |
| stdio pipe closed | 子进程死了 | 重新 spawn,重新握手 |
14.2 协议层错误(JSON-RPC)
// 标准错误码
-32700 Parse error
-32600 Invalid Request
-32601 Method not found
-32602 Invalid params
-32603 Internal error
// MCP 扩展码
ErrorCode.UrlElicitationRequired // 需要 URL elicitation
ErrorCode.AuthorizationRequired // 需要认证
14.3 应用层(tools/call 的 isError)
工具返回 isError: true 不是协议错误 —— 而是"工具说执行失败"。client 应该把它作为 tool_result 喂给模型,让模型自己决定怎么办。这跟协议错误截然不同。
14.4 重试次数上限
// URL elicitation 重试 3 次
const MAX_URL_ELICITATION_RETRIES = 3
// 连接重试通过 reconnectAttempt / maxReconnectAttempts
// 失败后转 failed 状态,用户需要手动 /mcp reconnect
15权限隔离与命名空间
一个 client 连多个 MCP server 时,如何防止工具名冲突、权限串扰?
15.1 命名空间
// 服务端原始工具名 "create_issue"
// 在 client 侧一律带 server 前缀:
"mcp__github__create_issue"
"mcp__gitlab__create_issue"
// 这样权限规则可以按 server 粒度控制:
"mcp__github(*)" // 允许 GitHub 所有工具
"mcp__gitlab(create_issue)" // 只允许 GitLab 创建 issue
15.2 channelPermissions
channelPermissions.ts 负责"哪些 MCP server 的工具可以在什么 context 下使用"。比如 plugin 提供的 MCP server 只能被该 plugin 的 agent 用,不被其他 agent 看到。
15.3 admin-trusted gate
// runAgent.ts:124 — 企业策略可锁 MCP 为 "plugin-only"
const agentIsAdminTrusted = isSourceAdminTrusted(agentDefinition.source)
if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) {
// 用户自定义 agent 不能引入 MCP server
return { clients: parentClients, tools: [], cleanup: async() => {} }
}
16与 Agent 系统的集成
MCP client 产出的 Tool 如何流入 Claude Code 的主循环?
16.1 启动期发现
// bootstrap 阶段
const configs = await getAllMcpConfigs() // 合并 7 层配置
const clients = await Promise.all(
Object.entries(configs).map(([name, config]) => connectToServer(name, config))
)
// clients: Array<MCPServerConnection>
const mcpTools = await getMcpToolsCommandsAndResources(clients)
// mcpTools: Array<Tool>
16.2 注入主线程 tool pool
// assembleToolPool(tools.ts)
function assembleToolPool(permissionContext, mcpTools) {
return [
...BUILTIN_TOOLS,
...mcpTools, // MCP 工具作为一等公民
].filter(t => isAllowedByMode(t, permissionContext))
}
16.3 agent 私有 MCP server
见 multi-agent 篇的 initializeAgentMcpServers —— agent frontmatter 可以 inline 定义 server,仅在该 agent 生命周期内存在。
16.4 runtime 添加 server(/mcp add)
用户可在 REPL 里 /mcp add 动态添加一个 server,系统会:
- 写配置到
~/.claude/mcp.json(或 project.claude/mcp.json) - 调
connectToServer建立连接 - 通过
refreshTools把新工具加入 tool pool - 下一回合模型就能看到新工具
17实战:20 行写一个 MCP server
理解协议最好的方式是写一个。下面是一个完整的 echo server。
17.1 package.json
{
"name": "echo-mcp-server",
"version": "1.0.0",
"type": "module",
"bin": { "echo-mcp": "./index.js" },
"dependencies": { "@modelcontextprotocol/sdk": "^1.0.0" }
}
17.2 index.js
// #!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
const server = new Server(
{ name: "echo", version: "1.0.0" },
{ capabilities: { tools: {} } }
)
server.setRequestHandler("tools/list", async () => ({
tools: [{
name: "echo",
description: "Echo the input text",
inputSchema: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"]
}
}]
}))
server.setRequestHandler("tools/call", async (req) => ({
content: [{ type: "text", text: req.params.arguments.text }]
}))
const transport = new StdioServerTransport()
await server.connect(transport)
17.3 在 Claude Code 里用
// ~/.claude/mcp.json
{
"mcpServers": {
"echo": {
"command": "node",
"args": ["/absolute/path/to/index.js"]
}
}
}
重启 Claude Code,模型即可调 mcp__echo__echo({ text: "hello" })。
console.error() 打日志,
Claude Code 会在 /mcp logs 里显示。永远不要用 console.log(),会污染协议流。
18可迁移设计原则
从 MCP 学到的 6 条可迁移到任何 agent 系统的原则。
-
标准化接口胜过更强的 SDK
Anthropic 本可以为 Claude 做一套独占 SDK。选择开放标准意味着"我可能会失去定价权,但会赢得生态"。 事实证明生态效应远大于短期锁定。对 AI agent 开发者:优先选择开放标准而非单一厂商 SDK。
-
JSON-RPC 的简洁胜过 gRPC / 自定义协议
JSON-RPC 文本、自描述、易调试、易嵌入任何语言。gRPC 性能更好但重量十倍。在 LLM 场景,网络延迟远大于序列化,JSON-RPC 是绝配。
-
能力协商胜过固定接口
initialize阶段 client/server 各自声明 capabilities,后续只调用双方都支持的方法。这让协议能无缝演进:新增能力不破坏旧 client。 -
反向 RPC(sampling)是生态杀手锏
允许 server 调 client 的 LLM 从根本上改变了权力关系 —— server 不再需要持有模型凭证,用户控制成本。 设计协议时多考虑双向性。
-
Elicitation:对话式工具调用
传统工具"参数不全就报错",elicitation 让 server 能反问 client。这是把"工具调用"从一次性 RPC升级到多轮对话的关键设计。
-
命名空间防冲突胜过全局工具名
mcp__<server>__<tool>的三段式名字让权限规则可按 server 粒度控制,也让多个 server 可以有同名工具(github/create_issue vs gitlab/create_issue)。 多租户协议必须命名空间化。