continente-mcp 3.1.0 → 4.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/src/index.js CHANGED
@@ -4,11 +4,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { chromium } from 'playwright';
6
6
  import * as cheerio from 'cheerio';
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
8
9
  import { normalizeCookies } from './utils.js';
10
+ import { normalizeCartProductId, quantityForCartUpdate, summarizeCartState } from './cart-utils.js';
11
+ import { refreshAuthCookies, resolveStatePaths } from './auth-session.js';
9
12
 
10
13
  const CONTINENTE_BASE = 'https://www.continente.pt';
11
- const STATE_DIR = process.env.CONTINENTE_STATE_DIR || `${process.env.HOME}/.continente`;
14
+ const { stateDir: STATE_DIR, cookieFile: COOKIE_FILE } = resolveStatePaths();
15
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
16
 
13
17
  function getPlatformUserAgent() {
14
18
  if (process.platform === 'win32') {
@@ -24,49 +28,91 @@ function getPlatformUserAgent() {
24
28
  let browser = null;
25
29
  let context = null;
26
30
  let page = null;
31
+ let loadedCookieMtimeMs = null;
27
32
 
28
- async function ensureBrowser() {
29
- if (browser) return browser;
30
-
31
- browser = await chromium.launch({ headless: true });
32
- context = await browser.newContext({
33
- userAgent: getPlatformUserAgent(),
34
- locale: 'pt-PT'
33
+ async function refreshAuthSession() {
34
+ const refreshed = await refreshAuthCookies({
35
+ stateDir: STATE_DIR,
36
+ closeBrowser,
37
+ log: (message) => console.error(message),
35
38
  });
39
+ return refreshed;
40
+ }
36
41
 
37
- // Load cookies from file
38
- const cookieFile = `${STATE_DIR}/cookies.json`;
39
- if (existsSync(cookieFile)) {
40
- try {
41
- const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
42
- const normalized = normalizeCookies(cookies);
43
- await context.addCookies(normalized);
44
- } catch (e) {
45
- console.error('Failed to load cookies:', e.message);
42
+ async function ensureBrowser() {
43
+ const cookieFile = COOKIE_FILE;
44
+ if (!existsSync(cookieFile)) {
45
+ await refreshAuthSession();
46
+ }
47
+ const nextCookieMtimeMs = existsSync(cookieFile) ? statSync(cookieFile).mtimeMs : null;
48
+
49
+ if (context && loadedCookieMtimeMs !== nextCookieMtimeMs) {
50
+ if (page) { try { await page.close(); } catch (e) {} page = null; }
51
+ if (context) { try { await context.close(); } catch (e) {} context = null; }
52
+ }
53
+
54
+ if (!browser) {
55
+ browser = await chromium.launch({ headless: true });
56
+ }
57
+
58
+ if (!context) {
59
+ context = await browser.newContext({
60
+ userAgent: getPlatformUserAgent(),
61
+ locale: 'pt-PT'
62
+ });
63
+
64
+ // Load cookies from file
65
+ if (existsSync(cookieFile)) {
66
+ try {
67
+ const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
68
+ const normalized = normalizeCookies(cookies);
69
+ await context.addCookies(normalized);
70
+ loadedCookieMtimeMs = nextCookieMtimeMs;
71
+ } catch (e) {
72
+ console.error('Could not read the cookie cache; refresh login or replace the cache.');
73
+ }
46
74
  }
47
75
  }
48
76
 
49
- page = await context.newPage();
77
+ if (!page) {
78
+ page = await context.newPage();
79
+ }
50
80
  return browser;
51
81
  }
52
82
 
53
83
  async function goto(url, wait = 'networkidle') {
54
84
  await ensureBrowser();
55
- await page.goto(url, { waitUntil: wait, timeout: 20000 });
85
+ const response = await page.goto(url, { waitUntil: wait, timeout: 20000 });
86
+ if (!response?.ok()) throw new Error(`Continente page returned HTTP ${response?.status() ?? 'unknown'}`);
56
87
  await page.waitForTimeout(1500);
57
88
  return page;
58
89
  }
59
90
 
91
+ async function retryAfterAutoLogin(operation) {
92
+ const first = await operation();
93
+ if (first?.error !== 'not_authenticated') {
94
+ return first;
95
+ }
96
+
97
+ if (!(await refreshAuthSession())) {
98
+ return first;
99
+ }
100
+
101
+ return operation();
102
+ }
103
+
60
104
  async function closeBrowser() {
61
105
  if (page) { try { await page.close(); } catch(e) {} page = null; }
62
106
  if (context) { try { await context.close(); } catch(e) {} context = null; }
63
107
  if (browser) { try { await browser.close(); } catch(e) {} browser = null; }
108
+ loadedCookieMtimeMs = null;
64
109
  }
65
110
 
66
111
  // ─── Favorites / Preferences ───────────────────────────────────────────────────
67
112
 
68
113
  async function fetchFavorites() {
69
114
  await goto(`${CONTINENTE_BASE}/conta/lista-produtos/?list=favorites`);
115
+ if (page.url().includes('/login')) return { error: 'not_authenticated' };
70
116
  const html = await page.content();
71
117
  const $ = cheerio.load(html);
72
118
  const products = [];
@@ -119,12 +165,13 @@ function parseProducts(html, limit = 30) {
119
165
  const productId = idMatch ? idMatch[1] : null;
120
166
  if (!productId) return;
121
167
 
122
- const cardText = $el.text().replace(/\s+/g, ' ').trim();
123
- const priceMatch = cardText.match(/(\d+[,.]\d+)€/);
168
+ const primaryText = $el.find('.pwc-tile--price-primary').first().text().replace(/\s+/g, ' ').trim();
169
+ const priceMatch = primaryText.match(/(\d+[,.]\d+)€/);
124
170
  if (!priceMatch) return;
125
171
 
126
172
  const price = parseFloat(priceMatch[1].replace(',', '.'));
127
- const unitMatch = cardText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
173
+ const secondaryText = $el.find('.pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim();
174
+ const unitMatch = secondaryText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
128
175
 
129
176
  const nameLink = $el.find('a[href*="/produto/"]').filter((_, a) => $(a).text().trim().length > 0).first();
130
177
  let name = nameLink.text().replace(/\s+/g, ' ').trim().substring(0, 100);
@@ -151,40 +198,134 @@ function parseProducts(html, limit = 30) {
151
198
  // ─── Cart ─────────────────────────────────────────────────────────────────────
152
199
 
153
200
  async function getCart() {
201
+ await ensureBrowser();
202
+
203
+ try {
204
+ const res = await context.request.get(
205
+ `${CONTINENTE_BASE}/on/demandware.store/Sites-continente-Site/default/Cart-MiniCartShow`,
206
+ {
207
+ headers: { 'X-Requested-With': 'XMLHttpRequest' },
208
+ timeout: 10000
209
+ }
210
+ );
211
+
212
+ if (!res.ok()) return { error: `http_${res.status()}` };
213
+
214
+ const payload = await res.json();
215
+ const cart = summarizeCartState(payload);
216
+ if (!cart.customerAuthenticated) return { error: 'not_authenticated' };
217
+ return cart;
218
+ } catch (e) {
219
+ return { error: e.message };
220
+ }
221
+ }
222
+
223
+ async function updateCartItem(productId, quantity) {
224
+ const before = await getCart();
225
+ if (before.error) return { success: false, error: before.error };
154
226
  await goto(`${CONTINENTE_BASE}/checkout/carrinho/`);
155
227
 
156
228
  if (page.url().includes('/login')) return { error: 'not_authenticated' };
157
229
 
158
- return page.evaluate(() => {
159
- const cartList = document.querySelector('.js-cart-product-list');
160
- if (!cartList) return [];
230
+ const cartPid = normalizeCartProductId(productId);
231
+ const updateData = await page.evaluate(({ cartPid }) => {
232
+ const item = Array.from(document.querySelectorAll('[data-pid][data-product-name]'))
233
+ .find(el => el.dataset.pid === cartPid || el.querySelector(`input.add-to-cart-quantity[data-pid="${cartPid}-master"]`));
161
234
 
162
- const seen = new Set();
163
- const items = [];
235
+ if (!item) return { error: 'not_found' };
164
236
 
165
- cartList.querySelectorAll('[data-pid][data-product-name]').forEach(el => {
166
- const pid = el.dataset.pid;
167
- const name = el.dataset.productName;
168
- if (!name || seen.has(pid)) return;
169
- seen.add(pid);
237
+ const removeEl = item.querySelector('button.remove-product');
238
+ const updateEl = item.querySelector('.ct-tile-quantity-update');
239
+ const qtyInput = item.querySelector('input.add-to-cart-quantity');
240
+ if ((!updateEl || !qtyInput) && !removeEl) return { error: 'missing_quantity_controls' };
170
241
 
171
- const priceEl = el.querySelector('.pwc-tile--price-primary');
172
- const priceText = priceEl?.textContent?.trim().replace(/[^\d,]/g, '').replace(',', '.');
173
- const price = priceText ? parseFloat(priceText) : null;
242
+ let measureOptions = {};
243
+ try {
244
+ measureOptions = updateEl?.dataset.measureOptions ? JSON.parse(updateEl.dataset.measureOptions) : {};
245
+ } catch {
246
+ measureOptions = {};
247
+ }
174
248
 
175
- const qtyInput = el.querySelector('input.add-to-cart-quantity');
176
- const qty = qtyInput ? parseInt(qtyInput.value) || 1 : 1;
249
+ const updateUrl = updateEl?.dataset.action;
250
+ const uuid = updateEl?.dataset.uuid || item.dataset.uuid;
177
251
 
178
- items.push({ name, price, qty });
179
- });
252
+ return {
253
+ updateUrl,
254
+ uuid,
255
+ measureOptions,
256
+ gtmIndex: item.dataset.idx || '1',
257
+ removeUrl: removeEl?.dataset.action,
258
+ removeUuid: removeEl?.dataset.uuid || item.dataset.uuid
259
+ };
260
+ }, { cartPid });
180
261
 
181
- return items;
262
+ if (updateData.error) return { success: false, error: updateData.error };
263
+
264
+ if (quantity === 0) {
265
+ if (!updateData.removeUrl || !updateData.removeUuid) {
266
+ return { success: false, error: 'missing_remove_data' };
267
+ }
268
+
269
+ const params = new URLSearchParams({ pid: cartPid, uuid: updateData.removeUuid });
270
+ const result = await requestCartChange(updateData.removeUrl, params);
271
+ if (result.error) return { success: false, error: result.error };
272
+ return verifyCartQuantity(cartPid, 0);
273
+ }
274
+
275
+ if (!updateData.updateUrl || !updateData.uuid) return { success: false, error: 'missing_update_data' };
276
+
277
+ const formattedQuantity = quantityForCartUpdate(quantity, updateData.measureOptions);
278
+ const conversion = Number(updateData.measureOptions.primaryToSecondary || updateData.measureOptions.unitConversionRate);
279
+ const step = Number(updateData.measureOptions.stepQuantity || conversion || 1).toString();
280
+ const dimension = updateData.measureOptions.secondaryunit || updateData.measureOptions.primaryunit || 'un';
281
+
282
+ const params = new URLSearchParams({
283
+ pid: cartPid,
284
+ quantity: formattedQuantity,
285
+ step,
286
+ uuid: updateData.uuid,
287
+ dimension,
288
+ isCart: 'true',
289
+ gtmList: 'Checkout',
290
+ gtmIndex: updateData.gtmIndex,
291
+ promotionData: 'null',
292
+ taggstarPromotionData: ''
182
293
  });
294
+
295
+ const result = await requestCartChange(updateData.updateUrl, params);
296
+ if (result.error) return { success: false, error: result.error };
297
+ return verifyCartQuantity(cartPid, quantity);
298
+ }
299
+
300
+ async function requestCartChange(action, params) {
301
+ const url = new URL(action, CONTINENTE_BASE);
302
+ if (url.origin !== CONTINENTE_BASE) return { error: 'invalid_cart_action' };
303
+ for (const [key, value] of params) url.searchParams.set(key, value);
304
+ const res = await context.request.get(url.href, {
305
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }, timeout: 10000
306
+ });
307
+ if (!res.ok()) return { error: `http_${res.status()}` };
308
+ const payload = await res.json();
309
+ if (payload?.error || payload?.success === false) return { error: 'cart_update_rejected' };
310
+ return { success: true };
311
+ }
312
+
313
+ async function verifyCartQuantity(productId, expectedQuantity) {
314
+ const cart = await getCart();
315
+ const actual = cart.items?.find(item => item.id === productId)?.qty ?? 0;
316
+ if (cart.error || Math.abs(actual - expectedQuantity) > 0.0005) {
317
+ // A write may have happened. Never automatically repeat it after this point.
318
+ return { success: false, error: 'cart_quantity_not_confirmed' };
319
+ }
320
+ return { success: true, product_id: productId, cart_quantity: actual };
183
321
  }
184
322
 
185
323
  async function addToCart(productId, quantity = 1) {
324
+ const before = await getCart();
325
+ if (before.error) return { success: false, error: before.error };
186
326
  const slug = productId.endsWith('.html') ? productId : `${productId}.html`;
187
- await goto(`${CONTINENTE_BASE}/produto/${slug}`);
327
+ await goto(`${CONTINENTE_BASE}/produto/${slug}`, 'domcontentloaded');
328
+ await page.waitForSelector('input.add-to-cart-url', { state: 'attached', timeout: 10000 });
188
329
 
189
330
  // Extract numeric PID and Cart-AddProduct URL from page
190
331
  const result = await page.evaluate(async (qty) => {
@@ -210,20 +351,24 @@ async function addToCart(productId, quantity = 1) {
210
351
  signal: AbortSignal.timeout(10000)
211
352
  });
212
353
  const json = await res.json();
213
- return { success: res.ok, status: res.status, cartCount: json?.quantityTotal, error: json?.message };
354
+ return { success: res.ok, status: res.status, payload: json, error: json?.message };
214
355
  } catch (e) {
215
356
  return { success: false, message: e.message };
216
357
  }
217
358
  }, quantity);
218
359
 
219
- return result;
360
+ if (!result.success || result.payload?.error || result.payload?.resources?.customerAuthenticated === false) {
361
+ return { success: false, error: 'cart_add_not_confirmed' };
362
+ }
363
+ const numericPid = normalizeCartProductId(productId);
364
+ const previousQuantity = before.items.find(item => item.id === numericPid)?.qty ?? 0;
365
+ return verifyCartQuantity(numericPid, previousQuantity + quantity);
220
366
  }
221
367
 
222
368
  // ─── Order History ────────────────────────────────────────────────────────────
223
369
 
224
370
  async function getOrderHistory(limit = 5) {
225
371
  await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
226
- await page.waitForTimeout(3000);
227
372
 
228
373
  if (page.url().includes('/login')) return { error: 'not_authenticated' };
229
374
 
@@ -265,7 +410,6 @@ async function getOrderHistory(limit = 5) {
265
410
 
266
411
  async function getOrderProducts(orderDetailUrl) {
267
412
  await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
268
- await page.waitForTimeout(2000);
269
413
 
270
414
  return page.evaluate(() => {
271
415
  const products = [];
@@ -287,9 +431,8 @@ async function getOrderProducts(orderDetailUrl) {
287
431
  });
288
432
  }
289
433
 
290
- async function getMostBought() {
434
+ async function getMostBought(limit = 10) {
291
435
  await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
292
- await page.waitForTimeout(3000);
293
436
 
294
437
  if (page.url().includes('/login')) return { error: 'not_authenticated' };
295
438
 
@@ -303,10 +446,10 @@ async function getMostBought() {
303
446
 
304
447
  if (orderLinks.length === 0) return { error: 'no_orders' };
305
448
 
306
- // Tally products across all orders
449
+ // Tally products across recent orders
307
450
  const tally = new Map(); // name -> { qty, orders }
308
451
 
309
- for (const link of orderLinks) {
452
+ for (const link of orderLinks.slice(0, limit)) {
310
453
  try {
311
454
  const products = await getOrderProducts(link);
312
455
  for (const { name, qty } of products) {
@@ -347,7 +490,8 @@ async function savePreferences(prefs) {
347
490
  }
348
491
 
349
492
  async function updatePreferencesFromFavorites() {
350
- const favorites = await fetchFavorites();
493
+ const favorites = await retryAfterAutoLogin(() => fetchFavorites());
494
+ if (favorites?.error) throw new Error(favorites.error);
351
495
  const prefs = {
352
496
  favorites,
353
497
  lastUpdated: new Date().toISOString()
@@ -356,17 +500,17 @@ async function updatePreferencesFromFavorites() {
356
500
  return prefs;
357
501
  }
358
502
 
359
- function rankByPreference(products, preferences) {
503
+ export function rankByPreference(products, preferences) {
360
504
  if (!preferences || !preferences.favorites) return products;
361
505
 
362
- const favIds = new Set(preferences.favorites.map(f => f.productId));
506
+ const favIds = new Set(preferences.favorites.map(f => normalizeCartProductId(f.productId)));
363
507
  const favNames = new Map(preferences.favorites.map(f => [f.name.toLowerCase(), f]));
364
508
 
365
509
  return products.map(p => {
366
510
  let score = 0;
367
- if (favIds.has(p.product_id)) score += 100;
511
+ if (favIds.has(normalizeCartProductId(p.product_id))) score += 100;
368
512
  const nameLower = p.name.toLowerCase();
369
- for (const [favName, fav] of favNames) {
513
+ for (const favName of favNames.keys()) {
370
514
  if (nameLower.includes(favName) || favName.includes(nameLower)) score += 50;
371
515
  }
372
516
  return { ...p, score };
@@ -375,12 +519,14 @@ function rankByPreference(products, preferences) {
375
519
 
376
520
  // ─── MCP Server ───────────────────────────────────────────────────────────────
377
521
 
378
- class ContinenteServer {
522
+ export class ContinenteServer {
379
523
  constructor() {
380
524
  this.server = new Server(
381
- { name: 'continente-mcp', version: '3.1.0' },
525
+ { name: 'continente-mcp', version: VERSION },
382
526
  { capabilities: { tools: {} } }
383
527
  );
528
+ this.server.onclose = () => { void closeBrowser(); };
529
+ this.pending = Promise.resolve();
384
530
  this.setupTools();
385
531
  }
386
532
 
@@ -394,7 +540,7 @@ class ContinenteServer {
394
540
  type: 'object',
395
541
  properties: {
396
542
  query: { type: 'string', description: 'Search term (e.g., "leite", "pao")' },
397
- limit: { type: 'number', description: 'Max results (default: 10)' }
543
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Max results (default: 10)' }
398
544
  },
399
545
  required: ['query']
400
546
  }
@@ -421,25 +567,42 @@ class ContinenteServer {
421
567
  type: 'object',
422
568
  properties: {
423
569
  product_id: { type: 'string', description: 'Product ID from search results' },
424
- quantity: { type: 'number', description: 'Quantity (default: 1)' }
570
+ quantity: { type: 'number', exclusiveMinimum: 0, description: 'Quantity (default: 1)' }
425
571
  },
426
572
  required: ['product_id']
427
573
  }
428
574
  },
575
+ {
576
+ name: 'update_cart_item',
577
+ description: 'Set the quantity for a product already in the cart by product ID. Use quantity 0 to remove the line if the site supports it.',
578
+ inputSchema: {
579
+ type: 'object',
580
+ properties: {
581
+ product_id: { type: 'string', description: 'Product ID from search results or cart item' },
582
+ quantity: { type: 'number', minimum: 0, description: 'Desired cart quantity' }
583
+ },
584
+ required: ['product_id', 'quantity']
585
+ }
586
+ },
429
587
  {
430
588
  name: 'get_order_history',
431
589
  description: 'Get your recent order history from your account.',
432
590
  inputSchema: {
433
591
  type: 'object',
434
592
  properties: {
435
- limit: { type: 'number', description: 'Number of orders (default: 5)' }
593
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of orders (default: 5)' }
436
594
  }
437
595
  }
438
596
  },
439
597
  {
440
598
  name: 'get_most_bought',
441
- description: 'Get the products you buy most often, tallied across all orders. Scans every order page sequentially — slow on accounts with many orders.',
442
- inputSchema: { type: 'object', properties: {} }
599
+ description: 'Get the products you buy most often, tallied across recent orders.',
600
+ inputSchema: {
601
+ type: 'object',
602
+ properties: {
603
+ limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Number of recent orders to scan (default: 10)' }
604
+ }
605
+ }
443
606
  },
444
607
  {
445
608
  name: 'close_session',
@@ -449,9 +612,28 @@ class ContinenteServer {
449
612
  ]
450
613
  }));
451
614
 
452
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
453
- const { name, arguments: args } = request.params;
615
+ this.server.setRequestHandler(CallToolRequestSchema, request =>
616
+ this.callTool(request.params.name, request.params.arguments ?? {}));
617
+ }
618
+
619
+ callTool(name, args = {}) {
620
+ // All tools share one browser page, including close_session.
621
+ const result = this.pending.then(() => this.executeTool(name, args));
622
+ this.pending = result.catch(() => {});
623
+ return result;
624
+ }
625
+
626
+ async executeTool(name, args) {
454
627
  try {
628
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Arguments must be an object');
629
+ if (['search_products', 'get_order_history', 'get_most_bought'].includes(name) && args.limit !== undefined &&
630
+ (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50)) throw new Error('limit must be an integer from 1 to 50');
631
+ if (name === 'search_products' && (typeof args.query !== 'string' || !args.query.trim())) throw new Error('query must be a non-empty string');
632
+ if (['add_to_cart', 'update_cart_item'].includes(name)) {
633
+ if (typeof args.product_id !== 'string' || !/^[a-zA-Z0-9_-]+(?:\.html)?$/.test(args.product_id)) throw new Error('Invalid product_id');
634
+ const quantity = args.quantity ?? (name === 'add_to_cart' ? 1 : undefined);
635
+ if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 || (name === 'add_to_cart' && quantity === 0)) throw new Error('Invalid quantity');
636
+ }
455
637
  switch (name) {
456
638
  case 'search_products':
457
639
  return await this.handle_search(args.query, args.limit || 10);
@@ -462,11 +644,13 @@ class ContinenteServer {
462
644
  case 'get_cart':
463
645
  return await this.handle_get_cart();
464
646
  case 'add_to_cart':
465
- return await this.handle_add_to_cart(args.product_id, args.quantity || 1);
647
+ return await this.handle_add_to_cart(args.product_id, args.quantity ?? 1);
648
+ case 'update_cart_item':
649
+ return await this.handle_update_cart_item(args.product_id, args.quantity);
466
650
  case 'get_order_history':
467
651
  return await this.handle_order_history(args.limit || 5);
468
652
  case 'get_most_bought':
469
- return await this.handle_most_bought();
653
+ return await this.handle_most_bought(args.limit || 10);
470
654
  case 'close_session':
471
655
  await closeBrowser();
472
656
  return { content: [{ type: 'text', text: 'Session closed.' }] };
@@ -476,12 +660,11 @@ class ContinenteServer {
476
660
  } catch (error) {
477
661
  return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
478
662
  }
479
- });
480
663
  }
481
664
 
482
665
  formatProducts(products, preferences = null) {
483
666
  return products.map((p, i) => {
484
- const fav = preferences?.favorites?.find(f => f.productId === p.product_id);
667
+ const fav = preferences?.favorites?.find(f => normalizeCartProductId(f.productId) === normalizeCartProductId(p.product_id));
485
668
  const favBadge = fav ? ' ⭐ (favorite)' : '';
486
669
  const unitStr = p.unit ? ` (${p.unit})` : '';
487
670
  return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}\n 🆔 ${p.product_id}`;
@@ -504,9 +687,9 @@ class ContinenteServer {
504
687
  }
505
688
 
506
689
  async handle_favorites() {
507
- const prefs = await getPreferences();
690
+ let prefs = await getPreferences();
508
691
  if (!prefs?.favorites?.length) {
509
- return { content: [{ type: 'text', text: 'No favorites loaded. Run refresh_favorites first.' }] };
692
+ prefs = await updatePreferencesFromFavorites();
510
693
  }
511
694
 
512
695
  const list = prefs.favorites.map((f, i) =>
@@ -532,37 +715,55 @@ class ContinenteServer {
532
715
  }
533
716
 
534
717
  async handle_get_cart() {
535
- const items = await getCart();
536
- if (items?.error === 'not_authenticated') {
537
- return { content: [{ type: 'text', text: 'Not logged in — cookies may have expired. Re-run the cookie sync script.' }] };
718
+ const cart = await retryAfterAutoLogin(() => getCart());
719
+ if (cart?.error === 'not_authenticated') {
720
+ return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
721
+ }
722
+ if (cart?.error) {
723
+ return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
538
724
  }
539
- if (!Array.isArray(items) || items.length === 0) {
725
+ if (!Array.isArray(cart.items) || cart.items.length === 0) {
540
726
  return { content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
541
727
  }
542
- const total = items.reduce((s, i) => s + (i.price || 0) * i.qty, 0);
543
- const list = items.map((item, i) =>
544
- `${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€`
728
+ const total = cart.total;
729
+ const totalText = total === null ? 'Unavailable' : `${total.toFixed(2)}€`;
730
+ const list = cart.items.map((item, i) =>
731
+ `${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€\n 🆔 ${item.id}`
545
732
  ).join('\n');
546
733
  return {
547
734
  content: [{
548
735
  type: 'text',
549
- text: `🛒 Cart (${items.length} items):\n\n${list}\n\n💶 Total: ${total.toFixed(2)}€\n\nGo to https://www.continente.pt/checkout/carrinho/ to checkout.`
736
+ text: `🛒 Cart (${cart.items.length} items):\n\n${list}\n\n💶 Total: ${totalText}\n\nGo to https://www.continente.pt/checkout/carrinho/ to checkout.`
550
737
  }]
551
738
  };
552
739
  }
553
740
 
554
741
  async handle_add_to_cart(productId, quantity) {
555
- const result = await addToCart(productId, quantity);
742
+ const result = await retryAfterAutoLogin(() => addToCart(productId, quantity));
556
743
  if (result.success) {
557
744
  return { content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
558
745
  }
559
- return { content: [{ type: 'text', text: `⚠️ ${result.message || 'Could not add to cart.'}` }], isError: true };
746
+ return { content: [{ type: 'text', text: `⚠️ ${result.error || result.message || 'Could not add to cart.'}` }], isError: true };
747
+ }
748
+
749
+ async handle_update_cart_item(productId, quantity) {
750
+ const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity));
751
+ if (result?.error === 'not_authenticated') {
752
+ return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
753
+ }
754
+ if (result?.error === 'not_found') {
755
+ return { content: [{ type: 'text', text: `Product ${productId} is not in the cart.` }], isError: true };
756
+ }
757
+ if (result.success) {
758
+ return { content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
759
+ }
760
+ return { content: [{ type: 'text', text: `⚠️ ${result.error || 'Could not update cart item.'}` }], isError: true };
560
761
  }
561
762
 
562
763
  async handle_order_history(limit) {
563
- const orders = await getOrderHistory(limit);
764
+ const orders = await retryAfterAutoLogin(() => getOrderHistory(limit));
564
765
  if (orders?.error === 'not_authenticated') {
565
- return { content: [{ type: 'text', text: 'Not logged in — cookies may have expired. Re-sync your cookies.' }] };
766
+ return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
566
767
  }
567
768
  if (!Array.isArray(orders) || orders.length === 0) {
568
769
  return { content: [{ type: 'text', text: 'Could not load order history. Check https://www.continente.pt/conta/encomendas/' }] };
@@ -582,10 +783,10 @@ class ContinenteServer {
582
783
  };
583
784
  }
584
785
 
585
- async handle_most_bought() {
586
- const result = await getMostBought();
786
+ async handle_most_bought(limit) {
787
+ const result = await retryAfterAutoLogin(() => getMostBought(limit));
587
788
  if (result.error) {
588
- return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in — cookies may have expired.' : `Error: ${result.error}` }] };
789
+ return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' : `Error: ${result.error}` }], isError: true };
589
790
  }
590
791
  if (!Array.isArray(result) || result.length === 0) {
591
792
  return { content: [{ type: 'text', text: 'Could not calculate most bought items from order history.' }] };
@@ -605,9 +806,11 @@ class ContinenteServer {
605
806
  async start() {
606
807
  const transport = new StdioServerTransport();
607
808
  await this.server.connect(transport);
608
- console.error('Continente MCP v3.1.0 started');
809
+ console.error(`Continente MCP v${VERSION} started`);
609
810
  }
610
811
  }
611
812
 
612
- const server = new ContinenteServer();
613
- server.start().catch(console.error);
813
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
814
+ const server = new ContinenteServer();
815
+ server.start().catch(error => { console.error(error.message); process.exitCode = 1; });
816
+ }