Python 手写 MCP 服务器:不用 SDK,从 JSON-RPC 消息循环讲起

2026-10-30 0 508

最近这段时间接手的项目里,有一半都在给 AI 客户端挂工具。MCP 这个词从去年年末还只是个新鲜玩意儿,到现在基本成了默认选项。官方 SDK 确实省事,几行代码就能起一个服务器,但第一次通读协议文档的时候我心里一直有个疙瘩:客户端和服务端之间到底传了些什么东西?真的只是发几段 JSON 吗?

后来索性把 SDK 撇开,只用 Python 标准库从头写了一个。整套东西两百行不到,跑通之后很多之前含糊的地方一下就清楚了——比如为什么你 print 一句调试信息就会把整个服务搞崩,比如工具执行报错到底该走哪条返回路径。这篇文章把这个过程完整记下来,附上两个真能用的工具。

stdio 传输层其实就一句话

MCP 支持好几种传输方式,但绝大多数桌面客户端用的都是 stdio:客户端把你的脚本当成子进程拉起来,往它的标准输入里写消息,从标准输出里读回复。没有端口,没有 HTTP 请求头,没有握手包,就是两根管子。

管子里面流的东西,本质上是 JSON-RPC 2.0 协议的消息,而且约定好了一行一条。也就是说,标准输出的每一行都必须是一个完整、可解析的 JSON 对象,除此之外什么都不能有。

一条请求长这样:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"a.txt"}}}

对应的响应必须带回同一个 id:

{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}],"isError":false}}

规则不多,但每条都得守住:带 id 的请求必须回复,没有 id 的通知绝对不能回复;方法名不存在时返回错误码 -32601;服务端内部异常返回 -32603。

先把消息循环搭起来

我习惯先把最外层的骨架写完再填内容,这样调试的时候能立刻看到原始报文。下面这段是整个服务器的主循环,只做三件事:读一行、解析、分发。

import json
import sys

def send(obj):
    sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "n")
    sys.stdout.flush()

def handle(msg):
    if "id" not in msg:
        return          # 通知,不回
    sys.stderr.write(f"收到 {msg.get('method')}n")
    send({"jsonrpc": "2.0", "id": msg["id"], "result": {}})

def main():
    while True:
        line = sys.stdin.readline()
        if not line:
            break
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
        except json.JSONDecodeError:
            sys.stderr.write(f"无法解析:{line[:120]}n")
            continue
        handle(msg)

if __name__ == "__main__":
    main()

注意 send 里那句 flush()。stdout 默认带缓冲,客户端在等你回应的时候你还在攒缓冲区,表现就是“请求发出去了但一直没反应”,这个坑我在一个线上脚本里踩过一次,排查了快半小时。

initialize 不只是握个手

客户端启动子进程之后发的第一条消息一定是 initialize。这一步除了打招呼,还顺带完成了两件实事:协议版本的协商,以及双方能力集的交换。

def handle_initialize(params):
    return {
        "protocolVersion": params.get("protocolVersion", "2024-11-05"),
        "capabilities": {"tools": {"listChanged": False}},
        "serverInfo": {"name": "mini-mcp", "version": "0.1.0"},
    }

protocolVersion 建议原样回显客户端给的值,除非你明确知道自己不支持。乱回一个版本号,部分客户端会直接断开连接。capabilities 里声明了 tools,等于告诉对方“我这边有工具可以调”,客户端才会接着发 tools/list。

紧跟在 initialize 后面的是 notifications/initialized。这条没有 id,是通知,收到之后什么都不用做——真的什么都不用做,下面会讲为什么这个“不回复”很关键。

tools/list 与 tools/call

握手完成后,正常流程是这样走的:客户端先拉工具清单,把每个工具的名字、描述、参数结构塞进模型的上下文;模型决定调用某个工具时,客户端再发 tools/call。

def describe_tools():
    return [
        {k: v for k, v in item.items() if k != "fn"}
        for item in REGISTRY.values()
    ]

def call_tool(params):
    name = params.get("name", "")
    args = params.get("arguments") or {}
    entry = REGISTRY.get(name)
    if entry is None:
        return {
            "content": [{"type": "text", "text": f"没有名为 {name} 的工具"}],
            "isError": True,
        }
    try:
        text = entry["fn"](**args)
    except Exception as exc:
        return {
            "content": [{"type": "text", "text": f"{type(exc).__name__}: {exc}"}],
            "isError": True,
        }
    return {"content": [{"type": "text", "text": text}], "isError": False}

这里有个设计上的分界,特别容易搞混:工具自己执行失败,和请求本身格式错误,是两回事。

如果模型让你读一个不存在的文件,这是工具执行失败,应该返回一条正常的结果,只是把 isError 置为 true,错误文本放进 content 里。客户端会把这段文本当成工具输出喂回给模型,模型看到“文件不存在:a.txt”,大概率会换个路径再试一次。

如果返回的是 JSON-RPC 层面的 error,那客户端会认为这条消息在协议层就坏了,模型根本看不到错误内容,于是它卡在那里,不知道该干嘛。我最早的版本全用了后一种写法,结果工具连续失败时模型的表现非常呆,改成 isError 之后明显好转。

填两个真能用的工具

骨架搭好,接下来往里塞东西。用装饰器注册是最省事的,工具函数保持纯 Python,跟协议完全解耦。

import os
import sqlite3
from pathlib import Path

BASE_DIR = Path(os.environ.get("MCP_BASE_DIR", ".")).resolve()
DB_PATH = os.environ.get("MCP_DB", "demo.db")
REGISTRY = {}

def register(name, description, schema):
    def deco(fn):
        REGISTRY[name] = {
            "name": name,
            "description": description,
            "inputSchema": schema,
            "fn": fn,
        }
        return fn
    return deco

第一个工具是文件阅读器。既然要交给模型去调,路径校验就必须自己做,不能指望客户端拦着。

@register(
    "read_file",
    "读取工作目录下的文本文件,最多返回前 200 行。",
    {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "相对于工作目录的文件路径"},
        },
        "required": ["path"],
    },
)
def read_file(path):
    target = (BASE_DIR / path).resolve()
    if not target.is_relative_to(BASE_DIR):
        raise ValueError("路径越界,拒绝访问")
    if not target.is_file():
        raise FileNotFoundError(f"找不到文件:{path}")
    lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
    body = "n".join(lines[:200])
    if len(lines) > 200:
        body += f"n...(共 {len(lines)} 行,已截断)"
    return body or "文件是空的"

is_relative_to 是 Python 3.9 才有的方法,比字符串前缀匹配可靠得多——后者在 /data 和 /data-backup 这种名字上会误判。截断也很必要,一份几千行的日志原样丢进上下文,token 直接烧掉一大半,模型反而抓不住重点。

第二个工具查 SQLite。只读模式打开,连驱动层都帮你把写操作挡掉。

@register(
    "query_db",
    "对内置的 SQLite 数据库执行只读 SELECT 查询,最多返回 50 行。",
    {
        "type": "object",
        "properties": {
            "sql": {"type": "string", "description": "一条 SELECT 语句"},
        },
        "required": ["sql"],
    },
)
def query_db(sql):
    if not sql.strip().lower().startswith("select"):
        raise ValueError("只接受 SELECT 语句")
    if not os.path.exists(DB_PATH):
        raise FileNotFoundError(f"数据库不存在:{DB_PATH}")
    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    try:
        cur = conn.execute(sql)
        cols = [d[0] for d in cur.description]
        rows = cur.fetchmany(50)
    finally:
        conn.close()
    if not rows:
        return "查询成功,但没有匹配的数据"
    lines = [" | ".join(cols)]
    lines += [" | ".join("NULL" if v is None else str(v) for v in row) for row in rows]
    return "n".join(lines)

输出用竖线分隔的纯文本,不用 JSON。原因是模型读表格型文本比读嵌套 JSON 稳,字段多的时候也不容易看串行。

几个把我卡住的细节

代码到这里就完整了,但真正让它稳定跑起来的是下面这五条。

第一,stdout 是协议通道,任何多余输出都是灾难。我调试时在工具函数里留了一句 print("查到了", rows),客户端立刻抛 Unexpected token '查'。它按行读取 stdout,每一行都试图当 JSON 解析,你哪怕输出一个空行都会让它崩。所有日志走 sys.stderr,客户端不会读它,但你在终端里能看到。

第二,不要给 json.dumps 加 indent。缩进之后一条消息变成好几行,按行切分的逻辑直接乱套。要给人看的美化版本,请写到 stderr 的日志里。

第三,通知真的不能回。我见过有人收到 notifications/initialized 之后条件反射地返回 {"result":{}},客户端会因为找不到对应的请求而报错。判断方法就是看 "id" in msg,这个字段的存在与否比任何方法名前缀都可靠。

第四,错误码别乱造。JSON-RPC 预留了几个标准码:-32700 解析错误、-32600 请求无效、-32601 方法不存在、-32603 内部错误。方法不存在的时候返回什么都不会生效,但返回正确的码至少能让客户端日志好看一点。

第五,小心阻塞。整个服务器是单线程顺序处理的,一个工具跑了三十秒,后面所有请求都排队等着。要跑长任务,要么开线程,要么利用 MCP 的进度通知机制把状态推回给客户端——但这两件事都超出了“先跑通”的范围,我一般是等到确实卡住了再加。

手动测一遍,再接到客户端上

不用急着找客户端,直接用 shell 把消息喂进去就能验证。标准输入是文件,服务器会逐行读、逐行回。

python3 server.py <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"tester","version":"0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"README.md"}}}
EOF

正常的输出应该是三行响应,加上 stderr 里几行日志。第二行对应的通知没有任何回应,如果你看到四条输出,说明哪里多回了。

确认没问题,就可以挂到客户端上了。绝大多数客户端用的是同一套配置格式,键名是 mcpServers:

{
  "mcpServers": {
    "mini-mcp": {
      "command": "python3",
      "args": ["/绝对路径/server.py"],
      "env": {
        "MCP_BASE_DIR": "/Users/me/notes",
        "MCP_DB": "/Users/me/notes/app.db"
      }
    }
  }
}

路径一定写绝对路径。客户端拉起子进程时的工作目录跟你终端里完全不是一回事,用相对路径会得到一个“文件不存在”的诡异结果,而且你会以为是代码问题。

接下来还能往哪走

这套骨架已经能撑起日常用的工具了。再往后,比较自然的扩展方向有三个:一是给工具加进度通知,长任务在 tools/call 的 _meta.progressToken 里带个令牌,服务端定期发 notifications/progress,客户端就能显示进度条;二是实现 resources 和 prompts 两种能力,前者适合暴露大量只读数据,后者适合把固定工作流封装成模板;三是换成 streamable HTTP 传输,让服务能跑在远端,不再是本机子进程。

不过说到底,协议层的东西就这么多。真正决定一个 MCP 服务器好不好用的是工具本身的设计:参数是不是够少、描述是不是够准、出错时给出的信息是不是足够让模型自我修正。这些跟写普通 Python 函数没什么区别,只是现在你的调用方从人变成了模型而已。

Python 手写 MCP 服务器:不用 SDK,从 JSON-RPC 消息循环讲起
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python 手写 MCP 服务器:不用 SDK,从 JSON-RPC 消息循环讲起 https://www.taomawang.com/server/python/2983.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务