早上六点,csdn-auto 的发布脚本跑完,history.json 里多了一行记录。我打开 AI 客户端,让它看看当天那篇文章:标题是不是在 20 到 45 个字符之间,tags 够不够 4 个,正文有没有到 2000 个汉字。它读完文件,回我一句“标题看起来偏长,字数应该差不多”。这种回答没法用——我要的是能直接决定“发还是改”的结论,不是感觉。
问题不在模型笨。字符数、标签个数、代码块是否成对,这些是确定性规则;让语言模型用眼睛去数,本来就是在用它的短板。我把这套检查逻辑写进脚本,再让脚本以工具的形式暴露给客户端,模型只负责决定“查哪一篇”和解释结果,判断标准始终落在代码里。这就是 MCP(Model Context Protocol)要解决的问题:客户端不必为每个本地能力写死集成,服务端按统一协议描述自己有哪些工具。
正好这几天在翻 MCP 的新规范。2026-07-28 那版把远程 Streamable HTTP 往无状态方向推,请求要带 Mcp-Method 和 Mcp-Name 两个头,方便网关按方法和工具名做路由、限流(来源:blog.modelcontextprotocol.io)。这是给多客户端、可横向扩容的远程服务准备的。我这个场景恰好相反:工具只读本机一个文章目录,客户端就在同一台电脑上,没有第二个用户,也没有跨机器调用的需求。所以我没有跟风写 HTTP 端点,而是用了最朴素的 stdio 传输。
一、先定边界:只做两个只读工具
项目想要小,第一件事是砍功能。我最后只留了两个工具:
list_articles:列出文章目录下的 Markdown 文件和标题,方便客户端先知道有哪些文章;check_article:传入文件名,返回标题长度、tags 数量、正文汉字数,以及代码块、表格、文末引用块是否齐全。
我没有给它加“修改文章”或“发布文章”的权限。原因很实际:MCP 客户端会自己决定什么时候调用工具,参数也由模型生成。只读工具最坏的结果是读错文件,写工具最坏的结果是改坏一篇已经排好版的稿子。检查类需求用只读就能闭环,没必要放大权限。
目录结构也很短,没有框架、没有依赖:
article-mcp/
├─ server.py # 全部逻辑,约 150 行
└─ articles/ # 被检查的 Markdown 目录(只读)
server.py 只使用 json、os、re、sys、pathlib 这些标准库,Windows 上装好 Python 就能跑,不需要 pip install 任何东西。对每天定时执行的小工具来说,少一个依赖就少一种半夜挂掉的可能。
二、协议最小闭环:一行一个 JSON
MCP 的消息层是 JSON-RPC 2.0。走 stdio 时,客户端把服务端作为子进程拉起,然后往它的标准输入里一行一行写 JSON;服务端把回复写到标准输出,同样一行一个。这里有一条硬规则:stdout 只能出现协议消息。调试日志、启动信息一律走 stderr,否则客户端解析到一半遇到一行中文,整个会话就断了。
握手只需要三步。客户端先发 initialize,服务端回协议版本、能力和自己的名字;客户端再发一条 notifications/initialized 通知,这条没有 id,不需要回复;之后就能 tools/list 和 tools/call。下面是我写的分发骨架:
import json
import os
import re
import sys
from pathlib import Path
ROOT = Path(os.environ.get("ARTICLES_DIR", "./articles")).resolve()
PROTOCOL_FALLBACK = "2025-06-18"
def log(*args):
print(*args, file=sys.stderr, flush=True)
def reply(mid, result):
return {"jsonrpc": "2.0", "id": mid, "result": result}
TOOLS = [
{
"name": "list_articles",
"description": "列出文章目录下的 Markdown 文件及其标题",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "check_article",
"description": "检查标题长度、tags 数量、正文汉字数、代码块、表格和文末引用块",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "文章文件名,例如 2026-11-08-xxx.md"}
},
"required": ["name"],
},
},
]
def handle(msg):
method = msg.get("method")
mid = msg.get("id")
params = msg.get("params") or {}
if mid is None: # 通知没有 id,直接忽略
return None
if method == "initialize":
return reply(mid, {
"protocolVersion": params.get("protocolVersion", PROTOCOL_FALLBACK),
"capabilities": {"tools": {}},
"serverInfo": {"name": "article-mcp", "version": "0.1.0"},
})
if method == "ping":
return reply(mid, {})
if method == "tools/list":
return reply(mid, {"tools": TOOLS})
if method == "tools/call":
return reply(mid, call_tool(params.get("name"), params.get("arguments") or {}))
return {"jsonrpc": "2.0", "id": mid,
"error": {"code": -32601, "message": f"未知方法 {method}"}}
def main():
log(f"article-mcp 已启动,只读目录:{ROOT}")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
out = handle(json.loads(line))
except Exception as exc:
out = {"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": str(exc)}}
if out:
print(json.dumps(out, ensure_ascii=False), flush=True)
协议版本用日期表示。我这里的处理是回显客户端发来的版本,客户端没给才退回一个写死的日期版本。对本机自用来说这样够用;如果要对外提供,应该按规范维护一份支持列表,遇到不认识的版本明确拒绝,而不是含糊地接受。
三、把“检查文章”写成工具
检查逻辑分两步:先拆 front matter,再看正文。文章用的是很简单的 --- 包围的键值对,没有必要为此引入 PyYAML;手写二十行解析,反而更容易控制 tags 这种以方括号表示的数组。
有个细节值得单独说:工具报错和协议报错要分开。文件名不存在、路径越界,这类是工具执行失败,应该作为正常结果返回,把 isError 置为 true,让模型看到原因后自己纠正;只有 JSON 解析失败、未知方法这类消息层问题,才用 JSON-RPC 的 error 字段。如果工具一失败就让进程退出,客户端下一次调用就找不到服务端了。
CJK = re.compile(r"[\u4e00-\u9fff]")
FOOTER = "> 个人博客:https://www.sakura.work.gd/ | GitHub:https://github.com/sakurajh"
def parse_front_matter(text):
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
meta = {}
for line in parts[1].strip().splitlines():
key, sep, value = line.partition(":")
if not sep:
continue
value = value.strip()
if value.startswith("[") and value.endswith("]"):
value = [v.strip().strip("\"'") for v in value[1:-1].split(",") if v.strip()]
else:
value = value.strip("\"'")
meta[key.strip()] = value
return meta, parts[2]
def safe_path(name):
path = (ROOT / name).resolve()
if ROOT not in path.parents:
raise ValueError("只允许访问 ARTICLES_DIR 下的文件")
return path
def check_article(name):
path = safe_path(name)
meta, body = parse_front_matter(path.read_text(encoding="utf-8"))
problems = []
title = meta.get("title", "")
if not 20 <= len(title) <= 45:
problems.append(f"标题 {len(title)} 个字符,应在 20–45 之间")
tags = meta.get("tags") or []
if isinstance(tags, str):
tags = [t for t in re.split(r"[,,\s]+", tags) if t]
if not 4 <= len(tags) <= 6:
problems.append(f"tags 有 {len(tags)} 个,应为 4–6 个")
for key in ("summary", "slug", "category"):
if not meta.get(key):
problems.append(f"front matter 缺少 {key}")
cjk = len(CJK.findall(body))
if cjk < 2000:
problems.append(f"正文汉字 {cjk} 个,少于 2000")
if body.count(chr(96) * 3) < 2:
problems.append("没有成对的代码块")
if not re.search(r"^\|[\s:|-]+\|\s*$", body, re.M):
problems.append("没有表格分隔行")
if FOOTER not in body:
problems.append("缺少文末引用块")
report = f"{path.name}:正文 {cjk} 个汉字"
return report + "\n" + ("\n".join("- " + p for p in problems) if problems else "检查通过")
def call_tool(name, args):
try:
if name == "list_articles":
text = "\n".join(
f"{p.name}\t{parse_front_matter(p.read_text(encoding='utf-8'))[0].get('title', '(无标题)')}"
for p in sorted(ROOT.glob("*.md"), reverse=True)
) or "目录下没有 Markdown 文件"
elif name == "check_article":
text = check_article(args.get("name", ""))
else:
raise ValueError(f"未知工具 {name}")
return {"content": [{"type": "text", "text": text}], "isError": False}
except Exception as exc:
return {"content": [{"type": "text", "text": str(exc)}], "isError": True}
if __name__ == "__main__":
main()
safe_path 那几行不是形式主义。模型生成的参数不可控,如果直接把名字拼进路径,一个 ../ 就能读到文章目录之外的文件。先 resolve() 再判断父目录,是把“能读什么”固定在启动时配置的 ARTICLES_DIR 里,而不是寄希望于模型每次都传对。
四、接进客户端与一次冒烟测试
客户端配置因软件而异,但核心都是“命令 + 参数 + 环境变量”。以常见的 mcpServers 写法为例:
{
"mcpServers": {
"article-check": {
"command": "python",
"args": ["F:/tools/article-mcp/server.py"],
"env": {
"ARTICLES_DIR": "F:/tools/article-mcp/articles"
}
}
}
}
这里有个 Windows 上很容易踩的坑:从图形界面启动的客户端,环境变量不一定和你在终端里一样,python 可能根本不在 PATH 里。稳妥的写法是把 command 换成 Python 的绝对路径。另一个坑是工作目录——服务端用相对路径找 articles 时,取决于客户端从哪里拉起进程,所以我把目录放进 ARTICLES_DIR,启动时就 resolve() 成绝对路径。
不装客户端也能验证。在项目目录里用管道喂三条消息,就能看到完整往返:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"check_article","arguments":{"name":"2026-11-08-mcp-stdio-article-check-server.md"}}}' \
| python server.py
我拿当天的这篇文章跑了一遍,返回的是:
2026-11-08-mcp-stdio-article-check-server.md:正文 2284 个汉字
检查通过
顺手又试了两个坏输入:参数传 ../server.py,返回 isError: true 和“只允许访问 ARTICLES_DIR 下的文件”;方法名写成 nope,拿到的则是 JSON-RPC 的 -32601。这两条走的不是同一条路——工具出错,会话还在,模型能看到原因;协议层出错,则是另一回事。
写这篇时还遇到一个小插曲:检查代码里本来要统计三个反引号的数量,结果这三个反引号把 Markdown 代码块提前截断了,后面的内容全跑到块外。最后改成 chr(96) * 3 才正常。一个检查格式的工具,自己先被格式坑了一次,也算合理。静态检查只能看“有没有”,看不出渲染出来是什么样,所以它输出的是问题清单,不是最终裁决,最后一眼还是要人看。
五、stdio 还是 Streamable HTTP
写完之后我又回头看了眼规范里的那条变化。把两者放在一起对比,选择就很清楚了:
| 维度 | stdio | Streamable HTTP |
|---|---|---|
| 连接方式 | 客户端拉起子进程,管道通信 | 独立 HTTP 端点 |
| 鉴权 | 沿用操作系统用户权限 | 需要额外做认证与授权 |
| 状态 | 进程内状态天然可用 | 新版规范强调无状态,跨请求状态要外置 |
| 适用场景 | 本机文件、命令、个人工具 | 多客户端共享、远程部署 |
| 调试 | 管道喂 JSON,看 stderr | 网关日志、抓包 |
2026-07-28 版规范要求远程请求带 Mcp-Method 和 Mcp-Name,本质上是让网关不解析 JSON 体也能知道“这是哪个方法、调的哪个工具”。这对平台方是好事,但它服务的是规模化部署。我的工具只读一个本机目录,没有多租户,也没有横向扩容的问题,硬上 HTTP 只会多出端口、鉴权和 CORS 三件事。
更重要的是一层设计上的分离:工具逻辑(解析 front matter、数汉字、查引用块)和传输方式(stdio 还是 HTTP)没有耦合。哪天真的需要让另一台机器上的客户端调用,我只需要把 handle() 外面换一层 HTTP 入口,check_article 一行都不用改。先按最小场景把逻辑跑通,再谈传输,这个顺序比一开始就搭一个“通用平台”靠谱得多。
六、小结
这个项目最后大约一百五十行,没有依赖,启动方式是让客户端拉起一个 Python 进程。它解决的不是什么大问题:让我在发布前用一句话拿到确定性的检查结果,而不是听模型说“应该差不多”。但它把两件事分清楚了——模型负责理解和调度,代码负责规则和判断。
如果继续加,我会先加一个读取 history.json 的只读工具,用来回答“今天发过没有”,仍然不碰写操作。权限边界守住了,工具多一点也不会失控。
个人博客:https://www.sakura.work.gd/ | GitHub:https://github.com/sakurajh
评论与讨论
使用 GitHub 账号登录即可留言(首次使用需在 giscus 中授权)