mcp-popey
Read-only MCP server for querying the Popey ERP database, organized by business circuits. It provides tools to list circuits, run SELECT queries, and retrieve schema metadata, with strict read-only enforcement.
README
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.txt
circuits.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.txt
Si
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 |
|---|---|---|---|
PGHOST |
No | localhost |
Host de Postgres |
PGPORT |
No | 5432 |
Puerto de Postgres |
PGDATABASE |
Sí | — | Nombre de la base |
PGUSER |
Sí | — | Rol de conexión — debe ser read-only, ver abajo |
PGPASSWORD |
Sí | — | Contraseña del rol |
DB_POOL_MIN |
No | 1 |
Conexiones mínimas del pool |
DB_POOL_MAX |
No | 5 |
Conexiones máximas del pool |
DB_STATEMENT_TIMEOUT_MS |
No | 30000 |
statement_timeout de sesión (ms), aplicado a cada conexión del pool al crearse |
DB_DEFAULT_ROW_LIMIT |
No | 1000 |
LIMIT que se agrega automáticamente a una query que no trae uno |
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.sql
Crea (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).
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.