在AI生态高速发展的当下,大语言模型(LLM)与外部工具、数据源的集成需求日益迫切。MCP(Model Context Protocol,模型上下文协议)作为Anthropic推出的标准化通信协议,彻底解决了"多模型×多工具"的集成爆炸难题,成为AI与外部世界交互的"通用连接器"。
很多开发者在接触MCP时,会依赖官方提供的mcp、fastmcp等库,但其实掌握MCP的底层原理后,完全可以纯手写一个无依赖、全兼容的MCP Server。本文将从MCP核心原理、实际用法出发,详细讲解如何手写MCP Server,并重点强调开发过程中的关键注意事项,帮助开发者快速上手。
一、MCP协议核心原理:读懂底层逻辑
MCP的本质是一套标准化的"客户端-服务器(C/S)"通信协议,核心目标是让LLM(通过MCP Client)与外部工具(通过MCP Server)实现"即插即用"的交互。其底层逻辑可拆解为3个核心部分,也是手写MCP Server的基础。
1.1 核心定位与价值
MCP类比为AI世界的"USB-C"或"HTTP",核心解决4大问题:
- 集成爆炸:避免M个模型与N个工具需开发M×N个定制接口的繁琐;
- 厂商锁定:打破各平台私有接口的壁垒,实现跨平台迁移;
- 安全风险:内置权限、审批机制,避免敏感数据泄露;
- 上下文割裂:让模型高效、结构化获取外部工具/资源的信息。
其核心价值在于"解耦"与"标准化":模型推理与外部能力分离,一套协议适配所有兼容模型与服务,大幅降低开发与维护成本。
1.2 通信架构与协议规范
MCP采用C/S架构,核心角色分为3类,但对于手写Server的开发者而言,只需关注"MCP Server"的实现(MCP Client已由Claude、Cursor等主流AI客户端内置):
- MCP Host:用户交互入口(如Claude Desktop、Cursor);
- MCP Client:协议处理层(客户端内置,负责与Server通信、路由消息);
- MCP Server:能力提供层(开发者需要实现的部分,封装自定义工具/资源)。
通信的核心规则的是:
- 通信方式:通过标准输入(stdin)/标准输出(stdout)交换数据(最常用,适合本地工具;也支持HTTP/SSE用于远程服务);
- 协议格式:严格遵循JSON-RPC 2.0,所有消息均为JSON格式,包含请求、响应、通知3种类型;
- 数据流转:Client向Server发送JSON请求,Server处理后返回JSON响应,全程通过stdin读、stdout写完成。
1.3 手写Server的核心前提
无论是否依赖第三方库,MCP Server要被客户端识别并正常工作,必须满足两个核心前提:
- 实现客户端强制调用的3个核心方法(缺一不可);
- 严格遵循通信规范,确保消息格式与传输无误。
二、MCP的实际用法:无需自建Client,专注Server开发
很多开发者会有疑问:"开发MCP需要同时写Client和Server吗?"答案是:绝大多数场景下,完全不用自建MCP Client。
2.1 为什么不用自建MCP Client?
主流AI客户端、IDE都已内置完整的MCP Client实现,例如:
- Anthropic Claude Desktop / Claude API 封装层;
- Cursor、VS Code等IDE(未来会深度内置);
- 国内主流LLM客户端/SDK(逐步适配支持)。
开发者的核心工作,只是编写MCP Server,暴露自定义工具/资源,让内置Client主动连接即可------类比为"你写HTTP接口(Server),浏览器(Client)来调用,无需自己写浏览器"。
2.2 常见使用场景:多工具聚合Server
最推荐的用法是"一个MCP Server聚合多个工具"(Server聚合模式),优势在于:
- 极简配置:只需在客户端配置一次,即可加载所有工具;
- 资源共享:多个工具共享进程内存、数据库连接等,避免重复消耗;
- 统一管理:便于权限控制、日志排查和版本迭代。
2.3 客户端配置方法(通用版)
无论你是用官方库还是手写Server,客户端配置都通过mcp.json文件实现,核心是告诉客户端"如何启动/连接你的MCP Server"。
2.3.1 配置文件结构(以stdio类型为例,最常用)
json
{
"mcpServers": {
"自定义服务器名称": {
"type": "stdio", // 传输类型:stdio(本地)/http(远程)/sse
"command": "python", // 启动Server的命令
"args": ["/绝对路径/到/你的/server脚本.py"], // 脚本路径
"env": { // 环境变量(可选,用于传递密钥等)
"API_KEY": "你的密钥"
},
"timeout": 30000 // 超时时间(毫秒)
}
}
}
2.3.2 配置文件存放位置
- Claude Desktop:全局配置(~/.claude/mcp.json)、项目配置(项目根目录/.mcp.json);
- Cursor:全局配置(~/.cursor/mcp.json)、项目配置(项目目录/.cursor/mcp.json);
- VS Code:全局配置(用户配置目录/mcp.json)、项目配置(.vscode/mcp.json)。
优先级:项目配置 > 全局配置,配置完成后重启客户端即可生效。
三、从零手写MCP Server:无依赖实战(核心重点)
掌握以上原理和用法后,我们开始手写一个无依赖、全兼容的MCP Server(Python原生实现)。重点关注开发过程中的5个关键要点(后文会反复强调,也是新手最容易踩坑的地方)。
3.1 手写Server的核心要求(必记)
这5个要点直接决定Server能否正常工作,缺一不可:
- 必须用 stdin/stdout:通信只能通过标准输入读、标准输出写,不能用print打日志(会破坏协议格式,导致客户端解析失败);
- 必须flush输出:每次通过stdout返回响应后,必须调用
sys.stdout.flush()(强制刷新缓冲区,否则客户端收不到消息); - 必须实现3个核心方法:
initialize(初始化握手)、tools/list(返回工具列表)、tools/call(执行工具调用); - 工具参数必须用JSON Schema:每个工具的输入参数需用JSON Schema描述,客户端会据此校验参数合法性;
- 无依赖=全平台兼容:采用Python原生库开发,不引入任何第三方包,可在Windows/Mac/Linux全平台运行。
3.2 完整手写代码(无依赖,可直接运行)
以下代码实现了一个聚合3个工具(加法、获取时间、回声)的MCP Server,注释详细,可直接复制运行,后续新增工具只需简单修改。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
纯手写MCP Server(无任何第三方依赖)
核心:stdin/stdout通信 + JSON-RPC 2.0 协议
满足所有MCP客户端兼容要求,支持多工具聚合
"""
import sys
import json
import traceback
from datetime import datetime
# ===================== 1. 自定义工具实现(可无限扩展) =====================
# 工具1:加法计算
def add(a: int, b: int) -> int:
"""计算两个整数的和"""
return a + b
# 工具2:获取当前系统时间
def get_time() -> str:
"""返回当前格式化时间(YYYY-MM-DD HH:MM:SS)"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 工具3:回声功能(原样返回输入文本)
def echo(text: str) -> str:
"""原样返回输入的文本,用于测试通信"""
return f"你输入的内容:{text}"
# 工具注册表:key=工具名(客户端调用时使用),value=工具函数对象
TOOLS = {
"add": add,
"get_time": get_time,
"echo": echo
}
# ===================== 2. MCP协议核心方法实现(3个必写方法) =====================
def get_tool_definitions():
"""
生成MCP标准格式的工具描述(客户端用于识别工具)
每个工具必须包含:name(工具名)、description(描述)、inputSchema(参数校验)
inputSchema 遵循JSON Schema规范,客户端据此校验参数
"""
return [
{
"name": "add",
"description": "计算两个整数的和",
"inputSchema": {
"type": "object",
"properties": {
"a": {"type": "integer", "description": "第一个整数"},
"b": {"type": "integer", "description": "第二个整数"}
},
"required": ["a", "b"] # 必传参数
}
},
{
"name": "get_time",
"description": "获取当前系统的格式化时间",
"inputSchema": {
"type": "object",
"properties": {}, # 无参数
"required": [] # 无必传参数
}
},
{
"name": "echo",
"description": "原样返回输入的文本,用于测试MCP通信是否正常",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "需要返回的文本"}
},
"required": ["text"]
}
}
]
def handle_json_rpc(request: dict) -> dict:
"""
处理客户端发送的所有JSON-RPC请求
核心逻辑:解析请求方法,调用对应处理逻辑,返回标准JSON响应
"""
# 提取请求核心信息(JSON-RPC 2.0 标准字段)
request_id = request.get("id") # 请求唯一标识,响应需对应
method = request.get("method") # 调用的方法名
params = request.get("params", {}) # 方法参数
# 方法1:initialize(必写)------ 初始化握手,客户端启动后首先调用
if method == "initialize":
return {
"jsonrpc": "2.0", # 固定为2.0(JSON-RPC版本)
"id": request_id,
"result": {
"protocolVersion": "2025-06-18", # MCP协议版本(固定值)
"capabilities": {
"tools": {} # 声明支持 tools 能力
},
"serverInfo": {
"name": "My-Custom-MCP-Server",
"version": "1.0.0"
}
}
# 方法2:tools/list(必写)------ 客户端调用此方法获取当前Server提供的所有工具列表
elif method == "tools/list":
try:
# 调用我们之前定义的 get_tool_definitions 函数获取工具描述列表
tool_list = get_tool_definitions()
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"tools": tool_list
}
}
except Exception as e:
# 异常处理:如果工具列表生成失败,返回错误信息
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32603, # Internal error
"message": f"Failed to list tools: {str(e)}"
}
}
# 方法3:tools/call(必写)------ 客户端调用此方法执行具体的工具函数
elif method == "tools/call":
try:
# 1. 解析参数:获取要调用的工具名 (name) 和输入参数 (arguments)
tool_name = params.get("name")
arguments = params.get("arguments", {})
# 2. 校验工具是否存在
if tool_name not in TOOLS:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601, # Method not found
"message": f"Tool '{tool_name}' not found"
}
}
# 3. 执行工具函数
tool_function = TOOLS[tool_name]
# 注意:这里直接解包 arguments 字典作为函数参数
# 实际生产中建议增加 try-except 包裹函数执行,避免单个工具崩溃导致 Server 退出
result = tool_function(**arguments)
# 4. 返回执行结果
return {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"content": [ # MCP 标准要求 content 是一个数组
{
"type": "text",
"text": str(result) # 所有结果统一转为字符串返回
}
]
}
}
except Exception as e:
# 捕获工具执行过程中的异常,返回给客户端展示
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32500, # Application error (自定义)
"message": f"Tool execution failed: {str(e)}",
"data": traceback.format_exc() # 携带堆栈信息便于调试
}
}
# 处理未知方法(可选,标准 JSON-RPC 规范)
else:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": -32601,
"message": f"Method {method} not supported"
}
}
3.2.2 实现主循环(Main Loop)
完成了请求处理函数,最后我们需要一个死循环来持续监听 stdin,读取客户端发来的数据。这是 MCP Server 的入口。
特别注意:
- 读取方式 :必须使用
sys.stdin.buffer.readline()读取二进制行,因为 MCP 协议头是二进制格式(Content-Length)。 - 输出刷新 :每次
print后必须紧跟sys.stdout.flush(),否则客户端会一直等待。
python
def main():
"""
MCP Server 主循环
持续从 stdin 读取数据,处理请求,写入 stdout
"""
# 设置标准输出为无缓冲模式,确保消息立即发送
sys.stdout.reconfigure(line_buffering=True) if hasattr(sys.stdout, 'reconfigure') else None
# 启动日志(注意:这里不能用普通 print,或者确保不影响协议)
# 实际开发中,建议将调试日志写入文件,避免污染 stdout
print("MCP Server Started", file=sys.stderr)
# 主循环
while True:
try:
# 1. 读取 HTTP-like 头部(获取 Content-Length)
# MCP 基于 stdio 时,每条消息前都有一个头部,格式如:Content-Length: 123\r\n\r\n
header = ""
while True:
line = sys.stdin.buffer.readline()
if not line:
# 客户端断开连接
return
header += line.decode("utf-8")
# 头部以 \r\n\r\n 结尾
if header.strip() == "":
break
# 2. 解析 Content-Length
# 从 header 字符串中提取数字
content_length = 0
for line in header.split("\r\n"):
if line.lower().startswith("content-length:"):
content_length = int(line.split(":", 1)[1].strip())
break
if content_length == 0:
continue
# 3. 读取 JSON Body
# 根据 Content-Length 读取指定字节数
raw_body = sys.stdin.buffer.read(content_length).decode("utf-8")
# 4. 解析 JSON 并处理请求
request = json.loads(raw_body)
response = handle_json_rpc(request)
# 5. 构造响应并发送
# MCP 响应也需要带上 Content-Length 头
response_body = json.dumps(response, ensure_ascii=False)
response_header = f"Content-Length: {len(response_body.encode('utf-8'))}\r\n\r\n"
# 关键点:必须先写 Header,再写 Body
sys.stdout.write(response_header)
sys.stdout.write(response_body)
# 关键点:必须强制刷新缓冲区!
sys.stdout.flush()
except KeyboardInterrupt:
# 允许用户手动中断
break
except Exception as e:
# 防御性编程:主循环不能因为一次错误而崩溃
# 这里可以写入 debug 日志文件,或者发送一个通用错误给客户端
error_response = {
"jsonrpc": "2.0",
"id": None,
"error": {
"code": -32000,
"message": f"Server internal error: {str(e)}"
}
}
# 即使出错也要尝试发送错误信息并刷新
try:
sys.stdout.write(f"Content-Length: {len(json.dumps(error_response))}\r\n\r\n")
sys.stdout.write(json.dumps(error_response))
sys.stdout.flush()
except:
pass # 如果连错误都发不出去,只能放弃了
# 程序入口
if __name__ == "__main__":
main()
四、开发过程中的关键注意事项(避坑指南)
虽然代码看起来只有百余行,但在实际调试中,开发者常因以下细节导致"明明代码没错,但客户端就是连不上"的问题。
4.1 日志输出的陷阱
- 问题 :在
main循环中使用print("Debug...")打印调试信息。 - 后果 :
print会直接写入stdout,客户端会误认为这是协议数据,导致解析 JSON 失败,报Invalid response错误。 - 解决方案 :
- 方案 A(推荐):将日志写入文件。
- 方案 B :使用
print("Debug", file=sys.stderr)。标准错误流(stderr)不会干扰标准输出流(stdout)的协议通信。
4.2 缓冲区刷新(Flush)的必要性
- 原理 :操作系统为了提高效率,会将写入
stdout的数据暂存在缓冲区中,直到缓冲区满或程序结束才真正发送给客户端。 - 现象:客户端一直转圈等待,直到超时,而 Server 端其实早就处理完了。
- 解决方案 :正如我们在核心要求中强调的,**每一次
sys.stdout.write之后,必须紧跟 **sys.stdout.flush()。这是 MCP Server 能够实时响应的关键。
4.3 JSON 序列化的兼容性
- 编码问题:MCP 协议要求使用 UTF-8 编码。
- 非 ASCII 字符 :如果工具返回的内容包含中文或特殊字符,
json.dumps时请务必设置ensure_ascii=False,否则客户端收到的将是 Unicode 转义字符(如\u4f60\u597d),导致显示乱码。
4.4 异常处理的健壮性
- 单点故障 :如果
tools/call中的某个工具(比如add)抛出了未捕获的异常(例如除零错误),且没有在handle_json_rpc中进行try-except包裹,整个 Python 进程会崩溃退出。 - 后果:客户端会报"连接被重置",需要重启客户端才能重新连接。
- 最佳实践 :在
tools/call的逻辑分支中,必须用try-except包裹具体的函数执行代码,将错误转化为 MCP 协议规定的 Error 对象返回,保证 Server 进程永不退出。
五、总结与展望
通过本文的讲解,我们不仅了解了 MCP 协议作为"AI 通用连接器"的核心价值,更重要的是,我们亲手剥离了所有第三方库的封装,使用 Python 原生能力实现了一个最精简的 MCP Server。
重点:
- 通信基石 :
stdin/stdout是连接的唯一通道。 - 协议规范 :
Content-Length头部 +JSON-RPC 2.0格式。 - 三大方法 :
initialize握手,tools/list暴露能力,tools/call执行业务。