Pattern 1 — On-Demand
add_tool(name, desc, handler) — AI calls when it needs fresh data. Handler reads sensor and calls send_result.
Custom tools are functions you write on the MCU that an AI agent can call by name. Unlike the built-in gpio_write / adc_read tools, custom tools let you wrap sensors, peripherals, and behaviors in clean, meaningful names.
There are three patterns that cover almost every use case:
| Pattern | When to use |
|---|---|
| On-demand read | AI asks for the current value (temperature, distance, etc.) |
| Parameterized action | AI sends a command with arguments (set brightness, move servo) |
| Auto-polled buffer | MCU accumulates samples continuously; client drains them on a schedule |
Add the library to platformio.ini:
lib_deps = bblanchon/ArduinoJson @ ^7 ThanabordeeN/MCP-U_Arduino @ ^1.2.0Minimal skeleton — all patterns build on this:
#include <MCP-U.h>
McpDevice mcp("my-device", "1.0.0");
// handlers go here (before setup)
void setup() { Serial.begin(115200); // mcp.add_pin(...) and mcp.add_tool(...) go here mcp.begin(Serial, 115200);}
void loop() { mcp.loop();}The AI calls the tool whenever it needs a fresh reading.
Example: LM35 temperature sensor on GPIO34.
#define TEMP_PIN 34
void handle_get_temperature(int id, JsonObject params) { int raw_adc = analogRead(TEMP_PIN); float voltage = raw_adc * 3.3f / 4095.0f; // ESP32: 12-bit ADC, 3.3 V ref float temp_c = (voltage - 0.5f) * 100.0f; // LM35: 10 mV/°C, 0.5 V offset
JsonDocument res; res["result"]["temperature_c"] = temp_c; res["result"]["voltage_v"] = voltage; res["result"]["raw_adc"] = raw_adc; mcp.send_result(id, res);}Register in setup():
mcp.add_tool( "get_temperature", "Read LM35 temperature sensor on GPIO34. Returns temperature_c, voltage_v, raw_adc.", handle_get_temperature);Response the AI receives:
{ "temperature_c": 24.8, "voltage_v": 0.748, "raw_adc": 928}If something can go wrong, return an error instead of garbage data:
void handle_get_temperature(int id, JsonObject params) { int raw = analogRead(TEMP_PIN);
if (raw < 10) { mcp.send_error(id, -32602, "Sensor not connected or reading too low"); return; }
float voltage = raw * 3.3f / 4095.0f; float temp_c = (voltage - 0.5f) * 100.0f;
JsonDocument res; res["result"]["temperature_c"] = temp_c; res["result"]["raw_adc"] = raw; mcp.send_result(id, res);}Standard error codes:
| Code | Meaning |
|---|---|
-32602 | Invalid params / bad state |
-32601 | Method not found (built-in, don’t use manually) |
-32600 | Invalid request (built-in) |
The AI sends arguments; the MCU validates them and acts.
Example: Set LED brightness via PWM on GPIO5.
#define LED_PIN 5
void handle_set_brightness(int id, JsonObject params) { // Validate parameter exists and is in range if (!params["duty"].is<int>()) { mcp.send_error(id, -32602, "Missing param: duty (integer 0-255)"); return; }
int duty = params["duty"].as<int>(); if (duty < 0 || duty > 255) { mcp.send_error(id, -32602, "duty must be 0-255"); return; }
analogWrite(LED_PIN, duty);
JsonDocument res; res["result"]["pin"] = LED_PIN; res["result"]["duty"] = duty; res["result"]["pct"] = (duty * 100) / 255; mcp.send_result(id, res);}Register with parameter description in the tool description:
mcp.add_tool( "set_brightness", "Set LED brightness on GPIO5. Param: duty (integer 0–255, where 0=off, 255=full).", handle_set_brightness);AI calls it as:
{ "duty": 128 }Response:
{ "pin": 5, "duty": 128, "pct": 50 }// Integerint count = params["count"].as<int>();
// Floatfloat threshold = params["threshold"].as<float>();
// Booleanbool enable = params["enable"].as<bool>();
// Stringconst char* label = params["label"].as<const char*>();
// With default fallback (operator| syntax)int count = params["count"] | 10; // default 10float thresh = params["threshold"] | 0.5f; // default 0.5bool enable = params["enable"] | true; // default trueUse this when you need continuous sensor data in the memory database — touch events, accelerometer traces, heart rate samples, etc.
The idea: the MCU fills a circular buffer in loop() at a high sample rate. A custom tool dumps the buffer on demand. McpPolling(ms) tells the client to call the tool automatically so data flows into the DB without any manual configuration.
Example: Capacitive touch sensor on GPIO4, sampled every 200 ms.
Declare the circular buffer (file scope, before any functions)
#define TOUCH_PIN 4#define TOUCH_BUF_SIZE 20 // samples kept in RAM#define TOUCH_INTERVAL_MS 200 // sample every 200 ms#define TOUCH_THRESHOLD 40 // raw value below this = touched
static int touch_buf[TOUCH_BUF_SIZE];static uint32_t touch_ts[TOUCH_BUF_SIZE]; // millis() at sample timestatic uint8_t touch_head = 0;static uint8_t touch_count = 0;static uint32_t last_touch_sample = 0;Write the handler — dumps the whole buffer in one call
void handle_read_touch(int id, JsonObject params) { JsonDocument res;
// These three fields trigger the client's buffer → DB pipeline res["result"]["type"] = "buffer"; res["result"]["resource"] = "touch_gpio4"; // metric name in DB res["result"]["sample_interval_ms"] = TOUCH_INTERVAL_MS;
uint8_t n = touch_count; for (uint8_t i = 0; i < n; i++) { uint8_t idx = (touch_head - n + i + TOUCH_BUF_SIZE) % TOUCH_BUF_SIZE; res["result"]["values"][i] = touch_buf[idx]; res["result"]["touched"][i] = (touch_buf[idx] < TOUCH_THRESHOLD); res["result"]["timestamps"][i] = touch_ts[idx]; }
mcp.send_result(id, res);}Register with McpPolling — the client will call this tool every 2 seconds on its own
mcp.add_tool( "read_touch", "Read capacitive touch sensor on GPIO4. Returns buffer of raw values " "and touched (bool) flags. resource=touch_gpio4, interval_ms=200.", handle_read_touch, McpPolling(2000) // client polls every 2 s automatically);Sample in loop() — non-blocking, never uses delay()
void loop() { mcp.loop(); // handle incoming RPC requests
// Fill circular buffer non-blocking if (millis() - last_touch_sample >= TOUCH_INTERVAL_MS) { last_touch_sample = millis(); touch_buf[touch_head] = touchRead(TOUCH_PIN); touch_ts[touch_head] = millis(); touch_head = (touch_head + 1) % TOUCH_BUF_SIZE; if (touch_count < TOUCH_BUF_SIZE) touch_count++; }}What happens at runtime:
sequenceDiagram participant Firmware participant Client participant MemoryDB as Memory DB
loop every 200ms Firmware->>Firmware: loop() fills buffer end Client->>Firmware: auto-poll read_touch Firmware-->>Client: {type:"buffer", ...} Client->>MemoryDB: expandBufferSamples<br/>(infers timestamps) Note over MemoryDB: rows writtenThe client reconstructs exact timestamps for each sample using receivedAt - (n * interval_ms). Buffer data lands in mcp_u_observations so the AI can query history: “what was the average touch value over the past hour?”
All three patterns together — temperature sensor, LED brightness control, and auto-polled touch:
#include <MCP-U.h>
#define TEMP_PIN 34#define LED_PIN 5#define TOUCH_PIN 4#define TOUCH_BUF_SIZE 20#define TOUCH_INTERVAL_MS 200#define TOUCH_THRESHOLD 40
McpDevice mcp("sensor-demo", "1.0.0");
// --- Pattern 3: circular buffer (file scope) ---static int touch_buf[TOUCH_BUF_SIZE];static uint32_t touch_ts[TOUCH_BUF_SIZE];static uint8_t touch_head = 0, touch_count = 0;static uint32_t last_touch_sample = 0;
// --- Pattern 1: on-demand read ---void handle_get_temperature(int id, JsonObject params) { int raw = analogRead(TEMP_PIN); if (raw < 10) { mcp.send_error(id, -32602, "Sensor not connected"); return; } float v = raw * 3.3f / 4095.0f; float temp = (v - 0.5f) * 100.0f; JsonDocument res; res["result"]["temperature_c"] = temp; res["result"]["raw_adc"] = raw; mcp.send_result(id, res);}
// --- Pattern 2: action with parameters ---void handle_set_brightness(int id, JsonObject params) { if (!params["duty"].is<int>()) { mcp.send_error(id, -32602, "Missing param: duty (0-255)"); return; } int duty = params["duty"].as<int>(); if (duty < 0 || duty > 255) { mcp.send_error(id, -32602, "duty must be 0-255"); return; } analogWrite(LED_PIN, duty); JsonDocument res; res["result"]["duty"] = duty; mcp.send_result(id, res);}
// --- Pattern 3: auto-polled buffer ---void handle_read_touch(int id, JsonObject params) { JsonDocument res; res["result"]["type"] = "buffer"; res["result"]["resource"] = "touch_gpio4"; res["result"]["sample_interval_ms"] = TOUCH_INTERVAL_MS; uint8_t n = touch_count; for (uint8_t i = 0; i < n; i++) { uint8_t idx = (touch_head - n + i + TOUCH_BUF_SIZE) % TOUCH_BUF_SIZE; res["result"]["values"][i] = touch_buf[idx]; res["result"]["touched"][i] = (touch_buf[idx] < TOUCH_THRESHOLD); } mcp.send_result(id, res);}
void setup() { Serial.begin(115200); pinMode(LED_PIN, OUTPUT);
mcp.add_tool("get_temperature", "Read LM35 on GPIO34. Returns temperature_c and raw_adc.", handle_get_temperature);
mcp.add_tool("set_brightness", "Set LED brightness on GPIO5. Param: duty (integer 0-255).", handle_set_brightness);
mcp.add_tool("read_touch", "Read capacitive touch on GPIO4. Returns buffer of raw values and touched flags.", handle_read_touch, McpPolling(2000));
mcp.begin(Serial, 115200);}
void loop() { mcp.loop();
if (millis() - last_touch_sample >= TOUCH_INTERVAL_MS) { last_touch_sample = millis(); touch_buf[touch_head] = touchRead(TOUCH_PIN); touch_ts[touch_head] = millis(); touch_head = (touch_head + 1) % TOUCH_BUF_SIZE; if (touch_count < TOUCH_BUF_SIZE) touch_count++; }}Before connecting an AI agent, verify every tool works:
SERIAL_PORT=/dev/ttyACM0 npx @modelcontextprotocol/inspector npx mcpu-clientset SERIAL_PORT=COM3 && npx @modelcontextprotocol/inspector npx mcpu-clientDEVICES=board:192.168.1.38:3000:tcp npx @modelcontextprotocol/inspector npx mcpu-clientOpen the browser URL, find each tool in the list, and call them manually. Confirm:
get_temperature — returns temperature_cset_brightness with {"duty": 128} — LED dimsread_touch — returns values[] array; [auto-poll] log line should appear in the terminalPattern 1 — On-Demand
add_tool(name, desc, handler) — AI calls when it needs fresh data. Handler reads sensor and calls send_result.
Pattern 2 — Action
Same registration. Use params["key"].as<Type>() to read arguments. Validate before acting, send error if invalid.
Pattern 3 — Auto-Poll
add_tool(name, desc, handler, McpPolling(ms)) — MCU samples in loop(), handler dumps buffer, client polls automatically. Buffer data goes into memory DB.
Always
Register before begin(). Write descriptions for the AI (include field names + units). Use send_error for bad states instead of returning garbage.