Skip to content

Your First Custom Tool

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:

PatternWhen to use
On-demand readAI asks for the current value (temperature, distance, etc.)
Parameterized actionAI sends a command with arguments (set brightness, move servo)
Auto-polled bufferMCU 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.0

Minimal 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:

CodeMeaning
-32602Invalid params / bad state
-32601Method not found (built-in, don’t use manually)
-32600Invalid 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 }
// Integer
int count = params["count"].as<int>();
// Float
float threshold = params["threshold"].as<float>();
// Boolean
bool enable = params["enable"].as<bool>();
// String
const char* label = params["label"].as<const char*>();
// With default fallback (operator| syntax)
int count = params["count"] | 10; // default 10
float thresh = params["threshold"] | 0.5f; // default 0.5
bool enable = params["enable"] | true; // default true

Pattern 3 — Auto-Polled Buffer (McpPolling)

Section titled “Pattern 3 — Auto-Polled Buffer (McpPolling)”

Use 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.

  1. 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 time
    static uint8_t touch_head = 0;
    static uint8_t touch_count = 0;
    static uint32_t last_touch_sample = 0;
  2. 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);
    }
  3. 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
    );
  4. 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 written

The 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:

Terminal window
SERIAL_PORT=/dev/ttyACM0 npx @modelcontextprotocol/inspector npx mcpu-client

Open the browser URL, find each tool in the list, and call them manually. Confirm:

  • get_temperature — returns temperature_c
  • set_brightness with {"duty": 128} — LED dims
  • read_touch — returns values[] array; [auto-poll] log line should appear in the terminal

Pattern 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.