Add Cloud Tour Libo knowledge graph platform
This commit is contained in:
343
app/api/mcp_server.py
Normal file
343
app/api/mcp_server.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user