mcp_weather_server.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. # 【启动 S2】独立 MCP 工具服务,监听 8002;供 A2A 子助手调用。
  2. # 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
  3. # 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
  4. # 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
  5. # → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
  6. # ============================================================
  7. # MCP 天气服务模块
  8. # 功能:提供天气查询的 MCP(Model Context Protocol)工具接口
  9. # 支持两种数据源:MySQL 数据库 / 和风天气 API(通过配置切换)
  10. # 启动入口:文件末尾 __main__;运行环境需要能导入 SmartBizTrip 包
  11. # 访问地址:http://127.0.0.1:8002/mcp
  12. #
  13. # 调用流程图:
  14. #
  15. # AI 智能体(如 Claude)
  16. # │
  17. # ▼
  18. # ┌─────────────────────────────┐
  19. # │ MCP Server (端口 8002) │
  20. # │ create_weather_mcp_server() │
  21. # │ FastMCP("WeatherTools") │
  22. # └──────────────┬──────────────┘
  23. # │
  24. # ▼
  25. # query_weather(city, start_date, end_date)
  26. # │
  27. # ┌─────────┴─────────┐
  28. # │ 检查 weather_source│
  29. # └─────────┬─────────┘
  30. # │
  31. # ┌─────────┴─────────┐
  32. # │ │
  33. # "api" 其他("db")
  34. # │ │
  35. # ▼ ▼
  36. # ┌──────────────┐ ┌──────────────────┐
  37. # │ fetch_weather│ │ WeatherService │
  38. # │ _from_api() │ │ .query_weather() │
  39. # └──────┬───────┘ └────────┬─────────┘
  40. # │ │
  41. # ▼ ▼
  42. # ① geo接口:城市→ID SQL查询 weather_data 表
  43. # │ SELECT ... FROM weather_data
  44. # ▼ WHERE city = %s AND
  45. # ② 天气API:用ID获取数据 fx_date BETWEEN %s AND %s
  46. # │ │
  47. # ▼ ▼
  48. # ③ 过滤日期范围 遍历结果集(字典格式)
  49. # ④ 字段名映射 处理 date/Decimal 类型
  50. # tempMax→temp_max │
  51. # textDay→text_day ▼
  52. # json.dumps() 序列化为 JSON 字符串
  53. # │ │
  54. # └─────────┬─────────┘
  55. # ▼
  56. # JSON 字符串返回给 AI 智能体
  57. #
  58. # 返回格式示例:
  59. # {
  60. # "status": "success",
  61. # "data": [
  62. # {
  63. # "city": "成都",
  64. # "fx_date": "2026-04-29",
  65. # "temp_max": 28,
  66. # "temp_min": 18,
  67. # "text_day": "多云",
  68. # "text_night": "晴",
  69. # "humidity": 65,
  70. # "wind_dir_day": "南风",
  71. # "wind_scale_day": "3",
  72. # "precip": 0.0
  73. # }
  74. # ]
  75. # }
  76. #
  77. # 数据源切换:修改 config.py 中的 weather_source 配置
  78. # weather_source = "api" → 使用和风天气实时 API
  79. # weather_source = "db" → 使用本地 MySQL 数据库
  80. # ============================================================
  81. import mysql.connector # MySQL 数据库驱动,用于连接和查询本地天气数据
  82. import json # JSON 序列化/反序列化,将 Python 字典转为 JSON 字符串返回
  83. from datetime import date, datetime, timedelta # 日期类型,处理数据库返回的时间字段
  84. from decimal import Decimal # 高精度数字类型,处理数据库返回的数值字段
  85. from python_a2a import FastMCP, create_fastapi_app # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
  86. import uvicorn # ASGI 服务器,用于运行 FastAPI 应用
  87. from config import Config # 项目配置类,读取数据库/API 等配置信息
  88. from create_logger import logger # 日志记录器,统一输出运行日志
  89. from utils.format import DateEncoder, default_encoder # 自定义 JSON 编码器,处理 date/Decimal 等特殊类型
  90. import requests # HTTP 请求库,用于调用和风天气 API
  91. # 加载全局配置对象
  92. conf = Config()
  93. class WeatherService:
  94. """
  95. 天气服务类:负责从 MySQL 数据库查询天气数据
  96. 工作原理:在初始化时建立数据库长连接,后续查询复用该连接
  97. """
  98. def __init__(self):
  99. # 建立 MySQL 数据库连接,使用 config.py 中的配置
  100. # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
  101. # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
  102. # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
  103. # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
  104. self.conn = mysql.connector.connect(
  105. host=conf.host, # 数据库主机地址,如 "localhost"
  106. user=conf.user, # 数据库用户名
  107. password=conf.password, # 数据库密码
  108. database=conf.database # 数据库名称
  109. )
  110. def query_weather(self, city: str, start_date: str, end_date: str) -> str:
  111. """
  112. 参数化查询数据库天气数据,返回 JSON 字符串
  113. 参数:
  114. city: 城市名称,如 "成都"
  115. start_date: 开始日期,格式 "YYYY-MM-DD"
  116. end_date: 结束日期,格式 "YYYY-MM-DD"
  117. 返回:
  118. JSON 字符串,包含 status(状态)和 data(天气数据列表)
  119. """
  120. # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
  121. # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
  122. # 调用者:MCP 包装函数在数据库分支时调用。city 是城市名,日期区间由 start_date/end_date 指定。
  123. # start_date=end_date 表示查某一天;BETWEEN 包含开始和结束日期,ORDER BY fx_date 按日期升序返回。
  124. # SELECT 明确选择天气字段,所以结果不包含 weather_data 表的全部列。
  125. try:
  126. # 创建游标,dictionary=True 使返回结果为字典而非元组
  127. # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
  128. # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
  129. cursor = self.conn.cursor(dictionary=True)
  130. # 参数化 SQL 查询:使用 %s 占位符防止 SQL 注入攻击
  131. # 查询 weather_data 表中指定城市和时间范围内的天气记录
  132. sql = ("SELECT city, fx_date, temp_max, temp_min, text_day, text_night, "
  133. "humidity, wind_dir_day, wind_scale_day, precip FROM weather_data "
  134. "WHERE city = %s AND fx_date BETWEEN %s AND %s ORDER BY fx_date")
  135. # 执行查询,参数以列表形式传入,自动转义防止注入
  136. # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
  137. # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
  138. cursor.execute(sql, [city, start_date, end_date])
  139. # 获取所有匹配的查询结果
  140. # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
  141. results = cursor.fetchall()
  142. # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
  143. cursor.close() # 关闭游标释放资源
  144. # 处理特殊类型字段:date/Decimal 等类型无法直接 JSON 序列化,需要转换
  145. # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
  146. # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
  147. for result in results:
  148. # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
  149. # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
  150. for key, value in result.items():
  151. if isinstance(value, (date, datetime, timedelta, Decimal)):
  152. result[key] = default_encoder(value) # 日期/时间转为字符串,Decimal 转为 float
  153. # 根据是否有结果返回不同格式:
  154. # - 有数据:status="success" + data 列表
  155. # - 无数据:status="no_data" + 提示信息
  156. # ensure_ascii=False 保证中文正常显示而非 Unicode 转义
  157. # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
  158. # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
  159. return json.dumps(
  160. {"status": "success", "data": results} if results
  161. else {"status": "no_data", "message": "未找到天气数据,请确认城市和日期。"},
  162. cls=DateEncoder, ensure_ascii=False
  163. )
  164. # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
  165. # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
  166. except Exception as e:
  167. # 捕获异常,记录日志并返回错误信息
  168. logger.error(f"天气查询错误: {str(e)}")
  169. return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
  170. def fetch_weather_from_api(city: str, start_date: str, end_date: str) -> str:
  171. """
  172. 从和风天气 API 获取天气数据
  173. 工作流程:
  174. 1. 先用城市名调用 geo 接口获取 location_id
  175. 2. 再用 location_id 调用天气预报接口获取数据
  176. 3. 过滤出指定日期范围内的数据,格式化后返回
  177. 参数:
  178. city: 城市名称,如 "成都"
  179. start_date: 开始日期,格式 "YYYY-MM-DD"
  180. end_date: 结束日期,格式 "YYYY-MM-DD"
  181. 返回:
  182. JSON 字符串,格式与数据库查询一致
  183. """
  184. # 【天气 API 内部步骤 1】用城市名查 location_id;找不到城市时立即返回 no_data。
  185. # 调用者:MCP 包装函数在 api 分支时调用。输入仍是城市和日期区间。
  186. # 输出与数据库分支一样采用 JSON 文本,让上游可以按同一种 status/data/message 结构读取。
  187. # 两次 requests.get 按先后执行:没有城市 ID,就不会请求天气预报。
  188. logger.info(f"从和风天气API获取数据: {city}, {start_date} ~ {end_date}")
  189. # 设置请求头,传入和风天气 API Key(认证凭证)
  190. # X-QW-Api-Key 是请求认证头;密钥从配置读取,城市与日期不是放在这个头里。
  191. headers = {
  192. "X-QW-Api-Key": conf.weather_api_key
  193. }
  194. # 第一步:城市名 → 地理位置 ID(和风天气需要先查城市 ID)
  195. geo_url = f"https://{conf.weather_api_host}/geo/v2/city/lookup?location={city}"
  196. geo_resp = requests.get(geo_url, headers=headers, timeout=15)
  197. geo_resp.raise_for_status()
  198. # 把 HTTP 响应体解析为 Python 字典;HTTP 响应对象与解析后的字典是两个不同层次。
  199. geo_data = geo_resp.json()
  200. # 如果 API 没有找到该城市,直接返回错误信息
  201. # 没有 location 字段或列表为空都提前返回 no_data,避免下一行取第一个元素时越界。
  202. if not geo_data.get("location"):
  203. return json.dumps({"status": "no_data", "message": f"未找到城市:{city}"}, ensure_ascii=False)
  204. # 取第一个匹配的城市 ID
  205. # 若城市查找返回多个候选,本实现直接采用第一个候选,并不再询问用户选择。
  206. location_id = geo_data["location"][0]["id"]
  207. # 第二步:用 location_id 获取天气预报数据
  208. # 【天气 API 内部步骤 2】用 location_id 请求预报;可查范围受配置 URL 的预报天数限制。
  209. weather_url = f"{conf.weather_base_url}?location={location_id}"
  210. weather_resp = requests.get(weather_url, headers=headers, timeout=15)
  211. weather_resp.raise_for_status()
  212. weather_data = weather_resp.json()
  213. # 第三步:过滤日期范围,并将 API 返回的字段映射为数据库同名字段
  214. results = []
  215. # 【天气 API 内部步骤 3】仅保留请求区间内的返回日期,统一字段名后交回 MCP。
  216. # 有返回数据也不保证覆盖整个请求区间;代码未在这里检查日期是否齐全。
  217. for day in weather_data.get("daily", []):
  218. # 字符串比较即可,因为日期格式都是 "YYYY-MM-DD"
  219. # 只有规范的 YYYY-MM-DD 格式才适合这种字符串日期比较;当前函数没有额外进行日期格式校验。
  220. # 不在区间内的 day 不进入 results,继续处理下一天。
  221. if start_date <= day.get("fxDate", "") <= end_date:
  222. # 将 API 的驼峰字段统一成数据库风格字段,并把温度/湿度/降水量转成数值。
  223. # 部分字段缺失时 get 默认 0 或空串,这是代码的兜底值,不能理解成接口确认的天气数值。
  224. results.append({
  225. "city": city, # 城市名
  226. "fx_date": day.get("fxDate"), # 预报日期
  227. "temp_max": int(day.get("tempMax", 0)), # 最高温度(转为整数)
  228. "temp_min": int(day.get("tempMin", 0)), # 最低温度(转为整数)
  229. "text_day": day.get("textDay", ""), # 白天天气描述
  230. "text_night": day.get("textNight", ""), # 夜间天气描述
  231. "humidity": int(day.get("humidity", 0)), # 湿度百分比
  232. "wind_dir_day": day.get("windDirDay", ""), # 白天风向
  233. "wind_scale_day": day.get("windScaleDay", ""), # 白天风力等级
  234. "precip": float(day.get("precip", 0)) # 降水量(保留浮点)
  235. })
  236. # 根据是否有结果返回对应格式
  237. if results:
  238. return json.dumps({"status": "success", "data": results}, ensure_ascii=False)
  239. return json.dumps({"status": "no_data", "message": "未找到天气数据。"}, ensure_ascii=False)
  240. def create_weather_mcp_server():
  241. """
  242. 创建并启动天气 MCP 服务器
  243. MCP(Model Context Protocol):一种协议,允许 AI 模型(如 Claude)通过标准接口调用外部工具
  244. 本模块将天气查询功能封装为 MCP 工具,供 AI 智能体调用
  245. """
  246. # 创建 FastMCP 实例,相当于定义一个工具服务
  247. # TODO 1.构建FastMCP实例
  248. # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
  249. # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
  250. # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
  251. # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
  252. # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
  253. weather_mcp = FastMCP(
  254. name="WeatherTools", # 工具服务名称,AI 调用时会看到这个名称
  255. )
  256. # 实例化数据库查询服务
  257. # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
  258. # API 模式不创建数据库连接;只有数据库模式才需要 WeatherService。
  259. service = WeatherService() if conf.weather_source != "api" else None
  260. # TODO 2.基于MCP实例对象,使用装饰器的方式注册可用工具
  261. # TODO description非常重要,尤其是日期的格式,需要明确指定。不然调用API可能会失败
  262. # 使用 @tool 装饰器将函数注册为 MCP 工具
  263. # name: 工具的唯一标识,AI 通过这个名字调用
  264. # description: 工具说明,AI 会阅读这段描述来判断是否使用该工具
  265. @weather_mcp.tool(
  266. name="query_weather",
  267. description="查询天气数据,参数:city(城市), start_date(开始日期YYYY-MM-DD), end_date(结束日期YYYY-MM-DD)"
  268. )
  269. def query_weather(city: str, start_date: str, end_date: str) -> str:
  270. """
  271. MCP 工具入口函数:根据配置选择从数据库或 API 获取天气数据
  272. 参数:
  273. city: 城市名称,如 "成都"
  274. start_date: 开始日期,格式 "YYYY-MM-DD"
  275. end_date: 结束日期,格式 "YYYY-MM-DD"
  276. 返回:
  277. JSON 字符串,包含天气数据
  278. """
  279. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  280. # 调用者:MCP 框架按工具名 query_weather 分派到这里。
  281. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  282. # 同名的 WeatherService.query_weather 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  283. logger.info(f"执行天气查询: {city}, {start_date} ~ {end_date}")
  284. # 根据 config.py 中的 weather_source 配置决定数据源:
  285. # - "api": 调用和风天气实时 API
  286. # - 其他值(如 "db"): 查询本地 MySQL 数据库
  287. # 【主流程 8.2】这是互斥分支:api 走远程预报接口,其余值走 MySQL。
  288. if conf.weather_source == "api":
  289. return fetch_weather_from_api(city, start_date, end_date)
  290. return service.query_weather(city, start_date, end_date)
  291. # 打印服务启动信息到日志
  292. logger.info("=== 天气MCP服务器信息 ===")
  293. logger.info(f"名称: {weather_mcp.name}")
  294. # TODO 3.启动fastmcp server : 把MCP Server对象转成fastapi服务, 通过uvicorn启动fastapi
  295. try:
  296. # 提示用户访问地址
  297. print("服务器已启动,请访问 http://127.0.0.1:8002/mcp")
  298. # 将 FastMCP 对象转为 FastAPI 应用
  299. # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
  300. weather_mcp_server = create_fastapi_app(weather_mcp)
  301. # 使用 uvicorn 启动 HTTP 服务
  302. # host="0.0.0.0" 表示允许外部访问,port=8002 是服务端口
  303. uvicorn.run(weather_mcp_server, host="0.0.0.0", port=8002)
  304. except Exception as e:
  305. # 捕获启动异常
  306. print(f"服务器启动失败: {e}")
  307. if __name__ == '__main__':
  308. # 调试用法:直接调用 API 测试,不启动 MCP 服务
  309. # api = fetch_weather_from_api(city="成都", start_date="2026-04-28", end_date="2026-05-01")
  310. # print(api)
  311. # 启动 MCP 天气服务器
  312. create_weather_mcp_server()