自己写一个 MCP Server 要几步?从装饰器到接入 Claude Desktop(2026实战)

装了七八个 MCP Server 之后(不清楚都有什么可选的,可以翻翻之前那篇 Server 清单),你可能会有个念头:别人写的 Server 要么功能多了用不上,要么缺了我最想要的那一个。与其继续挑,不如自己写。官方 Python SDK 两行装饰器就能挂一个工具,这篇从零跑通一个能查天气的 Server,写完接进 Claude Desktop,全程不需要懂协议细节。

先回忆一下 Server 到底提供什么

之前那篇 MCP 协议入门讲过,MCP 是个 C/S 结构:客户端是 Claude Desktop、Cursor 这类 AI 应用,服务器负责提供三类东西,官方文档叫三大原语:

Tools(工具):模型能主动调用的函数。查数据库、调 API、改文件都算。控制权在模型手里,它根据上下文决定什么时候调用,执行前客户端一般会找用户确认。

Resources(资源):只读的数据源,文件内容、数据库表结构、API 文档都可以包成资源。应用决定读什么、传什么给模型,模型自己不能改。

Prompts(提示模板):预先写好的指令模板,比如「帮我规划一次旅行」这种固定套路,由用户主动选用。

自己写 Server,绝大多数场景就是在写 Tools。这篇也以工具为主。

环境准备:uv 一个命令搞定

官方 Python SDK 叫 mcp,要求 Python 3.10 以上,当前 PyPI 最新版本 2.3.0。跟着官方教程走,用 uv 建项目最省事:

curl -LsSf https://astral.sh/uv/install.sh | sh
# 重启终端后
uv init weather
cd weather
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
touch weather.py

mcp[cli] 这个 extra 会多装一个命令行工具,后面调试用的 mcp dev 就靠它。Windows 把激活命令换成 .venv\Scripts\activate 即可。

写一个最简单的 Server

打开 weather.py,先写骨架:

from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather")

然后挂一个查美国官方气象预警的工具:

NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"

async def make_nws_request(url: str) -> dict[str, Any] | None:
    headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
    async with httpx.AsyncClient() as client:
        try:
            r = await client.get(url, headers=headers, timeout=30.0)
            r.raise_for_status()
            return r.json()
        except Exception:
            return None

@mcp.tool()
async def get_alerts(state: str) -> str:
    """Get weather alerts for a US state.

    Args:
        state: Two-letter US state code (e.g. CA, NY)
    """
    url = f"{NWS_API_BASE}/alerts/active/area/{state}"
    data = await make_nws_request(url)
    if not data or "features" not in data:
        return "Unable to fetch alerts or no alerts found."
    if not data["features"]:
        return "No active alerts for this state."
    alerts = []
    for f in data["features"]:
        p = f["properties"]
        alerts.append(f"{p.get('event')}: {p.get('areaDesc')} | {p.get('headline')}")
    return "\n---\n".join(alerts)

重点全在 @mcp.tool() 这一行。装饰器会读两样东西:类型标注(state: str 变成参数的 JSON Schema)和 docstring(变成工具描述)。模型靠这段描述决定什么时候用这个工具,所以 docstring 认真写,把参数含义、格式、举例都写清楚。这是整个开发里最值得花时间的部分,官方文档也专门强调了这一点。对比一下:差的描述写「查询天气」,模型不知道能查什么范围、参数是什么格式,用户问「纽约热不热」它可能不调用工具,靠记忆瞎答。好的描述像上面那样写明用途,参数注明「Two-letter US state code (e.g. CA, NY)」,模型一看就懂,传参也准。

文件末尾加启动代码:

if __name__ == "__main__":
    mcp.run(transport="stdio")

stdio 传输就是之前 传输层那篇讲的第一种:Server 作为客户端的子进程跑,通过标准输入输出交换 JSON-RPC 消息。本地工具用这种最合适,不用开端口。

调试:Inspector 里点一点

写完先别急着接客户端,官方 Inspector 是专门调试 MCP 的工具:

mcp dev weather.py

浏览器会打开一个界面,在 Tools 标签里能看到 get_alerts,填个 "CA" 直接跑。参数错不错、返回长什么样,在这里先验一遍,比接进 Claude Desktop 之后瞎猜快得多。

接进 Claude Desktop

打开配置文件(macOS):

~/Library/Application Support/Claude/claude_desktop_config.json

Windows 在 %AppData%\Claude\ 下同名文件。加上:

{
  "mcpServers": {
    "weather": {
      "command": "uv",
      "args": [
        "--directory",
        "/绝对路径/weather",
        "run",
        "weather.py"
      ]
    }
  }
}

路径必须是绝对路径。重启 Claude Desktop,对话框右下角出现工具图标就说明挂载成功了。问一句「加州现在有什么气象预警」,它会请求调用 get_alerts,你点确认,它拿着返回结果组织答案。

有个细节值得知道:Server 里不要用 print 输出日志。stdio 传输下 stdout 是协议通道,print 的内容会被当成消息发给客户端,轻则报错重则直接断连。日志统一走 logging 模块,它会写 stderr,不影响协议。官方教程给的正反例就一行代码的区别:

import logging
logger = logging.getLogger(__name__)

# 错误写法:污染 stdout,会破坏协议通信
print("Processing request")

# 正确写法:写到 stderr,客户端不会误解
logger.info("Processing request")

进阶前再给两个工具

一个 Server 可以挂多个工具,再加一个查预报的:

@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """Get weather forecast for a location.

    Args:
        latitude: Latitude of the location
        longitude: Longitude of the location
    """
    # 先拿网格点,再取预报,两步请求
    points = await make_nws_request(f"{NWS_API_BASE}/points/{latitude},{longitude}")
    if not points:
        return "Unable to fetch forecast data."
    forecast_url = points["properties"]["forecast"]
    forecast = await make_nws_request(forecast_url)
    if not forecast:
        return "Unable to fetch forecast."
    periods = forecast["properties"]["periods"]
    return "\n".join(
        f"{p['name']}: {p['temperature']}°{p['temperatureUnit']}, {p['shortForecast']}"
        for p in periods[:5]
    )

两个工具,一个查预警一个查预报,模型会按用户的问题自己选。工具之间不需要任何注册外的协调,每个工具管好自己的输入输出就行。

顺便提醒一句:模型不是每次都调得对。工具名起得清楚、描述写得好,调用准确率会高很多;描述含糊,模型就猜。之前写 Function Calling 那篇时提过参数设计的三个原则,写 MCP 工具同样适用。

三条安全红线

工具是真实执行的代码,接进客户端之后模型每次调用都在跑你的函数,几条底线要守住:

一,参数校验别偷懒。类型标注只是给模型看的说明书,恶意或异常输入照样会传进来,涉及文件路径、SQL、shell 命令的工具要自己过滤。

二,最小权限。工具能访问什么就暴露什么,读文件的 Server 不要挂整个家目录,查数据库的账号给只读权限。官方安全建议里专门警告过本地 Server 的目录绑定问题。

三,敏感操作加人工确认。Claude Desktop 默认每次调用都会弹确认框,别图省事关掉。自己接其他客户端时,写操作、删操作务必保留这层确认。

写在最后

一个能跑的 MCP Server,核心就是:FastMCP 实例加若干个带类型标注和 docstring 的函数,stdio 传输,Inspector 调试,Claude Desktop 验证。官方 Python 教程的天气例子本身就是最好的模板,换掉 API 和函数体,就能改成查自己业务数据的工具。

写完想上线给团队用,就要从 stdio 换成 Streamable HTTP,鉴权和部署的事可以回头翻传输层那篇。后续如果写远程 Server 的部署实战,会再开一篇。

版本协商:老客户端怎么办

协议本身在演进,2026 年的版本和 2025 年的握手方式有差别。官方 TypeScript SDK 的 serveStdio 已经内置了协商:客户端连上来先探新协议,不行就回退旧握手,同一个 Server 文件能同时服务新旧两代客户端。所以你写的时候一般不用操心版本号,除非你的客户端特别老,手动 connect 的老写法就只能服务旧协议。这个坑在升级 SDK 版本、老客户端突然连不上时值得先想起来。

发表评论