diff --git a/AGENTS.md b/AGENTS.md index dab671c..c07284c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ prisma/ # RegistroHoras, Configuracion, Bono, FinanciamientoPlan) seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts migrations/ # 10 migrations -api/ # Standalone Python FastAPI + MCP server (see below) +api/ # Standalone Python FastAPI service (see below) docs/ # Business/product notes (Spanish), not code docs ``` @@ -119,7 +119,9 @@ docs/ # Business/product notes (Spanish), not code docs ## Python API (`api/`) — Optional Second Backend -- **FastAPI app** (`api/main.py`) exposing the same domain as REST, **plus an MCP server at `/mcp`** for AI agents (n8n, Claude, ChatGPT). Tools/resources defined in `api/app/mcp/`. +- **FastAPI app** (`api/main.py`) exposing the same domain as REST, for n8n and other integrations. +- **The MCP server was removed on 2026-07-28.** It was unused, its transport had been broken for some time (it built a `StreamableHTTPServerTransport` per request with no session handling and returned 500 even with valid credentials), and its unpinned SDK took the whole API down in production when `mcp` jumped to 2.0.0. To revive it: `git show edb500f5:api/app/mcp/server.py`. +- **`api/requirements.txt` pins exact versions on purpose.** That outage is why. Bump a dependency deliberately — never by accident on a redeploy. - Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions). - Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`. - It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma). diff --git a/README.md b/README.md index b26ee88..d255697 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ y financiamiento opcional. Exporta a PDF y Excel. - **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) - **Base de datos:** PostgreSQL 16 (vía Docker) - **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs` -- **API alterna:** servicio Python FastAPI + servidor MCP en [`api/`](api/) (para n8n / agentes de IA) +- **API alterna:** servicio Python FastAPI en [`api/`](api/) (para n8n / integraciones) ## Requisitos @@ -51,7 +51,7 @@ npm run dev # http://localhost:3000 ```bash cd api pip install -r requirements.txt -uvicorn main:app --reload --port 8000 # Swagger en /docs, MCP en /mcp +uvicorn main:app --reload --port 8000 # Swagger en /docs ``` Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md). @@ -89,7 +89,8 @@ El stack de producción está en [docker-compose.coolify.yml](docker-compose.coo - No expongas el puerto 5432: los servicios se comunican por la red interna del compose. - El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres. -- El servidor MCP queda en `https:///mcp` (auth por `X-API-Key`). +- El API expone REST en `https://` con auth por `X-API-Key` o JWT. El servidor MCP se retiro el 2026-07-28 (ver `AGENTS.md`). +- Las versiones de `api/requirements.txt` estan fijas a proposito: un rango abierto dejo entrar `mcp` 2.0.0 y tumbo el API en produccion. Sube dependencias a proposito, no al redesplegar. - La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad. ## Documentación para agentes diff --git a/api/COTIZADOR_API_SKILL.md b/api/COTIZADOR_API_SKILL.md index 9399420..f92fc0d 100644 --- a/api/COTIZADOR_API_SKILL.md +++ b/api/COTIZADOR_API_SKILL.md @@ -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. --- diff --git a/api/app/mcp/__init__.py b/api/app/mcp/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api/app/mcp/server.py b/api/app/mcp/server.py deleted file mode 100644 index 4ac3730..0000000 --- a/api/app/mcp/server.py +++ /dev/null @@ -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 diff --git a/api/app/mcp/tools.py b/api/app/mcp/tools.py deleted file mode 100644 index 469061b..0000000 --- a/api/app/mcp/tools.py +++ /dev/null @@ -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", - }, -] diff --git a/api/main.py b/api/main.py index be7f1e6..e7dbec1 100644 --- a/api/main.py +++ b/api/main.py @@ -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", } diff --git a/api/requirements.txt b/api/requirements.txt index 5474b93..5297cca 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/docs/superpowers/specs/2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md b/docs/superpowers/specs/2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md index 9a79d48..4a7ec42 100644 --- a/docs/superpowers/specs/2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md +++ b/docs/superpowers/specs/2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md @@ -8,11 +8,60 @@ > el análisis destapó) y Fase 1 (script local que valida si la IA escribe documentos utilizables). > Fases 2-4 quedan fuera y tendrán su propio ciclo. > -> **Fecha:** 2026-07-28 · **Estado:** aprobado para planeación +> **Fecha:** 2026-07-28 · **Estado:** Fase 0 desplegada en producción · Fase 1 pendiente > **Base verificada:** commit `26e6d3a9` --- +## 0. Registro de ejecución — Fase 0 (2026-07-28) + +Fase 0 está en producción. Tres cosas salieron distinto de lo diseñado y este documento +quedaría mintiendo si no se dijeran. + +### 0.1 El movimiento de datos (Despliegue B) se canceló, no se pospuso + +El §4.3 describe un `UPDATE` que mueve `observaciones` → `observacionesInternas`. **No se +va a ejecutar nunca.** Al inspeccionar producción antes de migrar, la única fila con +`observaciones` no vacías resultó ser `UJ2606AG777` (estado `aprobada`), y su texto son +condiciones de pago dirigidas al cliente en segunda persona: *"llevamos una bitácora de +horas con acceso para ti; a fin de mes te enviamos el reporte… solo pagas las horas +efectivamente trabajadas."* + +Aplicar el `UPDATE` conservador habría ocultado condiciones ya acordadas de un documento +emitido. Esa fila ya estaba clasificada correctamente. La migración quedó reducida a +`ADD COLUMN IF NOT EXISTS`: cero filas modificadas. Verificado tras el despliegue — +conteos `2/3/59/5/2` idénticos al respaldo y cero filas en `observacionesInternas`. + +**Lección para el futuro: mirar el dato real antes de diseñar su migración.** El diseño +"conservador" era el equivocado para estos datos. + +### 0.2 El servidor MCP se retiró en vez de asegurarse + +El §4.1 diseñaba Fase 0-A como *"aplicar `require_auth` + lista blanca de columnas"*. Eso +se implementó y se verificó en producción (401 sin credencial). Después se descubrió que, +**con credencial válida, el endpoint devolvía 500**: el código construía un +`StreamableHTTPServerTransport` por petición sin manejo de sesión. Llevaba roto desde antes +de este trabajo — lo que estaba expuesto era la puerta abierta de un cuarto averiado. + +Dado que nadie lo usaba, se retiró por completo (endpoint, paquete `api/app/mcp/` y la +dependencia). Es una mitigación más fuerte que autenticarlo. Recuperable con +`git show edb500f5:api/app/mcp/server.py`. + +### 0.3 Una dependencia sin fijar tumbó el API 50 minutos + +`api/requirements.txt` tenía `mcp>=1.0.0`. La primera reconstrucción en dos semanas tomó +`mcp` 2.0.0, que eliminó `Server.list_tools()`. El import lanzó `AttributeError` al +arrancar y el contenedor quedó en crash-loop. + +Lo agravó que `main.py` capturara solo `ImportError` alrededor del montaje del MCP: un +`AttributeError` se escapó y tumbó también el REST, por un componente opcional. + +Once de las doce dependencias eran rangos `>=` sin techo. **Todas se fijaron a versión +exacta** — las que corrían sanas en producción. El `web` (Next.js) nunca estuvo caído: +tiene `package-lock.json` y es reproducible. + +--- + ## 1. Por qué el alcance es este El documento de negocio describe ocho subsistemas. Construirlos de una vez significa un mes de @@ -122,6 +171,10 @@ Cuatro bloques. A y B son prerrequisitos de C. ### 4.1 Fase 0-A · Cerrar el MCP +> **Superado por §0.2.** Esto se implementó tal cual y se verificó (401 sin credencial), +> pero después el servidor MCP se retiró por completo. Se conserva el diseño original +> porque documenta el agujero que existía y por qué importaba. + 1. Aplicar `require_auth` (`api/app/auth.py:45`) al endpoint `POST /mcp` en `api/main.py:107`. 2. Sustituir el `SELECT c.*` de `_obtener_cotizacion` (`api/app/mcp/server.py:250`) por una lista blanca de columnas explícita, con el mismo criterio que el REST ya aplica vía @@ -168,7 +221,10 @@ ninguno. declara `observacionesInternas` — la garantía es estructural, no disciplinaria. - Ningún exportador recibe el campo interno. -**Despliegue B — movimiento de datos.** Solo cuando A lleve días estable: +**Despliegue B — movimiento de datos. CANCELADO, ver §0.1.** El `UPDATE` de abajo no se +ejecutó ni se va a ejecutar: la única fila afectada contenía texto dirigido al cliente y ya +estaba donde debía. Se conserva por si algún día aparece una instalación con datos mal +clasificados. ```sql UPDATE "Cotizacion" @@ -425,7 +481,8 @@ exige normalizar saltos de línea. **Fase 0** — verificable de inmediato: -- `POST /mcp` sin credencial devuelve 401; con credencial, `obtener_cotizacion` no expone el campo interno. +- El endpoint `/mcp` ya no existe (§0.2). Se cumplió antes con 401 verificado; la retirada lo supera. +- `api/requirements.txt` fija versiones exactas y el API arranca sin el paquete `mcp` (§0.3). - El total del PDF y el del Excel coinciden dígito por dígito en cotizaciones reales. - Una cotización con `incluirIva: false` no muestra IVA en ningún exportador. - Las notas internas no aparecen en ningún archivo exportable.