深入理解 MCP(Model Context Protocol):从原理到源码,AI 工具标准化的完整解析

3 阅读14分钟

本文从实际使用场景出发,逐步拆解 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 + SkillMCP
工具描述手写文档,和代码分离写在代码里,代码即文档
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(会话层)                                    │
│  BaseSessionServerSession / 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 ResponseJSON

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"这个动作的输出是确定的(固定执行)
  → 从思路到执行都尽量确定

最佳实践

场景用什么原因
规范代码风格SkillAI 自己能写代码,只需要告诉它规范
代码审查流程Skill规范检查维度和步骤
操控浏览器MCPAI 自己做不到,需要外部 Playwright
查数据库MCPAI 自己连不上数据库
调用内部 APIMCPAI 不知道内部接口细节
复杂任务(审查+部署)Skill + MCPSkill 规范流程,MCP 提供执行能力

核心原则:AI 能做的事用 Skill 约束方向,AI 做不到的事用 MCP 提供能力。把 AI 的推理能力用在决策上,不浪费在重复实现已有功能上。


九、总结

MCP 不是新能力,是标准化

你用 Flask + Skill 完全能实现 MCP 做的事。MCP 的价值不在于"能做什么新事",而在于统一了做事的方式

  • 工具声明格式统一 → AI 自动发现
  • 调用协议统一 → 跨平台通用
  • 传输层可插拔 → stdio/HTTP/WebSocket 随意切换

核心价值

  1. 自描述list_tools() 让 AI 自动知道有什么工具、怎么用,不需要人工写文档教它
  2. 可插拔:换传输方式不改业务代码,换 AI 平台不改工具代码
  3. 生态统一:写一次 MCP Server,Claude Code、Cursor、OpenClaw 都能用

适用场景与局限性

适合用 MCP 的场景

  • AI 需要调用外部系统(数据库、浏览器、第三方 API)
  • 工具需要跨多个 AI 平台使用
  • 操作需要确定性执行(不能让 AI 自己写代码实现)

不需要 MCP 的场景

  • AI 用自己已有的工具就能完成(读写文件、跑命令)
  • 一次性的简单脚本
  • 只在一个平台上使用,不需要跨平台兼容

一句话总结:MCP 就是 AI 工具生态的 USB 接口——技术上没有魔法,但标准化本身就是最大的价值。