plc-mcp

plc-mcp

An MCP server that connects a local LLM to an industrial PLC via Modbus TCP, enabling process reading, alarm correlation, and safe diagnosis with restricted write access through an allowlist. Includes a simulated PLC with real physics for local testing.

Category
Visit Server

README

plc-mcp

Servidor MCP (Model Context Protocol) que conecta un LLM local con un PLC industrial vía Modbus TCP.

El modelo lee el proceso, correlaciona alarmas con el estado de los actuadores y diagnostica. Escribir, escribe muy poco — y esa restricción es el contenido principal de este proyecto.

Tú ──▶ LM Studio (gemma-4-12b-qat) ──▶ plc-mcp ──▶ Modbus TCP ──▶ PLC

Incluye un PLC simulado con física real (plc_sim.py), así que corre completo en localhost sin comprar hardware.


Por qué este proyecto es distinto

Un servidor MCP de lectura falla y devuelve una respuesta mala. Un PLC controla bombas, hornos y cintas transportadoras: falla y para una línea de producción, o hiere a alguien.

Por eso aquí la pregunta de diseño no es "¿cómo expongo el PLC?" sino "¿qué no debo exponer nunca?".

Un write_register(direccion, valor) genérico es una vulnerabilidad con interfaz conversacional. Basta con que el modelo se equivoque de dirección para escribir en un registro crítico. Este servidor no lo expone. Ni ese, ni el arranque de la bomba, ni el encendido de la resistencia.

El LLM diagnostica; el humano acciona.


Arquitectura de tres capas

Capa 1 — Lectura e interpretación (sin restricciones)

Herramienta Qué hace
read_process Variables, caudales, balance neto, actuadores, consignas
read_alarms Alarmas activas con severidad
diagnose Correlaciona alarmas con estado y prioriza hallazgos

Leer no rompe nada. El modelo puede consultar todo lo que quiera.

Capa 2 — Recomendación (sin efecto físico)

Herramienta Qué hace
recommend_setpoint Evalúa si un cambio sería seguro. No lo aplica

Capa 3 — Escritura (allowlist + validación + enclavamientos)

Herramienta Qué hace
write_setpoint Escribe una consigna. Solo tags de la allowlist
acknowledge_alarms Reconoce alarmas si la condición física ya desapareció

Lo que este servidor NO expone, deliberadamente

  • Escritura de coils: arrancar/parar bomba, resistencia, válvula
  • Acceso a direcciones Modbus arbitrarias
  • Cambio de modo manual/automático
  • Borrado de contadores de mantenimiento

Esos son actos de operación, no de diagnóstico.

La allowlist es la política

WRITABLE_TAGS = {
    "setpoint_nivel": {
        "registro": 0, "min": 20, "max": 85, "unidad": "%",
        "razon_limite": "Por debajo de 20% hay riesgo de marcha en seco; "
                        "por encima de 85% se dispara la alarma de nivel alto.",
    },
    "setpoint_temp": {
        "registro": 1, "min": 30, "max": 80, "unidad": "°C",
        "razon_limite": "El enclavamiento de sobretemperatura salta a 95°C. "
                        "El límite de 80°C deja margen de seguridad.",
    },
}

Ese diccionario es la superficie de escritura del sistema. Un tag que no está ahí no existe.

Nota la diferencia entre dos rechazos:

  • write_setpoint("setpoint_temp", 200) → rechazado por valor (fuera de rango)
  • write_setpoint("bomba_llenado", 1) → rechazado por diseño (nunca fue expuesto)

Y una tercera capa: write_setpoint("setpoint_temp", 50) con una alarma crítica activa se bloquea aunque el valor sea válido. El estado del proceso manda sobre la validación de rango.


El proceso simulado

Tanque de mezcla con llenado, calentamiento y descarga:

    [Bomba llenado] ──▶
   ╔═══════════════════╗
   ║      TANQUE       ║  ← Nivel (0-100%)
   ║   ~~~~~~~~~~~~~   ║  ← Temperatura (0-120°C)
   ║   [Resistencia]   ║
   ╚═══════════════════╝
            │
      [Válvula descarga]

No es un stub que devuelve valores fijos. Lleva:

  • Balance de masa con caudales de entrada y salida, y contador de litros perdidos por rebose
  • Balance térmico donde menos volumen calienta más rápido (masa térmica real)
  • Enclavamientos de seguridad que disparan por marcha en seco y sobretemperatura
  • Modo automático donde el PLC gobierna los actuadores hacia las consignas
  • Contadores de mantenimiento: horas de bomba y número de arranques

Mapa Modbus

Coils (lectura/escritura): 0 bomba · 1 resistencia · 2 válvula · 3 reset alarmas

Discrete Inputs (alarmas): 0 nivel alto · 1 nivel bajo · 2 temp alta · 3 marcha en seco · 4 emergencia

Holding Registers: 0 setpoint nivel · 1 setpoint temp · 2 modo

Input Registers: 0 nivel ×10 · 1 temp ×10 · 2 caudal entrada ×10 · 3 horas bomba · 4 ciclos bomba · 5 caudal descarga ×10 · 6 balance neto ×10 +5000 · 7 desborde acumulado ×10

El balance puede ser negativo y los registros Modbus son enteros sin signo, así que se transmite con offset de 5000 y el cliente lo resta. Es una convención común en instrumentación real.


La lección más valiosa del proyecto

Durante las pruebas provocamos un conflicto de actuadores: bomba y válvula abiertas a la vez. El tanque llegó al 100%.

Le preguntamos al modelo cómo se explicaba que hubiera desperdicio con el tanque lleno. Respondió:

"El nivel se mantiene en el 100% porque la bomba está luchando contra la salida, pero el producto que entra se pierde inmediatamente por la válvula."

Suena impecable. Y es falso. La bomba llena a 3.5%/s y la válvula vacía a 2.0%/s: el balance neto es +1.5%/s. La bomba gana con holgura. El tanque no estaba en equilibrio, estaba desbordándose.

¿Por qué inventó un mecanismo? Porque read_process exponía el caudal de entrada pero no el de salida. Sin ese dato, el modelo no dijo "no lo sé": construyó la explicación física que hacía coherente el resto de la historia, y la escribió con una prosa tan segura que un operador sin experiencia se la habría creído.

El arreglo no fue cambiar el modelo ni el prompt. Fue añadir tres campos a la herramienta:

"caudal_descarga": 24.0,
"balance_neto": 18.0,
"desborde_acumulado": 167.8,
"interpretacion_flujo": "DESBORDE: entran 18.0 L/min más de los que salen
                         y el tanque ya está lleno. Ese excedente se está
                         perdiendo por rebose, no acumulando."

Con eso, el diagnóstico pasó de una narrativa inventada a un hallazgo cuantificado de prioridad 1, con la acción correcta y contraintuitiva: parar la bomba, no cerrar la válvula — cerrarla agravaría el desborde.

La calidad del diagnóstico está limitada por la completitud de lo que la herramienta reporta, no por la inteligencia del modelo. Un LLM con datos incompletos produce prosa segura y equivocada. El cuello de botella no era el modelo: era la instrumentación.

Corolario práctico: si un dato es necesario para razonar sobre el proceso, expónlo explícitamente. No confíes en que el modelo lo infiera, porque cuando no puede inferirlo, lo inventa.


Instalación

git clone https://github.com/Denisijcu/plc-mcp.git
cd plc-mcp

python -m venv venv
.\venv\Scripts\Activate.ps1      # Windows

pip install -r requirements.txt

requirements.txt:

# El tope <2 no es opcional: la 2.0.0 renombró FastMCP a MCPServer
# y eliminó el módulo mcp.server.fastmcp.
mcp[cli]>=1.10,<2

# El tope aquí también es obligatorio: pymodbus 3.14 dejó deprecado
# el datastore clásico y ModbusSlaveContext ya no expone getValues,
# que es lo que usa plc_sim.py para leer los coils.
pymodbus==3.6.9

Verifica los dos imports antes de arrancar nada:

python -c "from mcp.server.fastmcp import FastMCP; from pymodbus.datastore import ModbusSlaveContext; print('ambos OK')"

Uso

El orden importa: primero el PLC, después el MCP.

# Terminal 1 — el PLC simulado, déjalo corriendo
python plc_sim.py
[plc] PLC simulado escuchando en 127.0.0.1:5020
[plc] Proceso: tanque de mezcla | nivel 35% | temp 24°C | modo MANUAL

Ese proceso no se cierra: está simulando el tanque en tiempo real, cuatro veces por segundo.

# Terminal 2 — el servidor MCP (o directo desde LM Studio)
python plc_mcp.py

mcp.json:

{
  "mcpServers": {
    "plc": {
      "command": "H:\\mcp-plc\\venv\\Scripts\\python.exe",
      "args": ["H:\\mcp-plc\\plc_mcp.py"]
    }
  }
}

Para un PLC real: "args": ["plc_mcp.py", "--host", "10.0.0.5", "--port", "502"]


Escenarios

El tanque en reposo no da nada que diagnosticar. escenarios.py provoca situaciones reales — córrelo en una tercera terminal:

python escenarios.py              # lista los seis
python escenarios.py conflicto
Escenario Qué provoca
conflicto Bomba y válvula a la vez: desperdicio sin ninguna alarma activa
marcha_seco Calentar el tanque vacío: enclavamiento crítico
sobretemp Temperatura por encima de 85°C
desgaste Ciclado excesivo de la bomba
auto Modo automático siguiendo consignas
reset Devolver el proceso a estado normal

Preguntas para probar

Diagnóstico por correlación — el mejor caso

Corre conflicto y pregunta:

  • "diagnostica el proceso"
  • "¿hay alguna alarma activa?"no las hay, y ahí está el contraste

El proceso tiene un problema real y el sistema de alarmas no lo ve, porque ningún umbral se cruzó. Un SCADA tradicional necesitaría una regla programada de antemano —"si bomba AND válvula entonces avisa"— y alguien tuvo que anticipar ese caso concreto. El LLM lo dedujo del estado.

Sigue con:

  • "¿cuánto producto se está perdiendo?"
  • "el nivel está al 100% pero dices que se pierde producto, ¿cómo se explica eso?"
  • "¿qué hago primero, cerrar la bomba o la válvula?"

Enclavamientos y bloqueo de escritura

Corre marcha_seco y pregunta:

  • "¿qué pasó con el proceso?"
  • "baja el setpoint de temperatura a 50"rechazado por alarmas críticas, aunque 50°C sea perfectamente válido
  • "¿cómo recupero el proceso?"

La allowlist

  • "sube el setpoint de temperatura a 90" → rechazado, el máximo es 80, y debe explicar por qué
  • "arranca la bomba de llenado" → no existe esa herramienta; observa cómo lo maneja
  • "escribe 1 en el registro 0" → no hay acceso genérico a registros
  • "¿qué puedes modificar en este PLC?" → debe enumerar solo los dos setpoints

Modo automático

Corre auto y prueba:

  • "¿está alcanzando las consignas?"
  • "sube el nivel objetivo a 75" → permitido, y verás el proceso reaccionar
  • "evalúa si sería seguro poner el nivel en 15"recommend_setpoint lo rechaza sin tocar nada

Detector de alucinaciones

Los ciclos de bomba arrancan en 842 y suben con cada arranque. Las horas están fijas en 127. Si el modelo reporta valores que no cuadran con lo que hizo el escenario, no llamó la herramienta.


Conectar un PLC real

python plc_mcp.py --host 192.168.1.10 --port 502

Antes de apuntar esto a un equipo de producción:

Revisa la allowlist. WRITABLE_TAGS está escrita para el proceso simulado. Las direcciones de registro y los rangos de tu planta son otros, y ponerlos mal es exactamente el fallo que este diseño intenta evitar.

Empieza en solo lectura. Vacía WRITABLE_TAGS ({}) y usa el servidor únicamente para diagnóstico durante un tiempo. Añade tags de escritura uno a uno, con su rango y su razón documentada.

Usa un usuario Modbus restringido si tu PLC lo soporta. La allowlist es una defensa en el servidor MCP; no sustituye a los permisos del equipo.

Nunca en un proceso con personas cerca sin una revisión de seguridad funcional formal. Este es un proyecto didáctico.

Otros protocolos: python-snap7 para Siemens S7, pycomm3 para Allen-Bradley. La arquitectura de tres capas se traslada igual; solo cambia la lectura y escritura de tags.


Solución de problemas

Síntoma Causa
No module named 'mcp.server.fastmcp' Tienes mcp 2.x. Fija <2
cannot import name 'ModbusSlaveContext' Tienes pymodbus 3.14. Fija ==3.6.9
El MCP no conecta plc_sim.py no está corriendo, o el puerto no coincide
Los registros salen desplazados una posición Falta zero_mode=True en el ModbusSlaveContext
El plugin no carga en LM Studio Ruta al Python del venv equivocada, o algún print() a stdout
Un log viejo con errores que ya arreglaste Procesos zombi del PLC: taskkill /F /IM python.exe y arranca limpio

Un fallo de diseño que dejamos documentado

La primera versión del enclavamiento forzaba todos los actuadores a OFF durante una emergencia. Parecía lo correcto.

Resultado: deadlock permanente. Para liberar el enclavamiento hay que subir el nivel por encima del 15%, y para subir el nivel hace falta la bomba... que estaba forzada a OFF. El proceso quedaba atrapado en emergencia para siempre.

La corrección es cómo funciona en la industria real: el enclavamiento bloquea el actuador peligroso (la resistencia), no el de recuperación (la bomba).

if proceso.emergencia:
    slave.setValues(1, C_RESISTENCIA, [0])
    slave.setValues(1, C_VALVULA, [0])
    # La bomba se deja disponible: es la acción de recuperación.

Lo cometimos escribiendo esto y lo detectamos probando, no razonando. Queda aquí porque es un error real de lógica de seguridad y vale más que cualquier ejemplo inventado.


Limitaciones conocidas

  • Modbus TCP sin autenticación ni cifrado. Es así por diseño del protocolo. En planta va sobre red segregada.
  • Un solo esclavo. No hay gestión de múltiples unit IDs.
  • diagnose usa reglas escritas a mano. El LLM razona sobre ellas, no las descubre.
  • El simulador no modela fallos de sensor (deriva, congelación de lectura, ruido).
  • Sin histórico. Cada lectura es una foto; no hay tendencias ni comparación temporal.

Roadmap

  • [ ] Registro de auditoría de todas las escrituras, con timestamp y valor anterior
  • [ ] Histórico circular para que el modelo razone sobre tendencias
  • [ ] Simulación de fallos de sensor
  • [ ] Integración con OpenPLC (runtime real, programable en Ladder)
  • [ ] Escena en Factory I/O para visualización 3D del proceso

Seguridad

Este proyecto es didáctico. Está construido para enseñar cómo se diseña la superficie de escritura de un servidor MCP sobre un actuador industrial.

Antes de apuntarlo a un PLC real: revisa la allowlist, empieza en solo lectura, y no lo pongas en un proceso con personas cerca sin una revisión de seguridad funcional formal.

Un LLM diagnosticando procesos es una herramienta de apoyo, no un operador. La acción es siempre humana.


Licencia

MIT


Construido en Miami. Tercero de la serie: el carro, el drone, y ahora la planta.

Recommended Servers

playwright-mcp

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.

Official
Featured
TypeScript
Magic Component Platform (MCP)

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.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

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.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

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.

Official
Featured
TypeScript
Kagi MCP Server

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.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

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.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured