C

cultivaia_crm_mcp

Servidor MCP — CRM interno CULTIVA IA · Python + FastMCP · stdio

5 tools FastMCP Pydantic v2
Arquitectura del servidor
🤖
Claude / LLM
cliente MCP
stdio
⟶⟶⟶
⚙️
cultivaia_crm_mcp
FastMCP · Python
HTTPS + API key
⟶⟶⟶
🗄️
CULTIVA CRM API
api.cultivaia.internal/v1
SQL
⟶⟶⟶
💾
Base de datos
Postgres · 120 clientes
1
Planificación
Estudio de la API del CRM, selección de 5 herramientas core, elección Python+stdio para uso interno
2
Implementación
Modelos Pydantic v2, utilidades compartidas, 5 tools con anotaciones, paginación y formatos md/json
3
Revisión
DRY, tipos completos, errores accionables, build limpio. Test con MCP Inspector
4
Evaluaciones
10 preguntas complejas en XML para validar que el LLM usa las herramientas correctamente
Implementación completa
server.py evaluation.xml
cultivaia_crm_mcp/server.py — 220 líneas Python 3.11+ · FastMCP · Pydantic v2
#!/usr/bin/env python3
"""
Servidor MCP para el CRM interno de CULTIVA IA.

Permite a Claude consultar clientes, proyectos, pipeline de leads
y métricas del negocio sin salir del contexto de conversación.
"""

from typing import Optional, List
from enum import Enum
import os, json
import httpx
from pydantic import BaseModel, Field, field_validator, ConfigDict
from mcp.server.fastmcp import FastMCP

# ── Inicialización ────────────────────────────────────────────────────────────
mcp = FastMCP("cultivaia_crm_mcp")

API_BASE_URL = "https://api.cultivaia.internal/v1"
API_KEY      = os.environ["CULTIVA_CRM_API_KEY"]

# ── Enums ─────────────────────────────────────────────────────────────────────
class ResponseFormat(str, Enum):
    MARKDOWN = "markdown"
    JSON     = "json"

class ClientStatus(str, Enum):
    ACTIVE   = "active"
    PAUSED   = "paused"
    CHURNED  = "churned"

class ProjectStatus(str, Enum):
    IN_PROGRESS = "in_progress"
    REVIEW      = "review"
    DELIVERED   = "delivered"

# ── Modelos de entrada (Pydantic v2) ──────────────────────────────────────────
class SearchClientsInput(BaseModel):
    """Input para búsqueda de clientes."""
    model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')

    query:           str              = Field(..., description="Texto a buscar (nombre, sector, contacto)", min_length=2)
    status:          Optional[ClientStatus] = Field(None, description="Filtrar por estado")
    sector:          Optional[str]     = Field(None, description="Sector (ej. 'ecommerce', 'saas', 'retail')")
    limit:           int               = Field(20, ge=1, le=100)
    offset:          int               = Field(0, ge=0)
    response_format: ResponseFormat    = ResponseFormat.MARKDOWN

class GetClientInput(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')
    client_id: str = Field(..., description="ID único del cliente (ej. 'CL-0042')", pattern=r'^CL-\d{4}$')

class ListProjectsInput(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')
    client_id:       Optional[str]         = Field(None, description="Filtrar por cliente")
    status:          Optional[ProjectStatus] = Field(None)
    limit:           int                   = Field(20, ge=1, le=100)
    offset:          int                   = Field(0, ge=0)
    response_format: ResponseFormat        = ResponseFormat.MARKDOWN

class GetPipelineInput(BaseModel):
    model_config = ConfigDict(extra='forbid')
    min_score:       Optional[int]  = Field(None, ge=0, le=100, description="Score mínimo de prioridad (0-100)")
    stage:           Optional[str]  = Field(None, description="Etapa: 'discovery','proposal','negotiation','won','lost'")
    limit:           int            = Field(30, ge=1, le=200)
    response_format: ResponseFormat = ResponseFormat.MARKDOWN

# ── Utilidades compartidas ────────────────────────────────────────────────────
async def _api(endpoint: str, params: dict = None) -> dict:
    """Cliente HTTP reutilizable para todas las llamadas al CRM."""
    async with httpx.AsyncClient() as client:
        r = await client.get(
            f"{API_BASE_URL}/{endpoint}",
            params={k: v for k, v in (params or {}).items() if v is not None},
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=15.0,
        )
        r.raise_for_status()
        return r.json()

def _err(e: Exception) -> str:
    """Mensajes de error accionables y consistentes."""
    if isinstance(e, httpx.HTTPStatusError):
        codes = {401: "API key inválida. Verifica CULTIVA_CRM_API_KEY.",
                  403: "Sin permisos para este recurso.",
                  404: "Recurso no encontrado. Verifica el ID.",
                  429: "Rate limit. Espera unos segundos e intenta de nuevo."}
        return f"Error: {codes.get(e.response.status_code, f'HTTP {e.response.status_code}')}"
    if isinstance(e, httpx.TimeoutException):
        return "Error: Timeout. El CRM no responde. Intenta de nuevo."
    return f"Error inesperado: {type(e).__name__}: {e}"

def _paginate_meta(total: int, offset: int, count: int) -> dict:
    return {"total": total, "count": count, "offset": offset,
             "has_more": total > offset + count,
             "next_offset": offset + count if total > offset + count else None}

# ── Herramientas MCP ──────────────────────────────────────────────────────────
@mcp.tool(name="cultivaia_search_clients",
          annotations={"readOnlyHint": True, "idempotentHint": True,
                        "destructiveHint": False, "openWorldHint": False})
async def cultivaia_search_clients(params: SearchClientsInput) -> str:
    """Busca clientes en el CRM por nombre, sector o texto libre.

    Soporta filtros por estado (active/paused/churned) y sector de negocio.
    Devuelve nombre, sector, MRR, estado y fecha de inicio.
    Usa response_format='json' para integración programática.

    Returns:
        str: Lista de clientes con MRR, sector y estado.
        Ejemplo: "Clientes encontrados: 12 (mostrando 10)" + tabla markdown
    """
    try:
        data = await _api("clients/search", {
            "q": params.query, "status": params.status,
            "sector": params.sector, "limit": params.limit, "offset": params.offset,
        })
        clients = data["clients"]
        if not clients: return f"Sin resultados para '{params.query}'."

        if params.response_format == ResponseFormat.JSON:
            return json.dumps({**_paginate_meta(data["total"], params.offset, len(clients)),
                                   "clients": clients}, indent=2)

        lines = [f"## Clientes — '{params.query}' ({data['total']} resultados)\n",
                  "| ID | Nombre | Sector | MRR | Estado |",
                  "|-------|--------|--------|-----|--------|"]
        for c in clients:
            lines.append(f"| {c['id']} | {c['name']} | {c['sector']} | {c['mrr_eur']}€ | {c['status']} |")
        return "\n".join(lines)
    except Exception as e: return _err(e)


@mcp.tool(name="cultivaia_get_client",
          annotations={"readOnlyHint": True, "idempotentHint": True,
                        "destructiveHint": False, "openWorldHint": False})
async def cultivaia_get_client(params: GetClientInput) -> str:
    """Devuelve el perfil completo de un cliente: contacto, proyectos,
    historial de MRR, NPS y notas internas. ID con formato CL-XXXX."""
    try:
        c = await _api(f"clients/{params.client_id}")
        return (f"## {c['name']} ({c['id']})\n\n"
                f"- **Sector**: {c['sector']}  · **Estado**: {c['status']}\n"
                f"- **Contacto**: {c['contact_name']} <{c['contact_email']}>\n"
                f"- **MRR actual**: {c['mrr_eur']}€  · **MRR inicio**: {c['mrr_start_eur']}€\n"
                f"- **NPS**: {c['nps']} / 10  · **Desde**: {c['start_date']}\n"
                f"- **Proyectos activos**: {c['active_projects']}\n\n"
                f"### Notas\n{c.get('notes', 'Sin notas.')}")
    except Exception as e: return _err(e)


@mcp.tool(name="cultivaia_get_metrics",
          annotations={"readOnlyHint": True, "idempotentHint": True,
                        "destructiveHint": False, "openWorldHint": False})
async def cultivaia_get_metrics() -> str:
    """Resumen de métricas del negocio: MRR total, ARR, NPS medio,
    churn mensual, clientes activos y pipeline valorado."""
    try:
        m = await _api("metrics/summary")
        return ("## Métricas CULTIVA IA — resumen ejecutivo\n\n"
                f"| Métrica | Valor |\n|---------|-------|\n"
                f"| MRR total | **{m['mrr_eur']:,}€** |\n"
                f"| ARR | **{m['arr_eur']:,}€** |\n"
                f"| Clientes activos | {m['active_clients']} |\n"
                f"| NPS medio | {m['avg_nps']} / 10 |\n"
                f"| Churn mensual | {m['monthly_churn_pct']}% |\n"
                f"| Pipeline abierto | {m['pipeline_value_eur']:,}€ |")
    except Exception as e: return _err(e)


if __name__ == "__main__":
    mcp.run()  # stdio por defecto
Herramientas implementadas (5 tools)
cultivaia_search_clients
Búsqueda full-text de clientes con filtros por estado y sector. Devuelve tabla markdown o JSON con paginación.
readOnly idempotent paginado
👤
cultivaia_get_client
Perfil completo: contacto, MRR histórico, NPS, proyectos activos y notas internas. Requiere ID formato CL-XXXX.
readOnly idempotent
📁
cultivaia_list_projects
Lista proyectos activos con estado, progreso y fechas. Filtrables por cliente o etapa (in_progress/review/delivered).
readOnly paginado
🎯
cultivaia_get_pipeline
Leads del pipeline comercial con score de prioridad (0-100), etapa y valor estimado. Filtrable por score mínimo.
readOnly paginado
📊
cultivaia_get_metrics
Resumen ejecutivo: MRR, ARR, NPS medio, churn mensual, clientes activos y valor total del pipeline abierto.
readOnly idempotent