Files
Cloud-Tour-to-Libo/app/api/mcp_server.py

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Minimal MCP Streamable HTTP adapter for Baixinghui customer-service tools."""
from __future__ import annotations
import json
import os
import time
import uuid
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from app.api.travel_assistant import _customer_service_query_response
router = APIRouter()
MCP_PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_MCP_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", MCP_PROTOCOL_VERSION}
DEFAULT_GRAPH_NAME = "baixinghui_travel_agency"
def _tool_definitions() -> list[dict[str, Any]]:
return [
{
"name": "ask_customer_service",
"description": (
"向百姓惠旅行社知识图谱智能客服提问。适用于线路价格、行程天数、景区、酒店、费用、车型、"
"线路推荐和复杂多跳问答。只读工具,不承诺最终价格、余位、房型或景区政策。"
),
"inputSchema": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "用户自然语言问题"},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
"request_id": {"type": "string", "description": "外部请求 ID可选"},
"customer_context": {
"type": "object",
"description": "客户上下文,例如人数、预算、出发日期、偏好,可选",
},
},
"required": ["question"],
},
},
{
"name": "query_route_price",
"description": "查询指定旅行线路的参考价格、成人/儿童价格区间和不可直接承诺事项。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_name": {"type": "string", "description": "线路名称或关键词,例如 黄小西三日游"},
"traveler_type": {
"type": "string",
"description": "游客类型,可选,例如 成人、儿童、老人",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_name"],
},
},
{
"name": "list_travel_routes",
"description": "查询百姓惠旅行线路清单,可按天数、景区或关键词筛选。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "线路或目的地关键词,可选"},
"duration_days": {"type": "integer", "description": "期望天数,可选"},
"limit": {"type": "integer", "description": "返回条数上限,可选,默认 20"},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
},
},
{
"name": "query_route_detail",
"description": "查询某条线路的天数、价格、景区、附近酒店和费用边界。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_name": {"type": "string", "description": "线路名称或关键词"},
"detail_focus": {
"type": "string",
"description": "关注点,可选,例如 价格、景区、酒店、费用、车型",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_name"],
},
},
{
"name": "compare_routes",
"description": "对比两条旅行线路的适配度、景点数量、轻松程度或客服推荐理由。只读工具。",
"inputSchema": {
"type": "object",
"properties": {
"route_a": {"type": "string", "description": "第一条线路名称或关键词"},
"route_b": {"type": "string", "description": "第二条线路名称或关键词"},
"compare_focus": {
"type": "string",
"description": "对比关注点,例如 老人小孩、不太累、景点更多、价格",
},
"session_id": {"type": "string", "description": "外部会话 ID可选"},
},
"required": ["route_a", "route_b"],
},
},
]
def _json_rpc_result(message_id: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": message_id, "result": result}
def _json_rpc_error(message_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]:
error: dict[str, Any] = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": message_id, "error": error}
def _mcp_auth_enabled() -> bool:
return bool(os.getenv("BXH_MCP_TOKEN", "").strip())
def _is_authorized(request: Request) -> bool:
expected = os.getenv("BXH_MCP_TOKEN", "").strip()
if not expected:
return True
auth = request.headers.get("authorization", "").strip()
api_key = request.headers.get("x-mcp-api-key", "").strip()
bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else auth
return expected in {bearer, api_key}
def _normalize_args(params: dict[str, Any]) -> tuple[str, dict[str, Any]]:
name = str(params.get("name") or "").strip()
args = params.get("arguments")
if args is None:
args = {}
if not isinstance(args, dict):
raise ValueError("arguments must be an object")
return name, args
def _question_for_tool(name: str, args: dict[str, Any]) -> str:
if name == "ask_customer_service":
return str(args.get("question") or "").strip()
if name == "query_route_price":
route = str(args.get("route_name") or "").strip()
traveler = str(args.get("traveler_type") or "").strip()
suffix = f"{traveler}怎么收费" if traveler else ""
return f"{route}多少钱{suffix}".strip()
if name == "list_travel_routes":
keyword = str(args.get("keyword") or "").strip()
days = args.get("duration_days")
pieces = ["旅行车线路有哪些"]
if keyword:
pieces.append(f"{keyword}相关")
if days:
pieces.append(f"{days}")
return "".join(pieces) + ""
if name == "query_route_detail":
route = str(args.get("route_name") or "").strip()
focus = str(args.get("detail_focus") or "").strip()
if focus:
return f"{route}{focus}是什么?"
return f"{route}多少钱,可以玩几天,期间可以去哪些景区,附近可以入住什么酒店?"
if name == "compare_routes":
route_a = str(args.get("route_a") or "").strip()
route_b = str(args.get("route_b") or "").strip()
focus = str(args.get("compare_focus") or "哪个更适合客户").strip()
return f"{route_a}{route_b}{focus},为什么?"
raise KeyError(name)
def _mcp_content_text(payload: dict[str, Any]) -> str:
reply = str(payload.get("customer_reply") or payload.get("answer") or "").strip()
if reply:
return reply
return "当前知识图谱没有返回可直接回复的话术,请补充线路、人数、天数或出行日期后再试。"
def _mcp_structured_data(payload: dict[str, Any]) -> dict[str, Any]:
knowledge = payload.get("knowledge") if isinstance(payload.get("knowledge"), dict) else {}
return {
"status": payload.get("status") or "ok",
"service": payload.get("service") or "baixinghui_customer_service",
"request_id": payload.get("request_id"),
"trace_id": payload.get("trace_id"),
"session_id": payload.get("session_id"),
"graph_name": payload.get("graph_name"),
"question": payload.get("question"),
"customer_reply": payload.get("customer_reply"),
"answer": payload.get("answer"),
"confidence": payload.get("confidence"),
"follow_up_questions": payload.get("follow_up_questions") or [],
"risk_notes": payload.get("risk_notes") or [],
"knowledge": {
"plans": knowledge.get("plans") or [],
"evidence": knowledge.get("evidence") or [],
},
"routing": payload.get("routing") or {},
"trace": payload.get("trace") or {},
}
def _negotiate_protocol_version(params: dict[str, Any]) -> str:
requested = str(params.get("protocolVersion") or "").strip()
if requested in SUPPORTED_MCP_PROTOCOL_VERSIONS:
return requested
return MCP_PROTOCOL_VERSION
async def _call_customer_service_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
question = _question_for_tool(name, args)
if not question:
raise ValueError("question or route fields are required")
body = {
"request_id": args.get("request_id") or f"mcp-{uuid.uuid4().hex[:16]}",
"session_id": args.get("session_id") or "",
"channel": args.get("channel") or "mcp",
"graph_name": args.get("graph_name") or DEFAULT_GRAPH_NAME,
"question": question,
"customer_context": args.get("customer_context") if isinstance(args.get("customer_context"), dict) else {},
"limit": int(args.get("limit") or 80),
}
started = time.perf_counter()
payload = await _customer_service_query_response(body)
structured = _mcp_structured_data(payload)
structured["tool_name"] = name
structured["tool_latency_ms"] = max(1, round((time.perf_counter() - started) * 1000))
return {
"content": [{"type": "text", "text": _mcp_content_text(payload)}],
"structuredContent": structured,
"isError": False,
}
async def _handle_request(message: dict[str, Any]) -> dict[str, Any] | None:
message_id = message.get("id")
method = str(message.get("method") or "")
params = message.get("params") if isinstance(message.get("params"), dict) else {}
if not method:
return _json_rpc_error(message_id, -32600, "Invalid Request")
# JSON-RPC notifications have no id and do not require a response.
if "id" not in message:
return None
if method == "initialize":
return _json_rpc_result(
message_id,
{
"protocolVersion": _negotiate_protocol_version(params),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {
"name": "baixinghui-customer-service-mcp",
"version": "0.1.0",
},
"instructions": "使用百姓惠旅行社知识图谱工具回答线路、价格、景区、酒店和费用问题。",
},
)
if method == "ping":
return _json_rpc_result(message_id, {})
if method == "tools/list":
return _json_rpc_result(message_id, {"tools": _tool_definitions()})
if method == "tools/call":
try:
name, args = _normalize_args(params)
tool_names = {tool["name"] for tool in _tool_definitions()}
if name not in tool_names:
return _json_rpc_error(message_id, -32602, f"Unknown tool: {name}")
result = await _call_customer_service_tool(name, args)
return _json_rpc_result(message_id, result)
except ValueError as exc:
return _json_rpc_error(message_id, -32602, str(exc))
except Exception as exc: # noqa: BLE001
return _json_rpc_error(message_id, -32603, f"Tool execution failed: {str(exc)[:300]}")
if method in {"resources/list", "prompts/list"}:
key = "resources" if method == "resources/list" else "prompts"
return _json_rpc_result(message_id, {key: []})
return _json_rpc_error(message_id, -32601, f"Method not found: {method}")
@router.get("/mcp")
async def mcp_get(request: Request):
if not _is_authorized(request):
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
accept = request.headers.get("accept", "")
if "text/event-stream" in accept:
async def stream():
yield ": baixinghui customer-service MCP endpoint is ready\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
return {
"name": "baixinghui-customer-service-mcp",
"protocolVersion": MCP_PROTOCOL_VERSION,
"auth": "bearer" if _mcp_auth_enabled() else "disabled",
"tools": [tool["name"] for tool in _tool_definitions()],
}
@router.post("/mcp")
async def mcp_post(request: Request):
if not _is_authorized(request):
return JSONResponse(
_json_rpc_error(None, -32001, "Unauthorized"),
status_code=401,
media_type="application/json",
)
try:
payload = await request.json()
except Exception:
return JSONResponse(
_json_rpc_error(None, -32700, "Parse error"),
status_code=400,
media_type="application/json",
)
if isinstance(payload, list):
responses = []
for message in payload:
if not isinstance(message, dict):
responses.append(_json_rpc_error(None, -32600, "Invalid Request"))
continue
response = await _handle_request(message)
if response is not None:
responses.append(response)
if not responses:
return Response(status_code=202)
return JSONResponse(responses, media_type="application/json")
if not isinstance(payload, dict):
return JSONResponse(
_json_rpc_error(None, -32600, "Invalid Request"),
status_code=400,
media_type="application/json",
)
response = await _handle_request(payload)
if response is None:
return Response(status_code=202)
return JSONResponse(response, media_type="application/json")