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
+4 -2
View File
@@ -103,7 +103,7 @@ prisma/
# RegistroHoras, Configuracion, Bono, FinanciamientoPlan) # RegistroHoras, Configuracion, Bono, FinanciamientoPlan)
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
migrations/ # 10 migrations 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 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 ## 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). - 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`. - 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). - It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma).
+4 -3
View File
@@ -10,7 +10,7 @@ y financiamiento opcional. Exporta a PDF y Excel.
- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) - **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`)
- **Base de datos:** PostgreSQL 16 (vía Docker) - **Base de datos:** PostgreSQL 16 (vía Docker)
- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs` - **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 ## Requisitos
@@ -51,7 +51,7 @@ npm run dev # http://localhost:3000
```bash ```bash
cd api cd api
pip install -r requirements.txt 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). 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. - 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 volumen `postgres_data` persiste la base de datos entre deploys. No lo borres.
- El servidor MCP queda en `https://<dominio-api>/mcp` (auth por `X-API-Key`). - El API expone REST en `https://<dominio-api>` 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. - 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 ## Documentación para agentes
+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 It was removed for three reasons: nobody was using it; its transport had been broken
| Tool | Description | for some time (it constructed a `StreamableHTTPServerTransport` per request with no
|------|-------------| session handling, returning 500 even with valid credentials); and its unpinned SDK
| `buscar_servicios` | Search catalog by phase, payment type, category, text | took the entire API down in production when `mcp` released 2.0.0 and dropped
| `crear_cotizacion` | Create complete quotation in one call | `Server.list_tools()`.
| `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 |
### Resources To bring it back: `git show edb500f5:api/app/mcp/server.py`. Pin the SDK to the 1.x
| Resource | Description | series and fix the transport before mounting it again.
|----------|-------------|
| `cotizador://servicios` | Full catalog |
| `cotizador://categorias` | Categories |
| `cotizador://configuracion` | Company config |
--- ---
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 """Cotizador E3 — FastAPI Application
REST API + MCP Server for digital marketing quotation management. REST API for digital marketing quotation management.
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT). Optimized for n8n workflows and other integrations.
""" """
from __future__ import annotations from __future__ import annotations
@@ -9,11 +9,10 @@ from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import Depends, FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.auth import require_auth
from app.config import settings from app.config import settings
from app.database import close_db, init_db from app.database import close_db, init_db
@@ -29,8 +28,8 @@ app = FastAPI(
title="Cotizador E3 API", title="Cotizador E3 API",
description=( description=(
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. " "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) " "Optimizada para integración con n8n y otros consumidores. "
"via MCP (Model Context Protocol)." "Autenticación por X-API-Key o Bearer JWT."
), ),
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
@@ -99,46 +98,11 @@ app.include_router(financiamiento.router)
app.include_router(export_.router) app.include_router(export_.router)
app.include_router(import_.router) app.include_router(import_.router)
# Mount MCP server # El servidor MCP se retiro (2026-07-28). No se estaba usando, su transporte
try: # llevaba tiempo roto (instanciaba un StreamableHTTPServerTransport por peticion,
from app.mcp.server import server as mcp_server # 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,
from mcp.server.streamable_http import StreamableHTTPServerTransport # el codigo esta en el historial: `git show edb500f5:api/app/mcp/server.py`.
@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,
)
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
@@ -149,7 +113,6 @@ async def root():
"docs": "/docs", "docs": "/docs",
"openapi": "/openapi.json", "openapi": "/openapi.json",
"health": "/health", "health": "/health",
"mcp": "/mcp",
} }
+30 -15
View File
@@ -1,15 +1,30 @@
fastapi>=0.115.0 # Versiones FIJAS a proposito.
uvicorn[standard]>=0.34.0 #
asyncpg>=0.30.0 # Por que: el 2026-07-28 una reconstruccion del API tomo mcp 2.0.0 (el rango era
pydantic>=2.0 # `mcp>=1.0.0`), que elimino Server.list_tools(). El import lanzo AttributeError
pydantic-settings>=2.0 # al arrancar y dejo el contenedor en crash-loop ~50 minutos. Once de las doce
python-jose[cryptography]>=3.3.0 # dependencias eran rangos `>=` sin techo, o sea que cada build era una tirada de
passlib[bcrypt]>=1.7.4 # dados contra PyPI: `pydantic>=2.0` habria aceptado pydantic 3 igual de alegre.
python-multipart>=0.0.18 #
reportlab>=4.0 # Estas versiones son exactamente las que corrian sanas en produccion cuando se
openpyxl>=3.1.0 # fijaron (capturadas con `pip freeze` del contenedor healthy).
# 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 # Para subir una dependencia: cambiala aqui a proposito, reconstruye y prueba.
# reconstruccion tomara 2.0.0 y tumbara el API entero en produccion. # Nunca por accidente al redesplegar.
mcp>=1.28.1,<2.0.0 #
httpx>=0.27.0 # 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
@@ -8,11 +8,60 @@
> el análisis destapó) y Fase 1 (script local que valida si la IA escribe documentos utilizables). > 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. > 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` > **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 ## 1. Por qué el alcance es este
El documento de negocio describe ocho subsistemas. Construirlos de una vez significa un mes de 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 ### 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`. 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 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 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. declara `observacionesInternas` — la garantía es estructural, no disciplinaria.
- Ningún exportador recibe el campo interno. - 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 ```sql
UPDATE "Cotizacion" UPDATE "Cotizacion"
@@ -425,7 +481,8 @@ exige normalizar saltos de línea.
**Fase 0** — verificable de inmediato: **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. - 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. - Una cotización con `incluirIva: false` no muestra IVA en ningún exportador.
- Las notas internas no aparecen en ningún archivo exportable. - Las notas internas no aparecen en ningún archivo exportable.