mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:12:02 +00:00
631 lines
30 KiB
Python
631 lines
30 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, Literal
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api import health
|
||
from app.api.deps import ensure_role
|
||
from app.capabilities import registry as capability_registry
|
||
from app.capabilities import secrets as vault
|
||
from app.capabilities.endpoints import bound_endpoint_ref, find_endpoint
|
||
from app.core.errors import explain, raw
|
||
from app.db.base import get_session
|
||
from app.db.models import Capability, CustomTool, Endpoint, McpServer
|
||
from app.tools.custom import ToolFailure, held_write, http_failure, schema_problem, trial
|
||
from app.tools.mcp_manager import mcp_manager
|
||
from app.tools.registry import (
|
||
ToolArgsError, ToolBuildError, ToolContext, Versions, all_specs, build_tools, call_is_dangerous,
|
||
call_tool, get_spec,
|
||
)
|
||
from app.tools.trust import load_trust, set_trust
|
||
|
||
router = APIRouter(prefix="/api/tools", tags=["tools"])
|
||
|
||
|
||
@router.get("")
|
||
async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[str, Any]]:
|
||
"""把内置、自定义、MCP 三类工具拉平成一个列表,前端节点面板直接用。"""
|
||
out: list[dict[str, Any]] = []
|
||
trust = await load_trust(session)
|
||
for name, spec in sorted(all_specs().items()):
|
||
out.append(
|
||
{
|
||
"id": name,
|
||
"name": name,
|
||
"description": spec.description,
|
||
"category": spec.category,
|
||
"source": "builtin",
|
||
"dangerous": spec.dangerous,
|
||
# 工作流里 approval=dangerous 的关卡认不认得它。dangerous 说的是「有副作用」,
|
||
# 这一项说的是「运行时真会停下来等审批」——两件事以前混在一个标签里
|
||
"runtime_approval": spec.dangerous,
|
||
"schema": spec.json_schema(),
|
||
}
|
||
)
|
||
|
||
rows = (
|
||
await session.execute(select(CustomTool).where(CustomTool.enabled.is_(True)))
|
||
).scalars()
|
||
for row in rows:
|
||
out.append(
|
||
{
|
||
"id": row.name,
|
||
"name": row.name,
|
||
"description": row.description,
|
||
"category": f"自定义 · {row.kind}",
|
||
"source": "custom",
|
||
"dangerous": True,
|
||
# 信任三档(tools/trust.py):等审批、门控把关都可能停下来等人
|
||
**_trust_fields(row.name, trust),
|
||
"schema": row.parameters or {},
|
||
# 这道关卡之前存进库的坏参数定义:绑上它的节点会失败,列表上先标出来
|
||
"problem": schema_problem(row.parameters or {}),
|
||
}
|
||
)
|
||
|
||
try:
|
||
out.extend({**t, "source": "mcp", "dangerous": True, **_trust_fields(t["id"], trust)}
|
||
for t in await mcp_manager.list_tools())
|
||
except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉
|
||
pass
|
||
# 在工具库里执行是不是只演练(写外部系统的能力,和执行接口 _held 同一个判断):详情区的提示条照这个说「这里只演练,
|
||
# 不真的发送」,不说「请求会实际发出」
|
||
customs = {row.name: row for row in (await session.execute(select(CustomTool))).scalars()}
|
||
# 能力登记一次读进会话:_held 逐个 session.get,登记里有的直接从会话里取,不再一个工具查一次库(会话按弱引用记着
|
||
# 读过的行,列表留到循环结束)
|
||
_caps = (await session.execute(select(Capability))).scalars().all()
|
||
for item in out:
|
||
custom = customs.get(item["id"]) if item["source"] == "custom" else None
|
||
item["write_rehearsal"] = await _held(item["id"], custom, session)
|
||
return out
|
||
|
||
|
||
def _trust_fields(key: str, trust: dict[str, str]) -> dict[str, Any]:
|
||
level = trust.get(key, "ask")
|
||
return {"trust": level, "trust_key": key, "runtime_approval": level != "always"}
|
||
|
||
|
||
class TrustIn(BaseModel):
|
||
key: str = Field(min_length=1, max_length=300)
|
||
trust: Literal["ask", "gated", "always"]
|
||
|
||
|
||
@router.put("/trust")
|
||
async def put_trust(payload: TrustIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""MCP / 自定义工具的信任三档。改了只影响以后发起的运行:每次运行在发起时快照一份。"""
|
||
await set_trust(session, payload.key, payload.trust)
|
||
return {"key": payload.key, "trust": payload.trust}
|
||
|
||
|
||
class RunToolIn(BaseModel):
|
||
args: dict[str, Any] = Field(default_factory=dict)
|
||
# 这个字段会被拼进工作目录路径。净化在 sanitize_session 里兜底,这里
|
||
# 再加一道模式校验做纵深防御——畸形的 session 直接 422 拒掉,
|
||
# 而不是悄悄被净化成 "default" 让调用方以为自己写对了。
|
||
sandbox_session: str = Field(default="playground", pattern=r"^[A-Za-z0-9_-]{1,64}$")
|
||
#: 有副作用的工具要带上它才执行。也可以写在查询串里(?confirm=true)
|
||
confirm: bool = False
|
||
|
||
|
||
def _builtin_effect(name: str, args: dict[str, Any]) -> str:
|
||
if name == "file_write":
|
||
path = args.get("path") or "(未填写路径)"
|
||
if args.get("append"):
|
||
return f"将向工作目录中的「{path}」追加内容"
|
||
return f"将在工作目录中写入「{path}」,同名文件会被覆盖"
|
||
if name == "http_request":
|
||
return (f"将向 {args.get('url') or '(未填写地址)'} 发送 {args.get('method') or 'GET'} 请求"
|
||
"(内网和回环地址会被拦截)")
|
||
if name == "shell_exec":
|
||
return f"将在沙箱中执行命令:{str(args.get('command') or '')[:120]}"
|
||
if name == "python_exec":
|
||
return "将在沙箱中执行这段 Python 代码"
|
||
if name == "run_code":
|
||
lang = args.get("language") or "python"
|
||
return f"将在沙箱中执行这段 {lang} 代码" + (",且允许联网" if args.get("network") else "")
|
||
return f"「{name}」有副作用"
|
||
|
||
|
||
async def _side_effect(name: str, args: dict[str, Any], session: AsyncSession) -> str | None:
|
||
"""这次调用有副作用就说清会做什么,没有返回 None。口径和列表上的 dangerous 一致。"""
|
||
spec = get_spec(name)
|
||
if spec is not None:
|
||
return _builtin_effect(name, args) if spec.dangerous else None
|
||
if name.startswith("mcp:"):
|
||
server, _, tool = name[4:].partition("/")
|
||
return (f"将调用 MCP 服务「{server}」上的工具「{tool}」。该工具由外部进程提供,"
|
||
"具体行为无法预先确定")
|
||
row = (await session.execute(
|
||
select(CustomTool).where(CustomTool.name == name)
|
||
)).scalar_one_or_none()
|
||
if row is not None:
|
||
cfg = row.config or {}
|
||
if ref := bound_endpoint_ref(row):
|
||
endpoint = await find_endpoint(session, ref)
|
||
return (f"将经登记的端点「{endpoint.name if endpoint else ref}」发送 {cfg.get('method') or 'GET'} 请求"
|
||
"(试用按探索运行处理:有测试环境时发往测试环境,没有时发往生产环境)")
|
||
if row.kind == "http":
|
||
return (f"将调用自定义接口「{name}」"
|
||
f"({cfg.get('method') or 'GET'} {cfg.get('url') or '(未填写地址)'})")
|
||
return f"将在沙箱中执行自定义工具「{name}」的代码"
|
||
# 数据源工具:只读源上的查询没有副作用,可写源上的写操作有。不在运行里,上传表格看当前版本
|
||
tools = await build_tools([name], ToolContext(run_id="playground", node_id="playground",
|
||
data_versions=Versions.CURRENT),
|
||
session=session)
|
||
if tools and call_is_dangerous(tools[0], name, args):
|
||
return f"将在可写数据源上执行写操作:{str(args.get('sql') or '')[:120]}"
|
||
return None
|
||
|
||
|
||
async def _held(name: str, custom: CustomTool | None, session: AsyncSession) -> bool:
|
||
"""在工具库里执行它是不是只演练:写外部系统的能力。自定义工具按它绑定的端点认(tools/custom.held_write),别的按
|
||
能力登记的治理认。"""
|
||
if custom is not None:
|
||
return await held_write(session, custom)
|
||
cap = await capability_registry.get_capability(session, name)
|
||
return cap is not None and capability_registry.writes_externally(
|
||
name, capability_registry.effective_effect(cap), cap.endpoint_id)
|
||
|
||
|
||
@router.post("/{tool_name:path}/run")
|
||
async def run_tool(
|
||
tool_name: str,
|
||
payload: RunToolIn,
|
||
confirm: bool = False,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> dict[str, Any]:
|
||
"""直接试跑一个工具。工具面板上的"试一下"用它,不用为了测工具去搭一张图。
|
||
|
||
有副作用的工具先 409 说清它会做什么,带上 confirm 再来一次才执行。工作流里
|
||
同一个「需确认」意味着要人工审批,在这里却点一下就真跑了——同一个标签两种
|
||
含义,工具库里试 file_write、shell_exec 的人会以为还有一道关。
|
||
|
||
写入外部系统的能力(capabilities.registry.writes_externally,和运行时同一个判断)在这里一律演练、不发送,结果标
|
||
rehearsal:对外写入只经调用工具节点(要确认、带幂等键、留生效记录,A5)。没有副作用,也就不先 409。
|
||
"""
|
||
import json
|
||
import time
|
||
|
||
custom = (await session.execute(select(CustomTool).where(CustomTool.name == tool_name))).scalar_one_or_none()
|
||
rehearsal = await _held(tool_name, custom, session)
|
||
if rehearsal and custom is None:
|
||
# 被治理为写入的 MCP、数据源、内置工具:拼不出将要发送的请求,不执行,交回这次的参数
|
||
return {"ok": True, "rehearsal": True, "duration_ms": 0,
|
||
"result": {"rehearsal": True, "message": "演练:写入能力不在这里真的执行", "args": payload.args}}
|
||
if not (rehearsal or confirm or payload.confirm):
|
||
effect = await _side_effect(tool_name, payload.args, session)
|
||
if effect:
|
||
# 只说后果,不说怎么确认:弹窗还是再点一次是界面的事,写死在这里
|
||
# 放进确认框里读起来就不对
|
||
raise HTTPException(409, f"{effect}。在工具库中执行不经过审批,确认后才会执行。")
|
||
|
||
# 不在运行里:上传表格查当前版本(运行里查的是发起时固定的那一版)
|
||
ctx = ToolContext(run_id="playground", node_id="playground",
|
||
sandbox_session=payload.sandbox_session, data_versions=Versions.CURRENT)
|
||
started = time.perf_counter()
|
||
notes: list[str] = []
|
||
try:
|
||
result = await call_tool(tool_name, payload.args, ctx, session=session, on_fix=notes.append)
|
||
except KeyError as e:
|
||
raise HTTPException(404, str(e.args[0] if e.args else e)) from e
|
||
except (ToolArgsError, ToolBuildError) as e:
|
||
# 参数对不上、工具自己的配置写坏了:报错里已经写清该改什么,原样给
|
||
return {"ok": False, "error": str(e), "hint": "", "detail": raw(e),
|
||
"duration_ms": int((time.perf_counter() - started) * 1000)}
|
||
except Exception as e: # noqa: BLE001
|
||
reason, hint = explain(e)
|
||
return {
|
||
"ok": False,
|
||
"error": f"工具执行失败:{reason}",
|
||
"hint": hint,
|
||
"detail": raw(e),
|
||
"duration_ms": int((time.perf_counter() - started) * 1000),
|
||
}
|
||
took = int((time.perf_counter() - started) * 1000)
|
||
# 参数名写错、被替换成唯一候选跑通了:运行时会报出来,这里也得说
|
||
extra = {"note": notes[0]} if notes else {}
|
||
if rehearsal:
|
||
# 演练回执是平台拼的 JSON:按对象交回,界面逐项画(方法、地址、请求头、请求体)。拼不出请求(路径参数不合法)的
|
||
# 交回的是 {error}:照实说失败
|
||
try:
|
||
value = json.loads(result) if isinstance(result, str) else result
|
||
except ValueError:
|
||
value = None
|
||
if not (isinstance(value, dict) and value.get("rehearsal")):
|
||
error = value.get("error") if isinstance(value, dict) else None
|
||
return {"ok": False, "error": str(error or result), "hint": "", "detail": "", "duration_ms": took, **extra}
|
||
return {"ok": True, "rehearsal": True, "result": value, "duration_ms": took, **extra}
|
||
if custom is not None and (failed := http_failure(custom, result)):
|
||
return {"ok": False, **failed, "duration_ms": took, **extra}
|
||
return {"ok": True, "result": result, "duration_ms": took, **extra}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 自定义工具
|
||
# --------------------------------------------------------------------------
|
||
|
||
custom_router = APIRouter(prefix="/api/custom-tools", tags=["tools"])
|
||
|
||
|
||
class CustomToolIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||
description: str = ""
|
||
kind: str = "http" # http | python
|
||
#: JSON Schema。不在这里限定成 dict:写成数组、字符串时要回一句人话(见 _refuse_bad_schema),
|
||
#: 而不是 pydantic 的英文错误列表
|
||
parameters: Any = Field(default_factory=dict)
|
||
config: dict[str, Any] = Field(default_factory=dict)
|
||
enabled: bool = True
|
||
|
||
|
||
class CustomToolOut(CustomToolIn):
|
||
id: str
|
||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||
#: 参数定义哪里写坏了(保存时的关卡加上之前存进库的)。绑上它的节点会失败
|
||
problem: str | None = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
def _custom_out(row: CustomTool) -> CustomToolOut:
|
||
out = CustomToolOut.model_validate(row)
|
||
out.problem = schema_problem(row.parameters or {})
|
||
if "headers" in (row.config or {}):
|
||
# 请求头的值存的是密文,接口只回掩码;原样提交回来表示保持原值(_seal_config)
|
||
out.config = {**(row.config or {}), "headers": vault.masked((row.config or {}).get("headers"))}
|
||
return out
|
||
|
||
|
||
def _refuse_bad_schema(parameters: Any) -> None:
|
||
"""参数定义写坏了就不让存:存进去之后,绑了它的节点在运行时才炸。"""
|
||
problem = schema_problem(parameters)
|
||
if problem:
|
||
raise HTTPException(422, problem)
|
||
|
||
|
||
def _seal_config(config: dict[str, Any], previous: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""请求头的值加密;提交的是原值的掩码时保持原值。名字不合法(空的、带空格、冒号、中文)的不让存:存进去之后每次
|
||
调用都在发出之前失败。值里可以写 {{ 参数 }} 模板,不查。"""
|
||
if "headers" not in config:
|
||
return config
|
||
headers = config.get("headers")
|
||
if headers is not None and not isinstance(headers, dict):
|
||
raise HTTPException(422, "请求头需要写成 JSON 对象,每个请求头名称对应一个值")
|
||
if problem := vault.header_problem(headers, check_values=False):
|
||
raise HTTPException(422, problem)
|
||
return {**config, "headers": vault.seal(headers, (previous or {}).get("headers"))}
|
||
|
||
|
||
async def _refuse_bad_binding(session: AsyncSession, kind: str, config: dict[str, Any]) -> None:
|
||
"""绑定登记端点的 http 工具:端点要存在,路径只写路径(主机和协议由端点决定),模型拿不到任意地址。"""
|
||
ref = config.get("endpoint")
|
||
if kind != "http" or not ref:
|
||
return
|
||
if await find_endpoint(session, str(ref)) is None:
|
||
raise HTTPException(422, "绑定的端点不存在,请先到「工具 → 端点」登记")
|
||
path = str(config.get("path") or "")
|
||
if path and not path.startswith("/"):
|
||
raise HTTPException(422, "绑定端点时,路径需要以 / 开头,例如 /orders/{{ order_id }}")
|
||
method = str(config.get("method") or "GET").upper()
|
||
if method not in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"):
|
||
raise HTTPException(422, f"不支持的请求方法「{method}」")
|
||
|
||
|
||
@custom_router.get("", response_model=list[CustomToolOut])
|
||
async def list_custom(session: AsyncSession = Depends(get_session)) -> list[CustomToolOut]:
|
||
return [_custom_out(r) for r in (await session.execute(select(CustomTool))).scalars()]
|
||
|
||
|
||
@custom_router.post("", response_model=CustomToolOut, status_code=201)
|
||
async def create_custom(
|
||
payload: CustomToolIn, session: AsyncSession = Depends(get_session)
|
||
) -> CustomToolOut:
|
||
if (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none():
|
||
raise HTTPException(409, f"已存在名为「{payload.name}」的工具,请换一个名称")
|
||
if payload.name in all_specs():
|
||
raise HTTPException(409, f"「{payload.name}」与内置工具重名,请换一个名称")
|
||
_refuse_bad_schema(payload.parameters)
|
||
await _refuse_bad_binding(session, payload.kind, payload.config)
|
||
row = CustomTool(**{**payload.model_dump(), "config": _seal_config(payload.config, None)})
|
||
session.add(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
await capability_registry.sync_all(session)
|
||
return _custom_out(row)
|
||
|
||
|
||
@custom_router.patch("/{tool_id}", response_model=CustomToolOut)
|
||
async def update_custom(
|
||
tool_id: str, payload: CustomToolIn, session: AsyncSession = Depends(get_session)
|
||
) -> CustomToolOut:
|
||
row = await session.get(CustomTool, tool_id)
|
||
if not row:
|
||
raise HTTPException(404, "工具不存在,可能已被删除")
|
||
_refuse_bad_schema(payload.parameters)
|
||
await _refuse_bad_binding(session, payload.kind, payload.config)
|
||
sealed = _seal_config(payload.config, row.config)
|
||
for key, value in payload.model_dump().items():
|
||
setattr(row, key, value)
|
||
row.config = sealed
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
await capability_registry.sync_all(session)
|
||
return _custom_out(row)
|
||
|
||
|
||
@custom_router.delete("/{tool_id}", status_code=204)
|
||
async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
||
row = await session.get(CustomTool, tool_id)
|
||
if not row:
|
||
raise HTTPException(404, "工具不存在,可能已被删除")
|
||
await session.delete(row)
|
||
await session.commit()
|
||
await capability_registry.sync_all(session)
|
||
|
||
|
||
async def _trial(row: CustomTool, args: dict[str, Any], session: AsyncSession) -> dict[str, Any]:
|
||
"""试跑一次,返回和工具库「执行」同一种形状:{ok, result | error/hint/detail, duration_ms, note?, rehearsal?}。
|
||
写入能力只演练(rehearsal: true,结果是将要发送的请求);HTTP 工具的结果按状态码说成败,非 2xx 是失败。"""
|
||
import json
|
||
import time
|
||
|
||
ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground",
|
||
data_versions=Versions.CURRENT)
|
||
started = time.perf_counter()
|
||
|
||
def took() -> int:
|
||
return int((time.perf_counter() - started) * 1000)
|
||
|
||
problem = schema_problem(row.parameters or {})
|
||
if problem:
|
||
# 手写的参数定义写坏了:是配置的事,不是「后端内部出错」
|
||
return {"ok": False, "error": problem,
|
||
"hint": '参数定义是 JSON Schema,格式如 {"type": "object", "properties": '
|
||
'{"n": {"type": "integer", "description": "…"}}, "required": ["n"]}',
|
||
"detail": "", "duration_ms": 0}
|
||
rehearsal = await held_write(session, row)
|
||
try:
|
||
result, note = await trial(row, args, ctx)
|
||
except ToolArgsError as e:
|
||
# 参数对不上时报错里已经写清该填什么,原样给
|
||
return {"ok": False, "error": str(e), "hint": "", "detail": raw(e), "duration_ms": took()}
|
||
except Exception as e: # noqa: BLE001
|
||
reason, hint = explain(e)
|
||
return {"ok": False, "error": f"工具执行失败:{reason}", "hint": hint, "detail": raw(e),
|
||
"duration_ms": took()}
|
||
# 参数名被纠正过:跑通了也要说,用户照着试跑的参数去配工作流
|
||
extra = {"note": note} if note else {}
|
||
if isinstance(result, ToolFailure):
|
||
detail = str(result.get("error") or "")
|
||
if "exit_code" in result:
|
||
error = f"代码执行出错(退出码 {result['exit_code']})"
|
||
hint = "请根据下方报错修改代码;调用时传入的参数在 args 变量中(一个 dict)"
|
||
else:
|
||
error = f"工具执行失败:{detail.splitlines()[0] if detail else '未提供说明'}"
|
||
hint = "HTTP 工具只能访问公网地址,内网、回环地址会被拦截" if "拦截" in detail else ""
|
||
return {"ok": False, "error": error, "hint": hint,
|
||
"detail": json.dumps(dict(result), ensure_ascii=False), "duration_ms": took(),
|
||
**extra}
|
||
if rehearsal:
|
||
return {"ok": True, "rehearsal": True, "result": result, "duration_ms": took(), **extra}
|
||
if failed := http_failure(row, result):
|
||
return {"ok": False, **failed, "duration_ms": took(), **extra}
|
||
return {"ok": True, "result": result, "duration_ms": took(), **extra}
|
||
|
||
|
||
class CustomToolDraftIn(BaseModel):
|
||
"""还没保存(或改了还没保存)的一份工具配置,外加这次试跑的参数。"""
|
||
|
||
name: str = "draft_tool"
|
||
kind: str = "http"
|
||
parameters: Any = Field(default_factory=dict)
|
||
config: dict[str, Any] = Field(default_factory=dict)
|
||
args: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
def _draft_problem(kind: str, config: dict[str, Any]) -> str | None:
|
||
if kind not in ("http", "python"):
|
||
return f"不支持「{kind}」工具类型:仅支持 http 和 python"
|
||
if kind == "http" and not str(config.get("url") or "").strip() and not config.get("endpoint"):
|
||
return "尚未填写接口地址"
|
||
if kind == "python" and not str(config.get("code") or "").strip():
|
||
return "尚未填写代码"
|
||
return None
|
||
|
||
|
||
@custom_router.post("/test")
|
||
async def test_custom_draft(payload: CustomToolDraftIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
"""试跑一份没保存的配置,不落库。
|
||
|
||
以前只能试已保存的:新建要先保存才能试,改了代码也得先存——保存等于让节点
|
||
立刻用上一个还没试过的版本。
|
||
|
||
编辑已保存的工具时,界面上的请求头是掩码:按同名工具存着的值认回来,不然试跑带着掩码去请求。
|
||
绑定端点的草稿按草稿的方法、路径、请求体拼请求(不是同名的已保存那一版);读写按已保存的治理和草稿的方法里更严的
|
||
一个认(tools/custom._effect_of):把只读的改成 POST 去试,只演练、不发送。
|
||
"""
|
||
problem = _draft_problem(payload.kind, payload.config)
|
||
if problem:
|
||
return {"ok": False, "error": problem, "hint": "", "detail": "", "duration_ms": 0}
|
||
saved = (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none()
|
||
draft = CustomTool(name=payload.name or "draft_tool", kind=payload.kind, parameters=payload.parameters,
|
||
config=_seal_config(payload.config, saved.config if saved else None))
|
||
return await _trial(draft, payload.args, session)
|
||
|
||
|
||
@custom_router.post("/{tool_id}/test")
|
||
async def test_custom(
|
||
tool_id: str, payload: RunToolIn, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
row = await session.get(CustomTool, tool_id)
|
||
if not row:
|
||
raise HTTPException(404, "工具不存在,可能已被删除")
|
||
return await _trial(row, payload.args, session)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# MCP
|
||
# --------------------------------------------------------------------------
|
||
|
||
mcp_router = APIRouter(prefix="/api/mcp", tags=["tools"])
|
||
|
||
|
||
class McpIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=100)
|
||
transport: str = "stdio" # stdio | http
|
||
command: str | None = None
|
||
args: list[str] = Field(default_factory=list)
|
||
#: env、headers 的值存密文、接口只回掩码,提交掩码值表示保持原值
|
||
env: dict[str, str] = Field(default_factory=dict)
|
||
url: str | None = None
|
||
enabled: bool = True
|
||
#: http 传输的认证头
|
||
headers: dict[str, str] = Field(default_factory=dict)
|
||
#: 绑定登记的端点:url 只填路径,主机、协议、内网访问、认证头跟端点走
|
||
endpoint_id: str | None = None
|
||
|
||
|
||
class McpOut(McpIn):
|
||
id: str
|
||
status: str
|
||
last_error: str | None
|
||
tools_cache: list[Any]
|
||
#: 最近一次连接的时刻和耗时(见 app/api/health.py)。status 只有结论,没有「何时」
|
||
last_checked_at: str | None = None
|
||
last_check_ok: bool | None = None
|
||
last_latency_ms: int | None = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
def _mcp_out(row: McpServer) -> McpOut:
|
||
out = McpOut.model_validate(row)
|
||
out.env, out.headers = vault.masked(row.env), vault.masked(row.headers)
|
||
checked = health.fields(row.last_check)
|
||
out.last_checked_at = checked["last_checked_at"]
|
||
out.last_check_ok = checked["last_check_ok"]
|
||
out.last_latency_ms = checked["last_latency_ms"]
|
||
return out
|
||
|
||
|
||
def _mcp_connection(row: McpServer) -> tuple[Any, ...]:
|
||
return (row.transport, row.command, list(row.args or []), dict(row.env or {}), row.url,
|
||
dict(row.headers or {}), row.endpoint_id)
|
||
|
||
|
||
async def _guard_mcp(payload: McpIn, row: McpServer, session: AsyncSession) -> None:
|
||
"""stdio 是在服务器上执行命令,只有管理员能建、能改(关登录时所有人等同管理员);http 的地址要么绑定登记的端点,
|
||
要么过 SSRF 检查(mcp_manager.check_url,连接前还会再查一次)。"""
|
||
if payload.transport != "http":
|
||
ensure_role("admin")
|
||
return
|
||
from app.tools import mcp_manager as mcp_module
|
||
from app.tools.net import UnsafeUrlError
|
||
|
||
if payload.endpoint_id and await session.get(Endpoint, payload.endpoint_id) is None:
|
||
raise HTTPException(422, "绑定的端点不存在,请先到「工具 → 端点」登记")
|
||
if not payload.endpoint_id and not (payload.url or "").strip():
|
||
raise HTTPException(422, "尚未填写 MCP 服务的地址")
|
||
try:
|
||
await mcp_module.check_url(row)
|
||
except UnsafeUrlError as e:
|
||
raise HTTPException(422, f"MCP 服务的地址未通过安全检查:{e}") from e
|
||
|
||
|
||
def _apply_mcp(row: McpServer, payload: McpIn) -> None:
|
||
previous_env, previous_headers = dict(row.env or {}), dict(row.headers or {})
|
||
for key, value in payload.model_dump().items():
|
||
setattr(row, key, value)
|
||
row.env = vault.seal(payload.env, previous_env)
|
||
row.headers = vault.seal(payload.headers, previous_headers)
|
||
|
||
|
||
@mcp_router.get("/servers", response_model=list[McpOut])
|
||
async def list_servers(session: AsyncSession = Depends(get_session)) -> list[McpOut]:
|
||
return [_mcp_out(r) for r in (await session.execute(select(McpServer))).scalars()]
|
||
|
||
|
||
@mcp_router.post("/servers", response_model=McpOut, status_code=201)
|
||
async def create_server(
|
||
payload: McpIn, session: AsyncSession = Depends(get_session)
|
||
) -> McpOut:
|
||
if (await session.execute(select(McpServer).where(McpServer.name == payload.name))).scalar_one_or_none():
|
||
raise HTTPException(409, f"已存在名为「{payload.name}」的 MCP 服务,请换一个名称")
|
||
row = McpServer()
|
||
_apply_mcp(row, payload)
|
||
await _guard_mcp(payload, row, session)
|
||
session.add(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
return _mcp_out(row)
|
||
|
||
|
||
@mcp_router.patch("/servers/{server_id}", response_model=McpOut)
|
||
async def update_server(
|
||
server_id: str, payload: McpIn, session: AsyncSession = Depends(get_session)
|
||
) -> McpOut:
|
||
row = await session.get(McpServer, server_id)
|
||
if not row:
|
||
raise HTTPException(404, "MCP 服务不存在,可能已被删除")
|
||
if row.transport != "http":
|
||
ensure_role("admin")
|
||
before, old_name, was_enabled = _mcp_connection(row), row.name, row.enabled
|
||
_apply_mcp(row, payload)
|
||
if _mcp_connection(row) != before:
|
||
await _guard_mcp(payload, row, session)
|
||
if _mcp_connection(row) != before:
|
||
# 启动命令、地址换了,上次的「连得上」说的是另一个进程
|
||
row.status, row.last_error, row.last_check = "unknown", None, None
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
if (_mcp_connection(row), row.name, row.enabled) != (before, old_name, was_enabled):
|
||
# 按旧配置、旧名字建的工具作废,下次用到时按新的连
|
||
mcp_manager.invalidate(old_name, row.name)
|
||
return _mcp_out(row)
|
||
|
||
|
||
@mcp_router.delete("/servers/{server_id}", status_code=204)
|
||
async def delete_server(server_id: str, session: AsyncSession = Depends(get_session)) -> None:
|
||
row = await session.get(McpServer, server_id)
|
||
if not row:
|
||
raise HTTPException(404, "MCP 服务不存在,可能已被删除")
|
||
if row.transport != "http":
|
||
ensure_role("admin")
|
||
await session.delete(row)
|
||
await session.commit()
|
||
mcp_manager.invalidate(row.name)
|
||
await capability_registry.sync_all(session)
|
||
|
||
|
||
@mcp_router.post("/servers/{server_id}/probe")
|
||
async def probe_server(
|
||
server_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> dict[str, Any]:
|
||
row = await session.get(McpServer, server_id)
|
||
if not row:
|
||
raise HTTPException(404, "MCP 服务不存在,可能已被删除")
|
||
import time
|
||
|
||
started = time.perf_counter()
|
||
result = await mcp_manager.probe(row)
|
||
result["elapsed_ms"] = int((time.perf_counter() - started) * 1000)
|
||
row.status = "ok" if result.get("ok") else "error"
|
||
row.last_error = result.get("error")
|
||
row.tools_cache = [t["name"] for t in result.get("tools", [])]
|
||
row.last_check = health.record(result.get("ok"), result["elapsed_ms"], result.get("error"))
|
||
await session.commit()
|
||
await capability_registry.sync_all(session)
|
||
return result
|
||
|
||
|
||
@mcp_router.post("/refresh")
|
||
async def refresh_mcp(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
servers = await mcp_manager.refresh()
|
||
await capability_registry.sync_all(session)
|
||
return {"servers": servers}
|