mcp-popey
MCP-Popey
Servidor MCP de consulta de solo lectura sobre la base de Popey ERP, organizada por circuitos de negocio (venta, compras, stock, etc.).
Estructura
config/circuits.yaml # datos: qué circuitos existen, qué tablas tiene cada uno
src/circuits.py # puerta de entrada a circuits.yaml (lo lee una vez, lo cachea)
src/db.py # acceso a Postgres (pool, guardas de solo-lectura, chequeo de rol)
src/server.py # servidor MCP: junta circuits.py + db.py en 3 tools
requirements.txtcircuits.py y db.py son independientes entre sí — ninguno de los dos sabe
que el otro existe. server.py es el único módulo que conoce a ambos.
Requisitos
Python 3.12+ (probado con esa versión; no se testeó en versiones anteriores).
Acceso de red a una instancia de Postgres con el esquema de Popey ERP.
Un rol de Postgres genuinamente read-only (ver Seguridad — el servidor se niega a arrancar si detecta que el rol tiene algún permiso de escritura).
Instalación
cd "MCP-Popey"
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtSi
python3 -m venvfalla por falta deensurepip/python3-venv(ModuleNotFoundError: No module named 'ensurepip'), instalá el paquete del sistema (sudo apt install python3.12-venven Debian/Ubuntu) o bootstrapeá pip a mano dentro del venv conget-pip.py.
Configuración (variables de entorno)
Variable | Obligatoria | Default | Descripción |
| No |
| Host de Postgres |
| No |
| Puerto de Postgres |
| Sí | — | Nombre de la base |
| Sí | — | Rol de conexión — debe ser read-only, ver abajo |
| Sí | — | Contraseña del rol |
| No |
| Conexiones mínimas del pool |
| No |
| Conexiones máximas del pool |
| No |
|
|
| No |
|
|
Si falta alguna de las 3 obligatorias, el servidor no arranca y lo dice
explícitamente (no hay fallback silencioso a valores de psycopg2).
Historial: el 2026-08-08 existió brevemente un
DB_ALLOW_WRITABLE_ROLEcomo escape hatch para poder arrancar contra dev antes de tener el rol read-only dedicado (langdev, el rol usado hasta entonces, es dueño de tablas y superuser). Se sacó el mismo día al crearmcp_popey_ro— ver creación del rol read-only más abajo. El chequeo de rol no tiene bypass hoy: si el rol conectado tiene cualquier permiso de escritura, el servidor aborta, sin excepciones.
Correr el servidor
export PGHOST=...
export PGDATABASE=...
export PGUSER=...
export PGPASSWORD=...
python src/server.py(Equivalente: cd src && python server.py — la resolución de rutas internas
no depende del directorio desde el que se invoque.)
Corre sobre stdio por default (mcp.run(), transporte "stdio"): queda
esperando mensajes JSON-RPC por stdin/stdout — no es para ejecutar suelto
en una terminal y esperar ver algo, es para que lo levante un cliente MCP.
No usar print() en ningún cambio a este código: con stdio, stdout es
el canal del protocolo.
Conectarlo a un cliente MCP
Ejemplo de configuración (Claude Desktop, Claude Code vía .mcp.json, u
otro cliente que use el mismo formato mcpServers):
{
"mcpServers": {
"popey-erp": {
"command": "/ruta/a/MCP-Popey/.venv/bin/python",
"args": ["/ruta/a/MCP-Popey/src/server.py"],
"env": {
"PGHOST": "...",
"PGDATABASE": "...",
"PGUSER": "...",
"PGPASSWORD": "..."
}
}
}
}Las 3 tools
list_circuits()
Sin parámetros. Devuelve [{name, description}, ...] — los circuitos de
negocio definidos en circuits.yaml.
query_circuit(circuito, sql, params=None)
Ejecuta un único SELECT de solo lectura. circuito (ej. "venta") es
contexto informativo, no una restricción de acceso — ver política abajo.
params son los parámetros de la query (lista para %s, dict para
%(nombre)s); nunca interpolar valores directo en sql.
Devuelve {"rows": [...], "warnings": [...]}.
get_circuit_schema(circuito)
Junta la metadata de negocio de circuits.yaml (role, key_columns, source,
status, notes, pending_columns) con las columnas reales de Postgres
(information_schema.columns, acotado a las tablas del circuito) — pensado
para que el modelo sepa qué puede pedir antes de armar el sql de
query_circuit.
Seguridad y política de acceso
Hay dos capas independientes, ninguna reemplaza a la otra:
1. El rol de Postgres (barrera primaria)
PGUSER tiene que ser un rol read-only real (GRANT SELECT únicamente).
El servidor no confía ciegamente en eso: al arrancar, db.init_pool()
verifica el rol contra Postgres (superusuario, GRANT de escritura propio o
de PUBLIC, ownership de alguna tabla, o CREATE sobre algún schema) y, si
encuentra cualquier permiso de escritura o DDL, loguea CRITICAL y aborta
el arranque — no levanta el servidor con la barrera primaria comprometida.
No hay forma de saltear este chequeo desde configuración (ver historial de
DB_ALLOW_WRITABLE_ROLE arriba).
Creación del rol read-only (mcp_popey_ro)
En el entorno de dev actual, Postgres corre en un contenedor Docker
(lang_docker_database_1) sin volumen persistente: cada vez que se
recrea el contenedor, se pierde el cluster entero, rol incluido. Por eso el
rol read-only se recrea con un script versionado en vez de dejarlo como
comandos sueltos:
docker cp scripts/create_readonly_role.sql lang_docker_database_1:/tmp/
docker exec -it lang_docker_database_1 \
psql -U langdev -d langdev -v ON_ERROR_STOP=1 \
-v ro_password='<elegir una password>' \
-f /tmp/create_readonly_role.sqlCrea (o actualiza la password de) el rol mcp_popey_ro con GRANT SELECT
únicamente sobre los schemas que aparecen en config/circuits.yaml
(administracion, compra, e_plataforma, mercado_libre, public,
servicio_tecnico, stock, util, venta) — no sobre todos los schemas
de la base. Si circuits.yaml suma un circuito en un schema nuevo, hay que
agregar ese schema a scripts/create_readonly_role.sql y volver a correrlo
(los GRANT son idempotentes). Después de correrlo, poner
PGUSER=mcp_popey_ro y esa misma password en PGPASSWORD del .env.
2. La guarda de código (segunda capa, defensa en profundidad)
Independientemente del rol, db.py valida cada SQL antes de mandarlo a
Postgres:
tiene que empezar con
SELECT;una sola sentencia (rechaza
;interno — bloquea múltiples sentencias);sin palabras prohibidas en ningún lugar de la query (
INSERT,UPDATE,DELETE,DROP,ALTER,CREATE,pg_sleep,dblink, etc.).
Un DELETE/UPDATE mandado a query_circuit se rechaza acá, en Python,
antes de llegar a la base — no depende de que Postgres tire un error de
permisos.
circuits.yaml es informativo, no una barrera de acceso
Decisión explícita: el circuito pedido en query_circuit nunca bloquea
qué se puede leer. Tres situaciones generan un aviso en warnings pero la
query se ejecuta igual:
la tabla no pertenece al circuito pedido (pertenece a otro, o a ninguno);
la tabla está marcada
status: needs_reviewencircuits.yaml;la tabla está marcada
status: legacyencircuits.yaml.
Única excepción bloqueante a esta política: si circuito no es uno de
los circuitos existentes (list_circuits()), query_circuit y
get_circuit_schema rechazan antes de ejecutar nada. No es un problema de
status de una tabla — es la ausencia total de un circuito de referencia:
sin eso no hay contra qué avisar, no hay "role/notes" que citar en un
warning informativo. Por eso ahí sí se corta.
Auditoría
Cada llamada a query_circuit que llega a ejecutarse loguea (vía el logger
popey-mcp.server, nivel WARNING si hubo algún aviso, INFO si no): el
circuito pedido, el SQL final ejecutado (con el LIMIT ya aplicado) y las
tablas que generaron warning. Limitación conocida: los intentos
rechazados por la guarda de código (SELECT-only) o que fallan contra
Postgres no quedan auditados — el log se emite justo antes del return, y
una excepción corta el flujo antes de llegar ahí.
Troubleshooting
"Faltan variables de entorno obligatorias para conectar a Postgres" — falta
PGDATABASE,PGUSERoPGPASSWORD.El servidor no arranca y loguea
CRITICALsobre permisos de escritura/DDL —PGUSERno es read-only; corregir losGRANTdel rol en Postgres (no hay forma de saltear este chequeo desde acá a propósito).ModuleNotFoundErroral importarmcp,sqlglotopsycopg2— faltópip install -r requirements.txt(o el venv no está activado).