dograh/api/services/tool_management.py
Abhishek d8cd34e8d2
feat: add vici dial controls from UI (#563)
* ViciDial Working

* feat(telephony): add FreeSWITCH provider to upstream-PBX seam

Generalize the upstream-PBX capture and control paths to dispatch by
provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over
the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID,
alongside the existing VICIdial ra_call_control path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* add vici specific configs

* feat(telephony): env-based VICIdial config + address3 post-call routing

Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings
out of hardcoded POC values into environment variables so the same image
works against a PBX on another server.

Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted
variables are mapped to lead columns and pushed via the non-agent API
before a transfer, with reserved API-control params dropped.

Add hardcoded address3 disposition routing (Y/N -> in-group transfer,
else hang up) and a synchronous final variable extraction so the
transfer path sees the freshest conversation state. Skip upstream_pbx
capture on non-PJSIP channels to avoid Asterisk 500s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: run formatter

* feat: make vici configurable from UI

* chore: clean up PR

* fix: incorporate review comments

* chore: incorporate review comments

* chore: generate client

* chore: incporporate review comments

* chore: incorporate review comments

---------

Co-authored-by: Dograh POC <payment@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:57:34 +05:30

305 lines
10 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.organization_preferences import external_pbx_integrations_enabled
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 validate_external_pbx_tool_definition(
definition: dict[str, Any],
*,
organization_id: int,
existing_definition: Optional[dict[str, Any]] = None,
) -> None:
"""Enforce the org feature gate for context-to-in-group routing."""
config = definition.get("config")
existing_config = (existing_definition or {}).get("config")
uses_external_pbx = (
isinstance(config, dict)
and config.get("destination_source") == "context_mapping"
)
existing_uses_external_pbx = (
isinstance(existing_config, dict)
and existing_config.get("destination_source") == "context_mapping"
)
if not uses_external_pbx and not existing_uses_external_pbx:
return
if await external_pbx_integrations_enabled(organization_id):
return
if isinstance(existing_config, dict) and existing_config == config:
# Preserve a hidden existing mapping while the feature is disabled.
return
raise ToolManagementError(
"external_pbx_feature_disabled",
(
"External PBX integrations are disabled for this organization. "
"Enable them in Platform Settings before configuring in-group routing."
),
status_code=403,
)
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_external_pbx_tool_definition(
definition, organization_id=user.selected_organization_id
)
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
)