MCP 服务器怎么写?我照着官方文档搭了个 SQLite 的,一晚上踩了 7 个坑

🔑 关键词:MCP服务器, Model Context Protocol, Claude Desktop, stdio, JSON-RPC

📖 摘要:从零写一个 MCP server 的踩坑记录:stdio 的日志为什么必须走 stderr、配置里为什么只能写绝对路径、工具描述吃掉多少 token,以及什么情况下你根本不该用 MCP。

周三凌晨一点半,我盯着 Claude Desktop 的日志文件看了快四十分钟。

图片

原因很蠢。我写了个 MCP server,命令行里 python server.py 敲下去风平浪静,但 Claude 那边就是死活刷不出工具。最后发现是 print() 的锅——我为了调试加了一行打印参数,那行输出混进了 stdout,把 JSON-RPC 的消息流冲烂了。MCP 走 stdio 传输的时候,stdout 是留给协议本身的,你所有的日志只能走 stderr。这条规则官方文档里写了,但我第一次读的时候直接跳过去了,因为它藏在“架构概览”那一大段后面,看着像废话。所以这篇不是教程,是我自己摸索的过程和结论,其中几个坑我觉得比怎么写代码更值得说。

先交代背景,免得你觉得这文章太空。我本职是做数据的,Python 能写但算不上工程师,最大的需求就是让 Claude 能直接查我本地几个 SQLite 文件:一份记了三年的记账表、读书笔记、还有个游戏销量的库。以前的做法是导成 CSV 再拖进对话框,超过两万行就卡,而且每次对话都要重来一遍。MCP 出来后我第一反应是“这不就是我想要的”,第二反应是“这玩意儿到底值不值得学”。现在两个星期过去,我本地跑着三个自写的 server、总共 11 个工具,可以给个阶段性的答案:值得,但多半不是你想的那个理由。

先把四种做法摆一起比

我把能想到的路子都试了一遍,下面这张表里的时间是我自己的实测,不保证对你有参考价值:

做法 从零到能用 上下文开销 换客户端 出错好查吗
贴 CSV 进对话 0 分钟 两万行约 60 万字符,直接爆 —— 不用查
自己写 function calling 半天 工具定义照样要占位 换个客户端全部重写 好查,都在自己代码里
给它一个 bash 工具 10 分钟 极小,就一个工具定义 好迁移 差,SQL 写错了你根本看不出来
写 MCP server 2 到 4 小时 中等,定义每次请求都带 好,Claude、Cursor 这些基本都认 中等,日志在客户端的目录里

图片

第三行那个“bash 工具”我用了差不多半年,一开始觉得很爽——给模型一个 shell,理论上它能干任何事。但问题很快暴露:它查出来的数字你没法验证。有一次它把我记账表里的“餐饮”和“外卖”当成两个分类分别求和,我看了三遍才发现口径不对。这不是模型的错,是我没有给它任何约束。MCP 的价值有一大半在这里:工具的边界是你定义的,参数是模型填的,返回是确定性的,出问题你能定位到具体哪个工具。

最小可跑的东西长什么样

我用的 Python SDK,一个只读的记账查询工具,代码大概三十行:

from mcp.server.fastmcp import FastMCP
import sqlite3

mcp = FastMCP("ledger")



![图片](http://img2.baidu.com/it/u=920992681,2580404466&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500)


@mcp.tool()
def query(sql: str) -> str:
    """只读查询本地记账库。表名 transactions,字段 date/amount/category/note。仅支持 SELECT。"""
    con = sqlite3.connect("/Users/chen/ledger.db")
    try:
        rows = con.execute(sql).fetchall()[:200]
        return "\n".join(str(r) for r in rows)
    finally:
        con.close()

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

然后是在 Claude Desktop 的配置里注册。macOS 上的路径是 ~/Library/Application Support/Claude/claude_desktop_config.json,Windows 是 %APPDATA%\Claude\claude_desktop_config.json,内容大概这样:

{
  "mcpServers": {
    "ledger": {
      "command": "/Users/chen/.venv/bin/python",
      "args": ["/Users/chen/mcp/ledger/server.py"]
    }
  }
}

看着简单,但这里有两个我卡了很久的点。第一,commandargs 里的路径必须是绝对路径,~ 不会展开,写相对路径也不行,因为客户端的工作目录不是你想的那个。第二,command 千万别写 python 或者 python3,要写你 venv 里那个解释器的绝对路径,否则客户端拉起来的是系统 Python,里面没装 mcp 这个包,表现就是进程秒退、界面毫无提示。

图片

七个坑,按我踩到的顺序排

  1. stdout 污染。上面说过了,任何 print()、任何没配置好的 logging 默认 handler,只要往 stdout 写东西就会破坏协议。我的做法是代码里所有调试输出统一走 sys.stderr.write,或者干脆用 logging.basicConfig(stream=sys.stderr)

  2. 日志文件在哪。macOS 下 Claude Desktop 的 MCP 日志在 ~/Library/Logs/Claude/ 目录,文件名是 mcp-server-<你的服务名>.log。这个路径我是翻了好久才找到的,官方文档里没写得很显眼。找不到日志,你就是在盲调。

  3. Node 版本。如果你用的是官方那些用 npx 起的 server,本机 Node 要 18 以上。我第一次跑的时候 Node 是 16,报错信息含糊得要命。另外首次启动 npx 要联网下载包,慢的时候客户端那边直接超时,我印象里启动等待差不多是 60 秒这个量级,超了就判定失败——这条我没去翻源码确认,反正别赌。

  4. 工具描述是每次请求都在交的税。我一开始四个工具,描述写得很啰嗦,加起来大概 3800 个 token。后来压到七个工具、总描述 900 多 token 左右(统计方法很土:在 server 里把 tools/list 的返回打日志,字符数除以 3.5 估),长对话跑偏的情况明显少了。这个开销不会因为你不用工具就省掉,它是常驻的。

图片

  1. 别把写权限交给模型。我现在读和写是两个 server,写那个平时关着,要改数据的时候手动在配置里打开、重启。听着很麻烦,但你想想 DELETE FROM transactions WHERE date < '2024-01-01' 被执行是什么感觉。

  2. 改完代码必须重启客户端。它是进程启动的时候把 server 拉起来的,没有热更新这回事。我有半小时一直在改代码然后疑惑为什么没生效。

  3. Windows 路径的反斜杠。JSON 里要写双反斜杠,这个属于基本功失误,但人在凌晨一点半的时候什么都会忘。

我的观点:MCP 解决的是分发,不是能力

这一点我想说得直白些。你的模型本来就能通过 function calling 调 SQLite,MCP 并没有给它新能力。它做的事是把“怎么调”从应用代码里剥出来,变成一个独立进程加一份标准协议。好处是别人能复用、你能跨客户端复用、换工具的时候不用重写。

图片

所以判断标准就变得很粗暴:你会不会换客户端?有没有第二个人要用?两个答案都是否,那就老老实实写 function calling,或者干脆做个命令行脚本让模型去调,两小时能省下来。我见过不少人一上来就折腾 MCP,结果服务只有自己用、客户端也只用 Claude Desktop,最后收益就是个仪式感。

另一个观点:MCP server 应该做得。我见过有人把重试、限流、缓存、业务规则全塞进 server 里,把它写成了一个小型后端。这些逻辑放外面去,server 只干一件事——把模型的自然语言参数翻译成一个边界明确的确定性调用。反过来说,千万别写那种 do_anything(action, params) 的万能工具,模型在这种工具上的表现是最差的,因为你把参数校验的活全丢回给它了。宁可多写几个窄工具。

现在的状态

三个 server,11 个工具。跑了两周,没出过数据损坏,查询准确率比我以前贴 CSV 高得多,因为口径被工具定义锁死了。代价是 Claude Desktop 的启动时间从大概 2 秒变成了 7 秒左右,每次开机都能感觉到。

值不值,取决于你是不是每天都在干同一件事。如果你一周就用一次这个场景,真的别折腾,把表导成 500 行的样本丢进对话里,效果差不了多少,还省下一晚上。我那一晚上本来是想九点睡的。

🏷️ 标签: