CLI 转 MCP:把命令行工具封装成 MCP 服务器
实操指南:把 CLI 工具改造成 MCP 服务器,让 Claude Code 和 Cursor 原生调用。TypeScript + Python 代码、jq 真实示例、踩坑点。
CLI 转 MCP:把命令行工具封装成 MCP 服务器
最后更新:2026-05-26
我花了一个周六,把几年前自己写的一个小 CLI 改成了 MCP 服务器。周日下午 Claude Code 已经像调用原生工具那样调用它了。三个小时的活,从此告别 shell-out 那点开销。
这篇文章是我自己开始时希望手边能有的版本——MCP 到底是什么、能跑通的 TypeScript 和 Python 最小骨架、一个真实的 jq 封装例子,以及那些咬掉我半个早上的坑。
太长不看
- MCP(Model Context Protocol) 是 Anthropic 推出的开放标准,让 AI agent 通过结构化的 JSON-RPC 通道和外部工具对话。规范文档见 modelcontextprotocol.io。
- 把 CLI 转成 MCP 服务器,意味着 agent 调用
mcp.tools.call("jq_query", { filter: ".foo" }),而不是盲目地 shell-out。它能拿到带类型的入参、结构化的错误、可发现性。 - TypeScript 或 Python 最小可用服务器大约 40-60 行代码。把现有命令行工具封装一遍,一个下午足够。
- 主要收益:参数 schema 校验、不再被引号转义折磨、错误对模型可见、agent 不用你教就能列出可用工具。
- 主要坑:stdio buffering、JSON-RPC framing、忘了 stderr 才是写日志的地方——往 stdout 写一个字符就会污染协议流。
MCP 是什么,为什么要把 CLI 转过来
MCP 是基于 JSON-RPC 的协议,跑在 stdio 之上(远程服务器也可以用 HTTP/SSE)。Claude Code 或 Cursor 这类 agent 通过 tools/list 和 tools/call 请求与 MCP 服务器对话,服务器返回结构化 JSON。就这么简单。
具体协议格式可以读 MCP 官方规范,简化版本是:agent 问"你有什么工具",拿到每个工具的 JSON schema,然后做带类型的调用。对比之下,如果让 agent 直接用 shell,它得猜你的参数惯例、猜你的输出格式、猜你的错误约定。十个工具乘起来,光是 shell 这些怪癖就把上下文窗口烧掉一大块。
CLI 是过程式的。你传 argv,你读 stdout,你检查 exit code。对人没问题,对 agent 信息损失严重。(如果你想了解 agent 工具调用从最早的 function call 演进到 MCP 这种带类型协议的来龙去脉,可以看我那篇 function calling 入门。)把同一个 CLI 包成 MCP 服务器就把这些问题一并解决了。agent 拿到的是带类型的契约。
TypeScript 版最小 MCP 服务器
下面是我能写出来还有点实际作用的最小 TypeScript MCP 服务器。它暴露一个工具,跑 git status --porcelain 并返回解析后的输出。
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const server = new Server(
{ name: "git-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "git_status",
description: "Return the working tree status in porcelain format.",
inputSchema: {
type: "object",
properties: {
cwd: { type: "string", description: "Repo directory" },
},
required: ["cwd"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name !== "git_status") {
throw new Error(`Unknown tool: ${req.params.name}`);
}
const cwd = String(req.params.arguments?.cwd ?? "");
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
return {
content: [{ type: "text", text: stdout || "(clean working tree)" }],
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
存为 server.ts,安装 @modelcontextprotocol/sdk,用 tsc 编译,一个能跑的服务器就有了。把 Claude Code 的 config 指向编译产物 node dist/server.js,它就会作为可调用工具出现。
注意这里用的是 execFile 而不是 exec——MCP 工具封装永远别用 exec。exec 会起一个 shell,引号转义那套坑就回来了。execFile 接受数组形式的 argv,这正是你需要的。
Python 版最小 MCP 服务器
同样的思路换成 Python,用官方 SDK。
import asyncio
import subprocess
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
app = Server("git-mcp")
@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="git_status",
description="Return git working tree status in porcelain format.",
inputSchema={
"type": "object",
"properties": {
"cwd": {"type": "string", "description": "Repo directory"},
},
"required": ["cwd"],
},
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "git_status":
raise ValueError(f"Unknown tool: {name}")
cwd = arguments.get("cwd", ".")
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=cwd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return [TextContent(type="text", text=f"git error: {result.stderr.strip()}")]
return [TextContent(type="text", text=result.stdout or "(clean working tree)")]
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
pip install mcp 装上,python server.py 启动。协议一样,语言不同。我自己的选择倾向:要封装的 CLI 在 Python 生态里(比如 kubectl、aws、gh)就用 Python;要封装的是 Node 原生工具就用 TypeScript。
实战封装:jq 这个例子
来走一遍我上个周末为 jq 写的那个服务器。目标是:让 agent 查询 JSON 文件时,不用我每次都给它复述一遍 jq 的 filter 语法。
import asyncio
import subprocess
import json
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
app = Server("jq-mcp")
@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="jq_query",
description=(
"Run a jq filter against JSON input. Returns parsed result. "
"Use this when you need to extract or transform data from a JSON "
"file or string. Filter syntax follows standard jq."
),
inputSchema={
"type": "object",
"properties": {
"filter": {
"type": "string",
"description": "jq filter expression, e.g. '.users[].email'",
},
"input_json": {
"type": "string",
"description": "JSON string to query. Use this OR file_path.",
},
"file_path": {
"type": "string",
"description": "Path to JSON file. Use this OR input_json.",
},
"raw_output": {
"type": "boolean",
"description": "If true, output raw strings without quotes.",
"default": False,
},
},
"required": ["filter"],
},
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "jq_query":
raise ValueError(f"Unknown tool: {name}")
filter_expr = arguments["filter"]
raw_output = arguments.get("raw_output", False)
cmd = ["jq"]
if raw_output:
cmd.append("-r")
cmd.append(filter_expr)
stdin_data = None
if "file_path" in arguments:
cmd.append(arguments["file_path"])
elif "input_json" in arguments:
stdin_data = arguments["input_json"]
else:
return [TextContent(type="text", text="error: provide input_json or file_path")]
try:
result = subprocess.run(
cmd, input=stdin_data, capture_output=True, text=True,
timeout=10, check=False,
)
except subprocess.TimeoutExpired:
return [TextContent(type="text", text="error: jq timed out after 10s")]
if result.returncode != 0:
return [TextContent(type="text", text=f"jq error: {result.stderr.strip()}")]
return [TextContent(type="text", text=result.stdout.rstrip())]
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
接到 Claude Code 之后,我会问类似"从 users.json 里把 role 是 admin 的邮箱全部拿出来",agent 自己写 filter、调 jq_query、把结果给我看。再也不用往上下文里贴 jq 的 man page。
这段代码里有几个细节值得说一下。我加了 10 秒超时,因为某个 filter 在大文件上跑失控会卡死整个 MCP 会话。我给工具写了一段能让模型用对的 description。我把 filter 设为必填,但 input 设成互斥——文件或字符串二选一,不能两个一起。这种 schema 设计能避免模型本来要踩的边界条件。
用 MCP Inspector 测试
MCP Inspector 是一个 Web UI,连到你的服务器之后可以手动发请求。我那个 jq 封装里一半的 bug 都是用 Inspector 抓出来的,赶在让 Claude 接手之前。
安装并运行:
npx @modelcontextprotocol/inspector python server.py
它会跑在 http://localhost:5173。左边那一栏列出你的工具(这就是 tools/list 的返回结果)。点一个工具,在表单里填参数,点 Call,右侧的响应面板里看到的就是 Claude 会看到的内容。
每次我都会检查这四件事:
tools/list是不是带着完整 schema 把所有工具都返回了?- 一个明显不合法的输入,会返回有用的错误字符串,还是直接 crash?
- 超时分支真的能超时,还是服务器会一直挂着?
- 成功调用返回的是文本内容,还是原始字节?
Inspector 里这四项都过,对接 Claude Code 或 Cursor 几乎都是一次就成。
这几个坑咬过我
stdio buffering。这个吃掉我一小时。Python 的 sys.stdout 在终端里默认行缓冲,但 stdout 是管道时变成块缓冲——MCP 客户端启动你的服务器时恰恰就是管道。如果你 print() 一个不是合规 JSON-RPC 消息的东西出去,要么污染流,要么死等 flush。解决办法:自己永远别往 stdout 写,让 SDK 接管协议。要打日志就写到 stderr(print(..., file=sys.stderr)),即使如此也尽量少写——有些客户端会把 stderr 当作工具错误上报。
JSON-RPC framing。SDK 帮你抽象掉了,但还是值得知道:消息按传输方式不同要么带长度前缀、要么按换行分隔。如果你不小心往 stdout 多写了一个 \n,一条消息就被切成两条不合规的。和 stdio buffering 是同一个根因,表现不同。
错误可见性。把 { "error": "something" } 当文本内容返回是错的。agent 会把它当成普通字符串读,可能不知道这是失败。更好的做法是从你的 handler 抛一个正经错误,让 SDK 把响应标记成错误。Python SDK 里你从 @app.call_tool() 里 raise 一下就自动处理了,TypeScript SDK 是 throw。
Shell 注入。这个我在 git 服务器上差点踩中。如果你接收一个 cwd 参数然后传给 exec 而不是 execFile,攻击者(或者一个糊涂的 agent)可以把 && rm -rf 注进路径里。永远用数组形式的 argv(Node 用 execFile,Python 用 subprocess.run(..., shell=False) ——这本来就是默认)。
Tool description 卫生。description 字段是 agent 用来判断该不该调用你工具的依据。写"跑 jq"没用。写"对 JSON 输入跑一个 jq filter,需要从 JSON 里提取或转换数据时用这个",模型才能用对。把 description 当作提示词工程对待,因为它本来就是。
四种方案对比:shell-out / 简单 wrapper / 原生 MCP / 现成 MCP
如果你在纠结到底值不值得把自己的 CLI 转一遍,下面是我自己会查的对比表。
| 方案 | 搭建复杂度 | 对 agent 的体验 | 错误可见性 | 安全性 |
|---|---|---|---|---|
| 裸 shell-out(agent 直接跑 CLI) | 零 | 差——agent 要猜参数、解析 stdout | 埋在 stderr 里 | 风险高——稍不小心就 shell 注入 |
| 轻量 CLI wrapper 脚本 | 低——bash 或 python 脚本 | 还行——参数固定,但解析问题没变 | 还是基于文本 | 校验过输入就好一些 |
| 原生 MCP 服务器(自己写) | 中等——一个下午的活 | 最好——带类型 schema、可列出工具 | 结构化错误 | 坚持用 execFile/argv 就稳 |
| 现成社区 MCP 服务器 | 有的话就是零 | 最好 | 看作者实现 | 看一眼源码再用 |
我的判断规则是:CLI 我每天都用、agent 每次会话都要调好几次,就花时间搭 MCP 服务器;如果是一个月才用一次的小工具,shell-out 就够了。(想了解 agent 在背后到底是怎么处理这些调用的,可以看我的 AI agent 架构概览。)
这套东西在 agent 工具栈里的位置
MCP 只是我那套小型 AI 开发设置里的一块。其他几块也很重要。如果你刚开始用 Claude Code、还在纠结到底用哪个 agentic 编辑器,可以看我那篇 Claude Code vs Cursor 对比,把权衡讲明白。
底层模型方面可以混搭——之前给自己 agent 选 reasoning backend 的时候,我写过 DeepSeek vs GPT 对比。如果你也在控成本,那篇 DeepClaude review 讲了一套大约能把 Claude Code 工作流压到 17 倍便宜的方案。
把 CLI 封装成 MCP 服务器,会把上面这些省下的钱进一步放大。一次带类型的工具调用比"让 agent 在 shell 里摸索"的烧 token 少得多——更少的 token 单次开销、更快的响应、更少的语法幻觉。
下次我会怎么改
过去一个月我写了三个 MCP 服务器。回头看,正确的顺序应该是:最简单的先做,最常用的次之,最复杂的最后。
我反着来了,从一个复杂的(带十六个子命令的内部 CLI)开始,光把 schema 调对就烧了三小时。正确的做法应该是先做个只有一个工具的服务器,比如上面那个 git 例子,先把协议吃进脑子里再去做大的。
还有一件事:我严重低估了写 description 这部分的工作量。代码很短。让 agent 真的把工具用对的那些 description,反而是慢工出细活的部分。这个时间得留出来。
常见问题
CLI 和 MCP 服务器有什么区别?
CLI 是一个程序,你在 shell 里用 argv 启动、从 stdout 读结果。MCP 服务器是常驻进程,通过 stdio(或 HTTP)讲 JSON-RPC、向 AI agent 暴露带类型的工具。你可以把 CLI 包在 MCP 服务器里——这篇文章讲的就是这件事。CLI 干活,MCP 给 agent 提供带类型的契约去调用它。
为什么不让 agent 直接 shell out?
可以这么干。Claude Code 这类 agent 经常这么做。问题是 agent 要猜参数语法、转义引号、解析散乱输出、应付 stderr 里的杂音。MCP 封装给它带类型的 schema、结构化错误、还有告诉它何时调用的 description。少烧 token 在 shell 的怪癖上,多省点给真正的问题。
能直接用现成的 MCP 服务器吗?
可以,而且应该先查一下。社区已经有一批针对常见工具的服务器——文件系统、git、搜索引擎、数据库。官方 MCP servers 仓库 是起点。如果已经有差不多的,用它、给它提 PR。自己工具特殊,或现成服务器缺关键功能,再自己写。
必须用 TypeScript 或 Python 吗?
不是。协议本身和语言无关。官方 SDK 是 TypeScript 和 Python,但只要能读 stdin、写 stdout,任何语言都能实现 JSON-RPC 握手。如果我要把服务器作为一个二进制分发,会用 Go 或 Rust。绝大多数情况下,SDK 省下的样板代码足够多,语言选择就跟着你现有技术栈走就行。
MCP 服务器出问题怎么调?
先用 MCP Inspector。然后 mcp-cli,或者把服务器开起 verbose 日志写到 stderr。如果是 JSON-RPC 那一层的问题,可以写一个小的中继脚本把 stdin/stdout tee 到文件——对抓 framing bug 特别管用。我自己遇到的多数问题,要么是 stdio buffering,要么是 schema 不匹配——后者 Inspector 五秒钟就告诉你了。
MCP 服务器能跑在远程吗?
可以——有 HTTP+SSE 传输。我自己写的本地开发服务器多数走 stdio,因为客户端(Claude Code、Cursor)会把服务器作为子进程拉起。但团队共享工具适合远程 MCP。协议一样,只是传输不同。
MCP 服务器有安全模型吗?
目前的模型是"你启动它,你信任它"。服务器跑在和你编辑器一样的权限下。把 MCP 服务器当成装一个 CLI 工具来对待——看一眼代码、优先选可信源、不要把一个实验性服务器丢到敏感环境里。团队场景下我会把 MCP 服务器集中在一个审过的 repo 里,PR 评审跟 CI 改动一个级别。
怎么判断一个 CLI 值不值得转?
三个问题。每周用得超过一次吗?agent 现在是不是经常把参数搞错?带类型的输入能让你工作流明显变快吗?三个里答两个 yes,就建 MCP 服务器。只有一个 yes,那不如教 agent 一段关于这个 CLI 的笔记,把那个下午省下来。
关于作者
Jim Liu 是悉尼一名独立开发者,自己一个人做小型 SaaS 工具,在 OpenAIToolsHub 上写 AI 开发工具的评测。2023 年开始持续做副业产品,日常的 Claude Code 工作流里大量用 MCP 封装的工具。
SDK API 之后有实质变化我再回来更新。