Skip to main content
Glama
jmurillov1

python-backend-mcp

by jmurillov1

Proyecto Curso — Control de Gastos con IA Generativa

Backend de control de gastos personales, construido durante el curso. Arquitectura: monolito modular + capas + Repository.

Reflexión (Paso 5)

  1. ¿Qué cambiaría si agrego un repository que guarde en JSON? En services/gastos.py, nada — ninguna línea de _validar_gasto ni de registrar_gasto/listar_gastos cambia, porque el service depende de la abstracción "objeto con guardar, listar, total_por_categoria" (DIP), no de la implementación concreta en memoria. Solo se crearía un nuevo módulo (p. ej. app/repositories/gastos_json.py) con esos mismos tres métodos, y se pasaría como repo=... al llamar al service (o se cambiaría el valor por defecto).

  2. "Voy a poner la validación del monto directo en el router": rompe SRP y la arquitectura en capas — el router debe recibir la solicitud, no decidir reglas de negocio. Si la validación vive en el router, queda duplicada o inconsistente en cualquier otro lugar que también llame a registrar_gasto (otro endpoint, un tool de MCP en la Sesión 8, un test), y ya no se puede probar esa regla sin levantar el framework web. Al dejarla en _validar_gasto dentro de services/, se prueba con un simple pytest, sin mocks ni servidor, tal como hicimos hoy.

Cierre

"Hoy pude probar mi lógica de negocio sin levantar infraestructura real (sin base de datos, sin mocks de librerías), gracias a la inversión de dependencias (DIP): el service recibe el repository como parámetro, así que en los tests le inyecté un RepositorioFalso con el mismo contrato."


Sesión 7 — De la arquitectura al código

Repository en memoria reemplazado por SQLAlchemy + Alembic (SQLite), autenticación OAuth2 + JWT, hashing de contraseñas con passlib/bcrypt, schemas Pydantic y routers REST protegidos.

Nota sobre passlib + bcrypt: bcrypt>=4.1 rompe con passlib==1.7.4 (lee un atributo interno que ya no existe). Se fijó bcrypt<4.1 con uv add "bcrypt<4.1", tal como advierte el propio enunciado, y hash_password/verify_password funcionan correctamente.

Hallazgo respecto al enunciado: el checkpoint del Paso 9 dice que tests/test_gastos.py de la Sesión 6 "sigue pasando tal cual" — no es exacto. registrar_gasto/listar_gastos ahora llaman a repo.total_por_categoria(db, usuario_id, categoria), repo.guardar(db, usuario_id, ...) y repo.listar(db, usuario_id, skip, limit), con nuevos parámetros db y usuario_id. Se actualizó RepositorioFalso (y las llamadas en los 4 tests) a esa misma firma — pasando None/1 como placeholders que el doble ignora — para que sigan cumpliendo el mismo contrato que el repository real, sin usar unittest.mock. _validar_gasto en sí no cambió ni una línea, que es la parte que el enunciado sí cumple.

Reflexión (Paso 12)

  1. ¿Qué archivo tocaría para migrar SQLite → Postgres? Solo DATABASE_URL en .env (y el driver en pyproject.toml, p. ej. psycopg). app/database.py no cambiaría de código (usa settings.database_url genéricamente), salvo quitar connect_args={"check_same_thread": False}, que es específico de SQLite. NO tocaría models/, schemas/, services/, repositories/ ni routers/ — todos hablan en términos de sesiones de SQLAlchemy, no de SQLite.

  2. ¿Podría alguien ver gastos de otro usuario pasando usuario_id en la URL? No, con el código de hoy no existe esa vía: GET /gastos/ no lee usuario_id de la URL ni del query string en ningún punto — lo obtiene siempre de usuario_actual.id, que sale del JWT verificado en get_current_user (app/dependencies.py). Un atacante tendría que falsificar un token válido firmado con el SECRET_KEY del servidor, no simplemente cambiar un parámetro en la URL.

Cierre

"Si mañana un atacante consigue mi archivo .env, podría firmar tokens JWT válidos para cualquier usuario (tiene el SECRET_KEY) y leer DATABASE_URL, pero NO podría recuperar las contraseñas originales de los usuarios, porque solo se guarda hashed_password (hash de bcrypt con salt, de un solo sentido) — nunca la contraseña en texto plano."


Sesión 8 — Del backend a los agentes

app/mcp/ deja de estar vacía: servidor MCP (app/mcp/server.py) con dos tools — registrar_gasto y listar_gastos (app/mcp/tools/gastos.py) — que llaman directo a app/services/gastos.py, el mismo que usa el router REST. services/gastos.py y repositories/gastos.py no cambiaron ni una línea desde la Sesión 7 (verificado con git diff --stat 507058a -- app/services/gastos.py app/repositories/gastos.py, sin salida).

Dependencia: uv add "mcp[cli]<2". El SDK instala por defecto mcp 2.x, que renombró FastMCP a MCPServer y no expone mcp.server.fastmcp — el mismo tipo de incompatibilidad de versión que bcrypt/passlib en la Sesión 7. Se fijó <2 para usar el código del enunciado tal cual.

Dos bugs reales encontrados y corregidos, reproducidos con el comando exacto del enunciado (uv run mcp dev app/mcp/server.py):

  1. Doble instancia de FastMCP. El patrón del enunciado (tools/gastos.py hace from app.mcp.server import mcp) rompe cuando el CLI de mcp carga server.py como script standalone: ese import de vuelta por la ruta del paquete (app.mcp.server) re-ejecuta el archivo y crea una segunda instancia de FastMCP, distinta de la que el CLI corre. Los tools quedan registrados en la instancia "fantasma"; el cliente ve list_tools() vacío y Unknown tool al intentar llamarlos. Verificado con id(): dos objetos FastMCP distintos en memoria. Fix aplicado: tools/gastos.py expone register(mcp) en vez de importar mcp; server.py crea la instancia y llama gastos.register(mcp) explícitamente. Ningún archivo se reimporta a sí mismo por una ruta distinta.

  2. ModuleNotFoundError: No module named 'app'. El ejecutable mcp (entry-point de consola en .venv/bin/) no agrega la raíz del proyecto a sys.path, a diferencia de python -m app.main. El proyecto no está declarado como paquete instalable, así que app no es importable fuera del directorio de trabajo. Fix: anteponer PYTHONPATH=. al comando, ej. PYTHONPATH=. uv run mcp dev app/mcp/server.py.

Verificación e2e real (sin mocks, protocolo MCP por stdio): un cliente ClientSession de la SDK, conectado al servidor vía uv run mcp run app/mcp/server.py con PYTHONPATH=., confirmó los 4 pasos del enunciado: list_tools() devuelve los 2 tools con sus descripciones; registrar_gasto("Almuerzo", 12.50, "comida") crea el gasto; listar_gastos() lo devuelve; registrar_gasto con categoria="inventada" responde {"error": "'inventada' no es una categoría válida"} sin stack trace.

Reflexión

  1. _obtener_o_crear_usuario_demo con varios usuarios reales: se rompería primero el aislamiento de datos — todos los clientes MCP compartirían el mismo usuario_id (el del email fijo demo@curso.com), así que cualquiera vería y modificaría los gastos de cualquier otro. Es exactamente el problema que el JWT resuelve en REST desde la Sesión 7; en MCP falta el equivalente (identidad propagada por el transporte/cliente).

  2. ¿Qué habría estado mal en la Sesión 6 para tener que tocar services/gastos.py hoy? Que registrar_gasto/listar_gastos dependieran directamente de app.repositories.gastos (import fijo) en vez de recibir repo como parámetro (DIP), o que la validación de negocio (_validar_gasto) viviera en el router/repository en lugar de en services/. Con cualquiera de los dos, el tool de MCP no habría podido reutilizar la lógica sin copiarla o sin acoplarse a FastAPI.

Cierre

"Hoy construí una tercera puerta de entrada al mismo backend. Lo que NO tuve que duplicar fue la lógica de negocio (_validar_gasto, registrar_gasto, listar_gastos), y eso fue posible gracias a DIP y a la separación en capas (decisión que tomamos en la Sesión 6)."

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jmurillov1/python-backend-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server