MCP 服务端手写记:stdout 污染与错误码混用

举报
yd_289817328 发表于 2026/11/02 09:02:39 2026/11/02
【摘要】 用 Python 从零实现一个 MCP stdio 服务端并跑通工具调用,复盘 stdout 被调试输出污染、工具错误误报成协议错误、结果截断三个真实坑,附完整可运行代码。

MCP 服务端手写记:stdout 污染与错误码混用

给编码 Agent 加一个"在仓库里按关键词找代码"的能力,最省事的做法是自己写一个 MCP 服务端:不用引入框架,不依赖任何 SDK,一个 Python 文件加标准库就够。协议本身确实薄——JSON-RPC 2.0,一行一条消息。但真实折腾的时间几乎都不在协议上,而在几个边界上:stdout 被调试信息污染、工具执行失败被错报成协议错误、返回结果把上下文撑爆。下面按"协议怎么走通、几个坑怎么踩的、最后怎么收"来讲,代码在本地跑通过。

一、问题:Agent 需要一个能收敛输出的检索工具

把 grep 直接塞给模型,最典型的失败是返回几千行匹配,模型的上下文被一次工具调用占满,后面全是噪声。所以工具要满足三点:参数校验在服务端做、结果有上限且明确告诉模型"被截断了"、失败信息要以模型能读懂的形式返回,而不是让它看到一个连接错误。

MCP 的 stdio 传输用换行分隔的 JSON-RPC 消息:每条消息一行,行内不能有裸换行。这里最容易和 LSP 混淆——LSP 用 Content-Length 头,MCP 的 stdio 不用。生命周期是 initialize → notifications/initialized → tools/list → tools/call,通知(没有 id 的消息)不需要回包,回了反而是协议违规。

还有一个细节值得较真:版本协商。客户端在 initialize 里报一个 protocolVersion,如果服务端支持这个版本,就原样回;不支持则回自己支持的最新版本,由客户端决定是否继续。直接报错拒绝握手,会让很多宿主认为服务端不可用。

二、方案:一个文件的服务端

核心只有三件事:读一行、分发、写一行。完整实现如下,保存为 server.py 即可运行。

#!/usr/bin/env python3
import json
import sys
import threading
import traceback
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

PROTOCOL_VERSION = "2025-06-18"
SUPPORTED = {"2024-11-05", "2025-03-26", "2025-06-18"}
TEXT_SUFFIX = {".py", ".md", ".ts", ".js", ".yaml", ".yml", ".json", ".go", ".java"}
WRITE_LOCK = threading.Lock()
POOL = ThreadPoolExecutor(max_workers=4)


def log(msg):
    print(f"[repo-search] {msg}", file=sys.stderr, flush=True)


def send(payload):
    line = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
    with WRITE_LOCK:
        sys.stdout.write(line + "\n")
        sys.stdout.flush()


TOOLS = [{
    "name": "search_text",
    "description": "在目录内的文本文件中搜索关键词,返回 文件:行号: 内容,超出上限会被截断",
    "inputSchema": {
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "要搜索的绝对目录路径"},
            "keyword": {"type": "string", "description": "关键词,区分大小写"},
            "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
        },
        "required": ["path", "keyword"],
    },
}]


def search_text(args):
    root = Path(args["path"])
    if not root.is_dir():
        raise ValueError(f"不是有效目录: {root}")
    keyword = args["keyword"]
    limit = min(int(args.get("limit", 50)), 200)
    hits, total = [], 0
    for p in sorted(root.rglob("*")):
        if not p.is_file() or p.suffix not in TEXT_SUFFIX:
            continue
        try:
            with p.open(errors="ignore") as f:
                for no, line in enumerate(f, 1):
                    if keyword in line:
                        total += 1
                        if len(hits) < limit:
                            hits.append(f"{p}:{no}: {line.rstrip()[:200]}")
        except OSError as e:
            log(f"跳过 {p}: {e}")
    if not hits:
        return "未找到匹配。"
    tail = "" if total <= limit else f"\n…… 共 {total} 条命中,已截断到前 {limit} 条"
    return "\n".join(hits) + tail


def check_args(args):
    if not isinstance(args, dict):
        raise TypeError("arguments 必须是对象")
    for key in TOOLS[0]["inputSchema"]["required"]:
        if not args.get(key):
            raise ValueError(f"缺少必填参数: {key}")
    return args


def handle(msg):
    method, mid = msg.get("method"), msg.get("id")
    params = msg.get("params") or {}
    if method == "initialize":
        asked = params.get("protocolVersion")
        return {"jsonrpc": "2.0", "id": mid, "result": {
            "protocolVersion": asked if asked in SUPPORTED else PROTOCOL_VERSION,
            "capabilities": {"tools": {"listChanged": False}},
            "serverInfo": {"name": "repo-search", "version": "0.1.0"},
        }}
    if method == "tools/list":
        return {"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}}
    if method == "tools/call":
        if params.get("name") != "search_text":
            return {"jsonrpc": "2.0", "id": mid,
                    "error": {"code": -32602, "message": f"未知工具: {params.get('name')}"}}
        try:
            text = search_text(check_args(params.get("arguments")))
            return {"jsonrpc": "2.0", "id": mid, "result": {
                "content": [{"type": "text", "text": text}], "isError": False}}
        except ValueError as e:
            return {"jsonrpc": "2.0", "id": mid, "result": {
                "content": [{"type": "text", "text": f"参数不合法: {e}"}], "isError": True}}
        except Exception as e:
            traceback.print_exc(file=sys.stderr)
            return {"jsonrpc": "2.0", "id": mid, "result": {
                "content": [{"type": "text", "text": f"工具内部错误: {e}"}], "isError": True}}
    return {"jsonrpc": "2.0", "id": mid,
            "error": {"code": -32601, "message": f"不支持的方法: {method}"}}


def dispatch(msg):
    if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
        send({"jsonrpc": "2.0", "id": None,
              "error": {"code": -32600, "message": "无效请求"}})
        return
    if "method" not in msg:
        return
    if "id" not in msg:
        log(f"收到通知: {msg.get('method')}")
        return
    try:
        send(handle(msg))
    except Exception:
        traceback.print_exc(file=sys.stderr)
        send({"jsonrpc": "2.0", "id": msg.get("id"),
              "error": {"code": -32603, "message": "内部错误"}})


def main():
    log("server started")
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
        except json.JSONDecodeError as e:
            send({"jsonrpc": "2.0", "id": None,
                  "error": {"code": -32700, "message": f"解析失败: {e}"}})
            continue
        POOL.submit(dispatch, msg)

    POOL.shutdown(wait=True)
    log("stdin closed, bye")


if __name__ == "__main__":
    main()

用管道就能验证,不需要任何依赖:

import json, subprocess, sys
p = subprocess.Popen([sys.executable, "server.py"], stdin=subprocess.PIPE,
                     stdout=subprocess.PIPE, text=True, bufsize=1)
def rpc(obj):
    p.stdin.write(json.dumps(obj) + "\n"); p.stdin.flush()
rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
     "params": {"protocolVersion": "2024-11-05", "capabilities": {}}})
print(p.stdout.readline())          # 回 {"protocolVersion": "2024-11-05", ...}
rpc({"jsonrpc": "2.0", "method": "notifications/initialized"})
rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
     "params": {"name": "search_text", "arguments": {"path": ".", "keyword": "def "}}})
print(p.stdout.readline())

这段 description 和 inputSchema 不是写给文档的,而是模型每一轮都能看到的 prompt:它直接决定模型传不传 limit、用绝对路径还是相对路径。所以"超出上限会被截断"必须写在描述里,不能藏着;limit 既给 minimum/maximum 又给默认值,前者是给模型看的约束,后者是服务端自己的兜底——客户端的 schema 校验未必真被执行,服务端必须再 clamp 一次,这里是 200。

三、坑:三个真实翻车点

1. stdout 是协议通道,一行 print 就能断链。 我加过一行 print(f"debug: 收到 {method}") 调试,客户端读到的第一行从 JSON 变成了 debug: 收到 tools/list,json.loads 直接抛 Expecting value。更麻烦的是字节流从此错位,后面每条消息都对不上号,现象看起来像"服务端挂死",其实服务端还在正常输出。所以日志一律走 stderr,json.dumps 也绝对不能带 indent——格式化成多行,等于把一条消息劈成几条非法帧。同理,ensure_ascii=False 是可以的,中文原样输出不会引入换行;但要小心别手工拼接字符串,工具返回内容里的 \n 会由 json.dumps 转义成 \\n,这才是单行帧能成立的原因。

2. 工具执行失败不等于协议错误。 目录不存在、缺必填参数,这类问题我第一版返回的是 JSON-RPC error(-32602)。结果客户端把整个调用当协议级失败,模型看不到"不是有效目录: /nope"这句话,也就无法自己改参数重试,会话经常直接卡住。正确分层是:参数和运行时错误放在 result 里,isError: true,错误文本放 content——这段文本会回灌给模型,它下一轮就能自我修正。error 只留给真正的协议问题:-32601 方法不存在、-32602 未知工具名、-32700 解析失败。改完之后实测返回的正文是 参数不合法: 不是有效目录: /nope,这段文本会进入模型下一轮的上下文,它才有机会自己换个路径重试。

3. 并发处理和结果截断。 每个请求丢进线程池,好处是慢查询不堵住读循环,代价是响应乱序——协议允许乱序(客户端按 id 匹配),但 stdout 的写入必须加锁,否则多个线程的 write 交错,一行会被切成两段,客户端解析直接崩。另外我这个版本把 initialize 也丢进了池,靠的是客户端"握手完成才发下一个请求"的时序;要更严格就把握手同步处理完再进池。还有一个更隐蔽的错:notifications/initialized 这类消息没有 id,一定不能回包。我早期图省事统一走 send(handle(msg)),给通知也回了一条 id 为空的 result,客户端收到无法匹配的响应后直接断开,日志里只留下一句"连接关闭"。结果截断则是另一道防线:limit 硬上限 200,超出时在末尾补一句"共 N 条命中,已截断到前 200 条",让模型自己缩小范围,而不是悄悄吞掉。截断不可怕,不告诉模型被截断了才可怕。

四、结论

MCP 的协议层很薄,一个下午能写完;真正花时间的是"边界怎么定义"。给三个可以直接照抄的判断标准:stdout 只写协议帧,日志全走 stderr;工具错误放 isError,协议错误放 error;任何可能很大的返回都设上限,并在文本里显式声明截断。做到这三条,这个服务端挂到 Claude Code、Cherry Studio 这类宿主上基本不会再出"莫名其妙连不上"的问题。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。