continentept-mcp 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-15
10
+
11
+ ### Fixed
12
+
13
+ - Searching for produce, meat and other products priced per kilogram failed with "Invalid money amount". Prices now record what they are for: per item, per piece, per kg or per litre.
14
+ - One unreadable search result no longer fails the whole search; the other results are returned as a partial result, and the unreadable one is described.
15
+ - Search results keep the website's relevance order. Products whose names merely resembled a favorite no longer jump ahead of better matches; exact favorites still come first.
16
+ - Favorites priced per kilogram are read correctly, and an unrecognised favorite price is reported as unknown instead of failing the refresh.
17
+
18
+ ### Changed
19
+
20
+ - **Breaking:** catalogue prices (`price` in search, favorites and `get_product`) are now `{ amount, per }`. `unitPriceText` is replaced by `secondaryPrice`, and the search field `similarity` is removed. Saved favorites are fetched again once after updating.
21
+ - Search results include `packageText`, the pack size as shown on the website.
22
+ - Sign-in errors say whether no credentials are configured or Continente did not accept them, with the next step for each case. The instructions present the `basket-planner` prompt as optional, for clients that support prompts.
23
+ - Browser engine updated to Playwright 1.64 (Chrome 156). After updating, the new Chromium downloads automatically on first use (about 150 MB, once).
24
+
9
25
  ## [0.4.0] - 2026-10-15
10
26
 
11
27
  ### Added
@@ -55,7 +71,8 @@ All notable changes to this project are documented here. The format follows
55
71
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
56
72
  - The `basket-planner` skill for planning a shopping list and updating the basket.
57
73
 
58
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...HEAD
74
+ [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...HEAD
75
+ [0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
59
76
  [0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
60
77
  [0.3.1]: https://github.com/andredelgado/continente-mcp/compare/v0.3.0...v0.3.1
61
78
  [0.3.0]: https://github.com/andredelgado/continente-mcp/compare/v0.2.0...v0.3.0
@@ -93,7 +93,7 @@ export class CatalogClient {
93
93
  break;
94
94
  }
95
95
  const previousCount = ids.size;
96
- parsed.value.forEach(product => ids.add(product.productId));
96
+ parsed.value.products.forEach(product => ids.add(product.productId));
97
97
  pages.push(html);
98
98
  reportProgress(pages.length, 5, 'Reading catalogue pages');
99
99
  const continuation = parseSearchPagination(html);
@@ -4,8 +4,16 @@ export declare const FavoriteProductSchema: z.ZodObject<{
4
4
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
5
5
  name: z.ZodString;
6
6
  price: z.ZodNullable<z.ZodObject<{
7
- currency: z.ZodLiteral<"EUR">;
8
- minorUnits: z.ZodNumber;
7
+ amount: z.ZodObject<{
8
+ currency: z.ZodLiteral<"EUR">;
9
+ minorUnits: z.ZodNumber;
10
+ }, z.core.$strict>;
11
+ per: z.ZodEnum<{
12
+ item: "item";
13
+ kg: "kg";
14
+ l: "l";
15
+ piece: "piece";
16
+ }>;
9
17
  }, z.core.$strict>>;
10
18
  url: z.ZodURL;
11
19
  }, z.core.$strict>;
@@ -15,11 +23,31 @@ export declare const ProductSchema: z.ZodObject<{
15
23
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
16
24
  name: z.ZodString;
17
25
  price: z.ZodNullable<z.ZodObject<{
18
- currency: z.ZodLiteral<"EUR">;
19
- minorUnits: z.ZodNumber;
26
+ amount: z.ZodObject<{
27
+ currency: z.ZodLiteral<"EUR">;
28
+ minorUnits: z.ZodNumber;
29
+ }, z.core.$strict>;
30
+ per: z.ZodEnum<{
31
+ item: "item";
32
+ kg: "kg";
33
+ l: "l";
34
+ piece: "piece";
35
+ }>;
20
36
  }, z.core.$strict>>;
21
37
  url: z.ZodURL;
22
- unitPriceText: z.ZodNullable<z.ZodString>;
38
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
39
+ amount: z.ZodObject<{
40
+ currency: z.ZodLiteral<"EUR">;
41
+ minorUnits: z.ZodNumber;
42
+ }, z.core.$strict>;
43
+ per: z.ZodEnum<{
44
+ item: "item";
45
+ kg: "kg";
46
+ l: "l";
47
+ piece: "piece";
48
+ }>;
49
+ }, z.core.$strict>>;
50
+ packageText: z.ZodNullable<z.ZodString>;
23
51
  minimumText: z.ZodNullable<z.ZodString>;
24
52
  }, z.core.$strict>;
25
53
  export type Product = z.infer<typeof ProductSchema>;
@@ -227,7 +255,7 @@ export declare const PurchaseFrequencySchema: z.ZodObject<{
227
255
  }, z.core.$strict>;
228
256
  export type PurchaseFrequency = z.infer<typeof PurchaseFrequencySchema>;
229
257
  export declare const FavoritesCacheSchema: z.ZodObject<{
230
- schemaVersion: z.ZodLiteral<1>;
258
+ schemaVersion: z.ZodLiteral<2>;
231
259
  accountScope: z.ZodString;
232
260
  fetchedAt: z.ZodISODateTime;
233
261
  favorites: z.ZodArray<z.ZodObject<{
@@ -235,8 +263,16 @@ export declare const FavoritesCacheSchema: z.ZodObject<{
235
263
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
236
264
  name: z.ZodString;
237
265
  price: z.ZodNullable<z.ZodObject<{
238
- currency: z.ZodLiteral<"EUR">;
239
- minorUnits: z.ZodNumber;
266
+ amount: z.ZodObject<{
267
+ currency: z.ZodLiteral<"EUR">;
268
+ minorUnits: z.ZodNumber;
269
+ }, z.core.$strict>;
270
+ per: z.ZodEnum<{
271
+ item: "item";
272
+ kg: "kg";
273
+ l: "l";
274
+ piece: "piece";
275
+ }>;
240
276
  }, z.core.$strict>>;
241
277
  url: z.ZodURL;
242
278
  }, z.core.$strict>>;
@@ -248,18 +284,30 @@ export declare const ProductDetailsSchema: z.ZodObject<{
248
284
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
249
285
  name: z.ZodString;
250
286
  url: z.ZodURL;
251
- price: z.ZodObject<{
252
- amount: z.ZodNullable<z.ZodObject<{
287
+ price: z.ZodNullable<z.ZodObject<{
288
+ amount: z.ZodObject<{
253
289
  currency: z.ZodLiteral<"EUR">;
254
290
  minorUnits: z.ZodNumber;
255
- }, z.core.$strict>>;
256
- basis: z.ZodEnum<{
257
- line: "line";
258
- unit: "unit";
259
- unknown: "unknown";
291
+ }, z.core.$strict>;
292
+ per: z.ZodEnum<{
293
+ item: "item";
294
+ kg: "kg";
295
+ l: "l";
296
+ piece: "piece";
260
297
  }>;
261
- }, z.core.$strict>;
262
- unitPriceText: z.ZodNullable<z.ZodString>;
298
+ }, z.core.$strict>>;
299
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
300
+ amount: z.ZodObject<{
301
+ currency: z.ZodLiteral<"EUR">;
302
+ minorUnits: z.ZodNumber;
303
+ }, z.core.$strict>;
304
+ per: z.ZodEnum<{
305
+ item: "item";
306
+ kg: "kg";
307
+ l: "l";
308
+ piece: "piece";
309
+ }>;
310
+ }, z.core.$strict>>;
263
311
  availability: z.ZodEnum<{
264
312
  available: "available";
265
313
  unavailable: "unavailable";
@@ -1,16 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { ToolErrorSchema } from './errors.js';
3
3
  import { CartItemIdSchema, OrderIdSchema, OrderNumberSchema, ProductIdSchema, ProductReferenceSchema } from './identity.js';
4
- import { MoneySchema, QuotedMoneySchema } from './money.js';
4
+ import { MoneySchema, PriceTagSchema, QuotedMoneySchema } from './money.js';
5
5
  import { ObservedQuantitySchema, QuantitySchema, ProductSaleRulesSchema } from './quantity.js';
6
6
  const productDetails = {
7
7
  name: z.string().trim().min(1).max(250),
8
- price: MoneySchema.nullable(),
8
+ /** The main price as displayed; null when absent or unrecognised. */
9
+ price: PriceTagSchema.nullable(),
9
10
  url: z.url(),
10
11
  };
11
12
  export const FavoriteProductSchema = ProductReferenceSchema.safeExtend(productDetails);
12
13
  export const ProductSchema = FavoriteProductSchema.safeExtend({
13
- unitPriceText: z.string().nullable(),
14
+ /** The comparison price shown next to the main one, such as per kg or per piece. */
15
+ secondaryPrice: PriceTagSchema.nullable(),
16
+ /** Pack size as displayed, such as `940 gr`, `2 x 52 gr` or `1,2 kg (aprox.)`. */
17
+ packageText: z.string().nullable(),
14
18
  minimumText: z.string().nullable(),
15
19
  });
16
20
  export const CartItemSchema = z.strictObject({
@@ -51,15 +55,15 @@ export const PurchaseFrequencySchema = z.strictObject({
51
55
  }).refine(frequency => (frequency.identityBasis === 'product') === (frequency.productId !== null), { message: 'Identity basis must agree with the available product ID' })
52
56
  .refine(frequency => new Set(frequency.quantities.map(quantity => quantity.unit)).size === frequency.quantities.length, { message: 'Purchase totals must contain each unit only once' });
53
57
  export const FavoritesCacheSchema = z.strictObject({
54
- schemaVersion: z.literal(1),
58
+ schemaVersion: z.literal(2),
55
59
  accountScope: z.string().trim().min(1).max(128),
56
60
  fetchedAt: z.iso.datetime({ offset: true }),
57
61
  favorites: z.array(FavoriteProductSchema),
58
62
  completeness: z.literal('complete').optional(),
59
63
  });
60
64
  export const ProductDetailsSchema = ProductReferenceSchema.safeExtend({
61
- name: productDetails.name, url: productDetails.url, price: QuotedMoneySchema,
62
- unitPriceText: z.string().nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
65
+ name: productDetails.name, url: productDetails.url, price: PriceTagSchema.nullable(),
66
+ secondaryPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
63
67
  saleRules: z.discriminatedUnion('status', [
64
68
  z.strictObject({ status: z.literal('available'), rules: ProductSaleRulesSchema }),
65
69
  z.strictObject({ status: z.literal('unavailable'), error: ToolErrorSchema }),
@@ -21,3 +21,22 @@ export declare const QuotedMoneySchema: z.ZodObject<{
21
21
  export type QuotedMoney = z.infer<typeof QuotedMoneySchema>;
22
22
  /** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
23
23
  export declare function parseMoneyAmount(input: unknown): Result<Money | null, ToolError>;
24
+ /**
25
+ * What a displayed price is for. The storefront shows packaged goods per `item` (one pack or
26
+ * article), produce and meat per `kg`, drinks per `l`, and an approximate price per `piece`.
27
+ */
28
+ export declare const PriceTagSchema: z.ZodObject<{
29
+ amount: z.ZodObject<{
30
+ currency: z.ZodLiteral<"EUR">;
31
+ minorUnits: z.ZodNumber;
32
+ }, z.core.$strict>;
33
+ per: z.ZodEnum<{
34
+ item: "item";
35
+ kg: "kg";
36
+ l: "l";
37
+ piece: "piece";
38
+ }>;
39
+ }, z.core.$strict>;
40
+ export type PriceTag = z.infer<typeof PriceTagSchema>;
41
+ /** Parse labels such as `1,29€`, `1,19€/kg` or `0,24€/un`. An empty label is an unknown price. */
42
+ export declare function parsePriceTag(input: unknown): Result<PriceTag | null, ToolError>;
@@ -28,3 +28,28 @@ export function parseMoneyAmount(input) {
28
28
  }
29
29
  return { ok: true, value: { currency: 'EUR', minorUnits: Number(minorUnits) } };
30
30
  }
31
+ /**
32
+ * What a displayed price is for. The storefront shows packaged goods per `item` (one pack or
33
+ * article), produce and meat per `kg`, drinks per `l`, and an approximate price per `piece`.
34
+ */
35
+ export const PriceTagSchema = z.strictObject({
36
+ amount: MoneySchema,
37
+ per: z.enum(['item', 'piece', 'kg', 'l']),
38
+ });
39
+ const PRICE_UNITS = { kg: 'kg', l: 'l', lt: 'l', un: 'piece' };
40
+ /** Parse labels such as `1,29€`, `1,19€/kg` or `0,24€/un`. An empty label is an unknown price. */
41
+ export function parsePriceTag(input) {
42
+ if (input === null || input === undefined)
43
+ return { ok: true, value: null };
44
+ if (typeof input !== 'string')
45
+ return { ok: false, error: invalidData('price') };
46
+ const text = input.replace(/\s+/g, '');
47
+ if (!text)
48
+ return { ok: true, value: null };
49
+ const match = /^(\d+(?:[.,]\d{1,2})?)€(?:\/([a-z]+))?$/i.exec(text);
50
+ const per = match ? PRICE_UNITS[(match[2] ?? '').toLowerCase()] ?? (match[2] === undefined ? 'item' : undefined) : undefined;
51
+ const amount = match && per ? parseMoneyAmount(match[1]) : null;
52
+ if (!amount?.ok || !amount.value || !per)
53
+ return { ok: false, error: { ...invalidData('price'), message: 'Unrecognised price format' } };
54
+ return { ok: true, value: { amount: amount.value, per } };
55
+ }
@@ -205,14 +205,33 @@ export declare const SearchData: z.ZodObject<{
205
205
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
206
206
  name: z.ZodString;
207
207
  price: z.ZodNullable<z.ZodObject<{
208
- currency: z.ZodLiteral<"EUR">;
209
- minorUnits: z.ZodNumber;
208
+ amount: z.ZodObject<{
209
+ currency: z.ZodLiteral<"EUR">;
210
+ minorUnits: z.ZodNumber;
211
+ }, z.core.$strict>;
212
+ per: z.ZodEnum<{
213
+ item: "item";
214
+ kg: "kg";
215
+ l: "l";
216
+ piece: "piece";
217
+ }>;
210
218
  }, z.core.$strict>>;
211
219
  url: z.ZodURL;
212
- unitPriceText: z.ZodNullable<z.ZodString>;
220
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
221
+ amount: z.ZodObject<{
222
+ currency: z.ZodLiteral<"EUR">;
223
+ minorUnits: z.ZodNumber;
224
+ }, z.core.$strict>;
225
+ per: z.ZodEnum<{
226
+ item: "item";
227
+ kg: "kg";
228
+ l: "l";
229
+ piece: "piece";
230
+ }>;
231
+ }, z.core.$strict>>;
232
+ packageText: z.ZodNullable<z.ZodString>;
213
233
  minimumText: z.ZodNullable<z.ZodString>;
214
234
  favorite: z.ZodBoolean;
215
- similarity: z.ZodNumber;
216
235
  }, z.core.$strict>>;
217
236
  scanned: z.ZodNumber;
218
237
  returned: z.ZodNumber;
@@ -256,8 +275,16 @@ export declare const FavoritesData: z.ZodObject<{
256
275
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
257
276
  name: z.ZodString;
258
277
  price: z.ZodNullable<z.ZodObject<{
259
- currency: z.ZodLiteral<"EUR">;
260
- minorUnits: z.ZodNumber;
278
+ amount: z.ZodObject<{
279
+ currency: z.ZodLiteral<"EUR">;
280
+ minorUnits: z.ZodNumber;
281
+ }, z.core.$strict>;
282
+ per: z.ZodEnum<{
283
+ item: "item";
284
+ kg: "kg";
285
+ l: "l";
286
+ piece: "piece";
287
+ }>;
261
288
  }, z.core.$strict>>;
262
289
  url: z.ZodURL;
263
290
  }, z.core.$strict>>;
@@ -498,14 +525,33 @@ export declare const SearchOutput: z.ZodObject<{
498
525
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
499
526
  name: z.ZodString;
500
527
  price: z.ZodNullable<z.ZodObject<{
501
- currency: z.ZodLiteral<"EUR">;
502
- minorUnits: z.ZodNumber;
528
+ amount: z.ZodObject<{
529
+ currency: z.ZodLiteral<"EUR">;
530
+ minorUnits: z.ZodNumber;
531
+ }, z.core.$strict>;
532
+ per: z.ZodEnum<{
533
+ item: "item";
534
+ kg: "kg";
535
+ l: "l";
536
+ piece: "piece";
537
+ }>;
503
538
  }, z.core.$strict>>;
504
539
  url: z.ZodURL;
505
- unitPriceText: z.ZodNullable<z.ZodString>;
540
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
541
+ amount: z.ZodObject<{
542
+ currency: z.ZodLiteral<"EUR">;
543
+ minorUnits: z.ZodNumber;
544
+ }, z.core.$strict>;
545
+ per: z.ZodEnum<{
546
+ item: "item";
547
+ kg: "kg";
548
+ l: "l";
549
+ piece: "piece";
550
+ }>;
551
+ }, z.core.$strict>>;
552
+ packageText: z.ZodNullable<z.ZodString>;
506
553
  minimumText: z.ZodNullable<z.ZodString>;
507
554
  favorite: z.ZodBoolean;
508
- similarity: z.ZodNumber;
509
555
  }, z.core.$strict>>;
510
556
  scanned: z.ZodNumber;
511
557
  returned: z.ZodNumber;
@@ -598,8 +644,16 @@ export declare const FavoritesOutput: z.ZodObject<{
598
644
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
599
645
  name: z.ZodString;
600
646
  price: z.ZodNullable<z.ZodObject<{
601
- currency: z.ZodLiteral<"EUR">;
602
- minorUnits: z.ZodNumber;
647
+ amount: z.ZodObject<{
648
+ currency: z.ZodLiteral<"EUR">;
649
+ minorUnits: z.ZodNumber;
650
+ }, z.core.$strict>;
651
+ per: z.ZodEnum<{
652
+ item: "item";
653
+ kg: "kg";
654
+ l: "l";
655
+ piece: "piece";
656
+ }>;
603
657
  }, z.core.$strict>>;
604
658
  url: z.ZodURL;
605
659
  }, z.core.$strict>>;
@@ -1364,18 +1418,30 @@ export declare const ProductDetailsOutput: z.ZodObject<{
1364
1418
  productSlug: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "ProductSlug", "out">>;
1365
1419
  name: z.ZodString;
1366
1420
  url: z.ZodURL;
1367
- price: z.ZodObject<{
1368
- amount: z.ZodNullable<z.ZodObject<{
1421
+ price: z.ZodNullable<z.ZodObject<{
1422
+ amount: z.ZodObject<{
1369
1423
  currency: z.ZodLiteral<"EUR">;
1370
1424
  minorUnits: z.ZodNumber;
1371
- }, z.core.$strict>>;
1372
- basis: z.ZodEnum<{
1373
- line: "line";
1374
- unit: "unit";
1375
- unknown: "unknown";
1425
+ }, z.core.$strict>;
1426
+ per: z.ZodEnum<{
1427
+ item: "item";
1428
+ kg: "kg";
1429
+ l: "l";
1430
+ piece: "piece";
1376
1431
  }>;
1377
- }, z.core.$strict>;
1378
- unitPriceText: z.ZodNullable<z.ZodString>;
1432
+ }, z.core.$strict>>;
1433
+ secondaryPrice: z.ZodNullable<z.ZodObject<{
1434
+ amount: z.ZodObject<{
1435
+ currency: z.ZodLiteral<"EUR">;
1436
+ minorUnits: z.ZodNumber;
1437
+ }, z.core.$strict>;
1438
+ per: z.ZodEnum<{
1439
+ item: "item";
1440
+ kg: "kg";
1441
+ l: "l";
1442
+ piece: "piece";
1443
+ }>;
1444
+ }, z.core.$strict>>;
1379
1445
  availability: z.ZodEnum<{
1380
1446
  available: "available";
1381
1447
  unavailable: "unavailable";
@@ -42,7 +42,7 @@ export function outputSchema(data, extra = {}) {
42
42
  ]), ...extra });
43
43
  }
44
44
  const integer = z.number().int().nonnegative();
45
- export const SearchData = z.strictObject({ products: z.array(ProductSchema.safeExtend({ favorite: z.boolean(), similarity: z.number().min(0).max(1) })),
45
+ export const SearchData = z.strictObject({ products: z.array(ProductSchema.safeExtend({ favorite: z.boolean() })),
46
46
  scanned: integer, returned: integer, favoritesLoaded: integer, completeness: z.enum(['complete', 'partial', 'unknown']),
47
47
  failures: z.array(ToolErrorSchema), nextCursor: z.string().nullable() });
48
48
  export const FavoritesData = z.strictObject({ favorites: z.array(FavoriteProductSchema), fetchedAt: z.iso.datetime({ offset: true }), completeness: z.enum(['complete', 'unknown']),
@@ -1,5 +1,5 @@
1
1
  import type { PromptHandler } from './server.js';
2
2
  export declare const BASKET_PLANNER_GUIDE = "# Basket planner\n\nTurn the user's shopping intent into a product list or a verified basket using the **continentept-mcp** tools. A request for a plan authorizes research only; a request to add, remove or set items authorizes those basket changes. Keep the user's existing choices and constraints. Ask only about choices that change the result, such as an unspecified substitute or whether a quantity means \"more\" or \"in total\".\n\n## Choose products and quantities\n\n1. Use `search_products` with `query` (and `limit` when useful). Treat results as candidates: match size, brand, dietary constraints and intended use before choosing. Keep `productId` as a string, including leading zeros.\n2. Use `get_product` when price, availability or sale rules matter. Quantities are in the returned sale unit. Respect minimum, maximum and step; never assume a weighted product is sold by the kilogram and never silently round a requested amount.\n3. Before changing the basket, read `get_cart` for current quantities and line identities. If several lines share a product ID, use the intended line's `cartItemId`, or ask the user which line they mean.\n4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means \u20AC1.29). Keep product prices, basket totals and estimated list costs distinct.\n\nSearches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.\n\n## Change the basket\n\n- `set_cart_item_quantity({ productId, quantity, cartItemId? })` makes the basket hold exactly `quantity`, adding the product if needed. Zero removes it. Prefer this whenever the user states a desired total; repeating it is safe.\n- `add_cart_item({ productId, quantity, cartItemId? })` adds `quantity` on top of what is already there (default one sale unit). Use it only for \"add more\".\n- `remove_cart_item({ productId, cartItemId? })` removes a line. An already absent item is confirmed without a write.\n\nIf the basket holds two units, \"add two more\" is `add_cart_item` with 2, while \"I need two\" is `set_cart_item_quantity` with 2 (which changes nothing). Process a list one item at a time and read each result before continuing. There is no batch, checkout or payment tool; checkout happens on the website.\n\n## Read outcomes before reporting success\n\nRead `structuredContent.result` rather than inferring success from prose.\n\n- `status: success`, `empty` or `partial` carries `data`. Report partial-result warnings and unavailable data.\n- A confirmed change has `data.outcome: \"confirmed\"` and `confirmedQuantity`, the quantity now in the basket. Report that observed quantity.\n- `status: error` carries `error`. For basket changes also read `mutation`: `not_applied` means nothing was submitted or the change was rejected; fix the reported cause before trying again.\n- `mutation.outcome: \"unknown\"` means the change may have happened. Stop dependent basket changes, call `get_cart`, and compare with the state before the change. Never repeat the original request just because the response was lost. If the basket state cannot settle the intent, explain the uncertainty and ask the user.\n\nA timeout, cancellation or lost connection during a change is not evidence that the basket stayed the same. Apply the same reconciliation when no structured result arrives.\n\n## Use account context when it helps\n\nFor \"my usual items\", `list_favorites` and `list_frequently_bought_products` can inform a proposed list. Past purchases are evidence, not permission to buy them again.\n\n`list_favorites` returns a local snapshot with `source` and `freshness`; pass `refresh: true` when the user wants the current website list or the snapshot is stale. An empty list is a valid result, and a failed refresh does not mean favorites disappeared.\n\n`list_orders` supports `limit`, `includeLines`, `detailLimit` and `cursor` (the returned `nextCursor`). Use the internal `orderId` with `get_order`, not the displayed order number. `list_frequently_bought_products` has separate `orderLimit` and `limit`; its scan counts and failures show how representative the ranking is. Never add quantities expressed in different units.\n\n`get_session_status` reports local state only; it does not verify that the login still works. Do not include credentials or account details in summaries.\n\nFinish with the chosen or changed items, confirmed quantities and units, and any unresolved choices or failures. Keep planned items separate from changes the server confirmed.\n\nFor exact schemas and limits, see the [tool reference](https://github.com/andredelgado/continente-mcp/blob/main/docs/tools.md).";
3
3
  /** Sent to every client on connect; the essential rules for working with the cart safely. */
4
- export declare const SERVER_INSTRUCTIONS = "Continente MCP shops at Continente.pt for one account. Checkout and payment always happen on the website.\n\n- Quantities are in the product's sale unit. Call get_product before choosing a quantity for weighted or bulk products, and respect its minimum, maximum and step.\n- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.\n- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.\n- If a cart change returns mutation.outcome \"unknown\", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.\n- Money is EUR in integer minorUnits (129 means \u20AC1.29). Missing prices, quantities or completeness stay unknown; do not guess them.\n- For planning a shopping list or basket, use the basket-planner prompt for the full workflow.";
4
+ export declare const SERVER_INSTRUCTIONS = "Continente MCP shops at Continente.pt for one account. Checkout and payment always happen on the website.\n\n- Quantities are in the product's sale unit. Call get_product before choosing a quantity for weighted or bulk products, and respect its minimum, maximum and step.\n- set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.\n- Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.\n- If a cart change returns mutation.outcome \"unknown\", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.\n- Money is EUR in integer minorUnits (129 means \u20AC1.29). Each price says what it is for: per item, per piece, per kg or per l. A price per kg or per l is not the price of one pack; check packageText and secondaryPrice before comparing. Missing prices, quantities or completeness stay unknown; do not guess them.\n- If your client supports MCP prompts, the basket-planner prompt has the full workflow for planning a shopping list or basket.";
5
5
  export declare const basketPlannerPrompt: PromptHandler;
package/dist/mcp/guide.js CHANGED
@@ -52,8 +52,8 @@ export const SERVER_INSTRUCTIONS = `Continente MCP shops at Continente.pt for on
52
52
  - set_cart_item_quantity sets an exact total (adding the product if needed; 0 removes it). add_cart_item adds on top of what is already there. When the user says how many they need, use set_cart_item_quantity.
53
53
  - Change the cart one item at a time and read structuredContent.result before reporting success. confirmedQuantity is what the cart now holds.
54
54
  - If a cart change returns mutation.outcome "unknown", the cart may have changed. Call get_cart and decide from its contents. Never repeat the change automatically.
55
- - Money is EUR in integer minorUnits (129 means €1.29). Missing prices, quantities or completeness stay unknown; do not guess them.
56
- - For planning a shopping list or basket, use the basket-planner prompt for the full workflow.`;
55
+ - Money is EUR in integer minorUnits (129 means €1.29). Each price says what it is for: per item, per piece, per kg or per l. A price per kg or per l is not the price of one pack; check packageText and secondaryPrice before comparing. Missing prices, quantities or completeness stay unknown; do not guess them.
56
+ - If your client supports MCP prompts, the basket-planner prompt has the full workflow for planning a shopping list or basket.`;
57
57
  export const basketPlannerPrompt = {
58
58
  name: 'basket-planner',
59
59
  title: 'Plan a Continente basket',
@@ -13,11 +13,15 @@ export interface ShoppingServices {
13
13
  history: PurchaseHistoryService;
14
14
  session: SessionService;
15
15
  }
16
+ /** What to do about a sign-in failure, depending on whether credentials are configured at all. */
17
+ export declare function signInHelp(credentialsConfigured: boolean | null): string;
16
18
  export declare function formatProductSearchResults(products: SearchResult['page']['items']): string;
17
19
  export declare class ShoppingHandlers {
18
20
  private readonly services;
19
21
  private readonly update;
20
22
  constructor(services: ShoppingServices, update: UpdateStatus);
23
+ /** An error result; sign-in failures before any cart write get guidance for fixing them. */
24
+ private failed;
21
25
  private recover;
22
26
  searchProducts(query: string, limit: number): Promise<CallToolResult>;
23
27
  getProduct(reference: string): Promise<CallToolResult>;
@@ -1,7 +1,19 @@
1
1
  import { failure } from '../domain/index.js';
2
2
  import * as contracts from './contracts.js';
3
3
  import { withAuthenticationRecovery } from '../services/recovery.js';
4
- const NOT_LOGGED_IN = 'Sign-in required. Configure CONTINENTE_EMAIL and CONTINENTE_PASSWORD (see the configuration guide) or run `continentept-mcp login`.';
4
+ const CONFIGURATION_GUIDE = 'https://github.com/andredelgado/continente-mcp/blob/main/docs/configuration.md';
5
+ const PUBLIC_TOOLS = 'Searching and product details work without signing in.';
6
+ /** What to do about a sign-in failure, depending on whether credentials are configured at all. */
7
+ export function signInHelp(credentialsConfigured) {
8
+ if (credentialsConfigured === false) {
9
+ return 'Sign-in required: no Continente email and password are configured. In the Claude Desktop extension, enter them in the '
10
+ + `extension's settings; in other setups, set CONTINENTE_EMAIL and CONTINENTE_PASSWORD (see ${CONFIGURATION_GUIDE}). ${PUBLIC_TOOLS}`;
11
+ }
12
+ const cause = credentialsConfigured ? 'Continente did not accept the configured email and password, or asked for extra verification'
13
+ : 'Continente did not accept the sign-in, or no credentials are configured';
14
+ return `Sign-in failed: ${cause}. Check the credentials, or run \`continentept-mcp login --show-browser\` in a terminal `
15
+ + `to sign in and complete any verification (see ${CONFIGURATION_GUIDE}). ${PUBLIC_TOOLS}`;
16
+ }
5
17
  const CHECKOUT_URL = 'https://www.continente.pt/checkout/carrinho/';
6
18
  // Text content mirrors the structured result for clients that only show text to the model,
7
19
  // so it always includes the identifiers needed for follow-up calls.
@@ -9,6 +21,8 @@ const euros = (money) => money === null ? 'price unavailable' : `€${(money.min
9
21
  const amount = (quantity) => `${Number(quantity.value.toPrecision(12))} ${quantity.unit}`;
10
22
  const observed = (quantity) => quantity.status === 'known' ? amount(quantity.quantity)
11
23
  : quantity.value === null ? 'quantity unknown' : `${quantity.value} (unit unknown)`;
24
+ const PER = { item: '', piece: ' per piece', kg: '/kg', l: '/l' };
25
+ const priced = (price) => price === null ? 'price unknown' : `${euros(price.amount)}${PER[price.per]}`;
12
26
  const quoted = (price) => price.amount === null ? 'price unavailable'
13
27
  : `${euros(price.amount)}${price.basis === 'unit' ? ' each' : price.basis === 'line' ? ' line total' : ''}`;
14
28
  const numbered = (lines) => lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
@@ -17,17 +31,16 @@ function response(data, text, status = 'success') {
17
31
  }
18
32
  function errorResult(error, mutation) {
19
33
  const unknown = mutation?.outcome === 'unknown';
20
- const publicError = { ...error, message: error.code === 'not_authenticated' && !unknown ? NOT_LOGGED_IN : error.message,
21
- ...(unknown ? { retryable: false } : {}) };
34
+ const publicError = { ...error, ...(unknown ? { retryable: false } : {}) };
22
35
  const note = unknown ? '\nThe cart may have changed. Call get_cart before making another change; do not repeat this request automatically.'
23
36
  : mutation ? '\nThe cart was not changed.' : '';
24
37
  return { isError: true, content: [{ type: 'text', text: `Error (${publicError.code}): ${publicError.message}${note}` }],
25
38
  structuredContent: { result: { status: 'error', error: publicError, ...(mutation ? { mutation } : {}) } } };
26
39
  }
27
40
  export function formatProductSearchResults(products) {
28
- return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${euros(product.price)}` +
29
- `${product.unitPriceText ? ` (${product.unitPriceText})` : ''}${product.minimumText ? `, minimum ${product.minimumText}` : ''}` +
30
- `\n productId: ${product.productId}`));
41
+ return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${priced(product.price)}` +
42
+ `${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.packageText ? `, pack ${product.packageText}` : ''}` +
43
+ `${product.minimumText ? `, minimum ${product.minimumText}` : ''}\n productId: ${product.productId}`));
31
44
  }
32
45
  function formatSaleRules(rules) {
33
46
  const bounds = [rules.minimum && `minimum ${amount(rules.minimum)}`, rules.maximum && `maximum ${amount(rules.maximum)}`,
@@ -36,7 +49,7 @@ function formatSaleRules(rules) {
36
49
  }
37
50
  function formatProduct(product) {
38
51
  return [`${product.name}`, `productId: ${product.productId}`,
39
- `Price: ${quoted(product.price)}${product.unitPriceText ? ` (${product.unitPriceText})` : ''}`,
52
+ `Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}`,
40
53
  `Availability: ${product.availability}`,
41
54
  product.saleRules.status === 'available' ? `Sale rules: ${formatSaleRules(product.saleRules.rules)}`
42
55
  : `Sale rules unavailable: ${product.saleRules.error.message}`, `URL: ${product.url}`].join('\n');
@@ -74,43 +87,51 @@ export class ShoppingHandlers {
74
87
  this.services = services;
75
88
  this.update = update;
76
89
  }
90
+ /** An error result; sign-in failures before any cart write get guidance for fixing them. */
91
+ async failed(error, mutation) {
92
+ if (error.code !== 'not_authenticated' || mutation?.outcome === 'unknown')
93
+ return errorResult(error, mutation);
94
+ const status = await this.services.session.getStatus?.().catch(() => undefined);
95
+ const configured = status ? status.credentials.hasEmail === true && status.credentials.hasPassword === true : null;
96
+ return errorResult({ ...error, message: signInHelp(status?.credentials.hasEmail === null ? null : configured) }, mutation);
97
+ }
77
98
  recover(operation) {
78
99
  return withAuthenticationRecovery(operation, () => this.services.session.refreshAuthentication());
79
100
  }
80
101
  async searchProducts(query, limit) {
81
102
  const result = await this.services.catalog.searchProducts(query, limit);
82
103
  if (!result.ok)
83
- return errorResult(result.error);
104
+ return this.failed(result.error);
84
105
  const { page, scanned, favorites } = result.value;
85
106
  const partial = page.completeness === 'partial';
86
107
  const text = page.items.length
87
108
  ? `${page.items.length} of ${scanned} scanned products for "${query}":\n\n${formatProductSearchResults(page.items)}`
88
109
  : `No products found for "${query}".`;
89
110
  return response({ products: page.items, scanned, returned: page.items.length, favoritesLoaded: favorites.length, completeness: page.completeness,
90
- failures: page.failures, nextCursor: page.nextCursor }, text + (partial ? '\n\nPartial results: some result pages could not be read.' : ''), partial ? 'partial' : page.items.length ? 'success' : 'empty');
111
+ failures: page.failures, nextCursor: page.nextCursor }, text + (partial ? `\n\nPartial results: ${page.failures.length} problem(s) reading results; see failures.` : ''), partial ? 'partial' : page.items.length ? 'success' : 'empty');
91
112
  }
92
113
  async getProduct(reference) {
93
114
  const result = await this.services.catalog.getProduct(reference);
94
115
  if (!result.ok)
95
- return errorResult(result.error);
116
+ return this.failed(result.error);
96
117
  const product = result.value;
97
- const partial = product.saleRules.status === 'unavailable' || product.price.amount === null || product.availability === 'unknown';
118
+ const partial = product.saleRules.status === 'unavailable' || product.price === null || product.availability === 'unknown';
98
119
  return response(product, formatProduct(product), partial ? 'partial' : 'success');
99
120
  }
100
121
  async listFavorites(refresh) {
101
122
  const result = await this.recover(() => refresh ? this.services.favorites.refreshFavorites() : this.services.favorites.listFavorites());
102
123
  if (!result.ok)
103
- return errorResult(result.error);
124
+ return this.failed(result.error);
104
125
  const { favorites, source, freshness } = result.value;
105
126
  const header = `${favorites.length} favorites (${source === 'website' ? 'fetched from the website' : `cached ${result.value.fetchedAt}`}):`;
106
- const list = numbered(favorites.map(item => `${item.name} — ${euros(item.price)}\n productId: ${item.productId}`));
127
+ const list = numbered(favorites.map(item => `${item.name} — ${priced(item.price)}\n productId: ${item.productId}`));
107
128
  return response(favoritesData(result.value), (favorites.length ? `${header}\n\n${list}` : 'No favorites saved.') +
108
129
  (freshness === 'stale' ? '\n\nThis snapshot is more than 24 hours old. Call list_favorites with refresh: true to update it.' : ''), favorites.length ? 'success' : 'empty');
109
130
  }
110
131
  async getCart() {
111
132
  const result = await this.recover(() => this.services.cart.getCart());
112
133
  if (!result.ok)
113
- return errorResult(result.error);
134
+ return this.failed(result.error);
114
135
  const cart = result.value;
115
136
  if (!cart.items.length)
116
137
  return response(cart, 'The cart is empty.', 'empty');
@@ -119,13 +140,13 @@ export class ShoppingHandlers {
119
140
  return response(cart, `Cart (${cart.items.length} lines):\n\n${list}\n\nTotal: ${cart.total === null ? 'unavailable' : euros(cart.total)}` +
120
141
  `\nCheckout happens on the website: ${CHECKOUT_URL}`);
121
142
  }
122
- mutation(result, operation) {
143
+ async mutation(result, operation) {
123
144
  if (!result.ok)
124
- return errorResult(result.error, { outcome: 'not_applied' });
145
+ return this.failed(result.error, { outcome: 'not_applied' });
125
146
  const value = result.value;
126
147
  if (value.outcome === 'confirmed')
127
148
  return response(value, mutationText(value, operation));
128
- return errorResult(value.error, value.outcome === 'unknown' ? { outcome: 'unknown', recovery: value.recovery } : { outcome: 'not_applied' });
149
+ return this.failed(value.error, value.outcome === 'unknown' ? { outcome: 'unknown', recovery: value.recovery } : { outcome: 'not_applied' });
129
150
  }
130
151
  async addCartItem(reference, quantity, cartItemId) {
131
152
  return this.mutation(await this.recover(() => this.services.cart.addCartItem(reference, quantity, cartItemId)), 'add');
@@ -139,7 +160,7 @@ export class ShoppingHandlers {
139
160
  async listOrders(limit, options) {
140
161
  const result = await this.recover(() => this.services.orders.listOrders(limit, options));
141
162
  if (!result.ok)
142
- return errorResult(result.error);
163
+ return this.failed(result.error);
143
164
  const page = result.value;
144
165
  const partial = page.completeness === 'partial';
145
166
  const list = numbered(page.items.map(order => `${orderHeading(order)}\n${formatOrderLines(order)}`));
@@ -151,7 +172,7 @@ export class ShoppingHandlers {
151
172
  async getOrder(orderId) {
152
173
  const result = await this.recover(() => this.services.orders.getOrder(orderId));
153
174
  if (!result.ok)
154
- return errorResult(result.error);
175
+ return this.failed(result.error);
155
176
  const order = result.value;
156
177
  const lines = order.lines.status === 'available' ? order.lines.items : [];
157
178
  const unknownQuantityLines = lines.filter(line => line.quantity.status === 'unknown').length;
@@ -161,7 +182,7 @@ export class ShoppingHandlers {
161
182
  async listFrequentlyBoughtProducts(orderLimit, limit) {
162
183
  const result = await this.recover(() => this.services.history.listFrequentlyBoughtProducts(orderLimit));
163
184
  if (!result.ok)
164
- return errorResult(result.error);
185
+ return this.failed(result.error);
165
186
  const scan = result.value;
166
187
  if (!scan.succeeded && (scan.failed || scan.unscanned)) {
167
188
  const unavailable = errorResult(failure('order_details_unavailable', 'No order details could be read. See scan.failures.').error);
@@ -198,8 +219,8 @@ export function registerShoppingTools(services, update = NO_UPDATE_INFO) {
198
219
  execute: raw => services.session.run(() => execute(raw)) };
199
220
  }
200
221
  return [
201
- tool('search_products', 'Search products', 'Search the Continente.pt catalogue. No login needed. Results show price, unit price and productId; products in your saved favorites rank first. ' +
202
- 'Reads at most five result pages, so completeness may be "unknown" for broad queries.', contracts.SearchInput, contracts.SearchOutput, READ, args => handlers.searchProducts(args.query, args.limit)),
222
+ tool('search_products', 'Search products', 'Search the Continente.pt catalogue. No login needed. Results keep the website\'s relevance order, with products from your saved favorites first, and show ' +
223
+ 'each price with what it is for (per item, piece, kg or litre), the pack size and productId. Reads at most five result pages, so completeness may be "unknown" for broad queries.', contracts.SearchInput, contracts.SearchOutput, READ, args => handlers.searchProducts(args.query, args.limit)),
203
224
  tool('get_product', 'Get product', 'Get one product by productId: price, availability and sale rules (sale unit, minimum, maximum and step). ' +
204
225
  'Check this before choosing a quantity for weighted or bulk products. No login needed.', contracts.GetProductInput, contracts.ProductDetailsOutput, READ, args => handlers.getProduct(args.productId)),
205
226
  tool('list_favorites', 'List favorites', 'List the products saved as favorites on the account. Uses a local snapshot that is refreshed automatically when missing; ' +
@@ -13,7 +13,13 @@ export declare function productReferenceFromUrl(href: string): Result<{
13
13
  code: string;
14
14
  }[] | undefined;
15
15
  }>;
16
- export declare function parseProductSearchPage(html: string): Result<Product[], ToolError>;
16
+ export interface SearchPage {
17
+ products: Product[];
18
+ /** One entry per result that was skipped or kept with missing data. */
19
+ problems: ToolError[];
20
+ }
21
+ /** Parse one results page. A bad card is reported in `problems` instead of failing the page. */
22
+ export declare function parseProductSearchPage(html: string): Result<SearchPage, ToolError>;
17
23
  export declare function parseFavoritesPageData(html: string): Result<{
18
24
  items: FavoriteProduct[];
19
25
  expectedIds: string[];
@@ -23,12 +29,14 @@ export declare function parseFavoritesContinuation(html: string): Result<string
23
29
  export declare function parseFavoritesPages(pages: string[]): Result<FavoriteProduct[], ToolError>;
24
30
  export declare function uniqueProducts<T extends FavoriteProduct>(products: T[]): T[];
25
31
  export interface RankedProduct extends Product {
26
- /** The exact product is in the saved favorites. */
32
+ /** This exact product is in the saved favorites. */
27
33
  favorite: boolean;
28
- /** Highest word overlap (0–1) between this name and a favorite's name. */
29
- similarity: number;
30
34
  }
31
- /** Favorites first, then names most similar to a favorite; otherwise keep the site's order. */
35
+ /**
36
+ * The website orders results by relevance to the query; keep that order and only move exact
37
+ * favorites to the front. Name similarity to favorites is not used: shared words such as a
38
+ * brand name would pull unrelated products above real matches.
39
+ */
32
40
  export declare function rankProductsByFavorites(products: Product[], favorites: FavoriteProduct[]): RankedProduct[];
33
41
  export declare function parseSearchPagination(html: string): Result<{
34
42
  url: string;
@@ -1,5 +1,5 @@
1
1
  import { load } from 'cheerio';
2
- import { FavoriteProductSchema, ProductSchema, parseMoneyAmount, parseProductReference, parseValue } from '../domain/index.js';
2
+ import { FavoriteProductSchema, ProductSchema, parsePriceTag, parseProductReference, parseValue } from '../domain/index.js';
3
3
  import { failure, success } from '../domain/index.js';
4
4
  export const STOREFRONT_ORIGIN = 'https://www.continente.pt';
5
5
  export function storefrontUrl(value) {
@@ -19,6 +19,7 @@ export function productReferenceFromUrl(href) {
19
19
  // Slugs can contain encoded punctuation that tool inputs do not accept; fall back to the numeric ID.
20
20
  return parsed.ok ? parsed : parseProductReference(slug?.match(/-(\d+)(?:-master)?$/)?.[1]);
21
21
  }
22
+ const PRICE_IN_LABEL = /\d+[,.]\d+\s*€(?:\s*\/\s*[a-z]+)?/gi;
22
23
  const SEARCH_EMPTY = '.search-noresults-wrapper, .search-no-results, [data-search-empty]';
23
24
  const PRODUCT_LINK = 'a[href*="/produto/"]';
24
25
  /** Text after "=" in labels such as "Quant. Mínima = 600 gr (3 un)". */
@@ -26,35 +27,52 @@ function minimumLabel(label) {
26
27
  const [caption, ...rest] = label.split('=');
27
28
  return /m[ií]nim/i.test(caption ?? '') && rest.length ? text(rest.join('=')) || null : null;
28
29
  }
30
+ /** Pack size from labels such as "emb. 940 gr" or "emb. 2 x 52 gr". */
31
+ function packageLabel(label) {
32
+ return text(/^emb\.\s*(.+)$/i.exec(text(label))?.[1] ?? '') || null;
33
+ }
34
+ /** An unrecognised price is reported, not fatal: the product stays usable with an unknown price. */
35
+ function priceOrNull(label) {
36
+ const parsed = parsePriceTag(label);
37
+ return parsed.ok ? { price: parsed.value, recognised: true } : { price: null, recognised: false };
38
+ }
39
+ /** Parse one results page. A bad card is reported in `problems` instead of failing the page. */
29
40
  export function parseProductSearchPage(html) {
30
41
  const $ = load(html);
31
42
  if ($(SEARCH_EMPTY).length)
32
- return success([]);
43
+ return success({ products: [], problems: [] });
33
44
  const tiles = $($('.search-results-grid').length ? '.search-results-grid .ct-inner-tile-wrap' : '.ct-inner-tile-wrap').toArray();
34
45
  if (!tiles.length)
35
46
  return failure('invalid_data', 'The search results page was not recognised');
36
47
  const products = [];
37
- for (const tile of tiles) {
48
+ const problems = [];
49
+ const problem = (message) => problems.push(failure('invalid_data', message).error);
50
+ tiles.forEach((tile, index) => {
38
51
  const card = $(tile);
52
+ const position = `Search result ${index + 1}`;
39
53
  const links = card.find(PRODUCT_LINK).toArray().map(link => ({ href: $(link).attr('href'), label: text($(link).text()) }));
40
54
  const href = links[0]?.href;
41
55
  const name = links.map(link => link.label).find(Boolean);
42
- if (!href || !name)
43
- return failure('invalid_data', 'A search result was not recognised');
44
- const reference = productReferenceFromUrl(href);
45
- if (!reference.ok)
46
- return reference;
47
- const price = parseMoneyAmount(text(card.find('.pwc-tile--price-primary').first().text()) || null);
48
- if (!price.ok)
49
- return price;
50
- const product = parseValue(ProductSchema, { ...reference.value, name, price: price.value, url: storefrontUrl(href),
51
- unitPriceText: text(card.find('.pwc-tile--price-secondary').first().text()) || null,
52
- minimumText: minimumLabel(card.find('.pwc-tile--quantity').first().text()) }, 'product');
53
- if (!product.ok)
54
- return product;
55
- products.push(product.value);
56
- }
57
- return success(uniqueProducts(products));
56
+ const reference = href ? productReferenceFromUrl(href) : null;
57
+ if (!href || !name || !reference?.ok) {
58
+ problem(`${position} was not recognised and was skipped.`);
59
+ return;
60
+ }
61
+ const main = priceOrNull(text(card.find('.pwc-tile--price-primary').first().text()));
62
+ const secondary = priceOrNull(text(card.find('.pwc-tile--price-secondary').first().text()));
63
+ if (!main.recognised)
64
+ problem(`${position} (product ${reference.value.productId}) has a price in an unrecognised format; its price is unknown.`);
65
+ const quantity = card.find('.pwc-tile--quantity').first().text();
66
+ const product = parseValue(ProductSchema, { ...reference.value, name, url: storefrontUrl(href), price: main.price,
67
+ secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity) }, 'product');
68
+ if (product.ok)
69
+ products.push(product.value);
70
+ else
71
+ problem(`${position} (product ${reference.value.productId}) was not recognised and was skipped.`);
72
+ });
73
+ if (!products.length && problems.length)
74
+ return failure('invalid_data', 'No search result on the page could be recognised');
75
+ return success({ products: uniqueProducts(products), problems });
58
76
  }
59
77
  export function parseFavoritesPageData(html) {
60
78
  const $ = load(html);
@@ -82,23 +100,19 @@ export function parseFavoritesPageData(html) {
82
100
  const anchor = card.find('a[href*="/produto/"]').filter((_, element) => text($(element).text()).length > 0).first();
83
101
  const label = text(anchor.text());
84
102
  const href = anchor.attr('href') ?? '';
85
- const name = label.replace(/\d+[,.]\d+\s*€/g, '').trim();
103
+ const name = label.replace(PRICE_IN_LABEL, '').trim();
86
104
  if (!name) {
87
105
  error = failure('invalid_data', 'A favorite product was not recognised').error;
88
106
  return;
89
107
  }
90
108
  const ref = productReferenceFromUrl(href);
91
- const rawPrice = text(card.find('.pwc-tile--price-primary').first().text()) || label.match(/(\d+[,.]\d+\s*€)/)?.[1];
92
- const price = parseMoneyAmount(rawPrice);
109
+ const rawPrice = text(card.find('.pwc-tile--price-primary').first().text()) || label.match(PRICE_IN_LABEL)?.[0] || '';
93
110
  if (!ref.ok) {
94
111
  error = ref.error;
95
112
  return;
96
113
  }
97
- if (!price.ok) {
98
- error = price.error;
99
- return;
100
- }
101
- const result = parseValue(FavoriteProductSchema, { ...ref.value, name, price: price.value, url: storefrontUrl(href) }, 'favorite');
114
+ // Favorites must be complete, but an unrecognised price only makes that price unknown.
115
+ const result = parseValue(FavoriteProductSchema, { ...ref.value, name, price: priceOrNull(rawPrice).price, url: storefrontUrl(href) }, 'favorite');
102
116
  if (result.ok)
103
117
  products.push(result.value);
104
118
  else
@@ -151,26 +165,15 @@ export function uniqueProducts(products) {
151
165
  return products.filter(product => { if (seen.has(product.productId))
152
166
  return false; seen.add(product.productId); return true; });
153
167
  }
154
- const words = (name) => new Set(name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
155
- .split(/[^a-z0-9]+/).filter(word => word.length > 1));
156
- function overlap(a, b) {
157
- let shared = 0;
158
- for (const word of a)
159
- if (b.has(word))
160
- shared++;
161
- const union = a.size + b.size - shared;
162
- return union ? shared / union : 0;
163
- }
164
- /** Favorites first, then names most similar to a favorite; otherwise keep the site's order. */
168
+ /**
169
+ * The website orders results by relevance to the query; keep that order and only move exact
170
+ * favorites to the front. Name similarity to favorites is not used: shared words such as a
171
+ * brand name would pull unrelated products above real matches.
172
+ */
165
173
  export function rankProductsByFavorites(products, favorites) {
166
174
  const favoriteIds = new Set(favorites.map(product => product.productId));
167
- const favoriteNames = favorites.map(product => words(product.name));
168
- return products.map((product, position) => {
169
- const name = words(product.name);
170
- const similarity = Math.round(Math.max(0, ...favoriteNames.map(favorite => overlap(name, favorite))) * 100) / 100;
171
- return { product: { ...product, favorite: favoriteIds.has(product.productId), similarity }, position };
172
- }).sort((a, b) => Number(b.product.favorite) - Number(a.product.favorite) || b.product.similarity - a.product.similarity || a.position - b.position)
173
- .map(entry => entry.product);
175
+ const ranked = products.map(product => ({ ...product, favorite: favoriteIds.has(product.productId) }));
176
+ return [...ranked.filter(product => product.favorite), ...ranked.filter(product => !product.favorite)];
174
177
  }
175
178
  export function parseSearchPagination(html) {
176
179
  const actions = load(html)('[data-url*="Search-UpdateGrid"]');
@@ -1,5 +1,5 @@
1
1
  import { load } from 'cheerio';
2
- import { ProductDetailsSchema, parseMoneyAmount, parseProductReference, parseValue } from '../domain/index.js';
2
+ import { ProductDetailsSchema, parsePriceTag, parseProductReference, parseValue } from '../domain/index.js';
3
3
  import { success, failure } from '../domain/index.js';
4
4
  import { parseSaleRules } from './cart.js';
5
5
  import { productReferenceFromUrl, storefrontUrl } from './catalog.js';
@@ -30,12 +30,14 @@ export function parseProductDetails(html, sourceUrl, expectedId) {
30
30
  });
31
31
  const offer = products.length === 1 ? record(products[0].offers) : null;
32
32
  const availability = typeof offer?.availability === 'string' ? offer.availability.replace(/^http:\/\//, 'https://') : '';
33
- const priceText = $('.ct-pdp--prices .pwc-tile--price-primary').first().text().replace(/\s+/g, '').trim();
34
- const amount = parseMoneyAmount(priceText);
33
+ const priceTag = (selector) => {
34
+ const parsed = parsePriceTag($(`.ct-pdp--prices ${selector}`).first().text());
35
+ return parsed.ok ? parsed.value : null;
36
+ };
35
37
  const product = {
36
38
  ...reference.value, name, url,
37
- price: { amount: amount.ok ? amount.value : null, basis: rules.ok && rules.value.rules.saleUnit === 'un' && amount.ok && amount.value ? 'unit' : 'unknown' },
38
- unitPriceText: $('.ct-pdp--prices .pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim() || null,
39
+ price: priceTag('.pwc-tile--price-primary'),
40
+ secondaryPrice: priceTag('.pwc-tile--price-secondary'),
39
41
  availability: availability === 'https://schema.org/InStock' ? 'available' : ['https://schema.org/OutOfStock', 'https://schema.org/Discontinued'].includes(availability) ? 'unavailable' : 'unknown',
40
42
  saleRules: rules.ok ? { status: 'available', rules: rules.value.rules } : { status: 'unavailable', error: rules.error },
41
43
  };
@@ -40,10 +40,16 @@ export declare class CatalogService {
40
40
  amount: {
41
41
  currency: "EUR";
42
42
  minorUnits: number;
43
- } | null;
44
- basis: "line" | "unit" | "unknown";
45
- };
46
- unitPriceText: string | null;
43
+ };
44
+ per: "item" | "kg" | "l" | "piece";
45
+ } | null;
46
+ secondaryPrice: {
47
+ amount: {
48
+ currency: "EUR";
49
+ minorUnits: number;
50
+ };
51
+ per: "item" | "kg" | "l" | "piece";
52
+ } | null;
47
53
  availability: "available" | "unavailable" | "unknown";
48
54
  saleRules: {
49
55
  status: "available";
@@ -68,7 +68,7 @@ export class FavoritesService {
68
68
  return products;
69
69
  if (scope !== this.scope())
70
70
  return failure('cache_unavailable', 'Favorites account/session changed during refresh');
71
- const cache = { schemaVersion: 1, accountScope: scope,
71
+ const cache = { schemaVersion: 2, accountScope: scope,
72
72
  fetchedAt: this.clock.now().toISOString(), favorites: products.value, completeness: 'complete' };
73
73
  const saved = await requestIO(() => this.store.save(cache));
74
74
  return saved.ok ? success(this.snapshot(cache, 'website')) : saved;
@@ -106,7 +106,8 @@ export class CatalogService {
106
106
  failures.push(parsed.error);
107
107
  break;
108
108
  }
109
- products.push(...parsed.value);
109
+ products.push(...parsed.value.products);
110
+ failures.push(...parsed.value.problems);
110
111
  }
111
112
  const unique = uniqueProducts(products);
112
113
  const favorites = cache?.favorites ?? [];
package/docs/tools.md CHANGED
@@ -50,9 +50,15 @@ calls, for clients that do not pass structured content to the model.
50
50
 
51
51
  ### Values
52
52
 
53
- - **Money** is `{ "currency": "EUR", "minorUnits": 129 }` (€1.29). An unknown price is `null`.
54
- Cart and product prices are `{ amount, basis }`, where `basis` is `unit`, `line` (a line
55
- total) or `unknown`.
53
+ - **Money** is `{ "currency": "EUR", "minorUnits": 129 }` (€1.29).
54
+ - **Catalogue prices** (search, favorites, `get_product`) are `{ amount, per }`, saying what the
55
+ price is for: `item` (one pack or article), `piece` (one unit, often approximate for
56
+ produce), `kg` or `l`. Produce and meat show their main `price` per kg, with an approximate
57
+ per-piece `secondaryPrice`; packaged goods show the pack price with a per-kg or per-litre
58
+ `secondaryPrice`. A price per kg or per litre is not the price of one pack. An unknown or
59
+ unrecognised price is `null`.
60
+ - **Cart prices** are `{ amount, basis }`, where `basis` is `unit`, `line` (a line total) or
61
+ `unknown`.
56
62
  - **Quantities** are `{ value, unit }` with unit `un`, `kg`, `g`, `l` or `ml`. Quantities read
57
63
  from the website are either `{ status: "known", quantity }` or
58
64
  `{ status: "unknown", value, rawText, reason }`; an unknown unit is never guessed.
@@ -64,17 +70,25 @@ calls, for clients that do not pass structured content to the model.
64
70
 
65
71
  ### `search_products`
66
72
 
67
- Searches the public catalogue. Products in your saved favorites rank first (`favorite: true`),
68
- then products whose names share the most words with a favorite (`similarity`, 0–1); ties
69
- keep the website's order. Search uses the local favorites snapshot and never signs in to
70
- refresh it.
73
+ Searches the public catalogue. Results keep the website's order, which reflects relevance to
74
+ the query, except that products in your saved favorites move to the front (`favorite: true`).
75
+ Search uses the local favorites snapshot and never signs in to refresh it.
76
+
77
+ Each product has `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`,
78
+ `packageText` (the pack size as shown, such as `940 gr`, `2 x 52 gr` or
79
+ `400 gr (peso escorrido 260 gr)`), `minimumText` (a minimum purchase such as `600 gr (3 un)`)
80
+ and `favorite`.
81
+
82
+ A result the server cannot read does not fail the search. It is skipped, or kept with an
83
+ unknown price when only the price is unreadable, and described in `failures`; the result is
84
+ then `partial`.
71
85
 
72
86
  `data`: `products`, `scanned`, `returned`, `favoritesLoaded`, `completeness`, `failures`,
73
87
  `nextCursor`.
74
88
 
75
89
  ### `get_product`
76
90
 
77
- Returns `productId`, `productSlug`, `name`, `url`, `price`, `unitPriceText`, `availability`
91
+ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `availability`
78
92
  (`available`, `unavailable` or `unknown`) and `saleRules`:
79
93
 
80
94
  ```json
@@ -189,7 +203,7 @@ credentials, cookies or file paths.
189
203
  | `quantity_below_minimum` | The resulting quantity is below the product minimum. |
190
204
  | `sale_rules_unknown` | The product's sale rules could not be interpreted safely. |
191
205
  | `not_found` | The product, cart line or order does not exist. |
192
- | `not_authenticated` | Sign-in is required and automatic sign-in failed or is not configured. |
206
+ | `not_authenticated` | Sign-in is required and automatic sign-in failed or is not configured. The message says which, and what to do. |
193
207
  | `upstream_unavailable` | The website could not be reached or returned an error. |
194
208
  | `invalid_data` | The website returned something the server does not recognise. |
195
209
  | `order_details_unavailable` | Order details could not be read. |
@@ -7,7 +7,7 @@ without contacting the website.
7
7
  | --- | --- |
8
8
  | `browser_unavailable`: Chromium is being downloaded | The first use downloads Chromium (about 150 MB). Wait a minute and try again. |
9
9
  | `browser_unavailable`: Chromium could not be downloaded | Check the internet connection, then run `npx -y continentept-mcp install-browser` to see the download output. On Linux, add `--with-deps` to install system libraries. |
10
- | `not_authenticated` | Check that `get_session_status` reports credentials as configured, then run `continentept-mcp login` to test them. See [configuration](configuration.md). |
10
+ | `not_authenticated` | The message says whether no credentials are configured or Continente did not accept them. Check them in the extension's settings or your configuration, or run `continentept-mcp login --show-browser` to sign in and complete any extra verification. See [configuration](configuration.md). |
11
11
  | Sign-in keeps failing | Run `continentept-mcp login --show-browser` to watch the browser and complete any extra verification the website asks for. |
12
12
  | Status shows `authentication: unknown` | Nothing has been observed since the server started or since the saved session changed. Any account tool will establish it. |
13
13
  | Favorites are stale or missing from search ranking | Call `list_favorites` with `refresh: true`, then search again. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continentept-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.andredelgado/continente-mcp",
5
5
  "description": "MCP server for Continente.pt: search products, check sale rules, read favorites and orders, and make verified cart changes.",
6
6
  "keywords": [
@@ -69,12 +69,15 @@
69
69
  "dependencies": {
70
70
  "@modelcontextprotocol/sdk": "^1.32.1",
71
71
  "cheerio": "1.2.0",
72
- "playwright": "1.59.1",
72
+ "playwright": "1.64.0",
73
73
  "zod": "4.6.7"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@anthropic-ai/mcpb": "2.1.2",
77
77
  "@types/node": "^22.18.0",
78
78
  "typescript": "^7.0.2"
79
+ },
80
+ "overrides": {
81
+ "tmp": "^0.2.7"
79
82
  }
80
83
  }