手搓 MCP 到最简 Agent,我都做了什么?

1 阅读8分钟

古法编程,代码是自己写的,哪怕说成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——

  1. 函数名 → 工具名
  2. 类型注解 → 参数 schema
  3. 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 "",
                })

三个关键点

  1. Schema 翻译几乎是透传:MCP 的 inputSchema 本身就是标准 JSON Schema,Ollama 的 parameters 也接受 JSON Schema。
  2. messages.append(response.message) 不能漏:它包含模型"决定调用工具"这个动作的记录,漏了下一轮模型就不知道自己请求过工具。
  3. 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)的最小实现:思考 → 行动 → 观察 → 再思考。

但它还是"最简"而非"完整"

我清楚它还缺四样东西:

  1. 记忆管理:messages 无限增长,聊久了会超出上下文窗口。
  2. 错误恢复:session.call_tool 抛异常时循环会崩,真实 Agent 要捕获异常并回填给模型。
  3. 多步规划:3B 模型在连续调多个工具的任务上容易乱。
  4. 工具描述优化:模型选对工具全靠 docstring 写得好,没有自动优化机制。

总结:我做了什么

步骤产出
1. 搭环境uv + Python 3.12 + mcp 1.30.0,锁死版本
2. 写 Serverserver.py 暴露三个工具:read_file、search_in_file、get_weather
3. 理解传输stdio = 标准输入输出管道,无端口无网络
4. 用 Inspector 验证看到工具列表、schema、调用结果,理解了 6 次 JSON-RPC 往返
5. 手搓 Clientclient.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) 的实践整理。