Mino The Ai Chatbot

Most AI demos today can talk really well, but they can’t do real work.

In this project, I’ll show you how to build a voice-controlled AI assistant using an ESP32 and Xiaozhi that can safely control real hardware and software automations. This assistant doesn’t just chat; it turns lights ON and OFF, reads sensor data, and even creates and fetches meetings from Google Calendar.

The key idea behind this project is Model Context Protocol (MCP). MCP acts as a bridge between an AI model and physical systems, allowing the AI to call predefined tools using structured data instead of guessing commands.

Using the DFRobot ESP32-S3 AI Cam, we combine voice input, AI decision-making, and real execution on an embedded device. The result is a reliable, predictable, and secure AI assistant that actually works in the real world.

This guide walks you through the complete process, from hardware setup and enclosure design to MCP tools and real-world automation.

DSC03793.JPG
DSC03791.JPG
DSC03783.JPG
DSC03798.JPG
DSC03786.JPG
HARDWARE LIST
1 DFRobot ESP32-S3 AI Cam
1 IP5306 Type-C BMS module
1 Li-Po battery
1 Mini power switch
1 Screw kit
DSC03717.JPG
DSC03718.JPG
DSC03720.JPG
DSC03737.JPG
DSC03738.JPG
DSC03723.JPG
DSC03724.JPG
DSC03726.JPG
DSC03725.JPG
HARDWARE LIST
1 FireBeetle 2 ESP32 S3
1 DHT11
1 Beetle ESP32 C3
1 10A Relay
STEP 1
CAD & 3D Printing

I designed a custom enclosure in Autodesk Fusion 360 to give the project a clean, product-like finish.

The enclosure consists of three parts:

The design is compact, lightweight, and comfortable to hold, roughly the size of a soap bar.

I 3D-printed all parts using a Bambu Lab P1S printer with yellow PLA filament.

You can:

  • Download the STL files and print them directly, or
  • Download the Fusion 360 (STEP) files and modify the design as needed
  • Note: This design is shared for educational and personal use only, not for commercial purposes.
DSC03729.JPG
DSC03730.JPG
DSC03731.JPG
DSC03733.JPG
DSC03727.JPG
STEP 2
Flash Xiaozhi Firmware

To flash the Xiaozhi firmware onto the ESP32-S3 AI Cam, follow these steps.

1. Download Required Files

ESP Flash Download Tool

https://docs.espressif.com/projects/esp-test-tools/en/latest/esp32/production_stage/tools/flash_download_tool.html

Mino Project Repository

https://github.com/MukeshSankhla/Mino-ESP32_MCP

This repository contains firmware and all project-related files.

2. Prepare the Flasher Tool

Screenshot 2026-01-28 102100.png

Extract all downloaded files

Open the ESP Flash Download Tool by double-clicking it

Select the chip type as ESP32-S3

Screenshot 2026-01-28 102131.png

3. Flash the Firmware

Enroll.png

You will now be on the flashing screen:

1.Click the three dots (⋯) and select the firmware .bin(xiaozhi_v1.9.4.bin) file from the project folder

2.Set the address to 0x00

3.Check the enable checkbox

4.Select the correct COM port

5.Click Erase and wait until it shows Finished

6.Click Start to begin flashing, wait until the flashing process completes

Once finished, the firmware is successfully flashed onto the ESP32-S3 AI Cam.

Screenshot 2026-01-28 102302.png
Screenshot 2026-01-28 103620.png
Screenshot 2026-01-28 103747.png
STEP 3
Circuit Connection
Project (1).png

Now, follow the circuit diagram and make the required connections using a soldering iron and wires.

Power Connections

Battery to BMS (Input)

Connect the Li-Po battery to the IP5306 BMS input

Red wire → Positive (+)

Black wire → Negative (−)

Double-check polarity before soldering.

Power Switch Connection

Connect the mini switch in series with the output side of the IP5306 BMS

This switch will control power delivery to the ESP32-S3 AI Cam

DSC03742.JPG
DSC03743.JPG
DSC03749.JPG
STEP 4
Power Connection to ESP32-S3 AI Cam

DSC03748.JPGNow connect the output of the IP5306 BMS to the ESP32-S3 AI Cam.

The ESP32-S3 AI Cam comes with a 2-pin battery terminal block, but I removed it to make the overall assembly slimmer by about 3 mm.

Connection Steps:

Solder the BMS output wires directly to the battery solder pads on the ESP32-S3 AI Cam

Positive (+) to PW+

Negative (−) to PW−

Ensure the solder joints are solid and there are no short circuits.

Turn ON the power switch to verify the connection.

If the board powers up correctly, the power wiring is complete.

DSC03752.JPG
DSC03755.JPG
DSC03756.JPG
DSC03761.JPG
STEP 5
ESP32-S3 Assembly
DSC03763.JPG

Take the main housing and the button extension, and place the button extension into its cutout in the housing.

Take the ESP32-S3 AI Cam board with the speaker connected.

Place the speaker into its dedicated slot inside the housing.

Align the ESP32-S3 board with the designed standoffs in the housing.

Secure the board using 4x M2 screws.

Press the button extension to make sure it moves freely and properly presses the on-board button.

If it feels tight, lightly sand the button extension until it presses and releases smoothly.

DSC03768.JPG
STEP 6
BMS Assembly

Place the IP5306 BMS module upside down inside the housing.

Align the Type-C connector with the cutout provided on the enclosure.

Secure the BMS using two M2 screws.

DSC03775.JPG
STEP 7
Switch Assembly

Use quick glue to secure the mini switch inside the housing.

Route the wires neatly to avoid pinching or stress.

Fix the battery in place using double-sided tape.

DSC03778.JPG
DSC03776.JPG
STEP 8
Final Assembly

DSC03782.JPGPlace the cover onto the housing, aligning the camera hole carefully.

Flip the assembly over and secure it using three M2 screws.

That’s it — the build is complete! 🎉

STEP 9
Configuration
Screenshot 2026-01-28 111630.png

Power on the Mino.

It will speak instructions and create a Wi-Fi hotspot named Xiaozhi…

On your phone or laptop, open Wi-Fi settings and connect to the Xiaozhi hotspot.

Open a browser and go to 192.168.1.4.

The Wi-Fi configuration page will open.

Enter your Wi-Fi SSID and Password, then tap Connect.

A green check mark confirms successful connection.

Once connected, the device will speak a 6-digit pairing code.

Go to https://xiaozhi.me/ and create an account (or log in).

Open the Console, click Add Device, and enter the 6-digit code.

The device will now appear in your console.

From here, select Configure Role to customize the device—change the agent's name, language, voice profile, role, and select the LLM/AI Model more....

STEP 10
ESP32 & MCP

4.pngModel Context Protocol (MCP) is a standard way for an AI model to safely interact with real systems.

AI models (LLMs) are great at understanding language, but they cannot directly control hardware. They work on probabilities and guesses, while hardware needs strict and predictable instructions.

MCP solves this by acting as a bridge between the AI and the ESP32.

Think of MCP like USB for AI models:

USB defines how devices talk to a computer

MCP defines how an AI talks to hardware and software tools

How MCP Runs on the ESP32

In this project:

The LLM runs in the cloud

The ESP32-S3 acts as an MCP server

MCP communication happens using structured JSON

5.png

The ESP32 exposes specific actions as tools, such as:

Turning LEDs ON or OFF

Reading sensor data

Creating or fetching Google Calendar events

6.png

Each MCP tool has:

A name

A description (for the AI)

A strict JSON input schema

A defined execution and response

The AI selects a tool and sends a valid JSON request.

The ESP32 parses this request and executes only the allowed action—nothing more.

This makes the system safe, predictable, and reliable.

LED Control Example

9.png

The LED is a simple example to show how MCP works.

The user says:

“Turn on the room light”

The AI selects the room_light tool and sends a JSON command:

{ "state": "ON" }

8.png

The ESP32:

Receives the JSON

Validates the input

Executes the action using digitalWrite()

The ESP32 sends a response back:

Success if the LED turns ON

Error if something fails

The AI confirms the result to the user.

10.png

Why This Matters

Without MCP:

AI guesses commands

APIs are unpredictable

Hardware control is unsafe

With MCP:

Every action is predefined

Inputs are validated

Execution is deterministic

This is how AI moves from chatting to real-world execution on embedded devices like the ESP32.

STEP 11
Basic MCP Example (LED + DHT11)

DSC03808.JPGProject (2).png

In this example, we use a DFRobot FireBeetle ESP32-S3, which has:

An on-board LED connected to GPIO 21

A DHT11 temperature & humidity sensor connected to GPIO 3

This sketch demonstrates how ESP32 exposes real hardware as MCP tools that an AI can call safely.

What This Code Does (High Level)

Connects the ESP32 to Wi-Fi

Opens a WebSocket connection to the MCP server

Registers two MCP tools:

room_light → Control the LED

room_climate → Read temperature & humidity

Waits for AI requests and executes them on real hardware

Required Libraries

Make sure these libraries are installed in Arduino IDE:

#include

#include

#include

Wi-Fi Configuration

const char* WIFI_SSID = "Makerbrains_2.4G";

const char* WIFI_PASS = "Balaji2830";

User Action:

Replace these with your own Wi-Fi credentials.

MCP Endpoint

const char* MCP_ENDPOINT = "wss://api.xiaozhi.me/mcp/?token=...";

This is the secure WebSocket endpoint that connects your ESP32 to the AI.

How to Get Your MCP Endpoint

Go to xiaozhi.me

Open Configure Role

Scroll to MCP Settings

Click Get MCP Endpoint

Copy and paste it here

Hardware Configuration

#define LED_PIN 21

#define DHT_PIN 3

LED is connected to GPIO 21

DHT11 data pin is connected to GPIO 3

MCP Tool 1: LED Control (room_light)

Tool Definition:

mcp.registerTool(

"room_light",

"Control LED connected to ESP32",

"{\"type\":\"object\",\"properties\":{\"state\":{\"type\":\"string\",\"enum\":[\"on\",\"off\"]}},\"required\":[\"state\"]}",

This tool:

Is named room_light

Accepts only one parameter

state must be "on" or "off"

No other values are allowed.

Tool Execution Logic

if (state == "on") {

digitalWrite(LED_PIN, HIGH);

} else if (state == "off") {

digitalWrite(LED_PIN, LOW);

}

"on" → LED turns ON

"off" → LED turns OFF

If the JSON is invalid or the value is wrong, an error is returned to the AI.

Tool Response

{

"success": true,

"device": "LED",

"state": "on"

}

This response tells the AI exactly what happened.

MCP Tool 2: Climate Sensor (room_climate)

Tool Definition:

mcp.registerTool(

"room_climate",

"Read temperature and humidity from DHT11",

"{\"type\":\"object\",\"properties\":{}}",

This tool:

Takes no input

Simply reads the DHT11 sensor

Sensor Reading

int result = dht11.readTemperatureHumidity(temperature, humidity);

If the read fails, an error is returned.

If successful, temperature and humidity are sent back to the AI.

Tool Response Example

{

"success": true,

"temperature_c": 28,

"humidity_percent": 60

}


MCP Connection Callback

void onMcpConnectionChange(bool connected)

When MCP connects:

Tools are registered

When MCP disconnects:

Status is printed on Serial Monitor

This ensures tools are available only when MCP is active.

Setup Function

In setup():

Serial communication starts

LED pin is configured

Wi-Fi connection is established

MCP client is started

mcp.begin(MCP_ENDPOINT, onMcpConnectionChange);

Loop Function

void loop() {

mcp.loop();

}

This keeps the MCP connection alive and listens for AI tool calls.

How the Full Flow Works

User speaks to AI

AI selects an MCP tool

AI sends structured JSON

ESP32 validates input

Hardware action is executed

ESP32 sends response

AI confirms result to user

CODE
/*
Project: ESP32 MCP Smart Climate Node
Author: Mukesh Sankhla | makerbrains.com

Description:
This project exposes ESP32 hardware as AI-callable tools using
Model Context Protocol (MCP) over WebSockets.

Tools exposed to AI:
- room_light      → Control an LED
- room_climate  → Read temperature & humidity from DHT11

This demonstrates real-world AI ↔ IoT integration.
*/

#include <Arduino.h>
#include <WiFi.h>
#include <WebSocketMCP.h>
#include <ArduinoJson.h>
#include <DHT11.h>

// ============================================================
// WiFi Configuration
// ============================================================
const char* WIFI_SSID = "Makerbrains_2.4G";
const char* WIFI_PASS = "Balaji2830";

// ============================================================
// MCP WebSocket Endpoint
// ============================================================
const char* MCP_ENDPOINT =
  "wss://api.xiaozhi.me/mcp/?token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQwNjE4MCwiYWdlbnRJZCI6MTMxNTI4MSwiZW5kcG9pbnRJZCI6ImFnZW50XzEzMTUyODEiLCJwdXJwb3NlIjoibWNwLWVuZHBvaW50IiwiaWF0IjoxNzY4Nzk5NTczLCJleHAiOjE4MDAzNTcxNzN9.LGubgNAglUb70pj_8UO9EE6zT05PN2E5VFhIJzxik9wiv6Fypadpg7omyO2e2hqKRs3kGNfDHG8KGtbtbeaw0g";

// ============================================================
// Hardware Configuration
// ============================================================
#define LED_PIN 21        // GPIO pin for LED
#define DHT_PIN 3         // GPIO pin for DHT11 data

DHT11 dht11(DHT_PIN);     // DHT11 sensor instance

// ============================================================
// MCP Client Instance
// ============================================================
WebSocketMCP mcp;

// ============================================================
// MCP Tool Registration
// ============================================================
void registerMcpTools() {

  // ----------------------------------------------------------
  // LED CONTROL TOOL
  // ----------------------------------------------------------
  mcp.registerTool(
    "room_light",
    "Control LED connected to ESP32",
    "{\"type\":\"object\",\"properties\":{\"state\":{\"type\":\"string\",\"enum\":[\"on\",\"off\"]}},\"required\":[\"state\"]}",
    [](const String& args) -> WebSocketMCP::ToolResponse {

      DynamicJsonDocument doc(128);
      if (deserializeJson(doc, args)) {
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Invalid JSON\"}", true
        );
      }

      String state = doc["state"] | "";

      if (state == "on") {
        digitalWrite(LED_PIN, HIGH);
      } else if (state == "off") {
        digitalWrite(LED_PIN, LOW);
      } else {
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"state must be on or off\"}", true
        );
      }

      return WebSocketMCP::ToolResponse(
        "{\"success\":true,\"device\":\"LED\",\"state\":\"" + state + "\"}"
      );
    }
  );

  // ----------------------------------------------------------
  // DHT11 CLIMATE TOOL
  // ----------------------------------------------------------
  mcp.registerTool(
    "room_climate",
    "Read temperature and humidity from DHT11",
    "{\"type\":\"object\",\"properties\":{}}",
    [](const String& args) -> WebSocketMCP::ToolResponse {

      int temperature = 0;
      int humidity = 0;

      int result = dht11.readTemperatureHumidity(temperature, humidity);

      if (result != 0) {
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"DHT11 read failed\"}", true
        );
      }

      String response =
        "{"
        "\"success\":true,"
        "\"temperature_c\":" + String(temperature) + ","
        "\"humidity_percent\":" + String(humidity) +
        "}";

      return WebSocketMCP::ToolResponse(response);
    }
  );
}

// ============================================================
// MCP Connection Callback
// ============================================================
void onMcpConnectionChange(bool connected) {
  if (connected) {
    Serial.println("[MCP] Connected");
    registerMcpTools();
  } else {
    Serial.println("[MCP] Disconnected");
  }
}

// ============================================================
// Setup
// ============================================================
void setup() {
  Serial.begin(115200);

  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  // ----------------------------------------------------------
  // WiFi Connection
  // ----------------------------------------------------------
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");

  // ----------------------------------------------------------
  // Start MCP Client
  // ----------------------------------------------------------
  mcp.begin(MCP_ENDPOINT, onMcpConnectionChange);
}

// ============================================================
// Loop
// ============================================================
void loop() {
  // Keep MCP connection alive
  mcp.loop();
}
STEP 12
Google Calendar Demo (ESP32 + MCP)

In this step, the ESP32 becomes a real Google Calendar assistant, not just a voice demo.

The same ESP32-S3 board runs:

MCP client (connected to Xiaozhi AI)

Custom calendar tools (set_meeting, get_meetings)

Google Calendar integration via Google Apps Script

When you speak a command, the AI decides which tool to call, and the ESP32 executes it.

13.png

1. set_meeting – Create a Google Calendar Event

This function is used when the AI hears something like:

“Create a meeting tomorrow at 2:30 PM for 60 minutes”

What the AI Sends to ESP32 (via MCP)

The AI does not send epoch time.

It sends human-readable structured data:

{

"title": "Project Review",

"time": "14:30",

"date": "18/01/2026",

"duration": 60

}

This is important because LLMs are bad at time math.

What the ESP32 Does (Step-by-Step)

1. Validate Inputs

if (timeStr.length() == 0 || dateStr.length() == 0)

Ensures time and date are present.

2. Convert Time + Date → Epoch (IST → UTC)

long long epochMs = convertToEpochMs(timeStr, dateStr);

Inside convertToEpochMs():

Accepts multiple formats

Builds a tm structure

Assumes IST

Converts to UTC epoch

Returns milliseconds

This fixes the biggest AI scheduling bug.

3. Build HTTP Request

?action=create

&title=Project%20Review

&start_epoch=1768636200000

&duration=30

The ESP32 sends this to Google Apps Script.

4. Google Apps Script Creates the Event

var start = new Date(startEpoch);

var end = new Date(start.getTime() + durationMin * 60000);

CalendarApp.getDefaultCalendar().createEvent(

title, start, end

);

Event is now live in Google Calendar.

Response Back to AI

{

"success": true,

"meeting": "created",

"title": "Project Review",

"scheduled_time": "14:30 IST",

"scheduled_date": "18/01/2026"

}

AI speaks the confirmation.

14.png

2. get_meetings – Retrieve Calendar Events

Used when the AI hears:

“What meetings do I have tomorrow evening from 4 to 5?”

What the AI Sends to ESP32

{

"start_time": "16:00",

"start_date": "18/01/2026",

"end_time": "17:00",

"end_date": "18/01/2026"

}

Again — no epoch from AI.

What the ESP32 Does

1. Validate Time Range

Checks all fields exist and:

startEpoch < endEpoch

2. Convert Both Times to Epoch

startEpochMs = convertToEpochMs(start_time, start_date);

endEpochMs = convertToEpochMs(end_time, end_date);

Both are:

Parsed as IST

Converted to UTC

Sent in milliseconds

3. Build Request

?action=get

&start_epoch=1768636200000

&end_epoch=1768643400000

Google Apps Script Fetches Meetings

var events = CalendarApp

.getDefaultCalendar()

.getEvents(startTime, endTime);

Each event is converted into JSON:

{

"title": "Project Review",

"start_readable": "Sat Jan 18 2026 16:00:00 GMT+0530",

"end_readable": "Sat Jan 18 2026 16:30:00 GMT+0530"

}

Response Back to ESP32 → AI

{

"success": true,

"count": 1,

"meetings": [ ... ]

}

The AI can now:

Read meetings aloud

Summarize schedule

Make decisions (free/busy logic)

15.png
CODE
/*
Project: ESP32 MCP Calendar Agent
Author: Mukesh Sankhla | makerbrains.com

Description:
This project turns an ESP32 into an AI-controlled
Google Calendar assistant using Model Context Protocol (MCP).

The ESP32 exposes calendar-related tools to an AI agent:
- Create meetings
- Retrieve meetings
- Handle IST timezone conversion
- Parse flexible date/time formats
*/

#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WebSocketMCP.h>
#include <ArduinoJson.h>
#include <time.h>

// ================= WiFi =================
const char* WIFI_SSID = "Makerbrains_2.4G";
const char* WIFI_PASS = "Balaji2830";

// ================= Google Calendar =================
const char* CALENDAR_URL =
"https://script.google.com/macros/s/AKfycbxAp-HoL_O7_sQk5ZQB9gYTPo3BwkN-jPhZxErfpeHjI_NDm2aqM_WWyeKqe386XaM/exec";

// ================= MCP =================
const char* MCP_ENDPOINT =
"wss://api.xiaozhi.me/mcp/?token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQwNjE4MCwiYWdlbnRJZCI6MTMxNTI4MSwiZW5kcG9pbnRJZCI6ImFnZW50XzEzMTUyODEiLCJwdXJwb3NlIjoibWNwLWVuZHBvaW50IiwiaWF0IjoxNzY4NTUzMDIwLCJleHAiOjE4MDAxMTA2MjB9._8glx_wbpBTBvPkQcCPnC5E6qDbZZfSFwyLfQircwTrd1CMucb7GmuXc8FnOu0ICUXRY8z2S3WqTZTIUKreomg";

// ================= MCP Client =================
WebSocketMCP mcp;

// ================= Time Offset =================
const long TIME_OFFSET_SECONDS = 5 * 3600 + 30 * 60; // +5:30

// ================= URL Encode =================
String urlEncode(const String &str) {
  String encoded = "";
  char c;
  char buf[4];

  for (int i = 0; i < str.length(); i++) {
    c = str.charAt(i);
    if (isalnum(c)) {
      encoded += c;
    } else {
      sprintf(buf, "%%%02X", c);
      encoded += buf;
    }
  }
  return encoded;
}

// ================= Parse DateTime and Convert to Epoch =================
// Accepts formats like "17:03" or "17:03:45"
// Accepts date formats like "17/01/2026" or "2026-01-17"
long long convertToEpochMs(const String& timeStr, const String& dateStr) {
  
  Serial.println("\n[PARSER] Converting DateTime to Epoch:");
  Serial.printf("  Time input: '%s'\n", timeStr.c_str());
  Serial.printf("  Date input: '%s'\n", dateStr.c_str());

  // Parse date (DD/MM/YYYY or YYYY-MM-DD)
  int day, month, year;
  
  if (dateStr.indexOf('/') >= 0) {
    // Format: DD/MM/YYYY
    sscanf(dateStr.c_str(), "%d/%d/%d", &day, &month, &year);
  } else if (dateStr.indexOf('-') >= 0) {
    // Format: YYYY-MM-DD
    sscanf(dateStr.c_str(), "%d-%d-%d", &year, &month, &day);
  } else {
    Serial.println("[ERROR] Invalid date format!");
    return -1;
  }

  // Parse time (HH:MM or HH:MM:SS)
  int hour, minute, second = 0;
  if (sscanf(timeStr.c_str(), "%d:%d:%d", &hour, &minute, &second) < 2) {
    Serial.println("[ERROR] Invalid time format!");
    return -1;
  }

  Serial.printf("  Parsed: %04d-%02d-%02d %02d:%02d:%02d IST\n", 
                year, month, day, hour, minute, second);

  // Validate ranges
  if (year < 2020 || year > 2030 || month < 1 || month > 12 || 
      day < 1 || day > 31 || hour < 0 || hour > 23 || 
      minute < 0 || minute > 59 || second < 0 || second > 59) {
    Serial.println("[ERROR] Date/time values out of range!");
    return -1;
  }

  // Create tm structure for IST time
  struct tm timeinfo = {0};
  timeinfo.tm_year = year - 1900;  // Years since 1900
  timeinfo.tm_mon = month - 1;     // Months since January (0-11)
  timeinfo.tm_mday = day;
  timeinfo.tm_hour = hour;
  timeinfo.tm_min = minute;
  timeinfo.tm_sec = second;
  timeinfo.tm_isdst = 0;           // No DST in IST

  // Convert to epoch (this gives UTC)
  time_t epochUtc = mktime(&timeinfo);
  
  // Subtract IST offset to get actual UTC epoch
  // (since mktime interprets as local, we need to adjust)
  epochUtc -= TIME_OFFSET_SECONDS;
  
  // Convert to milliseconds
  long long epochMs = (long long)epochUtc * 1000LL;

  Serial.printf("  UTC Epoch: %lld (%lld ms)\n", (long long)epochUtc, epochMs);
  Serial.printf("  ✓ Conversion successful!\n");

  return epochMs;
}

// ================= MCP Tools =================
void registerMcpTools() {

  // ---------- GET MEETINGS ----------
  String getMeetingsDescription = 
    "Retrieve scheduled meetings from Google Calendar for a specific time range. "
    "Provide start and end time/date in IST timezone. "
    "Example: start_time='16:00', start_date='18/01/2026', end_time='17:00', end_date='18/01/2026'";
  
  String getMeetingsSchema = String("{") +
    "\"type\":\"object\"," +
    "\"properties\":{" +
      "\"start_time\":{\"type\":\"string\",\"description\":\"Start time in 24-hour format HH:MM (IST). Example: 16:00\"}," +
      "\"start_date\":{\"type\":\"string\",\"description\":\"Start date as DD/MM/YYYY. Example: 18/01/2026\"}," +
      "\"end_time\":{\"type\":\"string\",\"description\":\"End time in 24-hour format HH:MM (IST). Example: 17:00\"}," +
      "\"end_date\":{\"type\":\"string\",\"description\":\"End date as DD/MM/YYYY. Example: 18/01/2026\"}" +
    "}," +
    "\"required\":[\"start_time\",\"start_date\",\"end_time\",\"end_date\"]" +
  "}";

  mcp.registerTool(
    "get_meetings",
    getMeetingsDescription.c_str(),
    getMeetingsSchema.c_str(),
    [](const String& args) -> WebSocketMCP::ToolResponse {

      DynamicJsonDocument doc(512);
      if (deserializeJson(doc, args)) {
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Invalid JSON\"}", true
        );
      }

      String startTime = doc["start_time"] | "";
      String startDate = doc["start_date"] | "";
      String endTime = doc["end_time"] | "";
      String endDate = doc["end_date"] | "";

      Serial.println("\n========================================");
      Serial.println("[GET MEETINGS] Request received");
      Serial.println("========================================");
      Serial.printf("Start: %s %s IST\n", startDate.c_str(), startTime.c_str());
      Serial.printf("End: %s %s IST\n", endDate.c_str(), endTime.c_str());

      // Validate inputs
      if (startTime.length() == 0 || startDate.length() == 0 || 
          endTime.length() == 0 || endDate.length() == 0) {
        Serial.println("[ERROR] Missing time or date parameters!");
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"All time and date parameters are required\"}", true
        );
      }

      // Convert to epoch
      long long startEpochMs = convertToEpochMs(startTime, startDate);
      long long endEpochMs = convertToEpochMs(endTime, endDate);
      
      if (startEpochMs < 0 || endEpochMs < 0) {
        Serial.println("[ERROR] Failed to convert date/time to epoch!");
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Invalid date/time format\"}", true
        );
      }

      if (startEpochMs >= endEpochMs) {
        Serial.println("[ERROR] Start time must be before end time!");
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Start time must be before end time\"}", true
        );
      }

      // Build URL for getting meetings
      String fullUrl = String(CALENDAR_URL)
        + "?action=get"
        + "&start_epoch=" + String(startEpochMs)
        + "&end_epoch=" + String(endEpochMs);

      Serial.println("\n[HTTP] Fetching meetings from Google Calendar:");
      Serial.println(fullUrl);

      HTTPClient http;
      http.begin(fullUrl);
      http.setTimeout(15000); // 15 second timeout for getting events
      http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); // Follow redirects
      
      int httpCode = http.GET();
      String response = http.getString();
      http.end();

      Serial.printf("\n[HTTP] Response Code: %d\n", httpCode);
      Serial.printf("[HTTP] Response: %s\n", response.c_str());

      if (httpCode == 200 || httpCode == 302) {
        Serial.println("[SUCCESS] ✓ Meetings retrieved successfully!");
        Serial.println("========================================\n");
        
        return WebSocketMCP::ToolResponse(response);
      }

      Serial.printf("[ERROR] HTTP request failed with code %d\n", httpCode);
      Serial.println("========================================\n");
      
      return WebSocketMCP::ToolResponse(
        "{\"success\":false,\"error\":\"Failed to retrieve meetings\"}", true
      );
    }
  );

  // ---------- SET MEETING ----------
  String meetingDescription = 
    "Create Google Calendar meeting. Provide time in 24-hour format (HH:MM) and date as DD/MM/YYYY. "
    "The system will handle IST timezone conversion automatically. "
    "Example: time='14:30', date='17/01/2026', duration=60";
  
  String meetingSchema = String("{") +
    "\"type\":\"object\"," +
    "\"properties\":{" +
      "\"title\":{\"type\":\"string\",\"description\":\"Meeting title\"}," +
      "\"time\":{\"type\":\"string\",\"description\":\"Meeting start time in 24-hour format HH:MM or HH:MM:SS (IST). Example: 14:30 or 09:15:00\"}," +
      "\"date\":{\"type\":\"string\",\"description\":\"Meeting date as DD/MM/YYYY or YYYY-MM-DD. Example: 17/01/2026 or 2026-01-17\"}," +
      "\"duration\":{\"type\":\"integer\",\"description\":\"Meeting duration in minutes (default 30)\"}" +
    "}," +
    "\"required\":[\"title\",\"time\",\"date\"]" +
  "}";

  mcp.registerTool(
    "set_meeting",
    meetingDescription.c_str(),
    meetingSchema.c_str(),
    [](const String& args) -> WebSocketMCP::ToolResponse {

      DynamicJsonDocument doc(512);
      if (deserializeJson(doc, args)) {
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Invalid JSON\"}", true
        );
      }

      String title = doc["title"] | "ESP32 MCP Meeting";
      String timeStr = doc["time"] | "";
      String dateStr = doc["date"] | "";
      int duration = doc["duration"] | 30;

      Serial.println("\n========================================");
      Serial.println("[MEETING] New meeting request received");
      Serial.println("========================================");
      Serial.printf("Title: %s\n", title.c_str());
      Serial.printf("Time: %s IST\n", timeStr.c_str());
      Serial.printf("Date: %s\n", dateStr.c_str());
      Serial.printf("Duration: %d minutes\n", duration);

      // Validate inputs
      if (timeStr.length() == 0 || dateStr.length() == 0) {
        Serial.println("[ERROR] Missing time or date!");
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Both time and date are required\"}", true
        );
      }

      // Convert to epoch
      long long epochMs = convertToEpochMs(timeStr, dateStr);
      
      if (epochMs < 0) {
        Serial.println("[ERROR] Failed to convert date/time to epoch!");
        return WebSocketMCP::ToolResponse(
          "{\"success\":false,\"error\":\"Invalid date/time format or values\"}", true
        );
      }

      // Build URL for creating meeting
      String fullUrl = String(CALENDAR_URL)
        + "?action=create"
        + "&title=" + urlEncode(title)
        + "&start_epoch=" + String(epochMs)
        + "&duration=" + String(duration);

      Serial.println("\n[HTTP] Sending request to Google Calendar:");
      Serial.println(fullUrl);

      HTTPClient http;
      http.begin(fullUrl);
      http.setTimeout(10000);
      http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); // Follow redirects
      
      int httpCode = http.GET();
      String response = http.getString();
      http.end();

      Serial.printf("\n[HTTP] Response Code: %d\n", httpCode);
      Serial.printf("[HTTP] Response Body: %s\n", response.c_str());

      if (httpCode == 200 || httpCode == 302) {
        Serial.println("[SUCCESS] ✓ Meeting created successfully!");
        Serial.println("========================================\n");
        
        String successResponse = "{\"success\":true," +
                                String("\"meeting\":\"created\",") +
                                "\"title\":\"" + title + "\"," +
                                "\"scheduled_time\":\"" + timeStr + " IST\","+
                                "\"scheduled_date\":\"" + dateStr + "\"," +
                                "\"epoch_ms\":" + String(epochMs) + "," +
                                "\"duration_minutes\":" + String(duration) + "}";
        
        return WebSocketMCP::ToolResponse(successResponse);
      }

      Serial.printf("[ERROR] HTTP request failed with code %d\n", httpCode);
      Serial.println("========================================\n");
      
      return WebSocketMCP::ToolResponse(
        "{\"success\":false,\"error\":\"HTTP request failed with code " + String(httpCode) + "\"}", true
      );
    }
  );
}

// ================= MCP Connection =================
void onMcpConnectionChange(bool connected) {
  Serial.println(connected ? "\n[MCP] ✓ Connected to server" : "\n[MCP] ✗ Disconnected from server");
  if (connected) {
    Serial.println("[MCP] Registering tools...");
    registerMcpTools();
    Serial.println("[MCP] ✓ All tools registered successfully!");
    Serial.println("       - set_meeting: Create calendar events");
    Serial.println("       - get_meetings: Retrieve scheduled meetings");
  }
}

// ================= Setup =================
void setup() {
  Serial.begin(115200);
  delay(1000);
  
  Serial.println("\n\n========================================");
  Serial.println("   ESP32 MCP Calendar System v3.0");
  Serial.println("   DateTime Parser Edition");
  Serial.println("========================================\n");

  // WiFi Connection
  Serial.println("[WiFi] Connecting to network...");
  Serial.printf("       SSID: %s\n", WIFI_SSID);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  
  int attempts = 0;
  while (WiFi.status() != WL_CONNECTED && attempts < 20) {
    delay(500);
    Serial.print(".");
    attempts++;
  }
  
  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\n[WiFi] ✓ Connected successfully");
    Serial.printf("       IP Address: %s\n", WiFi.localIP().toString().c_str());
    Serial.printf("       Signal Strength: %d dBm\n", WiFi.RSSI());
  } else {
    Serial.println("\n[WiFi] ✗ Connection failed!");
    Serial.println("       Please check credentials and restart");
  }

  // MCP Connection
  Serial.println("\n[MCP] Connecting to server...");
  Serial.println("      Endpoint: wss://api.xiaozhi.me/mcp/");
  mcp.begin(MCP_ENDPOINT, onMcpConnectionChange);
  
  Serial.println("\n========================================");
  Serial.println("         SYSTEM READY!");
  Serial.println("========================================");
  Serial.println("Configuration:");
  Serial.println("  • Timezone: IST (UTC+5:30)");
  Serial.println("  • DateTime parsing: ENABLED");
  Serial.println("  • Input format: HH:MM, DD/MM/YYYY");
  Serial.println("========================================\n");
}

// ================= Loop =================
void loop() {
  mcp.loop();
  delay(10);
}
STEP 13
Get the Google Apps Script Web URL

To connect ESP32 with Google Calendar, we need a public Web App URL from Google Apps Script.

1. Create a New Script

Go to https://script.google.com/

Click New Project

Delete the default code

Copy–paste the provided Apps Script code

2. Save the Script

Click Save

Give the project a name (e.g., ESP32 Calendar MCP)

3. Deploy as Web App

Click Deploy → New deployment

Select Web app

Set the options:

Execute as: Me

Who has access: Anyone

Then click Deploy

On first deploy, Google will ask for permission — approve it.

4. Copy the Web URL

After deployment, Google shows a Web App URL

Copy this URL

5. Paste URL in ESP32 Code

Replace CALENDAR_URL in the ESP32 sketch:

const char* CALENDAR_URL = "PASTE_YOUR_WEB_APP_URL_HERE";
CODE
function doGet(e) {
  var action = e.parameter.action || "create";
  
  if (action === "create") {
    return createMeeting(e);
  } else if (action === "get") {
    return getMeetings(e);
  }
  
  return ContentService.createTextOutput(JSON.stringify({
    success: false,
    error: "Invalid action. Use action=create or action=get"
  })).setMimeType(ContentService.MimeType.JSON);
}

// Create meeting function
function createMeeting(e) {
  var title = e.parameter.title || "ESP32 Meeting";
  var startEpoch = Number(e.parameter.start_epoch);
  var durationMin = Number(e.parameter.duration || 30);

  if (!startEpoch || isNaN(startEpoch)) {
    return ContentService.createTextOutput(JSON.stringify({
      success: false,
      error: "Invalid epoch"
    })).setMimeType(ContentService.MimeType.JSON);
  }

  var start = new Date(startEpoch);
  var end = new Date(start.getTime() + durationMin * 60000);

  try {
    var event = CalendarApp.getDefaultCalendar().createEvent(
      title,
      start,
      end,
      { description: "Created from ESP32" }
    );

    return ContentService
      .createTextOutput(JSON.stringify({
        success: true,
        message: "Meeting created",
        title: title,
        start: start.toString(),
        end: end.toString(),
        id: event.getId()
      }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService
      .createTextOutput(JSON.stringify({
        success: false,
        error: error.toString()
      }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

// Get meetings function
function getMeetings(e) {
  var startEpoch = Number(e.parameter.start_epoch);
  var endEpoch = Number(e.parameter.end_epoch);
  
  if (!startEpoch || !endEpoch || isNaN(startEpoch) || isNaN(endEpoch)) {
    return ContentService.createTextOutput(JSON.stringify({
      success: false,
      error: "Invalid start_epoch or end_epoch"
    })).setMimeType(ContentService.MimeType.JSON);
  }
  
  try {
    var startTime = new Date(startEpoch);
    var endTime = new Date(endEpoch);
    
    var events = CalendarApp.getDefaultCalendar().getEvents(startTime, endTime);
    
    var meetings = events.map(function(event) {
      return {
        title: event.getTitle(),
        start: event.getStartTime().getTime(),
        end: event.getEndTime().getTime(),
        start_readable: event.getStartTime().toString(),
        end_readable: event.getEndTime().toString(),
        description: event.getDescription() || "",
        location: event.getLocation() || ""
      };
    });
    
    return ContentService
      .createTextOutput(JSON.stringify({
        success: true,
        count: meetings.length,
        search_range: {
          start: startTime.toString(),
          end: endTime.toString()
        },
        meetings: meetings
      }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService
      .createTextOutput(JSON.stringify({
        success: false,
        error: error.toString()
      }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}
STEP 14
Xiaozhi MCP Light (Relay Example)

Project (3).pngIn this step, we demonstrate real AI-controlled hardware execution using Xiaozhi MCP.

Instead of a camera board, we use a DFRobot Beetle ESP32-C3, connected to a 10A relay module on GPIO 0.

This relay can control real loads like lights, fans, or appliances.

This example proves that MCP is not limited to one ESP32 — multiple ESP32 devices can expose tools independently.

Hardware Used:

DFRobot Beetle ESP32-C3

10A Relay Module

Relay control pin → GPIO 0

When the relay pin goes HIGH, the relay turns ON.

When it goes LOW, the relay turns OFF.

What This Example Does

The ESP32 exposes a single MCP tool: office_light

This tool allows the AI to:

Turn the relay ON

Turn the relay OFF

The AI does not toggle GPIOs directly.

It calls a structured tool, and the ESP32 executes it safely.

How the MCP Flow Works?

Voice or AI Command

Example:

“Turn on the office light”

Xiaozhi AI

Understands intent

Calls the MCP tool office_light

Sends structured JSON: { "state": "on" }

ESP32 Execution

Receives the tool call

Sets GPIO 0 HIGH or LOW

Controls the relay instantly

Response Back to AI

ESP32 sends execution status

AI confirms the action

This is true AI → hardware control, not keywords or if-else logic.

Conclusion
Model_Context_Protocol_page-0001.jpg

In this project, we built a real voice-controlled AI system on ESP32 — not a chatbot, but an execution engine.

Using MCP (Model Context Protocol), the ESP32 exposes its hardware and services as structured tools that an AI can safely call. This allowed us to:

Control real hardware (LEDs, sensors, relays)

Convert natural language into deterministic actions

Create and fetch Google Calendar meetings

Handle time, timezone, and epoch conversion directly on the device

What you’ve seen in this project are just a few examples of what MCP enables.

The real power is that any hardware or software capability can be exposed as an MCP tool — from home automation and factory sensors to cloud services, dashboards, and industrial control systems.

The possibilities are truly endless when AI is combined with structured, secure execution.

The key takeaway is the architecture:

The AI decides what needs to be done

MCP defines how it can be done

ESP32 executes it safely in the real world

If you understand this flow, you’re no longer just building IoT projects —

you’re designing AI-driven automation systems.

Special Thanks

A big thank you to DFRobot for providing all the hardware components used in this project and supporting open, educational innovation.

Happy building 🚀

License
All Rights
Reserved
licensBg
0