Retirar el servidor MCP y fijar todas las dependencias del API

MCP: se retira por completo (endpoint, paquete api/app/mcp/ y dependencia).
Tres razones, en orden de peso:

- Nadie lo usa.
- Llevaba roto desde antes de este trabajo. Con credencial valida devolvia 500:
  el handler construia un StreamableHTTPServerTransport nuevo por peticion, sin
  manejo de sesion. Lo que estaba expuesto a internet era la puerta abierta de un
  cuarto averiado.
- Su SDK sin fijar tumbo el API entero en produccion al saltar a 2.0.0.

Retirarlo es una mitigacion mas fuerte que autenticarlo, que fue lo que hizo el
commit anterior. Recuperable con `git show edb500f5:api/app/mcp/server.py`.
Se limpia tambien la ruta "/mcp" que el endpoint raiz seguia anunciando, y las
menciones a MCP del docstring y la descripcion de Swagger.

Dependencias: once de las doce eran rangos `>=` sin techo, o sea que cada
reconstruccion era una tirada de dados contra PyPI. `pydantic>=2.0` habria
aceptado pydantic 3 con la misma alegria con la que `mcp>=1.0.0` acepto 2.0.0.
Ahora todas van fijadas a la version exacta que corre sana en produccion,
capturada con pip freeze del contenedor healthy.

El lado Next.js ya era reproducible via package-lock.json; por eso el web nunca
se cayo durante el incidente y el api si.

Docs actualizados: AGENTS.md, README.md, api/COTIZADOR_API_SKILL.md y el spec,
que ademas registra en su seccion 0 las tres desviaciones de Fase 0 respecto a
lo disenado (el Despliegue B cancelado, la retirada del MCP y la deriva de
dependencia).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-28 17:51:21 -06:00
co-authored by Claude Opus 5
parent edb500f517
commit 20f535d485
9 changed files with 118 additions and 862 deletions
+10 -23
View File
@@ -418,32 +418,19 @@ granTotal = totalMensual × meses
---
## MCP Integration
## MCP Integration — removed (2026-07-28)
MCP server at `/mcp` for OpenClaw, Claude, ChatGPT.
The `/mcp` endpoint and the `api/app/mcp/` package no longer exist. Agents should use
the REST endpoints documented above, authenticating with `X-API-Key` or a Bearer JWT.
### Tools
| Tool | Description |
|------|-------------|
| `buscar_servicios` | Search catalog by phase, payment type, category, text |
| `crear_cotizacion` | Create complete quotation in one call |
| `obtener_cotizacion` | Get quotation details |
| `listar_cotizaciones` | List with filters |
| `cambiar_estado_cotizacion` | Change status |
| `actualizar_precio_servicio` | Adjust service price |
| `duplicar_cotizacion` | Clone as draft |
| `calcular_financiamiento` | Calculate payments |
| `generar_pdf_cotizacion` | Generate PDF |
| `obtener_configuracion` | Company config |
| `listar_bonos` | Available bonuses |
| `listar_planes_bucefalo` | CRM plans |
It was removed for three reasons: nobody was using it; its transport had been broken
for some time (it constructed a `StreamableHTTPServerTransport` per request with no
session handling, returning 500 even with valid credentials); and its unpinned SDK
took the entire API down in production when `mcp` released 2.0.0 and dropped
`Server.list_tools()`.
### Resources
| Resource | Description |
|----------|-------------|
| `cotizador://servicios` | Full catalog |
| `cotizador://categorias` | Categories |
| `cotizador://configuracion` | Company config |
To bring it back: `git show edb500f5:api/app/mcp/server.py`. Pin the SDK to the 1.x
series and fix the transport before mounting it again.
---
View File
-464
View File
@@ -1,464 +0,0 @@
"""MCP Server for the Cotizador E3 API.
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
to interact with the quotation system.
"""
from __future__ import annotations
import json
from datetime import datetime
from mcp.server import Server
from mcp.types import Resource, TextContent, Tool
from app.database import get_pool
from app.mcp.tools import RESOURCES, TOOLS
from app.services.calculators import (
BONOS,
PLANES_BUCEFALO,
calcular_financiamiento,
bucefalo_precio,
)
server = Server("cotizador-e3")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name=t["name"],
description=t["description"],
inputSchema=t["inputSchema"],
)
for t in TOOLS
]
@server.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri=r["uri"],
name=r["name"],
description=r["description"],
mimeType=r["mimeType"],
)
for r in RESOURCES
]
@server.read_resource()
async def read_resource(uri: str) -> str:
pool = await get_pool()
async with pool.acquire() as conn:
if uri == "cotizador://servicios":
rows = await conn.fetch(
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, s.activo, s.orden,
c.nombre as categoria_nombre
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE s.activo = true
ORDER BY s.fase ASC, s.orden ASC"""
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://categorias":
rows = await conn.fetch(
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
)
return json.dumps([dict(r) for r in rows], default=str)
elif uri == "cotizador://configuracion":
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return json.dumps({r["clave"]: r["valor"] for r in rows})
return json.dumps({"error": f"Unknown resource: {uri}"})
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
pool = await get_pool()
async with pool.acquire() as conn:
result = await _handle_tool(conn, name, arguments)
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
if name == "buscar_servicios":
return await _buscar_servicios(conn, arguments)
elif name == "crear_cotizacion":
return await _crear_cotizacion(conn, arguments)
elif name == "obtener_cotizacion":
return await _obtener_cotizacion(conn, arguments)
elif name == "listar_cotizaciones":
return await _listar_cotizaciones(conn, arguments)
elif name == "cambiar_estado_cotizacion":
return await _cambiar_estado(conn, arguments)
elif name == "actualizar_precio_servicio":
return await _actualizar_precio(conn, arguments)
elif name == "duplicar_cotizacion":
return await _duplicar_cotizacion(conn, arguments)
elif name == "calcular_financiamiento":
return await _calcular_financiamiento(arguments)
elif name == "generar_pdf_cotizacion":
return await _generar_pdf(conn, arguments)
elif name == "obtener_configuracion":
return await _obtener_configuracion(conn)
elif name == "listar_bonos":
return {"bonos": BONOS}
elif name == "listar_planes_bucefalo":
return {"planes": PLANES_BUCEFALO}
else:
return {"error": f"Unknown tool: {name}"}
async def _buscar_servicios(conn, args: dict) -> dict:
conditions = ['s.activo = true']
params = []
idx = 1
if args.get("fase") is not None:
conditions.append(f's.fase = ${idx}')
params.append(args["fase"])
idx += 1
if args.get("tipo_pago"):
conditions.append(f's."tipoPago" = ${idx}')
params.append(args["tipo_pago"])
idx += 1
if args.get("categoria"):
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
params.append(args["categoria"])
idx += 1
if args.get("busqueda"):
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
params.append(f"%{args['busqueda']}%")
idx += 1
where = " AND ".join(conditions)
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, c.nombre as categoria
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
WHERE {where}
ORDER BY s.fase ASC, s.orden ASC"""
rows = await conn.fetch(query, *params)
servicios = []
for r in rows:
d = dict(r)
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
try:
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
except (json.JSONDecodeError, TypeError):
pass
servicios.append(d)
return {"servicios": servicios, "total": len(servicios)}
async def _crear_cotizacion(conn, args: dict) -> dict:
cliente_data = args["cliente"]
servicios_data = args["servicios"]
plan_bucefalo = args.get("plan_bucefalo")
moneda = args.get("moneda", "MXN")
proyecto = args.get("proyecto", "MKT Digital")
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
es_doble = bool(args.get("es_doble", False))
opciones_metadata = args.get("opciones_metadata") if es_doble else None
cliente = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
)
if not cliente:
cliente = await conn.fetchrow(
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
cliente_data["nombre"],
cliente_data.get("empresa", ""),
cliente_data.get("email", ""),
cliente_data.get("telefono", ""),
)
cliente_id = cliente["id"]
now = datetime.now()
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
from app.services.calculators import calcular_vigencia
vigencia = calcular_vigencia(now)
async with conn.transaction():
cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW())
RETURNING id, numero""",
numero, now, vigencia, moneda, proyecto, esquema,
es_doble,
json.dumps(opciones_metadata) if opciones_metadata else None,
cliente_id, "agent",
)
cot_id = cot["id"]
for srv in servicios_data:
catalogo_id = srv["servicio_id"]
cat_row = await conn.fetchrow(
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
catalogo_id,
)
if not cat_row:
continue
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
entregables = cat_row["entregablesDefault"]
if isinstance(entregables, str):
try:
entregables = json.loads(entregables)
except (json.JSONDecodeError, TypeError):
entregables = []
opcion = (srv.get("opcion") or "ambas") if es_doble else None
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""",
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion,
)
if plan_bucefalo:
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
precio_bp = bucefalo_precio(nivel)
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
cot_id, nivel, precio_bp,
)
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
async def _obtener_cotizacion(conn, args: dict) -> dict:
# Lista blanca de columnas: esta herramienta devuelve la fila completa al agente
# (result = dict(cot) mas abajo), asi que un SELECT c.* publicaria cualquier
# columna nueva sin que nadie lo decida. Espeja CotizacionResponse del REST
# (app/models/cotizacion.py) y excluye deliberadamente "observacionesInternas",
# que es texto que no debe salir de la app.
cot = await conn.fetchrow(
"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.moneda, c."tipoCambio",
c.proyecto, c."esquemaPago", c.estado, c."incluirBonos",
c."incluirFinanciamiento", c."incluirIva", c."esDoble",
c."opcionesMetadata", c.observaciones, c."clienteId", c."asesorId",
c."createdAt", c."updatedAt",
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
cl.email as cliente_email, cl.telefono as cliente_telefono
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
args["cotizacion_id"],
)
if not cot:
return {"error": "Cotización no encontrada"}
servicios = await conn.fetch(
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
WHERE sc."cotizacionId" = $1""",
args["cotizacion_id"],
)
plan = await conn.fetchrow(
"""SELECT id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt"
FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1""",
args["cotizacion_id"],
)
result = dict(cot)
result["cliente"] = {
"nombre": cot["cliente_nombre"],
"empresa": cot["cliente_empresa"],
"email": cot["cliente_email"],
"telefono": cot["cliente_telefono"],
}
result["servicios"] = [dict(s) for s in servicios]
result["planBucefalo"] = dict(plan) if plan else None
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
result.pop(k, None)
return result
async def _listar_cotizaciones(conn, args: dict) -> dict:
conditions = []
params = []
idx = 1
if args.get("estado"):
conditions.append(f'c.estado = ${idx}')
params.append(args["estado"])
idx += 1
if args.get("cliente_nombre"):
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
params.append(f"%{args['cliente_nombre']}%")
idx += 1
if args.get("busqueda"):
conditions.append(
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
)
params.append(f"%{args['busqueda']}%")
idx += 1
where = "WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
c.moneda, c."esquemaPago",
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
{where}
ORDER BY c."createdAt" DESC
LIMIT 50"""
rows = await conn.fetch(query, *params)
cotizaciones = []
for r in rows:
d = dict(r)
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
cotizaciones.append(d)
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
async def _cambiar_estado(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
estado = args["estado"]
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
return {"error": "Cotización no encontrada"}
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
async def _actualizar_precio(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
servicio_id = args["servicio_id"]
nuevo_precio = args["nuevo_precio"]
srv = await conn.fetchrow(
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
servicio_id, cot_id,
)
if not srv:
return {"error": "Servicio no encontrado en esta cotización"}
await conn.execute(
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
nuevo_precio, servicio_id,
)
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
async def _duplicar_cotizacion(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not original:
return {"error": "Cotización no encontrada"}
now = datetime.now()
new_numero = f"{original['numero']}-COPY"
async with conn.transaction():
new_cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING id, numero""",
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"],
original["observaciones"], original["clienteId"], original["asesorId"],
)
new_id = new_cot["id"]
servicios = await conn.fetch(
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
)
for s in servicios:
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""",
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"],
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
)
if plan:
await conn.fetchrow(
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
)
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
async def _calcular_financiamiento(args: dict) -> dict:
monto = args["monto"]
meses = args["meses"]
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
from app.services.calculators import FINANCIAMIENTO_PLANES
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
if not plan:
return {"error": f"Plan de {meses} meses no disponible"}
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
result["meses"] = meses
result["tasa"] = plan["tasa"]
result["comision"] = plan["comision"]
return result
async def _generar_pdf(conn, args: dict) -> dict:
cot_id = args["cotizacion_id"]
cot = await conn.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
WHERE c.id = $1""",
cot_id,
)
if not cot:
return {"error": "Cotización no encontrada"}
return {
"status": "pdf_generated",
"cotizacion_id": cot_id,
"numero": cot["numero"],
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
"message": "PDF generation will be implemented with reportlab",
}
async def _obtener_configuracion(conn) -> dict:
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
return {"config": {r["clave"]: r["valor"] for r in rows}}
from app.services.calculators import FINANCIAMIENTO_PLANES
-305
View File
@@ -1,305 +0,0 @@
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
"""
TOOLS = [
{
"name": "buscar_servicios",
"description": (
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
),
"inputSchema": {
"type": "object",
"properties": {
"fase": {
"type": "integer",
"enum": [0, 1, 2, 3],
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
},
"tipo_pago": {
"type": "string",
"enum": ["unico", "mensual"],
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
},
"categoria": {
"type": "string",
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en nombre y descripción del servicio",
},
},
},
},
{
"name": "crear_cotizacion",
"description": (
"Crea una cotización completa de servicios de marketing digital para un cliente. "
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
"y configuración de moneda y esquema de pago. "
"La cotización se crea en estado 'borrador'. "
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. "
"Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio "
"se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, "
"descripción y exclusiones de cada opción."
),
"inputSchema": {
"type": "object",
"properties": {
"cliente": {
"type": "object",
"properties": {
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
"email": {"type": "string", "description": "Email de contacto"},
"telefono": {"type": "string", "description": "Teléfono de contacto"},
},
"required": ["nombre"],
},
"servicios": {
"type": "array",
"items": {
"type": "object",
"properties": {
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
"opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."},
},
"required": ["servicio_id"],
},
"description": "Lista de servicios a incluir en la cotización",
},
"es_doble": {
"type": "boolean",
"description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).",
},
"opciones_metadata": {
"type": "object",
"description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.",
"properties": {
"1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
"2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
},
},
"plan_bucefalo": {
"type": "string",
"enum": ["basico", "estandar", "premium", "empresarial"],
"description": "Nivel del plan CRM Bucefalo (opcional)",
},
"proyecto": {
"type": "string",
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
},
"moneda": {
"type": "string",
"enum": ["MXN", "USD"],
"description": "Moneda de la cotización (default: MXN)",
},
"esquema_pago": {
"type": "string",
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
"description": "Esquema de pago (default: Pago Unico/Mensual)",
},
},
"required": ["cliente", "servicios"],
},
},
{
"name": "obtener_cotizacion",
"description": (
"Obtiene los detalles completos de una cotización existente incluyendo: "
"datos del cliente, servicios seleccionados con precios, estado actual, "
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "listar_cotizaciones",
"description": (
"Lista cotizaciones con filtros opcionales. "
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
),
"inputSchema": {
"type": "object",
"properties": {
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Filtrar por estado",
},
"cliente_nombre": {
"type": "string",
"description": "Buscar por nombre de cliente",
},
"busqueda": {
"type": "string",
"description": "Texto libre para buscar en número, proyecto o cliente",
},
},
},
},
{
"name": "cambiar_estado_cotizacion",
"description": (
"Cambia el estado de una cotización. "
"Flujo normal: borrador → enviada → aprobada o rechazada. "
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"estado": {
"type": "string",
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
"description": "Nuevo estado de la cotización",
},
},
"required": ["cotizacion_id", "estado"],
},
},
{
"name": "actualizar_precio_servicio",
"description": (
"Actualiza el precio de un servicio específico dentro de una cotización. "
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
"Útil para negociar precios individuales sin recrear toda la cotización."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
},
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
},
},
{
"name": "duplicar_cotizacion",
"description": (
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
"Crea una copia exacta con nuevo ID y número. "
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "calcular_financiamiento",
"description": (
"Calcula las mensualidades para financiar una cotización o monto específico. "
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
"Todos incluyen 2.5% de comisión + 16% IVA. "
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
),
"inputSchema": {
"type": "object",
"properties": {
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
},
"required": ["monto", "meses"],
},
},
{
"name": "generar_pdf_cotizacion",
"description": (
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
"y datos bancarios para transferencia. Devuelve el archivo PDF."
),
"inputSchema": {
"type": "object",
"properties": {
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
},
"required": ["cotizacion_id"],
},
},
{
"name": "obtener_configuracion",
"description": (
"Obtiene la configuración de la empresa Consultoría E3: "
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
"colores de marca, logo, y términos y condiciones. "
"Útil para generar documentos o verificar información fiscal."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_bonos",
"description": (
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
"2) Workshop Buyer Persona, "
"3) Workshop Propuesta de Valor, "
"4) Membresía Premium (1 año), "
"5) Mes Gratis CRM Bucefalo, "
"6) Script de Ventas (100+ complementos)."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "listar_planes_bucefalo",
"description": (
"Lista los niveles del CRM Bucefalo con precios mensuales: "
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
"Bucefalo es un CRM para gestión de ventas y clientes."
),
"inputSchema": {"type": "object", "properties": {}},
},
]
RESOURCES = [
{
"uri": "cotizador://servicios",
"name": "Catálogo de Servicios",
"description": (
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://categorias",
"name": "Categorías de Servicios",
"description": (
"Categorías disponibles para clasificar servicios: "
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
),
"mimeType": "application/json",
},
{
"uri": "cotizador://configuracion",
"name": "Configuración de la Empresa",
"description": (
"Datos fiscales, bancarios y de marca de Consultoría E3. "
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
"colores de marca, logo, y términos y condiciones."
),
"mimeType": "application/json",
},
]
+10 -47
View File
@@ -1,7 +1,7 @@
"""Cotizador E3 — FastAPI Application
REST API + MCP Server for digital marketing quotation management.
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT).
REST API for digital marketing quotation management.
Optimized for n8n workflows and other integrations.
"""
from __future__ import annotations
@@ -9,11 +9,10 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import Depends, FastAPI, Request
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.auth import require_auth
from app.config import settings
from app.database import close_db, init_db
@@ -29,8 +28,8 @@ app = FastAPI(
title="Cotizador E3 API",
description=(
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
"Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) "
"via MCP (Model Context Protocol)."
"Optimizada para integración con n8n y otros consumidores. "
"Autenticación por X-API-Key o Bearer JWT."
),
version="1.0.0",
lifespan=lifespan,
@@ -99,46 +98,11 @@ app.include_router(financiamiento.router)
app.include_router(export_.router)
app.include_router(import_.router)
# Mount MCP server
try:
from app.mcp.server import server as mcp_server
from mcp.server.streamable_http import StreamableHTTPServerTransport
@app.post("/mcp")
async def mcp_endpoint(request: Request, _auth: dict = Depends(require_auth)):
"""MCP (Model Context Protocol) endpoint for AI agents.
Requiere autenticacion (X-API-Key o Bearer JWT), igual que el resto del API.
Sin esto el endpoint quedaba abierto a internet: el servicio recibe dominio
publico en produccion (docker-compose.coolify.yml, SERVICE_FQDN_API_8000) y
las herramientas MCP leen y escriben cotizaciones directamente.
"""
transport = StreamableHTTPServerTransport(mcp_server)
return await transport.handle_request(request)
@app.get("/mcp")
async def mcp_info():
"""MCP server info. Use POST for actual MCP communication."""
return {
"name": "cotizador-e3",
"version": "1.0.0",
"protocol": "mcp",
"description": "MCP server for Cotizador E3 quotation system",
}
except Exception as exc: # noqa: BLE001
# Antes esto capturaba solo ImportError. Cuando el SDK de MCP salto a 2.0.0 y
# elimino Server.list_tools(), el import lanzo AttributeError, se escapo del
# except y tumbo TODA la aplicacion: el REST tambien dejo de responder.
# El servidor MCP es opcional; el REST no. Si el MCP no monta, se registra y
# la API sigue de pie.
import logging
logging.getLogger("uvicorn.error").error(
"No se pudo montar el servidor MCP (%s: %s). El REST sigue disponible.",
type(exc).__name__,
exc,
)
# El servidor MCP se retiro (2026-07-28). No se estaba usando, su transporte
# llevaba tiempo roto (instanciaba un StreamableHTTPServerTransport por peticion,
# sin manejo de sesion, y devolvia 500 con credencial valida) y su SDK sin fijar
# tumbo el API entero en produccion al saltar a 2.0.0. Si algun dia se retoma,
# el codigo esta en el historial: `git show edb500f5:api/app/mcp/server.py`.
@app.get("/", include_in_schema=False)
@@ -149,7 +113,6 @@ async def root():
"docs": "/docs",
"openapi": "/openapi.json",
"health": "/health",
"mcp": "/mcp",
}
+30 -15
View File
@@ -1,15 +1,30 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
asyncpg>=0.30.0
pydantic>=2.0
pydantic-settings>=2.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.18
reportlab>=4.0
openpyxl>=3.1.0
# Fijado a la serie 1.x: mcp 2.0.0 elimino Server.list_tools() y rompe
# app/mcp/server.py al importarse. El rango abierto `>=1.0.0` dejo que una
# reconstruccion tomara 2.0.0 y tumbara el API entero en produccion.
mcp>=1.28.1,<2.0.0
httpx>=0.27.0
# Versiones FIJAS a proposito.
#
# Por que: el 2026-07-28 una reconstruccion del API tomo mcp 2.0.0 (el rango era
# `mcp>=1.0.0`), que elimino Server.list_tools(). El import lanzo AttributeError
# al arrancar y dejo el contenedor en crash-loop ~50 minutos. Once de las doce
# dependencias eran rangos `>=` sin techo, o sea que cada build era una tirada de
# dados contra PyPI: `pydantic>=2.0` habria aceptado pydantic 3 igual de alegre.
#
# Estas versiones son exactamente las que corrian sanas en produccion cuando se
# fijaron (capturadas con `pip freeze` del contenedor healthy).
#
# Para subir una dependencia: cambiala aqui a proposito, reconstruye y prueba.
# Nunca por accidente al redesplegar.
#
# Limitacion conocida: esto fija las dependencias DIRECTAS. Las transitivas
# (starlette, cryptography, anyio...) las sigue resolviendo pip. Es un riesgo
# mucho menor, pero si algun dia muerde, el siguiente paso es un lock completo
# con pip-tools o uv.
fastapi==0.140.13
uvicorn[standard]==0.51.0
asyncpg==0.31.0
pydantic==2.13.4
pydantic-settings==2.14.2
python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.32
reportlab==5.0.0
openpyxl==3.1.5
httpx==0.28.1