← 返回专栏
· 2 分钟阅读

60 行 Python 手写 MCP Server 接进 DSH:零依赖实战

想在 DSH(DeepSeek Harness)里用自己的工具,不用装任何 SDK——MCP 的 stdio 传输就是逐行 JSON-RPC。本文用 60 行纯标准库 Python 手写一个能通过完整握手的 MCP server,用管道冒烟测试拿到真实输出,再用一段 cordis.patch.yml 配置把它注册成模型的原生工具。附远程 MCP(streamable-http)接法和四个真实踩坑。

TL;DR

一句话总结 —— MCP server 不是框架,是一份协议:stdio 传输上逐行读写 JSON-RPC 2.0,回答 initialize、tools/list、tools/call 三个方法就够了。本文 60 行纯标准库 Python 从零实现一个,管道冒烟测试全部真实输出,然后一段 YAML 接进 DSH,模型在聊天里直接调用你的工具。远程工具库用 streamable-http,同一格式的 url 字段换掉 command。

最近一个月,我们在 Bing 的后台看到一小簇很有意思的中文搜索:dsh添加mcp、dsh mcp配置、deepseek harness 接入自定义mcp 教程。它们全部落在我们八月那篇 DSH 安装指南上——但那篇讲的是把现成的 MCP server 接进去。这些查询问的其实是下一层:我自己的脚本,怎么变成 DSH 能调用的工具?

答案比想象中便宜。MCP server 不是一个框架,是一份协议,而协议的核心就是进程的 stdin/stdout。不需要官方 SDK,不需要 pip 装任何东西。

先把 MCP 拆到最小

stdio 传输下,客户端(DSH 的 @deepseek-ai/dsh-mcp-client 插件)spawn 你的进程,然后两边按行交换 JSON-RPC 2.0 消息。一次完整会话只有四种流量:

  • initialize 请求 → 你回协议版本、能力声明、server 信息
  • notifications/initialized 通知 → 不回复(JSON-RPC 通知没有 id,也不允许有响应)
  • tools/list 请求 → 你回工具清单,每个工具带名字、描述、参数 JSON Schema
  • tools/call 请求 → 你拿到工具名和参数,执行,回一段 content

就这些。读懂这一层之后,“写一个 MCP server” 退化成 “写一个循环读行、按 method 分发的程序”。

60 行实现

下面是我们在本机跑通的最小版本,纯标准库,带一个演示工具 char_count:

#!/usr/bin/env python3
"""Minimal MCP stdio server - zero dependencies, pure stdlib."""
import json, sys

TOOLS = [{
    "name": "char_count",
    "description": "Count characters in a text string",
    "inputSchema": {"type": "object",
                    "properties": {"text": {"type": "string"}},
                    "required": ["text"]},
}]

def send(msg):
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    msg = json.loads(line)
    if "id" not in msg:  # notification, no response
        continue
    m = msg.get("method")
    if m == "initialize":
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {
            "protocolVersion": msg["params"].get("protocolVersion", "2025-06-18"),
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "mini-mcp", "version": "0.1.0"}}})
    elif m == "tools/list":
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {"tools": TOOLS}})
    elif m == "tools/call":
        text = msg["params"]["arguments"]["text"]
        send({"jsonrpc": "2.0", "id": msg["id"], "result": {
            "content": [{"type": "text", "text": str(len(text))}],
            "isError": False}})
    else:
        send({"jsonrpc": "2.0", "id": msg["id"],
              "error": {"code": -32601, "message": f"method not found: {m}"}})

三个实现细节值得停一下:

  • send() 每次都 flush。 stdout 是块缓冲的,不刷出去,客户端这边就是无限等待。这是手写 stdio 服务最常见的静默死法。
  • 通知直接 continue。 回复一个没有 id 的消息不是礼貌,是违约。
  • protocolVersion 原样回显客户端发来的版本。 握手是版本协商,客户端说什么你认什么,别自己发明。

冒烟测试:不启动 DSH 也能验证

MCP 是文本协议,意味着你可以用一根管道测完全程。把四条消息按顺序灌进去:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"handtest","version":"0"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
 '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"char_count","arguments":{"text":"hello mcp"}}}' \
 | python3 server.py

我们机器上的真实输出:

{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "mini-mcp", "version": "0.1.0"}}}
{"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "char_count", "description": "Count characters in a text string", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}]}}
{"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "9"}], "isError": false}}

三行响应:握手通过、工具声明通过、调用返回 "9"。到这里,你的 server 已经是一个合法的 MCP server 了——对任何客户端都成立,不止 DSH。

接进 DSH:一段 YAML

编辑 ~/.dsh/profiles/web/cordis.patch.yml,加一个 insert 条目:

- insert:
    - id: mcp-mini
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: mini
        transport: stdio
        command: python3
        args: ['/path/to/server.py']
        toolCallTimeoutMs: 60000

保存即生效——DSH 的 HMR 会断开重连,工具立刻出现在会话里,名字是 mcp__mini__char_count,和 Claude Code 的 mcp__<server>__<tool> 命名约定完全一致。

不确定自己的层叠配置对不对时,不用真开 web UI,--dump-config 会打印合成后的 profile 树。下面是我们在本机带 patch 跑 dsh --profile headless --patch ./patch.yml --dump-config 的真实输出(截取):

# == /tmp/mini_mcp/patch.yml
- id: mcp-mini
  name: '@deepseek-ai/dsh-mcp-client'
  config:
    serverName: mini
    transport: stdio
    command: python3
    args:
      - /tmp/mini_mcp/server.py
    toolCallTimeoutMs: 60000

注意输出里 patch 文件是按注释分层的:基础 bundle、用户层、--patch 追加层依次覆盖。你的 MCP 配置永远活在最上层,升级 DSH 不会动它。

远程工具库:streamable-http

工具不在本机时,transport 换成 streamable-http,command/args 换成一个 url。我们自己的 profile 里就同时挂着两种:

- insert:
    - id: mcp-remote-hub
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: hub
        transport: streamable-http
        url: https://mcp.jask.dev/mcp

本地 stdio 适合自有脚本和内网数据,远程 http 适合团队共享的工具库,两种在同一份 patch 里并存,各占一个条目。

四个真实踩坑

  • 默认超时 60 秒。 批量工具(审计、爬取)经常超,按需调大 toolCallTimeoutMs——它是单次调用超时,不影响会话。
  • stdio 子进程的 cwd 不继承你的终端。 server 里解析相对路径(.env、数据缓存)会莫名找不到文件,要么在 config 里写 cwd,要么在代码里全部用绝对路径。
  • MISSING_CREDENTIAL 与 MCP 无关。 这个报错说的是模型 API key(~/.dsh/settings.yaml,web UI 的 Models 页写),别在 MCP 配置里排查。
  • 别在 server 里 print 调试信息。 stdout 是协议通道,任何多余输出都会污染 JSON 流,调试一律走 stderr。

从演示到生产

把 char_count 换成你的真实工具,就是生产形态。我们的开源 SEO 工具包用同一机制注册了 22 个工具——keyword_research、kd、geo_score 等,纯标准库实现,一个下午就能接完:仓库在 GitHub,python3 -m zens_ink.mcp 直接起 server。

DSH 本身的安装、web UI 和 profile 体系见上一篇 DSH 实战;完整工具文档在 docs。如果你在搭自己的 agent 工具链,也可以看看我们怎么用免费额度抓 SERP 数据——那套脚本现在就是被 MCP 包着跑的。

FAQ

不用官方 SDK 能写 MCP server 吗?

能。MCP 的 stdio 传输就是进程的 stdin/stdout 上按行分隔的 JSON-RPC 2.0 消息。server 只需要正确回答三个请求方法——initialize(握手并回协议版本)、tools/list(声明工具和参数 schema)、tools/call(执行并返回 content),对没有 id 的通知消息不回复。用 Python 标准库的 json 和 sys 就能写完,60 行以内。

DSH 里怎么添加自己的 MCP server?

编辑 ~/.dsh/profiles/web/cordis.patch.yml,插入一个 name 为 @deepseek-ai/dsh-mcp-client 的条目,config 里写 serverName、transport: stdio、command、args 和 toolCallTimeoutMs。保存即生效,HMR 热重载不用重启。远程 server 把 transport 换成 streamable-http 并改用 url 字段。

MCP server 启动后一直挂着没反应,是哪里错了?

先检查握手:initialize 请求必须返回带 protocolVersion 的 result,且收到 notifications/initialized 这类没有 id 字段的通知时不要回复——JSON-RPC 规定通知没有响应,最小实现里直接跳过。其次确认输出是逐行 flush 的,缓冲不刷出去客户端永远等不到消息。最后看 toolCallTimeoutMs,默认 60 秒,批量工具跑超了会被掐断。

接远程 MCP 工具库还要写 server 吗?

不用。transport 写 streamable-http,把 command/args 换成一个 url 字段指向远程端点即可,工具注册逻辑完全一样。本地 stdio 适合自有脚本和内网数据,远程 http 适合团队共享的工具库——同一个 DSH profile 里两种可以并存,各占一个 insert 条目。

想给自己的站点做同样的分析?

ZensInk Pro 把这个流程自动化了。一条命令,从种子词到内容计划。

查看 Pro →