feat: add secure agent and MCP extensions
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
backend/apps/tools/__init__.py
Normal file
1
backend/apps/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
273
backend/apps/tools/api.py
Normal file
273
backend/apps/tools/api.py
Normal 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
|
||||
6
backend/apps/tools/apps.py
Normal file
6
backend/apps/tools/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ToolsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.tools"
|
||||
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})
|
||||
117
backend/apps/tools/migrations/0001_initial.py
Normal file
117
backend/apps/tools/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
backend/apps/tools/migrations/__init__.py
Normal file
0
backend/apps/tools/migrations/__init__.py
Normal file
161
backend/apps/tools/models.py
Normal file
161
backend/apps/tools/models.py
Normal 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)
|
||||
99
backend/apps/tools/schemas.py
Normal file
99
backend/apps/tools/schemas.py
Normal 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 = ""
|
||||
284
backend/apps/tools/services.py
Normal file
284
backend/apps/tools/services.py
Normal 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
|
||||
1
backend/apps/tools/tests/__init__.py
Normal file
1
backend/apps/tools/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
354
backend/apps/tools/tests/test_tool_runtime.py
Normal file
354
backend/apps/tools/tests/test_tool_runtime.py
Normal 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
|
||||
Reference in New Issue
Block a user