前言
最近我们团队做了一个叫 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/list、tools/call、resources/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,用于列举所有可用的资源。但我们的场景下,文档可能很多,全量列举没有意义。我们改为:
- 通过
resources/templates/list告诉客户端三种 URI 模板 - 通过
search_document工具定位具体文档 - 通过
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 > end、start <= 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