"""MCP tool definitions for LeoCRM — contacts, calendar, mail, DMS. Each tool has a name, description, parameter schema, required permission, and an async handler that operates on the database. """ from __future__ import annotations import base64 import logging import uuid from datetime import datetime from typing import Any from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter logger = logging.getLogger(__name__) # ─── Tool Definitions ────────────────────────────────────────────────────── TOOL_DEFINITIONS: list[McpToolDefinition] = [ McpToolDefinition( name="search_contacts", description="Search contacts by name, email, or phone number.", category="contacts", required_permission="mcp:read", parameters=[ McpToolParameter(name="query", type="string", description="Search query (name, email, phone)", required=True), McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20), ], ), McpToolDefinition( name="get_contact", description="Get detailed information about a specific contact.", category="contacts", required_permission="mcp:read", parameters=[ McpToolParameter(name="contact_id", type="string", description="Contact UUID", required=True), ], ), McpToolDefinition( name="create_contact", description="Create a new contact in LeoCRM.", category="contacts", required_permission="mcp:write", parameters=[ McpToolParameter(name="name", type="string", description="Contact name", required=True), McpToolParameter(name="email", type="string", description="Contact email", required=False), McpToolParameter(name="phone", type="string", description="Contact phone", required=False), McpToolParameter(name="type", type="string", description="Contact type (person, company)", required=False, default="person"), ], ), McpToolDefinition( name="list_calendar_entries", description="List calendar entries within a date range.", category="calendar", required_permission="mcp:read", parameters=[ McpToolParameter(name="date_from", type="string", description="Start date (ISO 8601)", required=False), McpToolParameter(name="date_to", type="string", description="End date (ISO 8601)", required=False), ], ), McpToolDefinition( name="create_calendar_entry", description="Create a new calendar appointment.", category="calendar", required_permission="mcp:write", parameters=[ McpToolParameter(name="title", type="string", description="Appointment title", required=True), McpToolParameter(name="start", type="string", description="Start datetime (ISO 8601)", required=True), McpToolParameter(name="end", type="string", description="End datetime (ISO 8601)", required=False), ], ), McpToolDefinition( name="list_emails", description="List emails from a specific folder.", category="mail", required_permission="mcp:read", parameters=[ McpToolParameter(name="folder", type="string", description="Folder name (default INBOX)", required=False, default="INBOX"), McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20), ], ), McpToolDefinition( name="send_email", description="Send an email from LeoCRM.", category="mail", required_permission="mcp:write", parameters=[ McpToolParameter(name="to", type="string", description="Recipient email address", required=True), McpToolParameter(name="subject", type="string", description="Email subject", required=True), McpToolParameter(name="body", type="string", description="Email body (plain text)", required=True), ], ), McpToolDefinition( name="list_files", description="List DMS files in a specific folder.", category="dms", required_permission="mcp:read", parameters=[ McpToolParameter(name="folder_id", type="string", description="Folder UUID (optional, root if omitted)", required=False), ], ), McpToolDefinition( name="upload_file", description="Upload a file to the DMS.", category="dms", required_permission="mcp:write", parameters=[ McpToolParameter(name="filename", type="string", description="File name", required=True), McpToolParameter(name="content_base64", type="string", description="File content as base64 string", required=True), ], ), ] def get_tool_definition(name: str) -> McpToolDefinition | None: """Get a tool definition by name.""" for tool in TOOL_DEFINITIONS: if tool.name == name: return tool return None def get_all_tool_names() -> list[str]: """Get all tool names.""" return [t.name for t in TOOL_DEFINITIONS] # ─── Tool Handlers ────────────────────────────────────────────────────────── async def _handler_search_contacts(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Search contacts by name/email/phone.""" from app.models.contact import Contact query = arguments.get("query", "") limit = min(int(arguments.get("limit", 20)), 100) pattern = f"%{query}%" stmt = select(Contact).where( and_( Contact.deleted_at.is_(None), (Contact.name.ilike(pattern)) | (Contact.displayname.ilike(pattern)) | (Contact.email_1.ilike(pattern)) | (Contact.phone_1.ilike(pattern)), ) ).limit(limit) result = await db.execute(stmt) contacts = result.scalars().all() return {"contacts": [_contact_to_dict(c) for c in contacts], "count": len(contacts)} async def _handler_get_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Get a single contact by ID.""" from app.models.contact import Contact contact_id = arguments.get("contact_id") try: cid = uuid.UUID(contact_id) except (ValueError, TypeError): return {"error": "Invalid contact_id"} stmt = select(Contact).where(and_(Contact.id == cid, Contact.deleted_at.is_(None))) result = await db.execute(stmt) contact = result.scalar_one_or_none() if not contact: return {"error": "Contact not found"} return _contact_to_dict(contact) async def _handler_create_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Create a new contact.""" from app.models.contact import Contact tenant_id = context.get("tenant_id") user_id = context.get("user_id") contact = Contact( tenant_id=uuid.UUID(tenant_id) if tenant_id else None, name=arguments.get("name", ""), displayname=arguments.get("name", ""), email_1=arguments.get("email"), phone_1=arguments.get("phone"), type=arguments.get("type", "person"), created_by=uuid.UUID(user_id) if user_id else None, ) db.add(contact) await db.flush() return _contact_to_dict(contact) async def _handler_list_calendar_entries(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """List calendar entries in a date range.""" from app.plugins.builtins.calendar.models import CalendarEntry date_from = arguments.get("date_from") date_to = arguments.get("date_to") conditions = [CalendarEntry.deleted_at.is_(None)] if date_from: conditions.append(CalendarEntry.start_at >= datetime.fromisoformat(date_from)) if date_to: conditions.append(CalendarEntry.start_at <= datetime.fromisoformat(date_to)) stmt = select(CalendarEntry).where(and_(*conditions)).limit(50) result = await db.execute(stmt) entries = result.scalars().all() return {"entries": [_entry_to_dict(e) for e in entries], "count": len(entries)} async def _handler_create_calendar_entry(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Create a new calendar entry.""" from app.plugins.builtins.calendar.models import Calendar, CalendarEntry tenant_id = context.get("tenant_id") user_id = context.get("user_id") # Find or create a default calendar cal_stmt = select(Calendar).where(and_(Calendar.tenant_id == uuid.UUID(tenant_id), Calendar.deleted_at.is_(None))).limit(1) cal_result = await db.execute(cal_stmt) calendar = cal_result.scalar_one_or_none() if not calendar: calendar = Calendar( tenant_id=uuid.UUID(tenant_id), name="MCP Default", owner_id=uuid.UUID(user_id), ) db.add(calendar) await db.flush() entry = CalendarEntry( tenant_id=uuid.UUID(tenant_id), calendar_id=calendar.id, entry_type="appointment", title=arguments.get("title", ""), start_at=datetime.fromisoformat(arguments.get("start")) if arguments.get("start") else None, end_at=datetime.fromisoformat(arguments.get("end")) if arguments.get("end") else None, created_by=uuid.UUID(user_id), ) db.add(entry) await db.flush() return _entry_to_dict(entry) async def _handler_list_emails(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """List emails from a folder.""" from app.plugins.builtins.mail.models import Mail, MailFolder folder_name = arguments.get("folder", "INBOX") limit = min(int(arguments.get("limit", 20)), 100) tenant_id = context.get("tenant_id") # Find folder by name folder_stmt = select(MailFolder).where( and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == folder_name) ).limit(1) folder_result = await db.execute(folder_stmt) folder = folder_result.scalar_one_or_none() if not folder: return {"emails": [], "count": 0} mail_stmt = select(Mail).where(and_(Mail.folder_id == folder.id, Mail.deleted_at.is_(None))).limit(limit) mail_result = await db.execute(mail_stmt) mails = mail_result.scalars().all() return {"emails": [_mail_to_dict(m) for m in mails], "count": len(mails)} async def _handler_send_email(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Send an email — creates a mail record (actual SMTP handled by mail plugin).""" from app.plugins.builtins.mail.models import Mail, MailFolder tenant_id = context.get("tenant_id") user_id = context.get("user_id") # Find or create a Sent folder folder_stmt = select(MailFolder).where( and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == "Sent") ).limit(1) folder_result = await db.execute(folder_stmt) folder = folder_result.scalar_one_or_none() if not folder: return {"error": "No Sent folder found — mail account not configured"} mail = Mail( tenant_id=uuid.UUID(tenant_id), folder_id=folder.id, from_addr="", to_addr=arguments.get("to", ""), subject=arguments.get("subject", ""), body_text=arguments.get("body", ""), is_outgoing=True, sent_at=datetime.utcnow(), ) db.add(mail) await db.flush() return {"message": "Email queued for sending", "mail_id": str(mail.id)} async def _handler_list_files(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """List DMS files in a folder.""" from app.plugins.builtins.dms.models import File as DmsFile, Folder tenant_id = context.get("tenant_id") folder_id = arguments.get("folder_id") conditions = [DmsFile.tenant_id == uuid.UUID(tenant_id), DmsFile.deleted_at.is_(None)] if folder_id: conditions.append(DmsFile.folder_id == uuid.UUID(folder_id)) stmt = select(DmsFile).where(and_(*conditions)).limit(50) result = await db.execute(stmt) files = result.scalars().all() return {"files": [_file_to_dict(f) for f in files], "count": len(files)} async def _handler_upload_file(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """Upload a file to DMS (stores content in storage backend).""" from app.plugins.builtins.dms.models import File as DmsFile, Folder from app.core.storage import get_storage_backend tenant_id = context.get("tenant_id") user_id = context.get("user_id") filename = arguments.get("filename", "untitled") content_b64 = arguments.get("content_base64", "") try: content = base64.b64decode(content_b64) except Exception: return {"error": "Invalid base64 content"} # Find root folder folder_stmt = select(Folder).where( and_(Folder.tenant_id == uuid.UUID(tenant_id), Folder.parent_id.is_(None)) ).limit(1) folder_result = await db.execute(folder_stmt) folder = folder_result.scalar_one_or_none() if not folder: folder = Folder(tenant_id=uuid.UUID(tenant_id), name="Root", created_by=uuid.UUID(user_id)) db.add(folder) await db.flush() # Store file storage = get_storage_backend() file_path = f"mcp/{uuid.uuid4()}/{filename}" storage.save(file_path, content) dms_file = DmsFile( tenant_id=uuid.UUID(tenant_id), folder_id=folder.id, name=filename, storage_path=file_path, mime_type="application/octet-stream", size=len(content), uploaded_by=uuid.UUID(user_id), ) db.add(dms_file) await db.flush() return {"file_id": str(dms_file.id), "name": dms_file.name, "size": dms_file.size} # ─── Handler Registry ────────────────────────────────────────────────────── TOOL_HANDLERS: dict[str, Any] = { "search_contacts": _handler_search_contacts, "get_contact": _handler_get_contact, "create_contact": _handler_create_contact, "list_calendar_entries": _handler_list_calendar_entries, "create_calendar_entry": _handler_create_calendar_entry, "list_emails": _handler_list_emails, "send_email": _handler_send_email, "list_files": _handler_list_files, "upload_file": _handler_upload_file, } # ─── Serialization Helpers ───────────────────────────────────────────────── def _contact_to_dict(c: Any) -> dict[str, Any]: return { "id": str(c.id), "name": c.name, "displayname": getattr(c, "displayname", c.name), "email": getattr(c, "email_1", None), "phone": getattr(c, "phone_1", None), "type": getattr(c, "type", "person"), "created_at": c.created_at.isoformat() if c.created_at else None, } def _entry_to_dict(e: Any) -> dict[str, Any]: return { "id": str(e.id), "calendar_id": str(e.calendar_id), "entry_type": e.entry_type, "title": e.title, "description": e.description, "start_at": e.start_at.isoformat() if e.start_at else None, "end_at": e.end_at.isoformat() if e.end_at else None, "status": e.status, } def _mail_to_dict(m: Any) -> dict[str, Any]: return { "id": str(m.id), "from": m.from_addr, "to": m.to_addr, "subject": m.subject, "body_preview": (m.body_text or "")[:200], "is_read": m.is_read, "sent_at": m.sent_at.isoformat() if m.sent_at else None, } def _file_to_dict(f: Any) -> dict[str, Any]: return { "id": str(f.id), "name": f.name, "size": f.size, "mime_type": f.mime_type, "folder_id": str(f.folder_id) if f.folder_id else None, "created_at": f.created_at.isoformat() if f.created_at else None, }