@striderlabs/mcp-zipcar 1.0.0
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/README.md +136 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +585 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -0
- package/src/index.ts +745 -0
- package/striderlabs-mcp-zipcar-1.0.0.tgz +0 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
Tool,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { chromium, Browser, BrowserContext, Page } from "patchright";
|
|
11
|
+
|
|
12
|
+
// --- Types ---
|
|
13
|
+
|
|
14
|
+
interface SearchCarsArgs {
|
|
15
|
+
location: string;
|
|
16
|
+
start_time: string;
|
|
17
|
+
end_time: string;
|
|
18
|
+
radius_miles?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GetCarDetailsArgs {
|
|
22
|
+
car_id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ReserveCarArgs {
|
|
26
|
+
car_id: string;
|
|
27
|
+
start_time: string;
|
|
28
|
+
end_time: string;
|
|
29
|
+
plan?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ExtendReservationArgs {
|
|
33
|
+
reservation_id: string;
|
|
34
|
+
new_end_time: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface EndTripArgs {
|
|
38
|
+
reservation_id: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GetReservationHistoryArgs {
|
|
42
|
+
filter?: "upcoming" | "past" | "all";
|
|
43
|
+
limit?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Browser session management ---
|
|
47
|
+
|
|
48
|
+
let browser: Browser | null = null;
|
|
49
|
+
let context: BrowserContext | null = null;
|
|
50
|
+
|
|
51
|
+
async function getBrowser(): Promise<Browser> {
|
|
52
|
+
if (!browser || !browser.isConnected()) {
|
|
53
|
+
browser = await chromium.launch({
|
|
54
|
+
headless: true,
|
|
55
|
+
args: [
|
|
56
|
+
"--no-sandbox",
|
|
57
|
+
"--disable-blink-features=AutomationControlled",
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return browser;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getContext(): Promise<BrowserContext> {
|
|
65
|
+
if (!context) {
|
|
66
|
+
const b = await getBrowser();
|
|
67
|
+
context = await b.newContext({
|
|
68
|
+
userAgent:
|
|
69
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
70
|
+
viewport: { width: 1280, height: 800 },
|
|
71
|
+
locale: "en-US",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return context;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function newPage(): Promise<Page> {
|
|
78
|
+
const ctx = await getContext();
|
|
79
|
+
return ctx.newPage();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Tool implementations ---
|
|
83
|
+
|
|
84
|
+
async function searchCars(args: SearchCarsArgs): Promise<string> {
|
|
85
|
+
const page = await newPage();
|
|
86
|
+
try {
|
|
87
|
+
const url = `https://www.zipcar.com/cars?location=${encodeURIComponent(args.location)}&starttime=${encodeURIComponent(args.start_time)}&endtime=${encodeURIComponent(args.end_time)}`;
|
|
88
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
89
|
+
|
|
90
|
+
// Wait for car listings to appear
|
|
91
|
+
await page.waitForSelector('[data-testid="car-card"], .vehicle-card, .car-listing', {
|
|
92
|
+
timeout: 15000,
|
|
93
|
+
}).catch(() => null);
|
|
94
|
+
|
|
95
|
+
const cars = await page.evaluate(() => {
|
|
96
|
+
const results: Array<{
|
|
97
|
+
id: string;
|
|
98
|
+
name: string;
|
|
99
|
+
type: string;
|
|
100
|
+
location: string;
|
|
101
|
+
distance: string;
|
|
102
|
+
hourly_rate: string;
|
|
103
|
+
daily_rate: string;
|
|
104
|
+
available: boolean;
|
|
105
|
+
}> = [];
|
|
106
|
+
|
|
107
|
+
// Try multiple selectors for different page structures
|
|
108
|
+
const cardSelectors = [
|
|
109
|
+
'[data-testid="car-card"]',
|
|
110
|
+
".vehicle-card",
|
|
111
|
+
".car-listing",
|
|
112
|
+
"[class*='VehicleCard']",
|
|
113
|
+
"[class*='CarCard']",
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
let cards: NodeListOf<Element> | null = null;
|
|
117
|
+
for (const sel of cardSelectors) {
|
|
118
|
+
const found = document.querySelectorAll(sel);
|
|
119
|
+
if (found.length > 0) {
|
|
120
|
+
cards = found;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!cards) return results;
|
|
126
|
+
|
|
127
|
+
cards.forEach((card, index) => {
|
|
128
|
+
const nameEl =
|
|
129
|
+
card.querySelector("[class*='name'], [class*='Name'], h2, h3") as HTMLElement | null;
|
|
130
|
+
const typeEl =
|
|
131
|
+
card.querySelector("[class*='type'], [class*='Type'], [class*='category']") as HTMLElement | null;
|
|
132
|
+
const locationEl =
|
|
133
|
+
card.querySelector("[class*='location'], [class*='Location'], [class*='address']") as HTMLElement | null;
|
|
134
|
+
const distEl =
|
|
135
|
+
card.querySelector("[class*='distance'], [class*='Distance']") as HTMLElement | null;
|
|
136
|
+
const hourlyEl =
|
|
137
|
+
card.querySelector("[class*='hourly'], [class*='Hourly'], [class*='rate']") as HTMLElement | null;
|
|
138
|
+
const dailyEl =
|
|
139
|
+
card.querySelector("[class*='daily'], [class*='Daily']") as HTMLElement | null;
|
|
140
|
+
|
|
141
|
+
const idAttr =
|
|
142
|
+
(card as HTMLElement).dataset.vehicleId ||
|
|
143
|
+
(card as HTMLElement).dataset.carId ||
|
|
144
|
+
(card as HTMLElement).dataset.id ||
|
|
145
|
+
`car-${index}`;
|
|
146
|
+
|
|
147
|
+
results.push({
|
|
148
|
+
id: idAttr,
|
|
149
|
+
name: nameEl?.innerText?.trim() || "Unknown Vehicle",
|
|
150
|
+
type: typeEl?.innerText?.trim() || "Car",
|
|
151
|
+
location: locationEl?.innerText?.trim() || "See map",
|
|
152
|
+
distance: distEl?.innerText?.trim() || "N/A",
|
|
153
|
+
hourly_rate: hourlyEl?.innerText?.trim() || "N/A",
|
|
154
|
+
daily_rate: dailyEl?.innerText?.trim() || "N/A",
|
|
155
|
+
available: !card.classList.contains("unavailable"),
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (cars.length === 0) {
|
|
163
|
+
return JSON.stringify({
|
|
164
|
+
status: "no_results",
|
|
165
|
+
message: `No available Zipcars found near "${args.location}" for the requested time period.`,
|
|
166
|
+
searched_url: url,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return JSON.stringify({
|
|
171
|
+
status: "success",
|
|
172
|
+
location: args.location,
|
|
173
|
+
start_time: args.start_time,
|
|
174
|
+
end_time: args.end_time,
|
|
175
|
+
total_found: cars.length,
|
|
176
|
+
cars,
|
|
177
|
+
});
|
|
178
|
+
} finally {
|
|
179
|
+
await page.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function getCarDetails(args: GetCarDetailsArgs): Promise<string> {
|
|
184
|
+
const page = await newPage();
|
|
185
|
+
try {
|
|
186
|
+
const url = `https://www.zipcar.com/cars/${encodeURIComponent(args.car_id)}`;
|
|
187
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
188
|
+
|
|
189
|
+
await page.waitForSelector(
|
|
190
|
+
"[class*='VehicleDetail'], [class*='CarDetail'], .vehicle-detail, main",
|
|
191
|
+
{ timeout: 15000 }
|
|
192
|
+
).catch(() => null);
|
|
193
|
+
|
|
194
|
+
const details = await page.evaluate((carId: string) => {
|
|
195
|
+
const getText = (selector: string): string =>
|
|
196
|
+
(document.querySelector(selector) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
197
|
+
|
|
198
|
+
const features: string[] = [];
|
|
199
|
+
document.querySelectorAll("[class*='feature'], [class*='Feature'], [class*='amenity']").forEach((el) => {
|
|
200
|
+
const text = (el as HTMLElement).innerText?.trim();
|
|
201
|
+
if (text) features.push(text);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
car_id: carId,
|
|
206
|
+
name: getText("h1, [class*='VehicleName'], [class*='CarName']"),
|
|
207
|
+
type:
|
|
208
|
+
getText("[class*='vehicleType'], [class*='VehicleType'], [class*='category']"),
|
|
209
|
+
year: getText("[class*='year'], [class*='Year']"),
|
|
210
|
+
make: getText("[class*='make'], [class*='Make']"),
|
|
211
|
+
model: getText("[class*='model'], [class*='Model']"),
|
|
212
|
+
seats: getText("[class*='seats'], [class*='Seats'], [class*='capacity']"),
|
|
213
|
+
fuel_type: getText("[class*='fuel'], [class*='Fuel']"),
|
|
214
|
+
transmission: getText("[class*='transmission'], [class*='Transmission']"),
|
|
215
|
+
hourly_rate: getText("[class*='hourly'], [class*='HourlyRate']"),
|
|
216
|
+
daily_rate: getText("[class*='daily'], [class*='DailyRate']"),
|
|
217
|
+
included_miles_per_hour: getText("[class*='miles'], [class*='Miles']"),
|
|
218
|
+
location: getText("[class*='location'], [class*='Location'], [class*='address']"),
|
|
219
|
+
features: features.slice(0, 20),
|
|
220
|
+
};
|
|
221
|
+
}, args.car_id);
|
|
222
|
+
|
|
223
|
+
return JSON.stringify({ status: "success", details });
|
|
224
|
+
} finally {
|
|
225
|
+
await page.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function reserveCar(args: ReserveCarArgs): Promise<string> {
|
|
230
|
+
const page = await newPage();
|
|
231
|
+
try {
|
|
232
|
+
// Navigate to the car's booking page
|
|
233
|
+
const url = `https://www.zipcar.com/cars/${encodeURIComponent(args.car_id)}/reserve`;
|
|
234
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
235
|
+
|
|
236
|
+
// Check if user is logged in
|
|
237
|
+
const isLoggedIn = await page.evaluate(() => {
|
|
238
|
+
return (
|
|
239
|
+
!document.querySelector("[class*='SignIn'], [class*='Login'], [href*='login']") ||
|
|
240
|
+
!!document.querySelector("[class*='UserMenu'], [class*='AccountMenu'], [data-testid='user-menu']")
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!isLoggedIn) {
|
|
245
|
+
return JSON.stringify({
|
|
246
|
+
status: "auth_required",
|
|
247
|
+
message:
|
|
248
|
+
"Authentication required. Please log in to Zipcar before making a reservation.",
|
|
249
|
+
action: "Please use a browser to log in to zipcar.com, then retry.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Fill in start time
|
|
254
|
+
const startInput = await page.$("[name='startTime'], [data-testid='start-time'], #start-time");
|
|
255
|
+
if (startInput) {
|
|
256
|
+
await startInput.fill(args.start_time);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Fill in end time
|
|
260
|
+
const endInput = await page.$("[name='endTime'], [data-testid='end-time'], #end-time");
|
|
261
|
+
if (endInput) {
|
|
262
|
+
await endInput.fill(args.end_time);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Select plan if specified
|
|
266
|
+
if (args.plan) {
|
|
267
|
+
const planSelector = await page.$(`[data-plan="${args.plan}"], [value="${args.plan}"]`);
|
|
268
|
+
if (planSelector) {
|
|
269
|
+
await planSelector.click();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Get reservation summary before confirming
|
|
274
|
+
const summary = await page.evaluate(() => {
|
|
275
|
+
const getText = (sel: string) =>
|
|
276
|
+
(document.querySelector(sel) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
277
|
+
return {
|
|
278
|
+
subtotal: getText("[class*='subtotal'], [class*='Subtotal']"),
|
|
279
|
+
total: getText("[class*='total'], [class*='Total']"),
|
|
280
|
+
duration: getText("[class*='duration'], [class*='Duration']"),
|
|
281
|
+
included_miles: getText("[class*='includedMiles'], [class*='miles']"),
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Click confirm/reserve button
|
|
286
|
+
const confirmBtn = await page.$(
|
|
287
|
+
"[data-testid='confirm-reservation'], [class*='ConfirmButton'], button[type='submit']"
|
|
288
|
+
);
|
|
289
|
+
if (confirmBtn) {
|
|
290
|
+
await confirmBtn.click();
|
|
291
|
+
await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => null);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Extract confirmation details
|
|
295
|
+
const confirmation = await page.evaluate(() => {
|
|
296
|
+
const getText = (sel: string) =>
|
|
297
|
+
(document.querySelector(sel) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
298
|
+
return {
|
|
299
|
+
reservation_id: getText("[class*='confirmationNumber'], [data-testid='reservation-id']"),
|
|
300
|
+
status: getText("[class*='status'], [class*='Status']"),
|
|
301
|
+
message: getText("[class*='confirmationMessage'], [class*='success']"),
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return JSON.stringify({
|
|
306
|
+
status: "success",
|
|
307
|
+
reservation: {
|
|
308
|
+
car_id: args.car_id,
|
|
309
|
+
start_time: args.start_time,
|
|
310
|
+
end_time: args.end_time,
|
|
311
|
+
plan: args.plan || "default",
|
|
312
|
+
...confirmation,
|
|
313
|
+
cost_summary: summary,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
} finally {
|
|
317
|
+
await page.close();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function extendReservation(args: ExtendReservationArgs): Promise<string> {
|
|
322
|
+
const page = await newPage();
|
|
323
|
+
try {
|
|
324
|
+
await page.goto("https://www.zipcar.com/my-zipcar/reservations", {
|
|
325
|
+
waitUntil: "domcontentloaded",
|
|
326
|
+
timeout: 30000,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Find the reservation
|
|
330
|
+
const reservationSelector = `[data-reservation-id="${args.reservation_id}"], [data-id="${args.reservation_id}"]`;
|
|
331
|
+
await page.waitForSelector(reservationSelector, { timeout: 10000 }).catch(() => null);
|
|
332
|
+
|
|
333
|
+
// Click extend/modify button
|
|
334
|
+
const extendBtn = await page.$(
|
|
335
|
+
`${reservationSelector} [class*='extend'], ${reservationSelector} [data-action='extend']`
|
|
336
|
+
);
|
|
337
|
+
if (!extendBtn) {
|
|
338
|
+
// Try finding extend button generically for the active reservation
|
|
339
|
+
const anyExtend = await page.$("[class*='ExtendTrip'], [data-testid='extend-trip'], button:has-text('Extend')");
|
|
340
|
+
if (anyExtend) await anyExtend.click();
|
|
341
|
+
} else {
|
|
342
|
+
await extendBtn.click();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Set new end time
|
|
346
|
+
await page.waitForSelector("[name='newEndTime'], [data-testid='new-end-time']", { timeout: 10000 }).catch(() => null);
|
|
347
|
+
const newEndInput = await page.$("[name='newEndTime'], [data-testid='new-end-time'], #new-end-time");
|
|
348
|
+
if (newEndInput) {
|
|
349
|
+
await newEndInput.fill(args.new_end_time);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Confirm extension
|
|
353
|
+
const confirmBtn = await page.$(
|
|
354
|
+
"[data-testid='confirm-extend'], button[type='submit'], [class*='ConfirmExtend']"
|
|
355
|
+
);
|
|
356
|
+
if (confirmBtn) {
|
|
357
|
+
await confirmBtn.click();
|
|
358
|
+
await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => null);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const result = await page.evaluate(() => {
|
|
362
|
+
const getText = (sel: string) =>
|
|
363
|
+
(document.querySelector(sel) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
364
|
+
return {
|
|
365
|
+
confirmation: getText("[class*='confirmation'], [class*='success']"),
|
|
366
|
+
confirmed_end_time: getText("[class*='endTime'], [class*='EndTime']"),
|
|
367
|
+
additional_cost: getText("[class*='additionalCost'], [class*='cost']"),
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return JSON.stringify({
|
|
372
|
+
status: "success",
|
|
373
|
+
reservation_id: args.reservation_id,
|
|
374
|
+
new_end_time: args.new_end_time,
|
|
375
|
+
...result,
|
|
376
|
+
});
|
|
377
|
+
} finally {
|
|
378
|
+
await page.close();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function endTrip(args: EndTripArgs): Promise<string> {
|
|
383
|
+
const page = await newPage();
|
|
384
|
+
try {
|
|
385
|
+
await page.goto("https://www.zipcar.com/my-zipcar/active-trip", {
|
|
386
|
+
waitUntil: "domcontentloaded",
|
|
387
|
+
timeout: 30000,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Find end trip button
|
|
391
|
+
await page.waitForSelector(
|
|
392
|
+
"[data-testid='end-trip'], [class*='EndTrip'], button:has-text('End Trip')",
|
|
393
|
+
{ timeout: 15000 }
|
|
394
|
+
).catch(() => null);
|
|
395
|
+
|
|
396
|
+
const endTripBtn = await page.$(
|
|
397
|
+
"[data-testid='end-trip'], [class*='EndTripButton'], button[data-action='end-trip']"
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (!endTripBtn) {
|
|
401
|
+
return JSON.stringify({
|
|
402
|
+
status: "error",
|
|
403
|
+
message: "Could not find active trip controls. Make sure you have an active reservation.",
|
|
404
|
+
reservation_id: args.reservation_id,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await endTripBtn.click();
|
|
409
|
+
|
|
410
|
+
// Handle confirmation dialog
|
|
411
|
+
await page.waitForSelector("[data-testid='confirm-end-trip'], [class*='ConfirmDialog']", {
|
|
412
|
+
timeout: 5000,
|
|
413
|
+
}).catch(() => null);
|
|
414
|
+
|
|
415
|
+
const confirmDialog = await page.$("[data-testid='confirm-end-trip'], [class*='ConfirmEnd']");
|
|
416
|
+
if (confirmDialog) {
|
|
417
|
+
await confirmDialog.click();
|
|
418
|
+
await page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => null);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const tripSummary = await page.evaluate(() => {
|
|
422
|
+
const getText = (sel: string) =>
|
|
423
|
+
(document.querySelector(sel) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
424
|
+
return {
|
|
425
|
+
end_time: getText("[class*='endTime'], [class*='tripEnd']"),
|
|
426
|
+
duration: getText("[class*='tripDuration'], [class*='duration']"),
|
|
427
|
+
miles_driven: getText("[class*='milesDriven'], [class*='miles']"),
|
|
428
|
+
total_cost: getText("[class*='totalCost'], [class*='total']"),
|
|
429
|
+
receipt_url: (document.querySelector("[href*='receipt']") as HTMLAnchorElement | null)?.href || "N/A",
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return JSON.stringify({
|
|
434
|
+
status: "success",
|
|
435
|
+
reservation_id: args.reservation_id,
|
|
436
|
+
trip_summary: tripSummary,
|
|
437
|
+
});
|
|
438
|
+
} finally {
|
|
439
|
+
await page.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function getReservationHistory(args: GetReservationHistoryArgs): Promise<string> {
|
|
444
|
+
const page = await newPage();
|
|
445
|
+
try {
|
|
446
|
+
await page.goto("https://www.zipcar.com/my-zipcar/reservations", {
|
|
447
|
+
waitUntil: "domcontentloaded",
|
|
448
|
+
timeout: 30000,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await page.waitForSelector(
|
|
452
|
+
"[class*='ReservationList'], [class*='reservation-list'], [data-testid='reservations']",
|
|
453
|
+
{ timeout: 15000 }
|
|
454
|
+
).catch(() => null);
|
|
455
|
+
|
|
456
|
+
const filter = args.filter || "all";
|
|
457
|
+
const limit = args.limit || 20;
|
|
458
|
+
|
|
459
|
+
// Click appropriate filter tab
|
|
460
|
+
if (filter !== "all") {
|
|
461
|
+
const tabSelector = `[data-tab="${filter}"], button:has-text("${filter === "upcoming" ? "Upcoming" : "Past"}")`;
|
|
462
|
+
const tab = await page.$(tabSelector);
|
|
463
|
+
if (tab) {
|
|
464
|
+
await tab.click();
|
|
465
|
+
await page.waitForTimeout(1000);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const reservations = await page.evaluate((maxItems: number) => {
|
|
470
|
+
const results: Array<{
|
|
471
|
+
id: string;
|
|
472
|
+
car_name: string;
|
|
473
|
+
car_type: string;
|
|
474
|
+
pickup_location: string;
|
|
475
|
+
start_time: string;
|
|
476
|
+
end_time: string;
|
|
477
|
+
status: string;
|
|
478
|
+
total_cost: string;
|
|
479
|
+
miles_driven: string;
|
|
480
|
+
}> = [];
|
|
481
|
+
|
|
482
|
+
const cardSelectors = [
|
|
483
|
+
"[class*='ReservationCard']",
|
|
484
|
+
"[class*='reservation-card']",
|
|
485
|
+
"[data-testid='reservation-item']",
|
|
486
|
+
"li[class*='reservation']",
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
let cards: NodeListOf<Element> | null = null;
|
|
490
|
+
for (const sel of cardSelectors) {
|
|
491
|
+
const found = document.querySelectorAll(sel);
|
|
492
|
+
if (found.length > 0) {
|
|
493
|
+
cards = found;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!cards) return results;
|
|
499
|
+
|
|
500
|
+
const count = Math.min(cards.length, maxItems);
|
|
501
|
+
for (let i = 0; i < count; i++) {
|
|
502
|
+
const card = cards[i];
|
|
503
|
+
const getText = (sel: string) =>
|
|
504
|
+
(card.querySelector(sel) as HTMLElement | null)?.innerText?.trim() || "N/A";
|
|
505
|
+
|
|
506
|
+
results.push({
|
|
507
|
+
id:
|
|
508
|
+
(card as HTMLElement).dataset.reservationId ||
|
|
509
|
+
(card as HTMLElement).dataset.id ||
|
|
510
|
+
`res-${i}`,
|
|
511
|
+
car_name: getText("[class*='carName'], [class*='vehicleName'], h3, h4"),
|
|
512
|
+
car_type: getText("[class*='carType'], [class*='vehicleType']"),
|
|
513
|
+
pickup_location: getText("[class*='location'], [class*='pickupLocation']"),
|
|
514
|
+
start_time: getText("[class*='startTime'], [class*='pickupTime'], [data-label='Start']"),
|
|
515
|
+
end_time: getText("[class*='endTime'], [class*='returnTime'], [data-label='End']"),
|
|
516
|
+
status: getText("[class*='status'], [class*='reservationStatus']"),
|
|
517
|
+
total_cost: getText("[class*='totalCost'], [class*='cost'], [class*='price']"),
|
|
518
|
+
miles_driven: getText("[class*='milesDriven'], [class*='miles']"),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return results;
|
|
523
|
+
}, limit);
|
|
524
|
+
|
|
525
|
+
return JSON.stringify({
|
|
526
|
+
status: "success",
|
|
527
|
+
filter,
|
|
528
|
+
total_returned: reservations.length,
|
|
529
|
+
reservations,
|
|
530
|
+
});
|
|
531
|
+
} finally {
|
|
532
|
+
await page.close();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Tool definitions ---
|
|
537
|
+
|
|
538
|
+
const TOOLS: Tool[] = [
|
|
539
|
+
{
|
|
540
|
+
name: "search_cars",
|
|
541
|
+
description:
|
|
542
|
+
"Search for available Zipcars near a location for a given time period. Returns a list of available vehicles with rates and locations.",
|
|
543
|
+
inputSchema: {
|
|
544
|
+
type: "object",
|
|
545
|
+
properties: {
|
|
546
|
+
location: {
|
|
547
|
+
type: "string",
|
|
548
|
+
description: "Address, city, or zip code to search near (e.g. '10001', 'Brooklyn, NY', '123 Main St, Boston')",
|
|
549
|
+
},
|
|
550
|
+
start_time: {
|
|
551
|
+
type: "string",
|
|
552
|
+
description: "Reservation start time in ISO 8601 format (e.g. '2025-08-15T10:00:00')",
|
|
553
|
+
},
|
|
554
|
+
end_time: {
|
|
555
|
+
type: "string",
|
|
556
|
+
description: "Reservation end time in ISO 8601 format (e.g. '2025-08-15T14:00:00')",
|
|
557
|
+
},
|
|
558
|
+
radius_miles: {
|
|
559
|
+
type: "number",
|
|
560
|
+
description: "Search radius in miles (default: 1)",
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
required: ["location", "start_time", "end_time"],
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "get_car_details",
|
|
568
|
+
description:
|
|
569
|
+
"Get detailed information about a specific Zipcar including vehicle type, features, seating, fuel type, and pricing rates.",
|
|
570
|
+
inputSchema: {
|
|
571
|
+
type: "object",
|
|
572
|
+
properties: {
|
|
573
|
+
car_id: {
|
|
574
|
+
type: "string",
|
|
575
|
+
description: "The Zipcar vehicle ID from search results",
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
required: ["car_id"],
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "reserve_car",
|
|
583
|
+
description:
|
|
584
|
+
"Book a Zipcar for a specific time slot. Requires the user to be logged in to Zipcar. Returns reservation confirmation details.",
|
|
585
|
+
inputSchema: {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
car_id: {
|
|
589
|
+
type: "string",
|
|
590
|
+
description: "The Zipcar vehicle ID to reserve",
|
|
591
|
+
},
|
|
592
|
+
start_time: {
|
|
593
|
+
type: "string",
|
|
594
|
+
description: "Reservation start time in ISO 8601 format",
|
|
595
|
+
},
|
|
596
|
+
end_time: {
|
|
597
|
+
type: "string",
|
|
598
|
+
description: "Reservation end time in ISO 8601 format",
|
|
599
|
+
},
|
|
600
|
+
plan: {
|
|
601
|
+
type: "string",
|
|
602
|
+
description: "Optional membership plan to use (e.g. 'hourly', 'daily')",
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
required: ["car_id", "start_time", "end_time"],
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "extend_reservation",
|
|
610
|
+
description:
|
|
611
|
+
"Extend an existing Zipcar reservation to a later end time. The car must still be available for the extended period.",
|
|
612
|
+
inputSchema: {
|
|
613
|
+
type: "object",
|
|
614
|
+
properties: {
|
|
615
|
+
reservation_id: {
|
|
616
|
+
type: "string",
|
|
617
|
+
description: "The reservation ID to extend",
|
|
618
|
+
},
|
|
619
|
+
new_end_time: {
|
|
620
|
+
type: "string",
|
|
621
|
+
description: "The new end time in ISO 8601 format",
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
required: ["reservation_id", "new_end_time"],
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: "end_trip",
|
|
629
|
+
description:
|
|
630
|
+
"End an active Zipcar rental session. The car must be parked at a valid Zipcar spot. Returns a trip summary with duration, miles, and cost.",
|
|
631
|
+
inputSchema: {
|
|
632
|
+
type: "object",
|
|
633
|
+
properties: {
|
|
634
|
+
reservation_id: {
|
|
635
|
+
type: "string",
|
|
636
|
+
description: "The active reservation ID to end",
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
required: ["reservation_id"],
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "get_reservation_history",
|
|
644
|
+
description:
|
|
645
|
+
"View past and upcoming Zipcar reservations. Can filter by upcoming or past reservations.",
|
|
646
|
+
inputSchema: {
|
|
647
|
+
type: "object",
|
|
648
|
+
properties: {
|
|
649
|
+
filter: {
|
|
650
|
+
type: "string",
|
|
651
|
+
enum: ["upcoming", "past", "all"],
|
|
652
|
+
description: "Filter reservations: 'upcoming', 'past', or 'all' (default: 'all')",
|
|
653
|
+
},
|
|
654
|
+
limit: {
|
|
655
|
+
type: "number",
|
|
656
|
+
description: "Maximum number of reservations to return (default: 20)",
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
required: [],
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
// --- MCP Server setup ---
|
|
665
|
+
|
|
666
|
+
const server = new Server(
|
|
667
|
+
{
|
|
668
|
+
name: "mcp-zipcar",
|
|
669
|
+
version: "1.0.0",
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
capabilities: {
|
|
673
|
+
tools: {},
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
679
|
+
return { tools: TOOLS };
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
683
|
+
const { name, arguments: args } = request.params;
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
let result: string;
|
|
687
|
+
|
|
688
|
+
switch (name) {
|
|
689
|
+
case "search_cars":
|
|
690
|
+
result = await searchCars(args as unknown as SearchCarsArgs);
|
|
691
|
+
break;
|
|
692
|
+
case "get_car_details":
|
|
693
|
+
result = await getCarDetails(args as unknown as GetCarDetailsArgs);
|
|
694
|
+
break;
|
|
695
|
+
case "reserve_car":
|
|
696
|
+
result = await reserveCar(args as unknown as ReserveCarArgs);
|
|
697
|
+
break;
|
|
698
|
+
case "extend_reservation":
|
|
699
|
+
result = await extendReservation(args as unknown as ExtendReservationArgs);
|
|
700
|
+
break;
|
|
701
|
+
case "end_trip":
|
|
702
|
+
result = await endTrip(args as unknown as EndTripArgs);
|
|
703
|
+
break;
|
|
704
|
+
case "get_reservation_history":
|
|
705
|
+
result = await getReservationHistory(args as unknown as GetReservationHistoryArgs);
|
|
706
|
+
break;
|
|
707
|
+
default:
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
710
|
+
isError: true,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
content: [{ type: "text", text: result }],
|
|
716
|
+
};
|
|
717
|
+
} catch (error) {
|
|
718
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
719
|
+
return {
|
|
720
|
+
content: [
|
|
721
|
+
{
|
|
722
|
+
type: "text",
|
|
723
|
+
text: JSON.stringify({ status: "error", error: message }),
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
isError: true,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// Cleanup on exit
|
|
732
|
+
process.on("exit", async () => {
|
|
733
|
+
if (context) await context.close().catch(() => null);
|
|
734
|
+
if (browser) await browser.close().catch(() => null);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
process.on("SIGINT", async () => {
|
|
738
|
+
if (context) await context.close().catch(() => null);
|
|
739
|
+
if (browser) await browser.close().catch(() => null);
|
|
740
|
+
process.exit(0);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Start server
|
|
744
|
+
const transport = new StdioServerTransport();
|
|
745
|
+
await server.connect(transport);
|