MCP 终极指南(进阶篇):手写一个 MCP Server,再用抓包拆解协议底层

25 阅读5分钟

基础篇讲了 MCP 是什么、能做什么。进阶篇做两件事:第一,从零手写一个能跑通的 MCP Server;第二,用抓包工具拆解 MCP 协议的每一帧消息,看清楚 Host 和 Server 到底在说什么。


为什么要手写而不是直接用现成的?

ClawHub 上已经有数千个现成的 MCP Server,你不需要从头写。

手写一次能给你带来用成品永远无法得到的东西:

  • 真正理解 @mcp.tool() 装饰器背后做了什么
  • 知道为什么 docstring 写不好,LLM 就不调用你的工具
  • 明白 Tool、Resource、Prompt 三种能力单元的实现差异
  • 遇到 MCP 报错时,能准确定位是协议层还是业务层的问题

环境搭建

# 安装 uv(现代 Python 包管理器,比 pip 快 10 倍)
curl -LsSf https://astral.sh/uv/install.sh | sh

# 创建项目
uv init my-mcp-server
cd my-mcp-server

# 安装依赖
uv add "mcp[cli]" httpx

# 项目结构
my-mcp-server/
├── pyproject.toml
├── weather.py         # MCP Server 主文件
└── mcp_logger.py      # 协议抓包工具

完整的 MCP Server 实现

# weather.py
from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("weather", log_level="ERROR")

NWS_API_BASE = "https://api.weather.gov"

# ─── Tool 实现 ───────────────────────────────────────

@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """
    获取指定经纬度的详细天气预报(未来5个时段)
    
    注意:此工具使用美国国家气象局 API,仅支持美国境内的坐标。
    
    Args:
        latitude: 纬度,范围 -90 到 90,精确到小数点后4位
        longitude: 经度,范围 -180 到 180,精确到小数点后4位
    
    Returns:
        包含未来5个时段天气预报的字符串
    """
    async with httpx.AsyncClient() as client:
        # Step 1: 获取网格信息
        try:
            r1 = await client.get(
                f"{NWS_API_BASE}/points/{latitude},{longitude}",
                headers={"User-Agent": "weather-mcp/1.0", "Accept": "application/geo+json"},
                timeout=30.0
            )
            r1.raise_for_status()
            points = r1.json()
        except Exception as e:
            return f"获取位置信息失败:{e}"
        
        # Step 2: 获取天气预报
        forecast_url = points["properties"]["forecast"]
        try:
            r2 = await client.get(
                forecast_url,
                headers={"User-Agent": "weather-mcp/1.0"},
                timeout=30.0
            )
            r2.raise_for_status()
            forecast = r2.json()
        except Exception as e:
            return f"获取天气预报失败:{e}"
        
        # Step 3: 格式化输出
        periods = forecast["properties"]["periods"][:5]
        lines = []
        for p in periods:
            lines.append(
                f"**{p['name']}**\n"
                f"  温度:{p['temperature']}°{p['temperatureUnit']}\n"
                f"  风速:{p['windSpeed']} {p['windDirection']}\n"
                f"  天气:{p['shortForecast']}"
            )
        return "\n\n".join(lines)


@mcp.tool()
async def get_alerts(state: str) -> str:
    """
    获取美国指定州的当前气象预警信息
    
    Args:
        state: 美国州的两字母缩写,例如 "CA" (加利福尼亚), "NY" (纽约), "TX" (德克萨斯)
    
    Returns:
        当前有效的气象预警列表,若无预警则返回"当前无有效预警"
    """
    async with httpx.AsyncClient() as client:
        try:
            r = await client.get(
                f"{NWS_API_BASE}/alerts/active?area={state.upper()}",
                headers={"User-Agent": "weather-mcp/1.0", "Accept": "application/geo+json"},
                timeout=30.0
            )
            r.raise_for_status()
            data = r.json()
        except Exception as e:
            return f"获取预警信息失败:{e}"
        
        features = data.get("features", [])
        if not features:
            return f"{state} 州当前无有效气象预警"
        
        alerts = []
        for f in features[:5]:
            props = f["properties"]
            alerts.append(
                f"⚠️ {props.get('headline', '未知预警')}\n"
                f"   类型:{props.get('event', '-')}\n"
                f"   有效期:{props.get('effective', '-')}{props.get('expires', '-')}\n"
                f"   描述:{props.get('description', '-')[:200]}..."
            )
        
        return f"{state} 州当前有 {len(features)} 条气象预警:\n\n" + "\n\n".join(alerts)


# ─── Resource 实现 ────────────────────────────────────

@mcp.resource("weather://supported-states")
def get_supported_states() -> str:
    """返回支持查询的美国州列表"""
    states = {
        "CA": "加利福尼亚", "NY": "纽约", "TX": "德克萨斯",
        "FL": "佛罗里达", "WA": "华盛顿", "OR": "俄勒冈",
        # ... 更多州
    }
    return "\n".join(f"{code}: {name}" for code, name in states.items())


# ─── 启动 ─────────────────────────────────────────────

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

深度解析:@mcp.tool() 背后发生了什么

@mcp.tool()
async def get_forecast(latitude: float, longitude: float) -> str:
    """..."""

这一行装饰器,FastMCP 自动完成了三件事:

1. 提取函数签名生成 JSON Schema

{
  "name": "get_forecast",
  "inputSchema": {
    "type": "object",
    "properties": {
      "latitude": {
        "type": "number",
        "description": "纬度,范围 -90 到 90..."
      },
      "longitude": {
        "type": "number", 
        "description": "经度,范围 -180 到 180..."
      }
    },
    "required": ["latitude", "longitude"]
  }
}

2. 把 docstring 第一行作为 description

{
  "description": "获取指定经纬度的详细天气预报(未来5个时段)"
}

⚠️ 重要:这个 description 直接影响 LLM 是否调用你的工具。写得越清晰、越具体,LLM 调用的准确率越高。

3. 注册到 MCP Server 的工具列表

当 Host 发送 tools/list 请求时,这个工具的完整信息会被返回。


抓包拆解协议底层

用一个简单的脚本拦截 MCP 通信:

# mcp_logger.py
import subprocess
import sys
import json
import threading

def log(direction: str, data: str):
    """格式化记录通信内容"""
    try:
        parsed = json.loads(data)
        print(f"\n{'→' if direction == 'send' else '←'} {direction.upper()}")
        print(json.dumps(parsed, ensure_ascii=False, indent=2))
    except json.JSONDecodeError:
        print(f"[{direction}] 非 JSON 数据: {data[:100]}")

# 启动真实的 MCP Server
proc = subprocess.Popen(
    ["uv", "run", "weather.py"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.DEVNULL,
    text=True
)

# 透传并记录
def forward(src, dst, direction):
    for line in src:
        log(direction, line.strip())
        dst.write(line)
        dst.flush()

t1 = threading.Thread(target=forward, args=(sys.stdin, proc.stdin, "send"))
t2 = threading.Thread(target=forward, args=(proc.stdout, sys.stdout, "recv"))
t1.start(); t2.start()
t1.join(); t2.join()

实际抓包记录

完整的交互帧序列

 SEND(初始化)
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {},
    "clientInfo": {"name": "Cline", "version": "3.8.0"}
  }
}

 RECV(初始化确认)
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {"tools": {}, "resources": {}},
    "serverInfo": {"name": "weather", "version": "1.0.0"}
  }
}

 SEND(发送初始化完成通知)
{"jsonrpc": "2.0", "method": "notifications/initialized"}

 SEND(查询工具列表)
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}

 RECV(工具列表)
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_forecast",
        "description": "获取指定经纬度的详细天气预报(未来5个时段)...",
        "inputSchema": {
          "type": "object",
          "properties": {
            "latitude": {"type": "number", "description": "..."},
            "longitude": {"type": "number", "description": "..."}
          },
          "required": ["latitude", "longitude"]
        }
      },
      {"name": "get_alerts", ...}
    ]
  }
}

 SEND(工具调用)
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_forecast",
    "arguments": {"latitude": 40.7128, "longitude": -74.006}
  }
}

 RECV(工具结果)
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {"type": "text", "text": "**Tonight**\n  温度:58°F\n  风速:S 10 mph\n..."}
    ],
    "isError": false
  }
}

一个容易踩的坑:协议版本

MCP 协议还在快速迭代,不同版本的消息格式有差异。

# 确保使用和 Host 兼容的版本
mcp = FastMCP("my-server", log_level="ERROR")
# FastMCP 会自动使用最新支持的协议版本

如果遇到工具无法调用,先检查:

  1. Server 和 Host 的协议版本是否兼容
  2. JSON-RPC 请求格式是否正确(必须有 jsonrpc: "2.0"id
  3. Tool 的 inputSchema 是否符合 JSON Schema 规范

总结

进阶篇的核心收获:

  1. 手写 MCP Server:掌握 Tool / Resource 的实现方式,理解装饰器的作用
  2. 抓包分析:理解初始化握手、工具发现、工具调用的完整流程
  3. 协议本质:MCP = JSON-RPC 2.0,加上三种能力单元(Tool/Resource/Prompt)的注册和调用规范

理解了这些,你就不再是 MCP 的"用户",而是能够开发和调试 MCP Server 的"构建者"。