guesty-mcp-server 0.7.0 → 0.8.2
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 +41 -0
- package/README.md +19 -2
- package/package.json +5 -6
- package/server.json +4 -4
- 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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Guesty MCP Server will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.2] - 2026-04-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- npm description synced to "43 production tools" (was stale at "38 tools" on npm page)
|
|
9
|
+
- Removed `claude-code` and `openclaw` from npm keywords (AI-disclosure hygiene)
|
|
10
|
+
- Added `iot` and `enterprise` keywords for discoverability
|
|
11
|
+
|
|
12
|
+
## [0.8.1] - 2026-04-19
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Added `.npmignore` to exclude token files, tests, and non-essential markdown from npm package
|
|
16
|
+
- Added `.mcpregistry_*` patterns to `.gitignore` (credential hygiene)
|
|
17
|
+
- Package size reduced from 42.3kB to 35.0kB (26→23 files)
|
|
18
|
+
|
|
19
|
+
## [0.8.0] - 2026-04-17
|
|
20
|
+
|
|
21
|
+
### Changed — Enterprise Tier MVP Merge (Owner-approved option-c path, msg 6406)
|
|
22
|
+
- Enterprise aggregators (`get_property_health`, `submit_checkout_photos`,
|
|
23
|
+
`get_maintenance_alerts`) now layer Guesty-side data (reservation status,
|
|
24
|
+
review score, last-clean timestamp) on top of IoT helpers. Single-call
|
|
25
|
+
snapshots for ops dashboards.
|
|
26
|
+
- IoT-only handlers extracted from `iot-tools.js` to internal async helpers
|
|
27
|
+
(`getIoTPropertyHealth`, `submitIoTCheckoutPhotos`, `getIoTMaintenanceAlerts`);
|
|
28
|
+
canonical MCP tool registration moved to `enterprise-tools.js`.
|
|
29
|
+
- Graceful degradation: Guesty sub-fetch failures degrade to null value +
|
|
30
|
+
per-field error note (aggregator still returns IoT data).
|
|
31
|
+
- `iot-tools.js` retains single MCP registration for `get_readiness_score`.
|
|
32
|
+
- Tool count reconciled across README + license.js + package.json + server.json
|
|
33
|
+
to 43 total (39 Guesty + 1 IoT + 3 Enterprise aggregators). Previous 3-way
|
|
34
|
+
drift (README:38, license.js:38, actual registrations:43) resolved.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `__handlers` export on `enterprise-tools.js` for direct smoke-test invocation
|
|
38
|
+
(renamed from legacy `__stubs` — real handlers, not stubs, post-merge).
|
|
39
|
+
- `tests/test-enterprise.js` rewritten: exports + free-tier-gate + enterprise-lift
|
|
40
|
+
smoke tests. Dynamic import + env-stub so test runs without real Guesty creds.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- `package.json` test script referenced non-existent `tests/test-tools.js`.
|
|
44
|
+
Now runs `tests/test-enterprise.js && tests/test-iot.js`.
|
|
45
|
+
|
|
5
46
|
## [0.7.0] - 2026-04-15
|
|
6
47
|
|
|
7
48
|
### Added
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
The first MCP (Model Context Protocol) server for [Guesty](https://guesty.com) property management. Connect AI agents directly to your Guesty account to manage reservations, communicate with guests, track finances, and update pricing -- all autonomously.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**43 tools** covering reservations, listings, guests, messaging, financials, tasks, calendars, webhooks, and pricing — plus **1 IoT tool** (`get_readiness_score`) and **3 Enterprise-tier aggregators** (`get_property_health`, `submit_checkout_photos`, `get_maintenance_alerts`) for property health aggregation, checkout photo intake, and portfolio maintenance alerts.
|
|
9
9
|
|
|
10
10
|
> **Want AI to handle your guest messages 24/7?** [Guesty Copilot](https://guestycopilot.com) -- AI guest management for Guesty hosts, built on this MCP server. Now in beta.
|
|
11
11
|
|
|
@@ -41,7 +41,7 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
|
|
|
41
41
|
3. Create an API application with `open-api` scope
|
|
42
42
|
4. Copy your **Client ID** and **Client Secret**
|
|
43
43
|
|
|
44
|
-
## All
|
|
44
|
+
## All 43 Tools
|
|
45
45
|
|
|
46
46
|
### Reservations & Guests
|
|
47
47
|
| Tool | Description |
|
|
@@ -105,6 +105,15 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
|
|
|
105
105
|
| `get_custom_fields` | Fetch custom fields for listings or reservations |
|
|
106
106
|
| `get_account_info` | Get account info and subscription details |
|
|
107
107
|
|
|
108
|
+
### Enterprise Tier
|
|
109
|
+
| Tool | Description |
|
|
110
|
+
|------|-------------|
|
|
111
|
+
| `get_property_health` | Aggregate health signal per property: reservation status, open maintenance alerts, review-score, last-clean timestamp, IoT hub status |
|
|
112
|
+
| `submit_checkout_photos` | Accept post-checkout photo uploads and log them to the property's maintenance/cleaning record |
|
|
113
|
+
| `get_maintenance_alerts` | List or filter open maintenance alerts for a property or portfolio |
|
|
114
|
+
|
|
115
|
+
Requires `GUESTY_MCP_LICENSE_KEY` with an Enterprise key (`gmcp_ent_*`). See [pricing](https://guestycopilot.com/pricing).
|
|
116
|
+
|
|
108
117
|
## Use Cases
|
|
109
118
|
|
|
110
119
|
- **Guest Communication**: AI agents auto-respond to guest inquiries using real reservation data
|
|
@@ -119,6 +128,14 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
|
|
|
119
128
|
- Guesty account with API access (Professional plan or higher)
|
|
120
129
|
- MCP-compatible AI client (Claude Code, Cursor, Windsurf, etc.)
|
|
121
130
|
|
|
131
|
+
## Environment Variables
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Purpose |
|
|
134
|
+
|----------|---------|---------|
|
|
135
|
+
| `GUESTY_CLIENT_ID` | — | OAuth2 client id (required) |
|
|
136
|
+
| `GUESTY_CLIENT_SECRET` | — | OAuth2 client secret (required) |
|
|
137
|
+
| `IOT_WEBHOOK_PORT` | `3100` | Port for the Enterprise-tier IoT webhook receiver stub (`src/webhook/iot-receiver-server.js`). Local/reverse-proxy only — do not expose publicly. Production requires a reverse proxy that terminates TLS and enforces real HMAC against `IOT_WEBHOOK_SECRET`. |
|
|
138
|
+
|
|
122
139
|
## API Reference
|
|
123
140
|
|
|
124
141
|
This server wraps the [Guesty Open API](https://open-api.guesty.com/api-docs). Authentication uses OAuth2 client credentials flow with automatic token caching, retry logic, and rate limit handling.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guesty-mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.2",
|
|
4
|
+
"description": "MCP server for Guesty property management — 43 production tools covering reservations, guests, messaging, pricing, revenue, tasks, webhooks, and IoT/property-health Enterprise tier.",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"guesty-mcp-server": "src/server.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"scripts": {
|
|
12
12
|
"start": "node src/server.js",
|
|
13
|
-
"test": "node tests/test-
|
|
13
|
+
"test": "node tests/test-enterprise.js && node tests/test-iot.js"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"mcp",
|
|
@@ -23,10 +23,9 @@
|
|
|
23
23
|
"vrbo",
|
|
24
24
|
"vacation-rental",
|
|
25
25
|
"ai-agent",
|
|
26
|
-
"claude",
|
|
27
|
-
"claude-code",
|
|
28
|
-
"openclaw",
|
|
29
26
|
"pms",
|
|
27
|
+
"iot",
|
|
28
|
+
"enterprise",
|
|
30
29
|
"hospitality",
|
|
31
30
|
"real-estate",
|
|
32
31
|
"automation"
|
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": "MCP server for Guesty property management
|
|
6
|
-
"version": "0.
|
|
5
|
+
"description": "MCP server for Guesty property management. 43 tools for STR operations.",
|
|
6
|
+
"version": "0.8.1",
|
|
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.8.1",
|
|
15
15
|
"transport": { "type": "stdio" },
|
|
16
16
|
"environmentVariables": [
|
|
17
17
|
{
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
]
|
|
39
39
|
}],
|
|
40
40
|
"remotes": [{
|
|
41
|
-
"
|
|
41
|
+
"type": "streamable-http",
|
|
42
42
|
"url": "https://guesty-mcp-server.vercel.app"
|
|
43
43
|
}]
|
|
44
44
|
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guesty MCP Server — Enterprise Tier Aggregation Tools
|
|
3
|
+
*
|
|
4
|
+
* Three Enterprise-tier MCP tools that aggregate IoT device signal with
|
|
5
|
+
* Guesty-side data (reservation status, review score, last-clean timestamp)
|
|
6
|
+
* into single-call snapshots for ops dashboards.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* - get_property_health aggregated single-property snapshot
|
|
10
|
+
* (IoT devices + reservation + reviews + clean)
|
|
11
|
+
* - submit_checkout_photos post-checkout photo intake (writes through to
|
|
12
|
+
* the IoT baseline store via iot-db.js)
|
|
13
|
+
* - get_maintenance_alerts active IoT maintenance alerts, filterable
|
|
14
|
+
* per listing or portfolio-wide
|
|
15
|
+
*
|
|
16
|
+
* ── MERGE STATUS (2026-04-17, Owner greenlight msg 6406) ────────────────
|
|
17
|
+
* These tools are now the CANONICAL registrations at these names. The
|
|
18
|
+
* same names previously lived in iot-tools.js as IoT-only views — those
|
|
19
|
+
* have been extracted into plain-async internal helpers and are imported
|
|
20
|
+
* here. This is the Owner-approved option-c merge path: one tool name
|
|
21
|
+
* per MCP surface, Enterprise aggregator wraps the IoT getter.
|
|
22
|
+
*
|
|
23
|
+
* Guesty-side layering (reservation status, review score, last-clean)
|
|
24
|
+
* uses guestyGet() from server.js. Each call is wrapped in try/catch so
|
|
25
|
+
* the aggregator degrades gracefully — if Guesty is slow or fails on one
|
|
26
|
+
* sub-query, the tool still returns IoT data with the failing field set
|
|
27
|
+
* to null + a non-fatal error note.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { guestyGet } from "./server.js";
|
|
32
|
+
import {
|
|
33
|
+
enterpriseGated,
|
|
34
|
+
getIoTPropertyHealth,
|
|
35
|
+
submitIoTCheckoutPhotos,
|
|
36
|
+
getIoTMaintenanceAlerts,
|
|
37
|
+
} from "./iot-tools.js";
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Graceful Guesty fetchers — each returns { value, error } so the aggregator
|
|
41
|
+
// can surface partial data even when one sub-call fails.
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function safeFetchReservationStatus(listingId) {
|
|
45
|
+
try {
|
|
46
|
+
// Current active / upcoming reservation for this listing
|
|
47
|
+
const data = await guestyGet("/v1/reservations", {
|
|
48
|
+
listingId,
|
|
49
|
+
limit: 1,
|
|
50
|
+
sort: "-checkIn",
|
|
51
|
+
"filters[status]": "confirmed,checked_in",
|
|
52
|
+
});
|
|
53
|
+
const r = (data && data.results && data.results[0]) || null;
|
|
54
|
+
if (!r) return { value: { status: "no_active_reservation" }, error: null };
|
|
55
|
+
return {
|
|
56
|
+
value: {
|
|
57
|
+
reservation_id: r._id,
|
|
58
|
+
status: r.status,
|
|
59
|
+
check_in: r.checkIn,
|
|
60
|
+
check_out: r.checkOut,
|
|
61
|
+
guest: r.guest && r.guest.fullName,
|
|
62
|
+
},
|
|
63
|
+
error: null,
|
|
64
|
+
};
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { value: null, error: e.message };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function safeFetchReviewScore(listingId) {
|
|
71
|
+
try {
|
|
72
|
+
const data = await guestyGet("/v1/reviews", { listingId, limit: 50 });
|
|
73
|
+
const reviews = (data && data.results) || [];
|
|
74
|
+
if (reviews.length === 0) {
|
|
75
|
+
return { value: { average: null, count: 0 }, error: null };
|
|
76
|
+
}
|
|
77
|
+
const sum = reviews.reduce(
|
|
78
|
+
(acc, rv) => acc + (typeof rv.overallRating === "number" ? rv.overallRating : 0),
|
|
79
|
+
0
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
value: {
|
|
83
|
+
average: Math.round((sum / reviews.length) * 10) / 10,
|
|
84
|
+
count: reviews.length,
|
|
85
|
+
},
|
|
86
|
+
error: null,
|
|
87
|
+
};
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { value: null, error: e.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function safeFetchLastClean(listingId) {
|
|
94
|
+
try {
|
|
95
|
+
// Tasks filtered to cleaning/turnover type, sorted by completion time desc
|
|
96
|
+
const data = await guestyGet("/v1/tasks", {
|
|
97
|
+
"filters[listing]": listingId,
|
|
98
|
+
"filters[type]": "cleaning,turnover",
|
|
99
|
+
"filters[status]": "completed",
|
|
100
|
+
limit: 1,
|
|
101
|
+
sort: "-completedAt",
|
|
102
|
+
});
|
|
103
|
+
const t = (data && data.results && data.results[0]) || null;
|
|
104
|
+
if (!t) return { value: { completed_at: null }, error: null };
|
|
105
|
+
return {
|
|
106
|
+
value: {
|
|
107
|
+
completed_at: t.completedAt || t.updatedAt || null,
|
|
108
|
+
task_id: t._id,
|
|
109
|
+
assignee: t.assignee && t.assignee.fullName,
|
|
110
|
+
},
|
|
111
|
+
error: null,
|
|
112
|
+
};
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return { value: null, error: e.message };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// Utility: wrap a raw data object in the MCP text-content envelope.
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
121
|
+
function envelope(obj) {
|
|
122
|
+
return {
|
|
123
|
+
content: [
|
|
124
|
+
{ type: "text", text: JSON.stringify(obj, null, 2) },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function errorEnvelope(message, details) {
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "text",
|
|
134
|
+
text: JSON.stringify({ error: message, details: details || null }, null, 2),
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
isError: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Register the 3 Enterprise-tier aggregation tools on the MCP server.
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export function registerEnterpriseTools(server) {
|
|
146
|
+
// ── Tool 1: get_property_health (aggregated) ────────────────────
|
|
147
|
+
server.tool(
|
|
148
|
+
"get_property_health",
|
|
149
|
+
"Aggregate health signal per property: IoT device status + overall IoT state, current reservation status, 50-review average score, and last-clean timestamp. Single-call snapshot for ops dashboards. Sub-fetches that fail degrade gracefully (null value + error note).",
|
|
150
|
+
{
|
|
151
|
+
listingId: z.string().describe("The Guesty listing ID for the property"),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
readOnlyHint: true,
|
|
155
|
+
destructiveHint: false,
|
|
156
|
+
idempotentHint: true,
|
|
157
|
+
openWorldHint: false,
|
|
158
|
+
},
|
|
159
|
+
enterpriseGated("get_property_health", async (params) => {
|
|
160
|
+
try {
|
|
161
|
+
const [iot, reservation, reviews, lastClean] = await Promise.all([
|
|
162
|
+
getIoTPropertyHealth(params.listingId),
|
|
163
|
+
safeFetchReservationStatus(params.listingId),
|
|
164
|
+
safeFetchReviewScore(params.listingId),
|
|
165
|
+
safeFetchLastClean(params.listingId),
|
|
166
|
+
]);
|
|
167
|
+
const partialErrors = [];
|
|
168
|
+
if (reservation.error) partialErrors.push({ field: "reservation_status", error: reservation.error });
|
|
169
|
+
if (reviews.error) partialErrors.push({ field: "review_score", error: reviews.error });
|
|
170
|
+
if (lastClean.error) partialErrors.push({ field: "last_clean", error: lastClean.error });
|
|
171
|
+
|
|
172
|
+
return envelope({
|
|
173
|
+
property_id: params.listingId,
|
|
174
|
+
iot: {
|
|
175
|
+
devices: iot.devices,
|
|
176
|
+
overall_status: iot.overall_status,
|
|
177
|
+
device_count: iot.devices.length,
|
|
178
|
+
},
|
|
179
|
+
reservation_status: reservation.value,
|
|
180
|
+
review_score: reviews.value,
|
|
181
|
+
last_clean: lastClean.value,
|
|
182
|
+
partial_errors: partialErrors.length ? partialErrors : null,
|
|
183
|
+
fetched_at: new Date().toISOString(),
|
|
184
|
+
});
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return errorEnvelope("Failed to aggregate property health.", e.message);
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// ── Tool 2: submit_checkout_photos ──────────────────────────────
|
|
192
|
+
server.tool(
|
|
193
|
+
"submit_checkout_photos",
|
|
194
|
+
"Accept post-checkout photo uploads and log them to the property's maintenance/cleaning record. Photos are queued for downstream inspection workflows (Phase 2 vision comparison).",
|
|
195
|
+
{
|
|
196
|
+
listingId: z
|
|
197
|
+
.string()
|
|
198
|
+
.describe("The Guesty listing ID for the property"),
|
|
199
|
+
reservationId: z
|
|
200
|
+
.string()
|
|
201
|
+
.describe("The reservation ID this checkout is for"),
|
|
202
|
+
photos: z
|
|
203
|
+
.array(z.string())
|
|
204
|
+
.describe("Array of photo URLs or file paths to submit"),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
readOnlyHint: false,
|
|
208
|
+
destructiveHint: false,
|
|
209
|
+
idempotentHint: false,
|
|
210
|
+
openWorldHint: false,
|
|
211
|
+
},
|
|
212
|
+
enterpriseGated("submit_checkout_photos", async (params) => {
|
|
213
|
+
try {
|
|
214
|
+
const result = await submitIoTCheckoutPhotos({
|
|
215
|
+
listingId: params.listingId,
|
|
216
|
+
reservationId: params.reservationId,
|
|
217
|
+
photos: params.photos,
|
|
218
|
+
});
|
|
219
|
+
return envelope(result);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return errorEnvelope("Failed to submit checkout photos.", e.message);
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// ── Tool 3: get_maintenance_alerts (portfolio-capable) ──────────
|
|
227
|
+
server.tool(
|
|
228
|
+
"get_maintenance_alerts",
|
|
229
|
+
"List or filter open maintenance alerts for a specific property or across the whole portfolio. Supports severity filtering and active-only (unresolved) filtering. IoT-sourced today; future Phase will merge Guesty-native task alerts.",
|
|
230
|
+
{
|
|
231
|
+
listingId: z
|
|
232
|
+
.string()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Filter alerts to a specific listing ID. Omit for portfolio-wide."),
|
|
235
|
+
severity: z
|
|
236
|
+
.enum(["critical", "warning", "info", "all"])
|
|
237
|
+
.optional()
|
|
238
|
+
.default("all")
|
|
239
|
+
.describe("Filter by alert severity level (default: all)"),
|
|
240
|
+
active_only: z
|
|
241
|
+
.boolean()
|
|
242
|
+
.optional()
|
|
243
|
+
.default(true)
|
|
244
|
+
.describe("Only return active (unresolved) alerts (default: true)"),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
readOnlyHint: true,
|
|
248
|
+
destructiveHint: false,
|
|
249
|
+
idempotentHint: true,
|
|
250
|
+
openWorldHint: false,
|
|
251
|
+
},
|
|
252
|
+
enterpriseGated("get_maintenance_alerts", async (params) => {
|
|
253
|
+
try {
|
|
254
|
+
const result = await getIoTMaintenanceAlerts({
|
|
255
|
+
listingId: params.listingId,
|
|
256
|
+
severity: params.severity,
|
|
257
|
+
activeOnly: params.active_only,
|
|
258
|
+
});
|
|
259
|
+
return envelope({
|
|
260
|
+
...result,
|
|
261
|
+
scope: params.listingId ? "listing" : "portfolio",
|
|
262
|
+
fetched_at: new Date().toISOString(),
|
|
263
|
+
});
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return errorEnvelope("Failed to retrieve maintenance alerts.", e.message);
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Exported for direct invocation from smoke tests without constructing
|
|
272
|
+
// a full McpServer instance. Each is a real call now, not a stub — tests
|
|
273
|
+
// may need to stub the IoT db + guestyGet for isolated execution.
|
|
274
|
+
export const __handlers = {
|
|
275
|
+
get_property_health: enterpriseGated("get_property_health", async (params) => {
|
|
276
|
+
const [iot, reservation, reviews, lastClean] = await Promise.all([
|
|
277
|
+
getIoTPropertyHealth(params.listingId),
|
|
278
|
+
safeFetchReservationStatus(params.listingId),
|
|
279
|
+
safeFetchReviewScore(params.listingId),
|
|
280
|
+
safeFetchLastClean(params.listingId),
|
|
281
|
+
]);
|
|
282
|
+
return envelope({
|
|
283
|
+
property_id: params.listingId,
|
|
284
|
+
iot: {
|
|
285
|
+
devices: iot.devices,
|
|
286
|
+
overall_status: iot.overall_status,
|
|
287
|
+
device_count: iot.devices.length,
|
|
288
|
+
},
|
|
289
|
+
reservation_status: reservation.value,
|
|
290
|
+
review_score: reviews.value,
|
|
291
|
+
last_clean: lastClean.value,
|
|
292
|
+
partial_errors: [
|
|
293
|
+
reservation.error && { field: "reservation_status", error: reservation.error },
|
|
294
|
+
reviews.error && { field: "review_score", error: reviews.error },
|
|
295
|
+
lastClean.error && { field: "last_clean", error: lastClean.error },
|
|
296
|
+
].filter(Boolean),
|
|
297
|
+
fetched_at: new Date().toISOString(),
|
|
298
|
+
});
|
|
299
|
+
}),
|
|
300
|
+
submit_checkout_photos: enterpriseGated("submit_checkout_photos", async (params) => {
|
|
301
|
+
const result = await submitIoTCheckoutPhotos({
|
|
302
|
+
listingId: params.listingId,
|
|
303
|
+
reservationId: params.reservationId,
|
|
304
|
+
photos: params.photos,
|
|
305
|
+
});
|
|
306
|
+
return envelope(result);
|
|
307
|
+
}),
|
|
308
|
+
get_maintenance_alerts: enterpriseGated("get_maintenance_alerts", async (params) => {
|
|
309
|
+
const result = await getIoTMaintenanceAlerts({
|
|
310
|
+
listingId: params.listingId,
|
|
311
|
+
severity: params.severity,
|
|
312
|
+
activeOnly: params.active_only,
|
|
313
|
+
});
|
|
314
|
+
return envelope({
|
|
315
|
+
...result,
|
|
316
|
+
scope: params.listingId ? "listing" : "portfolio",
|
|
317
|
+
fetched_at: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
}),
|
|
320
|
+
};
|
package/src/http-server.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import express from "express";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import iotRouter from "./iot-webhook.js";
|
|
9
|
+
import iotReceiverRouter from "./webhook/iot-receiver.js";
|
|
9
10
|
import { initDB } from "./iot-db.js";
|
|
10
11
|
|
|
11
12
|
const app = express();
|
|
@@ -15,6 +16,13 @@ app.use(express.json());
|
|
|
15
16
|
initDB();
|
|
16
17
|
app.use(iotRouter);
|
|
17
18
|
|
|
19
|
+
// Enterprise Tier — inbound IoT event receiver (stub).
|
|
20
|
+
// Mounted on the same app so hosted deployments can accept events at
|
|
21
|
+
// /webhook/iot/:property_id. For local dev, the standalone boot file
|
|
22
|
+
// src/webhook/iot-receiver-server.js exposes the same router on
|
|
23
|
+
// IOT_WEBHOOK_PORT (default 3100).
|
|
24
|
+
app.use(iotReceiverRouter);
|
|
25
|
+
|
|
18
26
|
const PORT = process.env.PORT || 3001;
|
|
19
27
|
|
|
20
28
|
// Request counter
|
package/src/iot-db.js
CHANGED
|
@@ -9,11 +9,25 @@
|
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
11
11
|
import { homedir } from "os";
|
|
12
|
-
import { join } from "path";
|
|
12
|
+
import { join, dirname } from "path";
|
|
13
13
|
import { randomUUID } from "crypto";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// R5_TASK_106_IOT_DB_ENV_OVERRIDE (2026-04-18 CTO): honor IOT_DB_PATH env var
|
|
16
|
+
// so integration tests can isolate to a temp DB without mutating prod data.
|
|
17
|
+
// Unset env var = current prod behavior (default path). Tests set it at top
|
|
18
|
+
// of harness to `os.tmpdir()/iot-test-<ts>.json`.
|
|
19
|
+
//
|
|
20
|
+
// Lazy evaluation: ES module bodies evaluate at import time, BEFORE test
|
|
21
|
+
// harness code runs, so top-level `const DB_PATH = process.env.X || ...`
|
|
22
|
+
// would capture the pre-test env state. getDbPath()/getDataDir() resolve
|
|
23
|
+
// on first call (inside load/flush), after the test has set the env var.
|
|
24
|
+
const DEFAULT_DB_PATH = join(homedir(), ".dlj-scripts", "data", "iot-devices.json");
|
|
25
|
+
function getDbPath() {
|
|
26
|
+
return process.env.IOT_DB_PATH || DEFAULT_DB_PATH;
|
|
27
|
+
}
|
|
28
|
+
function getDataDir() {
|
|
29
|
+
return dirname(getDbPath());
|
|
30
|
+
}
|
|
17
31
|
|
|
18
32
|
// ── Schema shape ───────────────────────────────────────────────────────────
|
|
19
33
|
|
|
@@ -37,12 +51,14 @@ let _db = null;
|
|
|
37
51
|
|
|
38
52
|
function load() {
|
|
39
53
|
if (_db) return _db;
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
const dataDir = getDataDir();
|
|
55
|
+
const dbPath = getDbPath();
|
|
56
|
+
if (!existsSync(dataDir)) {
|
|
57
|
+
mkdirSync(dataDir, { recursive: true });
|
|
42
58
|
}
|
|
43
|
-
if (existsSync(
|
|
59
|
+
if (existsSync(dbPath)) {
|
|
44
60
|
try {
|
|
45
|
-
_db = JSON.parse(readFileSync(
|
|
61
|
+
_db = JSON.parse(readFileSync(dbPath, "utf-8"));
|
|
46
62
|
// Ensure all top-level keys exist (forward compat)
|
|
47
63
|
const empty = emptyDB();
|
|
48
64
|
for (const key of Object.keys(empty)) {
|
|
@@ -61,7 +77,7 @@ function load() {
|
|
|
61
77
|
function flush() {
|
|
62
78
|
const db = load();
|
|
63
79
|
db._meta.last_write = new Date().toISOString();
|
|
64
|
-
writeFileSync(
|
|
80
|
+
writeFileSync(getDbPath(), JSON.stringify(db, null, 2), "utf-8");
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
@@ -73,8 +89,9 @@ function flush() {
|
|
|
73
89
|
export function initDB() {
|
|
74
90
|
load();
|
|
75
91
|
flush();
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
const dbPath = getDbPath();
|
|
93
|
+
console.log(`[iot-db] Initialized at ${dbPath}`);
|
|
94
|
+
return dbPath;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
// ── Devices ────────────────────────────────────────────────────────────────
|