本文从实际使用场景出发,逐步拆解 MCP 的设计动机、通信原理、SDK 源码架构,帮助你真正理解这个 AI 工具生态的"USB 接口"。
一、MCP 是什么?一句话说清楚
MCP(Model Context Protocol)是给 AI 接外挂的标准接口。
AI 大模型本身只会"思考和生成文本",但很多事情光靠文本做不到——操控浏览器、查数据库、调用第三方 API、读写特定系统。这些能力需要外部程序来提供。
MCP 就是连接 AI 和外部程序的标准协议。它规定了:
- 外部程序怎么声明自己有哪些能力(工具)
- AI 怎么发现这些能力
- AI 怎么调用这些能力
- 外部程序怎么返回结果
为什么需要 MCP?
在 MCP 出现之前,每个 AI 平台对接外部工具的方式都不一样。你给 Claude 写的工具,换到 Cursor 上要重写适配,换到 OpenAI 又是另一套格式。MCP 统一了这套规范——写一次,到处用。
二、从一个问题出发:AI 怎么调用外部能力?
假设你有一组浏览器操作函数(打开网页、点击、输入),想让 AI 能调用它们。有两种方案:
方案一:Flask + Skill(手动对接)
# 用 Flask 写接口
from flask import Flask, request
app = Flask(__name__)
@app.route("/browser/click", methods=["POST"])
def click():
selector = request.json["selector"]
# 执行点击逻辑...
return {"success": True}
app.run(port=5000)
然后写一份 Skill 文档告诉 AI:
你可以通过以下接口操控浏览器:
- POST http://localhost:5000/browser/click,参数:{"selector": "CSS选择器"}
- POST http://localhost:5000/browser/open,参数:{"url": "网址"}
...
AI 读了文档后,需要时用 curl 命令调用接口。
这完全可以跑。 但有几个问题:
- 你要自己写文档描述每个接口
- 改了接口忘改文档就出错
- 换个 AI 平台(比如从 Claude Code 换到 Cursor),文档格式要重写
- AI 通过 Bash 执行 curl 调用,链路长、解析麻烦
方案二:MCP(标准化协议)
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("browser-tools")
@server.list_tools()
async def list_tools():
return [
Tool(name="browser_click", description="点击页面元素",
inputSchema={"type":"object", "properties":{"selector":{"type":"string"}}})
]
@server.call_tool()
async def call_tool(name, args):
if name == "browser_click":
# 执行点击逻辑...
return [TextContent(type="text", text='{"success": true}')]
配置一行命令注册到 Claude Code,AI 自动发现所有工具,直接调用。
两种方案完整对比
| 维度 | Flask + Skill | MCP |
|---|---|---|
| 工具描述 | 手写文档,和代码分离 | 写在代码里,代码即文档 |
| AI 发现工具 | 读 Skill 文本理解(可能有偏差) | 调 list_tools() 拿结构化 Schema |
| 调用方式 | Bash → curl → HTTP → Flask | 内置 MCP Client → 直接调函数 |
| 参数类型 | AI 靠文本理解参数格式 | JSON Schema 明确定义类型 |
| 跨平台 | 换平台要重写 Skill 适配 | Claude Code / Cursor / OpenClaw 通用 |
| 文档同步 | 手动维护,容易过时 | 改代码就是改描述,自动同步 |
| 权限管理 | 自己实现 | 内置 allowed_tools / disallowed_tools |
三、MCP vs Flask/REST API 的本质区别
路由方式对比
Flask(每个功能一个 URL):
POST /browser/click → click 处理函数
POST /browser/input → input 处理函数
POST /browser/open → open 处理函数
MCP(所有功能走同一个端点):
所有请求 → JSON-RPC method 字段区分 → 分发到对应处理函数
请求响应格式对比
Flask 请求:
POST http://localhost:5000/browser/click
Body: {"selector": "#btn"}
Flask 响应(自己定义,随便写):
{"success": true, "data": "点击成功"}
---
MCP 请求:
{"jsonrpc":"2.0", "id":1, "method":"tools/call",
"params":{"name":"browser_click", "arguments":{"selector":"#btn"}}}
MCP 响应(mcp 包统一格式):
{"jsonrpc":"2.0", "id":1,
"result":{"content":[{"type":"text","text":"点击成功"}]}}
核心差异:list_tools 自描述机制
Flask 接口写好了,没人知道它存在——除非你写文档告诉别人。
MCP Server 启动后,AI Client 第一件事就是调 list_tools(),自动获取所有工具的名字、描述、参数定义。不需要任何人"教" AI 怎么用——它自己能看懂。
USB 类比
USB 出现之前:
打印机用并口,鼠标用 PS/2,相机用 FireWire
每个设备都能用,但每个都要单独的驱动和接口
→ 能工作,但麻烦
USB 出现之后:
统一接口,插上就用
→ 功能没变,体验完全不同
Flask + Skill = 并口/PS/2(能用,每次都要手工适配)
MCP = USB(标准化,插上就认)
四、MCP 的通信方式
JSON-RPC:消息格式,不是传输协议
JSON-RPC 只是规定了消息长什么样:
// 请求
{"jsonrpc": "2.0", "method": "tools/call", "params": {...}, "id": 1}
// 响应
{"jsonrpc": "2.0", "result": {...}, "id": 1}
它不关心这条消息是通过什么方式传输的。可以走 stdin/stdout 管道,也可以走 HTTP,甚至可以走 WebSocket。
stdio 模式:管道通信
Claude Code 进程 MCP Server 进程
│ │
│──── stdin 写入 ─────────────→│ (JSON-RPC 请求)
│ │ 解析 → 执行函数
│←──── stdout 读取 ────────────│ (JSON-RPC 响应)
│ │
两个进程通过管道通信,不走网络
- Claude Code 启动 MCP Server 作为子进程
- 通过操作系统的管道(stdin/stdout)通信
- 不需要端口,不走网络
- 进程跟着 Claude Code 启停
适合场景:本地集成,IDE 插件(Claude Code、Cursor)
HTTP 模式:端口服务
Claude Code MCP Server
│ │
│─── POST http://localhost:8000/mcp ──→│
│ │ 解析 → 执行函数
│←── HTTP Response ────────────│
│ │
MCP Server 作为独立 HTTP 服务运行
- MCP Server 独立启动,监听端口
- 通过 HTTP 请求响应通信
- 进程独立于 Claude Code,可以常驻运行
适合场景:远程调用、需要保持状态的服务(如浏览器会话)
入口文件的区别
两种模式的业务代码完全一样,只有启动方式不同:
# 业务代码(两种模式共用)
server = Server("my-tools")
@server.list_tools()
async def list_tools():
return [Tool(name="greet", description="打招呼", inputSchema={...})]
@server.call_tool()
async def call_tool(name, args):
if name == "greet":
return [TextContent(type="text", text=f"你好 {args['name']}")]
# stdio 模式启动
from mcp.server.stdio import stdio_server
async def main():
async with stdio_server() as (read, write):
await server.run(read, write) # ← 监听 stdin/stdout
# HTTP 模式启动
from mcp.server.streamable_http import StreamableHTTPServer
async def main():
http = StreamableHTTPServer(server, host="0.0.0.0", port=8000)
await http.run() # ← 监听 HTTP 端口
跟 Flask 类比:app.run() 启动 HTTP 服务,MCP 只是多了一种 stdio 启动方式。
五、MCP Server 的完整生命周期
1. 配置注册
告诉 Claude Code "有一个 MCP Server 可以用"。两种方式:
全局配置(手动注册,所有项目可用):
claude mcp add zerotoken "python mcp_server.py"
写入 ~/.claude/settings.json:
{
"mcpServers": {
"zerotoken": {
"command": "python",
"args": ["mcp_server.py"]
}
}
}
项目级配置(自动发现,进入目录自动加载):
在项目根目录放 .mcp.json:
{
"mcpServers": {
"zerotoken": {
"command": "python",
"args": ["mcp_server.py"]
}
}
}
.mcp.json 文件名是固定的,Claude Code 硬编码会去找这个文件——跟 .gitignore 一样的约定。
2. 进程启动
Claude Code 启动时(或执行 /mcp 命令时),读取配置,按里面写的命令逐个启动 MCP Server 子进程。
3. 握手(工具发现)
Claude Code → MCP Server:list_tools 请求
MCP Server → Claude Code:返回所有工具列表
[ {name: "browser_click", description: "点击元素", inputSchema: {...}}, {name: "browser_open", description: "打开网页", inputSchema: {...}}, ... ]
Claude Code 现在知道了所有可用工具和参数格式。
4. 工具调用
用户对话中,AI 判断需要使用某个工具时:
Claude Code → MCP Server:
{"method":"tools/call", "params":{"name":"browser_click","arguments":{"selector":"#btn"}}}
MCP Server 执行函数,返回:
{"result":{"content":[{"type":"text","text":"点击成功"}]}}
5. 进程退出
Claude Code 关闭时,自动杀掉所有 MCP Server 子进程。
六、手把手:用 mcp 包搭建一个 MCP Server
安装
pip install mcp
最小示例(30 行代码)
# my_server.py
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
import asyncio
server = Server("my-tools")
@server.list_tools()
async def list_tools():
return [
Tool(
name="greet",
description="向用户打招呼",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "用户名"}
},
"required": ["name"]
}
)
]
@server.call_tool()
async def call_tool(name, args):
if name == "greet":
return [TextContent(type="text", text=f"你好,{args['name']}!")]
async def main():
async with stdio_server() as (read, write):
await server.run(read, write)
if __name__ == "__main__":
asyncio.run(main())
lowlevel API vs 高级 API
上面的例子用的是 lowlevel API,需要手写 inputSchema。MCP SDK 还提供了高级 API,通过 @server.tool() 装饰器自动从函数签名提取参数定义:
from mcp.server.mcpserver import MCPServer
server = MCPServer("my-tools")
# 高级 API:装饰器自动提取函数签名生成 JSON Schema
@server.tool()
def greet(name: str) -> str:
"""向用户打招呼"""
return f"你好,{name}!"
# 不需要手写 inputSchema
# 不需要手写 list_tools
# 不需要手写 call_tool 路由
# 装饰器从函数签名自动得知:参数 name 是 string 类型,必填
# 从 docstring 自动提取 description
对比:
lowlevel API:
手写 list_tools() → 声明工具名、描述、参数 Schema
手写 call_tool() → if name == "xxx" 路由到具体逻辑
灵活,但重复代码多
高级 API:
@server.tool() 一个装饰器搞定
自动分析:函数名 → 工具名,类型注解 → Schema,docstring → description
简洁,适合大多数场景
注册到 Claude Code
claude mcp add my-tools "python my_server.py"
完成。Claude Code 下次启动时自动加载,AI 就能调用 greet 工具了。
七、MCP Python SDK 源码架构解析
目录结构
mcp/
├── types/ # 类型定义
│ ├── _types.py # Tool, Resource, Prompt 等协议类型
│ └── jsonrpc.py # JSON-RPC 2.0 消息格式定义
│
├── server/ # 服务器端
│ ├── lowlevel/
│ │ └── server.py # 底层 Server:消息路由表
│ ├── mcpserver/
│ │ ├── server.py # 高级 MCPServer:装饰器 API
│ │ └── tools/
│ │ ├── base.py # Tool 类(from_function 签名提取)
│ │ └── tool_manager.py # 工具注册与调用管理
│ ├── session.py # ServerSession:服务器端会话
│ ├── stdio.py # stdio 传输实现
│ ├── streamable_http.py # HTTP 传输实现
│ ├── sse.py # SSE 传输实现
│ └── websocket.py # WebSocket 传输实现
│
├── client/ # 客户端(AI 侧)
│ ├── client.py # Client 类
│ ├── session.py # ClientSession
│ ├── stdio.py # 客户端 stdio 传输
│ └── streamable_http.py # 客户端 HTTP 传输
│
└── shared/ # 共享基础设施
├── session.py # BaseSession:请求-响应配对
└── _stream_protocols.py # ReadStream/WriteStream 接口定义
五层架构
┌─────────────────────────────────────────────────────┐
│ 应用层(开发者代码) │
│ @server.tool() 定义工具函数 │
└───────────────────────┬─────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ MCPServer(高级 API) │
│ 装饰器注册 + 函数签名自动提取 JSON Schema │
│ Tool/Resource/Prompt Manager 管理注册表 │
└───────────────────────┬─────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ lowlevel Server(协议层) │
│ handlers = {"tools/list": fn, "tools/call": fn} │
│ 根据 JSON-RPC method 字段查表分发 │
└───────────────────────┬─────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Session(会话层) │
│ BaseSession → ServerSession / ClientSession │
│ 请求 ID 生成、响应配对、超时控制、并发管理 │
└───────────────────────┬─────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 传输层(可插拔) │
│ stdio / StreamableHTTP / SSE / WebSocket │
│ 统一实现 ReadStream + WriteStream 接口 │
└─────────────────────────────────────────────────────┘
装饰器实现原理
@server.tool() 背后执行的步骤:
# 你写的代码
@server.tool()
def add(a: int, b: int) -> int:
"""两数相加"""
return a + b
# 装饰器内部做了什么:
# 第一步:分析函数签名
# 用 inspect 提取参数名、类型注解、docstring
# a: int, b: int → 参数名和类型
# 第二步:生成 JSON Schema(通过 pydantic)
# {
# "type": "object",
# "properties": {
# "a": {"type": "integer"},
# "b": {"type": "integer"}
# },
# "required": ["a", "b"]
# }
# 第三步:注册到 ToolManager
# tool_manager.tools["add"] = Tool(
# name="add",
# description="两数相加",
# fn=add,
# inputSchema={...}
# )
# 第四步:返回原函数(不修改函数本身)
所以装饰器不是"运行时拦截",而是启动时注册——把函数信息收集起来,等 AI 调 list_tools() 时返回。
消息路由:字典查表
lowlevel Server 的核心就是一个字典:
class Server:
def __init__(self):
self._request_handlers = {
"tools/list": self._handle_list_tools,
"tools/call": self._handle_call_tool,
"resources/list": self._handle_list_resources,
"resources/read": self._handle_read_resource,
"prompts/list": self._handle_list_prompts,
"prompts/get": self._handle_get_prompt,
}
async def _handle_request(self, req):
handler = self._request_handlers[req.method] # 查表
result = await handler(req.params) # 执行
return result # 返回
收到 {"method": "tools/call", ...},查表找到 _handle_call_tool,执行,返回结果。跟 Flask 的 URL 路由一模一样,只是路由键从 URL 变成了 method 字符串。
传输层:可插拔的 ReadStream/WriteStream
所有传输层都实现同一个接口:
class ReadStream(Protocol):
async def receive(self) -> SessionMessage: ...
class WriteStream(Protocol):
async def send(self, item: SessionMessage) -> None: ...
stdio 传输:
ReadStream ← 从 stdin 逐行读 JSON
WriteStream → 往 stdout 逐行写 JSON
HTTP 传输:
ReadStream ← 从 HTTP POST body 读 JSON
WriteStream → 往 HTTP Response 写 JSON
WebSocket 传输:
ReadStream ← 从 WebSocket 消息读 JSON
WriteStream → 往 WebSocket 消息写 JSON
server.run(read, write) 不关心底层是什么传输,只要能读能写就行。换传输层不需要改业务代码。
Session 的请求-响应配对
stdio 和 HTTP 都是流式的,可能同时有多个请求在飞。怎么知道哪个响应对应哪个请求?
靠 JSON-RPC 的 id 字段:
class BaseSession:
def __init__(self):
self._request_id = 0
self._in_flight = {} # id → 等待响应的 stream
async def send_request(self, request):
self._request_id += 1
id = self._request_id
# 创建等待通道
response_stream = create_stream()
self._in_flight[id] = response_stream
# 发送请求
await self._write_stream.send(JSONRPCRequest(id=id, ...))
# 等待对应的响应
return await response_stream.receive()
async def _receive_loop(self):
async for message in self._read_stream:
if isinstance(message, JSONRPCResponse):
# 按 id 找到等待方,通知它
stream = self._in_flight.pop(message.id)
await stream.send(message.result)
请求:{"id": 1, "method": "tools/call", "params": {...}}
请求:{"id": 2, "method": "tools/call", "params": {...}}
响应可能乱序返回:
响应:{"id": 2, "result": {...}} ← 先返回 id=2
响应:{"id": 1, "result": {...}} ← 后返回 id=1
Session 按 id 匹配,保证每个请求拿到自己的响应。
完整请求流程图
Claude Code 要调用工具 "browser_click"
① Client 端
client.call_tool("browser_click", {"selector": "#btn"})
→ ClientSession 生成 id=1
→ 序列化为 JSON-RPC:
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"browser_click","arguments":{"selector":"#btn"}}}
② 传输层发送
→ stdio: 写入子进程的 stdin 管道
→ HTTP: POST http://localhost:8000/mcp
③ Server 传输层接收
→ stdio: 从 stdin 读一行 JSON
→ HTTP: 从 request body 读 JSON
④ ServerSession._receive_loop()
→ json.loads() 解析为 JSONRPCRequest
→ 创建 RequestResponder(id=1)
⑤ Server._handle_request()
→ 查路由表:handlers["tools/call"]
→ 调用 MCPServer._handle_call_tool()
⑥ ToolManager.call_tool("browser_click", {"selector":"#btn"})
→ 查字典找到对应 Tool 对象
→ tool.run(arguments) → 执行开发者写的 Python 函数
⑦ 返回结果
→ RequestResponder.respond(CallToolResult(...))
→ 序列化为 JSON-RPC:
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"成功"}]}}
⑧ 传输层发回
→ stdio: 写入 stdout
→ HTTP: 作为 HTTP Response body
⑨ ClientSession 收到响应
→ 匹配 in_flight[1] → 返回给 client.call_tool() 调用方
八、MCP 与 Skills 的关系:固定"怎么想" vs 固定"怎么做"
Skills 的定位:思路与约束规范
Skills 本质是一段 prompt 指令文本,告诉 AI 按什么思路、什么规范去解决问题。
Skill 示例:"代码审查规范"
检查以下维度:
1. 是否有硬编码的密钥
2. 用户输入是否做了验证
3. 错误处理是否完整
4. 函数是否超过 50 行
...
AI 读了 Skill 之后,还是用自己已有的工具(读文件、写文件、跑命令)去执行。Skill 不提供新能力,只规范思考方向。
MCP 的定位:确定性代码能力
MCP 提供的是 AI 本身做不到的能力——操控浏览器、查数据库、调用特定 API。而且这些能力是确定性的:输入固定,输出固定,不依赖 AI 的推理。
MCP 工具:"browser_click"
输入:{"selector": "#btn"}
执行:Playwright 调用 page.click("#btn")
输出:{"success": true}
这个过程完全确定,没有 AI 的"创造性发挥"空间。
两者如何协同
没有 Skills 也没有 MCP:
AI 自由发挥 → 每次结果都不同 → 质量靠运气
只有 Skills:
AI 按规范思考 → 方向确定,但具体实现还有变数
只有 MCP:
AI 能调用固定函数 → 单步操作确定,但整体流程可能乱
两者结合:
Skills 规定"先做A、再做B、再做C"(固定思路)
MCP 保证"做A"这个动作的输出是确定的(固定执行)
→ 从思路到执行都尽量确定
最佳实践
| 场景 | 用什么 | 原因 |
|---|---|---|
| 规范代码风格 | Skill | AI 自己能写代码,只需要告诉它规范 |
| 代码审查流程 | Skill | 规范检查维度和步骤 |
| 操控浏览器 | MCP | AI 自己做不到,需要外部 Playwright |
| 查数据库 | MCP | AI 自己连不上数据库 |
| 调用内部 API | MCP | AI 不知道内部接口细节 |
| 复杂任务(审查+部署) | Skill + MCP | Skill 规范流程,MCP 提供执行能力 |
核心原则:AI 能做的事用 Skill 约束方向,AI 做不到的事用 MCP 提供能力。把 AI 的推理能力用在决策上,不浪费在重复实现已有功能上。
九、总结
MCP 不是新能力,是标准化
你用 Flask + Skill 完全能实现 MCP 做的事。MCP 的价值不在于"能做什么新事",而在于统一了做事的方式:
- 工具声明格式统一 → AI 自动发现
- 调用协议统一 → 跨平台通用
- 传输层可插拔 → stdio/HTTP/WebSocket 随意切换
核心价值
- 自描述:
list_tools()让 AI 自动知道有什么工具、怎么用,不需要人工写文档教它 - 可插拔:换传输方式不改业务代码,换 AI 平台不改工具代码
- 生态统一:写一次 MCP Server,Claude Code、Cursor、OpenClaw 都能用
适用场景与局限性
适合用 MCP 的场景:
- AI 需要调用外部系统(数据库、浏览器、第三方 API)
- 工具需要跨多个 AI 平台使用
- 操作需要确定性执行(不能让 AI 自己写代码实现)
不需要 MCP 的场景:
- AI 用自己已有的工具就能完成(读写文件、跑命令)
- 一次性的简单脚本
- 只在一个平台上使用,不需要跨平台兼容
一句话总结:MCP 就是 AI 工具生态的 USB 接口——技术上没有魔法,但标准化本身就是最大的价值。