mcp_ticket_server.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. # 【启动 S2】独立 MCP 工具服务,监听 8001;供 A2A 子助手调用。
  2. # 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
  3. # 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
  4. # 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
  5. # → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
  6. """
  7. 票务 MCP Server 模块
  8. ====================
  9. 功能:为企业差旅智能体提供票务相关的 MCP 工具(Tool)
  10. - 高铁票查询 / 预定
  11. - 航班查询 / 预定
  12. - 会展门票查询 / 预定
  13. 技术要点:
  14. 1. 使用 MySQL 参数化查询(%s 占位符),防止 SQL 注入
  15. 2. 通过 FastMCP 框架将 Python 方法注册为 MCP 工具,供 Agent 调用
  16. 3. 自定义 JSON 编码器处理 date/datetime/Decimal 等特殊类型
  17. """
  18. import mysql.connector
  19. import json
  20. from datetime import date, datetime, timedelta
  21. from decimal import Decimal
  22. from python_a2a import FastMCP, create_fastapi_app # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
  23. import uvicorn # ASGI 服务器,用于运行 FastAPI 应用
  24. from config import Config
  25. from create_logger import logger
  26. from utils.format import DateEncoder, default_encoder
  27. # 加载配置文件中的数据库连接参数
  28. conf = Config()
  29. # ============================================================
  30. # 票务服务类 —— 封装所有票务相关的数据库查询和预定逻辑
  31. # ============================================================
  32. class TicketService:
  33. """
  34. 票务服务类,负责高铁票、航班、会展门票的查询与预定。
  35. 所有查询方法统一通过 _execute_query 执行参数化 SQL,返回标准 JSON 字符串。
  36. """
  37. def __init__(self):
  38. """初始化方法:建立 MySQL 数据库连接"""
  39. # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
  40. # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
  41. # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
  42. # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
  43. self.conn = mysql.connector.connect(
  44. host=conf.host, # 数据库主机地址,如 "localhost" 或 "192.168.1.100"
  45. user=conf.user, # 数据库用户名
  46. password=conf.password, # 数据库密码
  47. database=conf.database # 数据库名称
  48. )
  49. def _execute_query(self, sql: str, params: list = None) -> str:
  50. """
  51. 执行参数化查询的通用方法。
  52. 参数:
  53. sql: SQL 语句,使用 %s 作为参数占位符(注意不是 Python 的 % 格式化)
  54. params: 参数列表,按顺序填充 %s 占位符
  55. 返回:
  56. JSON 字符串,包含三种可能的结构:
  57. - {"status": "success", "data": [...]} 查询到数据
  58. - {"status": "no_data", "message": "..."} 无匹配数据
  59. - {"status": "error", "message": "..."} SQL 执行异常
  60. 关键制度要点:
  61. - dictionary=True:cursor 返回字典而非元组,便于通过列名访问
  62. - default_encoder:将日期/时间转为字符串,Decimal 转为浮点数
  63. - DateEncoder:json.dumps 的自定义编码器类
  64. """
  65. # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
  66. # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
  67. try:
  68. # 创建游标,dictionary=True 使结果以字典列表形式返回
  69. # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
  70. # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
  71. cursor = self.conn.cursor(dictionary=True)
  72. # 执行参数化查询 —— params 中的值会自动转义,防止 SQL 注入
  73. # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
  74. # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
  75. cursor.execute(sql, params or [])
  76. # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
  77. results = cursor.fetchall()
  78. # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
  79. cursor.close()
  80. # 遍历结果,转换为 JSON 能处理的字符串或数值
  81. # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
  82. # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
  83. for result in results:
  84. # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
  85. # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
  86. for key, value in result.items():
  87. if isinstance(value, (date, datetime, timedelta, Decimal)):
  88. result[key] = default_encoder(value)
  89. # 根据是否有数据返回不同的 JSON 结构
  90. # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
  91. # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
  92. return json.dumps(
  93. {"status": "success", "data": results} if results
  94. else {"status": "no_data", "message": "未找到票务数据,请确认查询条件。"},
  95. cls=DateEncoder, ensure_ascii=False # ensure_ascii=False 保证中文正常显示
  96. )
  97. # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
  98. # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
  99. except Exception as e: # noqa: BLE001
  100. # 记录错误日志并返回错误 JSON
  101. logger.error(f"票务查询错误: {str(e)}")
  102. return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
  103. def query_train(self, departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
  104. """
  105. 查询高铁票。
  106. 参数:
  107. departure_city: 出发城市,如 "北京"
  108. arrival_city: 到达城市,如 "上海"
  109. date: 出发日期,格式 "YYYY-MM-DD"
  110. seat_type: 座位类型(可选),如 "一等座"、"二等座"
  111. 返回:
  112. JSON 字符串,包含车次、时间、价格、余票等信息
  113. SQL 说明:
  114. - WHERE 条件使用 %s 占位符,参数通过 params 列表传入
  115. - 如果指定了 seat_type,动态追加 AND 条件
  116. - DATE(departure_time) 提取日期部分进行匹配
  117. """
  118. # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
  119. # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
  120. # 数据源:MySQL 的 train_tickets 表,SQL 写在代码中,由参数决定筛选值。
  121. # 例如 seat_type="二等座" 才追加座席条件;未提供时保留该日各座席类型。
  122. sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
  123. "train_number, seat_type, price, remaining_seats FROM train_tickets "
  124. "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
  125. # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
  126. params = [departure_city, arrival_city, date]
  127. # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
  128. if seat_type:
  129. sql += " AND seat_type = %s"
  130. # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
  131. params.append(seat_type)
  132. # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
  133. return self._execute_query(sql, params)
  134. def query_flight(self, departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
  135. """
  136. 查询航班。
  137. 参数:
  138. departure_city: 出发城市
  139. arrival_city: 到达城市
  140. date: 出发日期,格式 "YYYY-MM-DD"
  141. cabin_type: 舱位类型(可选),如 "经济舱"、"商务舱"
  142. 返回:
  143. JSON 字符串,包含航班号、时间、价格、余票等信息
  144. """
  145. # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
  146. # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
  147. # 数据源:MySQL 的 flight_tickets 表,SQL 写在代码中,由参数决定筛选值。
  148. # 例如 cabin_type="经济舱" 才追加舱位条件;未提供时查询所有符合基础条件的舱位。
  149. sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
  150. "flight_number, cabin_type, price, remaining_seats FROM flight_tickets "
  151. "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
  152. # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
  153. params = [departure_city, arrival_city, date]
  154. # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
  155. if cabin_type:
  156. sql += " AND cabin_type = %s"
  157. # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
  158. params.append(cabin_type)
  159. # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
  160. return self._execute_query(sql, params)
  161. def query_event(self, city: str, event_name: str, date: str, ticket_type: str = None) -> str:
  162. """
  163. 查询会展活动/会展票。
  164. 参数:
  165. city: 举办城市,如 "北京"
  166. event_name: 活动名称,如 "成都企业数字化转型峰会"
  167. date: 举办日期,格式 "YYYY-MM-DD"
  168. ticket_type: 票类型(可选),如 "VIP"、"普通票"
  169. 返回:
  170. JSON 字符串,包含活动名称、场馆、时间、票价、余票等信息
  171. """
  172. # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
  173. # 调用者:同名 MCP 包装函数。输入:举办城市、活动名、日期;输出:查询结果的 JSON 字符串。
  174. # 数据源:MySQL 的 event_tickets 表,SQL 写在代码中,由参数决定筛选值。
  175. # event_name 使用等值匹配;这不是语义检索,也没有使用 LIKE 做模糊匹配。
  176. sql = ("SELECT id, event_name, city, venue, start_time, end_time, "
  177. "ticket_type, price, remaining_seats FROM event_tickets "
  178. "WHERE city = %s AND event_name = %s AND DATE(start_time) = %s")
  179. # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
  180. params = [city, event_name, date]
  181. # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
  182. if ticket_type:
  183. sql += " AND ticket_type = %s"
  184. # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
  185. params.append(ticket_type)
  186. # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
  187. return self._execute_query(sql, params)
  188. # --------------------------------------------------------
  189. # 预定方法(演示环境:仅模拟返回,未实际写入数据库)
  190. # --------------------------------------------------------
  191. def order_train(self, departure_date: str, train_number: str, seat_type: str, number: int) -> str:
  192. """模拟高铁票预定,返回预定成功信息"""
  193. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  194. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  195. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  196. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  197. return f"高铁票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},车次:{train_number},座位:{seat_type},数量:{number}张。"
  198. def order_flight(self, departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
  199. """模拟航班预定,返回预定成功信息"""
  200. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  201. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  202. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  203. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  204. return f"航班预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},航班号:{flight_number},舱位:{cabin_type},数量:{number}张。"
  205. def order_event(self, start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
  206. """模拟会展门票预定,返回预定成功信息"""
  207. # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
  208. # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
  209. # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
  210. # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
  211. return f"会展票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{start_date},活动名称:{event_name},场馆:{venue},票类型:{ticket_type},数量:{number}张。"
  212. def create_ticket_mcp_server():
  213. # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
  214. # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
  215. # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
  216. # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
  217. # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
  218. ticket_mcp = FastMCP(
  219. name="TicketTools",
  220. )
  221. # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
  222. service = TicketService()
  223. # 注册查询工具
  224. @ticket_mcp.tool(
  225. name="query_train",
  226. description="查询高铁票信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), seat_type(座位类型,可选)"
  227. )
  228. def query_train(departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
  229. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  230. # 调用者:MCP 框架按工具名 query_train 分派到这里。
  231. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  232. # 同名的 TicketService.query_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  233. return service.query_train(departure_city, arrival_city, date, seat_type)
  234. @ticket_mcp.tool(
  235. name="query_flight",
  236. description="查询航班信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), cabin_type(舱位类型,可选)"
  237. )
  238. def query_flight(departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
  239. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  240. # 调用者:MCP 框架按工具名 query_flight 分派到这里。
  241. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  242. # 同名的 TicketService.query_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  243. return service.query_flight(departure_city, arrival_city, date, cabin_type)
  244. @ticket_mcp.tool(
  245. name="query_event",
  246. description="查询会展门票信息,参数:city(城市), event_name(活动名称), date(日期), ticket_type(票类型,可选)"
  247. )
  248. def query_event(city: str, event_name: str, date: str, ticket_type: str = None) -> str:
  249. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  250. # 调用者:MCP 框架按工具名 query_event 分派到这里。
  251. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  252. # 同名的 TicketService.query_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  253. return service.query_event(city, event_name, date, ticket_type)
  254. # 注册预定工具
  255. @ticket_mcp.tool(
  256. name="order_train",
  257. description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据车次号、日期、座位类型和数量预定高铁票。参数:train_number(车次号,如G123), departure_date(出发日期,格式YYYY-MM-DD), seat_type(座位类型,如二等座/一等座/商务座), number(订票数量)"
  258. )
  259. def order_train(departure_date: str, train_number: str, seat_type: str, number: int) -> str:
  260. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  261. # 调用者:MCP 框架按工具名 order_train 分派到这里。
  262. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  263. # 同名的 TicketService.order_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  264. return service.order_train(departure_date, train_number, seat_type, number)
  265. @ticket_mcp.tool(
  266. name="order_flight",
  267. description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据航班号、日期、舱位类型和数量预定航班。参数:flight_number(航班号,如CA4101), departure_date(出发日期,格式YYYY-MM-DD), cabin_type(舱位类型,如经济舱/商务舱/头等舱), number(订票数量)"
  268. )
  269. def order_flight(departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
  270. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  271. # 调用者:MCP 框架按工具名 order_flight 分派到这里。
  272. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  273. # 同名的 TicketService.order_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  274. return service.order_flight(departure_date, flight_number, cabin_type, number)
  275. @ticket_mcp.tool(
  276. name="order_event",
  277. description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据活动名称、日期、场馆、票类型和数量预定会展门票。参数:event_name(活动名称), start_date(举办日期,格式YYYY-MM-DD), venue(会展场馆名), ticket_type(票类型,如VIP/普通票), number(订票数量)"
  278. )
  279. def order_event(start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
  280. # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
  281. # 调用者:MCP 框架按工具名 order_event 分派到这里。
  282. # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
  283. # 同名的 TicketService.order_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
  284. return service.order_event(start_date, event_name, venue, ticket_type, number)
  285. logger.info("=== 票务MCP服务器信息 ===")
  286. logger.info(f"名称: {ticket_mcp.name}")
  287. try:
  288. print("服务器已启动,请访问 http://127.0.0.1:8001/mcp")
  289. # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
  290. ticket_mcp_server = create_fastapi_app(ticket_mcp)
  291. uvicorn.run(ticket_mcp_server, host="0.0.0.0", port=8001)
  292. except Exception as e:
  293. print(f"服务器启动失败: {e}")
  294. if __name__ == '__main__':
  295. # service = TicketService()
  296. # train = service.query_train("北京", "成都", "2026-05-01", "二等座")
  297. # print(train)
  298. # flight = service.query_flight("北京", "成都", "2026-05-01", "头等舱")
  299. # print(flight)
  300. create_ticket_mcp_server()