MCP开发实战:手把手教你用Python创建天气查询MCP工具
前言
大家好,我是 Leo 哥🤓🤓🤓。还记得前面几篇文章中我们聊过的MCP工具吗?从sequential-thinking到chrome-mcp-server,我们体验了各种强大的MCP工具。但是有没有想过,如果现有的工具都不能满足你的需求,怎么办?
比如说,我最近想让Claude帮我查天气,但是现有的MCP工具要么功能太简单,要么配置太复杂。作为一个程序员,遇到这种情况当然是------自己动手,丰衣足食!
为什么要开发自己的MCP工具
在开始编码之前,我们先聊聊为什么要自己开发MCP工具。
现有工具的局限性
虽然MCP社区已经有了很多优秀的工具,但是在实际使用中,我发现了一些问题:
-
功能太泛化:很多工具为了满足通用性,功能设计得比较宽泛,反而不够精准
-
配置复杂:一些强大的工具(比如chrome-mcp-server)配置起来相当复杂
-
依赖关系:部分工具需要安装额外的系统依赖,部署起来麻烦
自定义MCP的价值
🤔 思考时间: 如果你是一个经常需要查看天气信息的人(比如户外工作者、旅行博主),你希望AI能够提供什么样的天气服务?
通过开发自己的MCP工具,我们可以:只做我们需要的功能,不要多余的复杂性,根据使用反馈快速调整和改进
MCP协议开发者视角解析
在前面的文章中,我们从使用者角度了解了MCP。现在让我们从开发者的角度重新认识它。
MCP协议的本质
官方定义:Model Context Protocol (MCP) 是一个标准化协议,允许AI模型与外部工具、数据源和服务进行安全、可控的交互。
Leo哥说人话:MCP就像给AI配了一套"标准化插头",不管是什么功能的"电器"(工具),只要符合这个插头标准,AI都能"插上就用"。我们开发MCP工具,就是在制造这种"标准化电器"。
MCP的三大核心组件
MCP协议主要包含三种类型的功能:
对于我们的天气查询工具,我们主要使用Tools功能,让AI能够调用我们的天气查询方法。
开发环境和技术栈选择
我选择Python作为开发语言,主要原因是:
- MCP-SDK支持完善:官方提供了成熟的Python SDK
- 异步编程友好:天气API调用需要异步处理
- 生态系统丰富:HTTP客户端、数据处理等库非常成熟
- 学习成本较低:相比Node.js,Python的入门门槛更低
项目整体架构设计
在开始编码之前,让我们先设计一下项目的整体架构。
项目目录结构
bash
weather-mcp/
├── src/
│ └── weather_mcp/
│ ├── __init__.py # 包初始化
│ ├── server.py # MCP服务器主文件
│ ├── weather_api.py # 天气API封装
│ ├── models.py # 数据模型定义
│ ├── config.py # 配置管理
│ └── utils.py # 工具函数
├── tests/ # 测试文件
│ ├── test_weather_api.py
│ └── test_server.py
├── .env.example # 环境变量模板
├── .env # 实际环境变量
├── requirements.txt # 依赖列表
├── README.md # 使用说明
├── setup.py # 安装脚本
└── run.py # 启动脚本
核心功能设计
我们的天气MCP将提供以下功能:
工具名称 | 功能描述 | 参数 | 返回数据 |
---|---|---|---|
get_current_weather |
获取当前天气 | city(城市名) | 温度、湿度、天气描述、风速等 |
get_weather_forecast |
获取天气预报 | city, days(天数,1-5) | 未来几天的天气预报 |
get_air_quality |
获取空气质量 | city | AQI指数、污染物浓度等 |
🤔 思考时间: 你觉得这三个功能够用吗?如果是你来设计,还会添加什么功能?
完整代码实现
现在让我们开始编写代码。我会按照模块来逐个实现。
1. 依赖管理 (requirements.txt)
首先定义项目依赖:
txt
# MCP核心依赖
mcp>=1.0.0
# HTTP请求
aiohttp>=3.9.0
httpx>=0.25.0
# 数据处理和验证
pydantic>=2.5.0
python-dotenv>=1.0.0
# 日志和工具
loguru>=0.7.0
tenacity>=8.2.0
# 开发和测试依赖
pytest>=7.4.0
pytest-asyncio>=0.21.0
black>=23.0.0
2. 配置管理 (src/weather_mcp/config.py)
python
"""
配置管理模块
处理环境变量和应用配置
"""
import os
from typing import Optional
from pydantic import BaseSettings, Field
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
class WeatherConfig(BaseSettings):
"""天气API配置"""
# OpenWeatherMap API配置
api_key: str = Field(..., env="OPENWEATHER_API_KEY")
api_base_url: str = Field(
default="https://api.openweathermap.org/data/2.5",
env="OPENWEATHER_BASE_URL"
)
# 请求配置
timeout: int = Field(default=10, env="REQUEST_TIMEOUT")
max_retries: int = Field(default=3, env="MAX_RETRIES")
# 缓存配置
cache_ttl: int = Field(default=300, env="CACHE_TTL") # 5分钟缓存
# 默认配置
default_units: str = Field(default="metric", env="DEFAULT_UNITS") # metric, imperial
default_lang: str = Field(default="zh_cn", env="DEFAULT_LANG")
class Config:
env_file = ".env"
case_sensitive = False
# 全局配置实例
config = WeatherConfig()
def validate_config() -> bool:
"""验证配置是否正确"""
try:
if not config.api_key:
print("❌ 错误:未找到OpenWeatherMap API Key")
print("请在.env文件中设置 OPENWEATHER_API_KEY")
return False
if len(config.api_key) != 32:
print("❌ 警告:API Key格式可能不正确")
print(f"当前Key长度:{len(config.api_key)},预期长度:32")
print("✅ 配置验证通过")
return True
except Exception as e:
print(f"❌ 配置验证失败:{e}")
return False
if __name__ == "__main__":
# 用于测试配置
validate_config()
3. 数据模型 (src/weather_mcp/models.py)
python
"""
数据模型定义
定义API返回数据的结构
"""
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
class CurrentWeather(BaseModel):
"""当前天气数据模型"""
city: str = Field(..., description="城市名称")
country: str = Field(..., description="国家代码")
# 基础天气信息
temperature: float = Field(..., description="当前温度(摄氏度)")
feels_like: float = Field(..., description="体感温度(摄氏度)")
humidity: int = Field(..., description="湿度百分比")
pressure: int = Field(..., description="气压(hPa)")
# 天气描述
weather_main: str = Field(..., description="天气主要状况")
weather_description: str = Field(..., description="详细天气描述")
weather_icon: str = Field(..., description="天气图标代码")
# 风力信息
wind_speed: float = Field(..., description="风速(米/秒)")
wind_direction: Optional[int] = Field(None, description="风向(度)")
# 其他信息
visibility: Optional[int] = Field(None, description="能见度(米)")
cloudiness: int = Field(..., description="云量百分比")
# 时间信息
sunrise: Optional[datetime] = Field(None, description="日出时间")
sunset: Optional[datetime] = Field(None, description="日落时间")
update_time: datetime = Field(..., description="数据更新时间")
def to_readable_string(self) -> str:
"""转换为易读的字符串格式"""
return f"""
🏙️ {self.city}, {self.country}
🌡️ **当前温度**: {self.temperature}°C(体感 {self.feels_like}°C)
☁️ **天气状况**: {self.weather_description}
💧 **湿度**: {self.humidity}%
🌪️ **风速**: {self.wind_speed} m/s
📊 **气压**: {self.pressure} hPa
👁️ **能见度**: {self.visibility or '未知'} 米
🕐 **更新时间**: {self.update_time.strftime('%Y-%m-%d %H:%M:%S')}
""".strip()
class ForecastDay(BaseModel):
"""单日预报数据模型"""
date: str = Field(..., description="日期(YYYY-MM-DD)")
temperature_max: float = Field(..., description="最高温度")
temperature_min: float = Field(..., description="最低温度")
weather_main: str = Field(..., description="主要天气")
weather_description: str = Field(..., description="天气描述")
weather_icon: str = Field(..., description="天气图标")
humidity: int = Field(..., description="湿度")
wind_speed: float = Field(..., description="风速")
class WeatherForecast(BaseModel):
"""天气预报数据模型"""
city: str = Field(..., description="城市名称")
country: str = Field(..., description="国家代码")
forecast_days: List[ForecastDay] = Field(..., description="预报数据")
def to_readable_string(self) -> str:
"""转换为易读的字符串格式"""
result = f"📅 {self.city}, {self.country} 天气预报\n\n"
for day in self.forecast_days:
result += f"📆 **{day.date}**\n"
result += f" 🌡️ {day.temperature_min}°C ~ {day.temperature_max}°C\n"
result += f" ☁️ {day.weather_description}\n"
result += f" 💨 风速: {day.wind_speed} m/s\n"
result += f" 💧 湿度: {day.humidity}%\n\n"
return result.strip()
class AirQuality(BaseModel):
"""空气质量数据模型"""
city: str = Field(..., description="城市名称")
country: str = Field(..., description="国家代码")
# 空气质量指数
aqi: int = Field(..., description="空气质量指数(1-5)")
aqi_description: str = Field(..., description="空气质量描述")
# 污染物浓度 (μg/m³)
co: Optional[float] = Field(None, description="一氧化碳浓度")
no2: Optional[float] = Field(None, description="二氧化氮浓度")
o3: Optional[float] = Field(None, description="臭氧浓度")
pm2_5: Optional[float] = Field(None, description="PM2.5浓度")
pm10: Optional[float] = Field(None, description="PM10浓度")
so2: Optional[float] = Field(None, description="二氧化硫浓度")
update_time: datetime = Field(..., description="数据更新时间")
@staticmethod
def get_aqi_description(aqi: int) -> str:
"""根据AQI等级返回描述"""
descriptions = {
1: "优秀 - 空气质量非常好",
2: "良好 - 空气质量可以接受",
3: "中等 - 敏感人群可能有轻微不适",
4: "较差 - 可能对健康有害",
5: "很差 - 健康风险较高"
}
return descriptions.get(aqi, "未知")
def to_readable_string(self) -> str:
"""转换为易读的字符串格式"""
return f"""
🏙️ {self.city}, {self.country} 空气质量
📊 **空气质量指数**: {self.aqi}/5 - {self.aqi_description}
🔬 **污染物浓度** (μg/m³):
• PM2.5: {self.pm2_5 or '未检测'}
• PM10: {self.pm10 or '未检测'}
• 二氧化氮: {self.no2 or '未检测'}
• 臭氧: {self.o3 or '未检测'}
• 二氧化硫: {self.so2 or '未检测'}
• 一氧化碳: {self.co or '未检测'}
🕐 **更新时间**: {self.update_time.strftime('%Y-%m-%d %H:%M:%S')}
""".strip()
4. 天气API封装 (src/weather_mcp/weather_api.py)
python
"""
天气API封装模块
处理与OpenWeatherMap API的交互
"""
import asyncio
import aiohttp
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from loguru import logger
from tenacity import retry, stop_after_attempt, wait_exponential
from .config import config
from .models import CurrentWeather, WeatherForecast, ForecastDay, AirQuality
class WeatherAPIError(Exception):
"""天气API相关异常"""
pass
class WeatherAPI:
"""天气API客户端"""
def __init__(self):
self.base_url = config.api_base_url
self.api_key = config.api_key
self.timeout = aiohttp.ClientTimeout(total=config.timeout)
self.session: Optional[aiohttp.ClientSession] = None
# 简单的内存缓存
self._cache: Dict[str, Dict[str, Any]] = {}
async def __aenter__(self):
"""异步上下文管理器入口"""
self.session = aiohttp.ClientSession(timeout=self.timeout)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口"""
if self.session:
await self.session.close()
def _get_cache_key(self, method: str, **params) -> str:
"""生成缓存键"""
param_str = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
return f"{method}:{param_str}"
def _get_cached_result(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""获取缓存结果"""
if cache_key in self._cache:
cached_data = self._cache[cache_key]
cache_time = cached_data.get("cache_time")
if cache_time and datetime.now() - cache_time < timedelta(seconds=config.cache_ttl):
logger.debug(f"使用缓存数据: {cache_key}")
return cached_data.get("data")
return None
def _set_cache(self, cache_key: str, data: Dict[str, Any]):
"""设置缓存"""
self._cache[cache_key] = {
"data": data,
"cache_time": datetime.now()
}
# 简单的缓存清理:保留最近100个
if len(self._cache) > 100:
oldest_key = min(self._cache.keys(),
key=lambda k: self._cache[k]["cache_time"])
del self._cache[oldest_key]
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10)
)
async def _make_request(self, endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""发送API请求(带重试机制)"""
if not self.session:
raise WeatherAPIError("API客户端未初始化")
params["appid"] = self.api_key
url = f"{self.base_url}/{endpoint}"
try:
async with self.session.get(url, params=params) as response:
if response.status == 200:
data = await response.json()
logger.debug(f"API请求成功: {endpoint}")
return data
elif response.status == 401:
raise WeatherAPIError("API密钥无效或已过期")
elif response.status == 404:
raise WeatherAPIError("城市未找到,请检查城市名称")
elif response.status == 429:
raise WeatherAPIError("API调用频率超限,请稍后再试")
else:
error_text = await response.text()
raise WeatherAPIError(f"API请求失败 ({response.status}): {error_text}")
except aiohttp.ClientError as e:
logger.error(f"网络请求失败: {e}")
raise WeatherAPIError(f"网络请求失败: {e}")
async def get_current_weather(self, city: str, units: str = "metric", lang: str = "zh_cn") -> CurrentWeather:
"""获取当前天气"""
cache_key = self._get_cache_key("current", city=city, units=units, lang=lang)
# 检查缓存
cached_data = self._get_cached_result(cache_key)
if cached_data:
return CurrentWeather(**cached_data)
# API请求参数
params = {
"q": city,
"units": units,
"lang": lang
}
try:
data = await self._make_request("weather", params)
# 解析API响应
weather_data = {
"city": data["name"],
"country": data["sys"]["country"],
"temperature": data["main"]["temp"],
"feels_like": data["main"]["feels_like"],
"humidity": data["main"]["humidity"],
"pressure": data["main"]["pressure"],
"weather_main": data["weather"][0]["main"],
"weather_description": data["weather"][0]["description"],
"weather_icon": data["weather"][0]["icon"],
"wind_speed": data["wind"]["speed"],
"wind_direction": data["wind"].get("deg"),
"visibility": data.get("visibility"),
"cloudiness": data["clouds"]["all"],
"update_time": datetime.now()
}
# 处理日出日落时间
if "sys" in data:
if "sunrise" in data["sys"]:
weather_data["sunrise"] = datetime.fromtimestamp(data["sys"]["sunrise"])
if "sunset" in data["sys"]:
weather_data["sunset"] = datetime.fromtimestamp(data["sys"]["sunset"])
# 缓存结果
self._set_cache(cache_key, weather_data)
return CurrentWeather(**weather_data)
except Exception as e:
logger.error(f"获取当前天气失败: {e}")
raise WeatherAPIError(f"获取天气数据失败: {e}")
async def get_weather_forecast(self, city: str, days: int = 5, units: str = "metric", lang: str = "zh_cn") -> WeatherForecast:
"""获取天气预报"""
if days < 1 or days > 5:
raise WeatherAPIError("预报天数必须在1-5天之间")
cache_key = self._get_cache_key("forecast", city=city, days=days, units=units, lang=lang)
# 检查缓存
cached_data = self._get_cached_result(cache_key)
if cached_data:
return WeatherForecast(**cached_data)
params = {
"q": city,
"units": units,
"lang": lang,
"cnt": days * 8 # 每天8个时间点(3小时间隔)
}
try:
data = await self._make_request("forecast", params)
# 按天分组数据
daily_data = {}
for item in data["list"]:
date = datetime.fromtimestamp(item["dt"]).strftime("%Y-%m-%d")
if date not in daily_data:
daily_data[date] = {
"temps": [],
"weather": item["weather"][0],
"humidity": item["main"]["humidity"],
"wind_speed": item["wind"]["speed"]
}
daily_data[date]["temps"].append(item["main"]["temp"])
# 生成每日预报
forecast_days = []
for date, day_data in list(daily_data.items())[:days]:
forecast_days.append(ForecastDay(
date=date,
temperature_max=max(day_data["temps"]),
temperature_min=min(day_data["temps"]),
weather_main=day_data["weather"]["main"],
weather_description=day_data["weather"]["description"],
weather_icon=day_data["weather"]["icon"],
humidity=day_data["humidity"],
wind_speed=day_data["wind_speed"]
))
forecast_data = {
"city": data["city"]["name"],
"country": data["city"]["country"],
"forecast_days": [day.dict() for day in forecast_days]
}
# 缓存结果
self._set_cache(cache_key, forecast_data)
return WeatherForecast(**forecast_data)
except Exception as e:
logger.error(f"获取天气预报失败: {e}")
raise WeatherAPIError(f"获取预报数据失败: {e}")
async def get_air_quality(self, city: str) -> AirQuality:
"""获取空气质量"""
cache_key = self._get_cache_key("air_quality", city=city)
# 检查缓存
cached_data = self._get_cached_result(cache_key)
if cached_data:
return AirQuality(**cached_data)
# 首先获取城市坐标
try:
geo_params = {"q": city, "limit": 1}
geo_data = await self._make_request("../geo/1.0/direct", geo_params)
if not geo_data:
raise WeatherAPIError(f"未找到城市: {city}")
lat, lon = geo_data[0]["lat"], geo_data[0]["lon"]
# 获取空气质量数据
air_params = {"lat": lat, "lon": lon}
air_data = await self._make_request("air_pollution", air_params)
pollution_data = air_data["list"][0]
air_quality_data = {
"city": geo_data[0]["name"],
"country": geo_data[0]["country"],
"aqi": pollution_data["main"]["aqi"],
"aqi_description": AirQuality.get_aqi_description(pollution_data["main"]["aqi"]),
"co": pollution_data["components"].get("co"),
"no2": pollution_data["components"].get("no2"),
"o3": pollution_data["components"].get("o3"),
"pm2_5": pollution_data["components"].get("pm2_5"),
"pm10": pollution_data["components"].get("pm10"),
"so2": pollution_data["components"].get("so2"),
"update_time": datetime.now()
}
# 缓存结果
self._set_cache(cache_key, air_quality_data)
return AirQuality(**air_quality_data)
except Exception as e:
logger.error(f"获取空气质量失败: {e}")
raise WeatherAPIError(f"获取空气质量数据失败: {e}")
# 全局API实例(单例模式)
_weather_api_instance: Optional[WeatherAPI] = None
async def get_weather_api() -> WeatherAPI:
"""获取天气API实例"""
global _weather_api_instance
if _weather_api_instance is None:
_weather_api_instance = WeatherAPI()
return _weather_api_instance
5. MCP服务器主文件 (src/weather_mcp/server.py)
python
"""
天气MCP服务器主文件
实现MCP协议的服务器端
"""
import asyncio
import json
from typing import Any, Dict, List
from loguru import logger
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.types as types
from .weather_api import get_weather_api, WeatherAPIError
from .config import config, validate_config
# 创建MCP服务器实例
server = Server("weather-mcp")
# 定义工具列表
WEATHER_TOOLS = [
Tool(
name="get_current_weather",
description="获取指定城市的当前天气信息,包括温度、湿度、风速、天气描述等详细信息",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,支持中文和英文,例如:'北京'、'Beijing'、'New York'"
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "温度单位,metric(摄氏度)或imperial(华氏度)",
"default": "metric"
},
"lang": {
"type": "string",
"description": "语言设置,zh_cn(中文)或en(英文)",
"default": "zh_cn"
}
},
"required": ["city"]
}
),
Tool(
name="get_weather_forecast",
description="获取指定城市的天气预报,支持1-5天的预报数据,包括每日最高最低温度和天气状况",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,支持中文和英文"
},
"days": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"description": "预报天数(1-5天)",
"default": 3
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "温度单位",
"default": "metric"
},
"lang": {
"type": "string",
"description": "语言设置",
"default": "zh_cn"
}
},
"required": ["city"]
}
),
Tool(
name="get_air_quality",
description="获取指定城市的空气质量信息,包括AQI指数和各种污染物浓度数据",
inputSchema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,支持中文和英文"
}
},
"required": ["city"]
}
)
]
@server.list_tools()
async def handle_list_tools() -> List[Tool]:
"""返回可用工具列表"""
logger.info("收到工具列表请求")
return WEATHER_TOOLS
@server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""处理工具调用请求"""
logger.info(f"收到工具调用请求: {name}, 参数: {arguments}")
try:
# 获取天气API客户端
weather_api = await get_weather_api()
async with weather_api:
if name == "get_current_weather":
return await _handle_current_weather(weather_api, arguments)
elif name == "get_weather_forecast":
return await _handle_weather_forecast(weather_api, arguments)
elif name == "get_air_quality":
return await _handle_air_quality(weather_api, arguments)
else:
error_msg = f"未知的工具名称: {name}"
logger.error(error_msg)
return [types.TextContent(type="text", text=f"❌ {error_msg}")]
except Exception as e:
error_msg = f"工具调用失败: {e}"
logger.error(error_msg)
return [types.TextContent(type="text", text=f"❌ {error_msg}")]
async def _handle_current_weather(weather_api, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""处理当前天气查询"""
city = arguments.get("city")
units = arguments.get("units", config.default_units)
lang = arguments.get("lang", config.default_lang)
if not city:
return [types.TextContent(type="text", text="❌ 错误:缺少城市参数")]
try:
weather = await weather_api.get_current_weather(city, units, lang)
# 返回格式化的天气信息
response = f"""📍 **{weather.city}, {weather.country}** 当前天气
🌡️ **温度**: {weather.temperature}°C(体感温度 {weather.feels_like}°C)
☁️ **天气**: {weather.weather_description}
💧 **湿度**: {weather.humidity}%
🌪️ **风速**: {weather.wind_speed} m/s
📊 **气压**: {weather.pressure} hPa
☁️ **云量**: {weather.cloudiness}%"""
if weather.visibility:
response += f"\n👁️ **能见度**: {weather.visibility} 米"
if weather.sunrise and weather.sunset:
response += f"\n🌅 **日出**: {weather.sunrise.strftime('%H:%M')}"
response += f"\n🌇 **日落**: {weather.sunset.strftime('%H:%M')}"
response += f"\n\n🕐 *数据更新时间: {weather.update_time.strftime('%Y-%m-%d %H:%M:%S')}*"
logger.info(f"成功获取{city}当前天气")
return [types.TextContent(type="text", text=response)]
except WeatherAPIError as e:
error_msg = f"获取天气数据失败: {e}"
logger.error(error_msg)
return [types.TextContent(type="text", text=f"❌ {error_msg}")]
async def _handle_weather_forecast(weather_api, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""处理天气预报查询"""
city = arguments.get("city")
days = arguments.get("days", 3)
units = arguments.get("units", config.default_units)
lang = arguments.get("lang", config.default_lang)
if not city:
return [types.TextContent(type="text", text="❌ 错误:缺少城市参数")]
try:
forecast = await weather_api.get_weather_forecast(city, days, units, lang)
# 构建预报响应
response = f"📅 **{forecast.city}, {forecast.country}** {days}天天气预报\n\n"
for day in forecast.forecast_days:
response += f"📆 **{day.date}**\n"
response += f" 🌡️ {day.temperature_min}°C ~ {day.temperature_max}°C\n"
response += f" ☁️ {day.weather_description}\n"
response += f" 💨 风速: {day.wind_speed} m/s\n"
response += f" 💧 湿度: {day.humidity}%\n\n"
logger.info(f"成功获取{city} {days}天预报")
return [types.TextContent(type="text", text=response)]
except WeatherAPIError as e:
error_msg = f"获取预报数据失败: {e}"
logger.error(error_msg)
return [types.TextContent(type="text", text=f"❌ {error_msg}")]
async def _handle_air_quality(weather_api, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""处理空气质量查询"""
city = arguments.get("city")
if not city:
return [types.TextContent(type="text", text="❌ 错误:缺少城市参数")]
try:
air_quality = await weather_api.get_air_quality(city)
# 构建空气质量响应
response = f"""🏙️ **{air_quality.city}, {air_quality.country}** 空气质量
📊 **空气质量指数**: {air_quality.aqi}/5 - {air_quality.aqi_description}
🔬 **污染物浓度** (μg/m³):"""
if air_quality.pm2_5:
response += f"\n • PM2.5: {air_quality.pm2_5}"
if air_quality.pm10:
response += f"\n • PM10: {air_quality.pm10}"
if air_quality.no2:
response += f"\n • 二氧化氮: {air_quality.no2}"
if air_quality.o3:
response += f"\n • 臭氧: {air_quality.o3}"
if air_quality.so2:
response += f"\n • 二氧化硫: {air_quality.so2}"
if air_quality.co:
response += f"\n • 一氧化碳: {air_quality.co}"
response += f"\n\n🕐 *数据更新时间: {air_quality.update_time.strftime('%Y-%m-%d %H:%M:%S')}*"
logger.info(f"成功获取{city}空气质量")
return [types.TextContent(type="text", text=response)]
except WeatherAPIError as e:
error_msg = f"获取空气质量数据失败: {e}"
logger.error(error_msg)
return [types.TextContent(type="text", text=f"❌ {error_msg}")]
async def main():
"""服务器主函数"""
# 配置日志
logger.add("weather_mcp.log", rotation="10 MB", retention="7 days")
# 验证配置
if not validate_config():
logger.error("配置验证失败,服务器无法启动")
return
logger.info("天气MCP服务器启动中...")
logger.info(f"API基础URL: {config.api_base_url}")
logger.info(f"缓存TTL: {config.cache_ttl}秒")
# 运行服务器
from mcp.server.stdio import stdio_server
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())
6. 环境变量配置文件 (.env.example)
bash
# OpenWeatherMap API配置
# 在 https://openweathermap.org/api 申请免费API Key
OPENWEATHER_API_KEY=your_api_key_here
# API基础URL(一般不需要修改)
OPENWEATHER_BASE_URL=https://api.openweathermap.org/data/2.5
# 请求配置
REQUEST_TIMEOUT=10
MAX_RETRIES=3
# 缓存配置(秒)
CACHE_TTL=300
# 默认配置
DEFAULT_UNITS=metric # metric(摄氏度) 或 imperial(华氏度)
DEFAULT_LANG=zh_cn # zh_cn(中文) 或 en(英文)
7. 启动脚本 (run.py)
python
#!/usr/bin/env python3
"""
天气MCP服务器启动脚本
"""
import asyncio
import sys
import os
# 添加src目录到Python路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from weather_mcp.server import main
if __name__ == "__main__":
print("🌤️ 启动天气MCP服务器...")
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n👋 天气MCP服务器已停止")
except Exception as e:
print(f"❌ 服务器启动失败: {e}")
sys.exit(1)
安装和配置指南
代码写完了,现在让我们来实际部署和使用这个天气MCP工具。
环境要求
在开始之前,请确保你的系统满足以下要求:
- Python版本: >= 3.8
- 操作系统: Windows 10+, macOS 10.14+, Linux (Ubuntu 18.04+)
- 网络环境: 能够访问OpenWeatherMap API
- Claude Code: 已安装并配置
第一步:获取OpenWeatherMap API Key
我们的天气数据来源是OpenWeatherMap,这是一个老牌的天气API服务商,免费版本每月提供1000次调用。
- 访问官网注册 :打开 openweathermap.org/api
- 创建免费账户:点击"Sign Up",填写基本信息
- 获取API Key:登录后在"API keys"页面找到你的Key
- 测试Key可用性:
bash
# 替换YOUR_API_KEY为实际的Key
curl "https://api.openweathermap.org/data/2.5/weather?q=Beijing&appid=YOUR_API_KEY"
如果返回JSON格式的天气数据,说明Key可用。
💥 Leo哥踩坑实录: 我第一次申请API Key时,没注意到需要激活。注册后立即使用,结果一直报401错误。后来发现OpenWeatherMap的API Key需要等几分钟才能生效,有时候甚至要等一个小时!如果遇到401错误,不要着急,等一等再试。
第二步:下载和安装项目
bash
# 创建项目目录
mkdir weather-mcp
cd weather-mcp
# 创建虚拟环境(推荐)
python -m venv venv
# 激活虚拟环境
# Windows:
venv\Scripts\activate
# macOS/Linux:
source venv/bin/activate
# 创建项目结构
mkdir -p src/weather_mcp tests
把前面的代码文件保存到对应的目录中,然后安装依赖:
bash
# 安装依赖
pip install -r requirements.txt
# 验证安装
python -c "import mcp; print('MCP SDK安装成功')"
第三步:配置环境变量
创建.env
文件并配置API密钥:
bash
# 复制环境变量模板
cp .env.example .env
# 编辑.env文件,填入你的API Key
# OPENWEATHER_API_KEY=你的实际API_KEY
重要提醒:
- 绝对不要把
.env
文件提交到Git仓库 - 建议在
.gitignore
中添加.env
第四步:测试MCP服务器
在部署到Claude之前,先测试服务器是否正常工作:
bash
# 运行配置验证
python -c "from src.weather_mcp.config import validate_config; validate_config()"
# 测试启动服务器
python run.py
如果看到类似以下输出,说明服务器启动成功:
yaml
🌤️ 启动天气MCP服务器...
2024-01-01 10:00:00 | INFO | 天气MCP服务器启动中...
2024-01-01 10:00:00 | INFO | API基础URL: https://api.openweathermap.org/data/2.5
2024-01-01 10:00:00 | INFO | 缓存TTL: 300秒
第五步:配置Claude Code
现在我们需要让Claude Code能够使用我们的天气MCP工具。
方法一:使用命令行配置(推荐)
bash
# 添加到Claude Code的MCP配置中
claude mcp add weather-mcp python /path/to/your/weather-mcp/run.py
# 验证配置
claude mcp list
方法二:手动编辑配置文件
找到Claude Code的配置文件并添加我们的MCP:
macOS/Linux配置路径 :~/.claude/config.json
Windows配置路径 :%APPDATA%\Claude\config.json
json
{
"mcpServers": {
"weather-mcp": {
"command": "python",
"args": ["/绝对路径/weather-mcp/run.py"],
"env": {
"OPENWEATHER_API_KEY": "你的API_KEY"
},
"description": "天气查询MCP工具 - 支持当前天气、预报、空气质量查询"
}
}
}
💥 Leo哥踩坑实录: 配置路径一定要用绝对路径!我开始用相对路径,结果Claude找不到我的脚本文件。还有,如果你用的是虚拟环境,command要指向虚拟环境中的python,比如:/path/to/venv/bin/python
。
使用效果展示
配置完成后,重启Claude Code,现在我们可以测试新的天气MCP工具了!
基础功能测试
测试1:查询当前天气
请你使用weather-mcp mcp 查询 HuBei 明天的天气情况
正常情况下,Claude会自动调用我们的 get_current_weather
工具,返回类似这样的结果:
测试2:查询天气预报
ShangHai未来3天的天气怎么样?

高级功能测试
多城市对比:
帮我对比一下Beijing、ShangHai、GuangZhou三个城市的当前天气情况
旅行规划助手:
我下周要去Hangzhou出差,帮我看看那边的天气,给个穿衣建议
🤔 思考时间: 你有没有发现,我们的MCP工具配合Claude的自然语言理解能力,已经可以实现相当智能的天气助手功能了?这就是MCP协议的魅力所在!
Leo哥踩坑经验
在开发这个天气MCP的过程中,我踩了不少坑。这里分享一些关键的经验,希望能帮大家避免同样的问题。
坑位1:城市名处理的编码噩梦
💥 踩坑过程: 用户输入"北京"查询天气时,API返回404错误。调试了半天发现,OpenWeatherMap对中文城市名支持不够好,需要用英文名或者城市ID。
解决方案:
- 建立城市映射表:常用中文城市名到英文名的映射
- 模糊匹配:使用城市搜索API先找到准确的城市信息
- 用户提示:当城市不存在时,提供相似的建议
python
# 城市名映射表示例
CITY_MAPPING = {
"北京": "Beijing",
"上海": "Shanghai",
"广州": "Guangzhou",
"深圳": "Shenzhen",
# 更多映射...
}
def normalize_city_name(city: str) -> str:
"""规范化城市名称"""
return CITY_MAPPING.get(city, city)
坑23:异步编程的内存泄漏
💥 踩坑过程: 在高频调用测试中,发现程序内存占用越来越高,最后直接OOM了。排查后发现是aiohttp session没有正确关闭导致的连接池泄漏。
解决方案:
- 正确使用async context manager:确保资源及时释放
- 连接池配置:合理设置连接池大小和超时时间
- 内存监控:在生产环境中监控内存使用情况
python
# 正确的做法
async with weather_api:
result = await weather_api.get_current_weather(city)
# 错误的做法(会导致资源泄漏)
weather_api = WeatherAPI()
result = await weather_api.get_current_weather(city)
# 忘记关闭session
坑位3:缓存策略的平衡艺术
💥 踩坑过程: 最初没有缓存,API调用很快就超额了。后来加了缓存,但是用户抱怨天气信息不够及时,特别是在天气变化大的时候。
解决方案:
- 差异化缓存策略:当前天气5分钟缓存,预报数据1小时缓存
- 智能失效策略:根据天气变化程度动态调整缓存时间
- 缓存统计:记录缓存命中率,优化缓存策略
扩展开发思路
现在我们有了一个基础的天气MCP工具,但这只是开始。让我们看看还有哪些扩展的可能性。
功能扩展方向
1. 智能推荐系统
python
def get_clothing_recommendation(weather: CurrentWeather) -> str:
"""根据天气推荐着装"""
temp = weather.temperature
if temp < 0:
return "🧥 建议穿厚羽绒服、戴帽子和手套"
elif temp < 10:
return "🧥 建议穿厚外套或毛衣"
elif temp < 20:
return "👔 建议穿薄外套或长袖衬衫"
elif temp < 30:
return "👕 建议穿短袖T恤或薄长袖"
else:
return "🩱 建议穿清凉的夏装,注意防晒"
def get_activity_suggestion(weather: CurrentWeather) -> str:
"""根据天气推荐活动"""
if "雨" in weather.weather_description:
return "🏠 适合室内活动,如看电影、读书"
elif weather.temperature > 25:
return "🏊 适合游泳、海边活动,注意防晒"
elif 15 < weather.temperature < 25:
return "🚶 适合户外散步、郊游、运动"
else:
return "☕ 适合室内活动,喝热饮保暖"
2. 历史数据分析
python
class WeatherAnalyzer:
"""天气数据分析器"""
async def get_historical_comparison(self, city: str, days: int = 30) -> Dict:
"""与历史同期天气对比"""
# 这里可以集成历史天气API
pass
async def predict_trend(self, city: str) -> str:
"""基于历史数据预测天气趋势"""
# 简单的趋势分析逻辑
pass
async def get_climate_summary(self, city: str, month: int) -> Dict:
"""获取指定月份的气候概况"""
# 返回该城市该月份的平均气候数据
pass
3. 多数据源整合
python
class MultiSourceWeatherAPI:
"""多数据源天气API"""
def __init__(self):
self.sources = {
"openweather": OpenWeatherAPI(),
"accuweather": AccuWeatherAPI(), # 假设的其他API
"weatherapi": WeatherAPIcom(),
}
async def get_consensus_weather(self, city: str) -> CurrentWeather:
"""获取多个数据源的共识天气"""
results = []
for name, api in self.sources.items():
try:
weather = await api.get_current_weather(city)
results.append(weather)
except Exception as e:
logger.warning(f"{name} API调用失败: {e}")
# 计算平均值或使用投票机制
return self._calculate_consensus(results)
故障排查指南
在使用过程中,可能会遇到各种问题。这里提供一个详细的故障排查指南。
常见问题及解决方案
问题1:Claude找不到天气MCP工具
bash
# 症状:/mcp命令中看不到weather-mcp
# 解决步骤:
# 1. 检查配置是否正确
claude mcp list
# 2. 检查Python路径和脚本路径
which python
ls -la /path/to/weather-mcp/run.py
# 3. 测试脚本是否能单独运行
python /path/to/weather-mcp/run.py
# 4. 重启Claude Code
问题2:API密钥相关错误
bash
# 症状:401 Unauthorized错误
# 解决步骤:
# 1. 验证API密钥格式
echo $OPENWEATHER_API_KEY | wc -c # 应该是33个字符(包括换行符)
# 2. 测试API密钥可用性
curl "https://api.openweathermap.org/data/2.5/weather?q=London&appid=$OPENWEATHER_API_KEY"
# 3. 检查环境变量是否正确设置
python -c "from src.weather_mcp.config import config; print(config.api_key[:8] + '...')"
问题3:网络连接问题
python
# 网络诊断脚本
import asyncio
import aiohttp
async def diagnose_network():
"""网络连接诊断"""
test_urls = [
"https://api.openweathermap.org",
"https://www.google.com",
"https://httpbin.org/get"
]
async with aiohttp.ClientSession() as session:
for url in test_urls:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as response:
print(f"✅ {url}: {response.status}")
except Exception as e:
print(f"❌ {url}: {e}")
# 运行诊断
asyncio.run(diagnose_network())
日志分析技巧
启用详细日志:
python
# 在config.py中添加
import logging
# 设置日志级别
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# 或者使用loguru的详细配置
logger.add("debug.log", level="DEBUG", rotation="1 MB")
关键日志检查点:
- MCP连接日志:检查Claude是否成功连接到我们的服务器
- API调用日志:查看具体的API请求和响应
- 缓存日志:确认缓存机制是否正常工作
- 错误日志:定位具体的错误原因
总结
恭喜你!现在你已经拥有了一个完整可用的天气查询MCP工具,并且掌握了MCP开发的核心技能。
💡 Leo哥的最后建议:
MCP开发的核心不在于技术本身,而在于找到真实的用户需求。最好的MCP工具都是从解决实际问题开始的。
记住,每个成功的MCP工具背后,都有一个明确的使用场景和用户痛点。先找到问题,再用技术解决它。
关于作者: Leo哥,专注AI工具和开发效率提升,分享实用的技术经验和避坑指南。
有问题欢迎交流讨论!