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/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.7.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
- if (params.checkInFrom) queryParams["checkIn[$gte]"] = params.checkInFrom;
161
- if (params.checkInTo) queryParams["checkIn[$lte]"] = params.checkInTo;
162
- if (params.checkOutFrom) queryParams["checkOut[$gte]"] = params.checkOutFrom;
163
- if (params.checkOutTo) queryParams["checkOut[$lte]"] = params.checkOutTo;
164
- if (params.listingId) queryParams.listingId = params.listingId;
165
- if (params.status) queryParams.status = params.status;
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
- if (params.listingId) queryParams.listingId = params.listingId;
301
- if (params.from) queryParams["checkIn[$gte]"] = params.from;
302
- if (params.to) queryParams["checkIn[$lte]"] = params.to;
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
- ...(params.listingId && { listingId: params.listingId }),
545
- ...(params.from && { "checkIn[$gte]": params.from }),
546
- ...(params.to && { "checkIn[$lte]": params.to }),
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
- const days = data.days || [];
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
- if (d.status === "booked" || d.status === "reserved") bookedDays++;
1071
- else if (d.status === "unavailable" || d.status === "blocked") blockedDays++;
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
- if (params.listingId) queryParams.listingId = params.listingId;
1114
- if (params.from) queryParams["checkIn[$gte]"] = params.from;
1115
- if (params.to) queryParams["checkIn[$lte]"] = params.to;
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();