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})