古法编程,代码是自己写的,哪怕说成CV工程师,也对业务、代码逻辑理解比较透彻,而现在说AI coding,我要怎么办?所以,我决定手搓MCP。
手搓 MCP 到最简 Agent,我都做了什么?
一次从零开始的实践记录:在 Mac 上手搓 MCP Server、接入本地千问 2.5 3B,最终跑通一个能自主调用工具的最简 Agent。
第一步:搭环境,先踩了三个坑
坑 1:Python 版本太旧
Mac 自带 Python 3.9.6,而 MCP SDK 要求 3.10+。第一次 uv add "mcp[cli]" 直接报错:
Because the requested Python version (>=3.9) does not satisfy Python>=3.10...
解决:用 uv 装独立 Python 版本,不污染系统:
uv python install 3.12
rm -rf .venv
uv python pin 3.12
uv venv
uv add "mcp[cli]"
关键教训:.python-version 文件的优先级高于 pyproject.toml 的 requires-python。只改后者不够,必须用 uv python pin 改前者。
坑 2:SDK 装成了 v2
装完发现是 mcp 2.x,报错 ModuleNotFoundError: No module named 'mcp.server.fastmcp'。v2 把 FastMCP 改名成了 MCPServer。
解决:为了先专注理解协议概念,锁回 v1:
uv add "mcp[cli]<2"
uv pip show mcp # 确认 Version: 1.30.0
坑 3:Inspector 启动失败
uv run mcp dev server.py 报错 Cannot find native binding,这是 npm 的已知 bug(npm/cli#4828),在 Apple Silicon 上没装上 darwin-arm64 原生模块。升级 npm 也没解决。
解决:用 pnpm 绕过:
pnpm dlx @modelcontextprotocol/inspector uv run python server.py
注意终端会打印带 ?MCP_INSPECTOR_API_TOKEN=... 的完整 URL,必须用完整地址打开浏览器,否则前端连不上后端。
第二步:手搓第一个 MCP Server
核心就三步
用 FastMCP 写 Server,流程固定:导入类 → 创建实例 → 用装饰器挂工具 → 启动监听。
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("handcrafted-local-tools")
@mcp.tool()
def read_file(filepath: str) -> str:
"""读取本地文本文件的全部内容。"""
...
@mcp.tool()
def search_in_file(filepath: str, keyword: str, max_results: int = 10) -> str:
"""在指定的本地文本文件中搜索包含关键词的行。"""
...
if __name__ == "__main__":
mcp.run(transport="stdio")
我理解到的关键点
@mcp.tool() 做了什么:读取三样东西自动生成 JSON Schema——
- 函数名 → 工具名
- 类型注解 → 参数 schema
- docstring → 工具描述
这份 schema 就是 LLM 看到的"说明书"。docstring 写得越清楚,模型选得越准。
两个工具函数惯例:
- 返回值统一用
str(LLM 只能读文本) - 错误不要
raise,直接return字符串(让模型能理解并决定下一步)
加一个 HTTP 工具:查询天气
用 wttr.in 免费 API,不需要 Key。踩了一个编码坑:'ascii' codec can't encode characters,原因是中文城市名和 format 参数里的特殊字符没完全编码。
解决:把城市名和 format 参数分开编码:
encoded_city = urllib.parse.quote(city, safe="")
fmt = urllib.parse.quote("%l:+%c+%t,+体感%f,+湿度%h,+风%w", safe="")
url = f"https://wttr.in/{encoded_city}?format={fmt}"
第三步:搞懂 transport="stdio" 到底在传什么
stdio 指 Standard Input / Standard Output。这行告诉 Server:用标准输入输出管道和 Client 通信,不开网络端口。
工作方式:Server 监听 stdin 等待 JSON 消息,把响应写到 stdout;Client 启动 Server 时创建子进程,把管道接到子进程的 stdin/stdout 上。
没有端口、没有 HTTP、没有网络。这也是为什么跑 Inspector 时不需要配任何地址。
⚠️ 一个坑:stdio 模式下,工具函数里绝对不能往 stdout 打印普通文本,否则会被当成 JSON-RPC 消息解析而报错。调试信息要用 stderr。
第四步:用 Inspector 验证,看懂了协议交互
Inspector 是官方提供的调试工具,扮演"临时替身 Client":连接 Server → 发 tools/list 拿工具清单 → 根据 schema 渲染表单 → 你填参数点执行 → 它发 tools/call → 展示结果。
关键认知:Inspector 不读我的 Python 代码,只读协议消息。它根据 JSON Schema 渲染表单,改一行代码重启 Server,表单自动更新。
一次工具调用背后的 6 次 JSON-RPC 往返
Inspector (Client) server.py (Server)
│ ① initialize 请求 │
│─────────────────────────────────────>│
│ ② initialize 响应 │
│<─────────────────────────────────────│
│ ③ tools/list 请求 │
│─────────────────────────────────────>│
│ ④ tools/list 响应(含 JSON Schema) │
│<─────────────────────────────────────│
│ ⑤ tools/call 请求 {name, arguments} │
│─────────────────────────────────────>│
│ ⑥ tools/call 响应(文本结果) │
│<─────────────────────────────────────│
三个核心概念对应图里的位置:
| 概念 | 位置 | 本质 |
|---|---|---|
| Transport | 箭头本身 | stdio 就是两个进程通过标准输入输出互相写 JSON 行 |
| JSON-RPC 2.0 | 每条消息格式 | 固定结构 {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{...}} |
| Tool Schema | ④ 的响应 | 装饰器读取函数名/注解/docstring 生成的说明书 |
最关键的认知:Server 从头到尾只做一件事——把 Python 函数"翻译"成 JSON Schema 说明书,等待别人按说明书调用。它不会思考,不会主动做事。真正"决定调用什么"的是 Client 那一侧。
第五步:手搓 Client,把千问 2.5 3B 接进来
完整流程
1. 连接 server.py(stdio 传输)
2. 发 tools/list → 拿到工具 schema
3. 把 schema 转成千问能理解的 tool 定义格式
4. 用户问题 + tool 定义一起发给千问
5. 千问返回 tool_call(如 "调 get_weather,city=北京")
6. Client 把 tool_call 翻译成 MCP 的 tools/call 请求
7. 拿到结果,回填给千问,让它生成最终回答
8. 循环直到千问不再请求工具
核心代码
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from ollama import chat
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools_result = await session.list_tools()
# MCP schema → Ollama tool 格式
ollama_tools = [{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.inputSchema,
}
} for t in tools_result.tools]
messages = [{"role": "user", "content": "北京今天天气怎么样?"}]
while True:
response = chat(
model="qwen2.5:3b",
messages=messages,
tools=ollama_tools,
)
messages.append(response.message)
if not response.message.tool_calls:
print(f"\n最终回答: {response.message.content}")
break
for tool_call in response.message.tool_calls:
result = await session.call_tool(
tool_call.function.name,
tool_call.function.arguments,
)
messages.append({
"role": "tool",
"tool_name": tool_call.function.name,
"content": result.content[0].text if result.content else "",
})
三个关键点
- Schema 翻译几乎是透传:MCP 的
inputSchema本身就是标准 JSON Schema,Ollama 的parameters也接受 JSON Schema。 messages.append(response.message)不能漏:它包含模型"决定调用工具"这个动作的记录,漏了下一轮模型就不知道自己请求过工具。role: "tool"的回填格式:Ollama 要求工具结果用{"role": "tool", "tool_name": ..., "content": ...}塞回历史。
第六步:想清楚"模型决策"和"框架执行"的分工
chat(...) 这一步,模型做的是:读对话历史 + 读工具清单 → 判断要不要调工具、调哪个、传什么参数 → 把决定写进 tool_calls。
它本身不执行工具。执行是后面的 session.call_tool(...) 做的。
用户: "北京今天天气怎么样?"
│
▼
Agent 把「对话历史 + 工具清单」发给 LLM
│
▼
LLM 返回: tool_call(get_weather, {city: "北京"}) ← 模型只负责"说"
│
▼
Agent 执行层: 通过 MCP 协议调用 get_weather ← 框架负责"做"
│
▼
MCP Server: 真的发 HTTP 请求,返回天气数据
│
▼
Agent 把结果塞回对话历史,再次发给 LLM
│
▼
LLM 读到天气数据,生成最终回答
模型选工具的依据,几乎完全来自工具的 description 和参数 schema。 这就是为什么 docstring 要写清楚"什么时候用",而不是只写"函数做什么"。
一个我意识到的重要误解:挂了 MCP 工具,LLM 不会自动"发现"工具(是框架每轮把清单塞给它),不会执行工具(它只输出调用请求),也可能选错(尤其是 3B 小模型,工具多了容易混淆)。
第七步:把 messages 改成控制台输入,得到最简 Agent
把写死的 messages 改成 input(),外层包一个 while,就是一个最简 Agent:
while True:
user_input = input("\n你: ")
if user_input.lower() in ("exit", "quit"):
break
messages.append({"role": "user", "content": user_input})
while True: # Agent 工具调用循环
response = chat(model="qwen2.5:3b", messages=messages, tools=ollama_tools)
messages.append(response.message)
if not response.message.tool_calls:
print(f"\n助手: {response.message.content}")
break
for tool_call in response.message.tool_calls:
result = await session.call_tool(
tool_call.function.name,
tool_call.function.arguments,
)
messages.append({
"role": "tool",
"tool_name": tool_call.function.name,
"content": result.content[0].text if result.content else "",
})
为什么这算"最简 Agent"
Agent 的经典定义是"感知环境、自主决策、执行动作、根据反馈调整"。这套代码覆盖了:
| Agent 要素 | 对应代码 |
|---|---|
| 感知 | input() 拿到用户输入,塞进 messages |
| 决策 | chat(..., tools=...) 让 LLM 判断调不调工具、调哪个 |
| 行动 | session.call_tool(...) 通过 MCP 真正执行 |
| 反馈 | 工具结果以 role: "tool" 回填,LLM 基于它生成回答 |
| 循环 | 内层 while 直到不再请求工具;外层 while 持续对话 |
这就是 ReAct 模式(Reasoning + Acting)的最小实现:思考 → 行动 → 观察 → 再思考。
但它还是"最简"而非"完整"
我清楚它还缺四样东西:
- 记忆管理:
messages无限增长,聊久了会超出上下文窗口。 - 错误恢复:
session.call_tool抛异常时循环会崩,真实 Agent 要捕获异常并回填给模型。 - 多步规划:3B 模型在连续调多个工具的任务上容易乱。
- 工具描述优化:模型选对工具全靠 docstring 写得好,没有自动优化机制。
总结:我做了什么
| 步骤 | 产出 |
|---|---|
| 1. 搭环境 | uv + Python 3.12 + mcp 1.30.0,锁死版本 |
| 2. 写 Server | server.py 暴露三个工具:read_file、search_in_file、get_weather |
| 3. 理解传输 | stdio = 标准输入输出管道,无端口无网络 |
| 4. 用 Inspector 验证 | 看到工具列表、schema、调用结果,理解了 6 次 JSON-RPC 往返 |
| 5. 手搓 Client | client.py 连接 Server + 调用 Ollama + 翻译 schema + 跑工具调用循环 |
| 6. 理清分工 | LLM 决策"说",MCP 执行"做",Agent 框架是"神经" |
| 7. 改造成最简 Agent | 控制台输入 + 内层工具循环 + 结果回填 = ReAct 最小实现 |
一句话总结这三层分工:
MCP 提供"手脚"(标准化工具),LLM 提供"大脑"(决策),Agent 框架是"神经"(把两者连起来、跑循环)。 三者配合,LLM 才能根据对话选择适当的 MCP 工具并完成任务。
这套最小闭环跑通之后,后续可以往"完整 Agent"方向加:记忆管理、错误恢复、多步规划、多工具协同。但骨架已经在了,剩下的都是在这个骨架上加肌肉。
基于 macOS + Python 3.12 + mcp 1.30.0 + Ollama (qwen2.5:3b) 的实践整理。