Build a Natural-Language MQTT Environmental Monitoring System Using XIAO ESP32-C3, BME280, & Claude AI
01 Overview
This project sets up a real-time environmental monitoring system you can talk to in plain English. A XIAO ESP32-C3 reads temperature, humidity, and pressure from a BME280 sensor and publishes the data over MQTT to a Mosquitto broker running on your PC. A custom Model Context Protocol (MCP) server bridges the broker to Claude Desktop, so you can ask Claude questions about the live sensor data, or tell it to do things like turn an LED on and off based on what it sees.
Project Use Case
This works well as a natural-language environmental assistant for indoor spaces like bedrooms, study areas, server closets, or small grow setups. Instead of staring at a dashboard full of numbers, you just ask Claude what's going on and get a proper answer with context. It can also trigger physical actions, like switching on an indicator LED when a sensor threshold is crossed. Everything runs locally on your network with no external API keys, and you can have it built in about one to two hours on any modern laptop.
02 Hardware and Software Components
Gather everything below before you start.
Hardware Components
| Component | Description |
|---|---|
| Seeed Studio XIAO ESP32-C3 | Main controller and Wi-Fi communication module (2.4 GHz only) |
| BME280 Sensor (I2C breakout) | Environmental sensor for temperature, humidity, and atmospheric pressure |
| LED (any standard 5 mm) | Output indicator |
| 220 Ω Resistor | Resistor for the LED |
| Breadboard | Prototyping platform for the LED circuit and shared ground rail |
| Jumper Wires | For connections between components |
| USB-C Cable (data-capable) | Programming and power for the XIAO |
| Computer or Laptop | Hosts Mosquitto, the MCP server, and Claude Desktop |
| 2.4 GHz Wi-Fi Source | Phone hotspot used (no client isolation, easy to configure) |
Software Tools
| Software | Version / Details |
|---|---|
| Arduino IDE | Latest version (1.8+ or 2.x) |
| ESP32 Board Package by Espressif Systems | Version 3.0 or newer |
| Mosquitto MQTT Broker | Latest Windows installer |
| Node.js | LTS, v18 or newer |
| Claude Desktop | Latest version |
| Cirkit Designer | Used for the wiring diagram |
Project Files
You'll be working with three project files: the Arduino sketch on the ESP32, the Node.js MCP server on your PC, and the Claude Desktop config file. All three are listed in full in the Code section below.
| File | Description |
|---|---|
| xiao_bme280_mqtt_led.ino | Arduino sketch- reads the BME280, publishes JSON over MQTT every 5 seconds, subscribes to the LED command topic |
| index.js | Node.js MCP server- bridges the Mosquitto broker to Claude Desktop, exposes 6 tools (read, write, subscribe, unsubscribe, list, history) |
| claude_desktop_config.json | Claude Desktop configuration- tells Claude where to find the MCP server and which broker to connect to |
| mosquitto.conf (edited) | Two lines added to the default Mosquitto config to enable network access and anonymous connections |
03 Application Discussion
Seeed Studio XIAO ESP32-C3
The XIAO ESP32-C3 is the main controller. It's a compact RISC-V microcontroller with built-in 2.4 GHz Wi-Fi, which makes it a good fit for small wireless IoT nodes. In this project it reads the BME280 over I2C, connects to Wi-Fi, and uses MQTT to publish sensor readings to the broker. It also subscribes to a command topic so it can react to instructions from Claude in real time, like turning the LED on when told to.
BME280 Sensor
The BME280 is a combined temperature, humidity, and barometric pressure sensor from Bosch. It talks over I2C, so it only needs two data lines (SDA and SCL) plus power and ground. Compared to a DHT11 or DHT22, it gives faster updates, finer resolution, and throws in a pressure reading. The XIAO polls it every five seconds and packs the readings into a small JSON payload.
Mosquitto MQTT Broker
Mosquitto is a lightweight open-source MQTT broker. MQTT (Message Queuing Telemetry Transport) is a publish-subscribe protocol built for constrained devices and unreliable networks. Devices that produce data publish messages to named channels called topics; devices that consume data subscribe to those topics. The broker sits in the middle, receives every published message, and forwards it to whoever's currently subscribed. That means the publisher and subscriber never have to know about each other or even be online at the same time. They just need to share the same broker.
Custom MCP Server (Node.js)
The Model Context Protocol (MCP) is an open standard that lets an AI assistant call external tools through a structured interface. The MCP server here is a small Node.js program that connects to Mosquitto as an MQTT client, caches the latest value and a rolling history for every topic it's subscribed to, and exposes that data to Claude as a set of callable tools. When Claude needs to answer a question about a sensor, it calls one of these tools; when it needs to control the LED, it calls a different tool that publishes back to the broker. The MCP server is what turns Claude from a chatbot into something that can actually see and touch your hardware.
Claude Desktop
Claude Desktop is the AI client you talk to. Once it's configured with the MCP server, it can read live sensor data, interpret the values in plain language, generate charts from history, and decide when to send control commands. Since the AI runs through Claude Desktop, you don't need API keys, paid subscriptions, or cloud services. The whole thing runs on your machine and your local network.
LED (Output Actuator)
The LED is the physical output. It's wired through a current-limiting resistor to GPIO 10 (pin D10) on the XIAO. When Claude publishes "ON" or "OFF" to the LED command topic, the XIAO flips the pin accordingly.
04 Hardware Setup
Wire the components to the XIAO ESP32-C3 using the tables below. Everything sits on the same breadboard with a shared ground rail.
BME280 to XIAO ESP32-C3 Connections (I2C)
| BME280 Pin | XIAO ESP32-C3 Pin | Description |
|---|---|---|
| VCC | 3.3V | Power supply. Do NOT connect to 5V |
| GND | GND | Ground (shared with LED circuit) |
| SDA | D4 (GPIO 6) | I2C data line |
| SCL | D5 (GPIO 7) | I2C clock line |
| CSB | (not connected) | Only used for SPI mode leave floating |
| SDO | (not connected) | I2C address select leave floating (defaults to 0x76) |
LED Output Circuit
| Component | XIAO ESP32-C3 Pin | Description |
|---|---|---|
| LED anode (long leg) via 220 Ω resistor | D10 (GPIO 10) | Output control pin |
| LED cathode (short leg) | GND | Shared ground with BME280 |
Assembly Instructions
- Place the BME280 module and the LED on the breadboard.
- Connect the BME280 VCC to the 3.3V pin of the XIAO. Important: do not use 5V.
- Connect the BME280 GND to the breadboard's ground rail, then run one wire from that rail to a GND pin on the XIAO. The LED's cathode also connects to this same rail.
- Connect the BME280 SDA to D4 (GPIO 6) and SCL to D5 (GPIO 7).
- Connect the LED's long leg (anode) through a 220 Ω resistor to D10 (GPIO 10). Connect the short leg (cathode) to the shared ground rail.
- Plug the XIAO into the host computer via a data-capable USB-C cable.
05 Software Setup
The build goes in six steps, each with a checkpoint to confirm that part of the system works before moving on. Don't skip the checkpoints. Debugging is way easier when you know exactly which layer is failing.
| Step | What it does | Checkpoint |
|---|---|---|
| 1 — Prepare the Network | Connect host PC to a 2.4 GHz hotspot and record its IP |
ipconfig shows the laptop's IP on the hotspot |
| 2 — Arduino IDE and Libraries | Install Arduino IDE, the ESP32 board package, and the three required libraries | XIAO_ESP32C3 appears under Tools → Board |
| 3 — Install and Configure Mosquitto | Install Mosquitto, edit its config, and confirm it listens on the network |
netstat -an | findstr :1883 shows 0.0.0.0:1883 LISTENING
|
| 4 — Install Node.js and Build the MCP Server | Install Node.js, create the project folder, write index.js
|
node -v shows v18+ and the project folder contains index.js
|
| 5 — Configure Claude Desktop | Install Claude Desktop and point it at the MCP server | The "mqtt" tools menu shows 6 tools in Claude Desktop |
| 6 — Edit and Upload the Arduino Sketch | Set credentials in the sketch and flash it to the XIAO | Serial Monitor shows Published: ... lines every 5 seconds |
Step 1 — Prepare the Network
- Turn on a mobile hotspot in 2.4 GHz mode.
- Make the hotspot name (SSID) simple. If your device name has an apostrophe in it (like "User's iPhone"), rename the device first. The apostrophe the iPhone broadcasts is a curly Unicode character that breaks Arduino string literals.
- Connect the host PC to the hotspot.
- Open Command Prompt and run
ipconfig. Note the Wi-Fi adapter's IPv4 address. On an iPhone hotspot this is usually172.20.10.X. This is the MQTT broker address and you'll need it in a few places below.
Step 2 — Arduino IDE and Libraries
- Install Arduino IDE from arduino.cc/en/software.
- Open File → Preferences. In Additional Board Manager URLs, add:
url
https://espressif.github.io/arduino-esp32/package_esp32_index.json - Open Tools → Board → Boards Manager, search "esp32", and install esp32 by Espressif Systems version 3.0 or newer.
- Select Tools → Board → esp32 → XIAO_ESP32C3.
- In Tools, set USB CDC On Boot to Enabled. Without this the Serial Monitor stays blank over USB-C.
- Open Sketch → Include Library → Manage Libraries and install:
- Adafruit BME280 Library
- Adafruit Unified Sensor (usually auto-prompted as a dependency)
- PubSubClient by Nick O'Leary
Step 3 — Install and Configure Mosquitto
- Download Mosquitto from mosquitto.org/download and run the Windows 64-bit installer with default options. It installs to
C:\Program Files\mosquitto\and registers a Windows service that starts on boot. - Open Notepad as Administrator, then File → Open. In the file-type dropdown choose All Files (*.*) and open
C:\Program Files\mosquitto\mosquitto.conf. - Scroll to the bottom of the file and add the two lines below. The first lets other devices on the network connect; the second allows connections without a password (fine for a local demo).
mosquitto.conf
listener 1883 0.0.0.0 allow_anonymous true - Save and close the file.
- Open Command Prompt as Administrator and allow port 1883 through Windows Firewall:
cmd (admin)
netsh advfirewall firewall add rule name="Mosquitto MQTT" dir=in action=allow protocol=TCP localport=1883 - Restart Mosquitto so it picks up the new config. If it's running as a service, restart it with
net stop mosquittothennet start mosquittoin admin Command Prompt. Otherwise launch it manually:cmd (admin)cd "C:\Program Files\mosquitto" mosquitto -c mosquitto.conf -v - Check that Mosquitto is listening:
You should see something likecmd
netstat -an | findstr :1883TCP 0.0.0.0:1883 0.0.0.0:0 LISTENING.
-c mosquitto.conf flag. Without it, Mosquitto runs with defaults and only accepts connections from localhost. Your ESP32 will never reach it.Step 4 — Install Node.js and Build the MCP Server
- Install Node.js LTS from nodejs.org with default options. After installing, open a fresh Command Prompt and check:
Both commands should return a version number.cmd
node -v npm -v - Create the MCP server project:
cmd
cd %USERPROFILE% mkdir mqtt-mcp-server cd mqtt-mcp-server npm init -y npm install @modelcontextprotocol/sdk mqtt dotenv zod - Open
package.jsonin Notepad using the commandnotepad package.json, and change"type": "commonjs"to"type": "module". The MCP server uses ES module imports and won't run otherwise. - Create
index.jsin the same folder and paste in the source from the Code section below. Save and close. - Note the project path. You'll need it for the Claude config:
It should show something likecmd
cdC:\Users\YOURNAME\mqtt-mcp-server.
Step 5 — Configure Claude Desktop
- Install Claude Desktop. Either the Microsoft Store version or the direct installer from claude.ai/download works.
- Sign in to Claude Desktop with your account.
- Create the config file. In Command Prompt:
Click Yes when Notepad asks to create the file. Paste in the JSON from the Code section, replacing the placeholders with your actual Windows username and the broker IP from Step 1.cmd
mkdir "%APPDATA%\Claude" notepad "%APPDATA%\Claude\claude_desktop_config.json" - Save the file using File → Save As, with Save as type set to All Files (*.*). Otherwise Notepad quietly saves it as
claude_desktop_config.json.txtand Claude won't find it. - Fully quit Claude Desktop (right-click the system tray icon and choose Quit). Reopen it from the Start menu. The MCP server launches automatically.
- Click the tools icon in the chat input area. You should see the mqtt server listed with 6 tools:
read_topic,write_topic,subscribe_topic,unsubscribe_topic,list_topics, andread_history.
Step 6 — Edit and Upload the Arduino Sketch
- Open the Arduino sketch from the Code section in Arduino IDE.
- At the top of the file, edit three constants:
-
WIFI_SSID: your WiFi's name -
WIFI_PASSWORD: your WiFi's password -
MQTT_BROKER: the host PC's IP address from Step 1
-
- Plug in the XIAO and select the right COM port under Tools → Port.
- Click Upload. After uploading, open Tools → Serial Monitor at 115200 baud and press the reset button on the XIAO.
06 Code
Copy each file below into the right location as described in the Software Setup section. The MCP server code is the same for everyone. Only the credentials and paths in the Arduino sketch and the Claude config need to change.
Arduino Sketch — xiao_bme280_mqtt_led.ino
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
// ====== CONFIG: edit these three lines ======
const char* WIFI_SSID = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_BROKER = "XXXXXXXXXX"; // host PC's IP from ipconfig
// ============================================
const int MQTT_PORT = 1883;
const char* MQTT_CLIENT_ID = "xiao-esp32c3-bme280";
const char* MQTT_TOPIC_DATA = "home/sensor/bme280";
const char* MQTT_TOPIC_LED = "home/sensor/bme280/led";
const char* MQTT_TOPIC_LED_ST = "home/sensor/bme280/led/status";
const unsigned long PUBLISH_INTERVAL_MS = 5000;
#define LED_PIN 10
Adafruit_BME280 bme;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
unsigned long lastPublish = 0;
bool ledState = false;
void setLed(bool on) {
ledState = on;
digitalWrite(LED_PIN, on ? HIGH : LOW);
mqtt.publish(MQTT_TOPIC_LED_ST, on ? "ON" : "OFF", true);
Serial.printf("LED set to %s\n", on ? "ON" : "OFF");
}
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
msg.trim();
msg.toUpperCase();
Serial.printf("Received on %s: %s\n", topic, msg.c_str());
if (String(topic) == MQTT_TOPIC_LED) {
if (msg == "ON" || msg == "1" || msg == "TRUE") setLed(true);
else if (msg == "OFF" || msg == "0" || msg == "FALSE") setLed(false);
}
}
void connectWiFi() {
Serial.printf("Connecting to WiFi: %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.printf("\nWiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
}
void connectMQTT() {
while (!mqtt.connected()) {
Serial.printf("Connecting to MQTT %s:%d ... ", MQTT_BROKER, MQTT_PORT);
if (mqtt.connect(MQTT_CLIENT_ID)) {
Serial.println("connected!");
mqtt.subscribe(MQTT_TOPIC_LED);
Serial.printf("Subscribed to %s\n", MQTT_TOPIC_LED);
mqtt.publish(MQTT_TOPIC_LED_ST, ledState ? "ON" : "OFF", true);
} else {
Serial.printf("failed, rc=%d. Retry 3s\n", mqtt.state());
delay(3000);
}
}
}
void setup() {
Serial.begin(115200);
delay(3000);
Serial.println("=== Boot ===");
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
Wire.begin(6, 7); // explicit SDA=GPIO6 (D4), SCL=GPIO7 (D5)
delay(500);
// WiFi first, then sensor init, avoids brownout during calibration load
connectWiFi();
delay(1000);
if (!bme.begin(0x76)) {
if (!bme.begin(0x77)) {
Serial.println("BME280 not found!");
while (1) delay(1000);
}
}
bme.setSampling(
Adafruit_BME280::MODE_NORMAL,
Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::FILTER_OFF,
Adafruit_BME280::STANDBY_MS_1000
);
Serial.println("BME280 ready.");
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWiFi();
if (!mqtt.connected()) connectMQTT();
mqtt.loop();
unsigned long now = millis();
if (now - lastPublish >= PUBLISH_INTERVAL_MS) {
lastPublish = now;
float temp = bme.readTemperature();
float hum = bme.readHumidity();
float pres = bme.readPressure() / 100.0F;
if (isnan(temp) || isnan(hum) || isnan(pres) ||
temp < -40 || temp > 85 ||
hum < 0 || hum > 100 ||
pres < 300 || pres > 1100) {
Serial.printf("Bad reading skipped: T=%.2f H=%.2f P=%.2f\n", temp, hum, pres);
return;
}
char payload[160];
snprintf(payload, sizeof(payload),
"{\"temperature\":%.2f,\"humidity\":%.2f,\"pressure\":%.2f,\"led\":\"%s\",\"ts\":%lu}",
temp, hum, pres, ledState ? "ON" : "OFF", now);
if (mqtt.publish(MQTT_TOPIC_DATA, payload)) {
Serial.printf("Published: %s\n", payload);
} else {
Serial.println("Publish failed!");
}
}
}
MCP Server — index.js
Place this in C:\Users\YOURNAME\mqtt-mcp-server\index.js. Remember to also set "type": "module" in the adjacent package.json.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import mqtt from "mqtt";
import dotenv from "dotenv";
dotenv.config();
// MQTT topics to auto-subscribe to on startup
const FEEDS = [
"home/sensor/bme280",
"home/sensor/bme280/led",
"home/sensor/bme280/led/status",
];
const MAX_HISTORY = 100;
// ===== Always-on automation rules (independent of Claude) =====
// Each rule watches a topic and publishes a command when a threshold is crossed.
// Add more rules to the array as needed.
const AUTO_RULES = [
{
topic: "home/sensor/bme280",
field: "temperature",
threshold: 30,
ledTopic: "home/sensor/bme280/led"
}
];
// Track the last command published per LED topic, so we don't spam
// the broker with duplicate ON/ON/ON or OFF/OFF/OFF messages.
const lastAutoCommand = {};
const mqttOptions = {
host: process.env.MQTT_HOST,
port: parseInt(process.env.MQTT_PORT || "1883"),
};
if (process.env.MQTT_USERNAME) mqttOptions.username = process.env.MQTT_USERNAME;
if (process.env.MQTT_PASSWORD) mqttOptions.password = process.env.MQTT_PASSWORD;
const client = mqtt.connect(mqttOptions);
const topicValues = {};
const topicHistory = {};
client.on("connect", () => {
FEEDS.forEach(feed => {
client.subscribe(feed, err => {
if (err) console.error(`Failed: ${feed} -- ${err.message}`);
else console.error(`Subscribed: ${feed}`);
});
});
});
// Cache every incoming message, push to history, AND evaluate automation rules
client.on("message", (topic, message) => {
const entry = { value: message.toString(), timestamp: new Date().toISOString() };
topicValues[topic] = entry;
if (!topicHistory[topic]) topicHistory[topic] = [];
topicHistory[topic].push(entry);
if (topicHistory[topic].length > MAX_HISTORY) topicHistory[topic].shift();
// Evaluate automation rules for this topic
AUTO_RULES.forEach(rule => {
if (topic !== rule.topic) return;
try {
const data = JSON.parse(message.toString());
const value = data[rule.field];
if (typeof value !== "number") return;
const desired = value > rule.threshold ? "ON" : "OFF";
if (lastAutoCommand[rule.ledTopic] !== desired) {
client.publish(rule.ledTopic, desired);
lastAutoCommand[rule.ledTopic] = desired;
console.error(`[AUTO] ${rule.field}=${value} threshold=${rule.threshold} -> LED ${desired}`);
}
} catch (e) {
// not JSON or parse failed, skip silently
}
});
});
function publishToTopic(topic, value) {
return new Promise((resolve, reject) =>
client.publish(topic, String(value), { qos: 1 },
err => err ? reject(err) : resolve()));
}
const server = new McpServer({ name: "mqtt-generic", version: "1.2.0" });
server.tool("read_topic",
{ topic: z.string().describe("Full MQTT topic, e.g. home/sensor/bme280") },
async ({ topic }) => {
const data = topicValues[topic];
if (!data) return { content: [{ type: "text",
text: `No data yet for "${topic}". Device may not have published.` }] };
return { content: [{ type: "text",
text: `Topic: ${topic}\nValue: ${data.value}\nUpdated: ${data.timestamp}` }] };
}
);
server.tool("write_topic",
{ topic: z.string().describe("Topic to publish to"),
value: z.string().describe("Value to send e.g. 1, 0, ON, OFF, 75") },
async ({ topic, value }) => {
try {
await publishToTopic(topic, value);
return { content: [{ type: "text", text: `Published "${value}" to "${topic}"` }] };
} catch (err) {
return { content: [{ type: "text", text: `Publish failed: ${err.message}` }] };
}
}
);
server.tool("subscribe_topic",
{ topic: z.string().describe("New topic to start listening to") },
async ({ topic }) => new Promise(res =>
client.subscribe(topic, err => res({ content: [{ type: "text",
text: err ? `Error: ${err.message}` : `Now subscribed to "${topic}"` }] }))
)
);
server.tool("unsubscribe_topic",
{ topic: z.string().describe("Topic to stop listening to") },
async ({ topic }) => new Promise(res =>
client.unsubscribe(topic, err => {
if (!err) { delete topicValues[topic]; delete topicHistory[topic]; }
res({ content: [{ type: "text",
text: err ? `Error: ${err.message}` : `Unsubscribed from "${topic}"` }] });
})
)
);
server.tool("list_topics", {}, async () => {
const keys = Object.keys(topicValues);
if (!keys.length) return { content: [{ type: "text", text: "No topic data received yet." }] };
const list = keys.map((t, i) => {
const hist = topicHistory[t] ? topicHistory[t].length : 0;
return `${i+1}. ${t}\n Value: ${topicValues[t].value}\n At: ${topicValues[t].timestamp}\n History size: ${hist}`;
}).join("\n\n");
return { content: [{ type: "text", text: `Active topics (${keys.length} total):\n\n${list}` }] };
});
server.tool("read_history",
{ topic: z.string().describe("Topic to get history for"),
count: z.number().optional().describe("How many recent readings (default 20, max 100)") },
async ({ topic, count }) => {
const history = topicHistory[topic];
if (!history || history.length === 0) {
return { content: [{ type: "text",
text: `No history yet for "${topic}". Wait a bit for readings to accumulate.` }] };
}
const n = Math.min(count || 20, history.length);
const slice = history.slice(-n);
return { content: [{ type: "text",
text: `Last ${n} of ${history.length} readings for ${topic}:\n${JSON.stringify(slice, null, 2)}` }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Claude Desktop Config — claude_desktop_config.json
Place this at %APPDATA%\Claude\claude_desktop_config.json. Replace YOURNAME with your Windows username and put your host PC's IP into MQTT_HOST.
{
"mcpServers": {
"mqtt": {
"command": "C:\\Program Files\\nodejs\\node.exe",
"args": ["C:\\Users\\YOURNAME\\mqtt-mcp-server\\index.js"],
"env": {
"MQTT_HOST": "XXXXXXXXXXX",
"MQTT_PORT": "1883"
}
}
}
}
07 Code Breakdown
Here's what each part of the system does. Read through this after uploading so you understand how data flows from the sensor all the way to Claude.
Arduino Libraries
| Library | Purpose |
|---|---|
| WiFi.h | Built-in ESP32 Wi-Fi stack. Manages the connection to the hotspot. |
| PubSubClient.h | Lightweight MQTT client by Nick O'Leary. Handles publish, subscribe, and callback handling. |
| Wire.h | Arduino's built-in I2C library. Talks to the BME280 on the I2C bus. |
| Adafruit_Sensor.h | Adafruit's unified sensor abstraction. A dependency of the BME280 library. |
| Adafruit_BME280.h | Driver for the BME280 sensor. Reads calibration registers and gives back converted temperature, humidity, and pressure values. |
Key Arduino Functions
setup()
Runs once at boot. Starts Serial, configures the LED pin, kicks off I2C with explicit pin assignment (Wire.begin(6, 7)), connects to Wi-Fi, then initializes the BME280. Wi-Fi gets connected before the sensor on purpose. The radio causes a brief current spike that can brown out a marginally powered ESP32, so doing it first means the sensor reads its calibration data under stable conditions. The sensor is then set up for continuous measurement with setSampling(), and the MQTT client is told which broker to use and which callback to fire when a message arrives.
loop()
Runs continuously. Keeps the Wi-Fi and MQTT connections alive, then calls mqtt.loop() to process any incoming messages on subscribed topics. Every 5 seconds it reads the BME280, runs a quick sanity check to throw out impossible values, builds a JSON payload, and publishes it to home/sensor/bme280.
mqttCallback()
Fires whenever an MQTT message lands on a subscribed topic. Converts the byte payload to an uppercase string and, if the topic is the LED command topic, flips the LED on or off. This is the function that closes the loop between Claude and the physical world.
setLed()
Helper that writes the GPIO, updates the cached state variable, and immediately publishes the new state back to home/sensor/bme280/led/status with the retained flag set. The retained flag tells the broker to keep the last published value around, so any new subscriber sees the current state right away instead of waiting for the next change.
connectWiFi() and connectMQTT()
Standard reconnect loops. They block until the connection succeeds, printing dots while they wait. connectMQTT() also re-subscribes to the LED command topic on every reconnect, so the device doesn't go deaf to commands after dropping and coming back.
MCP Server Components
| Component | Purpose |
|---|---|
| FEEDS array | List of topics the server auto-subscribes to on startup. Includes the sensor data topic and both LED topics. |
| topicValues / topicHistory | In-memory caches. topicValues holds the latest message per topic; topicHistory holds a rolling buffer of up to 100 readings per topic. |
| AUTO_RULES array | List of always-on automation rules. Each rule specifies a topic to watch, the JSON field to check, a threshold, and the LED topic to publish to. You can add more rules without touching the rest of the code. |
| lastAutoCommand | Tracks the last command published per LED topic so the server doesn't keep republishing the same ON or OFF on every reading. Without this, the broker would get a duplicate message every 5 seconds. |
| client.on("message", ...) | Runs for every incoming MQTT message. Updates the latest-value cache, pushes the reading onto the history buffer, then checks every applicable automation rule. If a rule's condition flipped since last time, the server publishes the new LED command. |
| read_topic tool | Returns the latest cached value for a given topic. Claude calls this to answer "what is the temperature right now?" |
| write_topic tool | Publishes a value to a topic. This is what Claude calls to control the LED. |
| subscribe_topic / unsubscribe_topic tools | Let Claude add or remove topics at runtime without restarting the server. |
| list_topics tool | Returns a summary of every active topic, its latest value, and how many readings are in its history buffer. |
| read_history tool | Returns up to N recent readings for a topic as a JSON array. Claude uses this to generate charts and analyze trends. |
| StdioServerTransport | The MCP transport. Claude Desktop launches this Node process and talks to it over stdin/stdout using JSON-RPC. |
General System Workflow
Every message from the XIAO triggers two parallel paths inside the MCP server: one for AI-driven interaction (Claude) and one for always-on rule-based automation. Both end up controlling the same LED topic.
- The XIAO ESP32-C3 boots, connects to Wi-Fi, initializes the BME280, and connects to the Mosquitto broker.
- Every 5 seconds the XIAO reads the sensor, filters out garbage values, and publishes a JSON payload to
home/sensor/bme280. - Mosquitto receives the payload and forwards it to every subscriber.
- The MCP server, as a subscriber, caches the latest value and appends it to a rolling history buffer.
- The MCP server also checks every entry in the
AUTO_RULESarray against the new reading. If a threshold has been crossed since the last reading, it publishes the matching LED command directly to the broker. This happens whether Claude is being used or not. - Separately, when you ask Claude a question, it picks which tool to call. For a status query it calls
read_topic; for a chart it callsread_history; for manual control it callswrite_topic. - Whenever a command lands on the LED topic, whether from an automation rule or from Claude, the XIAO's callback fires, flips the LED, and reports the new state back to the broker.
- Claude reads the result of the tool call and answers you in natural language.
08 Testing and Calibration
After uploading the sketch and configuring Claude, check each of these to make sure the system is actually working. Test from the bottom up: sensor first, then MQTT, then MCP, then Claude. It's a lot easier to debug when you know which layer is failing.
Sensor Readings
Open Arduino IDE → Serial Monitor at 115200 baud and press the reset button on the XIAO. You should see BME280 ready. followed by Published: lines every 5 seconds with reasonable values (around 25-30 °C, 40-80 % humidity, ~1010 hPa). If the readings are NaN, way out of range, or stuck on the same numbers for a long time, re-seat the BME280 wiring and check the USB cable for power issues.
MQTT Broker Reception
Open a Command Prompt on the host PC and subscribe to the topic from the broker side. Replace 172.20.10.3 with your host PC's actual IP.
"C:\Program Files\mosquitto\mosquitto_sub.exe" -h 172.20.10.3 -t "home/sensor/bme280" -v
You should see live JSON payloads arriving every 5 seconds. This proves the messages are making it all the way from the ESP32 into Mosquitto.
Network Connectivity
If the ESP32 connects to Wi-Fi but fails MQTT with rc=-2, run this on the host PC:
netstat -an | findstr :1883
A line with 0.0.0.0:1883 ... LISTENING means Mosquitto is up. An extra line ending in ESTABLISHED with the XIAO's IP means the ESP32 is connected. If you only see LISTENING and no ESTABLISHED line, double-check that the MQTT_BROKER constant in the sketch matches the host PC's current IP exactly.
MCP Server and Claude Tools
In Claude Desktop, click the tools icon in the chat input area. You should see the mqtt server with 6 tools listed. If it's missing, check the MCP server log at %APPDATA%\Claude\logs\mcp.log. The usual suspects are a wrong path in claude_desktop_config.json, missing "type": "module" in package.json, or the broker being unreachable when the MCP server started.
End-to-End AI Query
In a new Claude chat, ask: "What is the current temperature?" Claude should call read_topic, return a fresh reading that matches the latest Published: line in the Arduino Serial Monitor, and explain it in plain English. Allow the tool call when Claude asks for permission.
LED Control
First verify the control path from the command line, which takes Claude out of the picture:
"C:\Program Files\mosquitto\mosquitto_pub.exe" -h 172.20.10.3 -t "home/sensor/bme280/led" -m "ON"
"C:\Program Files\mosquitto\mosquitto_pub.exe" -h 172.20.10.3 -t "home/sensor/bme280/led" -m "OFF"
The Serial Monitor should show Received on home/sensor/bme280/led: ON followed by LED set to ON, and the LED itself should respond. Once that works, try it through Claude by asking: "Turn on the LED by publishing ON to home/sensor/bme280/led."
09 System Demonstration
Video Demonstration
Direct Status Queries
- "What is the current temperature?"
- "What is the humidity right now?"
- "What is the air pressure?"
- "Show me all the active topics and their latest values."
Claude calls read_topic or list_topics and gives you the live reading.
Interpretive Questions
- "Is the room too hot?"
- "Is the humidity comfortable?"
- "Is the air pressure normal for sea level?"
- "Explain the sensor readings."
Claude reads the values and actually reasons about what they mean instead of just giving you back a number.
Historical and Visual Analysis
- "Get the last 100 readings and chart temperature, humidity, and pressure over time."
- "Is the temperature trending up or down over the last few minutes?"
- "How fresh is the latest reading?"
Claude calls read_history, gets back an array of cached readings, and either summarizes the trend or drops a chart artifact straight into the chat.
Closed-Loop Automation
- "Turn on the LED by publishing ON to home/sensor/bme280/led."
- "Check the temperature; if it's above 27°C, turn on the LED as a heat warning, otherwise turn it off."
- "Toggle the LED."
Claude calls write_topic, Mosquitto forwards the message to the XIAO, and the LED responds. That's the full monitoring-and-automation pipeline working end to end.
Automated Response Testing
To figure out where the AI-driven automation actually breaks down, the system was tested under two conditions using the rule: "If temperature > 27°C, turn the LED ON; otherwise, turn it OFF." The goal was to see whether automation behaves the same when Claude is actively chatting versus when the chat is open but idle.
Test 1 — Chat open, Claude actively monitoring
Setup: The BME280 was publishing sensor data normally, and the LED on GPIO 10 was wired and confirmed working. Claude got this prompt:
Please monitor the temperature on home/sensor/bme280. Check the reading every
15 seconds for the next 2 minutes. If the temperature goes above 27°C, publish
"ON" to home/sensor/bme280/led. If it drops back below 27°C, publish "OFF".
Tell me each time you check and what you do.
Procedure: While Claude was monitoring, the BME280 was warmed by cupping it in a hand for about 30 seconds to push the temperature past 27°C, then released to let it cool back down.
Result: Claude called read_topic at every 15-second interval, checked the temperature against the threshold, and called write_topic whenever the threshold was crossed. When the temperature went above 27°C the LED turned ON; when it dropped back below 27°C the LED turned OFF on the next check. The cycle worked for the full 2-minute window, and then Claude reported that monitoring was complete and stopped.
Conclusion: Inside an active conversational turn, the AI-driven control loop works as expected. Claude reads, decides, and acts on sensor data on its own based on a plain-English rule.
Test 2 — Chat open, but Claude is idle
Setup: After Test 1 ended, the Claude chat window was left open. No new prompts were sent. The BME280 kept publishing normally, which was confirmed by watching live JSON readings come in through a terminal running mosquitto_sub -h 172.20.10.3 -t home/sensor/bme280 -v.
Procedure: The BME280 was warmed up again until the published temperature went past 27°C, confirmed in both the Arduino Serial Monitor and mosquitto_sub.
Result: The LED did not turn on, even though the threshold was clearly crossed and the sensor data was still flowing through the broker. The chat window stayed idle, and Claude didn't make any tool calls.
Conclusion: Claude doesn't poll in the background between conversational turns. Once a response is done, no more tool calls happen until you send a new message. The AI-driven automation only exists while Claude is actively reasoning inside a response.
Test 3 — Always-on rule inside the MCP server
Setup: To work around the idle-chat issue from Test 2, an automation rule was added straight into the MCP server's AUTO_RULES array: "For topic home/sensor/bme280, if temperature > 30°C, publish ON to home/sensor/bme280/led; otherwise, publish OFF." The MCP server now checks this rule against every incoming sensor reading. Claude was not given any prompt.
Procedure: With no active conversation and no Claude monitoring prompt, the BME280 was warmed past 30°C and then allowed to cool. The MCP server log was watched alongside the LED.
Result: The LED turned ON within one publish cycle of the temperature crossing 30°C, and OFF within one cycle of dropping back below. The MCP server log printed lines like [AUTO] temperature=30.42 threshold=30 -> LED ON confirming the rule fired. Manual control through Claude (asking "turn off the LED") still worked, but the automation rule would override it on the next sensor reading if the condition still applied.
Conclusion: Always-on automation works fine when you embed the rule directly in the MCP server. The catch is that this path is not AI-driven. It's deterministic hardcoded logic, basically a glorified thermostat. The AI is no longer in the decision loop for Test 3. This trade-off is on purpose: the system uses AI reasoning during chat sessions (Test 1) and deterministic rules for always-on operation (Test 3), so you get both flexibility and reliability, just split across two paths.
Summary of Findings
| Test | Decision Maker | Works When Chat Is Idle? | AI in the Loop? |
|---|---|---|---|
| 1 — Claude actively monitoring in a chat turn | Claude (natural-language rule) | No | Yes: Claude reads, decides, and acts |
| 2 — Claude idle, sensor crosses threshold | None: nothing's actively running | No | No: Claude doesn't poll between turns |
| 3 — AUTO_RULES rule inside MCP server | Deterministic threshold check | Yes | No: the rule is hardcoded logic |
The three tests together lay out the design space honestly. Test 1 shows what AI-driven automation looks like: flexible, conversational, and reasoned, but only active during a chat turn. Test 2 shows the natural limit of that approach: Claude doesn't poll in the background, so AI-driven automation is reactive, not autonomous. Test 3 closes the always-on gap with a small deterministic rule engine inside the MCP server, but it does that by taking AI out of the decision loop. That rule is just conventional IoT automation, not AI reasoning.
A natural next step would be a hybrid setup: let the user describe a rule to Claude in plain English ("watch temperature, turn the LED on above 30 °C"), have Claude write that rule into the MCP server's config, and let the deterministic engine run it 24/7. That puts AI in the design loop while keeping deterministic code in the execution loop, which is roughly how most real-world AI-assisted automation systems are built.
10 Conclusion
This project shows a full real-time environmental monitoring and automation system you can run with natural language. The XIAO ESP32-C3 reliably reads data from the BME280 and publishes it over MQTT to a Mosquitto broker, which covers the basic goal of capturing live sensor data and getting it onto the network. Bridging the broker to Claude through a custom MCP server lets you ask plain-English questions about the live data and get interpreted answers back, and to send plain-English commands that cause physical changes, closing the loop between sensing, reasoning, and action without any external API keys or cloud services. It's been tested on multiple laptops and across both wired and hotspot networks, so it's reproducible rather than tied to one specific machine.
Possible Improvements and Future Enhancements
- Add a soil moisture sensor to directly answer questions about plant watering.
- Swap the indicator LED for a relay module to switch real appliances like a fan or lamp.
- Move the broker to a cloud MQTT service (HiveMQ Cloud, EMQX Cloud) so you can access it from different networks instead of needing a shared local Wi-Fi.
- Persist sensor readings to a time-series database like SQLite or InfluxDB so the history survives MCP server restarts and supports longer-term analysis.
- Add more environmental sensors (CO₂, particulate matter, light) and expose each as its own MQTT topic.
- Build scheduled or threshold-based automations directly into the MCP server, with Claude as the natural-language configuration interface.
- Add a Telegram or web chat front-end so you can query the system from your phone instead of the desktop.
11 References
- Arduino IDE Documentation, arduino.cc
- Espressif Systems, ESP32-C3 Documentation
- Seeed Studio, XIAO ESP32-C3 Wiki
- Bosch Sensortec, BME280 Datasheet
- Eclipse Mosquitto, mosquitto.org
- Adafruit BME280 Library, github.com/adafruit/Adafruit_BME280_Library
- PubSubClient by Nick O'Leary, github.com/knolleary/pubsubclient
- Model Context Protocol, modelcontextprotocol.io
- MCP TypeScript SDK, github.com/modelcontextprotocol/typescript-sdk
- MQTT.js, github.com/mqttjs/MQTT.js
- Anthropic, Claude Desktop documentation
- Node.js Documentation, nodejs.org
12 Project Authors
- Keen Montilla
- Ben Sumauang
