MCP 工具开发最佳实践(结合 weather-mcp)

写给 MCP Server 的开发者。本文不重复 Model Context Protocol 官方文档 的协议层定义,而是从工程落地角度总结常见踩坑点。


目录


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. 鉴权设计

三大原则

  1. 优先支持推荐的鉴权方式。和风天气 2027-01-01 起限制 API KEY,本项目把 JWT 设为默认模式。
  2. 密钥永远不进代码、不进 README。见 3. 配置管理
  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 条规则

  1. 第一句说"什么时候用"(用户场景),不是"这个工具做什么"
    • 查询指定城市的实时天气。location 支持 LocationID 或经纬度。
    • 获取天气数据
  2. 每个参数都给 description(LLM 用来理解语义)
  3. 枚举值用 enum 列出(避免 LLM 编造非法值)
  4. required 列出所有必填字段;可选字段给 default
  5. additionalProperties: False 严格禁止额外字段
  6. 数字字段给 minimum / maximum 防止 LLM 给 days=999
  7. 避免一句话的 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}")

入参校验三段式

  1. 必填检查(空字符串、None)
  2. 枚举/范围检查(enum / min / max / regex)
  3. 格式检查(经纬度格式 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-052025-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,缓存有效期
Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐