guesty-mcp-server 0.8.2 → 0.9.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 +10 -0
- package/package.json +2 -2
- package/package.json.pre-resources-20260420-232423 +49 -0
- package/server.json +40 -34
- package/src/http-server.js +19 -16
- 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
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();
|