guesty-mcp-server 0.7.0 → 0.8.1
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 +34 -0
- package/README.md +19 -2
- package/package.json +3 -3
- package/server.json +3 -3
- package/src/enterprise-tools.js +320 -0
- package/src/http-server.js +8 -0
- package/src/iot-db.js +27 -10
- package/src/iot-tools.js +108 -241
- package/src/license.js +5 -2
- package/src/server.js +9 -2
- package/src/webhook/iot-receiver-server.js +30 -0
- package/src/webhook/iot-receiver.js +110 -0
- package/.env.example +0 -2
- package/CONTRIBUTING.md +0 -66
- package/Dockerfile +0 -13
- package/HACKERNEWS-POST.md +0 -44
- package/PRODUCT-HUNT-LAUNCH.md +0 -28
- package/SECURITY.md +0 -25
- package/docs/multi-account-design.md +0 -32
- package/examples/docker-compose.yml +0 -13
- package/guesty-partnership-email.md +0 -48
- package/tests/test-iot.js +0 -108
- package/tripadvisor-partnership-email.md +0 -27
package/src/iot-tools.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Guesty MCP Server — IoT / Property Health
|
|
2
|
+
* Guesty MCP Server — IoT / Property Health (Internal Helpers + 1 MCP Tool)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* checkout photo submission, maintenance alerts, and
|
|
6
|
-
* physical readiness scoring.
|
|
7
|
-
*
|
|
8
|
-
* Phase 1: Core data collection and scoring.
|
|
4
|
+
* Phase 1: Core data collection + readiness scoring.
|
|
9
5
|
* Phase 2: Vision-based photo comparison (planned).
|
|
6
|
+
*
|
|
7
|
+
* ── MERGE NOTE (2026-04-17, per Owner greenlight msg 6406) ─────────────
|
|
8
|
+
* The three tools `get_property_health`, `submit_checkout_photos`,
|
|
9
|
+
* `get_maintenance_alerts` that previously lived HERE as IoT-only MCP tools
|
|
10
|
+
* have been EXTRACTED into plain-async internal helpers. Their canonical
|
|
11
|
+
* MCP-tool registration has MOVED to `enterprise-tools.js`, where each one
|
|
12
|
+
* becomes an Enterprise aggregator that calls these helpers AND layers in
|
|
13
|
+
* Guesty-side data (reservation status, review score, last-clean timestamp).
|
|
14
|
+
*
|
|
15
|
+
* Only one tool — `get_readiness_score` — is registered directly from here.
|
|
16
|
+
* It is Physical-Readiness only (sensor + photo signal) and has no Guesty
|
|
17
|
+
* aggregation need.
|
|
18
|
+
*
|
|
19
|
+
* All three helpers RETURN raw data objects (not MCP content envelopes) and
|
|
20
|
+
* THROW on error. Enterprise aggregators handle envelope + error shaping.
|
|
10
21
|
*/
|
|
11
22
|
|
|
12
23
|
import { z } from "zod";
|
|
13
|
-
import { getTier
|
|
24
|
+
import { getTier } from "./license.js";
|
|
14
25
|
import {
|
|
15
26
|
getLatestReadings,
|
|
16
27
|
getAlerts,
|
|
17
|
-
saveBaseline,
|
|
18
28
|
getBaseline,
|
|
19
29
|
savePhotos,
|
|
20
30
|
} from "./iot-db.js";
|
|
@@ -22,8 +32,11 @@ import {
|
|
|
22
32
|
/**
|
|
23
33
|
* Enterprise-tier gate. Returns an error response if the current
|
|
24
34
|
* license is not enterprise. Wraps the inner handler otherwise.
|
|
35
|
+
*
|
|
36
|
+
* Exported so enterprise-tools.js can reuse the same gate without
|
|
37
|
+
* duplicating the string copy.
|
|
25
38
|
*/
|
|
26
|
-
function enterpriseGated(toolName, handler) {
|
|
39
|
+
export function enterpriseGated(toolName, handler) {
|
|
27
40
|
return async (params) => {
|
|
28
41
|
const tier = getTier();
|
|
29
42
|
if (tier !== "enterprise") {
|
|
@@ -34,7 +47,7 @@ function enterpriseGated(toolName, handler) {
|
|
|
34
47
|
text:
|
|
35
48
|
`This tool (${toolName}) requires an Enterprise license. ` +
|
|
36
49
|
`Your current tier is "${tier}". ` +
|
|
37
|
-
"IoT monitoring, property health, and readiness scoring are Enterprise-only features. " +
|
|
50
|
+
"IoT monitoring, property health aggregation, and readiness scoring are Enterprise-only features. " +
|
|
38
51
|
"Upgrade at https://guestycopilot.com/pricing -- " +
|
|
39
52
|
"Set GUESTY_MCP_LICENSE_KEY with an Enterprise key (gmcp_ent_*) to unlock.",
|
|
40
53
|
},
|
|
@@ -52,7 +65,7 @@ function enterpriseGated(toolName, handler) {
|
|
|
52
65
|
* - "warning" if ANY device has a warning alert
|
|
53
66
|
* - "healthy" otherwise
|
|
54
67
|
*/
|
|
55
|
-
function deriveOverallStatus(devices) {
|
|
68
|
+
export function deriveOverallStatus(devices) {
|
|
56
69
|
let status = "healthy";
|
|
57
70
|
for (const d of devices) {
|
|
58
71
|
if (d.alert_level === "critical") return "critical";
|
|
@@ -61,242 +74,96 @@ function deriveOverallStatus(devices) {
|
|
|
61
74
|
return status;
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// INTERNAL HELPERS (exported for enterprise-tools.js aggregators)
|
|
79
|
+
// Return raw data objects. Throw on error. No MCP envelope wrapping.
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
64
82
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* @
|
|
83
|
+
* Fetch IoT device health snapshot for a single property.
|
|
84
|
+
* @param {string} listingId Guesty listing ID
|
|
85
|
+
* @returns {Promise<{property_id: string, devices: Array, overall_status: string}>}
|
|
68
86
|
*/
|
|
69
|
-
export function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
);
|
|
87
|
+
export async function getIoTPropertyHealth(listingId) {
|
|
88
|
+
const readings = await getLatestReadings(listingId);
|
|
89
|
+
const devices = (readings || []).map((r) => ({
|
|
90
|
+
device_id: r.device_id,
|
|
91
|
+
type: r.type,
|
|
92
|
+
location: r.location,
|
|
93
|
+
last_reading: r.last_reading,
|
|
94
|
+
last_seen: r.last_seen,
|
|
95
|
+
alert_level: r.alert_level || null,
|
|
96
|
+
}));
|
|
97
|
+
return {
|
|
98
|
+
property_id: listingId,
|
|
99
|
+
devices,
|
|
100
|
+
overall_status: deriveOverallStatus(devices),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
217
103
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
};
|
|
104
|
+
/**
|
|
105
|
+
* Save submitted post-checkout photos for a property+reservation.
|
|
106
|
+
* @param {{listingId: string, reservationId: string, photos: string[]}} args
|
|
107
|
+
* @returns {Promise<{submission_id: string, photo_count: number, status: string, message: string}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function submitIoTCheckoutPhotos({ listingId, reservationId, photos }) {
|
|
110
|
+
if (!photos || photos.length === 0) {
|
|
111
|
+
throw new Error("No photos provided. Include at least one photo URL or path.");
|
|
112
|
+
}
|
|
113
|
+
const result = await savePhotos({
|
|
114
|
+
listingId,
|
|
115
|
+
reservationId,
|
|
116
|
+
photos,
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
submission_id: result.submission_id,
|
|
121
|
+
photo_count: photos.length,
|
|
122
|
+
status: "received",
|
|
123
|
+
message: "Photos queued for analysis",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
251
126
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Fetch IoT maintenance alerts, optionally filtered by listing / severity / active-only.
|
|
129
|
+
* @param {{listingId?: string, severity?: string, activeOnly?: boolean}} [filters]
|
|
130
|
+
* @returns {Promise<{alerts: Array, total_count: number}>}
|
|
131
|
+
*/
|
|
132
|
+
export async function getIoTMaintenanceAlerts(filters = {}) {
|
|
133
|
+
const dbFilters = {
|
|
134
|
+
listingId: filters.listingId || null,
|
|
135
|
+
severity:
|
|
136
|
+
!filters.severity || filters.severity === "all"
|
|
137
|
+
? null
|
|
138
|
+
: filters.severity,
|
|
139
|
+
activeOnly: filters.activeOnly !== false, // default true
|
|
140
|
+
};
|
|
141
|
+
const alertData = await getAlerts(dbFilters);
|
|
142
|
+
const alerts = (alertData || []).map((a) => ({
|
|
143
|
+
id: a.id,
|
|
144
|
+
device_id: a.device_id,
|
|
145
|
+
property_id: a.property_id,
|
|
146
|
+
alert_type: a.alert_type,
|
|
147
|
+
severity: a.severity,
|
|
148
|
+
message: a.message,
|
|
149
|
+
created_at: a.created_at,
|
|
150
|
+
}));
|
|
151
|
+
return { alerts, total_count: alerts.length };
|
|
152
|
+
}
|
|
262
153
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
);
|
|
154
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
155
|
+
// MCP TOOL REGISTRATION (only 1 tool — get_readiness_score)
|
|
156
|
+
// The other 3 tools live in enterprise-tools.js post-merge.
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
298
158
|
|
|
299
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Register the Physical Readiness Score tool on the given MCP server.
|
|
161
|
+
* Enterprise-gated — requires Enterprise license key.
|
|
162
|
+
*
|
|
163
|
+
* @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
|
|
164
|
+
*/
|
|
165
|
+
export function registerIoTTools(server) {
|
|
166
|
+
// ── Tool: get_readiness_score ───────────────────────────────────
|
|
300
167
|
server.tool(
|
|
301
168
|
"get_readiness_score",
|
|
302
169
|
"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.",
|
package/src/license.js
CHANGED
|
@@ -84,7 +84,10 @@ function isToolAllowed(toolName) {
|
|
|
84
84
|
|
|
85
85
|
function getTierInfo() {
|
|
86
86
|
const tier = getTier();
|
|
87
|
-
|
|
87
|
+
// 39 Guesty core tools + 1 IoT (get_readiness_score) + 3 Enterprise aggregators
|
|
88
|
+
// (get_property_health, submit_checkout_photos, get_maintenance_alerts) = 43.
|
|
89
|
+
// Updated 2026-04-17 for Enterprise Tier MVP merge (Owner msg 6406).
|
|
90
|
+
const totalTools = 43;
|
|
88
91
|
return {
|
|
89
92
|
tier,
|
|
90
93
|
hasKey: !!process.env.GUESTY_MCP_LICENSE_KEY,
|
|
@@ -101,7 +104,7 @@ function gatedHandler(toolName, handler) {
|
|
|
101
104
|
"Free tier includes " + FREE_TOOLS.length + " operations and data tools. " +
|
|
102
105
|
"Guest messaging, review responses, and write operations require Pro+. " +
|
|
103
106
|
"Upgrade at https://guestycopilot.com/pricing -- " +
|
|
104
|
-
"Set GUESTY_MCP_LICENSE_KEY env var to unlock all
|
|
107
|
+
"Set GUESTY_MCP_LICENSE_KEY env var to unlock all 43 tools.";
|
|
105
108
|
return {
|
|
106
109
|
content: [{ type: "text", text: msg }],
|
|
107
110
|
isError: true,
|
package/src/server.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { registerIoTTools } from './iot-tools.js';
|
|
7
|
+
import { registerEnterpriseTools } from './enterprise-tools.js';
|
|
7
8
|
import { initDB } from './iot-db.js';
|
|
8
9
|
|
|
9
10
|
// Guesty API Configuration
|
|
@@ -41,7 +42,7 @@ async function getToken() {
|
|
|
41
42
|
return cachedToken;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
async function guestyGet(path, params = {}, retries = 2) {
|
|
45
|
+
export async function guestyGet(path, params = {}, retries = 2) {
|
|
45
46
|
const token = await getToken();
|
|
46
47
|
const url = new URL(`${GUESTY_API_BASE}${path}`);
|
|
47
48
|
Object.entries(params).forEach(([k, v]) => {
|
|
@@ -1415,9 +1416,15 @@ server.tool(
|
|
|
1415
1416
|
}
|
|
1416
1417
|
);
|
|
1417
1418
|
|
|
1418
|
-
// Initialize IoT database and register
|
|
1419
|
+
// Initialize IoT database and register Enterprise-tier tools.
|
|
1420
|
+
// After the 2026-04-17 MERGE:
|
|
1421
|
+
// - iot-tools.js registers `get_readiness_score` only (1 tool).
|
|
1422
|
+
// - enterprise-tools.js registers the 3 aggregator tools
|
|
1423
|
+
// (get_property_health, submit_checkout_photos, get_maintenance_alerts)
|
|
1424
|
+
// which wrap the extracted IoT helpers + Guesty-layer data.
|
|
1419
1425
|
initDB();
|
|
1420
1426
|
registerIoTTools(server);
|
|
1427
|
+
registerEnterpriseTools(server);
|
|
1421
1428
|
|
|
1422
1429
|
// Start server
|
|
1423
1430
|
const transport = new StdioServerTransport();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone boot for the IoT webhook receiver (Enterprise Tier stub).
|
|
4
|
+
*
|
|
5
|
+
* Runs the iot-receiver router on its own Express app, listening on
|
|
6
|
+
* IOT_WEBHOOK_PORT (default 3100). Use this for local dev / smoke tests
|
|
7
|
+
* when you do not want to start the full MCP HTTP server.
|
|
8
|
+
*
|
|
9
|
+
* node src/webhook/iot-receiver-server.js
|
|
10
|
+
* IOT_WEBHOOK_PORT=4100 node src/webhook/iot-receiver-server.js
|
|
11
|
+
*
|
|
12
|
+
* Production: run behind a reverse proxy that terminates TLS and enforces
|
|
13
|
+
* a real HMAC check against IOT_WEBHOOK_SECRET. Do NOT expose this port
|
|
14
|
+
* directly on the public internet.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import express from "express";
|
|
18
|
+
import iotReceiverRouter from "./iot-receiver.js";
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.IOT_WEBHOOK_PORT || "3100", 10);
|
|
21
|
+
|
|
22
|
+
const app = express();
|
|
23
|
+
app.use(express.json({ limit: "256kb" }));
|
|
24
|
+
app.use(iotReceiverRouter);
|
|
25
|
+
|
|
26
|
+
app.listen(PORT, "127.0.0.1", () => {
|
|
27
|
+
console.log(`[iot-receiver] stub listening on http://127.0.0.1:${PORT}`);
|
|
28
|
+
console.log(`[iot-receiver] POST /webhook/iot/:property_id`);
|
|
29
|
+
console.log(`[iot-receiver] GET /webhook/iot/health`);
|
|
30
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IoT Webhook Receiver — Enterprise Tier (STUB)
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* POST /webhook/iot/:property_id — accept inbound IoT events
|
|
6
|
+
* GET /webhook/iot/health — liveness probe
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities (stub scope only):
|
|
9
|
+
* - Require a non-empty `x-dlj-webhook-signature` header
|
|
10
|
+
* (HMAC verification is stubbed — see TODO below).
|
|
11
|
+
* - Parse JSON body: { device_id, event_type, severity, timestamp, metadata }
|
|
12
|
+
* - Append one JSON line per event to ~/.dlj-scripts/logs/iot-webhook.log
|
|
13
|
+
* - Return 202 Accepted with { received: true, event_id: <uuid> }
|
|
14
|
+
*
|
|
15
|
+
* NOT IN SCOPE (future phases):
|
|
16
|
+
* - Real HMAC verification against IOT_WEBHOOK_SECRET
|
|
17
|
+
* - Rate limiting
|
|
18
|
+
* - Durable persistence / DB writes
|
|
19
|
+
* - Fan-out to `get_maintenance_alerts` MCP tool
|
|
20
|
+
*
|
|
21
|
+
* Production deployment note:
|
|
22
|
+
* This receiver is intended to run behind a reverse proxy (nginx / Caddy /
|
|
23
|
+
* Cloudflare Tunnel) that terminates TLS and enforces real HMAC checks.
|
|
24
|
+
* Do NOT expose port IOT_WEBHOOK_PORT (default 3100) on the public internet.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Router } from "express";
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
|
|
33
|
+
const router = Router();
|
|
34
|
+
|
|
35
|
+
const LOG_PATH = `${homedir()}/.dlj-scripts/logs/iot-webhook.log`;
|
|
36
|
+
|
|
37
|
+
// Best-effort: ensure logs dir exists at import time.
|
|
38
|
+
mkdir(dirname(LOG_PATH), { recursive: true }).catch(() => {});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Append a single JSON line to the rotating log file.
|
|
42
|
+
* Rotation itself is delegated to logrotate / launchd — this module is
|
|
43
|
+
* append-only.
|
|
44
|
+
*/
|
|
45
|
+
async function logEvent(entry) {
|
|
46
|
+
try {
|
|
47
|
+
await appendFile(LOG_PATH, JSON.stringify(entry) + "\n", "utf8");
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Surface a line to stderr but do not fail the request — observability
|
|
50
|
+
// is best-effort at this tier.
|
|
51
|
+
console.error("[iot-receiver] log write failed:", err.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Health ────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
router.get("/webhook/iot/health", (req, res) => {
|
|
58
|
+
res.status(200).json({
|
|
59
|
+
status: "ok",
|
|
60
|
+
receiver: "iot-webhook-stub",
|
|
61
|
+
log_path: LOG_PATH
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── Inbound event ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
router.post("/webhook/iot/:property_id", async (req, res) => {
|
|
68
|
+
const signature = req.get("x-dlj-webhook-signature");
|
|
69
|
+
|
|
70
|
+
// TODO: verify HMAC against IOT_WEBHOOK_SECRET
|
|
71
|
+
// Expected: HMAC-SHA256(body, IOT_WEBHOOK_SECRET) === signature
|
|
72
|
+
if (!signature || typeof signature !== "string" || signature.length === 0) {
|
|
73
|
+
return res.status(401).json({
|
|
74
|
+
received: false,
|
|
75
|
+
error: "missing x-dlj-webhook-signature header"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = req.body;
|
|
80
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
81
|
+
return res.status(400).json({
|
|
82
|
+
received: false,
|
|
83
|
+
error: "body must be a JSON object"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { device_id, event_type, severity, timestamp, metadata } = body;
|
|
88
|
+
|
|
89
|
+
const event_id = randomUUID();
|
|
90
|
+
const entry = {
|
|
91
|
+
event_id,
|
|
92
|
+
property_id: req.params.property_id,
|
|
93
|
+
device_id: device_id ?? null,
|
|
94
|
+
event_type: event_type ?? null,
|
|
95
|
+
severity: severity ?? null,
|
|
96
|
+
timestamp: timestamp ?? new Date().toISOString(),
|
|
97
|
+
metadata: metadata ?? null,
|
|
98
|
+
signature_present: true,
|
|
99
|
+
received_at: new Date().toISOString()
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
await logEvent(entry);
|
|
103
|
+
|
|
104
|
+
return res.status(202).json({
|
|
105
|
+
received: true,
|
|
106
|
+
event_id
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export default router;
|
package/.env.example
DELETED
package/CONTRIBUTING.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# Contributing to Guesty MCP Server
|
|
2
|
-
|
|
3
|
-
Thank you for your interest in contributing! This is the first MCP server for property management, and community contributions help make it better for everyone.
|
|
4
|
-
|
|
5
|
-
## Getting Started
|
|
6
|
-
|
|
7
|
-
1. Fork the repository
|
|
8
|
-
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/guesty-mcp-server.git`
|
|
9
|
-
3. Install dependencies: `npm install`
|
|
10
|
-
4. Create a `.env` file with your Guesty API credentials (see `.env.example`)
|
|
11
|
-
5. Run the server: `npm start`
|
|
12
|
-
|
|
13
|
-
## Adding New Tools
|
|
14
|
-
|
|
15
|
-
Each tool wraps a Guesty API endpoint. To add a new tool:
|
|
16
|
-
|
|
17
|
-
1. Identify the Guesty API endpoint from [Guesty Open API docs](https://open-api.guesty.com/api-docs)
|
|
18
|
-
2. Add the tool in `src/server.js` using the `server.tool()` pattern
|
|
19
|
-
3. Follow the existing naming convention: `verb_noun` (e.g., `get_reservations`, `update_pricing`)
|
|
20
|
-
4. Include proper Zod schema validation for all parameters
|
|
21
|
-
5. Return structured JSON in the response
|
|
22
|
-
|
|
23
|
-
### Tool Template
|
|
24
|
-
|
|
25
|
-
```javascript
|
|
26
|
-
server.tool(
|
|
27
|
-
"tool_name",
|
|
28
|
-
"Clear description of what this tool does",
|
|
29
|
-
{
|
|
30
|
-
param1: z.string().describe("What this parameter does"),
|
|
31
|
-
param2: z.number().optional().describe("Optional parameter"),
|
|
32
|
-
},
|
|
33
|
-
async (params) => {
|
|
34
|
-
const data = await guestyGet("/endpoint", { key: params.param1 });
|
|
35
|
-
return {
|
|
36
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
);
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
## Desired Contributions
|
|
43
|
-
|
|
44
|
-
We'd love help with:
|
|
45
|
-
- **New tools**: Reviews management, task/cleaning scheduling, owner statements, channel management
|
|
46
|
-
- **Testing**: Integration tests, error handling edge cases
|
|
47
|
-
- **Documentation**: Usage examples, video tutorials, blog posts
|
|
48
|
-
- **PMS integrations**: Adapting this pattern for Hostaway, Lodgify, OwnerRez, etc.
|
|
49
|
-
|
|
50
|
-
## Code Style
|
|
51
|
-
|
|
52
|
-
- ES modules (import/export)
|
|
53
|
-
- Async/await for all API calls
|
|
54
|
-
- Descriptive error messages
|
|
55
|
-
- Keep tools focused -- one API action per tool
|
|
56
|
-
|
|
57
|
-
## Pull Requests
|
|
58
|
-
|
|
59
|
-
1. Create a feature branch: `git checkout -b feature/new-tool`
|
|
60
|
-
2. Make your changes
|
|
61
|
-
3. Test against the Guesty API
|
|
62
|
-
4. Submit a PR with a clear description of what the tool does and why
|
|
63
|
-
|
|
64
|
-
## Questions?
|
|
65
|
-
|
|
66
|
-
Open an issue on GitHub or reach out to [DLJ Properties](https://tinyhomeboutiques.com).
|