feat: add secure agent and MCP extensions

This commit is contained in:
hailin
2026-07-27 19:02:37 +08:00
parent 07c9ea1cc4
commit d93cb75c10
29 changed files with 3186 additions and 21 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-27 04:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agents', '0004_agentrunevent'),
]
operations = [
migrations.AddField(
model_name='agentversion',
name='allowed_actions',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -0,0 +1,34 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("agents", "0005_agentversion_allowed_actions"),
("modelhub", "0002_modelprovider_image_egress_approved_and_more"),
]
operations = [
migrations.AddField(
model_name="agentstepbinding",
name="model_deployment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="agent_step_bindings",
to="modelhub.modeldeployment",
),
),
migrations.AddField(
model_name="agentrunstep",
name="model_deployment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="agent_run_steps",
to="modelhub.modeldeployment",
),
),
]

View File

@@ -158,7 +158,7 @@ def create_deployment(request, payload: ModelDeploymentIn):
raise HttpError(400, "Temperature must be between 0 and 2")
if not 1 <= payload.max_output_tokens <= 32768:
raise HttpError(400, "Max output tokens are out of range")
if payload.capability not in {"text_chat", "vision_chat"}:
if payload.capability not in {"text_chat", "vision_chat", "embedding", "rerank"}:
raise HttpError(400, "Unknown model capability")
deployment = ModelDeployment.objects.create(**payload.dict())
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
@@ -177,7 +177,12 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
values = payload.dict(exclude_unset=True)
if "capability" in values and values["capability"] not in {"text_chat", "vision_chat"}:
if "capability" in values and values["capability"] not in {
"text_chat",
"vision_chat",
"embedding",
"rerank",
}:
raise HttpError(400, "Unknown model capability")
for field, value in values.items():
setattr(deployment, field, value)
@@ -196,6 +201,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
def test_deployment(request, deployment_id: int, payload: ModelTestIn):
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
if deployment.capability == "embedding":
raise HttpError(400, "Embedding 模型请通过知识检索验证")
invocation = create_model_test(deployment, payload.prompt, request.user)
run_model_test.delay(invocation.id)
invocation.refresh_from_db()

View File

@@ -1,3 +1,9 @@
"""模型调用执行器。
统一处理超时预算、可重试错误、凭据脱敏、调用快照和 Token/耗时记录。
"""
from collections.abc import Callable
from dataclasses import dataclass
from time import monotonic, sleep
from typing import Any
@@ -38,12 +44,18 @@ def _request_completion(
*,
temperature: float,
max_output_tokens: int,
timeout_seconds: float | None = None,
) -> CompletionResult:
provider = deployment.provider
request_timeout = min(max(provider.default_timeout_seconds, 1), 300)
if timeout_seconds is not None:
request_timeout = min(request_timeout, max(timeout_seconds, 1))
client = OpenAI(
api_key=decrypt_secret(provider.api_key_encrypted),
base_url=provider.base_url,
timeout=min(max(provider.default_timeout_seconds, 1), 300),
timeout=request_timeout,
# Retry here so the platform can expose it instead of waiting on opaque SDK retries.
max_retries=0,
)
response = client.chat.completions.create(
model=deployment.model_name,
@@ -84,7 +96,11 @@ def execute_invocation(
*,
temperature: float | None = None,
max_output_tokens: int | None = None,
on_retry: Callable[[int, str], None] | None = None,
total_timeout_seconds: float | None = None,
) -> ModelInvocation:
"""在总时间预算内调用模型,分类错误、限制重试并记录脱敏终态。"""
deployment = invocation.deployment
if deployment is None or not deployment.enabled or not deployment.provider.enabled:
invocation.status = "failed"
@@ -111,6 +127,12 @@ def execute_invocation(
result: CompletionResult | None = None
last_error: Exception | None = None
for attempt in range(2):
elapsed = monotonic() - started
remaining = total_timeout_seconds - elapsed if total_timeout_seconds is not None else None
if remaining is not None and remaining <= 0:
invocation.error_code = "timeout"
invocation.error_message = "模型请求超过 Agent 时间预算"
break
try:
result = _request_completion(
deployment,
@@ -119,6 +141,7 @@ def execute_invocation(
if temperature is not None
else deployment.default_temperature,
max_output_tokens=max_output_tokens or deployment.max_output_tokens,
timeout_seconds=remaining,
)
invocation.retry_count = attempt
break
@@ -130,6 +153,18 @@ def execute_invocation(
invocation.error_code = code
invocation.error_message = message
break
if total_timeout_seconds is not None:
remaining_after_error = total_timeout_seconds - (monotonic() - started)
if remaining_after_error <= 1:
invocation.error_code = code
invocation.error_message = message
break
invocation.retry_count = attempt + 1
invocation.error_code = code
invocation.error_message = message
invocation.save(update_fields=["retry_count", "error_code", "error_message"])
if on_retry:
on_retry(attempt + 1, code)
sleep(1)
invocation.duration_ms = max(int((monotonic() - started) * 1000), 0)
@@ -156,6 +191,8 @@ def create_model_test(
prompt: str,
user=None,
) -> ModelInvocation:
"""创建可审计的异步模型连通测试,不绕过正式模型执行器。"""
return ModelInvocation.objects.create(
deployment=deployment,
invocation_type="model_test",

View File

@@ -89,6 +89,45 @@ def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch):
assert response.json()["output_tokens"] == 4
def test_embedding_deployment_can_be_configured_but_uses_knowledge_search_for_test(client, db):
login_admin(client)
provider_id = create_provider(client).json()["id"]
deployment = client.post(
"/api/modelhub/deployments",
data={
"provider_id": provider_id,
"display_name": "知识向量模型",
"model_name": "embedding-model",
"capability": "embedding",
},
content_type="application/json",
)
assert deployment.status_code == 200
assert deployment.json()["capability"] == "embedding"
response = client.post(
f"/api/modelhub/deployments/{deployment.json()['id']}/test",
data={"prompt": "测试连接"},
content_type="application/json",
)
assert response.status_code == 400
assert "知识检索" in response.json()["detail"]
rerank = client.post(
"/api/modelhub/deployments",
data={
"provider_id": provider_id,
"display_name": "知识重排模型",
"model_name": "rerank-model",
"capability": "rerank",
},
content_type="application/json",
)
assert rerank.status_code == 200
assert rerank.json()["capability"] == "rerank"
def test_non_ai_admin_cannot_manage_modelhub(client, db):
user = get_user_model().objects.create_user(username="requester", password="password")
client.force_login(user)

View File

@@ -27,6 +27,7 @@ def create_invocation():
def test_transient_error_retries_once(db, monkeypatch):
invocation = create_invocation()
calls = []
retries = []
def request(*args, **kwargs):
calls.append(1)
@@ -37,12 +38,17 @@ def test_transient_error_retries_once(db, monkeypatch):
monkeypatch.setattr("apps.modelhub.services._request_completion", request)
monkeypatch.setattr("apps.modelhub.services.sleep", lambda *_: None)
execute_invocation(invocation, invocation.input_snapshot["messages"])
execute_invocation(
invocation,
invocation.input_snapshot["messages"],
on_retry=lambda retry_number, code: retries.append((retry_number, code)),
)
invocation.refresh_from_db()
assert len(calls) == 2
assert invocation.status == "succeeded"
assert invocation.retry_count == 1
assert retries == [(1, "timeout")]
def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch):
@@ -61,6 +67,31 @@ def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch):
assert "secret provider detail" not in invocation.error_message
def test_agent_time_budget_does_not_repeat_a_full_timeout(db, monkeypatch):
invocation = create_invocation()
calls = []
clock = iter([0.0, 0.0, 19.5, 20.0])
def request(*args, **kwargs):
calls.append(kwargs["timeout_seconds"])
raise APITimeoutError(request=None)
monkeypatch.setattr("apps.modelhub.services._request_completion", request)
monkeypatch.setattr("apps.modelhub.services.monotonic", lambda: next(clock))
execute_invocation(
invocation,
invocation.input_snapshot["messages"],
total_timeout_seconds=20,
)
invocation.refresh_from_db()
assert calls == [20]
assert invocation.status == "failed"
assert invocation.error_code == "timeout"
assert invocation.retry_count == 0
def provider_error(error_type, status_code):
request = httpx.Request("POST", "https://models.example.com/v1/chat/completions")
response = httpx.Response(status_code, request=request)

View File

@@ -0,0 +1 @@

273
backend/apps/tools/api.py Normal file
View File

@@ -0,0 +1,273 @@
from django.db import transaction
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.agents.models import AgentRun
from apps.modelhub.crypto import encrypt_secret
from apps.skills.services import SkillValidationError
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolProvider,
UnifiedTool,
)
from apps.tools.schemas import (
ToolAuthorizationIn,
ToolAuthorizationOut,
ToolCallIn,
ToolCallOut,
ToolConfirmationIn,
ToolProviderIn,
ToolProviderOut,
ToolProviderUpdateIn,
UnifiedToolOut,
UnifiedToolUpdateIn,
)
from apps.tools.services import (
ToolExecutionError,
decide_tool_confirmation,
request_tool_call,
sync_mcp_tools,
)
router = Router(tags=["tools"])
def serialize_provider(provider: ToolProvider) -> dict:
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"credential_set": bool(provider.token_encrypted),
"enabled": provider.enabled,
"timeout_seconds": provider.timeout_seconds,
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def serialize_call(call: ToolCall) -> dict:
confirmation = getattr(call, "confirmation", None)
return {
"public_id": call.public_id,
"tool_code": call.tool.code,
"requested_by_id": call.requested_by_id,
"requested_by_name": (
call.requested_by.get_full_name() or call.requested_by.username
if call.requested_by
else ""
),
"status": call.status,
"input_payload": call.input_payload,
"output_payload": call.output_payload,
"authorization_snapshot": call.authorization_snapshot,
"risk_snapshot": call.risk_snapshot,
"error_code": call.error_code,
"error_message": call.error_message,
"confirmation_status": confirmation.status if confirmation else None,
"confirmation_reason": confirmation.reason if confirmation else "",
"created_at": call.created_at,
"started_at": call.started_at,
"finished_at": call.finished_at,
}
@router.get("/providers", response=list[ToolProviderOut])
def list_tool_providers(request):
require_permission(request, "tool.manage")
return [serialize_provider(item) for item in ToolProvider.objects.all()]
@router.post("/providers", response=ToolProviderOut)
def create_tool_provider(request, payload: ToolProviderIn):
require_permission(request, "tool.manage")
if payload.provider_type == "mcp_http" and not payload.base_url:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
provider = ToolProvider.objects.create(
name=payload.name,
provider_type=payload.provider_type,
base_url=payload.base_url,
token_encrypted=encrypt_secret(payload.token) if payload.token else "",
enabled=payload.enabled,
timeout_seconds=min(max(payload.timeout_seconds, 1), 300),
created_by=request.user,
)
return serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=ToolProviderOut)
def update_tool_provider(
request,
provider_id: int,
payload: ToolProviderUpdateIn,
):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
values = payload.dict(exclude_unset=True)
if "base_url" in values and provider.provider_type == "mcp_http":
if not values["base_url"]:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
token = values.pop("token", None)
if token:
provider.token_encrypted = encrypt_secret(token)
if "timeout_seconds" in values:
values["timeout_seconds"] = min(max(values["timeout_seconds"], 1), 300)
for field, value in values.items():
setattr(provider, field, value)
provider.save()
return serialize_provider(provider)
@router.post("/providers/{provider_id}/sync", response=list[UnifiedToolOut])
def sync_tool_provider(request, provider_id: int):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
try:
return sync_mcp_tools(provider)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
@router.get("", response=list[UnifiedToolOut])
def list_tools(request):
require_permission(request, "tool.view")
return UnifiedTool.objects.select_related("provider").all()
@router.get(
"/{tool_id}/authorizations",
response=list[ToolAuthorizationOut],
)
def list_tool_authorizations(request, tool_id: int):
require_permission(request, "tool.manage")
if not UnifiedTool.objects.filter(id=tool_id).exists():
raise HttpError(404, "工具不存在")
return ToolAuthorization.objects.filter(tool_id=tool_id)
@router.post(
"/{tool_id}/authorizations",
response=ToolAuthorizationOut,
)
def create_tool_authorization(request, tool_id: int, payload: ToolAuthorizationIn):
require_permission(request, "tool.manage")
if payload.principal_type not in {"user", "group", "agent"}:
raise HttpError(400, "授权主体必须是 user、group 或 agent")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
authorization, _ = ToolAuthorization.objects.update_or_create(
tool=tool,
principal_type=payload.principal_type,
principal_id=payload.principal_id,
defaults={"allowed": payload.allowed, "created_by": request.user},
)
return authorization
@router.get("/calls", response=list[ToolCallOut])
def list_tool_calls(request, status: str = "", tool_code: str = ""):
require_permission(request, "tool.invoke")
queryset = ToolCall.objects.select_related(
"tool",
"requested_by",
"confirmation",
)
can_confirm = request.user.is_superuser or request.user.has_perm(
"system.tool.confirm"
)
if not can_confirm:
queryset = queryset.filter(requested_by=request.user)
if status:
queryset = queryset.filter(status=status)
if tool_code:
queryset = queryset.filter(tool__code=tool_code)
return [serialize_call(call) for call in queryset[:100]]
@router.post("/calls", response=ToolCallOut)
def create_tool_call(request, payload: ToolCallIn):
require_permission(request, "tool.invoke")
tool = UnifiedTool.objects.filter(code=payload.tool_code).first()
if tool is None:
raise HttpError(404, "工具不存在")
run = None
if payload.agent_run_id:
run = AgentRun.objects.filter(public_id=payload.agent_run_id).first()
if run is None:
raise HttpError(404, "Agent 运行不存在")
try:
with transaction.atomic():
call = request_tool_call(
tool,
payload.input,
user=request.user,
run=run,
idempotency_key=payload.idempotency_key,
)
except (ToolExecutionError, SkillValidationError) as error:
raise HttpError(400, str(error)) from error
call = ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
return serialize_call(call)
@router.get("/calls/{public_id}", response=ToolCallOut)
def get_tool_call(request, public_id: str):
require_permission(request, "tool.invoke")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
if not request.user.is_superuser and call.requested_by_id != request.user.id:
require_permission(request, "tool.confirm")
return serialize_call(call)
@router.post("/calls/{public_id}/confirmation", response=ToolCallOut)
def confirm_tool_call(request, public_id: str, payload: ToolConfirmationIn):
require_permission(request, "tool.confirm")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
try:
with transaction.atomic():
decide_tool_confirmation(
call,
reviewer=request.user,
decision=payload.decision,
reason=payload.reason,
)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
call.refresh_from_db()
return serialize_call(
ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
)
@router.patch("/{tool_id}", response=UnifiedToolOut)
def update_tool(request, tool_id: int, payload: UnifiedToolUpdateIn):
require_permission(request, "tool.manage")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
values = payload.dict(exclude_unset=True)
if "risk_level" in values and values["risk_level"] not in {
"read_only",
"low_write",
"high_risk",
}:
raise HttpError(400, "未知工具风险等级")
for field, value in values.items():
setattr(tool, field, value)
tool.save()
return tool

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ToolsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.tools"

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

View File

@@ -0,0 +1,117 @@
# Generated by Django 5.2.15 on 2026-07-27 05:53
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('agents', '0005_agentversion_allowed_actions'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ToolCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('pending_confirmation', '待确认'), ('running', '执行中'), ('succeeded', '成功'), ('failed', '失败'), ('rejected', '已拒绝')], max_length=32)),
('input_payload', models.JSONField(default=dict)),
('output_payload', models.JSONField(blank=True, null=True)),
('authorization_snapshot', models.JSONField(default=dict)),
('risk_snapshot', models.JSONField(default=dict)),
('idempotency_key', models.CharField(blank=True, max_length=160)),
('error_code', models.CharField(blank=True, max_length=80)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('agent_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tool_calls', to='agents.agentrun')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_tool_calls', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ToolConfirmation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(default='pending', max_length=24)),
('reason', models.TextField(blank=True)),
('decided_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('call', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='confirmation', to='tools.toolcall')),
('decided_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='decided_tool_confirmations', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ToolProvider',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('provider_type', models.CharField(choices=[('native', '原生工具'), ('mcp_http', 'HTTP MCP')], default='mcp_http', max_length=24)),
('base_url', models.URLField(blank=True, max_length=500)),
('token_encrypted', models.TextField(blank=True)),
('enabled', models.BooleanField(default=True)),
('timeout_seconds', models.PositiveIntegerField(default=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_providers', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UnifiedTool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=160, unique=True)),
('external_name', models.CharField(blank=True, max_length=200)),
('name', models.CharField(max_length=160)),
('description', models.TextField(blank=True)),
('input_schema', models.JSONField(default=dict)),
('risk_level', models.CharField(choices=[('read_only', '只读'), ('low_write', '低风险写'), ('high_risk', '高风险')], default='read_only', max_length=24)),
('enabled', models.BooleanField(default=True)),
('synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('provider', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tools', to='tools.toolprovider')),
],
options={
'ordering': ['code'],
},
),
migrations.AddField(
model_name='toolcall',
name='tool',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='calls', to='tools.unifiedtool'),
),
migrations.CreateModel(
name='ToolAuthorization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('principal_type', models.CharField(max_length=24)),
('principal_id', models.PositiveBigIntegerField()),
('allowed', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_authorizations', to=settings.AUTH_USER_MODEL)),
('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authorizations', to='tools.unifiedtool')),
],
),
migrations.AddConstraint(
model_name='toolcall',
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__gt', '')), fields=('tool', 'idempotency_key'), name='unique_tool_call_idempotency_key'),
),
migrations.AddConstraint(
model_name='toolauthorization',
constraint=models.UniqueConstraint(fields=('tool', 'principal_type', 'principal_id'), name='unique_tool_authorization_principal'),
),
]

View File

@@ -0,0 +1,161 @@
import uuid
from django.conf import settings
from django.db import models
class ToolProviderType(models.TextChoices):
NATIVE = "native", "原生工具"
MCP_HTTP = "mcp_http", "HTTP MCP"
class ToolRiskLevel(models.TextChoices):
READ_ONLY = "read_only", "只读"
LOW_WRITE = "low_write", "低风险写"
HIGH_RISK = "high_risk", "高风险"
class ToolProvider(models.Model):
name = models.CharField(max_length=120, unique=True)
provider_type = models.CharField(
max_length=24,
choices=ToolProviderType.choices,
default=ToolProviderType.MCP_HTTP,
)
base_url = models.URLField(max_length=500, blank=True)
token_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
timeout_seconds = models.PositiveIntegerField(default=30)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_tool_providers",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
class UnifiedTool(models.Model):
code = models.CharField(max_length=160, unique=True)
provider = models.ForeignKey(
ToolProvider,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="tools",
)
external_name = models.CharField(max_length=200, blank=True)
name = models.CharField(max_length=160)
description = models.TextField(blank=True)
input_schema = models.JSONField(default=dict)
risk_level = models.CharField(
max_length=24,
choices=ToolRiskLevel.choices,
default=ToolRiskLevel.READ_ONLY,
)
enabled = models.BooleanField(default=True)
synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["code"]
class ToolAuthorization(models.Model):
tool = models.ForeignKey(
UnifiedTool,
on_delete=models.CASCADE,
related_name="authorizations",
)
principal_type = models.CharField(max_length=24)
principal_id = models.PositiveBigIntegerField()
allowed = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_tool_authorizations",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tool", "principal_type", "principal_id"],
name="unique_tool_authorization_principal",
)
]
class ToolCallStatus(models.TextChoices):
PENDING_CONFIRMATION = "pending_confirmation", "待确认"
RUNNING = "running", "执行中"
SUCCEEDED = "succeeded", "成功"
FAILED = "failed", "失败"
REJECTED = "rejected", "已拒绝"
class ToolCall(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
tool = models.ForeignKey(UnifiedTool, on_delete=models.PROTECT, related_name="calls")
agent_run = models.ForeignKey(
"agents.AgentRun",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="tool_calls",
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_tool_calls",
)
status = models.CharField(max_length=32, choices=ToolCallStatus.choices)
input_payload = models.JSONField(default=dict)
output_payload = models.JSONField(null=True, blank=True)
authorization_snapshot = models.JSONField(default=dict)
risk_snapshot = models.JSONField(default=dict)
idempotency_key = models.CharField(max_length=160, blank=True)
error_code = models.CharField(max_length=80, blank=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["tool", "idempotency_key"],
condition=models.Q(idempotency_key__gt=""),
name="unique_tool_call_idempotency_key",
)
]
class ToolConfirmation(models.Model):
call = models.OneToOneField(
ToolCall,
on_delete=models.CASCADE,
related_name="confirmation",
)
status = models.CharField(max_length=24, default="pending")
reason = models.TextField(blank=True)
decided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="decided_tool_confirmations",
)
decided_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -0,0 +1,99 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from ninja import Schema
class ToolProviderIn(Schema):
name: str
provider_type: str = "mcp_http"
base_url: str = ""
token: str = ""
enabled: bool = True
timeout_seconds: int = 30
class ToolProviderOut(Schema):
id: int
name: str
provider_type: str
base_url: str
credential_set: bool
enabled: bool
timeout_seconds: int
created_at: datetime
updated_at: datetime
class ToolProviderUpdateIn(Schema):
name: str | None = None
base_url: str | None = None
token: str | None = None
enabled: bool | None = None
timeout_seconds: int | None = None
class UnifiedToolOut(Schema):
id: int
code: str
provider_id: int | None
external_name: str
name: str
description: str
input_schema: dict[str, Any]
risk_level: str
enabled: bool
synced_at: datetime | None
class UnifiedToolUpdateIn(Schema):
name: str | None = None
description: str | None = None
risk_level: str | None = None
enabled: bool | None = None
class ToolAuthorizationIn(Schema):
principal_type: str
principal_id: int
allowed: bool = True
class ToolAuthorizationOut(Schema):
id: int
tool_id: int
principal_type: str
principal_id: int
allowed: bool
class ToolCallIn(Schema):
tool_code: str
input: dict[str, Any]
agent_run_id: UUID | None = None
idempotency_key: str = ""
class ToolCallOut(Schema):
public_id: UUID
tool_code: str
requested_by_id: int | None
requested_by_name: str
status: str
input_payload: dict[str, Any]
output_payload: Any | None
authorization_snapshot: dict[str, Any]
risk_snapshot: dict[str, Any]
error_code: str
error_message: str
confirmation_status: str | None
confirmation_reason: str
created_at: datetime
started_at: datetime | None
finished_at: datetime | None
class ToolConfirmationIn(Schema):
decision: str
reason: str = ""

View File

@@ -0,0 +1,284 @@
"""统一 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,354 @@
import json
import pytest
from django.contrib.auth import get_user_model
from django.db import transaction
from django.test import override_settings
from apps.agents.models import AgentRun
from apps.agents.tests.helpers import create_agent
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
from apps.tickets.models import Ticket
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolCallStatus,
ToolProvider,
ToolRiskLevel,
UnifiedTool,
)
from apps.tools.services import (
ToolExecutionError,
decide_tool_confirmation,
request_tool_call,
sync_mcp_tools,
)
from apps.workitems.models import WorkItem, WorkItemType
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
def read(self):
return json.dumps(self.payload).encode()
@pytest.mark.django_db
def test_http_mcp_sync_and_read_only_call_are_audited_and_idempotent(
django_capture_on_commit_callbacks,
monkeypatch,
):
responses = iter(
[
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {
"tools": [
{
"name": "lookup_customer",
"description": "只读查询客户状态",
"inputSchema": {
"type": "object",
"properties": {"customer_id": {"type": "string"}},
"required": ["customer_id"],
},
}
]
},
},
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {"content": [{"type": "text", "text": "active"}]},
},
]
)
monkeypatch.setattr(
"apps.tools.services.urlopen",
lambda *args, **kwargs: FakeResponse(next(responses)),
)
provider = ToolProvider.objects.create(
name="内部查询 MCP",
base_url="https://mcp.internal.example/rpc",
)
tools = sync_mcp_tools(provider)
assert len(tools) == 1
tool = tools[0]
assert tool.code == f"mcp.{provider.id}.lookup_customer"
actor = get_user_model().objects.create_superuser(
username="tool-admin",
email="tool-admin@example.com",
password="password",
)
with django_capture_on_commit_callbacks(execute=True):
first = request_tool_call(
tool,
{"customer_id": "C-001"},
user=actor,
idempotency_key="lookup-C-001",
)
first.refresh_from_db()
second = request_tool_call(
tool,
{"customer_id": "C-001"},
user=actor,
idempotency_key="lookup-C-001",
)
assert first.status == ToolCallStatus.SUCCEEDED
assert first.output_payload["content"][0]["text"] == "active"
assert second.id == first.id
assert ToolCall.objects.count() == 1
assert OperationAuditLog.objects.filter(
module="tools",
action="tool_call_finished",
target_id=str(first.public_id),
).exists()
@pytest.mark.django_db
def test_high_risk_tool_requires_independent_confirmation(
django_capture_on_commit_callbacks,
monkeypatch,
):
monkeypatch.setattr(
"apps.tools.services.urlopen",
lambda *args, **kwargs: FakeResponse(
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {"content": [{"type": "text", "text": "done"}]},
}
),
)
provider = ToolProvider.objects.create(
name="变更 MCP",
base_url="https://mcp.internal.example/rpc",
)
tool = UnifiedTool.objects.create(
code="mcp.change.restart",
provider=provider,
external_name="restart_service",
name="重启服务",
input_schema={"type": "object"},
risk_level=ToolRiskLevel.HIGH_RISK,
)
requester = get_user_model().objects.create_superuser(
username="tool-requester",
email="requester@example.com",
password="password",
)
reviewer = get_user_model().objects.create_superuser(
username="tool-reviewer",
email="reviewer@example.com",
password="password",
)
call = request_tool_call(tool, {}, user=requester)
assert call.status == ToolCallStatus.PENDING_CONFIRMATION
assert call.confirmation.status == "pending"
task = WorkItem.objects.get(task_type=WorkItemType.TOOL_CONFIRMATION)
assert task.status == "active"
with pytest.raises(ToolExecutionError, match="不能由申请人自审"):
decide_tool_confirmation(call, reviewer=requester, decision="approve")
with django_capture_on_commit_callbacks(execute=True):
decide_tool_confirmation(
call,
reviewer=reviewer,
decision="approve",
reason="已核对变更窗口",
)
call.refresh_from_db()
call.confirmation.refresh_from_db()
assert call.status == ToolCallStatus.SUCCEEDED
assert call.confirmation.status == "approved"
assert call.confirmation.decided_by == reviewer
task.refresh_from_db()
assert task.status == "completed"
@pytest.mark.django_db
def test_tool_api_hides_credentials_and_blocks_unprivileged_invocation(
admin_client,
client,
):
response = admin_client.post(
"/api/tools/providers",
{
"name": "受控 MCP",
"provider_type": "mcp_http",
"base_url": "https://mcp.internal.example/rpc",
"token": "top-secret-token",
},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["credential_set"] is True
assert "token" not in response.json()
user = get_user_model().objects.create_user(
username="unprivileged-tool-user",
password="password",
)
client.force_login(user)
response = client.post(
"/api/tools/calls",
{"tool_code": "missing", "input": {}},
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.django_db
def test_tool_governance_api_lists_grants_and_calls(admin_client):
provider = ToolProvider.objects.create(
name="治理 MCP",
base_url="https://mcp.internal.example/rpc",
)
tool = UnifiedTool.objects.create(
code="mcp.governance.lookup",
provider=provider,
external_name="lookup",
name="治理查询",
input_schema={"type": "object"},
)
response = admin_client.post(
f"/api/tools/{tool.id}/authorizations",
{"principal_type": "group", "principal_id": 7, "allowed": True},
content_type="application/json",
)
assert response.status_code == 200
response = admin_client.get(f"/api/tools/{tool.id}/authorizations")
assert response.status_code == 200
assert response.json()[0]["principal_type"] == "group"
response = admin_client.patch(
f"/api/tools/providers/{provider.id}",
{"timeout_seconds": 45, "enabled": False},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["timeout_seconds"] == 45
assert response.json()["enabled"] is False
actor = get_user_model().objects.filter(is_superuser=True).first()
call = ToolCall.objects.create(
tool=tool,
requested_by=actor,
status=ToolCallStatus.SUCCEEDED,
input_payload={"query": "status"},
output_payload={"status": "ok"},
)
response = admin_client.get("/api/tools/calls")
assert response.status_code == 200
payload = response.json()
assert payload[0]["public_id"] == str(call.public_id)
assert payload[0]["requested_by_id"] == actor.id
@pytest.mark.django_db
@override_settings(INTERNAL_MCP_TOKEN="internal-ticket-token")
def test_internal_ticket_mcp_requires_bearer_and_queries_real_tickets(client):
Ticket.objects.create(
ticket_no="TK-MCP-001",
title="客户无法登录控制台",
description="登录后立即返回认证失败",
status="pending",
)
rpc = {
"jsonrpc": "2.0",
"id": "gateway-test",
"method": "tools/call",
"params": {
"name": "search_tickets",
"arguments": {"query": "登录", "limit": 5},
},
}
unauthorized = client.post(
"/internal-mcp",
rpc,
content_type="application/json",
)
assert unauthorized.status_code == 401
response = client.post(
"/internal-mcp",
rpc,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer internal-ticket-token",
)
assert response.status_code == 200
structured = response.json()["result"]["structuredContent"]
assert structured["count"] == 1
assert structured["tickets"][0]["ticket_no"] == "TK-MCP-001"
@pytest.mark.django_db(transaction=True)
@override_settings(INTERNAL_MCP_TOKEN="real-http-ticket-token")
def test_internal_ticket_mcp_real_http_agent_call_end_to_end(live_server):
ticket = Ticket.objects.create(
ticket_no="TK-MCP-E2E",
title="生产订单同步延迟",
description="订单同步任务积压,需要查询同类工单",
status="in_progress",
)
actor = get_user_model().objects.create_superuser(
username="mcp-e2e-user",
email="mcp-e2e@example.com",
password="password",
)
definition, version = create_agent(published=True, two_steps=False)
provider = ToolProvider.objects.create(
name="平台内部工单 MCP",
base_url=f"{live_server.url}/internal-mcp",
token_encrypted=encrypt_secret("real-http-ticket-token"),
)
tools = sync_mcp_tools(provider)
assert len(tools) == 1
tool = tools[0]
assert tool.external_name == "search_tickets"
version.allowed_actions = [f"tool.call:{tool.code}"]
version.save(update_fields=["allowed_actions"])
run = AgentRun.objects.create(
definition=definition,
agent_version=version,
created_by=actor,
config_snapshot={
"agent": {"allowed_actions": [f"tool.call:{tool.code}"]}
},
)
ToolAuthorization.objects.create(
tool=tool,
principal_type="agent",
principal_id=definition.id,
allowed=True,
)
with transaction.atomic():
call = request_tool_call(
tool,
{"query": "订单同步", "status": "in_progress"},
user=actor,
run=run,
idempotency_key="internal-ticket-mcp-e2e",
)
call.refresh_from_db()
assert call.status == ToolCallStatus.SUCCEEDED
structured = call.output_payload["structuredContent"]
assert structured["count"] == 1
assert structured["tickets"][0]["id"] == ticket.id
assert call.authorization_snapshot["agent_grant"] is True
assert call.risk_snapshot["risk_level"] == ToolRiskLevel.READ_ONLY