给 Agent 接一个工具,今天是道乘法题:4 个宿主 × 6 个内部系统 = 24 份适配代码,每份都要单独维护参数 schema、单独处理鉴权和超时。MCP(Model Context Protocol)把这道乘法改成加法——每个系统写一次 Server,每个宿主实现一次 Client,中间那层乘积消失。
下面不铺概念,直接从一个"查服务器状态"的小工具开始,把角色、原语、传输、注册、调试和几个必踩的坑一次走完。协议基线为 2024-11-05,涉及远程传输处标注 2025-03-26 起的修订。
1. N×M 是怎么变成 N+M 的
| 做法 | 集成成本 | 换宿主时 | 换模型厂商时 |
|---|---|---|---|
| 每个宿主内嵌自家 function calling | N×M | 全部重写 | 参数 schema 重写 |
| 内部 HTTP API + 每家一个适配层 | 仍是 N×M | 适配层重写 | 适配层重写 |
| MCP Server + 宿主自带 Client | N+M | 只改一行注册配置 | 协议不变 |
MCP 没有发明新能力,它固定的是"描述与调用"的形状:tools/list 返回的 inputSchema 是标准 JSON Schema,任何宿主都能把它渲染成自家的 function 定义;tools/call 返回的 content 是纯文本或图片块,任何宿主都能塞回上下文。形状固定了,乘积就塌成了和。
2. 三个角色:Host、Client、Server
| 角色 | 是什么 | 谁来实现 | 典型例子 |
|---|---|---|---|
| Host | 用户面对的那个 AI 应用,管模型、管上下文、管权限 | 应用厂商 | Claude Desktop、Codex、IDE 插件 |
| Client | Host 内部与单个 Server 的 1:1 连接器 | 宿主随 MCP SDK 实现 | 每个 server 对应一个 client 实例 |
| Server | 暴露 tools / resources / prompts 的独立进程 | 你 | 一个 Python 或 TS 服务 |
关键在 1:1:一个 Agent 接 10 个 MCP 服务 = 10 个 Client 连接,不是 10 个 Host。所以工具名冲突、鉴权隔离、超时都是每条连接各自的事,不是全局的事。宿主要给用户展示"这个工具来自哪个 server",靠的就是这个一对一的边界。
3. 三种能力原语,加一种反向请求
| 原语 | 谁发起 | 谁决定用不用 | 典型用途 | 方法 |
|---|---|---|---|---|
| tools | Server 声明,模型调用 | 模型 | 有副作用的动作:查库、发消息、执行命令 | tools/list、tools/call |
| resources | Server 声明,应用读取 | 人 / 应用 | 只读数据:文件、表结构、日志片段 | resources/list、resources/read |
| prompts | Server 声明,用户选用 | 人 | 预置模板、工作流入口 | prompts/list、prompts/get |
| sampling | Server 反向请求 | 宿主 | Server 借宿主的模型做一次生成 | sampling/createMessage |
一句口诀:要模型自己决定"做不做"→ tools;要人决定"看不看"→ resources;要人点一下"跑不跑"→ prompts。
sampling 的方向是倒的,这点最容易被忽略:Server 通常不持有模型凭证,需要生成时向 Client 发 sampling/createMessage,由宿主决定用哪个模型、要不要弹人工确认。它是"服务端无密钥"设计的支点——你的 MCP 服务可以不碰任何 API key 就完成摘要、改写、分类。
4. 传输:stdio 与 Streamable HTTP
| 传输 | 起始版本 | 形态 | 适用 |
|---|---|---|---|
| stdio | 2024-11-05 | 宿主 spawn 子进程,stdin/stdout 走换行分隔 JSON,日志走 stderr | 本机工具、单用户 |
| HTTP+SSE(两段式) | 2024-11-05 | 先 GET /sse 拿 endpoint,再 POST 消息 |
已被取代,只为兼容老客户端保留 |
| Streamable HTTP | 2025-03-26 | 单端点 POST/GET,需要时升级为 SSE 流 |
远程、多租户、需鉴权 |
stdio 的硬约束只有一条,但违反它的代价最大:stdout 上只能出现协议消息。你在工具里写一句 print("debug"),宿主就收到一行不合法的 JSON-RPC,轻则 parse error,重则整条会话断掉,而错误日志不会告诉你是哪一行 print 干的。
mcp.run() # 默认 stdio
mcp.run(transport="streamable-http") # 端点 /mcp,见第二篇
5. 最小骨架:FastMCP + 一个 @mcp.tool()
import shutil
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ops-tools")
@mcp.tool()
def disk_usage(path: str = "/") -> str:
"""返回指定挂载点的磁盘使用率。path 必须是已挂载的绝对路径。"""
st = shutil.disk_usage(path)
return f"{path} used={st.used / st.total:.1%} free={st.free >> 20}MB"
if __name__ == "__main__":
mcp.run()
FastMCP 从函数签名生成 inputSchema,从 docstring 生成 description。docstring 第一行就是模型看到的工具描述——它是给模型读的 prompt,不是给人读的注释。"返回指定挂载点的磁盘使用率"比 "get disk usage" 命中率高得多,因为前者写清了输入约束和输出形状。
生成的 tools/list 响应大致长这样(典型响应,非实测输出):
{
"jsonrpc": "2.0", "id": 1,
"result": {"tools": [{
"name": "disk_usage",
"description": "返回指定挂载点的磁盘使用率。path 必须是已挂载的绝对路径。",
"inputSchema": {
"type": "object",
"properties": {"path": {"type": "string", "default": "/"}},
"required": []
}
}]}
}
参数要更严的约束就显式挂 Field,描述里直接写枚举——不少宿主并不渲染 enum 的下拉框,模型只能读描述:
from pydantic import Field
@mcp.tool()
def restart_service(
name: str = Field(description="服务名,取值只能是 web、api、worker 之一"),
dry_run: bool = Field(default=True, description="true 只打印将执行的命令,不真重启"),
) -> str:
"""重启一个后端服务。默认 dry_run,确认无误后再传 false。"""
...
6. 注册到 Claude Desktop 与 Codex
Claude Desktop 用 JSON,路径 macOS ~/Library/Application Support/Claude/claude_desktop_config.json,Windows %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"ops-tools": {
"command": "/opt/venv/bin/python",
"args": ["/opt/mcp/ops_server.py"],
"env": {"OPS_ENV": "prod"}
}
}
}
Codex 用 TOML,路径 ~/.codex/config.toml:
[mcp_servers.ops-tools]
command = "/opt/venv/bin/python"
args = ["/opt/mcp/ops_server.py"]
[mcp_servers.ops-tools.env]
OPS_ENV = "prod"
| 宿主 | 配置文件 | 格式 | 远程 server |
|---|---|---|---|
| Claude Desktop | claude_desktop_config.json |
JSON | "url": "https://host/mcp" |
| Codex CLI | ~/.codex/config.toml |
TOML | 同结构加 url 键 |
| 自研宿主 | 自定义 | — | SDK 的 Client + streamablehttp_client() |
两个细节:command 必须是宿主进程 PATH 里能找到的可执行文件,虚拟环境里请写绝对路径,不要写 python;改完配置要完全退出宿主再重启,关窗口不算,很多宿主只在启动时读一次配置。
7. 调试:Inspector 与手工喂 stdin
npx @modelcontextprotocol/inspector /opt/venv/bin/python /opt/mcp/ops_server.py
它起一个本地 Web 界面,列出 tools/resources/prompts,能直接填参数调用并查看原始 JSON-RPC 报文。协议层的问题在 Inspector 里十分钟能定位,在宿主里只能看到一句"工具调用失败"。
不起 GUI 时手工喂三条消息就够验证握手和列表:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | /opt/venv/bin/python /opt/mcp/ops_server.py
顺序不能颠倒:initialize → notifications/initialized → 业务方法。少了那行通知,部分实现会拒绝对 tools/list 作答。
8. 排错:六种"看起来像玄学"的失败
| 现象 | 真正原因 | 处理 |
|---|---|---|
| 宿主提示 server 启动失败 | command 不在 PATH / 用了相对路径 |
写绝对路径,先在终端手动跑一遍 |
| 启动成功但工具列表是空的 | stdout 被日志污染,解析器读断 | 所有输出改 print(..., file=sys.stderr) 或 logging 到 stderr |
| 工具在列表里,模型从不调用 | description 太泛("处理数据") | 描述写清"何时用、参数含义、返回什么" |
| 调用卡住,无任何响应 | 工具内部阻塞且没有超时 | 工具内设超时,超时返回错误文本而不是抛异常 |
| 中文参数变乱码 | 未声明 UTF-8 | 环境变量 PYTHONIOENCODING=utf-8;自实现按行读要 encoding="utf-8" |
| 第一次调用必超时 | 冷启动 + 顶层 import 重依赖 | 预热进程,把重依赖移出模块顶层 |
第一条和第三条是 90% 的新手事故。尤其第三条:模型不调工具,八成不是模型笨,是描述没写清"这个工具解决什么问题"。
9. 边界:什么时候不该上 MCP
| 场景 | 选择 | 理由 |
|---|---|---|
| 一次性脚本里的单次调用 | 直接 function calling | 起进程 + 握手的固定开销不划算 |
| 同一批工具要给多个宿主复用 | MCP | 这正是 N+M 的场景 |
| 模型需要读大文件 | 用 resources,不要让 tool 返回全文 | resource 可按需分段读、可被宿主缓存 |
| 强低延迟的同步链路 | 谨慎 | stdio 换行解析、HTTP 跳转都有固定成本 |
| 调用前必须人工审批 | 宿主侧确认 / prompts | 协议本身不做审批,别指望 server 拦 |
MCP 是连接层标准,不是 Agent 框架。它不规定 planner、不规定记忆、不规定多 Agent 协作——那些是 LangGraph、CrewAI 的地盘。把 MCP 当框架用,只会得到一堆没有编排的服务。
10. 自测
- N×M 里的 N 和 M 分别指什么,MCP 靠什么机制把它压成 N+M?
- 一个 Host 接 10 个 MCP 服务时,Client 实例有几个?
- "删除一条工单"应该做成 tools 还是 resources,判断依据是什么?
- sampling 为什么是 Server 发起而不是 Client 发起?
- Inspector 一切正常、宿主却报 parse error,最先怀疑什么?
- 工具 description 的读者是谁,它应该包含哪三类信息?