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
访问服务器

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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选