Files
ai-ticket-platform/backend/apps/tools/mcp_gateway.py
2026-07-27 19:02:37 +08:00

138 lines
4.7 KiB
Python

import json
import secrets
from django.conf import settings
from django.db.models import Q
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from apps.tickets.models import Ticket, TicketStatus
TICKET_SEARCH_TOOL = {
"name": "search_tickets",
"title": "内部工单查询",
"description": "按编号、标题或问题描述查询未归档工单,可选状态过滤。",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "工单编号、标题或问题关键词",
},
"status": {
"type": "string",
"enum": [value for value, _label in TicketStatus.choices],
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 20,
},
},
},
}
def _rpc_error(rpc_id, code: int, message: str, *, status: int = 200):
return JsonResponse(
{
"jsonrpc": "2.0",
"id": rpc_id,
"error": {"code": code, "message": message},
},
status=status,
)
def _authorized(request) -> bool:
expected = settings.INTERNAL_MCP_TOKEN
authorization = request.headers.get("Authorization", "")
if not expected or not authorization.startswith("Bearer "):
return False
return secrets.compare_digest(authorization.removeprefix("Bearer "), expected)
def _search_tickets(arguments: dict) -> dict:
query = str(arguments.get("query") or "").strip()
status = str(arguments.get("status") or "").strip()
try:
limit = min(max(int(arguments.get("limit", 10)), 1), 20)
except (TypeError, ValueError) as error:
raise ValueError("limit 必须是 1 到 20 的整数") from error
if status and status not in TicketStatus.values:
raise ValueError("status 不是有效工单状态")
queryset = Ticket.objects.select_related("category").filter(archived_at__isnull=True)
if query:
queryset = queryset.filter(
Q(ticket_no__icontains=query)
| Q(title__icontains=query)
| Q(description__icontains=query)
)
if status:
queryset = queryset.filter(status=status)
tickets = [
{
"id": ticket.id,
"ticket_no": ticket.ticket_no,
"title": ticket.title,
"description": ticket.description[:500],
"status": ticket.status,
"priority": ticket.priority,
"category_code": ticket.category.code if ticket.category else None,
"updated_at": ticket.updated_at.isoformat(),
}
for ticket in queryset[:limit]
]
return {
"query": query,
"status": status or None,
"count": len(tickets),
"tickets": tickets,
}
@csrf_exempt
def internal_mcp_view(request):
if request.method != "POST":
return _rpc_error(None, -32600, "仅支持 POST", status=405)
if not settings.INTERNAL_MCP_TOKEN:
return _rpc_error(None, -32001, "内部 MCP 尚未配置访问令牌", status=503)
if not _authorized(request):
return _rpc_error(None, -32000, "未授权的内部 MCP 请求", status=401)
try:
payload = json.loads(request.body)
except (json.JSONDecodeError, UnicodeDecodeError):
return _rpc_error(None, -32700, "JSON 解析失败", status=400)
if not isinstance(payload, dict):
return _rpc_error(None, -32600, "JSON-RPC 请求结构无效", status=400)
rpc_id = payload.get("id")
method = payload.get("method")
params = payload.get("params")
if not isinstance(params, dict):
return _rpc_error(rpc_id, -32602, "params 必须是对象")
if method == "tools/list":
result = {"tools": [TICKET_SEARCH_TOOL]}
elif method == "tools/call":
if params.get("name") != TICKET_SEARCH_TOOL["name"]:
return _rpc_error(rpc_id, -32601, "工具不存在")
arguments = params.get("arguments", {})
if not isinstance(arguments, dict):
return _rpc_error(rpc_id, -32602, "arguments 必须是对象")
try:
structured = _search_tickets(arguments)
except ValueError as error:
return _rpc_error(rpc_id, -32602, str(error))
result = {
"content": [
{
"type": "text",
"text": json.dumps(structured, ensure_ascii=False),
}
],
"structuredContent": structured,
"isError": False,
}
else:
return _rpc_error(rpc_id, -32601, "不支持的 MCP 方法")
return JsonResponse({"jsonrpc": "2.0", "id": rpc_id, "result": result})