dograh/api/services/tool_management.py
Sabiha Khan 2801c3156e
Feat/dybamic transfer (#521)
* feat: enable dynamic transfer destination resolution

* fix: remove approved routes, policy and fallback

* fix: review comments
2026-07-10 21:40:26 +05:30

266 lines
8.6 KiB
Python

"""Service layer for reusable tool management.
Routes and MCP tools both use this module so validation, credential
scoping, MCP discovery, and analytics stay consistent.
"""
from __future__ import annotations
import asyncio
from typing import Any, Optional
from loguru import logger
from api.db import db_client
from api.db.models import UserModel
from api.enums import PostHogEvent, ToolCategory
from api.schemas.tool import (
CreatedByResponse,
CreateToolRequest,
McpRefreshResponse,
ToolResponse,
)
from api.services.posthog_client import capture_event
from api.services.workflow.mcp_tool_session import discover_mcp_tools
from api.services.workflow.tools.mcp_tool import (
McpDefinitionError,
validate_mcp_definition,
)
class ToolManagementError(ValueError):
"""Recoverable tool-management error with an MCP/HTTP friendly code."""
def __init__(self, error_code: str, message: str, *, status_code: int = 400):
super().__init__(message)
self.error_code = error_code
self.message = message
self.status_code = status_code
def build_tool_response(tool: Any, include_created_by: bool = False) -> ToolResponse:
"""Build a public response from a ToolModel-like object."""
created_by = None
if include_created_by and tool.created_by_user:
created_by = CreatedByResponse(
id=tool.created_by_user.id,
provider_id=tool.created_by_user.provider_id,
)
return ToolResponse(
id=tool.id,
tool_uuid=tool.tool_uuid,
name=tool.name,
description=tool.description,
category=tool.category,
icon=tool.icon,
icon_color=tool.icon_color,
status=tool.status,
definition=tool.definition,
created_at=tool.created_at,
updated_at=tool.updated_at,
created_by=created_by,
)
def _credential_uuid_from_definition(definition: dict[str, Any]) -> Optional[str]:
config = definition.get("config")
if not isinstance(config, dict):
return None
credential_uuid = config.get("credential_uuid")
return credential_uuid if isinstance(credential_uuid, str) else None
def _credential_uuids_from_definition(definition: dict[str, Any]) -> list[str]:
credential_uuids: list[str] = []
top_level = _credential_uuid_from_definition(definition)
if top_level:
credential_uuids.append(top_level)
config = definition.get("config")
if isinstance(config, dict):
resolver = config.get("resolver")
if isinstance(resolver, dict):
resolver_credential_uuid = resolver.get("credential_uuid")
if isinstance(resolver_credential_uuid, str) and resolver_credential_uuid:
credential_uuids.append(resolver_credential_uuid)
return list(dict.fromkeys(credential_uuids))
async def fetch_credential(credential_uuid: Optional[str], organization_id: int):
"""Best-effort credential lookup for MCP auth/discovery."""
if not credential_uuid:
return None
try:
return await db_client.get_credential_by_uuid(credential_uuid, organization_id)
except Exception as e: # noqa: BLE001
logger.warning(f"Tool credential fetch failed: {e}")
return None
async def validate_tool_credential_references(
definition: dict[str, Any], *, organization_id: int
) -> None:
"""Ensure credential UUID references belong to the caller's organization."""
for credential_uuid in _credential_uuids_from_definition(definition):
credential = await db_client.get_credential_by_uuid(
credential_uuid, organization_id
)
if not credential:
raise ToolManagementError(
"credential_not_found",
(
f"Credential '{credential_uuid}' was not found in this "
"organization. Create it in the UI first, then retry with its "
"credential_uuid."
),
status_code=404,
)
async def populate_discovered_tools(
definition: dict[str, Any], *, organization_id: int
) -> dict[str, Any]:
"""Best-effort MCP discovery before saving a tool definition.
Non-MCP definitions pass through untouched. For MCP definitions, a dead
server yields ``discovered_tools: []`` and does not block creation.
"""
if not isinstance(definition, dict) or definition.get("type") != "mcp":
return definition
try:
cfg = validate_mcp_definition(definition)
except McpDefinitionError:
return definition
credential = await fetch_credential(cfg.get("credential_uuid"), organization_id)
async def _run() -> list:
try:
return await discover_mcp_tools(
url=cfg["url"],
credential=credential,
timeout_secs=cfg["timeout_secs"],
sse_read_timeout_secs=cfg["sse_read_timeout_secs"],
)
except BaseException as e: # noqa: BLE001
logger.warning(f"MCP discovery failed; caching empty list: {e}")
return []
discovered = await asyncio.ensure_future(_run())
definition["config"]["discovered_tools"] = discovered
return definition
async def create_tool_for_user(
request: CreateToolRequest,
user: UserModel,
*,
source: str = "api",
) -> ToolResponse:
"""Create a reusable tool for the authenticated user's selected org."""
if not user.selected_organization_id:
raise ToolManagementError(
"organization_required",
"No organization selected for the user",
status_code=400,
)
definition = request.definition.model_dump()
await validate_tool_credential_references(
definition, organization_id=user.selected_organization_id
)
definition = await populate_discovered_tools(
definition,
organization_id=user.selected_organization_id,
)
tool = await db_client.create_tool(
organization_id=user.selected_organization_id,
user_id=user.id,
name=request.name,
definition=definition,
category=request.category,
description=request.description,
icon=request.icon,
icon_color=request.icon_color,
)
capture_event(
distinct_id=str(user.provider_id),
event=PostHogEvent.TOOL_CREATED,
properties={
"tool_name": request.name,
"tool_category": request.category,
"source": source,
"organization_id": user.selected_organization_id,
},
)
return build_tool_response(tool)
async def refresh_mcp_tool_for_user(
tool_uuid: str,
user: UserModel,
) -> McpRefreshResponse:
"""Refresh cached MCP catalog for a tool owned by the user's org."""
if not user.selected_organization_id:
raise ToolManagementError(
"organization_required",
"No organization selected for the user",
status_code=400,
)
tool = await db_client.get_tool_by_uuid(
tool_uuid, user.selected_organization_id, include_archived=True
)
if not tool:
raise ToolManagementError("tool_not_found", "Tool not found", status_code=404)
if tool.category != ToolCategory.MCP.value:
raise ToolManagementError(
"not_mcp_tool", "Tool is not an MCP tool", status_code=400
)
try:
cfg = validate_mcp_definition(tool.definition)
except McpDefinitionError as e:
raise ToolManagementError(
"invalid_mcp_definition",
f"Invalid MCP definition: {e}",
status_code=400,
) from e
credential = await fetch_credential(
cfg.get("credential_uuid"), user.selected_organization_id
)
try:
discovered = await discover_mcp_tools(
url=cfg["url"],
credential=credential,
timeout_secs=cfg["timeout_secs"],
sse_read_timeout_secs=cfg["sse_read_timeout_secs"],
)
except Exception as e: # noqa: BLE001
logger.warning(f"MCP refresh discovery failed: {e}")
discovered = []
if not discovered:
error = (
f"Could not reach the MCP server at {cfg['url']} "
f"(or it exposes no tools). Previously cached list retained."
)
return McpRefreshResponse(tool_uuid=tool_uuid, discovered_tools=[], error=error)
new_def = dict(tool.definition or {})
new_def["config"] = {**new_def.get("config", {}), "discovered_tools": discovered}
await db_client.update_tool(
tool_uuid=tool_uuid,
organization_id=user.selected_organization_id,
definition=new_def,
)
return McpRefreshResponse(
tool_uuid=tool_uuid, discovered_tools=discovered, error=None
)