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.
@@ -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 };
@@ -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.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();