python-backend-mcp
by jmurillov1
README.md
# 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)."
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues