good-eggs-mcp-server 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.
- package/README.md +178 -0
- package/build/index.integration-with-mock.js +149 -0
- package/build/index.js +93 -0
- package/package.json +46 -0
- package/shared/index.d.ts +5 -0
- package/shared/index.js +6 -0
- package/shared/logging.d.ts +20 -0
- package/shared/logging.js +34 -0
- package/shared/server.d.ts +120 -0
- package/shared/server.js +620 -0
- package/shared/tools.d.ts +4 -0
- package/shared/tools.js +612 -0
- package/shared/types.d.ts +95 -0
- package/shared/types.js +43 -0
package/shared/server.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { createRegisterTools } from './tools.js';
|
|
3
|
+
const BASE_URL = 'https://www.goodeggs.com';
|
|
4
|
+
/**
|
|
5
|
+
* Good Eggs client implementation using Playwright
|
|
6
|
+
*/
|
|
7
|
+
export class GoodEggsClient {
|
|
8
|
+
browser = null;
|
|
9
|
+
context = null;
|
|
10
|
+
page = null;
|
|
11
|
+
config;
|
|
12
|
+
isInitialized = false;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
}
|
|
16
|
+
async ensureBrowser() {
|
|
17
|
+
if (!this.page) {
|
|
18
|
+
throw new Error('Browser not initialized. Call initialize() first.');
|
|
19
|
+
}
|
|
20
|
+
return this.page;
|
|
21
|
+
}
|
|
22
|
+
async initialize() {
|
|
23
|
+
if (this.isInitialized) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Use playwright-extra with stealth plugin for better bot detection avoidance
|
|
27
|
+
const { chromium } = await import('playwright-extra');
|
|
28
|
+
const StealthPlugin = (await import('puppeteer-extra-plugin-stealth')).default;
|
|
29
|
+
chromium.use(StealthPlugin());
|
|
30
|
+
this.browser = await chromium.launch({
|
|
31
|
+
headless: this.config.headless,
|
|
32
|
+
args: [
|
|
33
|
+
'--disable-blink-features=AutomationControlled',
|
|
34
|
+
'--disable-dev-shm-usage',
|
|
35
|
+
'--no-sandbox',
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
this.context = await this.browser.newContext({
|
|
39
|
+
viewport: { width: 1920, height: 1080 },
|
|
40
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
41
|
+
});
|
|
42
|
+
this.page = await this.context.newPage();
|
|
43
|
+
// Navigate to login page
|
|
44
|
+
await this.page.goto(`${BASE_URL}/signin`, { waitUntil: 'networkidle' });
|
|
45
|
+
// Fill in login credentials
|
|
46
|
+
await this.page.fill('input[name="email"], input[type="email"]', this.config.username);
|
|
47
|
+
await this.page.fill('input[name="password"], input[type="password"]', this.config.password);
|
|
48
|
+
// Click sign in button
|
|
49
|
+
await this.page.click('button:has-text("Sign In")');
|
|
50
|
+
// Wait for navigation to complete (should redirect to home or previous page)
|
|
51
|
+
await this.page.waitForURL((url) => !url.pathname.includes('/signin'), {
|
|
52
|
+
timeout: this.config.timeout,
|
|
53
|
+
});
|
|
54
|
+
// Verify login was successful by checking if we're no longer on signin page
|
|
55
|
+
const currentUrl = this.page.url();
|
|
56
|
+
if (currentUrl.includes('/signin')) {
|
|
57
|
+
throw new Error('Login failed - still on signin page. Check your credentials.');
|
|
58
|
+
}
|
|
59
|
+
this.isInitialized = true;
|
|
60
|
+
}
|
|
61
|
+
async searchGroceries(query) {
|
|
62
|
+
const page = await this.ensureBrowser();
|
|
63
|
+
// Navigate to search page
|
|
64
|
+
await page.goto(`${BASE_URL}/search?q=${encodeURIComponent(query)}`, {
|
|
65
|
+
waitUntil: 'networkidle',
|
|
66
|
+
});
|
|
67
|
+
// Wait for products to load
|
|
68
|
+
await page.waitForTimeout(1000);
|
|
69
|
+
// Extract product information from the page
|
|
70
|
+
const items = await page.evaluate(() => {
|
|
71
|
+
const products = [];
|
|
72
|
+
// Find all product cards/links by looking for product name elements
|
|
73
|
+
const productElements = document.querySelectorAll('a[href*="/product/"], a[href*="goodeggs"]');
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
productElements.forEach((el) => {
|
|
76
|
+
const link = el;
|
|
77
|
+
const href = link.href;
|
|
78
|
+
// Skip if not a product link or already seen
|
|
79
|
+
if (!href || seen.has(href) || href.includes('/search') || href.includes('/signin')) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Try to find product info within or near this element
|
|
83
|
+
const container = link.closest('div[class*="product"], article, [class*="card"]') || link;
|
|
84
|
+
const nameEl = container.querySelector('h2, h3, [class*="title"], [class*="name"]') ||
|
|
85
|
+
link.querySelector('h2, h3');
|
|
86
|
+
const brandEl = container.querySelector('[class*="brand"], [class*="producer"]');
|
|
87
|
+
const priceEl = container.querySelector('[class*="price"]');
|
|
88
|
+
const discountEl = container.querySelector('[class*="off"], [class*="discount"]');
|
|
89
|
+
const imgEl = container.querySelector('img');
|
|
90
|
+
const name = nameEl?.textContent?.trim();
|
|
91
|
+
if (!name || name.length < 3)
|
|
92
|
+
return;
|
|
93
|
+
seen.add(href);
|
|
94
|
+
products.push({
|
|
95
|
+
url: href,
|
|
96
|
+
name: name,
|
|
97
|
+
brand: brandEl?.textContent?.trim() || '',
|
|
98
|
+
price: priceEl?.textContent?.trim() || '',
|
|
99
|
+
discount: discountEl?.textContent?.trim() || undefined,
|
|
100
|
+
imageUrl: imgEl?.src || undefined,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return products;
|
|
104
|
+
});
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
async getFavorites() {
|
|
108
|
+
const page = await this.ensureBrowser();
|
|
109
|
+
// Navigate to favorites page
|
|
110
|
+
await page.goto(`${BASE_URL}/favorites`, { waitUntil: 'networkidle' });
|
|
111
|
+
// Wait for page to load
|
|
112
|
+
await page.waitForTimeout(1000);
|
|
113
|
+
// Check if we're redirected to signin
|
|
114
|
+
if (page.url().includes('/signin')) {
|
|
115
|
+
throw new Error('Not logged in. Cannot access favorites.');
|
|
116
|
+
}
|
|
117
|
+
// Extract favorite items
|
|
118
|
+
const items = await page.evaluate(() => {
|
|
119
|
+
const products = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const productElements = document.querySelectorAll('a[href*="/product/"], a[href*="goodeggs"]');
|
|
122
|
+
productElements.forEach((el) => {
|
|
123
|
+
const link = el;
|
|
124
|
+
const href = link.href;
|
|
125
|
+
if (!href || seen.has(href) || href.includes('/favorites') || href.includes('/signin')) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const container = link.closest('div[class*="product"], article, [class*="card"]') || link;
|
|
129
|
+
const nameEl = container.querySelector('h2, h3, [class*="title"], [class*="name"]');
|
|
130
|
+
const brandEl = container.querySelector('[class*="brand"], [class*="producer"]');
|
|
131
|
+
const priceEl = container.querySelector('[class*="price"]');
|
|
132
|
+
const imgEl = container.querySelector('img');
|
|
133
|
+
const name = nameEl?.textContent?.trim();
|
|
134
|
+
if (!name || name.length < 3)
|
|
135
|
+
return;
|
|
136
|
+
seen.add(href);
|
|
137
|
+
products.push({
|
|
138
|
+
url: href,
|
|
139
|
+
name: name,
|
|
140
|
+
brand: brandEl?.textContent?.trim() || '',
|
|
141
|
+
price: priceEl?.textContent?.trim() || '',
|
|
142
|
+
imageUrl: imgEl?.src || undefined,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
return products;
|
|
146
|
+
});
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
149
|
+
async getGroceryDetails(groceryUrl) {
|
|
150
|
+
const page = await this.ensureBrowser();
|
|
151
|
+
// Check if we're already on the product page
|
|
152
|
+
const currentUrl = page.url();
|
|
153
|
+
if (!currentUrl.includes(groceryUrl) && !groceryUrl.includes(currentUrl)) {
|
|
154
|
+
// Navigate to the product page
|
|
155
|
+
const fullUrl = groceryUrl.startsWith('http') ? groceryUrl : `${BASE_URL}${groceryUrl}`;
|
|
156
|
+
await page.goto(fullUrl, { waitUntil: 'networkidle' });
|
|
157
|
+
}
|
|
158
|
+
// Wait for page content to load
|
|
159
|
+
await page.waitForTimeout(1000);
|
|
160
|
+
// Extract product details
|
|
161
|
+
const details = await page.evaluate((url) => {
|
|
162
|
+
// Find the main product info
|
|
163
|
+
const nameEl = document.querySelector('h1, [class*="product-name"], [class*="title"]');
|
|
164
|
+
const brandEl = document.querySelector('[class*="brand"], [class*="producer"]');
|
|
165
|
+
const priceEl = document.querySelector('[class*="sale-price"], [class*="current-price"]');
|
|
166
|
+
const originalPriceEl = document.querySelector('[class*="original-price"], [class*="regular-price"], s');
|
|
167
|
+
const discountEl = document.querySelector('[class*="off"], [class*="discount"]');
|
|
168
|
+
const descriptionEl = document.querySelector('[class*="description"], [class*="details"] p, .product-description');
|
|
169
|
+
const productDetailsEl = document.querySelector('[class*="product-details"]');
|
|
170
|
+
const imgEl = document.querySelector('[class*="product"] img, main img');
|
|
171
|
+
// Get availability dates
|
|
172
|
+
const availabilityEls = document.querySelectorAll('[class*="availability"] span, [class*="delivery"] span');
|
|
173
|
+
const availability = [];
|
|
174
|
+
availabilityEls.forEach((el) => {
|
|
175
|
+
const text = el.textContent?.trim();
|
|
176
|
+
if (text)
|
|
177
|
+
availability.push(text);
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
url: url,
|
|
181
|
+
name: nameEl?.textContent?.trim() || 'Unknown',
|
|
182
|
+
brand: brandEl?.textContent?.trim() || '',
|
|
183
|
+
price: priceEl?.textContent?.trim() || '',
|
|
184
|
+
originalPrice: originalPriceEl?.textContent?.trim() || undefined,
|
|
185
|
+
discount: discountEl?.textContent?.trim() || undefined,
|
|
186
|
+
description: descriptionEl?.textContent?.trim() || undefined,
|
|
187
|
+
productDetails: productDetailsEl?.textContent?.trim() || undefined,
|
|
188
|
+
availability: availability.length > 0 ? availability : undefined,
|
|
189
|
+
imageUrl: imgEl?.src || undefined,
|
|
190
|
+
};
|
|
191
|
+
}, groceryUrl);
|
|
192
|
+
return details;
|
|
193
|
+
}
|
|
194
|
+
async addToCart(groceryUrl, quantity) {
|
|
195
|
+
const page = await this.ensureBrowser();
|
|
196
|
+
// Check if we're already on the product page
|
|
197
|
+
const currentUrl = page.url();
|
|
198
|
+
const normalizedGroceryUrl = groceryUrl.replace(BASE_URL, '');
|
|
199
|
+
const normalizedCurrentUrl = currentUrl.replace(BASE_URL, '');
|
|
200
|
+
if (!normalizedCurrentUrl.includes(normalizedGroceryUrl.split('/').pop() || '')) {
|
|
201
|
+
// Navigate to the product page
|
|
202
|
+
const fullUrl = groceryUrl.startsWith('http') ? groceryUrl : `${BASE_URL}${groceryUrl}`;
|
|
203
|
+
await page.goto(fullUrl, { waitUntil: 'networkidle' });
|
|
204
|
+
}
|
|
205
|
+
// Get the product name for the result
|
|
206
|
+
const itemName = await page.evaluate(() => {
|
|
207
|
+
const nameEl = document.querySelector('h1, [class*="product-name"], [class*="title"]');
|
|
208
|
+
return nameEl?.textContent?.trim() || 'Unknown item';
|
|
209
|
+
});
|
|
210
|
+
// Set quantity if different from 1
|
|
211
|
+
let quantitySet = false;
|
|
212
|
+
if (quantity > 1) {
|
|
213
|
+
// Try to find and use quantity selector
|
|
214
|
+
const quantitySelector = await page.$('select[class*="quantity"], [class*="quantity"] select');
|
|
215
|
+
if (quantitySelector) {
|
|
216
|
+
await quantitySelector.selectOption(String(quantity));
|
|
217
|
+
quantitySet = true;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// Try clicking + button multiple times
|
|
221
|
+
const plusButton = await page.$('button[aria-label*="increase"], button:has-text("+")');
|
|
222
|
+
if (plusButton) {
|
|
223
|
+
for (let i = 1; i < quantity; i++) {
|
|
224
|
+
await plusButton.click();
|
|
225
|
+
await page.waitForTimeout(200);
|
|
226
|
+
}
|
|
227
|
+
quantitySet = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!quantitySet) {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
message: `Could not set quantity to ${quantity} - quantity controls not found. Item may only support single-item adds.`,
|
|
234
|
+
itemName,
|
|
235
|
+
quantity: 1,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Click the add to cart/basket button
|
|
240
|
+
const addButton = await page.$('button:has-text("ADD TO BASKET"), button:has-text("Add to Cart"), button:has-text("Add to Basket")');
|
|
241
|
+
if (!addButton) {
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
message: 'Could not find add to cart button',
|
|
245
|
+
itemName,
|
|
246
|
+
quantity,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
await addButton.click();
|
|
250
|
+
// Wait for cart update
|
|
251
|
+
await page.waitForTimeout(1000);
|
|
252
|
+
return {
|
|
253
|
+
success: true,
|
|
254
|
+
message: `Successfully added ${quantity} x ${itemName} to cart`,
|
|
255
|
+
itemName,
|
|
256
|
+
quantity,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async searchFreebieGroceries() {
|
|
260
|
+
const page = await this.ensureBrowser();
|
|
261
|
+
const allFreeItems = [];
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
// Helper function to extract free items ($0.00) from current page
|
|
264
|
+
const extractFreeItems = async () => {
|
|
265
|
+
return await page.evaluate(() => {
|
|
266
|
+
const products = [];
|
|
267
|
+
const seenUrls = new Set();
|
|
268
|
+
// Find all product links
|
|
269
|
+
const productElements = document.querySelectorAll('a[href*="/product/"], a[href*="goodeggs"]');
|
|
270
|
+
productElements.forEach((el) => {
|
|
271
|
+
const link = el;
|
|
272
|
+
const href = link.href;
|
|
273
|
+
if (!href ||
|
|
274
|
+
seenUrls.has(href) ||
|
|
275
|
+
href.includes('/fresh-picks') ||
|
|
276
|
+
href.includes('/signin')) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const container = link.closest('div[class*="product"], article, [class*="card"]') || link;
|
|
280
|
+
// Look for price element and check if it's $0.00
|
|
281
|
+
const priceEl = container.querySelector('[class*="price"]');
|
|
282
|
+
const priceText = priceEl?.textContent?.trim() || '';
|
|
283
|
+
// Only include items that are truly free ($0.00 or $0)
|
|
284
|
+
// Must use exact matching to avoid false positives like $10.00, $20.50
|
|
285
|
+
const isFree = /^\$0(\.00)?$/.test(priceText) || priceText === '$0.00' || priceText === '$0';
|
|
286
|
+
if (!isFree) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const nameEl = container.querySelector('h2, h3, [class*="title"], [class*="name"]');
|
|
290
|
+
const brandEl = container.querySelector('[class*="brand"], [class*="producer"]');
|
|
291
|
+
const imgEl = container.querySelector('img');
|
|
292
|
+
const name = nameEl?.textContent?.trim();
|
|
293
|
+
if (!name || name.length < 3)
|
|
294
|
+
return;
|
|
295
|
+
seenUrls.add(href);
|
|
296
|
+
products.push({
|
|
297
|
+
url: href,
|
|
298
|
+
name: name,
|
|
299
|
+
brand: brandEl?.textContent?.trim() || '',
|
|
300
|
+
price: priceText,
|
|
301
|
+
discount: 'FREE',
|
|
302
|
+
imageUrl: imgEl?.src || undefined,
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
return products;
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
// Check homepage for free items
|
|
309
|
+
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
|
|
310
|
+
await page.waitForTimeout(1000);
|
|
311
|
+
const homePageItems = await extractFreeItems();
|
|
312
|
+
for (const item of homePageItems) {
|
|
313
|
+
if (!seen.has(item.url)) {
|
|
314
|
+
seen.add(item.url);
|
|
315
|
+
allFreeItems.push(item);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Check /fresh-picks page for free items
|
|
319
|
+
await page.goto(`${BASE_URL}/fresh-picks`, { waitUntil: 'networkidle' });
|
|
320
|
+
await page.waitForTimeout(1000);
|
|
321
|
+
const freshPicksItems = await extractFreeItems();
|
|
322
|
+
for (const item of freshPicksItems) {
|
|
323
|
+
if (!seen.has(item.url)) {
|
|
324
|
+
seen.add(item.url);
|
|
325
|
+
allFreeItems.push(item);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return allFreeItems;
|
|
329
|
+
}
|
|
330
|
+
async getPastOrderDates() {
|
|
331
|
+
const page = await this.ensureBrowser();
|
|
332
|
+
// Navigate to reorder/past orders page
|
|
333
|
+
await page.goto(`${BASE_URL}/reorder`, { waitUntil: 'networkidle' });
|
|
334
|
+
// Wait for page to load
|
|
335
|
+
await page.waitForTimeout(1000);
|
|
336
|
+
// Check if we're redirected to signin
|
|
337
|
+
if (page.url().includes('/signin')) {
|
|
338
|
+
throw new Error('Not logged in. Cannot access past orders.');
|
|
339
|
+
}
|
|
340
|
+
// Extract past order information
|
|
341
|
+
const orders = await page.evaluate(() => {
|
|
342
|
+
const orderList = [];
|
|
343
|
+
// Look for order date elements
|
|
344
|
+
const orderElements = document.querySelectorAll('[class*="order"], [class*="history"] > div, [class*="past-order"]');
|
|
345
|
+
orderElements.forEach((el) => {
|
|
346
|
+
const dateEl = el.querySelector('[class*="date"], time');
|
|
347
|
+
const totalEl = el.querySelector('[class*="total"], [class*="price"]');
|
|
348
|
+
const countEl = el.querySelector('[class*="count"], [class*="items"]');
|
|
349
|
+
const dateText = dateEl?.textContent?.trim();
|
|
350
|
+
if (!dateText)
|
|
351
|
+
return;
|
|
352
|
+
orderList.push({
|
|
353
|
+
date: dateText,
|
|
354
|
+
total: totalEl?.textContent?.trim() || undefined,
|
|
355
|
+
itemCount: countEl?.textContent ? parseInt(countEl.textContent) : undefined,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
return orderList;
|
|
359
|
+
});
|
|
360
|
+
return orders;
|
|
361
|
+
}
|
|
362
|
+
async getPastOrderGroceries(orderDate) {
|
|
363
|
+
const page = await this.ensureBrowser();
|
|
364
|
+
// First, go to reorder page
|
|
365
|
+
if (!page.url().includes('/reorder')) {
|
|
366
|
+
await page.goto(`${BASE_URL}/reorder`, { waitUntil: 'networkidle' });
|
|
367
|
+
await page.waitForTimeout(1000);
|
|
368
|
+
}
|
|
369
|
+
// Check if we're redirected to signin
|
|
370
|
+
if (page.url().includes('/signin')) {
|
|
371
|
+
throw new Error('Not logged in. Cannot access past orders.');
|
|
372
|
+
}
|
|
373
|
+
// Try to click on the specific order date
|
|
374
|
+
const orderLink = await page.$(`text=${orderDate}`);
|
|
375
|
+
if (orderLink) {
|
|
376
|
+
await orderLink.click();
|
|
377
|
+
await page.waitForTimeout(1000);
|
|
378
|
+
}
|
|
379
|
+
// Extract items from the order
|
|
380
|
+
const items = await page.evaluate(() => {
|
|
381
|
+
const products = [];
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const productElements = document.querySelectorAll('a[href*="/product/"], a[href*="goodeggs"]');
|
|
384
|
+
productElements.forEach((el) => {
|
|
385
|
+
const link = el;
|
|
386
|
+
const href = link.href;
|
|
387
|
+
if (!href || seen.has(href) || href.includes('/reorder') || href.includes('/signin')) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const container = link.closest('div[class*="product"], article, [class*="card"]') || link;
|
|
391
|
+
const nameEl = container.querySelector('h2, h3, [class*="title"], [class*="name"]');
|
|
392
|
+
const brandEl = container.querySelector('[class*="brand"], [class*="producer"]');
|
|
393
|
+
const priceEl = container.querySelector('[class*="price"]');
|
|
394
|
+
const imgEl = container.querySelector('img');
|
|
395
|
+
const name = nameEl?.textContent?.trim();
|
|
396
|
+
if (!name || name.length < 3)
|
|
397
|
+
return;
|
|
398
|
+
seen.add(href);
|
|
399
|
+
products.push({
|
|
400
|
+
url: href,
|
|
401
|
+
name: name,
|
|
402
|
+
brand: brandEl?.textContent?.trim() || '',
|
|
403
|
+
price: priceEl?.textContent?.trim() || '',
|
|
404
|
+
imageUrl: imgEl?.src || undefined,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
return products;
|
|
408
|
+
});
|
|
409
|
+
return items;
|
|
410
|
+
}
|
|
411
|
+
async addFavorite(groceryUrl) {
|
|
412
|
+
const page = await this.ensureBrowser();
|
|
413
|
+
// Navigate to the product page if not already there
|
|
414
|
+
const currentUrl = page.url();
|
|
415
|
+
const normalizedGroceryUrl = groceryUrl.replace(BASE_URL, '');
|
|
416
|
+
const normalizedCurrentUrl = currentUrl.replace(BASE_URL, '');
|
|
417
|
+
if (!normalizedCurrentUrl.includes(normalizedGroceryUrl.split('/').pop() || '')) {
|
|
418
|
+
const fullUrl = groceryUrl.startsWith('http') ? groceryUrl : `${BASE_URL}${groceryUrl}`;
|
|
419
|
+
await page.goto(fullUrl, { waitUntil: 'networkidle' });
|
|
420
|
+
}
|
|
421
|
+
// Get the product name for the result
|
|
422
|
+
const itemName = await page.evaluate(() => {
|
|
423
|
+
const nameEl = document.querySelector('h1, [class*="product-name"], [class*="title"]');
|
|
424
|
+
return nameEl?.textContent?.trim() || 'Unknown item';
|
|
425
|
+
});
|
|
426
|
+
// Look for the favorite/heart button
|
|
427
|
+
const favoriteButton = await page.$('button[aria-label*="favorite"], button[aria-label*="heart"], button:has([class*="heart"]), [class*="favorite"] button, button[class*="favorite"]');
|
|
428
|
+
if (!favoriteButton) {
|
|
429
|
+
return {
|
|
430
|
+
success: false,
|
|
431
|
+
message: 'Could not find favorite button',
|
|
432
|
+
itemName,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// Check if already favorited (button might have "filled" or "active" state)
|
|
436
|
+
const isAlreadyFavorited = await page.evaluate((btn) => {
|
|
437
|
+
const classList = btn.className || '';
|
|
438
|
+
const ariaPressed = btn.getAttribute('aria-pressed');
|
|
439
|
+
return (classList.includes('active') ||
|
|
440
|
+
classList.includes('filled') ||
|
|
441
|
+
classList.includes('favorited') ||
|
|
442
|
+
ariaPressed === 'true');
|
|
443
|
+
}, favoriteButton);
|
|
444
|
+
if (isAlreadyFavorited) {
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
message: `${itemName} is already in favorites`,
|
|
448
|
+
itemName,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
await favoriteButton.click();
|
|
452
|
+
await page.waitForTimeout(500);
|
|
453
|
+
return {
|
|
454
|
+
success: true,
|
|
455
|
+
message: `Successfully added ${itemName} to favorites`,
|
|
456
|
+
itemName,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async removeFavorite(groceryUrl) {
|
|
460
|
+
const page = await this.ensureBrowser();
|
|
461
|
+
// Navigate to the product page if not already there
|
|
462
|
+
const currentUrl = page.url();
|
|
463
|
+
const normalizedGroceryUrl = groceryUrl.replace(BASE_URL, '');
|
|
464
|
+
const normalizedCurrentUrl = currentUrl.replace(BASE_URL, '');
|
|
465
|
+
if (!normalizedCurrentUrl.includes(normalizedGroceryUrl.split('/').pop() || '')) {
|
|
466
|
+
const fullUrl = groceryUrl.startsWith('http') ? groceryUrl : `${BASE_URL}${groceryUrl}`;
|
|
467
|
+
await page.goto(fullUrl, { waitUntil: 'networkidle' });
|
|
468
|
+
}
|
|
469
|
+
// Get the product name for the result
|
|
470
|
+
const itemName = await page.evaluate(() => {
|
|
471
|
+
const nameEl = document.querySelector('h1, [class*="product-name"], [class*="title"]');
|
|
472
|
+
return nameEl?.textContent?.trim() || 'Unknown item';
|
|
473
|
+
});
|
|
474
|
+
// Look for the favorite/heart button
|
|
475
|
+
const favoriteButton = await page.$('button[aria-label*="favorite"], button[aria-label*="heart"], button:has([class*="heart"]), [class*="favorite"] button, button[class*="favorite"]');
|
|
476
|
+
if (!favoriteButton) {
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
message: 'Could not find favorite button',
|
|
480
|
+
itemName,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// Check if already favorited (button might have "filled" or "active" state)
|
|
484
|
+
const isFavorited = await page.evaluate((btn) => {
|
|
485
|
+
const classList = btn.className || '';
|
|
486
|
+
const ariaPressed = btn.getAttribute('aria-pressed');
|
|
487
|
+
return (classList.includes('active') ||
|
|
488
|
+
classList.includes('filled') ||
|
|
489
|
+
classList.includes('favorited') ||
|
|
490
|
+
ariaPressed === 'true');
|
|
491
|
+
}, favoriteButton);
|
|
492
|
+
if (!isFavorited) {
|
|
493
|
+
return {
|
|
494
|
+
success: true,
|
|
495
|
+
message: `${itemName} is not in favorites`,
|
|
496
|
+
itemName,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
await favoriteButton.click();
|
|
500
|
+
await page.waitForTimeout(500);
|
|
501
|
+
return {
|
|
502
|
+
success: true,
|
|
503
|
+
message: `Successfully removed ${itemName} from favorites`,
|
|
504
|
+
itemName,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
async removeFromCart(groceryUrl) {
|
|
508
|
+
const page = await this.ensureBrowser();
|
|
509
|
+
// Navigate to cart page
|
|
510
|
+
await page.goto(`${BASE_URL}/basket`, { waitUntil: 'networkidle' });
|
|
511
|
+
await page.waitForTimeout(1000);
|
|
512
|
+
// Try to find the item in the cart by its URL or name
|
|
513
|
+
// First, let's get the product name from the URL if possible
|
|
514
|
+
const productSlug = groceryUrl.split('/').pop() || '';
|
|
515
|
+
// Validate that we have a valid product slug to search for
|
|
516
|
+
if (!productSlug || productSlug.length < 3) {
|
|
517
|
+
return {
|
|
518
|
+
success: false,
|
|
519
|
+
message: 'Invalid grocery URL - could not extract product identifier',
|
|
520
|
+
itemName: groceryUrl,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// Look for the item in the cart
|
|
524
|
+
const cartItems = await page.$$('[class*="cart-item"], [class*="basket-item"], [class*="line-item"]');
|
|
525
|
+
let itemFound = false;
|
|
526
|
+
let itemName = 'Unknown item';
|
|
527
|
+
for (const cartItem of cartItems) {
|
|
528
|
+
// Check if this cart item contains a link to our product
|
|
529
|
+
const itemLink = await cartItem.$(`a[href*="${productSlug}"]`);
|
|
530
|
+
if (itemLink) {
|
|
531
|
+
itemFound = true;
|
|
532
|
+
// Get the item name
|
|
533
|
+
const nameEl = await cartItem.$('[class*="name"], [class*="title"], h3, h4');
|
|
534
|
+
if (nameEl) {
|
|
535
|
+
itemName = (await nameEl.textContent()) || 'Unknown item';
|
|
536
|
+
}
|
|
537
|
+
// Find and click the remove button
|
|
538
|
+
const removeButton = await cartItem.$('button[aria-label*="remove"], button:has-text("Remove"), button:has-text("×"), [class*="remove"] button');
|
|
539
|
+
if (removeButton) {
|
|
540
|
+
await removeButton.click();
|
|
541
|
+
await page.waitForTimeout(500);
|
|
542
|
+
return {
|
|
543
|
+
success: true,
|
|
544
|
+
message: `Successfully removed ${itemName.trim()} from cart`,
|
|
545
|
+
itemName: itemName.trim(),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!itemFound) {
|
|
551
|
+
return {
|
|
552
|
+
success: false,
|
|
553
|
+
message: 'Item not found in cart',
|
|
554
|
+
itemName: productSlug,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
success: false,
|
|
559
|
+
message: 'Could not find remove button for item in cart',
|
|
560
|
+
itemName,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async getCurrentUrl() {
|
|
564
|
+
const page = await this.ensureBrowser();
|
|
565
|
+
return page.url();
|
|
566
|
+
}
|
|
567
|
+
async close() {
|
|
568
|
+
if (this.browser) {
|
|
569
|
+
await this.browser.close();
|
|
570
|
+
this.browser = null;
|
|
571
|
+
this.context = null;
|
|
572
|
+
this.page = null;
|
|
573
|
+
this.isInitialized = false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
getConfig() {
|
|
577
|
+
return this.config;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
export function createMCPServer() {
|
|
581
|
+
const server = new Server({
|
|
582
|
+
name: 'good-eggs-mcp-server',
|
|
583
|
+
version: '0.1.0',
|
|
584
|
+
}, {
|
|
585
|
+
capabilities: {
|
|
586
|
+
tools: {},
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
// Track active client for cleanup
|
|
590
|
+
let activeClient = null;
|
|
591
|
+
const registerHandlers = async (server, clientFactory) => {
|
|
592
|
+
// Use provided factory or create default client
|
|
593
|
+
const factory = clientFactory ||
|
|
594
|
+
(() => {
|
|
595
|
+
const username = process.env.GOOD_EGGS_USERNAME;
|
|
596
|
+
const password = process.env.GOOD_EGGS_PASSWORD;
|
|
597
|
+
const headless = process.env.HEADLESS !== 'false';
|
|
598
|
+
const timeout = parseInt(process.env.TIMEOUT || '30000', 10);
|
|
599
|
+
if (!username || !password) {
|
|
600
|
+
throw new Error('GOOD_EGGS_USERNAME and GOOD_EGGS_PASSWORD environment variables must be configured');
|
|
601
|
+
}
|
|
602
|
+
activeClient = new GoodEggsClient({
|
|
603
|
+
username,
|
|
604
|
+
password,
|
|
605
|
+
headless,
|
|
606
|
+
timeout,
|
|
607
|
+
});
|
|
608
|
+
return activeClient;
|
|
609
|
+
});
|
|
610
|
+
const registerTools = createRegisterTools(factory);
|
|
611
|
+
registerTools(server);
|
|
612
|
+
};
|
|
613
|
+
const cleanup = async () => {
|
|
614
|
+
if (activeClient) {
|
|
615
|
+
await activeClient.close();
|
|
616
|
+
activeClient = null;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
return { server, registerHandlers, cleanup };
|
|
620
|
+
}
|