285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""统一 Tool/MCP 同步、授权、风险确认和调用审计服务。"""
|
|
|
|
import json
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.agents.models import AgentRun
|
|
from apps.audit.models import OperationAuditLog
|
|
from apps.modelhub.crypto import decrypt_secret
|
|
from apps.skills.services import validate_payload
|
|
from apps.tools.models import (
|
|
ToolAuthorization,
|
|
ToolCall,
|
|
ToolCallStatus,
|
|
ToolConfirmation,
|
|
ToolProvider,
|
|
ToolProviderType,
|
|
ToolRiskLevel,
|
|
UnifiedTool,
|
|
)
|
|
|
|
|
|
class ToolExecutionError(ValueError):
|
|
pass
|
|
|
|
|
|
def _validate_http_url(value: str) -> None:
|
|
parsed = urlparse(value)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
|
|
raise ToolExecutionError("MCP 地址必须是无内嵌凭证的 HTTP(S) URL")
|
|
|
|
|
|
def _json_rpc(provider: ToolProvider, method: str, params: dict) -> dict:
|
|
_validate_http_url(provider.base_url)
|
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
if provider.token_encrypted:
|
|
headers["Authorization"] = f"Bearer {decrypt_secret(provider.token_encrypted)}"
|
|
payload = json.dumps(
|
|
{"jsonrpc": "2.0", "id": "ai-ticket-platform", "method": method, "params": params}
|
|
).encode()
|
|
request = Request(provider.base_url, data=payload, headers=headers, method="POST")
|
|
try:
|
|
with urlopen(request, timeout=provider.timeout_seconds) as response:
|
|
result = json.loads(response.read().decode("utf-8"))
|
|
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
|
|
raise ToolExecutionError("HTTP MCP 调用失败") from error
|
|
if not isinstance(result, dict) or result.get("error"):
|
|
raise ToolExecutionError("HTTP MCP 返回错误")
|
|
rpc_result = result.get("result")
|
|
if not isinstance(rpc_result, dict):
|
|
raise ToolExecutionError("HTTP MCP 返回结构无效")
|
|
return rpc_result
|
|
|
|
|
|
@transaction.atomic
|
|
def sync_mcp_tools(provider: ToolProvider) -> list[UnifiedTool]:
|
|
"""从 HTTP MCP 同步工具定义,但保留本地风险等级、开关和授权治理。"""
|
|
|
|
provider = ToolProvider.objects.select_for_update().get(id=provider.id)
|
|
if not provider.enabled or provider.provider_type != ToolProviderType.MCP_HTTP:
|
|
raise ToolExecutionError("MCP 服务未启用或类型不正确")
|
|
result = _json_rpc(provider, "tools/list", {})
|
|
remote_tools = result.get("tools")
|
|
if not isinstance(remote_tools, list):
|
|
raise ToolExecutionError("MCP tools/list 未返回工具列表")
|
|
synced = []
|
|
now = timezone.now()
|
|
for item in remote_tools:
|
|
if not isinstance(item, dict) or not str(item.get("name") or "").strip():
|
|
continue
|
|
external_name = str(item["name"]).strip()
|
|
code = f"mcp.{provider.id}.{external_name}"
|
|
tool, _ = UnifiedTool.objects.update_or_create(
|
|
code=code,
|
|
defaults={
|
|
"provider": provider,
|
|
"external_name": external_name,
|
|
"name": str(item.get("title") or external_name),
|
|
"description": str(item.get("description") or ""),
|
|
"input_schema": item.get("inputSchema")
|
|
if isinstance(item.get("inputSchema"), dict)
|
|
else {"type": "object"},
|
|
"synced_at": now,
|
|
},
|
|
)
|
|
synced.append(tool)
|
|
OperationAuditLog.objects.create(
|
|
module="tools",
|
|
action="mcp_tools_synced",
|
|
target_type="tool_provider",
|
|
target_id=str(provider.id),
|
|
request_snapshot={"tool_count": len(synced)},
|
|
result="success",
|
|
)
|
|
return synced
|
|
|
|
|
|
def _authorization_snapshot(tool: UnifiedTool, user, run: AgentRun | None) -> dict:
|
|
"""计算并冻结用户、用户组、Agent 动作与 Agent 授权的交集。"""
|
|
|
|
if not user or not user.is_authenticated:
|
|
raise ToolExecutionError("工具调用需要登录用户")
|
|
if not (user.is_superuser or user.has_perm("system.tool.invoke")):
|
|
raise ToolExecutionError("用户没有工具调用权限")
|
|
authorizations = ToolAuthorization.objects.filter(tool=tool)
|
|
group_ids = user.groups.values_list("id", flat=True)
|
|
explicitly_denied = authorizations.filter(
|
|
allowed=False,
|
|
principal_type="user",
|
|
principal_id=user.id,
|
|
).exists() or authorizations.filter(
|
|
allowed=False,
|
|
principal_type="group",
|
|
principal_id__in=group_ids,
|
|
).exists()
|
|
grants = authorizations.filter(allowed=True)
|
|
user_allowed = not explicitly_denied and (
|
|
user.is_superuser or grants.filter(
|
|
principal_type="user", principal_id=user.id
|
|
).exists() or grants.filter(
|
|
principal_type="group", principal_id__in=group_ids
|
|
).exists())
|
|
if not user_allowed:
|
|
raise ToolExecutionError("用户或用户组未获得该工具授权")
|
|
agent_allowed = True
|
|
agent_grant = True
|
|
if run is not None:
|
|
action = f"tool.call:{tool.code}"
|
|
agent_allowed = action in run.config_snapshot.get("agent", {}).get(
|
|
"allowed_actions", []
|
|
)
|
|
agent_grant = grants.filter(
|
|
principal_type="agent",
|
|
principal_id=run.definition_id,
|
|
).exists()
|
|
if not agent_allowed or not agent_grant:
|
|
raise ToolExecutionError("Agent 版本或 Agent 授权未允许该工具")
|
|
return {
|
|
"user_id": user.id,
|
|
"user_permission": True,
|
|
"principal_grant": user_allowed,
|
|
"agent_run_id": str(run.public_id) if run else None,
|
|
"agent_action_allowed": agent_allowed,
|
|
"agent_grant": agent_grant,
|
|
}
|
|
|
|
|
|
def execute_tool_call(call: ToolCall) -> ToolCall:
|
|
"""执行已通过授权和风险确认的工具调用,并持久化终态审计。"""
|
|
|
|
call = ToolCall.objects.select_related("tool__provider").get(id=call.id)
|
|
if call.status not in {ToolCallStatus.RUNNING, ToolCallStatus.PENDING_CONFIRMATION}:
|
|
return call
|
|
call.status = ToolCallStatus.RUNNING
|
|
call.started_at = timezone.now()
|
|
call.save(update_fields=["status", "started_at"])
|
|
try:
|
|
if not call.tool.provider_id:
|
|
raise ToolExecutionError("原生工具尚未注册执行器")
|
|
result = _json_rpc(
|
|
call.tool.provider,
|
|
"tools/call",
|
|
{"name": call.tool.external_name, "arguments": call.input_payload},
|
|
)
|
|
except Exception as error:
|
|
call.status = ToolCallStatus.FAILED
|
|
call.error_code = "tool_call_failed"
|
|
call.error_message = str(error)
|
|
else:
|
|
call.status = ToolCallStatus.SUCCEEDED
|
|
call.output_payload = result
|
|
call.finished_at = timezone.now()
|
|
call.save()
|
|
OperationAuditLog.objects.create(
|
|
user=call.requested_by,
|
|
module="tools",
|
|
action="tool_call_finished",
|
|
target_type="tool_call",
|
|
target_id=str(call.public_id),
|
|
request_snapshot={
|
|
"tool_code": call.tool.code,
|
|
"status": call.status,
|
|
"risk_level": call.tool.risk_level,
|
|
"agent_run_id": str(call.agent_run.public_id) if call.agent_run_id else None,
|
|
},
|
|
result="success" if call.status == ToolCallStatus.SUCCEEDED else "failed",
|
|
)
|
|
return call
|
|
|
|
|
|
@transaction.atomic
|
|
def request_tool_call(
|
|
tool: UnifiedTool,
|
|
payload: dict,
|
|
*,
|
|
user,
|
|
run: AgentRun | None = None,
|
|
idempotency_key: str = "",
|
|
) -> ToolCall:
|
|
"""创建幂等工具调用;高风险调用先进入独立人工确认,不直接执行。"""
|
|
|
|
tool = UnifiedTool.objects.select_for_update().select_related("provider").get(id=tool.id)
|
|
if not tool.enabled or (tool.provider_id and not tool.provider.enabled):
|
|
raise ToolExecutionError("工具或工具服务未启用")
|
|
if idempotency_key:
|
|
existing = ToolCall.objects.filter(tool=tool, idempotency_key=idempotency_key).first()
|
|
if existing is not None:
|
|
return existing
|
|
validate_payload(tool.input_schema, payload, "工具输入")
|
|
authorization = _authorization_snapshot(tool, user, run)
|
|
needs_confirmation = tool.risk_level == ToolRiskLevel.HIGH_RISK
|
|
call_values = {
|
|
"agent_run": run,
|
|
"requested_by": user,
|
|
"status": (
|
|
ToolCallStatus.PENDING_CONFIRMATION
|
|
if needs_confirmation
|
|
else ToolCallStatus.RUNNING
|
|
),
|
|
"input_payload": payload,
|
|
"authorization_snapshot": authorization,
|
|
"risk_snapshot": {
|
|
"risk_level": tool.risk_level,
|
|
"confirmation_required": needs_confirmation,
|
|
},
|
|
}
|
|
normalized_key = idempotency_key.strip()
|
|
if normalized_key:
|
|
call, created = ToolCall.objects.get_or_create(
|
|
tool=tool,
|
|
idempotency_key=normalized_key,
|
|
defaults=call_values,
|
|
)
|
|
if not created:
|
|
return call
|
|
else:
|
|
call = ToolCall.objects.create(
|
|
tool=tool,
|
|
idempotency_key="",
|
|
**call_values,
|
|
)
|
|
if needs_confirmation:
|
|
ToolConfirmation.objects.create(call=call)
|
|
from apps.workitems.services import sync_tool_confirmation_work_item
|
|
|
|
sync_tool_confirmation_work_item(call, actor=user)
|
|
return call
|
|
transaction.on_commit(lambda: execute_tool_call(call))
|
|
return call
|
|
|
|
|
|
@transaction.atomic
|
|
def decide_tool_confirmation(call: ToolCall, *, reviewer, decision: str, reason: str = ""):
|
|
"""由非申请人审批高风险调用,并仅在事务提交后开始执行。"""
|
|
|
|
call = ToolCall.objects.select_for_update().select_related("confirmation").get(id=call.id)
|
|
if call.status != ToolCallStatus.PENDING_CONFIRMATION:
|
|
raise ToolExecutionError("工具调用不在待确认状态")
|
|
if call.requested_by_id == reviewer.id:
|
|
raise ToolExecutionError("高风险工具调用不能由申请人自审")
|
|
if decision not in {"approve", "reject"}:
|
|
raise ToolExecutionError("确认决定必须是 approve 或 reject")
|
|
confirmation = call.confirmation
|
|
confirmation.status = "approved" if decision == "approve" else "rejected"
|
|
confirmation.reason = reason.strip()
|
|
confirmation.decided_by = reviewer
|
|
confirmation.decided_at = timezone.now()
|
|
confirmation.save()
|
|
if decision == "reject":
|
|
call.status = ToolCallStatus.REJECTED
|
|
call.finished_at = timezone.now()
|
|
call.save(update_fields=["status", "finished_at"])
|
|
else:
|
|
call.status = ToolCallStatus.RUNNING
|
|
call.save(update_fields=["status"])
|
|
transaction.on_commit(lambda: execute_tool_call(call))
|
|
from apps.workitems.services import sync_tool_confirmation_work_item
|
|
|
|
sync_tool_confirmation_work_item(call, actor=reviewer)
|
|
return call
|