guesty-mcp-server 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/package.json +13 -2
- package/server.json +3 -3
- package/src/http-server.js +8 -2
- package/src/iot-db.js +294 -0
- package/src/iot-tools.js +471 -0
- package/src/iot-webhook.js +364 -0
- package/src/server.js +7 -1
- package/tests/test-iot.js +108 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Guesty MCP Server will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-04-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **IoT/Property Health Monitoring** (Enterprise tier)
|
|
9
|
+
- `get_property_health` — Real-time device status for any property
|
|
10
|
+
- `submit_checkout_photos` — Photo submission for post-checkout analysis
|
|
11
|
+
- `get_maintenance_alerts` — Active IoT alerts filtered by property/severity
|
|
12
|
+
- `get_readiness_score` — 0-100 Physical Readiness Score with 6 weighted checks
|
|
13
|
+
- **IoT Webhook Receiver** (`POST /webhooks/iot`)
|
|
14
|
+
- Supports Tuya, Google Nest, SmartThings, and generic payloads
|
|
15
|
+
- Auto-normalizes all formats to standard schema
|
|
16
|
+
- Auto-creates alerts for out-of-range readings
|
|
17
|
+
- **IoT Data Layer** (`iot-db.js`)
|
|
18
|
+
- Zero-dependency JSON file store for devices, readings, alerts, baselines
|
|
19
|
+
- Auto-pruning at 50K readings and 10K alerts
|
|
20
|
+
- Tool count: 38 → 42 (4 new Enterprise-tier tools)
|
|
21
|
+
|
|
5
22
|
## [0.6.0] - 2026-04-10
|
|
6
23
|
|
|
7
24
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guesty-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The first MCP server for Guesty property management. 38 tools for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, and webhooks.",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -14,11 +14,22 @@
|
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"mcp",
|
|
17
|
+
"mcp-server",
|
|
18
|
+
"model-context-protocol",
|
|
17
19
|
"guesty",
|
|
18
20
|
"property-management",
|
|
19
21
|
"short-term-rental",
|
|
20
22
|
"airbnb",
|
|
21
|
-
"
|
|
23
|
+
"vrbo",
|
|
24
|
+
"vacation-rental",
|
|
25
|
+
"ai-agent",
|
|
26
|
+
"claude",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"openclaw",
|
|
29
|
+
"pms",
|
|
30
|
+
"hospitality",
|
|
31
|
+
"real-estate",
|
|
32
|
+
"automation"
|
|
22
33
|
],
|
|
23
34
|
"author": "DLJ Properties",
|
|
24
35
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.DLJRealty/guesty",
|
|
4
4
|
"title": "Guesty MCP Server",
|
|
5
|
-
"description": "
|
|
6
|
-
"version": "0.
|
|
5
|
+
"description": "MCP server for Guesty property management — 38 tools for STR operations.",
|
|
6
|
+
"version": "0.7.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/DLJRealty/guesty-mcp-server",
|
|
9
9
|
"source": "github"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"packages": [{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "guesty-mcp-server",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.7.0",
|
|
15
15
|
"transport": { "type": "stdio" },
|
|
16
16
|
"environmentVariables": [
|
|
17
17
|
{
|
package/src/http-server.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import express from "express";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
|
+
import iotRouter from "./iot-webhook.js";
|
|
9
|
+
import { initDB } from "./iot-db.js";
|
|
8
10
|
|
|
9
11
|
const app = express();
|
|
10
12
|
app.use(express.json());
|
|
11
13
|
|
|
14
|
+
// Initialize IoT database and mount webhook routes
|
|
15
|
+
initDB();
|
|
16
|
+
app.use(iotRouter);
|
|
17
|
+
|
|
12
18
|
const PORT = process.env.PORT || 3001;
|
|
13
19
|
|
|
14
20
|
// Request counter
|
|
@@ -23,8 +29,8 @@ app.use((req, res, next) => {
|
|
|
23
29
|
// Server info
|
|
24
30
|
const SERVER_INFO = {
|
|
25
31
|
name: "guesty-mcp-server",
|
|
26
|
-
version: "0.
|
|
27
|
-
description: "
|
|
32
|
+
version: "0.7.0",
|
|
33
|
+
description: "MCP server for Guesty property management. 42 tools including IoT monitoring, property health scores, and checkout photo analysis.",
|
|
28
34
|
capabilities: {
|
|
29
35
|
tools: { listChanged: false },
|
|
30
36
|
resources: { listChanged: false }
|
package/src/iot-db.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IoT Device Data Layer — Guesty MCP Server Enterprise
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency JSON file-based store for IoT device readings,
|
|
5
|
+
* alerts, and property baselines.
|
|
6
|
+
*
|
|
7
|
+
* Storage: ~/.dlj-scripts/data/iot-devices.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
|
|
15
|
+
const DATA_DIR = join(homedir(), ".dlj-scripts", "data");
|
|
16
|
+
const DB_PATH = join(DATA_DIR, "iot-devices.json");
|
|
17
|
+
|
|
18
|
+
// ── Schema shape ───────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function emptyDB() {
|
|
21
|
+
return {
|
|
22
|
+
devices: {}, // keyed by device_id
|
|
23
|
+
readings: [], // append-only log
|
|
24
|
+
alerts: [], // append-only log
|
|
25
|
+
baselines: {}, // keyed by property_id
|
|
26
|
+
_meta: {
|
|
27
|
+
version: 1,
|
|
28
|
+
created_at: new Date().toISOString(),
|
|
29
|
+
last_write: new Date().toISOString()
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Persistence helpers ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
let _db = null;
|
|
37
|
+
|
|
38
|
+
function load() {
|
|
39
|
+
if (_db) return _db;
|
|
40
|
+
if (!existsSync(DATA_DIR)) {
|
|
41
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(DB_PATH)) {
|
|
44
|
+
try {
|
|
45
|
+
_db = JSON.parse(readFileSync(DB_PATH, "utf-8"));
|
|
46
|
+
// Ensure all top-level keys exist (forward compat)
|
|
47
|
+
const empty = emptyDB();
|
|
48
|
+
for (const key of Object.keys(empty)) {
|
|
49
|
+
if (!(key in _db)) _db[key] = empty[key];
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
console.error("[iot-db] Corrupt JSON store — reinitializing");
|
|
53
|
+
_db = emptyDB();
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
_db = emptyDB();
|
|
57
|
+
}
|
|
58
|
+
return _db;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function flush() {
|
|
62
|
+
const db = load();
|
|
63
|
+
db._meta.last_write = new Date().toISOString();
|
|
64
|
+
writeFileSync(DB_PATH, JSON.stringify(db, null, 2), "utf-8");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initialise the database (creates the file if absent).
|
|
71
|
+
* Safe to call multiple times.
|
|
72
|
+
*/
|
|
73
|
+
export function initDB() {
|
|
74
|
+
load();
|
|
75
|
+
flush();
|
|
76
|
+
console.log(`[iot-db] Initialized at ${DB_PATH}`);
|
|
77
|
+
return DB_PATH;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Devices ────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const VALID_DEVICE_TYPES = [
|
|
83
|
+
"temp_sensor",
|
|
84
|
+
"humidity_sensor",
|
|
85
|
+
"leak_sensor",
|
|
86
|
+
"smart_lock",
|
|
87
|
+
"motion_sensor"
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Register or update a device. Automatically called on first reading
|
|
92
|
+
* if the device isn't already known.
|
|
93
|
+
*/
|
|
94
|
+
export function upsertDevice({ device_id, device_type, property_id, location }) {
|
|
95
|
+
const db = load();
|
|
96
|
+
const existing = db.devices[device_id];
|
|
97
|
+
db.devices[device_id] = {
|
|
98
|
+
device_id,
|
|
99
|
+
device_type: VALID_DEVICE_TYPES.includes(device_type) ? device_type : (existing?.device_type || "temp_sensor"),
|
|
100
|
+
property_id: property_id || existing?.property_id || null,
|
|
101
|
+
location: location || existing?.location || null,
|
|
102
|
+
installed_at: existing?.installed_at || new Date().toISOString(),
|
|
103
|
+
last_seen: new Date().toISOString()
|
|
104
|
+
};
|
|
105
|
+
flush();
|
|
106
|
+
return db.devices[device_id];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Readings ───────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const VALID_READING_TYPES = [
|
|
112
|
+
"temperature",
|
|
113
|
+
"humidity",
|
|
114
|
+
"leak_detected",
|
|
115
|
+
"lock_status",
|
|
116
|
+
"motion"
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Persist a normalised reading. Returns the stored record.
|
|
121
|
+
*/
|
|
122
|
+
export function saveReading({ device_id, reading_type, value, raw_payload }) {
|
|
123
|
+
const db = load();
|
|
124
|
+
|
|
125
|
+
if (!VALID_READING_TYPES.includes(reading_type)) {
|
|
126
|
+
throw new Error(`Invalid reading_type: ${reading_type}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const record = {
|
|
130
|
+
id: randomUUID(),
|
|
131
|
+
device_id,
|
|
132
|
+
reading_type,
|
|
133
|
+
value: String(value),
|
|
134
|
+
timestamp: new Date().toISOString(),
|
|
135
|
+
raw_payload: raw_payload || null
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
db.readings.push(record);
|
|
139
|
+
|
|
140
|
+
// Update device last_seen
|
|
141
|
+
if (db.devices[device_id]) {
|
|
142
|
+
db.devices[device_id].last_seen = record.timestamp;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Keep readings bounded (last 50 000)
|
|
146
|
+
if (db.readings.length > 50000) {
|
|
147
|
+
db.readings = db.readings.slice(-40000);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
flush();
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Return the most recent reading per device for a given property.
|
|
156
|
+
* If no propertyId, returns all.
|
|
157
|
+
*/
|
|
158
|
+
export function getLatestReadings(propertyId) {
|
|
159
|
+
const db = load();
|
|
160
|
+
|
|
161
|
+
// Find devices for this property
|
|
162
|
+
const deviceIds = propertyId
|
|
163
|
+
? Object.values(db.devices)
|
|
164
|
+
.filter(d => d.property_id === propertyId)
|
|
165
|
+
.map(d => d.device_id)
|
|
166
|
+
: Object.keys(db.devices);
|
|
167
|
+
|
|
168
|
+
const latest = {};
|
|
169
|
+
// Walk backwards for efficiency
|
|
170
|
+
for (let i = db.readings.length - 1; i >= 0; i--) {
|
|
171
|
+
const r = db.readings[i];
|
|
172
|
+
if (!deviceIds.includes(r.device_id)) continue;
|
|
173
|
+
const key = `${r.device_id}:${r.reading_type}`;
|
|
174
|
+
if (!latest[key]) {
|
|
175
|
+
latest[key] = r;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return Object.values(latest);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Alerts ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const VALID_ALERT_TYPES = [
|
|
185
|
+
"leak",
|
|
186
|
+
"temp_out_of_range",
|
|
187
|
+
"lock_offline",
|
|
188
|
+
"motion_unusual"
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const VALID_SEVERITIES = ["critical", "warning", "info"];
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create and persist an alert.
|
|
195
|
+
*/
|
|
196
|
+
export function saveAlert({ device_id, property_id, alert_type, severity, message }) {
|
|
197
|
+
const db = load();
|
|
198
|
+
|
|
199
|
+
const record = {
|
|
200
|
+
id: randomUUID(),
|
|
201
|
+
device_id,
|
|
202
|
+
property_id: property_id || db.devices[device_id]?.property_id || null,
|
|
203
|
+
alert_type: VALID_ALERT_TYPES.includes(alert_type) ? alert_type : "leak",
|
|
204
|
+
severity: VALID_SEVERITIES.includes(severity) ? severity : "warning",
|
|
205
|
+
message: message || "",
|
|
206
|
+
created_at: new Date().toISOString(),
|
|
207
|
+
resolved_at: null,
|
|
208
|
+
resolved_by: null
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
db.alerts.push(record);
|
|
212
|
+
|
|
213
|
+
// Keep alerts bounded (last 10 000)
|
|
214
|
+
if (db.alerts.length > 10000) {
|
|
215
|
+
db.alerts = db.alerts.slice(-8000);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
flush();
|
|
219
|
+
return record;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Retrieve alerts for a property. Defaults to unresolved only.
|
|
224
|
+
*/
|
|
225
|
+
export function getAlerts(propertyId, { includeResolved = false } = {}) {
|
|
226
|
+
const db = load();
|
|
227
|
+
return db.alerts.filter(a => {
|
|
228
|
+
if (propertyId && a.property_id !== propertyId) return false;
|
|
229
|
+
if (!includeResolved && a.resolved_at) return false;
|
|
230
|
+
return true;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Mark an alert as resolved.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveAlert(alertId, resolvedBy = "system") {
|
|
238
|
+
const db = load();
|
|
239
|
+
const alert = db.alerts.find(a => a.id === alertId);
|
|
240
|
+
if (!alert) return null;
|
|
241
|
+
alert.resolved_at = new Date().toISOString();
|
|
242
|
+
alert.resolved_by = resolvedBy;
|
|
243
|
+
flush();
|
|
244
|
+
return alert;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Baselines ──────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Save a property condition baseline (photos + notes).
|
|
251
|
+
*/
|
|
252
|
+
export function saveBaseline({ property_id, photo_urls, notes }) {
|
|
253
|
+
const db = load();
|
|
254
|
+
db.baselines[property_id] = {
|
|
255
|
+
property_id,
|
|
256
|
+
photo_urls: Array.isArray(photo_urls) ? photo_urls : [],
|
|
257
|
+
created_at: new Date().toISOString(),
|
|
258
|
+
notes: notes || ""
|
|
259
|
+
};
|
|
260
|
+
flush();
|
|
261
|
+
return db.baselines[property_id];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Retrieve the current baseline for a property.
|
|
266
|
+
*/
|
|
267
|
+
export function getBaseline(propertyId) {
|
|
268
|
+
const db = load();
|
|
269
|
+
return db.baselines[propertyId] || null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Save checkout photos for a property/reservation.
|
|
274
|
+
* Returns a submission object with a unique ID.
|
|
275
|
+
*/
|
|
276
|
+
export function savePhotos({ property_id, reservation_id, photos }) {
|
|
277
|
+
const db = load();
|
|
278
|
+
if (!db.photo_submissions) db.photo_submissions = [];
|
|
279
|
+
const submission = {
|
|
280
|
+
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
281
|
+
property_id,
|
|
282
|
+
reservation_id: reservation_id || null,
|
|
283
|
+
photo_urls: Array.isArray(photos) ? photos : [],
|
|
284
|
+
submitted_at: new Date().toISOString(),
|
|
285
|
+
analysis_status: "pending"
|
|
286
|
+
};
|
|
287
|
+
db.photo_submissions.push(submission);
|
|
288
|
+
// Prune old submissions (keep last 1000)
|
|
289
|
+
if (db.photo_submissions.length > 1000) {
|
|
290
|
+
db.photo_submissions = db.photo_submissions.slice(-1000);
|
|
291
|
+
}
|
|
292
|
+
flush();
|
|
293
|
+
return submission;
|
|
294
|
+
}
|
package/src/iot-tools.js
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guesty MCP Server — IoT / Property Health Monitoring Tools
|
|
3
|
+
*
|
|
4
|
+
* Enterprise-tier gated tools for IoT device monitoring,
|
|
5
|
+
* checkout photo submission, maintenance alerts, and
|
|
6
|
+
* physical readiness scoring.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1: Core data collection and scoring.
|
|
9
|
+
* Phase 2: Vision-based photo comparison (planned).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { getTier, gatedHandler } from "./license.js";
|
|
14
|
+
import {
|
|
15
|
+
getLatestReadings,
|
|
16
|
+
getAlerts,
|
|
17
|
+
saveBaseline,
|
|
18
|
+
getBaseline,
|
|
19
|
+
savePhotos,
|
|
20
|
+
} from "./iot-db.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Enterprise-tier gate. Returns an error response if the current
|
|
24
|
+
* license is not enterprise. Wraps the inner handler otherwise.
|
|
25
|
+
*/
|
|
26
|
+
function enterpriseGated(toolName, handler) {
|
|
27
|
+
return async (params) => {
|
|
28
|
+
const tier = getTier();
|
|
29
|
+
if (tier !== "enterprise") {
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text:
|
|
35
|
+
`This tool (${toolName}) requires an Enterprise license. ` +
|
|
36
|
+
`Your current tier is "${tier}". ` +
|
|
37
|
+
"IoT monitoring, property health, and readiness scoring are Enterprise-only features. " +
|
|
38
|
+
"Upgrade at https://guestycopilot.com/pricing -- " +
|
|
39
|
+
"Set GUESTY_MCP_LICENSE_KEY with an Enterprise key (gmcp_ent_*) to unlock.",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return handler(params);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Derive an overall status from a set of device readings.
|
|
51
|
+
* - "critical" if ANY device has a critical alert
|
|
52
|
+
* - "warning" if ANY device has a warning alert
|
|
53
|
+
* - "healthy" otherwise
|
|
54
|
+
*/
|
|
55
|
+
function deriveOverallStatus(devices) {
|
|
56
|
+
let status = "healthy";
|
|
57
|
+
for (const d of devices) {
|
|
58
|
+
if (d.alert_level === "critical") return "critical";
|
|
59
|
+
if (d.alert_level === "warning") status = "warning";
|
|
60
|
+
}
|
|
61
|
+
return status;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Register all 4 IoT / Property Health tools on the given MCP server.
|
|
66
|
+
*
|
|
67
|
+
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
68
|
+
*/
|
|
69
|
+
export function registerIoTTools(server) {
|
|
70
|
+
// ── Tool 1: get_property_health ─────────────────────────────────
|
|
71
|
+
server.tool(
|
|
72
|
+
"get_property_health",
|
|
73
|
+
"Get the current health status of all IoT devices at a property. Returns latest readings for every device (temperature, humidity, leak, lock, etc.) and an overall property status.",
|
|
74
|
+
{
|
|
75
|
+
listingId: z.string().describe("The Guesty listing ID for the property"),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
readOnlyHint: true,
|
|
79
|
+
destructiveHint: false,
|
|
80
|
+
idempotentHint: true,
|
|
81
|
+
openWorldHint: false,
|
|
82
|
+
},
|
|
83
|
+
enterpriseGated("get_property_health", async (params) => {
|
|
84
|
+
try {
|
|
85
|
+
const readings = await getLatestReadings(params.listingId);
|
|
86
|
+
const devices = (readings || []).map((r) => ({
|
|
87
|
+
device_id: r.device_id,
|
|
88
|
+
type: r.type,
|
|
89
|
+
location: r.location,
|
|
90
|
+
last_reading: r.last_reading,
|
|
91
|
+
last_seen: r.last_seen,
|
|
92
|
+
alert_level: r.alert_level || null,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
const overallStatus = deriveOverallStatus(devices);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
property_id: params.listingId,
|
|
104
|
+
devices,
|
|
105
|
+
overall_status: overallStatus,
|
|
106
|
+
},
|
|
107
|
+
null,
|
|
108
|
+
2
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: "text",
|
|
118
|
+
text: JSON.stringify(
|
|
119
|
+
{
|
|
120
|
+
error: "Failed to retrieve property health data.",
|
|
121
|
+
details: e.message,
|
|
122
|
+
},
|
|
123
|
+
null,
|
|
124
|
+
2
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
isError: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// ── Tool 2: submit_checkout_photos ──────────────────────────────
|
|
135
|
+
server.tool(
|
|
136
|
+
"submit_checkout_photos",
|
|
137
|
+
"Submit post-checkout photos for a property. Stores photo URLs/paths with reservation metadata for later comparison against baseline images. (Vision analysis is Phase 2.)",
|
|
138
|
+
{
|
|
139
|
+
listingId: z
|
|
140
|
+
.string()
|
|
141
|
+
.describe("The Guesty listing ID for the property"),
|
|
142
|
+
reservationId: z
|
|
143
|
+
.string()
|
|
144
|
+
.describe("The reservation ID this checkout is for"),
|
|
145
|
+
photos: z
|
|
146
|
+
.array(z.string())
|
|
147
|
+
.describe("Array of photo URLs or file paths to submit"),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
readOnlyHint: false,
|
|
151
|
+
destructiveHint: false,
|
|
152
|
+
idempotentHint: false,
|
|
153
|
+
openWorldHint: false,
|
|
154
|
+
},
|
|
155
|
+
enterpriseGated("submit_checkout_photos", async (params) => {
|
|
156
|
+
try {
|
|
157
|
+
if (!params.photos || params.photos.length === 0) {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: JSON.stringify(
|
|
163
|
+
{ error: "No photos provided. Include at least one photo URL or path." },
|
|
164
|
+
null,
|
|
165
|
+
2
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
isError: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = await savePhotos({
|
|
174
|
+
listingId: params.listingId,
|
|
175
|
+
reservationId: params.reservationId,
|
|
176
|
+
photos: params.photos,
|
|
177
|
+
timestamp: new Date().toISOString(),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
content: [
|
|
182
|
+
{
|
|
183
|
+
type: "text",
|
|
184
|
+
text: JSON.stringify(
|
|
185
|
+
{
|
|
186
|
+
submission_id: result.submission_id,
|
|
187
|
+
photo_count: params.photos.length,
|
|
188
|
+
status: "received",
|
|
189
|
+
message: "Photos queued for analysis",
|
|
190
|
+
},
|
|
191
|
+
null,
|
|
192
|
+
2
|
|
193
|
+
),
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return {
|
|
199
|
+
content: [
|
|
200
|
+
{
|
|
201
|
+
type: "text",
|
|
202
|
+
text: JSON.stringify(
|
|
203
|
+
{
|
|
204
|
+
error: "Failed to submit checkout photos.",
|
|
205
|
+
details: e.message,
|
|
206
|
+
},
|
|
207
|
+
null,
|
|
208
|
+
2
|
|
209
|
+
),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
isError: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// ── Tool 3: get_maintenance_alerts ──────────────────────────────
|
|
219
|
+
server.tool(
|
|
220
|
+
"get_maintenance_alerts",
|
|
221
|
+
"Get active maintenance alerts from IoT devices. Filter by property, severity, or active-only status. Returns alerts with device info, severity, and timestamps.",
|
|
222
|
+
{
|
|
223
|
+
listingId: z
|
|
224
|
+
.string()
|
|
225
|
+
.optional()
|
|
226
|
+
.describe("Filter alerts to a specific listing ID. Omit for all properties."),
|
|
227
|
+
severity: z
|
|
228
|
+
.enum(["critical", "warning", "info", "all"])
|
|
229
|
+
.optional()
|
|
230
|
+
.default("all")
|
|
231
|
+
.describe("Filter by alert severity level (default: all)"),
|
|
232
|
+
active_only: z
|
|
233
|
+
.boolean()
|
|
234
|
+
.optional()
|
|
235
|
+
.default(true)
|
|
236
|
+
.describe("Only return active (unresolved) alerts (default: true)"),
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
readOnlyHint: true,
|
|
240
|
+
destructiveHint: false,
|
|
241
|
+
idempotentHint: true,
|
|
242
|
+
openWorldHint: false,
|
|
243
|
+
},
|
|
244
|
+
enterpriseGated("get_maintenance_alerts", async (params) => {
|
|
245
|
+
try {
|
|
246
|
+
const filters = {
|
|
247
|
+
listingId: params.listingId || null,
|
|
248
|
+
severity: params.severity === "all" ? null : params.severity,
|
|
249
|
+
activeOnly: params.active_only,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const alertData = await getAlerts(filters);
|
|
253
|
+
const alerts = (alertData || []).map((a) => ({
|
|
254
|
+
id: a.id,
|
|
255
|
+
device_id: a.device_id,
|
|
256
|
+
property_id: a.property_id,
|
|
257
|
+
alert_type: a.alert_type,
|
|
258
|
+
severity: a.severity,
|
|
259
|
+
message: a.message,
|
|
260
|
+
created_at: a.created_at,
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: "text",
|
|
267
|
+
text: JSON.stringify(
|
|
268
|
+
{
|
|
269
|
+
alerts,
|
|
270
|
+
total_count: alerts.length,
|
|
271
|
+
},
|
|
272
|
+
null,
|
|
273
|
+
2
|
|
274
|
+
),
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return {
|
|
280
|
+
content: [
|
|
281
|
+
{
|
|
282
|
+
type: "text",
|
|
283
|
+
text: JSON.stringify(
|
|
284
|
+
{
|
|
285
|
+
error: "Failed to retrieve maintenance alerts.",
|
|
286
|
+
details: e.message,
|
|
287
|
+
},
|
|
288
|
+
null,
|
|
289
|
+
2
|
|
290
|
+
),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
isError: true,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// ── Tool 4: get_readiness_score ─────────────────────────────────
|
|
300
|
+
server.tool(
|
|
301
|
+
"get_readiness_score",
|
|
302
|
+
"Calculate a 0-100 Physical Readiness Score for a property before guest check-in. Evaluates temperature, leak detection, door lock, humidity, critical alerts, and baseline photos.",
|
|
303
|
+
{
|
|
304
|
+
listingId: z.string().describe("The Guesty listing ID for the property"),
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
readOnlyHint: true,
|
|
308
|
+
destructiveHint: false,
|
|
309
|
+
idempotentHint: true,
|
|
310
|
+
openWorldHint: false,
|
|
311
|
+
},
|
|
312
|
+
enterpriseGated("get_readiness_score", async (params) => {
|
|
313
|
+
try {
|
|
314
|
+
const [readings, alerts, baseline] = await Promise.all([
|
|
315
|
+
getLatestReadings(params.listingId),
|
|
316
|
+
getAlerts({
|
|
317
|
+
listingId: params.listingId,
|
|
318
|
+
severity: null,
|
|
319
|
+
activeOnly: true,
|
|
320
|
+
}),
|
|
321
|
+
getBaseline(params.listingId),
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
const checks = [];
|
|
325
|
+
let score = 0;
|
|
326
|
+
const MAX_SCORE = 100;
|
|
327
|
+
|
|
328
|
+
// ── Check 1: Temperature in range (10 pts) ──
|
|
329
|
+
const tempDevice = (readings || []).find(
|
|
330
|
+
(r) => r.type === "temperature"
|
|
331
|
+
);
|
|
332
|
+
const tempOk =
|
|
333
|
+
tempDevice &&
|
|
334
|
+
tempDevice.last_reading !== null &&
|
|
335
|
+
tempDevice.last_reading >= 60 &&
|
|
336
|
+
tempDevice.last_reading <= 80;
|
|
337
|
+
checks.push({
|
|
338
|
+
name: "temperature_in_range",
|
|
339
|
+
passed: !!tempOk,
|
|
340
|
+
points: tempOk ? 10 : 0,
|
|
341
|
+
detail: tempDevice
|
|
342
|
+
? `Temperature: ${tempDevice.last_reading}F (range: 60-80F)`
|
|
343
|
+
: "No temperature sensor found",
|
|
344
|
+
});
|
|
345
|
+
if (tempOk) score += 10;
|
|
346
|
+
|
|
347
|
+
// ── Check 2: No leak alerts (25 pts) ──
|
|
348
|
+
const leakAlerts = (alerts || []).filter(
|
|
349
|
+
(a) => a.alert_type === "leak" || a.alert_type === "water_leak"
|
|
350
|
+
);
|
|
351
|
+
const noLeaks = leakAlerts.length === 0;
|
|
352
|
+
checks.push({
|
|
353
|
+
name: "no_leak_alerts",
|
|
354
|
+
passed: noLeaks,
|
|
355
|
+
points: noLeaks ? 25 : 0,
|
|
356
|
+
detail: noLeaks
|
|
357
|
+
? "No active leak alerts"
|
|
358
|
+
: `${leakAlerts.length} active leak alert(s)`,
|
|
359
|
+
});
|
|
360
|
+
if (noLeaks) score += 25;
|
|
361
|
+
|
|
362
|
+
// ── Check 3: Door lock responsive (25 pts) ──
|
|
363
|
+
const lockDevice = (readings || []).find(
|
|
364
|
+
(r) => r.type === "lock" || r.type === "door_lock"
|
|
365
|
+
);
|
|
366
|
+
const lockOk =
|
|
367
|
+
lockDevice &&
|
|
368
|
+
lockDevice.last_seen &&
|
|
369
|
+
Date.now() - new Date(lockDevice.last_seen).getTime() <
|
|
370
|
+
30 * 60 * 1000; // seen within 30 min
|
|
371
|
+
checks.push({
|
|
372
|
+
name: "door_lock_responsive",
|
|
373
|
+
passed: !!lockOk,
|
|
374
|
+
points: lockOk ? 25 : 0,
|
|
375
|
+
detail: lockDevice
|
|
376
|
+
? `Lock last seen: ${lockDevice.last_seen}`
|
|
377
|
+
: "No door lock device found",
|
|
378
|
+
});
|
|
379
|
+
if (lockOk) score += 25;
|
|
380
|
+
|
|
381
|
+
// ── Check 4: Humidity OK (10 pts) ──
|
|
382
|
+
const humidDevice = (readings || []).find(
|
|
383
|
+
(r) => r.type === "humidity"
|
|
384
|
+
);
|
|
385
|
+
const humidOk =
|
|
386
|
+
humidDevice &&
|
|
387
|
+
humidDevice.last_reading !== null &&
|
|
388
|
+
humidDevice.last_reading >= 30 &&
|
|
389
|
+
humidDevice.last_reading <= 60;
|
|
390
|
+
checks.push({
|
|
391
|
+
name: "humidity_ok",
|
|
392
|
+
passed: !!humidOk,
|
|
393
|
+
points: humidOk ? 10 : 0,
|
|
394
|
+
detail: humidDevice
|
|
395
|
+
? `Humidity: ${humidDevice.last_reading}% (range: 30-60%)`
|
|
396
|
+
: "No humidity sensor found",
|
|
397
|
+
});
|
|
398
|
+
if (humidOk) score += 10;
|
|
399
|
+
|
|
400
|
+
// ── Check 5: No critical alerts (20 pts) ──
|
|
401
|
+
const criticals = (alerts || []).filter(
|
|
402
|
+
(a) => a.severity === "critical"
|
|
403
|
+
);
|
|
404
|
+
const noCritical = criticals.length === 0;
|
|
405
|
+
checks.push({
|
|
406
|
+
name: "no_critical_alerts",
|
|
407
|
+
passed: noCritical,
|
|
408
|
+
points: noCritical ? 20 : 0,
|
|
409
|
+
detail: noCritical
|
|
410
|
+
? "No critical alerts"
|
|
411
|
+
: `${criticals.length} critical alert(s) active`,
|
|
412
|
+
});
|
|
413
|
+
if (noCritical) score += 20;
|
|
414
|
+
|
|
415
|
+
// ── Check 6: Baseline photos exist (10 pts) ──
|
|
416
|
+
const hasBaseline =
|
|
417
|
+
baseline && baseline.photos && baseline.photos.length > 0;
|
|
418
|
+
checks.push({
|
|
419
|
+
name: "baseline_photos_exist",
|
|
420
|
+
passed: !!hasBaseline,
|
|
421
|
+
points: hasBaseline ? 10 : 0,
|
|
422
|
+
detail: hasBaseline
|
|
423
|
+
? `${baseline.photos.length} baseline photo(s) on file`
|
|
424
|
+
: "No baseline photos uploaded",
|
|
425
|
+
});
|
|
426
|
+
if (hasBaseline) score += 10;
|
|
427
|
+
|
|
428
|
+
// ── Derive status ──
|
|
429
|
+
let status;
|
|
430
|
+
if (score >= 80) status = "ready";
|
|
431
|
+
else if (score >= 50) status = "warning";
|
|
432
|
+
else status = "not_ready";
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
content: [
|
|
436
|
+
{
|
|
437
|
+
type: "text",
|
|
438
|
+
text: JSON.stringify(
|
|
439
|
+
{
|
|
440
|
+
score,
|
|
441
|
+
max_score: MAX_SCORE,
|
|
442
|
+
status,
|
|
443
|
+
checks,
|
|
444
|
+
},
|
|
445
|
+
null,
|
|
446
|
+
2
|
|
447
|
+
),
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
};
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return {
|
|
453
|
+
content: [
|
|
454
|
+
{
|
|
455
|
+
type: "text",
|
|
456
|
+
text: JSON.stringify(
|
|
457
|
+
{
|
|
458
|
+
error: "Failed to calculate readiness score.",
|
|
459
|
+
details: e.message,
|
|
460
|
+
},
|
|
461
|
+
null,
|
|
462
|
+
2
|
|
463
|
+
),
|
|
464
|
+
},
|
|
465
|
+
],
|
|
466
|
+
isError: true,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
);
|
|
471
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IoT Webhook Receiver — Guesty MCP Server Enterprise
|
|
3
|
+
*
|
|
4
|
+
* POST /webhooks/iot
|
|
5
|
+
*
|
|
6
|
+
* Accepts payloads from Tuya, Google Nest, SmartThings, and a
|
|
7
|
+
* generic format. Normalises everything, stores readings, and
|
|
8
|
+
* auto-creates alerts when thresholds are breached.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Router } from "express";
|
|
12
|
+
import {
|
|
13
|
+
saveReading,
|
|
14
|
+
saveAlert,
|
|
15
|
+
upsertDevice,
|
|
16
|
+
getLatestReadings,
|
|
17
|
+
getAlerts
|
|
18
|
+
} from "./iot-db.js";
|
|
19
|
+
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
// ── Threshold config ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const THRESHOLDS = {
|
|
25
|
+
temperature: { min: 60, max: 85 }, // Fahrenheit
|
|
26
|
+
humidity: { max: 70 }, // percent
|
|
27
|
+
leak_detected: { trigger: "true" },
|
|
28
|
+
lock_status: { unlocked_max_minutes: 60 }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ── Format normalisation ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect the payload format and return a normalised reading object
|
|
35
|
+
* (or array of objects).
|
|
36
|
+
*/
|
|
37
|
+
function normalise(body) {
|
|
38
|
+
// Tuya format
|
|
39
|
+
if (body.devId && body.status && Array.isArray(body.status)) {
|
|
40
|
+
return normaliseTuya(body);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Google Nest format
|
|
44
|
+
if (body.name && body.traits && typeof body.traits === "object") {
|
|
45
|
+
return normaliseNest(body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// SmartThings format
|
|
49
|
+
if (body.deviceId && body.capability) {
|
|
50
|
+
return normaliseSmartThings(body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Generic format (pass-through)
|
|
54
|
+
if (body.device_id && body.reading_type) {
|
|
55
|
+
return [normaliseGeneric(body)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error("Unrecognised payload format");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Tuya ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const TUYA_CODE_MAP = {
|
|
64
|
+
temp_current: { reading_type: "temperature", device_type: "temp_sensor" },
|
|
65
|
+
va_temperature: { reading_type: "temperature", device_type: "temp_sensor" },
|
|
66
|
+
humidity_value: { reading_type: "humidity", device_type: "humidity_sensor" },
|
|
67
|
+
va_humidity: { reading_type: "humidity", device_type: "humidity_sensor" },
|
|
68
|
+
watersensor_state: { reading_type: "leak_detected", device_type: "leak_sensor" },
|
|
69
|
+
doorcontact_state: { reading_type: "lock_status", device_type: "smart_lock" },
|
|
70
|
+
pir: { reading_type: "motion", device_type: "motion_sensor" },
|
|
71
|
+
switch: { reading_type: "lock_status", device_type: "smart_lock" }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function normaliseTuya(body) {
|
|
75
|
+
const results = [];
|
|
76
|
+
for (const item of body.status) {
|
|
77
|
+
const mapping = TUYA_CODE_MAP[item.code];
|
|
78
|
+
if (!mapping) continue;
|
|
79
|
+
|
|
80
|
+
let value = String(item.value);
|
|
81
|
+
// Tuya often sends temp * 10
|
|
82
|
+
if (mapping.reading_type === "temperature" && Number(value) > 200) {
|
|
83
|
+
value = String(Number(value) / 10);
|
|
84
|
+
}
|
|
85
|
+
// Tuya booleans / water states
|
|
86
|
+
if (mapping.reading_type === "leak_detected") {
|
|
87
|
+
value = (value === "1" || value === "true" || value === "alarm") ? "true" : "false";
|
|
88
|
+
}
|
|
89
|
+
if (mapping.reading_type === "lock_status") {
|
|
90
|
+
value = (value === "true" || value === "1" || value === "open") ? "unlocked" : "locked";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
results.push({
|
|
94
|
+
device_id: body.devId,
|
|
95
|
+
device_type: mapping.device_type,
|
|
96
|
+
property_id: body.property_id || null,
|
|
97
|
+
reading_type: mapping.reading_type,
|
|
98
|
+
value,
|
|
99
|
+
raw_payload: body
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Google Nest ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const NEST_TRAIT_MAP = {
|
|
108
|
+
"sdm.devices.traits.Temperature": {
|
|
109
|
+
field: "ambientTemperatureCelsius",
|
|
110
|
+
reading_type: "temperature",
|
|
111
|
+
device_type: "temp_sensor",
|
|
112
|
+
transform: (c) => String(Math.round(c * 9 / 5 + 32)) // C → F
|
|
113
|
+
},
|
|
114
|
+
"sdm.devices.traits.Humidity": {
|
|
115
|
+
field: "ambientHumidityPercent",
|
|
116
|
+
reading_type: "humidity",
|
|
117
|
+
device_type: "humidity_sensor",
|
|
118
|
+
transform: (v) => String(v)
|
|
119
|
+
},
|
|
120
|
+
"sdm.devices.traits.CameraMotion": {
|
|
121
|
+
field: null,
|
|
122
|
+
reading_type: "motion",
|
|
123
|
+
device_type: "motion_sensor",
|
|
124
|
+
transform: () => "true"
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function normaliseNest(body) {
|
|
129
|
+
const results = [];
|
|
130
|
+
const deviceId = body.name.split("/").pop() || body.name;
|
|
131
|
+
|
|
132
|
+
for (const [trait, data] of Object.entries(body.traits)) {
|
|
133
|
+
const mapping = NEST_TRAIT_MAP[trait];
|
|
134
|
+
if (!mapping) continue;
|
|
135
|
+
|
|
136
|
+
const raw = mapping.field ? data[mapping.field] : true;
|
|
137
|
+
const value = mapping.transform(raw);
|
|
138
|
+
|
|
139
|
+
results.push({
|
|
140
|
+
device_id: deviceId,
|
|
141
|
+
device_type: mapping.device_type,
|
|
142
|
+
property_id: body.property_id || null,
|
|
143
|
+
reading_type: mapping.reading_type,
|
|
144
|
+
value,
|
|
145
|
+
raw_payload: body
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── SmartThings ────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const ST_CAPABILITY_MAP = {
|
|
154
|
+
temperatureMeasurement: { reading_type: "temperature", device_type: "temp_sensor" },
|
|
155
|
+
relativeHumidityMeasurement: { reading_type: "humidity", device_type: "humidity_sensor" },
|
|
156
|
+
waterSensor: { reading_type: "leak_detected", device_type: "leak_sensor" },
|
|
157
|
+
lock: { reading_type: "lock_status", device_type: "smart_lock" },
|
|
158
|
+
motionSensor: { reading_type: "motion", device_type: "motion_sensor" }
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function normaliseSmartThings(body) {
|
|
162
|
+
const mapping = ST_CAPABILITY_MAP[body.capability];
|
|
163
|
+
if (!mapping) {
|
|
164
|
+
throw new Error(`Unsupported SmartThings capability: ${body.capability}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let value = String(body.value);
|
|
168
|
+
if (mapping.reading_type === "leak_detected") {
|
|
169
|
+
value = (value === "wet" || value === "true" || value === "1") ? "true" : "false";
|
|
170
|
+
}
|
|
171
|
+
if (mapping.reading_type === "lock_status") {
|
|
172
|
+
value = (value === "unlocked" || value === "open") ? "unlocked" : "locked";
|
|
173
|
+
}
|
|
174
|
+
if (mapping.reading_type === "motion") {
|
|
175
|
+
value = (value === "active" || value === "true" || value === "1") ? "true" : "false";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return [{
|
|
179
|
+
device_id: body.deviceId,
|
|
180
|
+
device_type: mapping.device_type,
|
|
181
|
+
property_id: body.property_id || null,
|
|
182
|
+
reading_type: mapping.reading_type,
|
|
183
|
+
value,
|
|
184
|
+
raw_payload: body
|
|
185
|
+
}];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Generic ────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function normaliseGeneric(body) {
|
|
191
|
+
return {
|
|
192
|
+
device_id: body.device_id,
|
|
193
|
+
device_type: body.device_type || "temp_sensor",
|
|
194
|
+
property_id: body.property_id || null,
|
|
195
|
+
reading_type: body.reading_type,
|
|
196
|
+
value: String(body.value),
|
|
197
|
+
raw_payload: body
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Alert evaluation ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function evaluateAlerts(reading, device) {
|
|
204
|
+
const alerts = [];
|
|
205
|
+
const propertyId = reading.property_id || device?.property_id || null;
|
|
206
|
+
|
|
207
|
+
switch (reading.reading_type) {
|
|
208
|
+
case "temperature": {
|
|
209
|
+
const temp = parseFloat(reading.value);
|
|
210
|
+
if (!isNaN(temp) && (temp < THRESHOLDS.temperature.min || temp > THRESHOLDS.temperature.max)) {
|
|
211
|
+
alerts.push({
|
|
212
|
+
device_id: reading.device_id,
|
|
213
|
+
property_id: propertyId,
|
|
214
|
+
alert_type: "temp_out_of_range",
|
|
215
|
+
severity: temp < 45 || temp > 95 ? "critical" : "warning",
|
|
216
|
+
message: `Temperature ${temp}°F is outside safe range (${THRESHOLDS.temperature.min}-${THRESHOLDS.temperature.max}°F)`
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "humidity": {
|
|
223
|
+
const hum = parseFloat(reading.value);
|
|
224
|
+
if (!isNaN(hum) && hum > THRESHOLDS.humidity.max) {
|
|
225
|
+
alerts.push({
|
|
226
|
+
device_id: reading.device_id,
|
|
227
|
+
property_id: propertyId,
|
|
228
|
+
alert_type: "temp_out_of_range", // closest match — humidity threshold
|
|
229
|
+
severity: hum > 85 ? "critical" : "warning",
|
|
230
|
+
message: `Humidity ${hum}% exceeds threshold (${THRESHOLDS.humidity.max}%)`
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "leak_detected": {
|
|
237
|
+
if (reading.value === "true") {
|
|
238
|
+
alerts.push({
|
|
239
|
+
device_id: reading.device_id,
|
|
240
|
+
property_id: propertyId,
|
|
241
|
+
alert_type: "leak",
|
|
242
|
+
severity: "critical",
|
|
243
|
+
message: "Water leak detected!"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case "lock_status": {
|
|
250
|
+
if (reading.value === "unlocked") {
|
|
251
|
+
// For now, create an info-level alert; a background job could
|
|
252
|
+
// escalate after THRESHOLDS.lock_status.unlocked_max_minutes.
|
|
253
|
+
alerts.push({
|
|
254
|
+
device_id: reading.device_id,
|
|
255
|
+
property_id: propertyId,
|
|
256
|
+
alert_type: "lock_offline",
|
|
257
|
+
severity: "info",
|
|
258
|
+
message: "Smart lock is unlocked — will escalate if still unlocked after 1 hour"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case "motion": {
|
|
265
|
+
// Motion alerts only fire if there is no active reservation
|
|
266
|
+
// (would need Guesty integration). For now, log as info.
|
|
267
|
+
if (reading.value === "true") {
|
|
268
|
+
alerts.push({
|
|
269
|
+
device_id: reading.device_id,
|
|
270
|
+
property_id: propertyId,
|
|
271
|
+
alert_type: "motion_unusual",
|
|
272
|
+
severity: "info",
|
|
273
|
+
message: "Motion detected — verify if property should be occupied"
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return alerts;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Route ──────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
router.post("/webhooks/iot", (req, res) => {
|
|
286
|
+
try {
|
|
287
|
+
const body = req.body;
|
|
288
|
+
if (!body || typeof body !== "object") {
|
|
289
|
+
return res.status(400).json({ error: "Empty or invalid payload" });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const normalised = normalise(body);
|
|
293
|
+
|
|
294
|
+
if (!normalised || normalised.length === 0) {
|
|
295
|
+
return res.status(422).json({ error: "No recognisable readings in payload" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let readingsSaved = 0;
|
|
299
|
+
let alertsCreated = 0;
|
|
300
|
+
let deviceId = null;
|
|
301
|
+
|
|
302
|
+
for (const reading of normalised) {
|
|
303
|
+
// Auto-register / update device
|
|
304
|
+
const device = upsertDevice({
|
|
305
|
+
device_id: reading.device_id,
|
|
306
|
+
device_type: reading.device_type,
|
|
307
|
+
property_id: reading.property_id,
|
|
308
|
+
location: reading.location || null
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Save reading
|
|
312
|
+
saveReading({
|
|
313
|
+
device_id: reading.device_id,
|
|
314
|
+
reading_type: reading.reading_type,
|
|
315
|
+
value: reading.value,
|
|
316
|
+
raw_payload: reading.raw_payload
|
|
317
|
+
});
|
|
318
|
+
readingsSaved++;
|
|
319
|
+
deviceId = reading.device_id;
|
|
320
|
+
|
|
321
|
+
// Evaluate thresholds
|
|
322
|
+
const alerts = evaluateAlerts(reading, device);
|
|
323
|
+
for (const alert of alerts) {
|
|
324
|
+
saveAlert(alert);
|
|
325
|
+
alertsCreated++;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
console.log(`[iot-webhook] ${readingsSaved} reading(s) saved, ${alertsCreated} alert(s) from device ${deviceId}`);
|
|
330
|
+
|
|
331
|
+
return res.status(200).json({
|
|
332
|
+
status: "ok",
|
|
333
|
+
device_id: deviceId,
|
|
334
|
+
readings_saved: readingsSaved,
|
|
335
|
+
alerts_created: alertsCreated
|
|
336
|
+
});
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error("[iot-webhook] Error:", err.message);
|
|
339
|
+
return res.status(400).json({ error: err.message });
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── Read-only endpoints (optional convenience) ─────────────────────────────
|
|
344
|
+
|
|
345
|
+
router.get("/webhooks/iot/readings/:propertyId", (req, res) => {
|
|
346
|
+
try {
|
|
347
|
+
const readings = getLatestReadings(req.params.propertyId);
|
|
348
|
+
return res.status(200).json({ property_id: req.params.propertyId, readings });
|
|
349
|
+
} catch (err) {
|
|
350
|
+
return res.status(500).json({ error: err.message });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
router.get("/webhooks/iot/alerts/:propertyId", (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const includeResolved = req.query.resolved === "true";
|
|
357
|
+
const alerts = getAlerts(req.params.propertyId, { includeResolved });
|
|
358
|
+
return res.status(200).json({ property_id: req.params.propertyId, alerts });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
return res.status(500).json({ error: err.message });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
export default router;
|
package/src/server.js
CHANGED
|
@@ -3,6 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getTier, getTierInfo, gatedHandler, FREE_TOOLS } from './license.js';
|
|
6
|
+
import { registerIoTTools } from './iot-tools.js';
|
|
7
|
+
import { initDB } from './iot-db.js';
|
|
6
8
|
|
|
7
9
|
// Guesty API Configuration
|
|
8
10
|
const GUESTY_CLIENT_ID = process.env.GUESTY_CLIENT_ID;
|
|
@@ -125,7 +127,7 @@ async function guestyDelete(path, retries = 2) {
|
|
|
125
127
|
// Create MCP Server
|
|
126
128
|
const server = new McpServer({
|
|
127
129
|
name: "guesty-mcp-server",
|
|
128
|
-
version: "0.
|
|
130
|
+
version: "0.7.0",
|
|
129
131
|
});
|
|
130
132
|
// License tier check
|
|
131
133
|
const _tier = getTier();
|
|
@@ -1413,6 +1415,10 @@ server.tool(
|
|
|
1413
1415
|
}
|
|
1414
1416
|
);
|
|
1415
1417
|
|
|
1418
|
+
// Initialize IoT database and register IoT/Property Health tools (Enterprise tier)
|
|
1419
|
+
initDB();
|
|
1420
|
+
registerIoTTools(server);
|
|
1421
|
+
|
|
1416
1422
|
// Start server
|
|
1417
1423
|
const transport = new StdioServerTransport();
|
|
1418
1424
|
await server.connect(transport);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for IoT/Property Health Enterprise tier
|
|
4
|
+
* Tests: iot-db.js, iot-webhook.js, iot-tools.js
|
|
5
|
+
*/
|
|
6
|
+
import { initDB, upsertDevice, saveReading, getLatestReadings, saveAlert, getAlerts, resolveAlert, saveBaseline, getBaseline, savePhotos } from '../src/iot-db.js';
|
|
7
|
+
import { registerIoTTools } from '../src/iot-tools.js';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
|
|
12
|
+
// Use temp DB for tests
|
|
13
|
+
const TEST_DB = path.join(os.tmpdir(), 'iot-test-' + Date.now() + '.json');
|
|
14
|
+
process.env.IOT_DB_PATH = TEST_DB;
|
|
15
|
+
|
|
16
|
+
let passed = 0;
|
|
17
|
+
let failed = 0;
|
|
18
|
+
|
|
19
|
+
function assert(condition, msg) {
|
|
20
|
+
if (condition) {
|
|
21
|
+
passed++;
|
|
22
|
+
console.log(` ✓ ${msg}`);
|
|
23
|
+
} else {
|
|
24
|
+
failed++;
|
|
25
|
+
console.log(` ✗ FAIL: ${msg}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run() {
|
|
30
|
+
console.log("=== IoT Integration Tests ===\n");
|
|
31
|
+
|
|
32
|
+
// Test 1: Database initialization
|
|
33
|
+
console.log("1. Database Init");
|
|
34
|
+
initDB();
|
|
35
|
+
assert(fs.existsSync(TEST_DB), "DB file created");
|
|
36
|
+
|
|
37
|
+
// Test 2: Device management
|
|
38
|
+
console.log("\n2. Device Management");
|
|
39
|
+
upsertDevice({ device_id: "sensor-001", device_type: "temp_sensor", property_id: "listing-abc", location: "living_room" });
|
|
40
|
+
upsertDevice({ device_id: "lock-001", device_type: "smart_lock", property_id: "listing-abc", location: "front_door" });
|
|
41
|
+
upsertDevice({ device_id: "leak-001", device_type: "leak_sensor", property_id: "listing-abc", location: "bathroom" });
|
|
42
|
+
const readings1 = getLatestReadings("listing-abc");
|
|
43
|
+
// getLatestReadings returns an array of readings, not { devices: [...] }
|
|
44
|
+
// With no readings yet, returns empty. Check devices were created by querying again after readings.
|
|
45
|
+
assert(Array.isArray(readings1), `getLatestReadings returns array (got ${typeof readings1})`);
|
|
46
|
+
|
|
47
|
+
// Test 3: Save readings
|
|
48
|
+
console.log("\n3. Readings");
|
|
49
|
+
saveReading({ device_id: "sensor-001", reading_type: "temperature", value: "72.5", raw_payload: { source: "test" } });
|
|
50
|
+
saveReading({ device_id: "sensor-001", reading_type: "temperature", value: "73.0", raw_payload: { source: "test" } });
|
|
51
|
+
saveReading({ device_id: "lock-001", reading_type: "lock_status", value: "locked", raw_payload: { source: "test" } });
|
|
52
|
+
saveReading({ device_id: "leak-001", reading_type: "leak_detected", value: "false", raw_payload: { source: "test" } });
|
|
53
|
+
const readings2 = getLatestReadings("listing-abc");
|
|
54
|
+
assert(readings2.length >= 3, `At least 3 readings for listing-abc (got ${readings2.length})`);
|
|
55
|
+
const tempReading = readings2.find(r => r.device_id === "sensor-001" && r.reading_type === "temperature");
|
|
56
|
+
assert(tempReading !== undefined, "Temp reading found");
|
|
57
|
+
assert(tempReading.value === "73.0", `Latest temp is 73.0 (got ${tempReading?.value})`);
|
|
58
|
+
|
|
59
|
+
// Test 4: Alerts
|
|
60
|
+
console.log("\n4. Alerts");
|
|
61
|
+
const alertId = saveAlert({ device_id: "leak-001", property_id: "listing-abc", alert_type: "leak", severity: "critical", message: "Water detected in bathroom!" });
|
|
62
|
+
assert(typeof alertId === 'number' || typeof alertId === 'string', `Alert created with ID: ${alertId}`);
|
|
63
|
+
const alerts = getAlerts("listing-abc");
|
|
64
|
+
assert(alerts.length >= 1, `At least 1 alert (got ${alerts.length})`);
|
|
65
|
+
assert(alerts[0].severity === "critical", "Alert severity is critical");
|
|
66
|
+
|
|
67
|
+
// Test 5: Resolve alert
|
|
68
|
+
console.log("\n5. Alert Resolution");
|
|
69
|
+
resolveAlert(alertId, "CTO");
|
|
70
|
+
const alertsAfter = getAlerts("listing-abc");
|
|
71
|
+
const resolvedAlert = getAlerts("listing-abc", { includeResolved: true }).find(a =>
|
|
72
|
+
a.message === "Water detected in bathroom!"
|
|
73
|
+
);
|
|
74
|
+
assert(resolvedAlert?.resolved_by === "CTO" || alertsAfter.length === 0, "Alert resolved by CTO");
|
|
75
|
+
|
|
76
|
+
// Test 6: Baselines
|
|
77
|
+
console.log("\n6. Baselines");
|
|
78
|
+
saveBaseline({ property_id: "listing-abc", photo_urls: ["https://example.com/clean1.jpg", "https://example.com/clean2.jpg"], notes: "Post deep-clean baseline" });
|
|
79
|
+
const baseline = getBaseline("listing-abc");
|
|
80
|
+
assert(baseline !== null, "Baseline saved");
|
|
81
|
+
assert(baseline.photo_urls?.length === 2, `2 baseline photos (got ${baseline?.photo_urls?.length})`);
|
|
82
|
+
|
|
83
|
+
// Test 7: Readiness Score components
|
|
84
|
+
console.log("\n7. Readiness Score Data");
|
|
85
|
+
// Add a fresh alert to test readiness impact
|
|
86
|
+
saveAlert({ device_id: "sensor-001", property_id: "listing-abc", alert_type: "temp_out_of_range", severity: "warning", message: "Temperature is 58F" });
|
|
87
|
+
const latestAll = getLatestReadings("listing-abc");
|
|
88
|
+
const activeAlerts = getAlerts("listing-abc");
|
|
89
|
+
assert(latestAll.length >= 3, `Multiple readings available (got ${latestAll.length})`);
|
|
90
|
+
assert(activeAlerts.length >= 1, "Active alerts present for readiness check");
|
|
91
|
+
|
|
92
|
+
// Test 8: Multi-property isolation
|
|
93
|
+
console.log("\n8. Multi-Property Isolation");
|
|
94
|
+
upsertDevice({ device_id: "sensor-002", device_type: "temp_sensor", property_id: "listing-xyz", location: "bedroom" });
|
|
95
|
+
saveReading({ device_id: "sensor-002", reading_type: "temperature", value: "70.0", raw_payload: { source: "test" } });
|
|
96
|
+
const readingsXYZ = getLatestReadings("listing-xyz");
|
|
97
|
+
const readingsABC = getLatestReadings("listing-abc");
|
|
98
|
+
assert(readingsXYZ.length >= 1, `listing-xyz has readings (got ${readingsXYZ.length})`);
|
|
99
|
+
assert(readingsABC.length >= 3, `listing-abc still has readings (got ${readingsABC.length})`);
|
|
100
|
+
|
|
101
|
+
// Cleanup
|
|
102
|
+
try { fs.unlinkSync(TEST_DB); } catch(e) {}
|
|
103
|
+
|
|
104
|
+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
105
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
run().catch(e => { console.error("Test error:", e); process.exit(1); });
|