MCP 工具开发最佳实践(结合 weather-mcp)
MCP 工具开发最佳实践(结合 weather-mcp)
写给 MCP Server 的开发者。本文不重复 Model Context Protocol 官方文档 的协议层定义,而是从工程落地角度总结常见踩坑点。
目录
- 1. 选 stdio 还是 streamable HTTP?
- 2. 鉴权设计
- 3. 配置管理
- 4. 工具描述决定 LLM 能否用好
- 5. 入参校验越早越好
- 6. 错误处理:抛错 vs 返回文本
- 7. HTTP 客户端配置
- 8. 日志:只在 stderr
- 9. 性能与缓存
- 10. 协议版本协商
- 11. 编写本地 smoke 测试
- 12. 打包与分发
- 13. 给 MCP 客户端作者的建议
- 14. 反模式清单
1. 选 stdio 还是 streamable HTTP?
| 维度 | stdio | streamable HTTP |
|---|---|---|
| 部署模型 | 本地子进程,客户端拉起 | 远程 HTTP 服务,可独立部署 |
| 适用场景 | 个人/桌面客户端,本机私钥访问 | 团队/生产,多用户共享 |
| 鉴权传递 | 通过进程 env 自然隔离 | 需要 OAuth/Bearer/HMAC |
| 调试 | 直接 python -u -m xxx |
curl / Postman |
| weather-mcp 选择 | ✅ stdio |
weather-mcp 的取舍:
- 私钥就在本机,没必要走远端
- 用户量小(单人 + LLM 客户端),HTTP 服务化收益不抵复杂度
- Claude Desktop / Trae / Cursor 默认就是 stdio 配置
什么时候改用 HTTP:你要给团队所有人共享一套 weather 工具,且不希望每台机器都配 Ed25519 私钥;这时把 server 跑成 HTTP 服务,用 OAuth 鉴权,每个客户端只配一个 token。
2. 鉴权设计
三大原则
- 优先支持推荐的鉴权方式。和风天气 2027-01-01 起限制 API KEY,本项目把 JWT 设为默认模式。
- 密钥永远不进代码、不进 README。见 3. 配置管理。
- 支持"可降级":上游 API 提供多套鉴权时,最好两种都支持,通过 env 切换。
weather-mcp 的实现
src/weather_mcp/auth.py 封装了一个 QWeatherAuth 类:
class QWeatherAuth:
def headers(self) -> Mapping[str, str]:
if self._cfg.auth_mode == "api_key":
return {"X-QW-Api-Key": self._cfg.api_key}
return {"Authorization": f"Bearer {self._get_jwt()}"}
JWT 缓存(TTL 前 30 秒自动续签),避免每个请求都重新做 Ed25519 签名:
def _get_jwt(self) -> str:
self._ensure_keys()
now = time.time()
with self._lock: # 多线程 MCP 调用也安全
if self._cached_token and now < self._cached_exp - 30:
return self._cached_token
token, exp = self._sign_jwt(now)
self._cached_token = token
self._cached_exp = exp
return token
关键细节:
- ✅
_lock防止并发签名(虽然 Ed25519 签名很快,但 MCP 客户端可能并行调用) - ✅ TTL - 30s 提前续签,避免临界过期
- ✅
iat = now - 30(和风官方建议,防客户端/服务端时钟误差) - ✅
exp - iat最大 86400 秒 - ❌ 不要把私钥写到日志里(即使 base64 也不行)
3. 配置管理
三层优先级
MCP 客户端 mcpServers.env (最高 — 客户端显式注入)
↓ 继承
操作系统 shell env (中 — 用户自己 export)
↓ 继承 / 合并
.env 文件 (override=False) (最低 — 项目本地配置)
weather-mcp 的 server.py:
from dotenv import load_dotenv
_ENV = Path(__file__).resolve().parents[2] / ".env"
if _ENV.is_file():
load_dotenv(_ENV, override=False)
配置模板分离
| 文件 | 用途 | 提交到 git |
|---|---|---|
.env.example |
占位符模板 | ✅ |
.env |
本地真实配置 | ❌ |
mcp-config.example.json |
MCP 客户端占位符模板 | ✅ |
mcp-config.local.json |
MCP 客户端真实配置 | ❌ |
.gitignore 必备
.env
.env.local
*.pem
*.key
mcp-config.local.json
claude_desktop_config.local.json
*.mcp.local.json
设计要点
- 不要让代码假设特定环境变量名——给每个变量定义一个带前缀的名字(
QWEATHER_KID而不是KID),便于多 MCP 并存。 - 启动时校验必填项,失败立刻
exit 1并把错误打到 stderr:
def main() -> None:
try:
config = load_config()
except ValueError as e:
print(f"[weather-mcp] 配置错误:{e}", file=sys.stderr)
raise SystemExit(1)
...
客户端看到 exit code 1 会显示"启动失败",避免 server 进了 MCP 握手循环后才报错。
4. 工具描述决定 LLM 能否用好
LLM 是看 description + inputSchema 决定何时调哪个工具的。写得越清楚,LLM 用得越准。
weather-mcp 的 lookup_weather
@mcp.tool()
def lookup_weather(
location: str,
mode: str = "city",
number: int = 10,
lang: str | None = None,
) -> str:
"""根据城市名或经纬度查询城市信息。
Args:
location: 城市关键字或 "lon,lat"。
mode: "city"(默认)或 "coord"。
number: 返回结果数量,默认 10。
lang: "zh" 或 "en",默认取全局配置。
"""
升级到 mcp 2.x 原生 API 后,描述放进 _TOOL_DEFS 集中管理:
_TOOL_DEFS: list[dict[str, Any]] = [
{
"name": "lookup_weather",
"description": "根据城市名或经纬度查询城市信息。",
"inputSchema": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市关键字或 'lon,lat'"},
"mode": {"type": "string", "enum": ["city", "coord"], "default": "city"},
...
},
"required": ["location"],
"additionalProperties": False,
},
},
...
]
写描述的 7 条规则
- 第一句说"什么时候用"(用户场景),不是"这个工具做什么"
- ✅
查询指定城市的实时天气。location 支持 LocationID 或经纬度。 - ❌
获取天气数据
- ✅
- 每个参数都给
description(LLM 用来理解语义) - 枚举值用
enum列出(避免 LLM 编造非法值) required列出所有必填字段;可选字段给defaultadditionalProperties: False严格禁止额外字段- 数字字段给
minimum/maximum防止 LLM 给days=999 - 避免一句话的 description,至少 1~2 句,包含 1 个例子更好
反例
{
"name": "do_thing",
"description": "做东西",
"inputSchema": {
"type": "object",
"properties": {
"x": {"type": "string"} // ❌ 没有 description,enum,required
}
}
}
5. 入参校验越早越好
不要信任 LLM 给的入参。即使 inputSchema 限制了字段,值也可能非法(比如 days=999 已经在 enum 内排除,但 mode="coord_xyz" 这种 typo 还是会进入函数体)。
weather-mcp 的 daily.py:
def get_daily_forecast(client, config, location, days=7, ...):
if not location:
raise ValueError("location 不能为空")
if days not in _VALID_DAYS: # (3, 7, 10, 15, 30)
raise ValueError(f"days 必须是 {_VALID_DAYS} 之一,当前为 {days}")
入参校验三段式
- 必填检查(空字符串、None)
- 枚举/范围检查(enum / min / max / regex)
- 格式检查(经纬度格式
lon,lat、邮箱、URL)
错误用 ValueError 抛出,会被 server 捕获并以纯文本返回(见下节)。
6. 错误处理:抛错 vs 返回文本
MCP 协议下,工具抛异常会让整个 server 进程不可用(除非框架兜底)。所以两种错误必须分开处理:
| 错误类型 | 处理方式 |
|---|---|
| 用户入参错误 | 工具返回 TextContent 文本,LLM 可以读 |
| 网络/上游错误 | 工具返回 TextContent 文本(带错误码),LLM 可以重试或告诉用户 |
| 程序 bug(如 KeyError) | 抛异常让 server 崩溃,让开发者看 stderr 修代码 |
weather-mcp server.py 的统一处理:
@server.call_tool(validate_input=False)
async def handle_call_tool(name, arguments):
arguments = arguments or {}
try:
if name == "lookup_weather":
text = lookup_mod.lookup_weather(...)
...
except (ValueError, KeyError) as e:
return [types.TextContent(type="text", text=f"参数错误:{e}")]
except Exception as e:
return [types.TextContent(type="text", text=f"{type(e).__name__}: {e}")]
return [types.TextContent(type="text", text=text)]
QWeatherError 子类分别在 client 里抛出:
class QWeatherError(RuntimeError): ...
class QWeatherHTTPError(QWeatherError): ... # HTTP 非 2xx
class QWeatherBusinessError(QWeatherError): ... # 业务 code 非 "200"
class QWeatherNetworkError(QWeatherError): ... # 超时/连接失败
错误文本格式建议
- ✅
参数错误:days 必须是 (3, 7, 10, 15, 30) 之一,当前为 5 - ✅
QWeather API error: 401 - {"error":...} - ❌
Traceback (most recent call last): ...—— 把 stack trace 暴露给 LLM 没用
7. HTTP 客户端配置
timeout
必须设置。weather-mcp 默认 10s:
client = QWeatherClient(auth, api_host, geo_host, timeout=10.0)
未设置 timeout 会让 LLM 客户端被永久挂起。
Gzip
和风天气支持 --compressed(gzip)。httpx 默认不带 header,但会自动协商:
headers = {
"Accept": "application/json",
"Accept-Encoding": "gzip",
**self._auth.headers(),
}
连接复用
对于高 QPS 场景,用 httpx.Client() 而不是 httpx.get()(每次新建连接)。weather-mcp 当前 QPS 低(LLM 调用频次),所以可以接受每次新建。如果要做 webhook / 主动推送类工具,必须用共享 Client()。
错误码透传
业务错误(HTTP 200 + code != "200")和 HTTP 错误要分开处理:
if resp.status_code != 200:
raise QWeatherHTTPError(...)
data = resp.json()
if code := str(data.get("code", "")):
if code != "200":
raise QWeatherBusinessError(...)
return data
注意:weather-mcp 的 /weatheralert/v1/current 接口响应顶层没有 code——这种情况用 if code 而不是 if code != "200" 兼容:
code = str(data.get("code", ""))
if code and code != "200":
raise QWeatherBusinessError(...)
8. 日志:只在 stderr
MCP stdio 模式下,stdout 是 JSON-RPC 通道——任何 print("xxx") 写到 stdout 都会破坏协议帧。
weather-mcp 的日志规范:
def main() -> None:
try:
config = load_config()
except ValueError as e:
print(f"[weather-mcp] 配置错误:{e}", file=sys.stderr) # ← stderr
raise SystemExit(1)
调试技巧
- 加
-u让 Python 不缓冲:python -u -m weather_mcp,stderr 实时打印 - 客户端日志里搜
[weather-mcp]前缀
9. 性能与缓存
JWT 缓存
不要每个请求都签名(CPU + 控制台计费)。weather-mcp TTL 默认 900 秒,进程内复用。
响应缓存(按需)
LLM 调用频次有限(一般秒级以下),业务接口结果本身已经"准实时",一般不需要再加缓存层。但如果上游是收费 API,可以:
- 在工具层加
functools.lru_cache(maxsize=128, ttl=60) - key =
(tool_name, location, normalized_args) - 注意:天气类数据缓存时间不能太长(5 分钟以上就有"过时"问题)
不要在 MCP 工具里做长任务
如果工具需要 > 30 秒:
- 用 streamable HTTP 而不是 stdio
- 实现成"提交任务"+"轮询结果"两步式工具
- 不要让 stdio 进程阻塞
10. 协议版本协商
MCP 协议有版本号(2024-11-05、2025-06-18 等)。客户端在 initialize 请求里会带 protocolVersion,server 应在 initialize 响应里回支持的版本。
weather-mcp 用 mcp 2.x SDK 自动处理:
server = Server("weather")
@server.list_tools()
async def handle_list_tools(): ...
兼容性建议
- 用最新稳定版的 mcp SDK(如
mcp>=2.0) - 不要手写 JSON-RPC 帧
- 关注 SDK 的 CHANGELOG(大版本会重命名模块,比如
mcp.server.fastmcp在 2.0 被移除)
11. 编写本地 smoke 测试
不要等用户接客户端才发现 bug。weather-mcp 自带 smoke_test.py,可以无客户端跑通 MCP 协议:
proc = subprocess.Popen([sys.executable, "-u", "-m", "weather_mcp"], ...)
send("initialize", {...})
send("notifications/initialized", is_notification=True)
send("tools/list")
send("tools/call", {"name": "get_current_weather", "arguments": {...}})
reader 实现要点
- 用单线程 + Queue 持续读 stdout 的每一行
- 不要为每个响应开新线程读单字节(顺序错位)
- 用
recv_id(target_id, timeout)按 id 匹配响应,忽略无关行(notifications)
CI 集成
# .github/workflows/mcp-smoke.yml
- name: MCP smoke test
run: python smoke_test.py
env:
QWEATHER_KID: ${{ secrets.QWEATHER_KID }}
QWEATHER_PROJECT_ID: ${{ secrets.QWEATHER_PROJECT_ID }}
QWEATHER_PRIVATE_KEY: ${{ secrets.QWEATHER_PRIVATE_KEY }}
QWEATHER_API_HOST: ${{ secrets.QWEATHER_API_HOST }}
QWEATHER_GEO_HOST: ${{ secrets.QWEATHER_GEO_HOST }}
12. 打包与分发
pyproject.toml 必备
[project]
name = "weather-mcp"
version = "0.1.0"
requires-python = ">=3.10"
[project.scripts]
weather-mcp = "weather_mcp.server:main" # 生成可执行脚本
[build-system]
requires = ["hatchling>=1.18"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/weather_mcp"]
安装方式
| 用户类型 | 安装方式 |
|---|---|
| 开发者 | pip install -e . |
| PyPI 用户 | pip install weather-mcp |
| 无 Python 环境 | uvx weather-mcp(需要先发布) |
发布到 PyPI 用 hatch build + hatch publish,或者 python -m build + twine upload。
13. 给 MCP 客户端作者的建议
虽然我们不是写客户端,但既然客户端配置 JSON 里要填 env,下面这条经验值得记:
用
override=False让子进程读.env—— 客户端传 env 时,只传必须传的字段(如自定义 host),让 server 自己从.env拿 KID/KEY。这样配置可以"分层"管理。
参考 weather-mcp 的 server.py:
_ENV = Path(__file__).resolve().parents[2] / ".env"
if _ENV.is_file():
load_dotenv(_ENV, override=False)
这样:
.env改完直接生效(重启进程)- 客户端
env仍可临时覆盖(如换 host) - 不需要把所有字段都搬到客户端配置里
14. 反模式清单
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| stdout 写日志 | 破坏 JSON-RPC 帧 | 只用 stderr |
| 抛异常而非返回文本 | server 进程可能挂掉 | 区分业务错误 vs 程序 bug |
print 调试信息 |
干扰协议 | 用 logging 模块写 stderr |
| 私钥提交到 git | 灾难 | .gitignore + .env.example 模板 |
| 客户端 env 覆盖 .env | 用户改 .env 不生效 | override=False |
| 工具描述一句话 | LLM 不会用 | 第一句说场景,参数都有 description |
| 接受任意 location 不校验 | 上游 404 频发 | 提前校验格式(“lon,lat” / 非空) |
| 单次签名每次都重新做 | CPU + 计数浪费 | TTL 内存缓存 |
| 不设 HTTP timeout | 客户端永久挂起 | 默认 10s,可 env 覆盖 |
tools/list 返回 100+ 工具 |
LLM 选择困难 | 工具数量控制在 5~20 个 |
| 入参是 free-form dict | LLM 编造字段 | 严格 inputSchema + additionalProperties: False |
在 name 里加随机后缀 |
工具名不稳定 | 固定命名 |
| 让用户必须读 README 才会用 | 体验差 | 工具描述里直接说明 |
把 requests 用于 MCP server |
不支持 async 友好 | httpx |
| 不写 smoke test | 上游变更时炸 | 提交 smoke_test.py 进 CI |
附录 A:weather-mcp 项目速览
weather-mcp/
├── pyproject.toml # 依赖 + 脚本入口
├── requirements.txt # 运行时依赖清单
├── .env.example # 配置模板(提交)
├── .env # 本地真实配置(不提交)
├── .gitignore # 屏蔽 .env / *.pem / 本地 mcp config
├── README.md # 用户手册
├── BEST_PRACTICES.md # ← 本文件
├── mcp-config.example.json # MCP 客户端配置模板
├── mcp-config.local.json # MCP 客户端真实配置
├── smoke_test.py # 本地协议层验证
└── src/weather_mcp/
├── __init__.py
├── __main__.py # python -m weather_mcp
├── server.py # MCP Server 入口(mcp 2.x 原生 API)
├── config.py # 环境变量加载与校验
├── auth.py # JWT (Ed25519) + API KEY 鉴权 + 缓存
├── client.py # httpx 封装 + 错误处理
├── models.py # pydantic 响应模型
└── tools/
├── lookup.py # 城市搜索
├── current.py # 实时天气
├── daily.py # 每日预报
├── hourly.py # 小时预报
├── indices.py # 生活指数
├── warning.py # 天气预警
└── minutely.py # 分钟级降水
附录 B:常见术语
| 术语 | 含义 |
|---|---|
| MCP | Model Context Protocol,LLM 与外部工具/数据源的通信协议 |
| stdio | 标准输入输出,本地子进程通信方式 |
| streamable HTTP | HTTP-based MCP 通信,支持远程服务 |
| JSON-RPC 2.0 | MCP 的传输层协议 |
| tool | LLM 可调用的函数,有 name + description + inputSchema |
| resource | LLM 可读取的 URI 资源(一般 MCP server 用 tool 即可) |
| prompt | MCP server 暴露的提示词模板(可选) |
| sampling | LLM 主动调用 MCP server 的能力(weather-mcp 不涉及) |
| Ed25519 | 一种椭圆曲线签名算法,和风天气 JWT 用它 |
| TTL | Time To Live,缓存有效期 |
更多推荐


所有评论(0)