跳至正文

用 Python 写了个 MCP Server,被一个 print() 卡了俩小时

上周我想让 AI 直接查我那台群晖的硬盘温度和 Docker 状态,不想每次都自己 SSH 上去敲命令。

试了几种方法都不顺手,最后决定自己写个 MCP Server。四十几行代码,跑起来了。但中间被一个 print() 卡了整整两个小时——这篇文章把过程和我踩的五个坑一次讲完,你照着做,半小时能跑通。

为什么是 MCP,不是“给 AI 一个 API”

我最早的做法是写个 Flask 接口,然后让 AI 去调。问题是:我得先跟它解释接口地址、参数格式、返回结构,聊完之后它还经常把参数名记错。

MCP(Model Context Protocol)解决的就是这个事——你把工具的能力、参数、说明交给协议本身,客户端会自己把这些塞进模型的上下文里。模型看到的是「有个工具叫 nas_disk_status,不用参数,返回各盘位的容量和温度」,而不是一串 URL。

说白了:API 是给人写的,MCP 是给模型看的。

第一步:环境,别跳过这步

我用 uv,比 pip 干净,而且后面注册到客户端时路径好写。

mkdir ~/mcp-nas && cd ~/mcp-nas
uv venv
source .venv/bin/activate
uv pip install "mcp[cli]"

装完你会有一个 mcp 命令,可以先跑官方的调试器验证环境没坏:

mcp dev server.py

第二步:四十行代码的 Server

新建 server.py:

import subprocess
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("nas-tools")

@mcp.tool()
def disk_status() -> str:
    """查询 NAS 各盘位的容量与温度。用户问硬盘、容量、磁盘健康时使用。"""
    out = subprocess.run(
        ["ssh", "nas", "df -h /volume1 /volume2"],
        capture_output=True, text=True, timeout=20,
    )
    if out.returncode != 0:
        return f"查询失败:{out.stderr.strip()[:200]}"
    return out.stdout

@mcp.tool()
def docker_ps() -> str:
    """列出 NAS 上所有运行中的 Docker 容器及其状态。"""
    out = subprocess.run(
        ["ssh", "nas",
         "/volume2/@appstore/ContainerManager/usr/local/bin/docker ps --format '{{.Names}}\t{{.Status}}'"],
        capture_output=True, text=True, timeout=20,
    )
    return out.stdout or "没有运行中的容器"

if __name__ == "__main__":
    mcp.run()

mcp.run() 默认走 stdio 传输——不用开端口、不用配证书,客户端把进程拉起来,通过标准输入输出通信。这也是最容易被坑的地方。

第三步:注册到客户端

我这儿是 WorkBuddy,配置在 ~/.workbuddy/mcp.json(注意不是 .mcp.json,带点的那个不生效):

{
  "mcpServers": {
    "nas-tools": {
      "command": "/Users/mac/mcp-nas/.venv/bin/python",
      "args": ["/Users/mac/mcp-nas/server.py"]
    }
  }
}

写完要重启客户端,部分客户端还要求你手动「信任」新加的 server,别在这上面浪费时间怀疑代码。

终端调试

五个坑,一个比一个隐蔽

坑 1:一句 print() 直接把协议打崩

我第一次跑,客户端报的是 Unexpected token,一看就是 JSON 解析炸了。

原因:stdio 传输下,stdout 是 JSON-RPC 的专线。你 print("debug"),就等于往协议流里塞了一行垃圾,客户端解析到那行就断了。而且报错信息完全指不到你的代码行。

解决:调试输出一律走 stderr 或 logging。

import sys, logging
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logging.info("这条能看,且不污染协议")

我把 print 换成 logging 之后,客户端立刻就通了。两个小时就没了。

坑 2:依赖装在你的 shell 里,跑起来却找不到

uv pip install 在终端里明明成功,客户端一启动就 ModuleNotFoundError: mcp。

因为客户端执行 command 用的是子进程环境,不走你 shell 的 activate。如果你写的是 "command": "python",客户端可能拿到的是系统 Python,不是你 venv 里那个。

注册时把绝对路径写死:/Users/mac/mcp-nas/.venv/bin/python。这一条能省掉一大半玄学问题。

坑 3:返回值不能是“随便什么对象”

我一开始图省事,直接 return {"temp": 42, "time": datetime.now()}。

结果客户端收到的是一坨报错。MCP 的返回要能序列化成 JSON,datetime、Path、Decimal 这些都不行。

要么全转成基础类型,要么直接像我上面那样返回一段字符串——说实话,对模型来说,一段格式化好的文本比嵌套 JSON 更好用,它不用猜字段含义。

from datetime import datetime
return {"query_at": datetime.now().isoformat(timespec="seconds")}

坑 4:异常别往外抛,模型看不到

subprocess 超时会抛 TimeoutExpired。抛出去的话,客户端只显示一句 tool call failed,模型完全不知道发生了什么,于是它开始瞎猜,甚至编一个“硬盘温度正常”给你。

正确做法是捕获异常,把原因当正常返回值交回去:

try:
    out = subprocess.run([...], timeout=20, capture_output=True, text=True)
except subprocess.TimeoutExpired:
    return "SSH 连接超时,NAS 可能关机或网络不通,请稍后重试。"

模型拿到这句话,会老老实实告诉你连不上,而不是骗你。

坑 5:docstring 决定它会不会被调用

工具能跑不代表模型会用。文档字符串写得含糊,模型就宁愿自己去猜。

有效的写法是说清三件事:干什么、什么时候用、返回什么。

@mcp.tool()
def disk_status() -> str:
    """查询 NAS 各盘位的容量与温度。当用户询问硬盘容量、剩余空间、
    磁盘温度或存储健康状态时调用。返回 df -h 格式的文本。"""

我改完 docstring,调用命中率从“经常不用”变成“基本都对”。这一点没人提醒,纯靠自己试出来的。

协议桥接

现在它帮我干什么

跑通之后我把常用的几个都包了进去:查硬盘、列 Docker 容器、重启指定容器(这个加了二次确认)、读最近 50 行日志。

最爽的是问「NAS 上那个 qBittorrent 挂了吗」,它自己去查 docker ps,回来告诉我「容器在跑,但状态是 Restarting (1),大概率是配置写坏了」。这比我自己敲命令快多了。

NAS服务器机房

四十几行代码,一下午的折腾。下次你要接自己的服务,记得:stdout 只看协议,绝对路径写死,异常当返回值,docstring 当说明书。 记住这四条,能省你两个小时。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注