| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- # 【启动 S2】独立 MCP 工具服务,监听 8001;供 A2A 子助手调用。
- # 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
- # 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
- # 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
- # → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
- """
- 票务 MCP Server 模块
- ====================
- 功能:为企业差旅智能体提供票务相关的 MCP 工具(Tool)
- - 高铁票查询 / 预定
- - 航班查询 / 预定
- - 会展门票查询 / 预定
- 技术要点:
- 1. 使用 MySQL 参数化查询(%s 占位符),防止 SQL 注入
- 2. 通过 FastMCP 框架将 Python 方法注册为 MCP 工具,供 Agent 调用
- 3. 自定义 JSON 编码器处理 date/datetime/Decimal 等特殊类型
- """
- import mysql.connector
- import json
- from datetime import date, datetime, timedelta
- from decimal import Decimal
- from python_a2a import FastMCP, create_fastapi_app # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
- import uvicorn # ASGI 服务器,用于运行 FastAPI 应用
- from config import Config
- from create_logger import logger
- from utils.format import DateEncoder, default_encoder
- # 加载配置文件中的数据库连接参数
- conf = Config()
- # ============================================================
- # 票务服务类 —— 封装所有票务相关的数据库查询和预定逻辑
- # ============================================================
- class TicketService:
- """
- 票务服务类,负责高铁票、航班、会展门票的查询与预定。
- 所有查询方法统一通过 _execute_query 执行参数化 SQL,返回标准 JSON 字符串。
- """
- def __init__(self):
- """初始化方法:建立 MySQL 数据库连接"""
- # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
- # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
- # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
- # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
- self.conn = mysql.connector.connect(
- host=conf.host, # 数据库主机地址,如 "localhost" 或 "192.168.1.100"
- user=conf.user, # 数据库用户名
- password=conf.password, # 数据库密码
- database=conf.database # 数据库名称
- )
- def _execute_query(self, sql: str, params: list = None) -> str:
- """
- 执行参数化查询的通用方法。
- 参数:
- sql: SQL 语句,使用 %s 作为参数占位符(注意不是 Python 的 % 格式化)
- params: 参数列表,按顺序填充 %s 占位符
- 返回:
- JSON 字符串,包含三种可能的结构:
- - {"status": "success", "data": [...]} 查询到数据
- - {"status": "no_data", "message": "..."} 无匹配数据
- - {"status": "error", "message": "..."} SQL 执行异常
- 关键制度要点:
- - dictionary=True:cursor 返回字典而非元组,便于通过列名访问
- - default_encoder:将日期/时间转为字符串,Decimal 转为浮点数
- - DateEncoder:json.dumps 的自定义编码器类
- """
- # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
- # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
- try:
- # 创建游标,dictionary=True 使结果以字典列表形式返回
- # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
- # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
- cursor = self.conn.cursor(dictionary=True)
- # 执行参数化查询 —— params 中的值会自动转义,防止 SQL 注入
- # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
- # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
- cursor.execute(sql, params or [])
- # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
- results = cursor.fetchall()
- # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
- cursor.close()
- # 遍历结果,转换为 JSON 能处理的字符串或数值
- # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
- # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
- for result in results:
- # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
- # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
- for key, value in result.items():
- if isinstance(value, (date, datetime, timedelta, Decimal)):
- result[key] = default_encoder(value)
- # 根据是否有数据返回不同的 JSON 结构
- # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
- # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
- return json.dumps(
- {"status": "success", "data": results} if results
- else {"status": "no_data", "message": "未找到票务数据,请确认查询条件。"},
- cls=DateEncoder, ensure_ascii=False # ensure_ascii=False 保证中文正常显示
- )
- # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
- # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
- except Exception as e: # noqa: BLE001
- # 记录错误日志并返回错误 JSON
- logger.error(f"票务查询错误: {str(e)}")
- return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
- def query_train(self, departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
- """
- 查询高铁票。
- 参数:
- departure_city: 出发城市,如 "北京"
- arrival_city: 到达城市,如 "上海"
- date: 出发日期,格式 "YYYY-MM-DD"
- seat_type: 座位类型(可选),如 "一等座"、"二等座"
- 返回:
- JSON 字符串,包含车次、时间、价格、余票等信息
- SQL 说明:
- - WHERE 条件使用 %s 占位符,参数通过 params 列表传入
- - 如果指定了 seat_type,动态追加 AND 条件
- - DATE(departure_time) 提取日期部分进行匹配
- """
- # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
- # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
- # 数据源:MySQL 的 train_tickets 表,SQL 写在代码中,由参数决定筛选值。
- # 例如 seat_type="二等座" 才追加座席条件;未提供时保留该日各座席类型。
- sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
- "train_number, seat_type, price, remaining_seats FROM train_tickets "
- "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
- # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
- params = [departure_city, arrival_city, date]
- # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
- if seat_type:
- sql += " AND seat_type = %s"
- # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
- params.append(seat_type)
- # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
- return self._execute_query(sql, params)
- def query_flight(self, departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
- """
- 查询航班。
- 参数:
- departure_city: 出发城市
- arrival_city: 到达城市
- date: 出发日期,格式 "YYYY-MM-DD"
- cabin_type: 舱位类型(可选),如 "经济舱"、"商务舱"
- 返回:
- JSON 字符串,包含航班号、时间、价格、余票等信息
- """
- # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
- # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
- # 数据源:MySQL 的 flight_tickets 表,SQL 写在代码中,由参数决定筛选值。
- # 例如 cabin_type="经济舱" 才追加舱位条件;未提供时查询所有符合基础条件的舱位。
- sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
- "flight_number, cabin_type, price, remaining_seats FROM flight_tickets "
- "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
- # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
- params = [departure_city, arrival_city, date]
- # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
- if cabin_type:
- sql += " AND cabin_type = %s"
- # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
- params.append(cabin_type)
- # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
- return self._execute_query(sql, params)
- def query_event(self, city: str, event_name: str, date: str, ticket_type: str = None) -> str:
- """
- 查询会展活动/会展票。
- 参数:
- city: 举办城市,如 "北京"
- event_name: 活动名称,如 "成都企业数字化转型峰会"
- date: 举办日期,格式 "YYYY-MM-DD"
- ticket_type: 票类型(可选),如 "VIP"、"普通票"
- 返回:
- JSON 字符串,包含活动名称、场馆、时间、票价、余票等信息
- """
- # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
- # 调用者:同名 MCP 包装函数。输入:举办城市、活动名、日期;输出:查询结果的 JSON 字符串。
- # 数据源:MySQL 的 event_tickets 表,SQL 写在代码中,由参数决定筛选值。
- # event_name 使用等值匹配;这不是语义检索,也没有使用 LIKE 做模糊匹配。
- sql = ("SELECT id, event_name, city, venue, start_time, end_time, "
- "ticket_type, price, remaining_seats FROM event_tickets "
- "WHERE city = %s AND event_name = %s AND DATE(start_time) = %s")
- # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
- params = [city, event_name, date]
- # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
- if ticket_type:
- sql += " AND ticket_type = %s"
- # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
- params.append(ticket_type)
- # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
- return self._execute_query(sql, params)
- # --------------------------------------------------------
- # 预定方法(演示环境:仅模拟返回,未实际写入数据库)
- # --------------------------------------------------------
- def order_train(self, departure_date: str, train_number: str, seat_type: str, number: int) -> str:
- """模拟高铁票预定,返回预定成功信息"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- return f"高铁票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},车次:{train_number},座位:{seat_type},数量:{number}张。"
- def order_flight(self, departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
- """模拟航班预定,返回预定成功信息"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- return f"航班预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},航班号:{flight_number},舱位:{cabin_type},数量:{number}张。"
- def order_event(self, start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
- """模拟会展门票预定,返回预定成功信息"""
- # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
- # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
- # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
- # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
- return f"会展票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{start_date},活动名称:{event_name},场馆:{venue},票类型:{ticket_type},数量:{number}张。"
- def create_ticket_mcp_server():
- # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
- # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
- # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
- # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
- # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
- ticket_mcp = FastMCP(
- name="TicketTools",
- )
- # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
- service = TicketService()
- # 注册查询工具
- @ticket_mcp.tool(
- name="query_train",
- description="查询高铁票信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), seat_type(座位类型,可选)"
- )
- def query_train(departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_train 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.query_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_train(departure_city, arrival_city, date, seat_type)
- @ticket_mcp.tool(
- name="query_flight",
- description="查询航班信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), cabin_type(舱位类型,可选)"
- )
- def query_flight(departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_flight 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.query_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_flight(departure_city, arrival_city, date, cabin_type)
- @ticket_mcp.tool(
- name="query_event",
- description="查询会展门票信息,参数:city(城市), event_name(活动名称), date(日期), ticket_type(票类型,可选)"
- )
- def query_event(city: str, event_name: str, date: str, ticket_type: str = None) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 query_event 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.query_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.query_event(city, event_name, date, ticket_type)
- # 注册预定工具
- @ticket_mcp.tool(
- name="order_train",
- description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据车次号、日期、座位类型和数量预定高铁票。参数:train_number(车次号,如G123), departure_date(出发日期,格式YYYY-MM-DD), seat_type(座位类型,如二等座/一等座/商务座), number(订票数量)"
- )
- def order_train(departure_date: str, train_number: str, seat_type: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_train 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.order_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.order_train(departure_date, train_number, seat_type, number)
- @ticket_mcp.tool(
- name="order_flight",
- description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据航班号、日期、舱位类型和数量预定航班。参数:flight_number(航班号,如CA4101), departure_date(出发日期,格式YYYY-MM-DD), cabin_type(舱位类型,如经济舱/商务舱/头等舱), number(订票数量)"
- )
- def order_flight(departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_flight 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.order_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.order_flight(departure_date, flight_number, cabin_type, number)
- @ticket_mcp.tool(
- name="order_event",
- description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据活动名称、日期、场馆、票类型和数量预定会展门票。参数:event_name(活动名称), start_date(举办日期,格式YYYY-MM-DD), venue(会展场馆名), ticket_type(票类型,如VIP/普通票), number(订票数量)"
- )
- def order_event(start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
- # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
- # 调用者:MCP 框架按工具名 order_event 分派到这里。
- # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
- # 同名的 TicketService.order_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
- return service.order_event(start_date, event_name, venue, ticket_type, number)
- logger.info("=== 票务MCP服务器信息 ===")
- logger.info(f"名称: {ticket_mcp.name}")
- try:
- print("服务器已启动,请访问 http://127.0.0.1:8001/mcp")
- # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
- ticket_mcp_server = create_fastapi_app(ticket_mcp)
- uvicorn.run(ticket_mcp_server, host="0.0.0.0", port=8001)
- except Exception as e:
- print(f"服务器启动失败: {e}")
- if __name__ == '__main__':
- # service = TicketService()
- # train = service.query_train("北京", "成都", "2026-05-01", "二等座")
- # print(train)
- # flight = service.query_flight("北京", "成都", "2026-05-01", "头等舱")
- # print(flight)
- create_ticket_mcp_server()
|