969 lines
36 KiB
Python
969 lines
36 KiB
Python
"""Tool management API — CRUD for tools and per-agent assignments."""
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.security import get_current_user
|
|
from app.database import get_db
|
|
from app.models.tool import Tool, AgentTool
|
|
from app.models.user import User
|
|
|
|
router = APIRouter(prefix="/tools", tags=["tools"])
|
|
|
|
|
|
# Sensitive field keys that should be encrypted when stored.
|
|
# This is used as a FALLBACK for tools that don't have config_schema.
|
|
# When config_schema is available, fields with type='password' are used instead.
|
|
SENSITIVE_FIELD_KEYS = {"api_key", "private_key", "auth_code", "password", "secret"}
|
|
|
|
|
|
def _get_sensitive_keys(config_schema: dict | None = None) -> set[str]:
|
|
"""Determine which config keys are sensitive.
|
|
|
|
If config_schema is provided, extract keys whose field type is 'password'.
|
|
Always includes the hardcoded SENSITIVE_FIELD_KEYS as a fallback so that
|
|
tools without config_schema still get encrypted/decrypted correctly.
|
|
"""
|
|
keys = set(SENSITIVE_FIELD_KEYS)
|
|
if config_schema:
|
|
for field in config_schema.get("fields", []):
|
|
if field.get("type") == "password":
|
|
keys.add(field.get("key", ""))
|
|
keys.discard("") # remove empty string if any
|
|
return keys
|
|
|
|
|
|
def _encrypt_sensitive_fields(config: dict, config_schema: dict | None = None) -> dict:
|
|
"""Encrypt sensitive fields in config dict.
|
|
|
|
Args:
|
|
config: Tool config dict
|
|
config_schema: Optional config_schema to extract password-type field keys
|
|
|
|
Returns:
|
|
Config dict with sensitive fields encrypted
|
|
"""
|
|
from app.core.security import encrypt_data, decrypt_data
|
|
from app.config import get_settings
|
|
|
|
if not config:
|
|
return config
|
|
|
|
settings = get_settings()
|
|
result = dict(config)
|
|
sensitive_keys = _get_sensitive_keys(config_schema)
|
|
|
|
for key in sensitive_keys:
|
|
if key in result and result[key]:
|
|
value = result[key]
|
|
if isinstance(value, str) and value:
|
|
# Guard against double-encryption: if the value can be
|
|
# successfully decrypted, it is already encrypted — skip it.
|
|
# This happens when the frontend re-submits a config without
|
|
# the user changing the password field (the field value comes
|
|
# from a previous list_tools response which returns decrypted
|
|
# values… EXCEPT when list_tools runs against a tool whose
|
|
# config_schema is empty and therefore couldn't decrypt).
|
|
try:
|
|
decrypt_data(value, settings.SECRET_KEY)
|
|
# Decryption succeeded → value is already encrypted, keep as-is
|
|
continue
|
|
except Exception:
|
|
# Not encrypted yet → proceed to encrypt
|
|
pass
|
|
|
|
try:
|
|
result[key] = encrypt_data(value, settings.SECRET_KEY)
|
|
except Exception:
|
|
# If encryption fails, keep the value as-is
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
def _decrypt_sensitive_fields(config: dict, config_schema: dict | None = None) -> dict:
|
|
"""Decrypt sensitive fields in config dict.
|
|
|
|
Args:
|
|
config: Tool config dict
|
|
config_schema: Optional config_schema to extract password-type field keys
|
|
|
|
Returns:
|
|
Config dict with sensitive fields decrypted
|
|
"""
|
|
from app.core.security import decrypt_data
|
|
from app.config import get_settings
|
|
|
|
if not config:
|
|
return config
|
|
|
|
settings = get_settings()
|
|
result = dict(config)
|
|
sensitive_keys = _get_sensitive_keys(config_schema)
|
|
|
|
for key in sensitive_keys:
|
|
if key in result and result[key]:
|
|
value = result[key]
|
|
if isinstance(value, str) and value:
|
|
try:
|
|
result[key] = decrypt_data(value, settings.SECRET_KEY)
|
|
except Exception:
|
|
# If decryption fails, assume it's plaintext
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
# ─── Schemas ────────────────────────────────────────────────
|
|
class ToolCreate(BaseModel):
|
|
name: str
|
|
display_name: str
|
|
description: str = ""
|
|
type: str = "mcp"
|
|
category: str = "custom"
|
|
icon: str = "🔧"
|
|
parameters_schema: dict = {}
|
|
mcp_server_url: str | None = None
|
|
mcp_server_name: str | None = None
|
|
mcp_tool_name: str | None = None
|
|
is_default: bool = False
|
|
# Optional: platform admins can specify target tenant (e.g. when managing
|
|
# another company's tools via the Enterprise Settings page).
|
|
tenant_id: str | None = None
|
|
|
|
|
|
class ToolUpdate(BaseModel):
|
|
display_name: str | None = None
|
|
description: str | None = None
|
|
icon: str | None = None
|
|
enabled: bool | None = None
|
|
mcp_server_url: str | None = None
|
|
mcp_server_name: str | None = None
|
|
parameters_schema: dict | None = None
|
|
is_default: bool | None = None
|
|
config: dict | None = None
|
|
|
|
|
|
class AgentToolUpdate(BaseModel):
|
|
tool_id: str
|
|
enabled: bool
|
|
|
|
|
|
class CategoryConfigUpdate(BaseModel):
|
|
config: dict
|
|
|
|
|
|
# ─── Global Tool CRUD ──────────────────────────────────────
|
|
@router.get("")
|
|
async def list_tools(
|
|
tenant_id: str | None = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List platform tools scoped by tenant (builtin + tenant-specific)."""
|
|
query = (
|
|
select(Tool)
|
|
.where(Tool.source.in_(["builtin", "admin"]))
|
|
.order_by(Tool.category, Tool.name)
|
|
)
|
|
# Scope by tenant: show builtin (tenant_id is NULL) + tenant-specific tools
|
|
tid = tenant_id or (str(current_user.tenant_id) if current_user.tenant_id else None)
|
|
if tid:
|
|
from sqlalchemy import or_ as _or
|
|
query = query.where(_or(Tool.tenant_id == None, Tool.tenant_id == uuid.UUID(tid)))
|
|
result = await db.execute(query)
|
|
tools = result.scalars().all()
|
|
return [
|
|
{
|
|
"id": str(t.id),
|
|
"name": t.name,
|
|
"display_name": t.display_name,
|
|
"description": t.description,
|
|
"type": t.type,
|
|
"category": t.category,
|
|
"icon": t.icon,
|
|
"parameters_schema": t.parameters_schema,
|
|
"mcp_server_url": t.mcp_server_url,
|
|
"mcp_server_name": t.mcp_server_name,
|
|
"mcp_tool_name": t.mcp_tool_name,
|
|
"enabled": t.enabled,
|
|
"is_default": t.is_default,
|
|
# Decrypt config for the admin UI so saved values are readable
|
|
"config": _decrypt_sensitive_fields(t.config or {}, t.config_schema),
|
|
"config_schema": t.config_schema or {},
|
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
}
|
|
for t in tools
|
|
]
|
|
|
|
|
|
@router.post("")
|
|
async def create_tool(
|
|
data: ToolCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a new tool (typically MCP).
|
|
|
|
The tool is scoped to the target tenant, which defaults to the caller's
|
|
own tenant but can be overridden via data.tenant_id. This allows platform
|
|
admins to import MCP tools while viewing another company's settings page.
|
|
"""
|
|
# Resolve target tenant: explicit payload value takes priority so that
|
|
# platform admins importing tools for another company work correctly.
|
|
target_tenant_id: uuid.UUID | None = None
|
|
if data.tenant_id:
|
|
try:
|
|
target_tenant_id = uuid.UUID(data.tenant_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid tenant_id format")
|
|
else:
|
|
target_tenant_id = current_user.tenant_id
|
|
|
|
# Unique name check is scoped per tenant to avoid cross-tenant collisions.
|
|
existing = await db.execute(
|
|
select(Tool).where(Tool.name == data.name, Tool.tenant_id == target_tenant_id)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail=f"Tool '{data.name}' already exists")
|
|
|
|
tool = Tool(
|
|
name=data.name,
|
|
display_name=data.display_name,
|
|
description=data.description,
|
|
type=data.type,
|
|
category=data.category,
|
|
icon=data.icon,
|
|
parameters_schema=data.parameters_schema,
|
|
mcp_server_url=data.mcp_server_url,
|
|
mcp_server_name=data.mcp_server_name,
|
|
mcp_tool_name=data.mcp_tool_name,
|
|
is_default=data.is_default,
|
|
tenant_id=target_tenant_id,
|
|
source="admin",
|
|
)
|
|
db.add(tool)
|
|
await db.commit()
|
|
await db.refresh(tool)
|
|
return {"id": str(tool.id), "name": tool.name}
|
|
|
|
|
|
# NOTE: Literal path routes (/bulk, /mcp-server) MUST be defined BEFORE
|
|
# parameterized routes (/{tool_id}) to avoid older FastAPI/Starlette versions
|
|
# matching "bulk" as a uuid.UUID path parameter and returning 422.
|
|
|
|
class BulkToolUpdateItem(BaseModel):
|
|
tool_id: str
|
|
enabled: bool
|
|
|
|
@router.put("/bulk")
|
|
async def update_tools_bulk(
|
|
updates: list[BulkToolUpdateItem],
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Bulk update the enabled status of multiple tools."""
|
|
tool_ids = [uuid.UUID(u.tool_id) for u in updates]
|
|
result = await db.execute(select(Tool).where(Tool.id.in_(tool_ids)))
|
|
tools_map = {str(t.id): t for t in result.scalars().all()}
|
|
|
|
for update in updates:
|
|
if update.tool_id in tools_map:
|
|
tools_map[update.tool_id].enabled = update.enabled
|
|
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.put("/{tool_id}")
|
|
async def update_tool(
|
|
tool_id: uuid.UUID,
|
|
data: ToolUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a tool."""
|
|
result = await db.execute(select(Tool).where(Tool.id == tool_id))
|
|
tool = result.scalar_one_or_none()
|
|
if not tool:
|
|
raise HTTPException(status_code=404, detail="Tool not found")
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
# Encrypt sensitive fields in config
|
|
if "config" in update_data and update_data["config"]:
|
|
update_data["config"] = _encrypt_sensitive_fields(update_data["config"], tool.config_schema)
|
|
|
|
for field, value in update_data.items():
|
|
setattr(tool, field, value)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/{tool_id}")
|
|
async def delete_tool(
|
|
tool_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a tool (only non-builtin)."""
|
|
result = await db.execute(select(Tool).where(Tool.id == tool_id))
|
|
tool = result.scalar_one_or_none()
|
|
if not tool:
|
|
raise HTTPException(status_code=404, detail="Tool not found")
|
|
if tool.type == "builtin":
|
|
raise HTTPException(status_code=400, detail="Cannot delete builtin tools")
|
|
|
|
await db.execute(delete(AgentTool).where(AgentTool.tool_id == tool_id))
|
|
await db.delete(tool)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ─── Per-Agent Tool Assignment ─────────────────────────────
|
|
@router.get("/agents/{agent_id}")
|
|
async def get_agent_tools(
|
|
agent_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get tools for a specific agent with their enabled status."""
|
|
from app.services.agent_tools import _agent_has_feishu
|
|
has_feishu = await _agent_has_feishu(agent_id)
|
|
|
|
# All available tools
|
|
all_tools_r = await db.execute(select(Tool).where(Tool.enabled == True).order_by(Tool.category, Tool.name))
|
|
all_tools = all_tools_r.scalars().all()
|
|
|
|
# Agent-specific assignments
|
|
agent_tools_r = await db.execute(select(AgentTool).where(AgentTool.agent_id == agent_id))
|
|
assignments = {str(at.tool_id): at for at in agent_tools_r.scalars().all()}
|
|
|
|
result = []
|
|
for t in all_tools:
|
|
# Hide feishu tools for agents without Feishu channel
|
|
if t.category == "feishu" and not has_feishu:
|
|
continue
|
|
tid = str(t.id)
|
|
at = assignments.get(tid)
|
|
# MCP tools installed by agents only show for that agent.
|
|
# MCP admin tools show for all agents (default disabled).
|
|
if t.source == "agent" and not at:
|
|
continue
|
|
# If no explicit assignment, use is_default
|
|
enabled = at.enabled if at else t.is_default
|
|
result.append({
|
|
"id": tid,
|
|
"name": t.name,
|
|
"display_name": t.display_name,
|
|
"description": t.description,
|
|
"type": t.type,
|
|
"category": t.category,
|
|
"icon": t.icon,
|
|
"enabled": enabled,
|
|
"is_default": t.is_default,
|
|
"mcp_server_name": t.mcp_server_name,
|
|
})
|
|
return result
|
|
|
|
|
|
@router.put("/agents/{agent_id}")
|
|
async def update_agent_tools(
|
|
agent_id: uuid.UUID,
|
|
updates: list[AgentToolUpdate],
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update tool assignments for an agent."""
|
|
for u in updates:
|
|
tool_id = uuid.UUID(u.tool_id)
|
|
# Upsert
|
|
result = await db.execute(
|
|
select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)
|
|
)
|
|
at = result.scalar_one_or_none()
|
|
if at:
|
|
at.enabled = u.enabled
|
|
else:
|
|
db.add(AgentTool(agent_id=agent_id, tool_id=tool_id, enabled=u.enabled))
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ─── MCP Server Testing ────────────────────────────────────
|
|
class MCPTestRequest(BaseModel):
|
|
server_url: str
|
|
# Optional standalone API Key. If provided, it is sent as
|
|
# 'Authorization: Bearer {api_key}' and is NOT embedded in the URL.
|
|
api_key: str | None = None
|
|
|
|
|
|
@router.post("/test-mcp")
|
|
async def test_mcp_connection(
|
|
data: MCPTestRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Test connection to an MCP server and list available tools.
|
|
|
|
Supports two authentication modes:
|
|
- URL-embedded key (e.g. ?tavilyApiKey=xxx) — include in server_url.
|
|
- Bearer token — pass via api_key field; sent as Authorization header.
|
|
"""
|
|
from app.services.mcp_client import MCPClient
|
|
|
|
try:
|
|
client = MCPClient(data.server_url, api_key=data.api_key or None)
|
|
tools = await client.list_tools()
|
|
return {"ok": True, "tools": tools}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
|
|
# ─── MCP Server-level Credential Management ────────────────
|
|
class MCPServerUpdate(BaseModel):
|
|
server_name: str # Identifies which server's tools to update
|
|
server_url: str # New MCP server URL (may contain embedded key)
|
|
api_key: str | None = None # Optional standalone Bearer key
|
|
# Target tenant (platform admins may manage another company's tools)
|
|
tenant_id: str | None = None
|
|
|
|
|
|
@router.put("/mcp-server")
|
|
async def update_mcp_server(
|
|
data: MCPServerUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Bulk-update the Server URL and API Key for all tools from an MCP server.
|
|
|
|
All tools sharing the same mcp_server_name under the target tenant are
|
|
updated atomically. The API Key is stored encrypted in tool.config so
|
|
the agent runner can resolve it at execution time without re-configuring
|
|
each tool individually.
|
|
|
|
Authentication priority at runtime (handled by MCPClient):
|
|
1. tool.config['api_key'] — sent as Authorization: Bearer header.
|
|
2. URL query param (e.g. ?tavilyApiKey=xxx) — extracted from the URL
|
|
and converted to Bearer by MCPClient automatically.
|
|
"""
|
|
# Resolve target tenant
|
|
target_tenant_id: uuid.UUID | None = None
|
|
if data.tenant_id:
|
|
try:
|
|
target_tenant_id = uuid.UUID(data.tenant_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid tenant_id format")
|
|
else:
|
|
target_tenant_id = current_user.tenant_id
|
|
|
|
# Load all tools from this server under the target tenant
|
|
result = await db.execute(
|
|
select(Tool).where(
|
|
Tool.mcp_server_name == data.server_name,
|
|
Tool.tenant_id == target_tenant_id,
|
|
)
|
|
)
|
|
tools = result.scalars().all()
|
|
if not tools:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No tools found for server '{data.server_name}'",
|
|
)
|
|
|
|
for tool in tools:
|
|
tool.mcp_server_url = data.server_url
|
|
if data.api_key is not None:
|
|
# Merge api_key into existing config (other keys preserved) and encrypt
|
|
current_config = dict(tool.config or {})
|
|
current_config["api_key"] = data.api_key
|
|
tool.config = _encrypt_sensitive_fields(current_config, tool.config_schema)
|
|
# If api_key is None (not provided), preserve the existing encrypted key
|
|
|
|
await db.commit()
|
|
return {"ok": True, "updated": len(tools)}
|
|
|
|
|
|
|
|
|
|
# ─── Agent-installed Tools Management (admin) ───────────────
|
|
|
|
@router.get("/agent-installed")
|
|
async def list_agent_installed_tools(
|
|
tenant_id: str | None = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Admin endpoint: list user-installed tools scoped by tenant."""
|
|
from app.models.agent import Agent
|
|
query = (
|
|
select(AgentTool, Tool, Agent)
|
|
.join(Tool, AgentTool.tool_id == Tool.id)
|
|
.outerjoin(Agent, AgentTool.installed_by_agent_id == Agent.id)
|
|
.where(AgentTool.source == "user_installed")
|
|
.order_by(AgentTool.created_at.desc())
|
|
)
|
|
# Scope by tenant: only show tools installed by agents in this tenant
|
|
tid = tenant_id or (str(current_user.tenant_id) if current_user.tenant_id else None)
|
|
if tid:
|
|
from app.models.agent import Agent as Ag
|
|
tenant_agent_ids = select(Ag.id).where(Ag.tenant_id == tid)
|
|
query = query.where(AgentTool.agent_id.in_(tenant_agent_ids))
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
return [
|
|
{
|
|
"agent_tool_id": str(at.id),
|
|
"agent_id": str(at.agent_id),
|
|
"tool_id": str(t.id),
|
|
"tool_name": t.name,
|
|
"tool_display_name": t.display_name,
|
|
"mcp_server_name": t.mcp_server_name,
|
|
"installed_by_agent_id": str(at.installed_by_agent_id) if at.installed_by_agent_id else None,
|
|
"installed_by_agent_name": a.name if a else None,
|
|
"enabled": at.enabled,
|
|
"installed_at": at.created_at.isoformat() if at.created_at else None,
|
|
}
|
|
for at, t, a in rows
|
|
]
|
|
|
|
|
|
@router.delete("/agent-tool/{agent_tool_id}")
|
|
async def delete_agent_tool(
|
|
agent_tool_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Admin: remove an agent-tool assignment. Also deletes the tool record if no other agents use it."""
|
|
at_r = await db.execute(select(AgentTool).where(AgentTool.id == agent_tool_id))
|
|
at = at_r.scalar_one_or_none()
|
|
if not at:
|
|
raise HTTPException(status_code=404, detail="Agent tool assignment not found")
|
|
tool_id = at.tool_id
|
|
await db.delete(at)
|
|
await db.flush()
|
|
# If no other agent uses this tool, delete the tool record too (for MCP tools)
|
|
remaining_r = await db.execute(select(AgentTool).where(AgentTool.tool_id == tool_id).limit(1))
|
|
if not remaining_r.scalar_one_or_none():
|
|
tool_r = await db.execute(select(Tool).where(Tool.id == tool_id))
|
|
tool = tool_r.scalar_one_or_none()
|
|
if tool and tool.type == "mcp":
|
|
await db.delete(tool)
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ─── Per-Agent Tool Config ───────────────────────────────────
|
|
|
|
class AgentToolConfigUpdate(BaseModel):
|
|
config: dict
|
|
|
|
|
|
@router.get("/agents/{agent_id}/tool-config/{tool_id}")
|
|
async def get_agent_tool_config(
|
|
agent_id: uuid.UUID,
|
|
tool_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get merged tool config (global defaults + agent overrides) and config_schema.
|
|
|
|
Both configs are decrypted before returning. Global sensitive fields are
|
|
masked so the frontend can show a key is configured without exposing it.
|
|
"""
|
|
tool_r = await db.execute(select(Tool).where(Tool.id == tool_id))
|
|
tool = tool_r.scalar_one_or_none()
|
|
if not tool:
|
|
raise HTTPException(status_code=404, detail="Tool not found")
|
|
at_r = await db.execute(
|
|
select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)
|
|
)
|
|
at = at_r.scalar_one_or_none()
|
|
|
|
# Decrypt both configs using the tool's config_schema for field type awareness
|
|
schema = tool.config_schema
|
|
raw_global = _decrypt_sensitive_fields(tool.config or {}, schema)
|
|
raw_agent = _decrypt_sensitive_fields(at.config if at else {}, schema)
|
|
|
|
# Mask sensitive fields in global config for display
|
|
masked_global = dict(raw_global)
|
|
sensitive_keys = _get_sensitive_keys(schema)
|
|
for key in sensitive_keys:
|
|
val = masked_global.get(key)
|
|
if val and isinstance(val, str) and len(val) > 0:
|
|
suffix = val[-4:] if len(val) > 4 else val
|
|
masked_global[key] = f"****{suffix}"
|
|
|
|
# Merged: agent overrides take precedence over global defaults.
|
|
# Use raw (non-masked) global as the base so the agent inherits actual values
|
|
# at runtime, but the UI will show masked_global for display hints.
|
|
merged = {**raw_global, **(raw_agent or {})}
|
|
return {
|
|
"global_config": masked_global,
|
|
"agent_config": raw_agent or {},
|
|
"merged_config": merged,
|
|
"config_schema": tool.config_schema or {},
|
|
}
|
|
|
|
|
|
@router.put("/agents/{agent_id}/tool-config/{tool_id}")
|
|
async def update_agent_tool_config(
|
|
agent_id: uuid.UUID,
|
|
tool_id: uuid.UUID,
|
|
data: AgentToolConfigUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Save per-agent config override for a tool."""
|
|
# Check permission: only platform_admin and org_admin can modify allow_network
|
|
if "allow_network" in data.config:
|
|
if current_user.role not in ("platform_admin", "org_admin"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Only platform admin or organization admin can modify network access settings"
|
|
)
|
|
|
|
# Encrypt sensitive fields using the tool's config_schema for field type awareness
|
|
tool_r2 = await db.execute(select(Tool).where(Tool.id == tool_id))
|
|
tool_for_schema = tool_r2.scalar_one_or_none()
|
|
encrypted_config = _encrypt_sensitive_fields(data.config, tool_for_schema.config_schema if tool_for_schema else None)
|
|
|
|
at_r = await db.execute(
|
|
select(AgentTool).where(AgentTool.agent_id == agent_id, AgentTool.tool_id == tool_id)
|
|
)
|
|
at = at_r.scalar_one_or_none()
|
|
if at:
|
|
at.config = encrypted_config
|
|
else:
|
|
# Create assignment if not exists
|
|
db.add(AgentTool(agent_id=agent_id, tool_id=tool_id, enabled=True, config=encrypted_config))
|
|
await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/agents/{agent_id}/with-config")
|
|
async def get_agent_tools_with_config(
|
|
agent_id: uuid.UUID,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get agent's enabled tools with per-agent config info and config_schema for settings UI.
|
|
|
|
Both global_config and agent_config are decrypted before returning.
|
|
For global_config, sensitive fields are masked (e.g. "sk-****abcd") so the
|
|
frontend can show that a company key is configured without exposing it.
|
|
|
|
Special handling: some tools (Jina) store their API key in system_settings
|
|
rather than Tool.config. We resolve those as part of the global config so
|
|
the agent-level UI can show the inherited key hint.
|
|
"""
|
|
from app.services.agent_tools import _agent_has_feishu
|
|
has_feishu = await _agent_has_feishu(agent_id)
|
|
|
|
all_tools_r = await db.execute(select(Tool).where(Tool.enabled == True).order_by(Tool.category, Tool.name))
|
|
all_tools = all_tools_r.scalars().all()
|
|
agent_tools_r = await db.execute(select(AgentTool).where(AgentTool.agent_id == agent_id))
|
|
assignments = {str(at.tool_id): at for at in agent_tools_r.scalars().all()}
|
|
|
|
# Pre-fetch system_settings keys that some tools use as an alternative
|
|
# config storage (e.g. Jina stores its API key in system_settings.jina_api_key)
|
|
system_keys_cache: dict[str, str] = {}
|
|
SYSTEM_SETTINGS_TOOL_MAP = {
|
|
# tool_name -> system_settings key + value path
|
|
"jina_search": ("jina_api_key", "api_key"),
|
|
"jina_read": ("jina_api_key", "api_key"),
|
|
}
|
|
|
|
result = []
|
|
for t in all_tools:
|
|
# Hide feishu tools for agents without Feishu channel
|
|
if t.category == "feishu" and not has_feishu:
|
|
continue
|
|
tid = str(t.id)
|
|
at = assignments.get(tid)
|
|
# MCP tools installed by agents only show for that agent.
|
|
# MCP admin tools show for all agents (default disabled).
|
|
if t.source == "agent" and not at:
|
|
continue
|
|
enabled = at.enabled if at else t.is_default
|
|
|
|
# Decrypt configs for the frontend
|
|
raw_global = _decrypt_sensitive_fields(t.config or {}, t.config_schema)
|
|
|
|
# Fallback: resolve api_key from system_settings for tools that store
|
|
# their key there (e.g. Jina). Only if Tool.config doesn't have it.
|
|
if t.name in SYSTEM_SETTINGS_TOOL_MAP and not raw_global.get("api_key"):
|
|
ss_key, ss_field = SYSTEM_SETTINGS_TOOL_MAP[t.name]
|
|
if ss_key not in system_keys_cache:
|
|
try:
|
|
from app.models.system_settings import SystemSetting
|
|
ss_r = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == ss_key)
|
|
)
|
|
ss = ss_r.scalar_one_or_none()
|
|
system_keys_cache[ss_key] = (
|
|
ss.value.get(ss_field, "") if ss and ss.value else ""
|
|
)
|
|
except Exception:
|
|
system_keys_cache[ss_key] = ""
|
|
if system_keys_cache[ss_key]:
|
|
raw_global["api_key"] = system_keys_cache[ss_key]
|
|
|
|
raw_agent = _decrypt_sensitive_fields((at.config if at else {}) or {}, t.config_schema)
|
|
|
|
# Mask sensitive fields in global_config so users can see that a key
|
|
# is configured at the company level without exposing the full value.
|
|
masked_global = dict(raw_global)
|
|
sensitive_keys = _get_sensitive_keys(t.config_schema)
|
|
for key in sensitive_keys:
|
|
val = masked_global.get(key)
|
|
if val and isinstance(val, str) and len(val) > 0:
|
|
# Show "****" + last 4 chars as a hint
|
|
suffix = val[-4:] if len(val) > 4 else val
|
|
masked_global[key] = f"****{suffix}"
|
|
|
|
result.append({
|
|
"id": tid,
|
|
"agent_tool_id": str(at.id) if at else None,
|
|
"name": t.name,
|
|
"display_name": t.display_name,
|
|
"description": t.description,
|
|
"type": t.type,
|
|
"category": t.category,
|
|
"icon": t.icon,
|
|
"enabled": enabled,
|
|
"is_default": t.is_default,
|
|
"mcp_server_name": t.mcp_server_name,
|
|
"config_schema": t.config_schema or {},
|
|
"global_config": masked_global,
|
|
"agent_config": raw_agent,
|
|
"source": t.source,
|
|
})
|
|
return result
|
|
|
|
|
|
# ─── Email Connection Testing ──────────────────────────────
|
|
|
|
class EmailTestRequest(BaseModel):
|
|
config: dict
|
|
|
|
|
|
@router.post("/test-email")
|
|
async def test_email_connection(
|
|
data: EmailTestRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Test IMAP and SMTP email connections with provided config."""
|
|
from app.services.email_service import test_connection
|
|
|
|
try:
|
|
result = await test_connection(data.config)
|
|
return result
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
|
|
@router.get("/email-providers")
|
|
async def get_email_providers(
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Get list of supported email provider presets with help text."""
|
|
from app.services.email_service import EMAIL_PROVIDERS
|
|
|
|
return {
|
|
key: {
|
|
"label": p["label"],
|
|
"help_url": p.get("help_url", ""),
|
|
"help_text": p.get("help_text", ""),
|
|
}
|
|
for key, p in EMAIL_PROVIDERS.items()
|
|
}
|
|
# ─── Tool Category Sharing Config (Generic ChannelConfig) ───
|
|
|
|
@router.get("/agents/{agent_id}/category-config/{category}")
|
|
async def get_category_config(
|
|
agent_id: uuid.UUID,
|
|
category: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get shared configuration for a tool category.
|
|
|
|
Returns both global_config (company-level, from Tool.config) and
|
|
agent_config (agent-level override, from ChannelConfig) separately.
|
|
Sensitive fields in global_config are masked for display.
|
|
Company-level values always take precedence at runtime.
|
|
"""
|
|
from app.core.permissions import check_agent_access
|
|
from app.models.channel_config import ChannelConfig
|
|
|
|
await check_agent_access(db, current_user, agent_id)
|
|
|
|
# ── 1. Load company-level (global) config from Tool.config ──────────────
|
|
# Find a tool in this category that actually has config data.
|
|
# We cannot just LIMIT 1 because most tools may have empty config.
|
|
all_cat_tools = await db.execute(
|
|
select(Tool).where(
|
|
Tool.category == category,
|
|
Tool.enabled == True,
|
|
).order_by(Tool.name)
|
|
)
|
|
raw_global: dict = {}
|
|
cat_schema: dict | None = None
|
|
for ct in all_cat_tools.scalars():
|
|
if ct.config and ct.config != {}:
|
|
cat_schema = ct.config_schema
|
|
raw_global = _decrypt_sensitive_fields(ct.config, cat_schema)
|
|
break
|
|
|
|
# Mask sensitive fields for UI display
|
|
masked_global = dict(raw_global)
|
|
sensitive_keys = _get_sensitive_keys(cat_schema)
|
|
for key in sensitive_keys:
|
|
val = masked_global.get(key)
|
|
if val and isinstance(val, str):
|
|
suffix = val[-4:] if len(val) > 4 else val
|
|
masked_global[key] = f"****{suffix}"
|
|
|
|
# ── 2. Load agent-level config from ChannelConfig ───────────────────────
|
|
result = await db.execute(
|
|
select(ChannelConfig).where(
|
|
ChannelConfig.agent_id == agent_id,
|
|
ChannelConfig.channel_type == category,
|
|
)
|
|
)
|
|
config = result.scalar_one_or_none()
|
|
|
|
config_id = None
|
|
is_configured = bool(raw_global) or config is not None
|
|
raw_agent: dict = {}
|
|
|
|
if config:
|
|
config_id = str(config.id)
|
|
full_agent = {
|
|
"api_key": config.app_secret,
|
|
**(config.extra_config or {}),
|
|
}
|
|
raw_agent = _decrypt_sensitive_fields(full_agent)
|
|
# Remove None values produced by missing app_secret
|
|
raw_agent = {k: v for k, v in raw_agent.items() if v is not None}
|
|
|
|
# ── 3. Build effective config ───────────────────────────────────────────
|
|
# Priority: Agent config > Company config > Default
|
|
# Agent can override company values by setting their own.
|
|
effective_config = {**raw_global, **raw_agent}
|
|
|
|
return {
|
|
"id": config_id,
|
|
"agent_id": str(agent_id),
|
|
"category": category,
|
|
"is_configured": is_configured,
|
|
# Legacy field (backward-compat): full effective config for display
|
|
"config": effective_config,
|
|
# New fields for richer UI: show global and agent configs separately
|
|
"global_config": masked_global,
|
|
"agent_config": raw_agent,
|
|
}
|
|
|
|
|
|
@router.post("/agents/{agent_id}/category-config/{category}")
|
|
async def update_category_config(
|
|
agent_id: uuid.UUID,
|
|
category: str,
|
|
data: CategoryConfigUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update or create shared configuration for a tool category."""
|
|
from app.core.permissions import check_agent_access, is_agent_creator
|
|
from app.models.channel_config import ChannelConfig
|
|
|
|
agent, _ = await check_agent_access(db, current_user, agent_id)
|
|
if not is_agent_creator(current_user, agent):
|
|
raise HTTPException(status_code=403, detail="Only creator can configure category")
|
|
|
|
# Encrypt sensitive fields
|
|
encrypted_config = _encrypt_sensitive_fields(data.config)
|
|
app_secret = encrypted_config.get("api_key") or encrypted_config.get("api_secret") or encrypted_config.get("app_secret")
|
|
extra = {k: v for k, v in encrypted_config.items() if k not in ("api_key", "api_secret", "app_secret")}
|
|
|
|
result = await db.execute(
|
|
select(ChannelConfig).where(
|
|
ChannelConfig.agent_id == agent_id,
|
|
ChannelConfig.channel_type == category,
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing:
|
|
if app_secret:
|
|
existing.app_secret = app_secret
|
|
# Merge extra config (note: extra is already encrypted)
|
|
existing.extra_config = {**(existing.extra_config or {}), **extra}
|
|
existing.is_configured = True
|
|
else:
|
|
config = ChannelConfig(
|
|
agent_id=agent_id,
|
|
channel_type=category,
|
|
app_id=category,
|
|
app_secret=app_secret,
|
|
extra_config=extra,
|
|
is_configured=True,
|
|
)
|
|
db.add(config)
|
|
|
|
await db.commit()
|
|
|
|
# Special logic for Atlassian: trigger sync
|
|
if category == "atlassian":
|
|
from app.api.atlassian import _sync_atlassian_tools_for_agent
|
|
import asyncio
|
|
# Need plaintext key for sync
|
|
plaintext_key = data.config.get("api_key") or data.config.get("api_secret") or data.config.get("app_secret")
|
|
asyncio.create_task(_sync_atlassian_tools_for_agent(agent_id, plaintext_key))
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/agents/{agent_id}/category-config/{category}", status_code=204)
|
|
async def delete_category_config(
|
|
agent_id: uuid.UUID,
|
|
category: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Remove shared configuration for a tool category."""
|
|
from app.core.permissions import check_agent_access, is_agent_creator
|
|
from app.models.channel_config import ChannelConfig
|
|
|
|
agent, _ = await check_agent_access(db, current_user, agent_id)
|
|
if not is_agent_creator(current_user, agent):
|
|
raise HTTPException(status_code=403, detail="Only creator can remove config")
|
|
|
|
await db.execute(
|
|
delete(ChannelConfig).where(
|
|
ChannelConfig.agent_id == agent_id,
|
|
ChannelConfig.channel_type == category,
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/agents/{agent_id}/category-config/{category}/test")
|
|
async def test_category_config(
|
|
agent_id: uuid.UUID,
|
|
category: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Test connectivity for a tool category."""
|
|
if category == "atlassian":
|
|
from app.api.atlassian import test_atlassian_channel
|
|
return await test_atlassian_channel(agent_id, current_user, db)
|
|
elif category == "agentbay":
|
|
from app.services.agentbay_client import test_agentbay_channel
|
|
return await test_agentbay_channel(agent_id, current_user, db)
|
|
|
|
return {"ok": True, "message": f"Settings for {category} saved."}
|