Cómo usar un LLM para controlar un Arduino usando MCP

En este post tenéis el código necesario para lanzar un un MCP en Arduino UNU R4 WiFi que permita a un LLM mostrar diferentes imágenes pre-configuradas en la matriz de leds, para más información puedes ver el vídeo:

/*
  Servidor MCP mínimo para Arduino UNO R4 WiFi.

  Dependencias:
    - WiFiS3 (incluida con el core Arduino Renesas)
    - Arduino_LED_Matrix (incluida con el core Arduino Renesas)
    - ArduinoJson 7.x (instalar desde el gestor de bibliotecas)

  Transporte MCP: Streamable HTTP, respuestas JSON (sin SSE), endpoint /mcp.
  Las dos funciones de hardware están vacías intencionadamente.
*/

#include <WiFiS3.h>
#include <Arduino_LED_Matrix.h>
#include <ArduinoJson.h>


#define DEBUG 1

#if DEBUG
  #define DEBUG_MSG(x) Serial.print(x)
  #define DEBUG_BR() Serial.println()
#else
  #define DEBUG_MSG(x) ((void)0)
  #define DEBUG_BR() ((void)0)
#endif


// ---------- Configuración WiFi ----------
const char WIFI_SSID[] = "WIFI_NAME";
const char WIFI_PASSWORD[] = "WIFI_PASSWORD";

// Ajusta estas constantes a la red local donde se conectará la placa.
const IPAddress MCP_IP(192, 168, 1, 15);
const uint16_t MCP_PORT = 8080;
const IPAddress MCP_DNS(192, 168, 1, 1);
const IPAddress MCP_GATEWAY(192, 168, 1, 1);
const IPAddress MCP_SUBNET(255, 255, 255, 0);

const char MCP_PROTOCOL_VERSION[] = "2025-06-18";
const char MCP_PATH[] = "/mcp";
const size_t MAX_HTTP_BODY = 4096;
const unsigned long CLIENT_TIMEOUT_MS = 3000;

// Estados admitidos por la pantalla.
static const char* const VALID_STATES[] = {
  "alegre", "triste", "enfado", "corazon","risa", "si", "no", "ok", "ko"
};
static const size_t VALID_STATES_COUNT =
  sizeof(VALID_STATES) / sizeof(VALID_STATES[0]);

WiFiServer mcpServer(MCP_PORT);
ArduinoLEDMatrix matrix;

const uint32_t EMPTY_FRAME[3] = {0x00000000, 0x00000000, 0x00000000};


byte OK_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0 },
  { 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};

byte HAPPY_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 },
  { 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};


byte SAD_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0 },
  { 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 },
  { 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 }
};

byte KO_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0 },
  { 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0 },
  { 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};


byte YES_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};

byte NO_FRAME[8][12] = {
  { 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0 },
  { 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 }
};


byte XD_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0 },
  { 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0 },
  { 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0 },
  { 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0 },
  { 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0 },
  { 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};

byte HEART_FRAME[8][12] = {
  { 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0 },
  { 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0 },
  { 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0 },
  { 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0 },
  { 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 },
  { 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0 }
};

byte ANGRY_FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0 },
  { 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};




//Plantilla vacia
/*byte _FRAME[8][12] = {
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
};*/



// ---------- Funciones que debe completar el usuario ----------

void pantalla(const char* estado) {
  if (strcmp(estado, "alegre") == 0) {
    // Mostrar cara alegre
    matrix.renderBitmap(HAPPY_FRAME, 8, 12);
  }
  else if (strcmp(estado, "triste") == 0) {
    // Mostrar cara triste
    matrix.renderBitmap(SAD_FRAME, 8, 12);
  }
  else if (strcmp(estado, "risa") == 0) {
    // Mostrar cara riendo
    matrix.renderBitmap(XD_FRAME, 8, 12);
  }
  else if (strcmp(estado, "si") == 0) {
    // Mostrar símbolo sí
    matrix.renderBitmap(YES_FRAME, 8, 12);
  }
  else if (strcmp(estado, "no") == 0) {
    // Mostrar símbolo no
    matrix.renderBitmap(NO_FRAME, 8, 12);
  }
  else if (strcmp(estado, "ok") == 0) {
    // Mostrar OK
    matrix.renderBitmap(OK_FRAME, 8, 12);
  }
  else if (strcmp(estado, "ko") == 0) {
    // Mostrar KO
    matrix.renderBitmap(KO_FRAME, 8, 12);
  }
  else if (strcmp(estado, "corazon") == 0) {
    // Mostrar KO
    matrix.renderBitmap(HEART_FRAME, 8, 12);
  }
  else if (strcmp(estado, "enfado") == 0) {
    // Mostrar KO
    matrix.renderBitmap(ANGRY_FRAME, 8, 12);
  }
}


void ventilador(uint8_t velocidad) {
  // Intencionadamente vacía.
  // Aquí se puede convertir 0..100 al PWM o al control requerido por el motor.
}

// ---------- Utilidades HTTP ----------

void sendCorsHeaders(WiFiClient& client) {
  client.println("Access-Control-Allow-Origin: *");
  client.println("Access-Control-Allow-Methods: POST, GET, OPTIONS");
  client.println("Access-Control-Allow-Headers: Content-Type, Accept, MCP-Protocol-Version");
  client.println("Access-Control-Expose-Headers: MCP-Protocol-Version");
  client.println("Access-Control-Max-Age: 86400");
}

void sendEmptyResponse(WiFiClient& client, int statusCode, const char* statusText) {
  client.print("HTTP/1.1 ");
  client.print(statusCode);
  client.print(' ');
  client.println(statusText);
  client.println("Content-Length: 0");
  sendCorsHeaders(client);
  client.println("Connection: close");
  client.println();
}

void sendJsonResponse(WiFiClient& client, int statusCode, const char* statusText,
                      JsonDocument& response) {
  const size_t length = measureJson(response);

  client.print("HTTP/1.1 ");
  client.print(statusCode);
  client.print(' ');
  client.println(statusText);
  client.println("Content-Type: application/json; charset=utf-8");
  client.print("MCP-Protocol-Version: ");
  client.println(MCP_PROTOCOL_VERSION);
  client.print("Content-Length: ");
  client.println(length);
  sendCorsHeaders(client);
  client.println("Connection: close");
  client.println();
  serializeJson(response, client);
}

void setResponseId(JsonDocument& response, JsonVariantConst id) {
  if (id.is<const char*>()) {
    response["id"] = id.as<const char*>();
  } else if (id.is<long>()) {
    response["id"] = id.as<long>();
  } else if (id.is<unsigned long>()) {
    response["id"] = id.as<unsigned long>();
  } else {
    response["id"] = nullptr;
  }
}

void makeError(JsonDocument& response, JsonVariantConst id, int code,
               const char* message) {
  response.clear();
  response["jsonrpc"] = "2.0";
  setResponseId(response, id);
  response["error"]["code"] = code;
  response["error"]["message"] = message;
}

void makeToolError(JsonDocument& response, JsonVariantConst id,
                   const char* message) {
  response.clear();
  response["jsonrpc"] = "2.0";
  setResponseId(response, id);
  response["result"]["isError"] = true;
  JsonObject item = response["result"]["content"].add<JsonObject>();
  item["type"] = "text";
  item["text"] = message;
}

void makeToolSuccess(JsonDocument& response, JsonVariantConst id,
                     const char* message) {
  response.clear();
  response["jsonrpc"] = "2.0";
  setResponseId(response, id);
  response["result"]["isError"] = false;
  JsonObject item = response["result"]["content"].add<JsonObject>();
  item["type"] = "text";
  item["text"] = message;
}

bool validScreenState(const char* state) {
  DEBUG_MSG("Validando estado: ");
  DEBUG_MSG(state);
  DEBUG_BR();
  
  if (state == nullptr) return false;

  for (size_t i = 0; i < VALID_STATES_COUNT; ++i) {
    if (strcmp(state, VALID_STATES[i]) == 0) return true;
  }
  return false;
}

// ---------- Métodos MCP ----------

void handleInitialize(JsonDocument& response, JsonVariantConst id) {
  response["jsonrpc"] = "2.0";
  setResponseId(response, id);
  response["result"]["protocolVersion"] = MCP_PROTOCOL_VERSION;
  response["result"]["capabilities"]["tools"]["listChanged"] = false;
  response["result"]["serverInfo"]["name"] = "arduino-uno-r4-wifi";
  response["result"]["serverInfo"]["version"] = "1.0.0";
}

void handleToolsList(JsonDocument& response, JsonVariantConst id) {
  response["jsonrpc"] = "2.0";
  setResponseId(response, id);

  JsonArray tools = response["result"]["tools"].to<JsonArray>();

  JsonObject screenTool = tools.add<JsonObject>();
  screenTool["name"] = "pantalla";
  screenTool["description"] = "Muestra en la matriz LED uno de los estados permitidos.";
  JsonObject screenSchema = screenTool["inputSchema"].to<JsonObject>();
  screenSchema["type"] = "object";
  JsonObject screenProperties = screenSchema["properties"].to<JsonObject>();
  JsonObject stateProperty = screenProperties["estado"].to<JsonObject>();
  stateProperty["type"] = "string";
  JsonArray states = stateProperty["enum"].to<JsonArray>();
  for (size_t i = 0; i < VALID_STATES_COUNT; ++i) {
    states.add(VALID_STATES[i]);
  }
  screenSchema["required"].add("estado");
  screenSchema["additionalProperties"] = false;

  JsonObject fanTool = tools.add<JsonObject>();
  fanTool["name"] = "ventilador";
  fanTool["description"] = "Fija la velocidad del ventilador entre 0 y 100.";
  JsonObject fanSchema = fanTool["inputSchema"].to<JsonObject>();
  fanSchema["type"] = "object";
  JsonObject fanProperties = fanSchema["properties"].to<JsonObject>();
  JsonObject speedProperty = fanProperties["velocidad"].to<JsonObject>();
  speedProperty["type"] = "integer";
  speedProperty["minimum"] = 0;
  speedProperty["maximum"] = 100;
  fanSchema["required"].add("velocidad");
  fanSchema["additionalProperties"] = false;
}

void handleToolCall(JsonDocument& request, JsonDocument& response,
                    JsonVariantConst id) {
  const char* name = request["params"]["name"].as<const char*>();
  JsonObjectConst arguments = request["params"]["arguments"].as<JsonObjectConst>();
    
  #if DEBUG
    DEBUG_MSG("JSON recibido:");
    serializeJson(request, Serial);
    DEBUG_BR();
  #endif
  
  DEBUG_MSG("Name: ");
  DEBUG_MSG(name);
  DEBUG_BR();

  if (strcmp(name, "pantalla") == 0) {
    const char* state = arguments["estado"].as<const char*>();
    if (!validScreenState(state)) {
      makeToolError(response, id,
                    "estado no válido; consulte tools/list para ver los permitidos");
      return;
    }

    pantalla(state);
    makeToolSuccess(response, id, "Estado de pantalla recibido");
    return;
  }

  if (strcmp(name, "ventilador") == 0) {
    JsonVariantConst speedValue = arguments["velocidad"];
    if (!speedValue.is<int>()) {
      makeToolError(response, id, "velocidad debe ser un número entero");
      return;
    }

    const int speed = speedValue.as<int>();
    if (speed < 0 || speed > 100) {
      makeToolError(response, id, "velocidad debe estar entre 0 y 100");
      return;
    }

    ventilador(static_cast<uint8_t>(speed));
    makeToolSuccess(response, id, "Velocidad del ventilador recibida");
    return;
  }

  makeToolError(response, id, "Herramienta desconocida");
}

void processMcpRequest(WiFiClient& client, const String& body) {
  JsonDocument request;
  JsonDocument response;

  DeserializationError error = deserializeJson(request, body);
  if (error) {
    makeError(response, JsonVariantConst(), -32700, "Parse error");
    sendJsonResponse(client, 200, "OK", response);
    return;
  }

  const char* method = request["method"] | "";
  JsonVariantConst id = request["id"];
  const bool isNotification = id.isNull();

  if (strcmp(method, "notifications/initialized") == 0) {
    sendEmptyResponse(client, 202, "Accepted");
    return;
  }

  if (isNotification) {
    sendEmptyResponse(client, 202, "Accepted");
    return;
  }

  if (strcmp(method, "initialize") == 0) {
    handleInitialize(response, id);
  } else if (strcmp(method, "ping") == 0) {
    response["jsonrpc"] = "2.0";
    setResponseId(response, id);
    response["result"].to<JsonObject>();
  } else if (strcmp(method, "tools/list") == 0) {
    handleToolsList(response, id);
  } else if (strcmp(method, "tools/call") == 0) {
    handleToolCall(request, response, id);
  } else {
    makeError(response, id, -32601, "Method not found");
  }

  sendJsonResponse(client, 200, "OK", response);
}

// ---------- Servidor ----------

bool readHttpRequest(WiFiClient& client, String& method, String& path,
                     String& body) {
  client.setTimeout(CLIENT_TIMEOUT_MS);

  String requestLine = client.readStringUntil('\n');
  requestLine.trim();
  const int firstSpace = requestLine.indexOf(' ');
  const int secondSpace = requestLine.indexOf(' ', firstSpace + 1);
  if (firstSpace <= 0 || secondSpace <= firstSpace) return false;

  method = requestLine.substring(0, firstSpace);
  path = requestLine.substring(firstSpace + 1, secondSpace);

  size_t contentLength = 0;
  while (true) {
    String header = client.readStringUntil('\n');
    header.trim();
    if (header.length() == 0) break;

    const int separator = header.indexOf(':');
    if (separator > 0) {
      String name = header.substring(0, separator);
      name.toLowerCase();
      if (name == "content-length") {
        contentLength = static_cast<size_t>(header.substring(separator + 1).toInt());
      }
    }
  }

  if (contentLength > MAX_HTTP_BODY) {
    sendEmptyResponse(client, 413, "Payload Too Large");
    return false;
  }

  body = "";
  body.reserve(contentLength + 1);
  const unsigned long deadline = millis() + CLIENT_TIMEOUT_MS;
  while (body.length() < contentLength &&
         static_cast<long>(deadline - millis()) > 0) {
    while (client.available() && body.length() < contentLength) {
      body += static_cast<char>(client.read());
    }
  }

  return body.length() == contentLength;
}

void handleClient(WiFiClient& client) {
  String method;
  String path;
  String body;

  if (!readHttpRequest(client, method, path, body)) return;

  if (path != MCP_PATH) {
    sendEmptyResponse(client, 404, "Not Found");
  } else if (method == "OPTIONS") {
    // Respuesta al preflight CORS del navegador.
    sendEmptyResponse(client, 204, "No Content");
  } else if (method == "POST") {
    processMcpRequest(client, body);
  } else if (method == "GET") {
    // Este servidor no abre un canal SSE iniciado por GET.
    sendEmptyResponse(client, 405, "Method Not Allowed");
  } else {
    sendEmptyResponse(client, 405, "Method Not Allowed");
  }
}

void connectWiFi() {
  if (WiFi.status() == WL_NO_MODULE) {
    DEBUG_MSG("No se ha detectado el módulo WiFi.");
    while (true) delay(1000);
  }

  WiFi.config(MCP_IP, MCP_DNS, MCP_GATEWAY, MCP_SUBNET);

  DEBUG_MSG("Conectando a ");
  DEBUG_MSG(WIFI_SSID);
  while (WiFi.begin(WIFI_SSID, WIFI_PASSWORD) != WL_CONNECTED) {
    DEBUG_MSG('.');    
    delay(3000);
  }

  DEBUG_BR();
  DEBUG_MSG("Servidor MCP: http://");
  DEBUG_MSG(WiFi.localIP());
  DEBUG_MSG(':');
  DEBUG_MSG(MCP_PORT);
  DEBUG_MSG(MCP_PATH);
  DEBUG_BR();
}

void setup() {
  Serial.begin(115200);
  while (!Serial && millis() < 3000) {}

  // La matriz 12x8 queda lista para que pantalla() cargue imágenes.
  matrix.begin();
  matrix.loadFrame(EMPTY_FRAME);

  connectWiFi();
  mcpServer.begin();
}

void loop() {
  WiFiClient client = mcpServer.available();
  if (client) {
    handleClient(client);
    client.stop();
  }
}