手搓 MCP 服务:从零实现 Model Context Protocol 的实践记录

前言

最近我们团队做了一个叫 Uni-Index 的项目------一个基于本地文件的文档索引服务。为了让它能和 AI 客户端(Claude Desktop、OpenCode、Trae CN 等)对话,我们决定从零实现 MCP(Model Context Protocol)服务端,而不是依赖现成的 SDK。

原因很简单:我们想完全掌控每一行代码,理解协议的全貌。这篇博文就记录了我们"手搓"MCP 的全过程。


一、MCP 是什么?一个简单的介绍

MCP(Model Context Protocol)是 Anthropic 提出的开放协议,定义了 AI 应用和外部工具/数据源之间的交互方式。它的核心理念可以类比为"AI 界的 USB-C":

复制代码
┌──────────────────┐         MCP 协议        ┌──────────────────┐
│   AI 客户端      │ ◄──────────────────────► │   MCP 服务端     │
│  (Claude,       │      JSON-RPC 2.0        │  (Uni-Index)    │
│   OpenCode 等)   │          over            │                  │
│                  │     SSE / HTTP / STDIO   │  工具 · 资源 · 提示 │
└──────────────────┘                         └──────────────────┘

MCP 定义了三个核心能力:

  • Tools(工具):客户端可以调用的函数,比如搜索文档
  • Resources(资源):客户端可以读取的数据,支持 URI 模板
  • Prompts(提示模板):预定义的对话模板

整个协议构建在 JSON-RPC 2.0 之上,传输层可以是 SSE、HTTP Streamable 或 STDIO。


二、MCP 客户端请求服务端的完整流程

我们选择 SSE(Server-Sent Events)作为传输层,这是目前 MCP 客户端最广泛支持的方案。下面是完整的交互流程:

复制代码
┌───────────────┐                              ┌───────────────┐
│  AI 客户端    │                              │  Uni-Index    │
│  (OpenCode)   │                              │  MCP Server   │
└───────┬───────┘                              └───────┬───────┘
        │                                              │
        │  ① GET /mcp (Accept: text/event-stream)      │
        │  Authorization: Bearer <api_key>              │
        │──────────────────────────────────────────────►│
        │                                              │
        │  ② event: endpoint                           │
        │  data: /mcp?session_id=abc-123               │
        │◄──────────────────────────────────────────────│
        │                                              │
        │  ③ POST /mcp?session_id=abc-123              │
        │  {"jsonrpc":"2.0","id":1,"method":"initialize"}│
        │──────────────────────────────────────────────►│
        │                                              │
        │  ④ event: message                            │
        │  data: {"jsonrpc":"2.0","id":1,              │
        │        "result":{"protocolVersion":"2024-11-05",│
        │                 "capabilities":{...},...}}     │
        │◄──────────────────────────────────────────────│
        │                                              │
        │  ⑤ POST /mcp?session_id=abc-123              │
        │  {"jsonrpc":"2.0","method":"notifications/   │
        │        initialized"}                          │
        │──────────────────────────────────────────────►│
        │                                              │
        │  ⑥ POST /mcp?session_id=abc-123              │
        │  {"jsonrpc":"2.0","id":2,"method":"tools/list"}│
        │──────────────────────────────────────────────►│
        │                                              │
        │  ⑦ event: message                            │
        │  data: {"jsonrpc":"2.0","id":2,              │
        │        "result":{"tools":[...]}}              │
        │◄──────────────────────────────────────────────│

每一步在做什么

步骤 方向 说明
请求 客户端发起 SSE 连接,带上 Bearer Token 认证
推送 服务端分配唯一 session_id,告诉客户端后续 POST 到哪个 URL
请求 客户端发送 initialize 进行协议协商
推送 服务端返回初始化结果,确认协议版本和服务器能力
请求 客户端通知"我初始化完毕了",之后会话进入 ACTIVE 状态
请求 客户端调用 tools/list 获取可用工具列表
推送 服务端返回工具定义列表

关键设计 :SSE 是服务端推送 通道(①②④⑦),客户端请求则通过 HTTP POST(③⑤⑥)发送。这种"GET 连 SSE 收消息,POST 发命令"的双通道模式是 MCP over SSE 的核心架构,也是很多初学者容易搞混的地方。


三、MCP 服务与 FastAPI 的端点绑定

我们的服务器构建在 FastAPI 之上,所有 MCP 功能绑定到仅两个路由之上:

python 复制代码
# server.py 核心结构

@app.get("/mcp")      # SSE 连接端点(长连接流)
@app.post("/mcp")     # JSON-RPC 消息端点(普通 HTTP)
@app.get("/health")   # 健康检查(免认证)

中间件栈

复制代码
HTTP 请求进入
       │
       ▼
┌───────────────────┐
│  CORSMiddleware   │  ← 最外层,处理 OPTIONS preflight
├───────────────────┤
│  MCPAuthMiddleware│  ← Bearer Token 认证
├───────────────────┤
│DecodedAccessLog   │  ← URL 解码 + 敏感信息脱敏
│  Middleware        │
├───────────────────┤
│  路由处理器       │  ← GET: SSE 流 / POST: JSON-RPC
└───────────────────┘

GET /mcp:SSE 事件流

这个端点一直保持连接,不断推送事件给客户端。它的核心逻辑是一个异步生成器:

复制代码
事件生成器循环:
1. 分配 session_id,建立连接
2. 推送 endpoint 事件(告诉客户端 POST URL)
3. 进入循环:
   ├─ 有消息? → 通过 SSE 推送出去
   ├─ 空闲? → 发服务端 ping(检查客户端死活)
   └─ 每30轮 → 发 SSE 注释行心跳(防代理超时)

需要注意EventSourceResponse 的参数必须是异步生成器,不能是普通函数。我们使用了 sse_starlette 库来处理 SSE 响应。

POST /mcp:JSON-RPC 消息处理器

这个端点处理两种场景:

场景一:SSE 模式 (有 session_id

python 复制代码
if session_id and session_id in sse_manager.active_sessions:
    asyncio.create_task(process_sse_request(session_id, body))
    return {"status": "processing", "session_id": session_id}

→ 异步处理后通过 SSE 推送响应,不阻塞 HTTP 响应

场景二:无状态模式/Streamable HTTP (无 session_id

python 复制代码
response_dict = await sse_handler.handle_message(body, session_id, sse_manager)
return JSONResponse(content=response_dict)

→ 同步处理,直接返回 JSON-RPC 响应


四、MCP 请求 Body 的公共结构

MCP 的所有请求都遵循 JSON-RPC 2.0 规范。一个 MCP 请求的完整结构是:

json 复制代码
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_document",
    "arguments": {
      "query": "MCP协议",
      "max_results": 5
    }
  }
}
字段 类型 说明
jsonrpc string 必须 = "2.0",少写一个字母就返回 -32600 错误
id int/string/null 请求标识,响应会带回这个 ID 用于匹配。可为空(如通知类消息)
method string MCP 方法名,如 tools/listtools/callresources/read
params object 方法参数,因方法而异(可为空 {}

我们定义的 Pydantic 模型

python 复制代码
class MCPMessage(BaseModel):
    jsonrpc: str = "2.0"
    id: Optional[Union[int, str]] = None

class ListToolsRequest(MCPMessage):
    method: str = "tools/list"  # 无 params,只有 method

class CallToolRequest(MCPMessage):
    method: str = "tools/call"
    params: Dict[str, Any]      # 包含 name 和 arguments

经验教训 :客户端的参数格式可能不一致。有的传 params.name,有的传 params.tool;有的传 params.arguments,有的传 params.args。我们统一做了兼容处理。


五、MCP 服务端点的路由细节

路由表

方法 路径 用途 认证 响应类型
GET /mcp SSE 长连接流 text/event-stream
POST /mcp JSON-RPC 消息 application/json
GET /health 健康检查 application/json

method 分发路由

所有 MCP 方法的真正分发在 MCPSSEHandler.handle_message() 中完成。我们支持的 method:

python 复制代码
"ping"                     → {"result": {}}                    # 心跳检测
"initialize"               → 返回协议版本 + 能力列表              # 握手
"notifications/initialized" → 标记会话状态为 ACTIVE            # 通知
"tools/list"               → 返回工具定义列表                   # 工具
"tools/call"               → 执行工具并返回结果                  # 工具
"resources/list"           → ❌ 未实现(基于模板替代),返回 -32601 错误  # 资源
"resources/templates/list"  → 返回 URI 模板列表                  # 资源
"resources/read"           → 根据 URI 读取文档内容               # 资源
"prompts/list"             → 返回 prompt 模板列表                # 提示
"prompts/get"              → 根据名称和参数生成 prompt 内容      # 提示

端点路径设计:与官方规范的差异

MCP over SSE 的官方路径推荐是分离的:

用途 官方推荐路径 我们的路径
SSE 流 GET /sse GET /mcp
客户端消息 POST /messages?session_id=xxx POST /mcp?session_id=xxx

我们把两个端点合并到同一个 /mcp 路径,仅通过 HTTP Method(GET vs POST)区分。好处 是客户端配置极简------只需配一个 URL http://host:port/mcp,OpenCode、Claude Desktop 等客户端都支持这种单端点模式。代价是路由逻辑依赖 Method 区分,代码可读性略低。

一个有趣的设计决策:为什么移除 resources/list

原始 MCP 规范中有 resources/list,用于列举所有可用的资源。但我们的场景下,文档可能很多,全量列举没有意义。我们改为:

  1. 通过 resources/templates/list 告诉客户端三种 URI 模板
  2. 通过 search_document 工具定位具体文档
  3. 通过 resources/read + 完整 URI 读取内容

这样更符合"先搜索,再精读"的使用模式。


六、我们实现了哪些能力

1. 两个工具(Tools)

get_server_status

复制代码
服务: Uni-Index MCP Server
版本: 0.2.0
状态: 运行中
文档数: 12
可用工具: get_server_status, search_document

search_document

复制代码
搜索 'JSON-RPC' 找到 3 条结果:

[docs_concepts:42]协议基础 (匹配度: 1.20)
  JSON-RPC 2.0 是 MCP 协议的通信基础...

[docs_endpoints:15]请求结构 (匹配度: 0.60)
  所有 MCP 请求遵循 JSON-RPC 2.0 规范...

2. 三种资源模板(Resources)

URI 模板 用途
uni-index://documents/{name} 读取文档全文
uni-index://documents/{name}/structure 读取文档的目录结构树
uni-index://documents/{name}/lines/{start}-{end} 读取指定行范围

URI 解析流程

复制代码
输入: uni-index://documents/architecture.md/lines/10-50
        │                │          │         │
        ▼                ▼          ▼         ▼
   URI_SCHEME        路径      文档名     lines/start-end
   "uni-index"    documents/   arch.md     10-50
    
     → ParsedUri(resource_type=LINES, doc_name="arch.md", 
                  start_line=10, end_line=50)

我们使用正则表达式进行路径匹配:

python 复制代码
LINES_PATH_PATTERN = re.compile(
    r"^documents/(?P<doc_name>[^/]+)/lines/(?P<start>\d+)-(?P<end>\d+)$"
)
STRUCTURE_PATH_PATTERN = re.compile(
    r"^documents/(?P<doc_name>[^/]+)/structure$"
)
CONTENT_PATH_PATTERN = re.compile(
    r"^documents/(?P<doc_name>[^/]+)$"
)

3. 一个 Prompt(提示模板)

research prompt 支持三种模式:

  • search:搜索文档并回答问题(典型的 RAG 流程)
  • analyze:深入分析指定文档的结构和内容
  • compare:对比两个文档的异同

每个模式通过 prompt 文本引导 AI 客户端按流程操作(搜索 → 精读 → 综合),而不是一次性塞入全部文档内容。

4. 会话生命周期管理

复制代码
NOT_INITIALIZED → AWAITING_INIT → ACTIVE
                     ↓
               (中途消息被拒绝 -32000)

我们维护了会话状态机,所有方法在处理前会检查会话状态:

python 复制代码
if state and state != SessionState.ACTIVE:
    return error_response(-32000, "会话未就绪", 
                          f"当前状态: {state.value}")

5. 服务端 Ping 机制

为了防止 NAT 网关或代理服务器切断空闲连接,我们实现了服务端主动 ping:

复制代码
空闲 60 秒 → 发送 {"jsonrpc":"2.0","id":"srv-ping-xxx","method":"ping"}
            → 等待客户端响应(30秒超时)
            → 超时则记录日志,不中断连接

这个机制和 SSE 心跳(每 30 秒发一行注释)构成了双重保活策略。

6. 认证

简单的 Bearer Token:

复制代码
Authorization: Bearer uni-index-dev-key-2026

7. 日志系统

  • HTTP 请求日志(URL 解码 + 敏感信息脱敏)
  • MCP 协议日志(请求/响应/工具调用/连接事件)
  • 异步日志(避免阻塞请求处理)

七、遇到的问题

1. CORS 和 Auth 的中间件顺序

问题:浏览器 preflight 请求(OPTIONS)被 Auth 中间件拦截,返回 401。

解决:将 CORSMiddleware 放在最外层(后注册在 FastAPI 中 = 最先执行),确保 OPTIONS 请求在到达 Auth 层之前就被处理。

python 复制代码
app.add_middleware(MCPAuthMiddleware, api_key=api_key)     # 内层
app.add_middleware(CORSMiddleware, ...)                     # 外层

2. Ping 响应和客户端请求的歧义

问题 :服务端发了 ping 后,客户端返回 {"jsonrpc":"2.0","id":"srv-ping-xxx","result":{}}(有 id 和 result,无 method)。但正常的客户端请求也可能有 id 和 result。如何区分?

解决:两层校验防止误判:

第一层:字段特征检测 --- 满足"无 method"+"(有 result 或 error)"+"有 id"的消息才可能为 ping 响应。

第二层:pending_pings 集合查询 --- 服务端发送 ping 时会记录到 _pending_pings 字典。收到消息后,不仅检查字段特征,还会调用 resolve_ping() 从挂起集合中查找匹配的 ping_id。只有确实发过这个 ping,才会判定为响应。

python 复制代码
# server.py --- 两层校验
if "id" in data and not data.get("method") and \
   ("result" in data or "error" in data):
    ping_id = str(data.get("id"))
    record = sse_manager.resolve_ping(ping_id)  # ② 查 pending_pings 集合
    if record:
        # → 确认是 ping 响应,不走 handler
        ...
    else:
        # 字段特征符合但不是已知 ping → 忽略
        logger.debug(f"收到未知 ping_id 的响应: {ping_id}")
python 复制代码
# sse.py --- 挂起 ping 管理
class SSEConnectionManager:
    def add_pending_ping(self, session_id, ping_id):
        # 发 ping 时记录
        self._pending_pings[ping_id] = PingRecord(...)

    def resolve_ping(self, ping_id):
        # 收到响应时查询并移除
        return self._pending_pings.pop(ping_id, None)  # None = 没发过这个 ping

这种双重校验机制避免了客户端恰好发了一个无 method 的请求时被误判为 ping 响应。

3. SSE 连接管理中 Asyncio.Queue 的阻塞问题

问题asyncio.Queue.get() 默认会一直阻塞到有消息。但我们需要在空闲时做心跳和 ping,不能无限等待。

解决 :结合 asyncio.Event + asyncio.wait_for 实现可超时的消息等待:

python 复制代码
async def wait_for_message(self, session_id, timeout=1.0):
    await asyncio.wait_for(event.wait(), timeout=timeout)
    event.clear()
    return await queue.get()  # queue 里肯定有消息了

4. 资源 URI 的行号校验

问题 :客户端可能传入 start > endstart <= 0、非数字行号等非法参数。

解决:在 URI 解析阶段做完整校验,返回规范化的 JSON-RPC 错误:

python 复制代码
if start < 1:
    raise UriParseError(f"起始行号必须为正整数: {start}")
if start > end:
    raise UriParseError(f"起始行号 {start} 不能大于结束行号 {end}")

5. 僵尸连接(Zombie Connection)

问题:客户端意外断开后,服务端无法检测到(SSE 本身没有断开通知机制)。

解决 :后台定时任务每 60 秒扫描所有会话,last_heartbeat 超过 300 秒的连接被视为僵尸并清理。


八、下一步:Streamable HTTP

v0.0.1 中我们只实现了 SSE 传输。但 MCP 规范最新的 Streamable HTTP 传输协议正在成为趋势------它比 SSE 更简单,不需要维持长连接。

我们已经在 OpenSpec 中规划了 add-streamable-http 变更:

复制代码
Streamable HTTP vs SSE
═══════════════════════════════════════════════════

特性               SSE              Streamable HTTP
───────────────────────────────────────────────────
连接方式           长连接           短连接(按需)
消息方向           单向推送          双向(流式响应)
代理友好度         差(长连接)       好(标准 HTTP)
实现复杂度         中等              较低
MCP 官方推荐       当前主流           未来方向

实现要点:
1. POST /mcp 支持 stream 参数(?stream=true)
2. stream=true 时返回 SSE 流式响应
3. stream=false 或无 stream 时直接返回 JSON(兼容旧客户端)
4. 在单个 HTTP 响应中完成请求和响应,无需 session 管理

核心思路是在现有的 POST /mcp 端点上做扩展,通过查询参数 ?stream=true 或 HTTP 头来区分流式和非流式响应。


九、写在最后

"手搓"MCP 服务是件很有意思的事。你不只是在用框架、调 API,而是在理解一种协议的设计哲学

  • JSON-RPC 2.0 为什么用 id 而不是 requestId?------ 为了通用性
  • 为什么 SSE 的双通道设计比纯 WebSocket 更适合 AI 场景?------ 因为 AI 响应是流式写入,SSE 天然支持
  • 为什么资源要用 URI 模板而不是枚举列表?------ 因为数据空间可能是无限的

我们只实现了 MCP 协议能力的子集(Tools + Resources + Prompts),但核心协议栈(消息分发、会话管理、认证、保活、日志)都已完整。后续我们会在 Streamable HTTP通知机制采样(Sampling) 等方向继续深入。

如果你也在手搓 MCP 服务,希望这篇记录对你有帮助。


项目地址:https://gitee.com/bytesifter/uni-index

欢迎 Star、Issue、PR

相关推荐
wuxinyan1231 小时前
大模型学习之路010:RAG 零基础入门教程(第六篇):重排序技术
人工智能·学习·rag
oscar9991 小时前
给 AI 编程助手立规矩:OpenCode 的自定义指令体系
人工智能·rule·opencode
SilentSamsara1 小时前
迭代器协议:`__iter__` / `__next__` 的完整执行流程
开发语言·人工智能·python·算法·机器学习
AI科技星1 小时前
算法联盟ROOT · 全域数学物理卷第20、21、22分册:量子纠缠、隐形场论与时间膨胀
人工智能·算法·数学建模·数据挖掘·机器人
Android出海1 小时前
ChatGPT Image2 2.0正式上线:功能解析 + 使用教程(附提示词)
人工智能·ai·chatgpt·ai生图·chatgpt image2·images2
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 周榜(2026-05-10)
人工智能·ai·大模型·llm·github
feasibility.2 小时前
多模态模型Qwen-3.5在Llama-Factory使用+llama.cpp量化导出+部署流程(含报错处理)
人工智能·llm·多模态·量化·llama.cpp·vlm·llama-factory
暗夜猎手-大魔王2 小时前
转载--一文彻底了解浏览器自动化,cdp、playwright、browser-user、midscene、browsermcp
人工智能·自动化
AI科技星2 小时前
微积分:变化与累积的数学(分层大白话解释版)
人工智能·算法·数学建模·数据挖掘·机器人