MCP + USB Serial: Claude Code controla hardware (2026)
Conecté un ESP32-S3 a mi laptop, escribí un servidor MCP mínimo en Python y le pedí a Claude Code que prendiera el LED. Al tercer prompt, el LED prendió. Los dos prompts anteriores fueron dos baches del handshake serial que no vi venir, y por los que ahora te aviso.
Este artículo cubre un caso concreto: expongo un microcontrolador conectado por USB serial como un servidor MCP, y dejo que Claude Code lo maneje con lenguaje natural. Va con el código completo y los 4 puntos donde tropecé.
Por qué MCP para hardware
Antes de MCP, mi flujo con Claude Code para prender un LED era así: yo le pedía a Claude que ejecutara un script Bash, Claude escribía python send.py /dev/ttyACM0 1, yo aprobaba el comando en Claude Code, el LED prendía. Funcionaba, pero cada iteración era 3 pasos de fricción: recordar la ruta del script, verificar el puerto, aprobar el comando Bash.
MCP (Model Context Protocol) es un protocolo que Anthropic publicó en noviembre de 2024 y que estandariza cómo un LLM habla con recursos externos. El servidor MCP se registra una sola vez en la configuración del cliente, y a partir de ahí Claude tiene “herramientas” (tools) que puede llamar directamente. Nada de Bash, nada de aprobar comandos, la abstracción es “Claude, prende el LED” y del otro lado el servidor MCP traduce eso en llamadas serial.
Vale la pena para hardware por una razón concreta. Los proyectos de embedded acumulan comandos raros: velocidades de baud, secuencias de reset, protocolos custom sobre UART. Cada uno se convierte en una función en el servidor MCP con nombre humano, y Claude compone esas funciones sin que yo tenga que recordar los detalles.
Servidor MCP mínimo (5 tools)
Este es el servidor MCP completo. Cinco tools: listar puertos, conectar, enviar, recibir, desconectar. Guardo esto como serial_mcp.py.
# serial_mcp.py
import serial
import serial.tools.list_ports
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("usb-serial")
_connection: serial.Serial | None = None
@mcp.tool()
def list_devices() -> list[dict]:
"""Lista los puertos USB serial que el SO reconoce."""
return [
{"port": p.device, "description": p.description, "hwid": p.hwid}
for p in serial.tools.list_ports.comports()
]
@mcp.tool()
def connect(port: str, baudrate: int = 115200) -> str:
"""Conecta al puerto especificado (una sola conexión activa)."""
global _connection
if _connection is not None and _connection.is_open:
_connection.close()
_connection = serial.Serial(port, baudrate, timeout=1)
return f"conectado a {port} a {baudrate} baud"
@mcp.tool()
def send(payload: str) -> str:
"""Envía una cadena ASCII al dispositivo conectado."""
if _connection is None or not _connection.is_open:
return "error: sin conexión. llamá a connect() primero."
_connection.write(payload.encode("ascii"))
_connection.flush()
return f"enviados {len(payload)} bytes: {payload!r}"
@mcp.tool()
def recv(max_bytes: int = 256) -> str:
"""Lee del buffer de recepción hasta max_bytes."""
if _connection is None or not _connection.is_open:
return "error: sin conexión."
data = _connection.read(max_bytes)
return data.decode("ascii", errors="replace")
@mcp.tool()
def disconnect() -> str:
"""Cierra la conexión."""
global _connection
if _connection is None:
return "sin conexión activa"
_connection.close()
_connection = None
return "desconectado"
if __name__ == "__main__":
mcp.run()
Instalás con pip install mcp pyserial. La conexión vive como variable global del proceso porque un puerto serial no puede ser abierto por dos procesos al mismo tiempo, y el servidor MCP tiene que ser el único dueño mientras está corriendo.
Registrando el servidor en Claude Code
Claude Code lee ~/.claude.json o un .mcp.json local del proyecto. Yo prefiero el .mcp.json local porque queda versionado con el repositorio.
{
"mcpServers": {
"usb-serial": {
"command": "python",
"args": ["/ruta/absoluta/a/serial_mcp.py"]
}
}
}
Reiniciás Claude Code, y las 5 tools aparecen bajo el namespace usb-serial. Si no aparecen, revisá que la ruta al script sea absoluta y que el Python del sistema tenga mcp y pyserial instalados. Yo perdí 20 minutos la primera vez porque el .mcp.json referenciaba un python de un venv que Claude Code no veía.
Del lado del firmware (ESP32-S3)
Del lado del ESP32-S3 escribí un firmware Arduino mínimo que interpreta 1 como “prender LED” y 0 como “apagar LED”. Sin protocolo, sin acknowledgment, la cosa más simple que arranca.
// esp32-led-serial.ino
const int LED_PIN = 2;
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
if (Serial.available() > 0) {
char cmd = Serial.read();
if (cmd == '1') {
digitalWrite(LED_PIN, HIGH);
} else if (cmd == '0') {
digitalWrite(LED_PIN, LOW);
}
}
}
Compilás y grabás con arduino-cli o el IDE de Arduino. El LED en el pin 2 es el LED interno de la mayoría de las placas ESP32-S3 dev; si tu placa lo tiene en otro pin, cambiálo.
Los 3 prompts (2 baches y 1 exitoso)
Con todo instalado, le pedí a Claude Code lo obvio: “prendé el LED del ESP32”. Anoté los 3 prompts porque los 2 primeros fallaron por motivos que valen la pena documentar.
Prompt 1. Claude llamó list_devices(), encontró /dev/ttyACM0, llamó connect("/dev/ttyACM0"), llamó send("1"). Y el LED no prendió. Yo revisé el firmware, el pin, el cable, todo estaba bien. El bache era el reset del ESP32-S3 al abrir la conexión serial: la placa se reinicia cuando el DTR/RTS se acciona, y esto toma unos 800ms. El primer send() cae en la ventana de arranque del firmware y se pierde.
Prompt 2. Le pedí a Claude que agregara un time.sleep(1.0) después del connect(). Claude modificó el servidor MCP, reinicié, volvimos a intentar. El LED tampoco prendió. Segundo bache: el firmware Arduino que escribí primero usaba Serial.read() sin Serial.available(), y en ese loop Serial.read() retorna -1 cuando no hay datos y el if (cmd == '1') se cumplía nunca porque cmd era -1. Fue un bug mío del firmware, no del MCP.
Prompt 3. Con el time.sleep(1.0) en el servidor y el Serial.available() > 0 en el firmware, mandé “prendé el LED”. Claude llamó las 4 tools en orden (list_devices, connect, send("1"), disconnect), el LED prendió. Después le pedí “hacelo parpadear 3 veces”, y Claude compuso send("1") + time.sleep(0.5) + send("0") + time.sleep(0.5) tres veces sin instrucción explícita. Ahí es donde MCP se justifica: la composición emerge del LLM sin que yo tenga que codificar la secuencia.
Los 4 baches que anoté para la próxima vez
Voy a listar los 4 puntos donde tropecé, no solo los 2 anteriores. Los otros 2 aparecieron en las siguientes horas de uso.
1. Reset del microcontrolador al abrir el puerto. Ya lo cubrí. En ESP32-S3, Pico y muchas placas modernas, abrir el puerto dispara reset por DTR/RTS. Solución: time.sleep(1.0) después de connect(), o desactivar DTR/RTS antes de abrir con serial.Serial(port, baudrate, dsrdtr=False, rtscts=False).
2. Serial.available() en el firmware. En Arduino, siempre chequeá Serial.available() > 0 antes de Serial.read(). Sin esto, read() retorna -1 cuando no hay bytes, y comparar -1 contra caracteres da resultados extraños. Es evidente cuando lo sabés, pero cuando estás debugueando por qué el LED no prende no es lo primero que revisás.
3. Decodificación de bytes que no son ASCII. data.decode("ascii", errors="replace") en el recv() es lo que me salvó de tool calls fallidas. Si el firmware manda un byte fuera del rango ASCII (por ejemplo, valor sensor crudo de 0xC5), sin errors="replace" el decode tira excepción, Claude ve error, no sabe cómo recuperarse. Con replace, ese byte se convierte en � y Claude sigue interpretando el resto.
4. Un solo proceso puede tener el puerto. Si tenés el monitor serial del IDE de Arduino abierto, el servidor MCP no puede abrir el puerto. El mensaje de error es engañoso (“permission denied” o “port busy”). Antes de debuguear MCP, cerrá cualquier otra herramienta que tenga el puerto abierto.
Cuándo esto vale la pena y cuándo no
Vale la pena si estás haciendo prototipos donde vas a iterar sobre secuencias de comandos serial. La composición que Claude hace de las 5 tools reduce la fricción de forma real, y cuando agregás un protocolo nuevo (por ejemplo Modbus sobre RS-485), sólo agregás una tool más y Claude la usa sin que le expliques nada.
No vale la pena si vas a mandar el mismo comando 100 veces. Ahí un shell script con python send.py 1 es más rápido de arrancar y no depende de que Claude Code esté corriendo. MCP es para exploración, no para producción determinística.
Otro caso donde MCP no sirve: cuando tenés que garantizar timing sub-milisegundo. El overhead de invocar una tool MCP es de decenas de milisegundos, así que para señales que requieren precisión (por ejemplo, generar PWM manualmente) el LLM está fuera de lugar. Delegá eso al microcontrolador y expone a MCP el nivel de abstracción alto (“configurá PWM a 50% duty”).
Con estos 4 baches evitados, el próximo LED que prendas con Claude debería salir al primer prompt. Si sale al segundo, seguramente es un bache número 5 que yo todavía no vi, y me gustaría que me lo escribas.
ken imoto · WebRTC & Voice AI engineer · kenimoto.dev
¿Te resultó útil este artículo?