guesty-mcp-server 0.8.2 → 0.9.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 +10 -0
- package/README.md +2 -0
- package/hero.png +0 -0
- package/package.json +2 -2
- package/package.json.pre-resources-20260420-232423 +49 -0
- package/package.json.pre-v092-stub-20260521-1751 +49 -0
- package/server.json +40 -34
- package/src/http-server.js +19 -16
- package/src/license.js +117 -18
- package/src/license.js.bak.20260426-tier-enforcement +117 -0
- package/src/license.js.pre-v092-stub-20260521-1751 +163 -0
- package/src/resources.js +273 -0
- package/src/server.js +134 -20
- package/src/server.js.pre-issue1-20260422-181941 +1438 -0
- package/src/server.js.pre-resources-20260420-232423 +1431 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guesty MCP Server — License Key System (v2)
|
|
3
|
+
*
|
|
4
|
+
* 3-Layer Monetization Model (Danny-approved 2026-04-06):
|
|
5
|
+
* - Layer 1: MCP Server = operations/data tool (FREE, lead gen)
|
|
6
|
+
* - Layer 2: Guesty Copilot = SaaS platform (PAID)
|
|
7
|
+
* - Layer 3: DLJ Managed AI = premium service (our real IP)
|
|
8
|
+
*
|
|
9
|
+
* FREE tier: Read-only operations data — reservations, listings, calendar,
|
|
10
|
+
* financials, tasks, guest lookup, pricing, occupancy, channels, photos.
|
|
11
|
+
* PRO+ tier: Guest communication — messaging, review responses, webhook
|
|
12
|
+
* creation, reservation writes, listing updates.
|
|
13
|
+
*
|
|
14
|
+
* Reads GUESTY_MCP_LICENSE_KEY from env.
|
|
15
|
+
* No key or invalid key = free tier (operations data only).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Free tier: read-only operations and data tools
|
|
19
|
+
const FREE_TOOLS = [
|
|
20
|
+
// Reservations (read-only)
|
|
21
|
+
"get_reservations",
|
|
22
|
+
"search_reservations",
|
|
23
|
+
"get_reservation_financials",
|
|
24
|
+
// Listings (read-only)
|
|
25
|
+
"get_listing",
|
|
26
|
+
"get_listing_occupancy",
|
|
27
|
+
"get_listing_pricing",
|
|
28
|
+
"get_photos",
|
|
29
|
+
// Guests (read-only)
|
|
30
|
+
"get_guests",
|
|
31
|
+
"get_guest_by_id",
|
|
32
|
+
// Calendar (read-only)
|
|
33
|
+
"get_calendar",
|
|
34
|
+
"get_calendar_blocks",
|
|
35
|
+
// Financials (read-only)
|
|
36
|
+
"get_financials",
|
|
37
|
+
"get_owner_statements",
|
|
38
|
+
"get_expenses",
|
|
39
|
+
"get_revenue_summary",
|
|
40
|
+
// Operations (read-only)
|
|
41
|
+
"get_tasks",
|
|
42
|
+
"get_channels",
|
|
43
|
+
"get_automation_rules",
|
|
44
|
+
"get_custom_fields",
|
|
45
|
+
"get_account_info",
|
|
46
|
+
"get_supported_languages",
|
|
47
|
+
// Reviews (read-only)
|
|
48
|
+
"get_reviews",
|
|
49
|
+
// Webhooks (read-only)
|
|
50
|
+
"get_webhooks",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// Enterprise tier: IoT + property aggregator tools (4 total)
|
|
54
|
+
// These ship in active validation per data-integrity gate; require Enterprise license.
|
|
55
|
+
const ENT_TOOLS = [
|
|
56
|
+
"get_readiness_score",
|
|
57
|
+
"get_property_health",
|
|
58
|
+
"submit_checkout_photos",
|
|
59
|
+
"get_maintenance_alerts",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Business tier: operational/SLA features the binary advertises (consumed by getTierInfo).
|
|
63
|
+
// No per-call tool gating — Business unlocks the same 39 base tools as Pro plus the
|
|
64
|
+
// multi-account license terms and priority support promised by guestycopilot.com pricing.
|
|
65
|
+
const BIZ_FEATURES = {
|
|
66
|
+
multiAccountLicense: true,
|
|
67
|
+
prioritySupport: true,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// PRO+ gated tools: guest communication, writes, and real-time events
|
|
71
|
+
// send_guest_message, respond_to_review, create_webhook, delete_webhook,
|
|
72
|
+
// create_reservation, update_reservation, create_reservation_note,
|
|
73
|
+
// update_pricing, update_calendar, update_listing, update_photos,
|
|
74
|
+
// update_listing_pricing, create_expense, create_task,
|
|
75
|
+
// get_conversations (contains message content)
|
|
76
|
+
|
|
77
|
+
// Simple key-to-tier mapping
|
|
78
|
+
// Production: Stripe webhook or DB lookup
|
|
79
|
+
// For now: keys prefixed gmcp_pro_*, gmcp_biz_*, gmcp_ent_*
|
|
80
|
+
function resolveTier(licenseKey) {
|
|
81
|
+
if (!licenseKey) return "free";
|
|
82
|
+
const key = licenseKey.trim();
|
|
83
|
+
if (key.startsWith("gmcp_ent_")) return "enterprise";
|
|
84
|
+
if (key.startsWith("gmcp_biz_")) return "business";
|
|
85
|
+
if (key.startsWith("gmcp_pro_")) return "pro";
|
|
86
|
+
if (key === "test_pro") return "pro";
|
|
87
|
+
if (key === "test_biz") return "business";
|
|
88
|
+
if (key === "test_ent") return "enterprise";
|
|
89
|
+
return "free";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getTier() {
|
|
93
|
+
return resolveTier(process.env.GUESTY_MCP_LICENSE_KEY);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isToolAllowed(toolName) {
|
|
97
|
+
const tier = getTier();
|
|
98
|
+
if (tier === "free") {
|
|
99
|
+
return FREE_TOOLS.includes(toolName);
|
|
100
|
+
}
|
|
101
|
+
// Enterprise-tier tools require enterprise license
|
|
102
|
+
if (ENT_TOOLS.includes(toolName)) {
|
|
103
|
+
return tier === "enterprise";
|
|
104
|
+
}
|
|
105
|
+
// All other paid tools (Pro+) allowed for pro / business / enterprise
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getTierInfo() {
|
|
110
|
+
const tier = getTier();
|
|
111
|
+
// 39 Guesty core tools + 1 IoT (get_readiness_score) + 3 Enterprise aggregators
|
|
112
|
+
// (get_property_health, submit_checkout_photos, get_maintenance_alerts) = 43.
|
|
113
|
+
// Updated 2026-04-17 for Enterprise Tier MVP merge (Owner msg 6406).
|
|
114
|
+
const totalTools = 43;
|
|
115
|
+
const entToolCount = ENT_TOOLS.length;
|
|
116
|
+
const baseToolCount = totalTools - entToolCount; // 39
|
|
117
|
+
const accessibleCount =
|
|
118
|
+
tier === "free" ? FREE_TOOLS.length :
|
|
119
|
+
tier === "enterprise" ? totalTools :
|
|
120
|
+
baseToolCount; // pro / business
|
|
121
|
+
return {
|
|
122
|
+
tier,
|
|
123
|
+
hasKey: !!process.env.GUESTY_MCP_LICENSE_KEY,
|
|
124
|
+
freeToolCount: FREE_TOOLS.length,
|
|
125
|
+
baseToolCount,
|
|
126
|
+
entToolCount,
|
|
127
|
+
accessibleToolCount: accessibleCount,
|
|
128
|
+
gatedToolCount: totalTools - accessibleCount,
|
|
129
|
+
unlocked: tier !== "free",
|
|
130
|
+
bizFeatures: tier === "business" || tier === "enterprise" ? BIZ_FEATURES : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function gatedHandler(toolName, handler) {
|
|
135
|
+
return async (params) => {
|
|
136
|
+
if (!isToolAllowed(toolName)) {
|
|
137
|
+
const tier = getTier();
|
|
138
|
+
let msg;
|
|
139
|
+
if (tier === "free") {
|
|
140
|
+
msg = "This tool (" + toolName + ") requires a Pro or higher license. " +
|
|
141
|
+
"Free tier includes " + FREE_TOOLS.length + " operations and data tools. " +
|
|
142
|
+
"Guest messaging, review responses, and write operations require Pro+. " +
|
|
143
|
+
"Upgrade at https://guestycopilot.com/pricing -- " +
|
|
144
|
+
"Set GUESTY_MCP_LICENSE_KEY env var to unlock all 43 tools.";
|
|
145
|
+
} else if (ENT_TOOLS.includes(toolName)) {
|
|
146
|
+
msg = "This tool (" + toolName + ") requires an Enterprise license. " +
|
|
147
|
+
"Your current tier (" + tier + ") includes the 39 base Guesty tools. " +
|
|
148
|
+
"Enterprise adds IoT readiness, property health, checkout photo intake, " +
|
|
149
|
+
"and maintenance alerts (4 additional tools). " +
|
|
150
|
+
"Talk to us at https://guestycopilot.com/pricing";
|
|
151
|
+
} else {
|
|
152
|
+
msg = "This tool (" + toolName + ") is not available in your current tier (" + tier + ").";
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text", text: msg }],
|
|
156
|
+
isError: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return handler(params);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { getTier, isToolAllowed, getTierInfo, gatedHandler, FREE_TOOLS, ENT_TOOLS, BIZ_FEATURES };
|
package/src/resources.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// src/resources.js
|
|
2
|
+
//
|
|
3
|
+
// MCP Resources primitive for Guesty MCP Server — v0.9.0 (2026-04-20).
|
|
4
|
+
//
|
|
5
|
+
// Exposes read-only addressable resources via guesty:// URI scheme so clients
|
|
6
|
+
// can surface Guesty entities as @-mentionable context rather than forcing a
|
|
7
|
+
// tool call for every read. Reuses the existing `guestyGet` helper from server.js.
|
|
8
|
+
//
|
|
9
|
+
// URI templates registered:
|
|
10
|
+
// guesty://listing/{id} — listing detail
|
|
11
|
+
// guesty://reservation/{id} — reservation detail
|
|
12
|
+
// guesty://review/{id} — review detail
|
|
13
|
+
// guesty://guest/{id} — guest profile
|
|
14
|
+
// guesty://thread/{conversation_id} — message thread
|
|
15
|
+
// guesty://report/revenue/{month} — monthly revenue snapshot (YYYY-MM)
|
|
16
|
+
// guesty://listing/{listing_id}/tasks — open tasks for a listing
|
|
17
|
+
//
|
|
18
|
+
// Capabilities declared by McpServer when at least one resource is registered:
|
|
19
|
+
// resources: { listChanged: false, subscribe: false }
|
|
20
|
+
//
|
|
21
|
+
// Subscribe + listChanged are intentionally OFF for v0.9.0 — keeps scope tight.
|
|
22
|
+
// Enable in v0.10.0 after per-resource caching strategy lands.
|
|
23
|
+
|
|
24
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} server - McpServer instance
|
|
28
|
+
* @param {object} deps - { guestyGet } dependency injection to avoid circular imports
|
|
29
|
+
*/
|
|
30
|
+
export function registerResources(server, { guestyGet }) {
|
|
31
|
+
// --- Helper: shape a ReadResourceResult ---
|
|
32
|
+
const asJsonResource = (uri, data, mimeType = "application/json") => ({
|
|
33
|
+
contents: [
|
|
34
|
+
{
|
|
35
|
+
uri: uri.toString(),
|
|
36
|
+
mimeType,
|
|
37
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const asErrorResource = (uri, err) => ({
|
|
43
|
+
contents: [
|
|
44
|
+
{
|
|
45
|
+
uri: uri.toString(),
|
|
46
|
+
mimeType: "text/plain",
|
|
47
|
+
text: `Error reading ${uri}: ${err.message || String(err)}`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// --- Helper: list callback factory ---
|
|
53
|
+
// For each template we provide a list callback that returns the top N of that
|
|
54
|
+
// entity so clients can enumerate them for @-mention pickers. Required per SDK.
|
|
55
|
+
const listTopN = async (path, buildUri, limit = 25) => {
|
|
56
|
+
try {
|
|
57
|
+
const data = await guestyGet(path, { limit });
|
|
58
|
+
const items = data.results || [];
|
|
59
|
+
return {
|
|
60
|
+
resources: items.map((item) => ({
|
|
61
|
+
uri: buildUri(item),
|
|
62
|
+
name: item.title || item.guest?.fullName || item.name || item._id,
|
|
63
|
+
description: item.nickname || item.publicReview || undefined,
|
|
64
|
+
mimeType: "application/json",
|
|
65
|
+
})),
|
|
66
|
+
};
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { resources: [] };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// --- 1. Listing detail ---
|
|
73
|
+
server.registerResource(
|
|
74
|
+
"listing",
|
|
75
|
+
new ResourceTemplate("guesty://listing/{id}", {
|
|
76
|
+
list: async () =>
|
|
77
|
+
listTopN("/listings", (l) => `guesty://listing/${l._id}`),
|
|
78
|
+
}),
|
|
79
|
+
{
|
|
80
|
+
title: "Guesty Listing",
|
|
81
|
+
description: "Full detail for a single Guesty listing (property).",
|
|
82
|
+
mimeType: "application/json",
|
|
83
|
+
},
|
|
84
|
+
async (uri, { id }) => {
|
|
85
|
+
try {
|
|
86
|
+
const data = await guestyGet(`/listings/${id}`);
|
|
87
|
+
return asJsonResource(uri, data);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return asErrorResource(uri, e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// --- 2. Reservation detail ---
|
|
95
|
+
server.registerResource(
|
|
96
|
+
"reservation",
|
|
97
|
+
new ResourceTemplate("guesty://reservation/{id}", {
|
|
98
|
+
list: async () =>
|
|
99
|
+
listTopN("/reservations", (r) => `guesty://reservation/${r._id}`),
|
|
100
|
+
}),
|
|
101
|
+
{
|
|
102
|
+
title: "Guesty Reservation",
|
|
103
|
+
description: "Full detail for a single reservation incl. guest, dates, money.",
|
|
104
|
+
mimeType: "application/json",
|
|
105
|
+
},
|
|
106
|
+
async (uri, { id }) => {
|
|
107
|
+
try {
|
|
108
|
+
const data = await guestyGet(`/reservations/${id}`);
|
|
109
|
+
return asJsonResource(uri, data);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return asErrorResource(uri, e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// --- 3. Review detail ---
|
|
117
|
+
server.registerResource(
|
|
118
|
+
"review",
|
|
119
|
+
new ResourceTemplate("guesty://review/{id}", {
|
|
120
|
+
list: async () =>
|
|
121
|
+
listTopN("/reviews", (r) => `guesty://review/${r._id}`),
|
|
122
|
+
}),
|
|
123
|
+
{
|
|
124
|
+
title: "Guesty Review",
|
|
125
|
+
description: "Full detail for a single review (Airbnb, Booking.com, etc.).",
|
|
126
|
+
mimeType: "application/json",
|
|
127
|
+
},
|
|
128
|
+
async (uri, { id }) => {
|
|
129
|
+
try {
|
|
130
|
+
const data = await guestyGet(`/reviews/${id}`);
|
|
131
|
+
return asJsonResource(uri, data);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return asErrorResource(uri, e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// --- 4. Guest profile ---
|
|
139
|
+
server.registerResource(
|
|
140
|
+
"guest",
|
|
141
|
+
new ResourceTemplate("guesty://guest/{id}", {
|
|
142
|
+
list: async () =>
|
|
143
|
+
listTopN("/guests", (g) => `guesty://guest/${g._id}`),
|
|
144
|
+
}),
|
|
145
|
+
{
|
|
146
|
+
title: "Guesty Guest",
|
|
147
|
+
description: "Guest profile: contact, tags, prior-stay history.",
|
|
148
|
+
mimeType: "application/json",
|
|
149
|
+
},
|
|
150
|
+
async (uri, { id }) => {
|
|
151
|
+
try {
|
|
152
|
+
const data = await guestyGet(`/guests/${id}`);
|
|
153
|
+
return asJsonResource(uri, data);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return asErrorResource(uri, e);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// --- 5. Message thread ---
|
|
161
|
+
server.registerResource(
|
|
162
|
+
"thread",
|
|
163
|
+
new ResourceTemplate("guesty://thread/{conversation_id}", {
|
|
164
|
+
// No bulk-list for threads (usually fetched per-reservation).
|
|
165
|
+
list: undefined,
|
|
166
|
+
}),
|
|
167
|
+
{
|
|
168
|
+
title: "Guesty Message Thread",
|
|
169
|
+
description: "All messages on a single conversation thread.",
|
|
170
|
+
mimeType: "application/json",
|
|
171
|
+
},
|
|
172
|
+
async (uri, { conversation_id }) => {
|
|
173
|
+
try {
|
|
174
|
+
const data = await guestyGet(
|
|
175
|
+
`/communication/conversations/${conversation_id}/messages`,
|
|
176
|
+
{ limit: 100 }
|
|
177
|
+
);
|
|
178
|
+
return asJsonResource(uri, data);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return asErrorResource(uri, e);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// --- 6. Monthly revenue snapshot ---
|
|
186
|
+
// Uses the existing /reservations endpoint filtered by checkInFrom/checkInTo.
|
|
187
|
+
// Month format: YYYY-MM. Returns aggregate money fields.
|
|
188
|
+
server.registerResource(
|
|
189
|
+
"revenue-month",
|
|
190
|
+
new ResourceTemplate("guesty://report/revenue/{month}", {
|
|
191
|
+
list: undefined,
|
|
192
|
+
}),
|
|
193
|
+
{
|
|
194
|
+
title: "Monthly Revenue",
|
|
195
|
+
description:
|
|
196
|
+
"Aggregate revenue snapshot for a given month (YYYY-MM). Sums nightly rate, cleaning, extras, discounts.",
|
|
197
|
+
mimeType: "application/json",
|
|
198
|
+
},
|
|
199
|
+
async (uri, { month }) => {
|
|
200
|
+
try {
|
|
201
|
+
// Validate YYYY-MM
|
|
202
|
+
if (!/^\d{4}-\d{2}$/.test(month)) {
|
|
203
|
+
return asErrorResource(uri, new Error(`Invalid month '${month}' — expected YYYY-MM`));
|
|
204
|
+
}
|
|
205
|
+
const [year, mo] = month.split("-").map(Number);
|
|
206
|
+
const from = `${month}-01`;
|
|
207
|
+
const lastDay = new Date(year, mo, 0).getDate();
|
|
208
|
+
const to = `${month}-${String(lastDay).padStart(2, "0")}`;
|
|
209
|
+
|
|
210
|
+
const data = await guestyGet("/reservations", {
|
|
211
|
+
limit: 100,
|
|
212
|
+
"checkIn[$gte]": from,
|
|
213
|
+
"checkIn[$lte]": to,
|
|
214
|
+
status: "confirmed",
|
|
215
|
+
});
|
|
216
|
+
const results = data.results || [];
|
|
217
|
+
|
|
218
|
+
const agg = results.reduce(
|
|
219
|
+
(acc, r) => {
|
|
220
|
+
const m = r.money || {};
|
|
221
|
+
acc.count += 1;
|
|
222
|
+
acc.nights += r.nightsCount || 0;
|
|
223
|
+
acc.fareAccommodation += m.fareAccommodation || 0;
|
|
224
|
+
acc.fareCleaning += m.fareCleaning || 0;
|
|
225
|
+
acc.hostPayout += m.hostPayout || 0;
|
|
226
|
+
acc.totalPaid += m.totalPaid || 0;
|
|
227
|
+
return acc;
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
month,
|
|
231
|
+
count: 0,
|
|
232
|
+
nights: 0,
|
|
233
|
+
fareAccommodation: 0,
|
|
234
|
+
fareCleaning: 0,
|
|
235
|
+
hostPayout: 0,
|
|
236
|
+
totalPaid: 0,
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
return asJsonResource(uri, agg);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return asErrorResource(uri, e);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// --- 7. Open tasks for a listing ---
|
|
248
|
+
server.registerResource(
|
|
249
|
+
"listing-tasks",
|
|
250
|
+
new ResourceTemplate("guesty://listing/{listing_id}/tasks", {
|
|
251
|
+
list: undefined,
|
|
252
|
+
}),
|
|
253
|
+
{
|
|
254
|
+
title: "Open Tasks for Listing",
|
|
255
|
+
description: "All open/pending tasks for a given listing.",
|
|
256
|
+
mimeType: "application/json",
|
|
257
|
+
},
|
|
258
|
+
async (uri, { listing_id }) => {
|
|
259
|
+
try {
|
|
260
|
+
const data = await guestyGet("/tasks", {
|
|
261
|
+
listingId: listing_id,
|
|
262
|
+
status: "pending",
|
|
263
|
+
limit: 50,
|
|
264
|
+
});
|
|
265
|
+
return asJsonResource(uri, data);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return asErrorResource(uri, e);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
console.error("[resources] Registered 7 Guesty resource templates (guesty://)");
|
|
273
|
+
}
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { getTier, getTierInfo, gatedHandler, FREE_TOOLS } from './license.js';
|
|
6
6
|
import { registerIoTTools } from './iot-tools.js';
|
|
7
7
|
import { registerEnterpriseTools } from './enterprise-tools.js';
|
|
8
|
+
import { registerResources } from './resources.js';
|
|
8
9
|
import { initDB } from './iot-db.js';
|
|
9
10
|
|
|
10
11
|
// Guesty API Configuration
|
|
@@ -42,6 +43,33 @@ async function getToken() {
|
|
|
42
43
|
return cachedToken;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
// ISSUE_1_FIX (2026-04-22 CTO): shared filter builder for /reservations queries.
|
|
47
|
+
// Guesty Open API v1 ignores `checkIn[$gte]`/`checkIn[$lte]` bracket-style
|
|
48
|
+
// query params — it requires a JSON `filters=[{field,operator,from,to}]` array.
|
|
49
|
+
// When filters are present, `listingId` is ALSO ignored as a top-level param
|
|
50
|
+
// and must move *inside* the filter array. No `context:"now"` — that scopes
|
|
51
|
+
// results to upcoming-only (the bug abada1987 reported in Issue #1).
|
|
52
|
+
export function buildReservationFilters({ listingId, checkInFrom, checkInTo, checkOutFrom, checkOutTo, status } = {}) {
|
|
53
|
+
const filters = [];
|
|
54
|
+
if (listingId) filters.push({ field: "listingId", operator: "$eq", value: listingId });
|
|
55
|
+
if (status) filters.push({ field: "status", operator: "$eq", value: status });
|
|
56
|
+
if (checkInFrom && checkInTo) {
|
|
57
|
+
filters.push({ field: "checkIn", operator: "$between", from: checkInFrom, to: checkInTo });
|
|
58
|
+
} else if (checkInFrom) {
|
|
59
|
+
filters.push({ field: "checkIn", operator: "$gte", value: checkInFrom });
|
|
60
|
+
} else if (checkInTo) {
|
|
61
|
+
filters.push({ field: "checkIn", operator: "$lte", value: checkInTo });
|
|
62
|
+
}
|
|
63
|
+
if (checkOutFrom && checkOutTo) {
|
|
64
|
+
filters.push({ field: "checkOut", operator: "$between", from: checkOutFrom, to: checkOutTo });
|
|
65
|
+
} else if (checkOutFrom) {
|
|
66
|
+
filters.push({ field: "checkOut", operator: "$gte", value: checkOutFrom });
|
|
67
|
+
} else if (checkOutTo) {
|
|
68
|
+
filters.push({ field: "checkOut", operator: "$lte", value: checkOutTo });
|
|
69
|
+
}
|
|
70
|
+
return filters;
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
export async function guestyGet(path, params = {}, retries = 2) {
|
|
46
74
|
const token = await getToken();
|
|
47
75
|
const url = new URL(`${GUESTY_API_BASE}${path}`);
|
|
@@ -128,7 +156,7 @@ async function guestyDelete(path, retries = 2) {
|
|
|
128
156
|
// Create MCP Server
|
|
129
157
|
const server = new McpServer({
|
|
130
158
|
name: "guesty-mcp-server",
|
|
131
|
-
version: "0.
|
|
159
|
+
version: "0.9.1",
|
|
132
160
|
});
|
|
133
161
|
// License tier check
|
|
134
162
|
const _tier = getTier();
|
|
@@ -157,12 +185,14 @@ server.tool(
|
|
|
157
185
|
sort: "checkIn",
|
|
158
186
|
order: "desc",
|
|
159
187
|
};
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
if (params.
|
|
188
|
+
// ISSUE_1_FIX (2026-04-22): use Open API `filters` JSON array instead of
|
|
189
|
+
// `checkIn[$gte]`/`[$lte]` bracket params (silently ignored → upcoming-only).
|
|
190
|
+
const filters = buildReservationFilters(params);
|
|
191
|
+
if (filters.length > 0) {
|
|
192
|
+
queryParams.filters = JSON.stringify(filters);
|
|
193
|
+
} else if (params.listingId) {
|
|
194
|
+
queryParams.listingId = params.listingId;
|
|
195
|
+
}
|
|
166
196
|
|
|
167
197
|
const data = await guestyGet("/reservations", queryParams);
|
|
168
198
|
|
|
@@ -297,9 +327,18 @@ server.tool(
|
|
|
297
327
|
sort: "checkIn",
|
|
298
328
|
order: "desc",
|
|
299
329
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
330
|
+
// ISSUE_1_FIX (2026-04-22): filters array for historical windows
|
|
331
|
+
// (bracket-style params were silently ignored → upcoming-only).
|
|
332
|
+
const filters = buildReservationFilters({
|
|
333
|
+
listingId: params.listingId,
|
|
334
|
+
checkInFrom: params.from,
|
|
335
|
+
checkInTo: params.to,
|
|
336
|
+
});
|
|
337
|
+
if (filters.length > 0) {
|
|
338
|
+
queryParams.filters = JSON.stringify(filters);
|
|
339
|
+
} else if (params.listingId) {
|
|
340
|
+
queryParams.listingId = params.listingId;
|
|
341
|
+
}
|
|
303
342
|
|
|
304
343
|
const data = await guestyGet("/reservations", queryParams);
|
|
305
344
|
const financials = (data.results || []).map((r) => ({
|
|
@@ -538,12 +577,18 @@ server.tool(
|
|
|
538
577
|
if (params.to) queryParams["to"] = params.to;
|
|
539
578
|
|
|
540
579
|
// Owner statements not available via Open API v1 — fall back to financial data from reservations
|
|
580
|
+
// ISSUE_1_FIX (2026-04-22): same bracket→filters rewrite as Issue #1 sibling tools.
|
|
581
|
+
const ownerFilters = buildReservationFilters({
|
|
582
|
+
listingId: params.listingId,
|
|
583
|
+
checkInFrom: params.from,
|
|
584
|
+
checkInTo: params.to,
|
|
585
|
+
});
|
|
541
586
|
const resData = await guestyGet("/reservations", {
|
|
542
587
|
limit: params.limit,
|
|
543
588
|
fields: "money guest checkIn checkOut listing status nightsCount",
|
|
544
|
-
...(
|
|
545
|
-
|
|
546
|
-
|
|
589
|
+
...(ownerFilters.length > 0
|
|
590
|
+
? { filters: JSON.stringify(ownerFilters) }
|
|
591
|
+
: (params.listingId ? { listingId: params.listingId } : {})),
|
|
547
592
|
});
|
|
548
593
|
const results = (resData.results || []).map((r) => ({
|
|
549
594
|
guest: r.guest?.fullName || "Unknown",
|
|
@@ -1055,20 +1100,76 @@ server.tool(
|
|
|
1055
1100
|
},
|
|
1056
1101
|
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1057
1102
|
async (params) => {
|
|
1103
|
+
// ISSUE_1_FIX (2026-04-22): Open API calendar returns the day array under
|
|
1104
|
+
// `data.days`, `data.data`, `data.results`, or as a bare top-level array
|
|
1105
|
+
// depending on endpoint variant. Previous code only looked at `data.days`
|
|
1106
|
+
// → totalDays:0 when response came back in any other shape (the bug
|
|
1107
|
+
// abada1987 flagged as "all-zero totals" in Issue #1).
|
|
1108
|
+
// Also: pass both `from`/`to` and `startDate`/`endDate` to be forgiving
|
|
1109
|
+
// across Open API v1 calendar route versions.
|
|
1058
1110
|
const data = await guestyGet(`/listings/${params.listingId}/calendar`, {
|
|
1059
1111
|
from: params.from,
|
|
1060
1112
|
to: params.to,
|
|
1113
|
+
startDate: params.from,
|
|
1114
|
+
endDate: params.to,
|
|
1061
1115
|
});
|
|
1062
1116
|
|
|
1063
|
-
|
|
1117
|
+
// Normalize response shape across known Guesty calendar variants
|
|
1118
|
+
let days = [];
|
|
1119
|
+
if (Array.isArray(data)) days = data;
|
|
1120
|
+
else if (Array.isArray(data?.days)) days = data.days;
|
|
1121
|
+
else if (Array.isArray(data?.data)) days = data.data;
|
|
1122
|
+
else if (Array.isArray(data?.results)) days = data.results;
|
|
1123
|
+
|
|
1124
|
+
// If the calendar endpoint didn't give us per-day rows, derive occupancy
|
|
1125
|
+
// from reservations over the window so we never silently return 0.
|
|
1126
|
+
if (days.length === 0 && params.from && params.to) {
|
|
1127
|
+
const occupFilters = buildReservationFilters({
|
|
1128
|
+
listingId: params.listingId,
|
|
1129
|
+
checkInFrom: params.from,
|
|
1130
|
+
checkInTo: params.to,
|
|
1131
|
+
});
|
|
1132
|
+
try {
|
|
1133
|
+
const resData = await guestyGet("/reservations", {
|
|
1134
|
+
limit: 100,
|
|
1135
|
+
fields: "checkIn checkOut nightsCount status",
|
|
1136
|
+
filters: JSON.stringify(occupFilters),
|
|
1137
|
+
});
|
|
1138
|
+
const totalDaysSpan = Math.max(1, Math.round((new Date(params.to) - new Date(params.from)) / 86400000) + 1);
|
|
1139
|
+
const bookedDays = (resData.results || [])
|
|
1140
|
+
.filter((r) => r.status === "confirmed" || r.status === "reserved")
|
|
1141
|
+
.reduce((s, r) => s + (r.nightsCount || 0), 0);
|
|
1142
|
+
const rate = Math.round((bookedDays / totalDaysSpan) * 10000) / 100;
|
|
1143
|
+
return {
|
|
1144
|
+
content: [{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: JSON.stringify({
|
|
1147
|
+
listing: params.listingId,
|
|
1148
|
+
from: params.from,
|
|
1149
|
+
to: params.to,
|
|
1150
|
+
totalDays: totalDaysSpan,
|
|
1151
|
+
bookedDays,
|
|
1152
|
+
blockedDays: 0,
|
|
1153
|
+
availableDays: Math.max(0, totalDaysSpan - bookedDays),
|
|
1154
|
+
occupancyRate: `${rate}%`,
|
|
1155
|
+
source: "reservations-derived",
|
|
1156
|
+
}, null, 2),
|
|
1157
|
+
}],
|
|
1158
|
+
};
|
|
1159
|
+
} catch (e) {
|
|
1160
|
+
// fall through to zero-day response
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1064
1164
|
const totalDays = days.length;
|
|
1065
1165
|
let bookedDays = 0;
|
|
1066
1166
|
let blockedDays = 0;
|
|
1067
1167
|
let availableDays = 0;
|
|
1068
1168
|
|
|
1069
1169
|
days.forEach((d) => {
|
|
1070
|
-
|
|
1071
|
-
|
|
1170
|
+
const s = d.status || d.state;
|
|
1171
|
+
if (s === "booked" || s === "reserved") bookedDays++;
|
|
1172
|
+
else if (s === "unavailable" || s === "blocked") blockedDays++;
|
|
1072
1173
|
else availableDays++;
|
|
1073
1174
|
});
|
|
1074
1175
|
|
|
@@ -1108,11 +1209,23 @@ server.tool(
|
|
|
1108
1209
|
fields: "money nightsCount listing checkIn checkOut status",
|
|
1109
1210
|
sort: "checkIn",
|
|
1110
1211
|
order: "desc",
|
|
1111
|
-
status: "confirmed",
|
|
1112
1212
|
};
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1213
|
+
// ISSUE_1_FIX (2026-04-22): filters array for historical windows.
|
|
1214
|
+
// `status: "confirmed"` moved into filters so it survives alongside
|
|
1215
|
+
// listingId + date window (top-level status was silently dropped when
|
|
1216
|
+
// filters were later set on other paths).
|
|
1217
|
+
const filters = buildReservationFilters({
|
|
1218
|
+
listingId: params.listingId,
|
|
1219
|
+
checkInFrom: params.from,
|
|
1220
|
+
checkInTo: params.to,
|
|
1221
|
+
status: "confirmed",
|
|
1222
|
+
});
|
|
1223
|
+
if (filters.length > 0) {
|
|
1224
|
+
queryParams.filters = JSON.stringify(filters);
|
|
1225
|
+
} else {
|
|
1226
|
+
queryParams.status = "confirmed";
|
|
1227
|
+
if (params.listingId) queryParams.listingId = params.listingId;
|
|
1228
|
+
}
|
|
1116
1229
|
|
|
1117
1230
|
const data = await guestyGet("/reservations", queryParams);
|
|
1118
1231
|
const reservations = data.results || [];
|
|
@@ -1425,6 +1538,7 @@ server.tool(
|
|
|
1425
1538
|
initDB();
|
|
1426
1539
|
registerIoTTools(server);
|
|
1427
1540
|
registerEnterpriseTools(server);
|
|
1541
|
+
registerResources(server, { guestyGet });
|
|
1428
1542
|
|
|
1429
1543
|
// Start server
|
|
1430
1544
|
const transport = new StdioServerTransport();
|