feat: add secure agent and MCP extensions
This commit is contained in:
137
backend/apps/tools/mcp_gateway.py
Normal file
137
backend/apps/tools/mcp_gateway.py
Normal file
@@ -0,0 +1,137 @@
|
||||
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})
|
||||
Reference in New Issue
Block a user