@striderlabs/mcp-opentable 0.1.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.
@@ -0,0 +1,608 @@
1
+ /**
2
+ * OpenTable Browser Automation
3
+ *
4
+ * Playwright-based automation for OpenTable reservation operations.
5
+ */
6
+ import { chromium } from "playwright";
7
+ import { saveCookies, loadCookies, getAuthState } from "./auth.js";
8
+ const OPENTABLE_BASE_URL = "https://www.opentable.com";
9
+ const DEFAULT_TIMEOUT = 30000;
10
+ // Singleton browser instance
11
+ let browser = null;
12
+ let context = null;
13
+ let page = null;
14
+ /**
15
+ * Initialize browser with stealth settings
16
+ */
17
+ async function initBrowser() {
18
+ if (browser)
19
+ return;
20
+ browser = await chromium.launch({
21
+ headless: true,
22
+ args: [
23
+ "--disable-blink-features=AutomationControlled",
24
+ "--no-sandbox",
25
+ "--disable-setuid-sandbox",
26
+ "--disable-dev-shm-usage",
27
+ ],
28
+ });
29
+ context = await browser.newContext({
30
+ userAgent: "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",
31
+ viewport: { width: 1280, height: 800 },
32
+ locale: "en-US",
33
+ extraHTTPHeaders: {
34
+ "Accept-Language": "en-US,en;q=0.9",
35
+ },
36
+ });
37
+ // Apply stealth patches
38
+ await context.addInitScript(() => {
39
+ Object.defineProperty(navigator, "webdriver", { get: () => undefined });
40
+ Object.defineProperty(navigator, "plugins", {
41
+ get: () => [1, 2, 3, 4, 5],
42
+ });
43
+ });
44
+ // Load saved cookies
45
+ await loadCookies(context);
46
+ page = await context.newPage();
47
+ // Block unnecessary resources for speed
48
+ await page.route("**/*.{png,jpg,jpeg,gif,svg,woff,woff2,mp4,webm}", (route) => route.abort());
49
+ }
50
+ /**
51
+ * Get the current page, initializing if needed
52
+ */
53
+ async function getPage() {
54
+ await initBrowser();
55
+ if (!page)
56
+ throw new Error("Page not initialized");
57
+ return page;
58
+ }
59
+ /**
60
+ * Get current context
61
+ */
62
+ async function getContext() {
63
+ await initBrowser();
64
+ if (!context)
65
+ throw new Error("Context not initialized");
66
+ return context;
67
+ }
68
+ /**
69
+ * Check if user is logged in to OpenTable
70
+ */
71
+ export async function checkAuth() {
72
+ const ctx = await getContext();
73
+ const p = await getPage();
74
+ await p.goto(OPENTABLE_BASE_URL, {
75
+ waitUntil: "domcontentloaded",
76
+ timeout: DEFAULT_TIMEOUT,
77
+ });
78
+ await p.waitForTimeout(2000);
79
+ const authState = await getAuthState(ctx);
80
+ await saveCookies(ctx);
81
+ return authState;
82
+ }
83
+ /**
84
+ * Return login URL and instructions for the user to authenticate
85
+ */
86
+ export async function getLoginUrl() {
87
+ const loginUrl = `${OPENTABLE_BASE_URL}/login`;
88
+ return {
89
+ url: loginUrl,
90
+ instructions: "Please log in to OpenTable in your browser. After logging in, run the 'opentable_status' tool to verify authentication and save your session. Your session will be stored in ~/.strider/opentable/cookies.json.",
91
+ };
92
+ }
93
+ /**
94
+ * Search restaurants on OpenTable
95
+ */
96
+ export async function searchRestaurants(params) {
97
+ const p = await getPage();
98
+ const ctx = await getContext();
99
+ try {
100
+ const { location, cuisine, partySize = 2, date, time } = params;
101
+ // Build search URL with query parameters
102
+ const searchParams = new URLSearchParams();
103
+ searchParams.set("term", location);
104
+ if (cuisine)
105
+ searchParams.set("cuisine", cuisine);
106
+ searchParams.set("covers", String(partySize));
107
+ if (date)
108
+ searchParams.set("dateTime", `${date}T${time || "19:00"}:00`);
109
+ const searchUrl = `${OPENTABLE_BASE_URL}/s?${searchParams.toString()}`;
110
+ await p.goto(searchUrl, {
111
+ waitUntil: "domcontentloaded",
112
+ timeout: DEFAULT_TIMEOUT,
113
+ });
114
+ await p.waitForTimeout(3000);
115
+ // Wait for restaurant results
116
+ await p
117
+ .locator('[data-test="search-result"], [data-testid="restaurant-card"], article[data-restaurant-id]')
118
+ .first()
119
+ .waitFor({ timeout: 10000 })
120
+ .catch(() => { });
121
+ const restaurants = [];
122
+ // Extract restaurant cards
123
+ const cards = p.locator('[data-test="search-result"], [data-testid="restaurant-card"], [data-restaurant-id]');
124
+ const cardCount = await cards.count();
125
+ for (let i = 0; i < Math.min(cardCount, 20); i++) {
126
+ const card = cards.nth(i);
127
+ try {
128
+ const name = (await card
129
+ .locator('h2, h3, [data-test="restaurant-name"], a[data-ot-track-component="Restaurant Name"]')
130
+ .first()
131
+ .textContent()
132
+ .catch(() => "")) || "";
133
+ const cuisineText = (await card
134
+ .locator('[data-test="cuisine"], span:has-text("Cuisine")')
135
+ .first()
136
+ .textContent()
137
+ .catch(() => "")) || "";
138
+ const neighborhoodText = (await card
139
+ .locator('[data-test="neighborhood"], [data-testid="neighborhood"]')
140
+ .first()
141
+ .textContent()
142
+ .catch(() => "")) || "";
143
+ const ratingText = (await card
144
+ .locator('[data-test="rating"], [aria-label*="rating"]')
145
+ .first()
146
+ .textContent()
147
+ .catch(() => "")) || "";
148
+ const reviewCountText = (await card
149
+ .locator('[data-test="review-count"], span:has-text("reviews")')
150
+ .first()
151
+ .textContent()
152
+ .catch(() => "")) || "";
153
+ const priceRange = (await card
154
+ .locator('[data-test="price"], span:has-text("$")')
155
+ .first()
156
+ .textContent()
157
+ .catch(() => "")) || "";
158
+ // Get profile link and extract restaurant ID
159
+ const profileLink = (await card
160
+ .locator("a[href*='/restaurant/']")
161
+ .first()
162
+ .getAttribute("href")
163
+ .catch(() => "")) || "";
164
+ const ridMatch = profileLink.match(/\/restaurant\/([^/?]+)/);
165
+ const restaurantId = ridMatch?.[1] || `restaurant-${i}`;
166
+ const restaurantIdAttr = (await card.getAttribute("data-restaurant-id").catch(() => "")) || restaurantId;
167
+ if (name.trim()) {
168
+ restaurants.push({
169
+ id: restaurantIdAttr || restaurantId,
170
+ name: name.trim(),
171
+ cuisine: cuisineText.trim(),
172
+ location: neighborhoodText.trim() || location,
173
+ neighborhood: neighborhoodText.trim(),
174
+ rating: parseFloat(ratingText.replace(/[^0-9.]/g, "")) || undefined,
175
+ reviewCount: parseInt(reviewCountText.replace(/[^0-9]/g, "")) || undefined,
176
+ priceRange: priceRange.trim() || undefined,
177
+ profileUrl: profileLink
178
+ ? `${OPENTABLE_BASE_URL}${profileLink}`
179
+ : undefined,
180
+ });
181
+ }
182
+ }
183
+ catch {
184
+ // Skip problematic cards
185
+ }
186
+ }
187
+ await saveCookies(ctx);
188
+ return { success: true, restaurants };
189
+ }
190
+ catch (error) {
191
+ return {
192
+ success: false,
193
+ error: error instanceof Error ? error.message : "Failed to search restaurants",
194
+ };
195
+ }
196
+ }
197
+ /**
198
+ * Get detailed information about a specific restaurant
199
+ */
200
+ export async function getRestaurantDetails(restaurantId) {
201
+ const p = await getPage();
202
+ const ctx = await getContext();
203
+ try {
204
+ const url = restaurantId.startsWith("http")
205
+ ? restaurantId
206
+ : `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
207
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
208
+ await p.waitForTimeout(3000);
209
+ const name = (await p
210
+ .locator("h1, [data-test='restaurant-name']")
211
+ .first()
212
+ .textContent()
213
+ .catch(() => "")) || "";
214
+ const description = (await p
215
+ .locator('[data-test="restaurant-description"], p.description, [aria-label*="description"]')
216
+ .first()
217
+ .textContent()
218
+ .catch(() => "")) || "";
219
+ const cuisineText = (await p
220
+ .locator('[data-test="cuisine-link"], a[href*="/cuisine"]')
221
+ .first()
222
+ .textContent()
223
+ .catch(() => "")) || "";
224
+ const address = (await p
225
+ .locator('[data-test="address"], address, [itemprop="address"]')
226
+ .first()
227
+ .textContent()
228
+ .catch(() => "")) || "";
229
+ const phone = (await p
230
+ .locator('[data-test="phone"], a[href^="tel:"], [itemprop="telephone"]')
231
+ .first()
232
+ .textContent()
233
+ .catch(() => "")) || "";
234
+ const ratingText = (await p
235
+ .locator('[data-test="rating-value"], [aria-label*="stars"]')
236
+ .first()
237
+ .textContent()
238
+ .catch(() => "")) || "";
239
+ const reviewCountText = (await p
240
+ .locator('[data-test="review-count"]')
241
+ .first()
242
+ .textContent()
243
+ .catch(() => "")) || "";
244
+ const priceRange = (await p
245
+ .locator('[data-test="price"], [aria-label*="price"]')
246
+ .first()
247
+ .textContent()
248
+ .catch(() => "")) || "";
249
+ const neighborhood = (await p
250
+ .locator('[data-test="neighborhood"], [data-testid="neighborhood"]')
251
+ .first()
252
+ .textContent()
253
+ .catch(() => "")) || "";
254
+ // Extract feature tags
255
+ const featureElements = p.locator('[data-test="feature-tag"], [data-testid="feature"], li.feature');
256
+ const featureCount = await featureElements.count();
257
+ const features = [];
258
+ for (let i = 0; i < Math.min(featureCount, 10); i++) {
259
+ const feat = await featureElements
260
+ .nth(i)
261
+ .textContent()
262
+ .catch(() => "");
263
+ if (feat?.trim())
264
+ features.push(feat.trim());
265
+ }
266
+ await saveCookies(ctx);
267
+ return {
268
+ success: true,
269
+ restaurant: {
270
+ id: restaurantId,
271
+ name: name.trim(),
272
+ cuisine: cuisineText.trim(),
273
+ location: address.trim() || neighborhood.trim(),
274
+ neighborhood: neighborhood.trim() || undefined,
275
+ description: description.trim() || undefined,
276
+ address: address.trim() || undefined,
277
+ phone: phone.trim() || undefined,
278
+ rating: parseFloat(ratingText.replace(/[^0-9.]/g, "")) || undefined,
279
+ reviewCount: parseInt(reviewCountText.replace(/[^0-9]/g, "")) || undefined,
280
+ priceRange: priceRange.trim() || undefined,
281
+ features: features.length > 0 ? features : undefined,
282
+ profileUrl: url,
283
+ },
284
+ };
285
+ }
286
+ catch (error) {
287
+ return {
288
+ success: false,
289
+ error: error instanceof Error
290
+ ? error.message
291
+ : "Failed to get restaurant details",
292
+ };
293
+ }
294
+ }
295
+ /**
296
+ * Check available reservation times for a restaurant
297
+ */
298
+ export async function checkAvailability(params) {
299
+ const p = await getPage();
300
+ const ctx = await getContext();
301
+ try {
302
+ const { restaurantId, date, time, partySize } = params;
303
+ // Build restaurant URL with availability params
304
+ const url = restaurantId.startsWith("http")
305
+ ? restaurantId
306
+ : `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
307
+ const fullUrl = `${url}?dateTime=${date}T${time}:00&covers=${partySize}`;
308
+ await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
309
+ await p.waitForTimeout(3000);
310
+ const restaurantName = (await p
311
+ .locator("h1, [data-test='restaurant-name']")
312
+ .first()
313
+ .textContent()
314
+ .catch(() => "")) || "Unknown Restaurant";
315
+ // Wait for availability slots to load
316
+ await p
317
+ .locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime]')
318
+ .first()
319
+ .waitFor({ timeout: 10000 })
320
+ .catch(() => { });
321
+ const slots = [];
322
+ // Extract available time slots
323
+ const timeSlots = p.locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime], [aria-label*="Reserve"]');
324
+ const slotCount = await timeSlots.count();
325
+ for (let i = 0; i < Math.min(slotCount, 30); i++) {
326
+ const slot = timeSlots.nth(i);
327
+ try {
328
+ const timeText = (await slot.textContent().catch(() => "")) || "";
329
+ const datetime = (await slot.getAttribute("data-datetime").catch(() => "")) || "";
330
+ const token = (await slot.getAttribute("data-reservation-token").catch(() => "")) ||
331
+ undefined;
332
+ if (timeText.trim()) {
333
+ slots.push({
334
+ time: timeText.trim(),
335
+ partySize,
336
+ date,
337
+ reservationToken: token,
338
+ });
339
+ }
340
+ }
341
+ catch {
342
+ // Skip problematic slots
343
+ }
344
+ }
345
+ await saveCookies(ctx);
346
+ return {
347
+ success: true,
348
+ restaurantName: restaurantName.trim(),
349
+ slots,
350
+ };
351
+ }
352
+ catch (error) {
353
+ return {
354
+ success: false,
355
+ error: error instanceof Error
356
+ ? error.message
357
+ : "Failed to check availability",
358
+ };
359
+ }
360
+ }
361
+ /**
362
+ * Make a reservation at a restaurant
363
+ */
364
+ export async function makeReservation(params) {
365
+ const p = await getPage();
366
+ const ctx = await getContext();
367
+ try {
368
+ const { restaurantId, date, time, partySize, specialRequests, confirm, } = params;
369
+ const url = restaurantId.startsWith("http")
370
+ ? restaurantId
371
+ : `${OPENTABLE_BASE_URL}/restaurant/${restaurantId}`;
372
+ const fullUrl = `${url}?dateTime=${date}T${time}:00&covers=${partySize}`;
373
+ await p.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
374
+ await p.waitForTimeout(3000);
375
+ const restaurantName = (await p
376
+ .locator("h1, [data-test='restaurant-name']")
377
+ .first()
378
+ .textContent()
379
+ .catch(() => "")) || "Unknown Restaurant";
380
+ // If not confirmed, return a preview
381
+ if (!confirm) {
382
+ return {
383
+ success: true,
384
+ requiresConfirmation: true,
385
+ preview: {
386
+ restaurantName: restaurantName.trim(),
387
+ date,
388
+ time,
389
+ partySize,
390
+ specialRequests,
391
+ },
392
+ };
393
+ }
394
+ // Find and click the time slot
395
+ const timeSlot = p
396
+ .locator(`[data-test="availability-time"]:has-text("${time}"), button[data-datetime*="${time}"], [aria-label*="${time}"]`)
397
+ .first();
398
+ if (await timeSlot.isVisible({ timeout: 5000 })) {
399
+ await timeSlot.click();
400
+ await p.waitForTimeout(2000);
401
+ }
402
+ else {
403
+ // Try clicking the first available slot
404
+ const firstSlot = p
405
+ .locator('[data-test="availability-time"], [data-testid="time-slot"], button[data-datetime]')
406
+ .first();
407
+ if (await firstSlot.isVisible({ timeout: 5000 })) {
408
+ await firstSlot.click();
409
+ await p.waitForTimeout(2000);
410
+ }
411
+ }
412
+ // Fill in special requests if provided
413
+ if (specialRequests) {
414
+ const requestsField = p.locator('textarea[name*="request"], textarea[placeholder*="request"], [data-test="special-requests"]');
415
+ if (await requestsField.isVisible({ timeout: 3000 })) {
416
+ await requestsField.fill(specialRequests);
417
+ }
418
+ }
419
+ // Click the reserve/complete button
420
+ const reserveButton = p
421
+ .locator('button:has-text("Complete reservation"), button:has-text("Reserve"), button[data-test="complete-reservation"], button[type="submit"]')
422
+ .first();
423
+ if (await reserveButton.isVisible({ timeout: 5000 })) {
424
+ await reserveButton.click();
425
+ await p.waitForTimeout(5000);
426
+ }
427
+ // Extract confirmation number from success page
428
+ const confirmationText = (await p
429
+ .locator('[data-test="confirmation-number"], h2:has-text("Confirmed"), [aria-label*="confirmation"]')
430
+ .first()
431
+ .textContent()
432
+ .catch(() => "")) || "";
433
+ const confirmationMatch = confirmationText.match(/[A-Z0-9]{6,}/);
434
+ const confirmationNumber = confirmationMatch?.[0];
435
+ // Get reservation ID from URL
436
+ const urlMatch = p.url().match(/reservation\/([^/?]+)/);
437
+ const reservationId = urlMatch?.[1] || `res-${Date.now()}`;
438
+ await saveCookies(ctx);
439
+ return {
440
+ success: true,
441
+ reservation: {
442
+ id: reservationId,
443
+ restaurantName: restaurantName.trim(),
444
+ date,
445
+ time,
446
+ partySize,
447
+ status: "confirmed",
448
+ confirmationNumber,
449
+ specialRequests,
450
+ },
451
+ };
452
+ }
453
+ catch (error) {
454
+ return {
455
+ success: false,
456
+ error: error instanceof Error ? error.message : "Failed to make reservation",
457
+ };
458
+ }
459
+ }
460
+ /**
461
+ * Get list of upcoming reservations
462
+ */
463
+ export async function getReservations() {
464
+ const p = await getPage();
465
+ const ctx = await getContext();
466
+ try {
467
+ await p.goto(`${OPENTABLE_BASE_URL}/account/reservations`, {
468
+ waitUntil: "domcontentloaded",
469
+ timeout: DEFAULT_TIMEOUT,
470
+ });
471
+ await p.waitForTimeout(3000);
472
+ // Wait for reservations to load
473
+ await p
474
+ .locator('[data-test="reservation-card"], [data-testid="reservation"], article[data-reservation-id]')
475
+ .first()
476
+ .waitFor({ timeout: 10000 })
477
+ .catch(() => { });
478
+ const reservations = [];
479
+ const cards = p.locator('[data-test="reservation-card"], [data-testid="reservation"], article[data-reservation-id]');
480
+ const cardCount = await cards.count();
481
+ for (let i = 0; i < Math.min(cardCount, 20); i++) {
482
+ const card = cards.nth(i);
483
+ try {
484
+ const restaurantName = (await card
485
+ .locator('h2, h3, [data-test="restaurant-name"], a[data-ot-track-component="Restaurant Name"]')
486
+ .first()
487
+ .textContent()
488
+ .catch(() => "")) || "";
489
+ const dateText = (await card
490
+ .locator('[data-test="reservation-date"], time, [datetime]')
491
+ .first()
492
+ .textContent()
493
+ .catch(() => "")) || "";
494
+ const timeText = (await card
495
+ .locator('[data-test="reservation-time"], [aria-label*="time"]')
496
+ .first()
497
+ .textContent()
498
+ .catch(() => "")) || "";
499
+ const partySizeText = (await card
500
+ .locator('[data-test="party-size"], [aria-label*="guest"]')
501
+ .first()
502
+ .textContent()
503
+ .catch(() => "")) || "";
504
+ const statusText = (await card
505
+ .locator('[data-test="reservation-status"], [aria-label*="status"]')
506
+ .first()
507
+ .textContent()
508
+ .catch(() => "upcoming")) || "upcoming";
509
+ const confirmationText = (await card
510
+ .locator('[data-test="confirmation-number"], span:has-text("Confirmation")')
511
+ .first()
512
+ .textContent()
513
+ .catch(() => "")) || "";
514
+ const reservationId = (await card
515
+ .getAttribute("data-reservation-id")
516
+ .catch(() => "")) || `res-${i}`;
517
+ if (restaurantName.trim()) {
518
+ reservations.push({
519
+ id: reservationId,
520
+ restaurantName: restaurantName.trim(),
521
+ date: dateText.trim(),
522
+ time: timeText.trim(),
523
+ partySize: parseInt(partySizeText.replace(/[^0-9]/g, "")) || 2,
524
+ status: statusText.trim(),
525
+ confirmationNumber: confirmationText.trim() || undefined,
526
+ });
527
+ }
528
+ }
529
+ catch {
530
+ // Skip problematic cards
531
+ }
532
+ }
533
+ await saveCookies(ctx);
534
+ return { success: true, reservations };
535
+ }
536
+ catch (error) {
537
+ return {
538
+ success: false,
539
+ error: error instanceof Error ? error.message : "Failed to get reservations",
540
+ };
541
+ }
542
+ }
543
+ /**
544
+ * Cancel a reservation
545
+ */
546
+ export async function cancelReservation(params) {
547
+ const p = await getPage();
548
+ const ctx = await getContext();
549
+ try {
550
+ const { reservationId, confirm } = params;
551
+ if (!confirm) {
552
+ return {
553
+ success: true,
554
+ requiresConfirmation: true,
555
+ message: `Please confirm cancellation of reservation ${reservationId}. Set confirm=true to proceed.`,
556
+ };
557
+ }
558
+ // Navigate to the reservation page
559
+ const url = `${OPENTABLE_BASE_URL}/account/reservations/${reservationId}`;
560
+ await p.goto(url, { waitUntil: "domcontentloaded", timeout: DEFAULT_TIMEOUT });
561
+ await p.waitForTimeout(2000);
562
+ // Click cancel button
563
+ const cancelButton = p
564
+ .locator('button:has-text("Cancel reservation"), button:has-text("Cancel"), [data-test="cancel-reservation"]')
565
+ .first();
566
+ if (!(await cancelButton.isVisible({ timeout: 5000 }))) {
567
+ return {
568
+ success: false,
569
+ error: "Cancel button not found. Reservation may not be cancellable.",
570
+ };
571
+ }
572
+ await cancelButton.click();
573
+ await p.waitForTimeout(2000);
574
+ // Confirm cancellation in dialog if present
575
+ const confirmButton = p
576
+ .locator('button:has-text("Yes, cancel"), button:has-text("Confirm cancel"), [data-test="confirm-cancel"]')
577
+ .first();
578
+ if (await confirmButton.isVisible({ timeout: 3000 })) {
579
+ await confirmButton.click();
580
+ await p.waitForTimeout(3000);
581
+ }
582
+ await saveCookies(ctx);
583
+ return {
584
+ success: true,
585
+ message: `Reservation ${reservationId} has been cancelled successfully.`,
586
+ };
587
+ }
588
+ catch (error) {
589
+ return {
590
+ success: false,
591
+ error: error instanceof Error ? error.message : "Failed to cancel reservation",
592
+ };
593
+ }
594
+ }
595
+ /**
596
+ * Cleanup browser resources
597
+ */
598
+ export async function cleanup() {
599
+ if (context) {
600
+ await saveCookies(context);
601
+ }
602
+ if (browser) {
603
+ await browser.close();
604
+ browser = null;
605
+ context = null;
606
+ page = null;
607
+ }
608
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Strider Labs OpenTable MCP Server
4
+ *
5
+ * MCP server that gives AI agents the ability to search restaurants,
6
+ * check availability, make reservations, and manage bookings on OpenTable.
7
+ * https://striderlabs.ai
8
+ */
9
+ export {};