continentept-mcp 0.5.0 → 0.6.1

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,32 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.1] - 2026-10-15
10
+
11
+ ### Fixed
12
+
13
+ - The first request after the browser started could fail with "The order history page was not recognised", or a search error, while the website was still setting up its session. A page that does not show what it should is now loaded once more.
14
+ - Order dates with abbreviated months, as the website now shows them ("01 Set 26"), are read. They were all `null`.
15
+ - `get_product` reports a product the website no longer sells as `not_found`, instead of `invalid_data`.
16
+ - After an upgrade, the update check no longer reports an older version as the latest one.
17
+
18
+ ## [0.6.0] - 2026-10-15
19
+
20
+ ### Changed
21
+
22
+ - **Breaking:** `get_cart` returns `totals` instead of `total`: `products`, `bags`, `deposit`, `delivery`, `discounts` and `grandTotal`, the amount to pay. The old `total` left out the bags, so it was less than the amount to pay.
23
+ - **Breaking:** cart line prices are `{ amount, per }` price tags, like catalogue prices, plus a `lineTotal`. Products sold by the piece but priced by weight were shown as a price "each" when it was the price per kg.
24
+
25
+ ### Added
26
+
27
+ - Cart lines have a `kind`, `bag` for the shopping bags the website adds by itself and `product` for everything else.
28
+ - `search_products` and `get_product` return `recommendedPrice`, the recommended retail price (PVPR) the website shows beside the price on many promotions, so assistants can compare it with the selling price. ([#4](https://github.com/andredelgado/continente-mcp/pull/4), thanks [@Lozano-Miguel](https://github.com/Lozano-Miguel))
29
+
30
+ ### Fixed
31
+
32
+ - Products sold by the piece but priced per kilogram (fruit, vegetables, meat, fish, counter cheese and ham) had no sale rules, so they could not be added to the cart. Their maximum is now read in kilograms, like the minimum and step, and converted to whole pieces.
33
+ - Cart quantities now have a unit. They were all reported as `unknown`; each line's unit now comes from its own sale rules, so a weighted line reads as pieces. A cart change is no longer confirmed by a line that reports another unit.
34
+
9
35
  ## [0.5.0] - 2026-10-15
10
36
 
11
37
  ### Fixed
@@ -71,7 +97,9 @@ All notable changes to this project are documented here. The format follows
71
97
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
72
98
  - The `basket-planner` skill for planning a shopping list and updating the basket.
73
99
 
74
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...HEAD
100
+ [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.1...HEAD
101
+ [0.6.1]: https://github.com/andredelgado/continente-mcp/compare/v0.6.0...v0.6.1
102
+ [0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
75
103
  [0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
76
104
  [0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
77
105
  [0.3.1]: https://github.com/andredelgado/continente-mcp/compare/v0.3.0...v0.3.1
package/README.md CHANGED
@@ -119,7 +119,7 @@ Continente cart"* or *"What do I usually buy? Build me a list for this week."*
119
119
  | `search_products` | Search the catalogue; favorites rank first |
120
120
  | `get_product` | Price, availability and sale rules for one product |
121
121
  | `list_favorites` | Your saved favorites (`refresh: true` fetches the latest) |
122
- | `get_cart` | Cart lines, quantities and total |
122
+ | `get_cart` | Cart lines, quantities, bags and totals |
123
123
  | `add_cart_item` | Add an amount on top of what is in the cart |
124
124
  | `set_cart_item_quantity` | Make the cart hold exactly a quantity; zero removes |
125
125
  | `remove_cart_item` | Remove a line from the cart |
@@ -7,6 +7,16 @@ export declare class StorefrontClient {
7
7
  constructor(session: StorefrontSession);
8
8
  io<T>(lease: BrowserLease, operation: () => Promise<T>): Promise<T>;
9
9
  navigateToStorefrontPage(path: string): Promise<Result<BrowserLease, ToolError>>;
10
+ /**
11
+ * Load a page and wait for a marker only the expected page has. A fresh browser's first visit can
12
+ * land on an interim page while the website sets up its session, so a page without the marker is
13
+ * loaded once more; `ready` says whether the marker appeared. `landed` rejects a final URL early.
14
+ */
15
+ readPage(path: string, marker: string, landed?: (url: URL) => ToolError | null): Promise<Result<{
16
+ html: string;
17
+ url: string;
18
+ ready: boolean;
19
+ }, ToolError>>;
10
20
  html(path: string, marker?: string): Promise<Result<string, ToolError>>;
11
21
  }
12
22
  export declare class CatalogClient implements CatalogGateway {
@@ -40,20 +40,36 @@ export class StorefrontClient {
40
40
  return browserFailure(error);
41
41
  }
42
42
  }
43
- async html(path, marker = 'main, [role="main"], .product-line, a[href*="/produto/"]') {
44
- const response = await this.navigateToStorefrontPage(path);
45
- if (!response.ok)
46
- return response;
47
- try {
48
- await this.io(response.value, () => response.value.page.waitForSelector(marker, { state: 'attached', timeout: requestTimeout(10000) }));
49
- return success(await this.io(response.value, () => response.value.page.content()));
50
- }
51
- catch (error) {
52
- if (error instanceof RequestStopped)
53
- throw error;
54
- return failure('upstream_unavailable', 'Page readiness marker was not found');
43
+ /**
44
+ * Load a page and wait for a marker only the expected page has. A fresh browser's first visit can
45
+ * land on an interim page while the website sets up its session, so a page without the marker is
46
+ * loaded once more; `ready` says whether the marker appeared. `landed` rejects a final URL early.
47
+ */
48
+ async readPage(path, marker, landed) {
49
+ for (let attempt = 1;; attempt++) {
50
+ const response = await this.navigateToStorefrontPage(path);
51
+ if (!response.ok)
52
+ return response;
53
+ const lease = response.value;
54
+ const rejected = landed?.(new URL(lease.page.url()));
55
+ if (rejected)
56
+ return { ok: false, error: rejected };
57
+ try {
58
+ const ready = await this.io(lease, () => lease.page.waitForSelector(marker, { state: 'attached', timeout: requestTimeout(10000) }).then(() => true, () => false));
59
+ if (ready || attempt === 2)
60
+ return success({ html: await this.io(lease, () => lease.page.content()), url: lease.page.url(), ready });
61
+ }
62
+ catch (error) {
63
+ return browserFailure(error);
64
+ }
55
65
  }
56
66
  }
67
+ async html(path, marker = 'main, [role="main"], .product-line, a[href*="/produto/"]') {
68
+ const page = await this.readPage(path, marker);
69
+ if (!page.ok)
70
+ return page;
71
+ return page.value.ready ? success(page.value.html) : failure('upstream_unavailable', 'Page readiness marker was not found');
72
+ }
57
73
  }
58
74
  export class CatalogClient {
59
75
  site;
@@ -61,18 +77,11 @@ export class CatalogClient {
61
77
  this.site = site;
62
78
  }
63
79
  async fetchProductDetails(reference) {
64
- const result = await this.site.navigateToStorefrontPage(productPath(reference));
65
- if (!result.ok)
66
- return result;
67
- try {
68
- await this.site.io(result.value, () => result.value.page.waitForSelector('h1.product-name, main', { state: 'attached', timeout: requestTimeout(10000) }));
69
- return success({ html: await this.site.io(result.value, () => result.value.page.content()), url: result.value.page.url() });
70
- }
71
- catch (error) {
72
- if (error instanceof RequestStopped)
73
- throw error;
74
- return failure('invalid_data', 'Product detail content is unavailable');
75
- }
80
+ // The website redirects a product it no longer sells to its home page.
81
+ const page = await this.site.readPage(productPath(reference), 'h1.product-name, .ct-pdp--prices', url => url.pathname.startsWith('/produto/') ? null : failure('not_found', 'This product is no longer sold on the website').error);
82
+ if (!page.ok)
83
+ return page;
84
+ return page.value.ready ? success({ html: page.value.html, url: page.value.url }) : failure('invalid_data', 'Product detail content is unavailable');
76
85
  }
77
86
  async searchProducts(query, limit) {
78
87
  const first = await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
@@ -280,17 +289,9 @@ export class OrdersClient {
280
289
  const path = cursor === undefined ? '/conta/encomendas/' : orderHistoryUrl(cursor);
281
290
  if (!path)
282
291
  return failure('invalid_input', 'Invalid order history cursor');
283
- const response = await this.site.navigateToStorefrontPage(path);
284
- if (!response.ok)
285
- return response;
286
- const { page } = response.value;
287
- try {
288
- await this.site.io(response.value, () => page.waitForSelector('main, [role="main"], a[href*="detalhe-encomenda"]', { state: 'attached', timeout: requestTimeout(10000) }));
289
- return success(await this.site.io(response.value, () => page.content()));
290
- }
291
- catch (error) {
292
- return browserFailure(error);
293
- }
292
+ // Without the marker the page is still handed to the parser, which says what it does not recognise.
293
+ const page = await this.site.readPage(path, '.ct-order-history--landing-container, .ct-order-history--order-preview-item');
294
+ return page.ok ? success(page.value.html) : page;
294
295
  }
295
296
  async fetchOrderLines(url) {
296
297
  const href = storefrontUrl(url);
@@ -48,6 +48,18 @@ export declare const ProductSchema: z.ZodObject<{
48
48
  }>;
49
49
  }, z.core.$strict>>;
50
50
  packageText: z.ZodNullable<z.ZodString>;
51
+ recommendedPrice: z.ZodNullable<z.ZodObject<{
52
+ amount: z.ZodObject<{
53
+ currency: z.ZodLiteral<"EUR">;
54
+ minorUnits: z.ZodNumber;
55
+ }, z.core.$strict>;
56
+ per: z.ZodEnum<{
57
+ item: "item";
58
+ kg: "kg";
59
+ l: "l";
60
+ piece: "piece";
61
+ }>;
62
+ }, z.core.$strict>>;
51
63
  minimumText: z.ZodNullable<z.ZodString>;
52
64
  }, z.core.$strict>;
53
65
  export type Product = z.infer<typeof ProductSchema>;
@@ -55,6 +67,10 @@ export declare const CartItemSchema: z.ZodObject<{
55
67
  productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
56
68
  cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
57
69
  name: z.ZodString;
70
+ kind: z.ZodEnum<{
71
+ bag: "bag";
72
+ product: "product";
73
+ }>;
58
74
  quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
59
75
  status: z.ZodLiteral<"known">;
60
76
  quantity: z.ZodObject<{
@@ -78,25 +94,62 @@ export declare const CartItemSchema: z.ZodObject<{
78
94
  unsupported_unit: "unsupported_unit";
79
95
  }>>;
80
96
  }, z.core.$strict>], "status">;
81
- price: z.ZodObject<{
82
- amount: z.ZodNullable<z.ZodObject<{
97
+ price: z.ZodNullable<z.ZodObject<{
98
+ amount: z.ZodObject<{
83
99
  currency: z.ZodLiteral<"EUR">;
84
100
  minorUnits: z.ZodNumber;
85
- }, z.core.$strict>>;
86
- basis: z.ZodEnum<{
87
- line: "line";
88
- unit: "unit";
89
- unknown: "unknown";
101
+ }, z.core.$strict>;
102
+ per: z.ZodEnum<{
103
+ item: "item";
104
+ kg: "kg";
105
+ l: "l";
106
+ piece: "piece";
90
107
  }>;
91
- }, z.core.$strict>;
108
+ }, z.core.$strict>>;
109
+ lineTotal: z.ZodNullable<z.ZodObject<{
110
+ currency: z.ZodLiteral<"EUR">;
111
+ minorUnits: z.ZodNumber;
112
+ }, z.core.$strict>>;
92
113
  }, z.core.$strict>;
93
114
  export type CartItem = z.infer<typeof CartItemSchema>;
115
+ /** Each amount as the website reports it, or null when it reports none. */
116
+ export declare const CartTotalsSchema: z.ZodObject<{
117
+ products: z.ZodNullable<z.ZodObject<{
118
+ currency: z.ZodLiteral<"EUR">;
119
+ minorUnits: z.ZodNumber;
120
+ }, z.core.$strict>>;
121
+ bags: z.ZodNullable<z.ZodObject<{
122
+ currency: z.ZodLiteral<"EUR">;
123
+ minorUnits: z.ZodNumber;
124
+ }, z.core.$strict>>;
125
+ deposit: z.ZodNullable<z.ZodObject<{
126
+ currency: z.ZodLiteral<"EUR">;
127
+ minorUnits: z.ZodNumber;
128
+ }, z.core.$strict>>;
129
+ delivery: z.ZodNullable<z.ZodObject<{
130
+ currency: z.ZodLiteral<"EUR">;
131
+ minorUnits: z.ZodNumber;
132
+ }, z.core.$strict>>;
133
+ discounts: z.ZodNullable<z.ZodObject<{
134
+ currency: z.ZodLiteral<"EUR">;
135
+ minorUnits: z.ZodNumber;
136
+ }, z.core.$strict>>;
137
+ grandTotal: z.ZodNullable<z.ZodObject<{
138
+ currency: z.ZodLiteral<"EUR">;
139
+ minorUnits: z.ZodNumber;
140
+ }, z.core.$strict>>;
141
+ }, z.core.$strict>;
142
+ export type CartTotals = z.infer<typeof CartTotalsSchema>;
94
143
  export declare const CartSchema: z.ZodObject<{
95
144
  customerAuthenticated: z.ZodBoolean;
96
145
  items: z.ZodArray<z.ZodObject<{
97
146
  productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
98
147
  cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
99
148
  name: z.ZodString;
149
+ kind: z.ZodEnum<{
150
+ bag: "bag";
151
+ product: "product";
152
+ }>;
100
153
  quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
101
154
  status: z.ZodLiteral<"known">;
102
155
  quantity: z.ZodObject<{
@@ -120,22 +173,49 @@ export declare const CartSchema: z.ZodObject<{
120
173
  unsupported_unit: "unsupported_unit";
121
174
  }>>;
122
175
  }, z.core.$strict>], "status">;
123
- price: z.ZodObject<{
124
- amount: z.ZodNullable<z.ZodObject<{
176
+ price: z.ZodNullable<z.ZodObject<{
177
+ amount: z.ZodObject<{
125
178
  currency: z.ZodLiteral<"EUR">;
126
179
  minorUnits: z.ZodNumber;
127
- }, z.core.$strict>>;
128
- basis: z.ZodEnum<{
129
- line: "line";
130
- unit: "unit";
131
- unknown: "unknown";
180
+ }, z.core.$strict>;
181
+ per: z.ZodEnum<{
182
+ item: "item";
183
+ kg: "kg";
184
+ l: "l";
185
+ piece: "piece";
132
186
  }>;
133
- }, z.core.$strict>;
134
- }, z.core.$strict>>;
135
- total: z.ZodNullable<z.ZodObject<{
136
- currency: z.ZodLiteral<"EUR">;
137
- minorUnits: z.ZodNumber;
187
+ }, z.core.$strict>>;
188
+ lineTotal: z.ZodNullable<z.ZodObject<{
189
+ currency: z.ZodLiteral<"EUR">;
190
+ minorUnits: z.ZodNumber;
191
+ }, z.core.$strict>>;
138
192
  }, z.core.$strict>>;
193
+ totals: z.ZodObject<{
194
+ products: z.ZodNullable<z.ZodObject<{
195
+ currency: z.ZodLiteral<"EUR">;
196
+ minorUnits: z.ZodNumber;
197
+ }, z.core.$strict>>;
198
+ bags: z.ZodNullable<z.ZodObject<{
199
+ currency: z.ZodLiteral<"EUR">;
200
+ minorUnits: z.ZodNumber;
201
+ }, z.core.$strict>>;
202
+ deposit: z.ZodNullable<z.ZodObject<{
203
+ currency: z.ZodLiteral<"EUR">;
204
+ minorUnits: z.ZodNumber;
205
+ }, z.core.$strict>>;
206
+ delivery: z.ZodNullable<z.ZodObject<{
207
+ currency: z.ZodLiteral<"EUR">;
208
+ minorUnits: z.ZodNumber;
209
+ }, z.core.$strict>>;
210
+ discounts: z.ZodNullable<z.ZodObject<{
211
+ currency: z.ZodLiteral<"EUR">;
212
+ minorUnits: z.ZodNumber;
213
+ }, z.core.$strict>>;
214
+ grandTotal: z.ZodNullable<z.ZodObject<{
215
+ currency: z.ZodLiteral<"EUR">;
216
+ minorUnits: z.ZodNumber;
217
+ }, z.core.$strict>>;
218
+ }, z.core.$strict>;
139
219
  }, z.core.$strict>;
140
220
  export type Cart = z.infer<typeof CartSchema>;
141
221
  export declare const OrderLineSchema: z.ZodObject<{
@@ -308,6 +388,18 @@ export declare const ProductDetailsSchema: z.ZodObject<{
308
388
  piece: "piece";
309
389
  }>;
310
390
  }, z.core.$strict>>;
391
+ recommendedPrice: z.ZodNullable<z.ZodObject<{
392
+ amount: z.ZodObject<{
393
+ currency: z.ZodLiteral<"EUR">;
394
+ minorUnits: z.ZodNumber;
395
+ }, z.core.$strict>;
396
+ per: z.ZodEnum<{
397
+ item: "item";
398
+ kg: "kg";
399
+ l: "l";
400
+ piece: "piece";
401
+ }>;
402
+ }, z.core.$strict>>;
311
403
  availability: z.ZodEnum<{
312
404
  available: "available";
313
405
  unavailable: "unavailable";
@@ -1,7 +1,7 @@
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, PriceTagSchema, QuotedMoneySchema } from './money.js';
4
+ import { MoneySchema, PriceTagSchema } 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),
@@ -15,19 +15,40 @@ export const ProductSchema = FavoriteProductSchema.safeExtend({
15
15
  secondaryPrice: PriceTagSchema.nullable(),
16
16
  /** Pack size as displayed, such as `940 gr`, `2 x 52 gr` or `1,2 kg (aprox.)`. */
17
17
  packageText: z.string().nullable(),
18
+ /** The recommended retail price (PVPR) shown beside a promotional price; null when the website shows none. */
19
+ recommendedPrice: PriceTagSchema.nullable(),
18
20
  minimumText: z.string().nullable(),
19
21
  });
20
22
  export const CartItemSchema = z.strictObject({
21
23
  productId: ProductIdSchema,
22
24
  cartItemId: CartItemIdSchema.nullable(),
23
25
  name: z.string().trim().min(1),
26
+ /** `bag` for the shopping bags the website adds to the cart on its own; `product` for everything else. */
27
+ kind: z.enum(['product', 'bag']),
24
28
  quantity: ObservedQuantitySchema,
25
- price: QuotedMoneySchema,
29
+ /** Unit price with what it is for: per item, or per kg for products sold by the piece but priced by weight. */
30
+ price: PriceTagSchema.nullable(),
31
+ /** The line's total as the website reports it. */
32
+ lineTotal: MoneySchema.nullable(),
33
+ });
34
+ /** Each amount as the website reports it, or null when it reports none. */
35
+ export const CartTotalsSchema = z.strictObject({
36
+ /** Product lines only, without bags or deposit. */
37
+ products: MoneySchema.nullable(),
38
+ /** The bag lines. */
39
+ bags: MoneySchema.nullable(),
40
+ /** Bottle and can deposit (SDR). */
41
+ deposit: MoneySchema.nullable(),
42
+ delivery: MoneySchema.nullable(),
43
+ /** Amount taken off by order and delivery discounts. */
44
+ discounts: MoneySchema.nullable(),
45
+ /** The amount to pay as the website shows it; it includes the bags. */
46
+ grandTotal: MoneySchema.nullable(),
26
47
  });
27
48
  export const CartSchema = z.strictObject({
28
49
  customerAuthenticated: z.boolean(),
29
50
  items: z.array(CartItemSchema),
30
- total: MoneySchema.nullable(),
51
+ totals: CartTotalsSchema,
31
52
  });
32
53
  export const OrderLineSchema = z.strictObject({
33
54
  productId: ProductIdSchema.nullable(),
@@ -63,7 +84,7 @@ export const FavoritesCacheSchema = z.strictObject({
63
84
  });
64
85
  export const ProductDetailsSchema = ProductReferenceSchema.safeExtend({
65
86
  name: productDetails.name, url: productDetails.url, price: PriceTagSchema.nullable(),
66
- secondaryPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
87
+ secondaryPrice: PriceTagSchema.nullable(), recommendedPrice: PriceTagSchema.nullable(), availability: z.enum(['available', 'unavailable', 'unknown']),
67
88
  saleRules: z.discriminatedUnion('status', [
68
89
  z.strictObject({ status: z.literal('available'), rules: ProductSaleRulesSchema }),
69
90
  z.strictObject({ status: z.literal('unavailable'), error: ToolErrorSchema }),
@@ -6,19 +6,6 @@ export declare const MoneySchema: z.ZodObject<{
6
6
  minorUnits: z.ZodNumber;
7
7
  }, z.core.$strict>;
8
8
  export type Money = z.infer<typeof MoneySchema>;
9
- /** The source of a price determines its basis; a line total is not a unit price. */
10
- export declare const QuotedMoneySchema: z.ZodObject<{
11
- amount: z.ZodNullable<z.ZodObject<{
12
- currency: z.ZodLiteral<"EUR">;
13
- minorUnits: z.ZodNumber;
14
- }, z.core.$strict>>;
15
- basis: z.ZodEnum<{
16
- line: "line";
17
- unit: "unit";
18
- unknown: "unknown";
19
- }>;
20
- }, z.core.$strict>;
21
- export type QuotedMoney = z.infer<typeof QuotedMoneySchema>;
22
9
  /** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
23
10
  export declare function parseMoneyAmount(input: unknown): Result<Money | null, ToolError>;
24
11
  /**
@@ -4,11 +4,6 @@ export const MoneySchema = z.strictObject({
4
4
  currency: z.literal('EUR'),
5
5
  minorUnits: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
6
6
  });
7
- /** The source of a price determines its basis; a line total is not a unit price. */
8
- export const QuotedMoneySchema = z.strictObject({
9
- amount: MoneySchema.nullable(),
10
- basis: z.enum(['unit', 'line', 'unknown']),
11
- });
12
7
  /** Convert decimal text through integer arithmetic; never round an unrecognized precision. */
13
8
  export function parseMoneyAmount(input) {
14
9
  if (input === null || input === undefined || input === '')
@@ -230,6 +230,18 @@ export declare const SearchData: z.ZodObject<{
230
230
  }>;
231
231
  }, z.core.$strict>>;
232
232
  packageText: z.ZodNullable<z.ZodString>;
233
+ recommendedPrice: z.ZodNullable<z.ZodObject<{
234
+ amount: z.ZodObject<{
235
+ currency: z.ZodLiteral<"EUR">;
236
+ minorUnits: z.ZodNumber;
237
+ }, z.core.$strict>;
238
+ per: z.ZodEnum<{
239
+ item: "item";
240
+ kg: "kg";
241
+ l: "l";
242
+ piece: "piece";
243
+ }>;
244
+ }, z.core.$strict>>;
233
245
  minimumText: z.ZodNullable<z.ZodString>;
234
246
  favorite: z.ZodBoolean;
235
247
  }, z.core.$strict>>;
@@ -550,6 +562,18 @@ export declare const SearchOutput: z.ZodObject<{
550
562
  }>;
551
563
  }, z.core.$strict>>;
552
564
  packageText: z.ZodNullable<z.ZodString>;
565
+ recommendedPrice: z.ZodNullable<z.ZodObject<{
566
+ amount: z.ZodObject<{
567
+ currency: z.ZodLiteral<"EUR">;
568
+ minorUnits: z.ZodNumber;
569
+ }, z.core.$strict>;
570
+ per: z.ZodEnum<{
571
+ item: "item";
572
+ kg: "kg";
573
+ l: "l";
574
+ piece: "piece";
575
+ }>;
576
+ }, z.core.$strict>>;
553
577
  minimumText: z.ZodNullable<z.ZodString>;
554
578
  favorite: z.ZodBoolean;
555
579
  }, z.core.$strict>>;
@@ -731,6 +755,10 @@ export declare const CartOutput: z.ZodObject<{
731
755
  productId: z.core.$ZodBranded<z.ZodString, "ProductId", "out">;
732
756
  cartItemId: z.ZodNullable<z.core.$ZodBranded<z.ZodString, "CartItemId", "out">>;
733
757
  name: z.ZodString;
758
+ kind: z.ZodEnum<{
759
+ bag: "bag";
760
+ product: "product";
761
+ }>;
734
762
  quantity: z.ZodDiscriminatedUnion<[z.ZodObject<{
735
763
  status: z.ZodLiteral<"known">;
736
764
  quantity: z.ZodObject<{
@@ -754,22 +782,49 @@ export declare const CartOutput: z.ZodObject<{
754
782
  unsupported_unit: "unsupported_unit";
755
783
  }>>;
756
784
  }, z.core.$strict>], "status">;
757
- price: z.ZodObject<{
758
- amount: z.ZodNullable<z.ZodObject<{
785
+ price: z.ZodNullable<z.ZodObject<{
786
+ amount: z.ZodObject<{
759
787
  currency: z.ZodLiteral<"EUR">;
760
788
  minorUnits: z.ZodNumber;
761
- }, z.core.$strict>>;
762
- basis: z.ZodEnum<{
763
- line: "line";
764
- unit: "unit";
765
- unknown: "unknown";
789
+ }, z.core.$strict>;
790
+ per: z.ZodEnum<{
791
+ item: "item";
792
+ kg: "kg";
793
+ l: "l";
794
+ piece: "piece";
766
795
  }>;
767
- }, z.core.$strict>;
768
- }, z.core.$strict>>;
769
- total: z.ZodNullable<z.ZodObject<{
770
- currency: z.ZodLiteral<"EUR">;
771
- minorUnits: z.ZodNumber;
796
+ }, z.core.$strict>>;
797
+ lineTotal: z.ZodNullable<z.ZodObject<{
798
+ currency: z.ZodLiteral<"EUR">;
799
+ minorUnits: z.ZodNumber;
800
+ }, z.core.$strict>>;
772
801
  }, z.core.$strict>>;
802
+ totals: z.ZodObject<{
803
+ products: z.ZodNullable<z.ZodObject<{
804
+ currency: z.ZodLiteral<"EUR">;
805
+ minorUnits: z.ZodNumber;
806
+ }, z.core.$strict>>;
807
+ bags: z.ZodNullable<z.ZodObject<{
808
+ currency: z.ZodLiteral<"EUR">;
809
+ minorUnits: z.ZodNumber;
810
+ }, z.core.$strict>>;
811
+ deposit: z.ZodNullable<z.ZodObject<{
812
+ currency: z.ZodLiteral<"EUR">;
813
+ minorUnits: z.ZodNumber;
814
+ }, z.core.$strict>>;
815
+ delivery: z.ZodNullable<z.ZodObject<{
816
+ currency: z.ZodLiteral<"EUR">;
817
+ minorUnits: z.ZodNumber;
818
+ }, z.core.$strict>>;
819
+ discounts: z.ZodNullable<z.ZodObject<{
820
+ currency: z.ZodLiteral<"EUR">;
821
+ minorUnits: z.ZodNumber;
822
+ }, z.core.$strict>>;
823
+ grandTotal: z.ZodNullable<z.ZodObject<{
824
+ currency: z.ZodLiteral<"EUR">;
825
+ minorUnits: z.ZodNumber;
826
+ }, z.core.$strict>>;
827
+ }, z.core.$strict>;
773
828
  }, z.core.$strict>;
774
829
  }, z.core.$strict>, z.ZodObject<{
775
830
  status: z.ZodLiteral<"error">;
@@ -1442,6 +1497,18 @@ export declare const ProductDetailsOutput: z.ZodObject<{
1442
1497
  piece: "piece";
1443
1498
  }>;
1444
1499
  }, z.core.$strict>>;
1500
+ recommendedPrice: z.ZodNullable<z.ZodObject<{
1501
+ amount: z.ZodObject<{
1502
+ currency: z.ZodLiteral<"EUR">;
1503
+ minorUnits: z.ZodNumber;
1504
+ }, z.core.$strict>;
1505
+ per: z.ZodEnum<{
1506
+ item: "item";
1507
+ kg: "kg";
1508
+ l: "l";
1509
+ piece: "piece";
1510
+ }>;
1511
+ }, z.core.$strict>>;
1445
1512
  availability: z.ZodEnum<{
1446
1513
  available: "available";
1447
1514
  unavailable: "unavailable";
@@ -1,5 +1,5 @@
1
1
  import type { PromptHandler } from './server.js';
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).";
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. The amount to pay is `get_cart`'s `totals.grandTotal`, which includes the bags the website adds.\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
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
@@ -9,7 +9,7 @@ Turn the user's shopping intent into a product list or a verified basket using t
9
9
  1. 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.
10
10
  2. 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.
11
11
  3. 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.
12
- 4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer \`minorUnits\` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct.
12
+ 4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer \`minorUnits\` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct. The amount to pay is \`get_cart\`'s \`totals.grandTotal\`, which includes the bags the website adds.
13
13
 
14
14
  Searches read a bounded number of result pages; respect \`completeness\` and \`failures\` and do not claim a search was exhaustive.
15
15
 
@@ -23,8 +23,6 @@ const observed = (quantity) => quantity.status === 'known' ? amount(quantity.qua
23
23
  : quantity.value === null ? 'quantity unknown' : `${quantity.value} (unit unknown)`;
24
24
  const PER = { item: '', piece: ' per piece', kg: '/kg', l: '/l' };
25
25
  const priced = (price) => price === null ? 'price unknown' : `${euros(price.amount)}${PER[price.per]}`;
26
- const quoted = (price) => price.amount === null ? 'price unavailable'
27
- : `${euros(price.amount)}${price.basis === 'unit' ? ' each' : price.basis === 'line' ? ' line total' : ''}`;
28
26
  const numbered = (lines) => lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
29
27
  function response(data, text, status = 'success') {
30
28
  return { structuredContent: { result: { status, data } }, content: [{ type: 'text', text }] };
@@ -39,7 +37,8 @@ function errorResult(error, mutation) {
39
37
  }
40
38
  export function formatProductSearchResults(products) {
41
39
  return numbered(products.map(product => `${product.name}${product.favorite ? ' ⭐ favorite' : ''} — ${priced(product.price)}` +
42
- `${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.packageText ? `, pack ${product.packageText}` : ''}` +
40
+ `${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}${product.recommendedPrice ? `, PVPR ${priced(product.recommendedPrice)}` : ''}` +
41
+ `${product.packageText ? `, pack ${product.packageText}` : ''}` +
43
42
  `${product.minimumText ? `, minimum ${product.minimumText}` : ''}\n productId: ${product.productId}`));
44
43
  }
45
44
  function formatSaleRules(rules) {
@@ -49,7 +48,8 @@ function formatSaleRules(rules) {
49
48
  }
50
49
  function formatProduct(product) {
51
50
  return [`${product.name}`, `productId: ${product.productId}`,
52
- `Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}`,
51
+ `Price: ${priced(product.price)}${product.secondaryPrice ? ` (${priced(product.secondaryPrice)})` : ''}` +
52
+ `${product.recommendedPrice ? `, PVPR ${priced(product.recommendedPrice)}` : ''}`,
53
53
  `Availability: ${product.availability}`,
54
54
  product.saleRules.status === 'available' ? `Sale rules: ${formatSaleRules(product.saleRules.rules)}`
55
55
  : `Sale rules unavailable: ${product.saleRules.error.message}`, `URL: ${product.url}`].join('\n');
@@ -135,10 +135,16 @@ export class ShoppingHandlers {
135
135
  const cart = result.value;
136
136
  if (!cart.items.length)
137
137
  return response(cart, 'The cart is empty.', 'empty');
138
- const list = numbered(cart.items.map(item => `${item.name} — ${observed(item.quantity)} × ${quoted(item.price)}` +
138
+ const list = numbered(cart.items.map(item => `${item.name}${item.kind === 'bag' ? ' (bag)' : ''} — ${observed(item.quantity)} × ${priced(item.price)}` +
139
+ `${item.lineTotal ? `, line ${euros(item.lineTotal)}` : ''}` +
139
140
  `\n productId: ${item.productId}${item.cartItemId ? `, cartItemId: ${item.cartItemId}` : ''}`));
140
- return response(cart, `Cart (${cart.items.length} lines):\n\n${list}\n\nTotal: ${cart.total === null ? 'unavailable' : euros(cart.total)}` +
141
- `\nCheckout happens on the website: ${CHECKOUT_URL}`);
141
+ const { totals } = cart;
142
+ const optional = [['Deposit', totals.deposit], ['Delivery', totals.delivery], ['Discounts', totals.discounts]]
143
+ .filter(([, value]) => value !== null && value.minorUnits > 0).map(([label, value]) => `${label}: ${euros(value)}`);
144
+ const summary = [`Products: ${totals.products === null ? 'unavailable' : euros(totals.products)}`,
145
+ ...(totals.bags !== null && totals.bags.minorUnits > 0 ? [`Bags: ${euros(totals.bags)}`] : []), ...optional,
146
+ `Total to pay: ${totals.grandTotal === null ? 'unavailable' : euros(totals.grandTotal)}`].join('\n');
147
+ return response(cart, `Cart (${cart.items.length} lines):\n\n${list}\n\n${summary}\nCheckout happens on the website: ${CHECKOUT_URL}`);
142
148
  }
143
149
  async mutation(result, operation) {
144
150
  if (!result.ok)
@@ -225,7 +231,9 @@ export function registerShoppingTools(services, update = NO_UPDATE_INFO) {
225
231
  'Check this before choosing a quantity for weighted or bulk products. No login needed.', contracts.GetProductInput, contracts.ProductDetailsOutput, READ, args => handlers.getProduct(args.productId)),
226
232
  tool('list_favorites', 'List favorites', 'List the products saved as favorites on the account. Uses a local snapshot that is refreshed automatically when missing; ' +
227
233
  'snapshots older than 24 hours are marked stale. Pass refresh: true to fetch the current list from the website.', contracts.FavoritesInput, contracts.FavoritesOutput, READ, args => handlers.listFavorites(args.refresh)),
228
- tool('get_cart', 'Get cart', 'Read the shopping cart: each line with productId, cartItemId, quantity and price, plus the total. ' +
234
+ tool('get_cart', 'Get cart', 'Read the shopping cart: each line with productId, cartItemId, kind (product or bag), quantity, unit price (per item or per kg) ' +
235
+ 'and lineTotal, plus the totals ' +
236
+ '(products, bags, deposit, delivery, discounts and grandTotal, the amount to pay). ' +
229
237
  'Use it to check current quantities before changing them and to verify any uncertain change.', contracts.EmptyInput, contracts.CartOutput, READ, () => handlers.getCart()),
230
238
  tool('add_cart_item', 'Add to cart', 'Add an amount of a product to the cart, on top of any quantity already there. quantity is in the product sale unit (default 1). ' +
231
239
  `Use set_cart_item_quantity instead when the user wants a specific total. ${UNCERTAIN_WRITE}`, contracts.AddCartItemInput, contracts.MutationOutput, ADD, args => handlers.addCartItem(args.productId, args.quantity, args.cartItemId), true),
@@ -1,6 +1,6 @@
1
1
  import { load } from 'cheerio';
2
2
  import { z } from 'zod';
3
- import { CartSchema, ProductSaleRulesSchema, failure, parseMoneyAmount, parseProductReference, parseValue, success } from '../domain/index.js';
3
+ import { CartSchema, ProductSaleRulesSchema, QuantityUnitSchema, failure, parseMoneyAmount, parseProductReference, parseValue, success } from '../domain/index.js';
4
4
  import { field, record } from './records.js';
5
5
  import { storefrontUrl } from './catalog.js';
6
6
  const unrecognizedCart = () => failure('invalid_data', 'The cart response was not recognised');
@@ -20,31 +20,76 @@ function cartLines(input) {
20
20
  const basket = record(field(input, 'basket'));
21
21
  return field(input, 'action') === 'Cart-MiniCartShow' && basket && Object.keys(basket).length === 0 ? [] : null;
22
22
  }
23
- function cartLine(line) {
23
+ const PRICE_PER = { kg: 'kg', l: 'l', lt: 'l' };
24
+ /**
25
+ * A line's units, from its own sale rules. Products sold by the piece but priced per kg count pieces
26
+ * and quote a price per kg; everything else counts and prices units. The line's `selectedunit` and
27
+ * `selectedDimension` do not follow the quantity, so they are not used.
28
+ */
29
+ function lineUnits(line) {
30
+ const rates = record(field(line, 'measurementInfo', 'quantityConversionRates'));
31
+ if (!rates)
32
+ return undefined;
33
+ if (rates.hasAlternativeSaleUnit !== true && rates.hasConversionRate !== true) {
34
+ return rates.primaryunit === 'un' ? { quantity: 'un', price: 'item' } : { quantity: null, price: null };
35
+ }
36
+ const unit = QuantityUnitSchema.safeParse(rates.secondaryunit);
37
+ return { quantity: unit.success ? unit.data : null, price: PRICE_PER[String(rates.primaryunit)] ?? null };
38
+ }
39
+ function cartLine(line, bag) {
24
40
  const reference = parseProductReference(field(line, 'id'));
25
- // Weighted lines report the amount in the displayed sale unit as secondaryQuantity.
26
41
  const reported = field(line, 'secondaryQuantity') ?? field(line, 'quantity');
27
42
  const rawQuantity = typeof reported === 'string' && reported.trim() !== '' ? Number(reported) : reported;
28
- const unitPrice = field(line, 'price', 'sales', 'value');
29
- const lineTotal = field(line, 'priceTotal', 'basePriceValue');
30
- const price = parseMoneyAmount(unitPrice ?? lineTotal);
31
- if (!reference.ok || typeof rawQuantity !== 'number' || !Number.isFinite(rawQuantity) || rawQuantity < 0 || !price.ok)
43
+ const unitPrice = parseMoneyAmount(field(line, 'price', 'sales', 'value'));
44
+ const lineTotal = parseMoneyAmount(field(line, 'priceTotal', 'value'));
45
+ if (!reference.ok || typeof rawQuantity !== 'number' || !Number.isFinite(rawQuantity) || rawQuantity < 0 || !unitPrice.ok || !lineTotal.ok)
32
46
  return unrecognizedCart();
47
+ const units = lineUnits(line);
33
48
  return parseValue(CartSchema.shape.items.element, {
34
49
  productId: reference.value.productId,
35
50
  cartItemId: field(line, 'UUID') ?? field(line, 'uuid') ?? null,
36
51
  name: field(line, 'productName'),
37
- quantity: { status: 'unknown', value: rawQuantity, rawText: null },
38
- price: { amount: price.value, basis: unitPrice != null ? 'unit' : lineTotal != null ? 'line' : 'unknown' },
52
+ kind: bag !== null && reference.value.productId === bag ? 'bag' : 'product',
53
+ quantity: units?.quantity ? { status: 'known', quantity: { value: rawQuantity, unit: units.quantity } }
54
+ : { status: 'unknown', value: rawQuantity, rawText: null, ...(units ? { reason: 'unsupported_unit' } : {}) },
55
+ price: unitPrice.value && units?.price ? { amount: unitPrice.value, per: units.price } : null,
56
+ lineTotal: lineTotal.value,
39
57
  }, 'cart line');
40
58
  }
59
+ const money = (value) => { const parsed = parseMoneyAmount(value); return parsed.ok ? parsed.value : null; };
60
+ const euro = (minorUnits) => ({ currency: 'EUR', minorUnits });
61
+ /** Mini-cart totals. An amount the website does not report, or reports unreadably, stays null. */
62
+ function cartTotals(input, lines, bag) {
63
+ const totals = field(input, 'basket', 'totals');
64
+ if (lines.length === 0 && field(input, 'action') === 'Cart-MiniCartShow' && !record(totals)) {
65
+ return { products: euro(0), bags: euro(0), deposit: euro(0), delivery: null, discounts: euro(0), grandTotal: euro(0) };
66
+ }
67
+ const products = money(field(input, 'cart', 'totalProductsValueNumber') ?? field(totals, 'productsTotalPriceOnly'));
68
+ const withDeposit = money(field(totals, 'productsTotalPriceOnlyWithSDR'));
69
+ const bagLines = bag === null ? null : lines.filter(line => { const reference = parseProductReference(field(line, 'id')); return reference.ok && reference.value.productId === bag; })
70
+ .map(line => money(field(line, 'priceTotal', 'value')));
71
+ const discountParts = [field(totals, 'orderLevelDiscountTotal', 'value'), field(totals, 'shippingLevelDiscountTotal', 'value')]
72
+ .filter(value => value != null).map(value => typeof value === 'number' ? money(Math.abs(value)) : null);
73
+ const sum = (amounts) => amounts && amounts.every(amount => amount !== null)
74
+ ? euro(amounts.reduce((total, amount) => total + amount.minorUnits, 0)) : null;
75
+ return {
76
+ products,
77
+ bags: sum(bagLines),
78
+ deposit: products && withDeposit && withDeposit.minorUnits >= products.minorUnits ? euro(withDeposit.minorUnits - products.minorUnits) : null,
79
+ delivery: money(field(totals, 'totalShippingCostNumber')),
80
+ discounts: discountParts.length ? sum(discountParts) : null,
81
+ grandTotal: money(field(totals, 'grandTotalNumber')),
82
+ };
83
+ }
41
84
  export function parseCartResponse(input) {
42
85
  const lines = cartLines(input);
43
86
  if (!lines)
44
87
  return unrecognizedCart();
88
+ const bagReference = parseProductReference(field(input, 'basket', 'resources', 'plasticBagID'));
89
+ const bag = bagReference.ok ? bagReference.value.productId : null;
45
90
  const items = [];
46
91
  for (const line of lines) {
47
- const item = cartLine(line);
92
+ const item = cartLine(line, bag);
48
93
  if (!item.ok)
49
94
  return item;
50
95
  items.push(item.value);
@@ -53,11 +98,7 @@ export function parseCartResponse(input) {
53
98
  if (new Set(lineIds).size !== lineIds.length)
54
99
  return failure('invalid_data', 'The cart contains duplicate line identifiers');
55
100
  const customerAuthenticated = field(input, 'resources', 'customerAuthenticated') === true;
56
- const total = lines.length === 0 && field(input, 'action') === 'Cart-MiniCartShow'
57
- ? success({ currency: 'EUR', minorUnits: 0 })
58
- : parseMoneyAmount(field(input, 'cart', 'totalProductsValueNumber') ?? field(input, 'basket', 'totals', 'productsTotalPriceOnly') ??
59
- field(input, 'basket', 'totals', 'productsTotalPriceOnlyWithSDR'));
60
- return total.ok ? success({ customerAuthenticated, items, total: total.value }) : total;
101
+ return success({ customerAuthenticated, items, totals: cartTotals(input, lines, bag) });
61
102
  }
62
103
  const amount = z.union([z.number(), z.string().regex(/^\d+(?:\.\d+)?$/).transform(Number)]).pipe(z.number().finite().nonnegative());
63
104
  const MeasureOptionsSchema = z.object({
@@ -77,6 +118,9 @@ const MEASURES = {
77
118
  l: { dimension: 'volume', grams: 1000 }, ml: { dimension: 'volume', grams: 1 },
78
119
  };
79
120
  const unknownRules = (message) => failure('sale_rules_unknown', message);
121
+ /** Storefront amount converted to sale units, without binary noise such as 98.99999999999999. */
122
+ const inSaleUnits = (value, factor) => Number((value / factor).toPrecision(12));
123
+ const isWholeMultiple = (value, step) => { const ratio = value / step; return Math.abs(ratio - Math.round(ratio)) < 1e-6; };
80
124
  /** Interpret the storefront's `data-measure-options`. Anything ambiguous is rejected rather than guessed. */
81
125
  export function parseSaleRules(input) {
82
126
  let raw = input;
@@ -106,21 +150,25 @@ export function parseSaleRules(input) {
106
150
  if (sale && storefront && (sale.dimension !== storefront.dimension || Math.abs(factor - sale.grams / storefront.grams) > 1e-12)) {
107
151
  return unknownRules('The unit conversion contradicts the declared units');
108
152
  }
109
- // The maximum's unit is not stated for alternative-unit products, so it cannot be applied safely.
110
- if (options.maxNumberOfUnitsPerSale != null)
111
- return unknownRules('The maximum quantity has no established unit for this product');
112
153
  }
113
154
  else if (options.primaryunit !== 'un' || (options.selectedunit != null && options.selectedunit !== 'primary')) {
114
155
  return unknownRules('Only unit sales and declared alternative units are supported');
115
156
  }
116
157
  const step = options.stepQuantity ?? (saleUnit === 'un' ? factor : null);
158
+ const max = options.maxNumberOfUnitsPerSale;
159
+ // Minimum, step and maximum are all in the storefront unit (kg for weighted products): the site's
160
+ // own quantity control compares one quantity against all three and snaps the maximum to the step.
161
+ // A maximum that is not a whole number of steps is not a shape we have seen, so refuse it.
162
+ if (alternativeUnit && max != null && step && !isWholeMultiple(max, step)) {
163
+ return unknownRules('The maximum quantity is not a whole number of steps');
164
+ }
117
165
  if (!step || !milliquantity(step).ok)
118
166
  return unknownRules('A positive step with at most three decimals is required');
119
167
  const unit = saleUnit;
120
- const minimum = options.minOrderQuantity ? { value: options.minOrderQuantity / factor, unit } : null;
121
- const maximum = options.maxNumberOfUnitsPerSale != null ? { value: options.maxNumberOfUnitsPerSale, unit } : null;
168
+ const minimum = options.minOrderQuantity ? { value: inSaleUnits(options.minOrderQuantity, factor), unit } : null;
169
+ const maximum = max != null ? { value: inSaleUnits(max, factor), unit } : null;
122
170
  const rules = ProductSaleRulesSchema.safeParse({ saleUnit, storefrontUnit: alternativeUnit ? options.primaryunit : 'un',
123
- storefrontUnitsPerSaleUnit: factor, minimum, maximum, step: { value: step / factor, unit } });
171
+ storefrontUnitsPerSaleUnit: factor, minimum, maximum, step: { value: inSaleUnits(step, factor), unit } });
124
172
  if (!rules.success)
125
173
  return unknownRules('Sale rules use an unsupported unit or contradictory bounds');
126
174
  return success({ rules: rules.data, alternativeUnit, storefrontStep: String(step), updateDimension: options.secondaryunit || options.primaryunit });
@@ -13,6 +13,8 @@ export declare function productReferenceFromUrl(href: string): Result<{
13
13
  code: string;
14
14
  }[] | undefined;
15
15
  }>;
16
+ /** The amount in a "PVPR 6,00€" label, the recommended retail price shown beside a promotional price. */
17
+ export declare function recommendedPriceLabel(label: string): string;
16
18
  export interface SearchPage {
17
19
  products: Product[];
18
20
  /** One entry per result that was skipped or kept with missing data. */
@@ -31,6 +31,10 @@ function minimumLabel(label) {
31
31
  function packageLabel(label) {
32
32
  return text(/^emb\.\s*(.+)$/i.exec(text(label))?.[1] ?? '') || null;
33
33
  }
34
+ /** The amount in a "PVPR 6,00€" label, the recommended retail price shown beside a promotional price. */
35
+ export function recommendedPriceLabel(label) {
36
+ return text(label).replace(/^PVPR\s*/i, '');
37
+ }
34
38
  /** An unrecognised price is reported, not fatal: the product stays usable with an unknown price. */
35
39
  function priceOrNull(label) {
36
40
  const parsed = parsePriceTag(label);
@@ -60,11 +64,15 @@ export function parseProductSearchPage(html) {
60
64
  }
61
65
  const main = priceOrNull(text(card.find('.pwc-tile--price-primary').first().text()));
62
66
  const secondary = priceOrNull(text(card.find('.pwc-tile--price-secondary').first().text()));
67
+ const recommended = priceOrNull(recommendedPriceLabel(card.find('.pvpr-info').first().parent().text()));
63
68
  if (!main.recognised)
64
69
  problem(`${position} (product ${reference.value.productId}) has a price in an unrecognised format; its price is unknown.`);
70
+ if (!recommended.recognised)
71
+ problem(`${position} (product ${reference.value.productId}) has a recommended price (PVPR) in an unrecognised format; it is unknown.`);
65
72
  const quantity = card.find('.pwc-tile--quantity').first().text();
66
73
  const product = parseValue(ProductSchema, { ...reference.value, name, url: storefrontUrl(href), price: main.price,
67
- secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity) }, 'product');
74
+ secondaryPrice: secondary.price, packageText: packageLabel(quantity), minimumText: minimumLabel(quantity),
75
+ recommendedPrice: recommended.price }, 'product');
68
76
  if (product.ok)
69
77
  products.push(product.value);
70
78
  else
@@ -13,6 +13,8 @@ export interface OrderHistory {
13
13
  totalCount: number | null;
14
14
  }
15
15
  export declare function orderHistoryUrl(value: string): string | null;
16
+ /** "2 outubro 2026", "2 de outubro de 2026" or the abbreviated "01 Set 26"; anything else stays unparsed. */
17
+ export declare function normalizedDate(raw: string): string | null;
16
18
  export declare function parseOrderHistory(html: string): Result<OrderHistory, ToolError>;
17
19
  export declare function parseOrderDetail(html: string, expected: Order): Result<OrderLine[], ToolError>;
18
20
  export declare function parseSingleOrder(html: string, orderId: Order['orderId']): Result<Order, ToolError>;
@@ -51,13 +51,16 @@ export function orderHistoryUrl(value) {
51
51
  const href = storefrontUrl(value);
52
52
  return href && new URL(href).pathname === '/conta/encomendas/' ? href : null;
53
53
  }
54
- function normalizedDate(raw) {
55
- const match = raw.trim().toLowerCase().match(/^(\d{1,2})\s+(?:de\s+)?([a-zç]+)\s+(?:de\s+)?(\d{4})$/);
54
+ const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
55
+ /** "2 outubro 2026", "2 de outubro de 2026" or the abbreviated "01 Set 26"; anything else stays unparsed. */
56
+ export function normalizedDate(raw) {
57
+ const match = raw.trim().toLowerCase().match(/^(\d{1,2})\s+(?:de\s+)?([a-zç]+)\.?\s+(?:de\s+)?(\d{4}|\d{2})$/);
56
58
  if (!match)
57
59
  return null;
58
- const month = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'].indexOf(match[2]);
60
+ const name = match[2];
61
+ const month = MONTHS.findIndex(full => full === name || (name.length === 3 && full.startsWith(name)));
59
62
  const day = Number(match[1]);
60
- const year = Number(match[3]);
63
+ const year = match[3].length === 2 ? 2000 + Number(match[3]) : Number(match[3]);
61
64
  const date = new Date(Date.UTC(year, month, day));
62
65
  return month >= 0 && date.getUTCFullYear() === year && date.getUTCMonth() === month && date.getUTCDate() === day ? date.toISOString().slice(0, 10) : null;
63
66
  }
@@ -2,7 +2,7 @@ import { load } from 'cheerio';
2
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
- import { productReferenceFromUrl, storefrontUrl } from './catalog.js';
5
+ import { productReferenceFromUrl, recommendedPriceLabel, storefrontUrl } from './catalog.js';
6
6
  import { record } from './records.js';
7
7
  /** Scope every source to the requested product; recommendations cannot supply missing facts. */
8
8
  export function parseProductDetails(html, sourceUrl, expectedId) {
@@ -34,10 +34,13 @@ export function parseProductDetails(html, sourceUrl, expectedId) {
34
34
  const parsed = parsePriceTag($(`.ct-pdp--prices ${selector}`).first().text());
35
35
  return parsed.ok ? parsed.value : null;
36
36
  };
37
+ // Only the product's own price block: recommendation tiles on the page carry their own PVPR.
38
+ const recommended = parsePriceTag(recommendedPriceLabel($('.ct-pdp--prices .pvpr-info').first().parent().text()));
37
39
  const product = {
38
40
  ...reference.value, name, url,
39
41
  price: priceTag('.pwc-tile--price-primary'),
40
42
  secondaryPrice: priceTag('.pwc-tile--price-secondary'),
43
+ recommendedPrice: recommended.ok ? recommended.value : null,
41
44
  availability: availability === 'https://schema.org/InStock' ? 'available' : ['https://schema.org/OutOfStock', 'https://schema.org/Discontinued'].includes(availability) ? 'unavailable' : 'unknown',
42
45
  saleRules: rules.ok ? { status: 'available', rules: rules.value.rules } : { status: 'unavailable', error: rules.error },
43
46
  };
@@ -63,7 +63,10 @@ export class CartService {
63
63
  const cart = await this.getCart();
64
64
  const actual = cart.ok ? observedCartQuantity(cart.value, productId, cartItemId) : null;
65
65
  const selected = cart.ok ? selectCartItem(cart.value, productId, cartItemId) : null;
66
- if (!cart.ok || actual === null || Math.abs(actual - expected) > 0.0005 || (requireAbsence && (!selected?.ok || selected.value !== null))) {
66
+ // A line that reports its unit must report the requested one; an amount in another unit is not a confirmation.
67
+ const line = selected?.ok ? selected.value : null;
68
+ const otherUnit = line?.quantity.status === 'known' && line.quantity.quantity.unit !== requested.unit;
69
+ if (!cart.ok || actual === null || otherUnit || Math.abs(actual - expected) > 0.0005 || (requireAbsence && (!selected?.ok || selected.value !== null))) {
67
70
  return this.unknown(productId, requested, failure('cart_change_unconfirmed').error);
68
71
  }
69
72
  const confirmed = { outcome: 'confirmed', productId, requested,
@@ -50,6 +50,13 @@ export declare class CatalogService {
50
50
  };
51
51
  per: "item" | "kg" | "l" | "piece";
52
52
  } | null;
53
+ recommendedPrice: {
54
+ amount: {
55
+ currency: "EUR";
56
+ minorUnits: number;
57
+ };
58
+ per: "item" | "kg" | "l" | "piece";
59
+ } | null;
53
60
  availability: "available" | "unavailable" | "unknown";
54
61
  saleRules: {
55
62
  status: "available";
@@ -50,15 +50,19 @@ export async function checkForUpdate(options) {
50
50
  cached = CacheSchema.parse(JSON.parse(await readFile(file, 'utf8')));
51
51
  }
52
52
  catch { /* no usable cache */ }
53
- let latest = cached?.latestVersion ?? null;
53
+ // An answer older than the running version predates an upgrade, so it is checked again.
54
+ const outdated = cached !== null && isNewerRelease(options.currentVersion, cached.latestVersion);
55
+ let latest = cached && !outdated ? cached.latestVersion : null;
54
56
  const age = cached ? now.getTime() - Date.parse(cached.checkedAt) : Infinity;
55
- if (!(age >= 0 && age < UPDATE_CHECK_INTERVAL_MS)) {
57
+ if (outdated || !(age >= 0 && age < UPDATE_CHECK_INTERVAL_MS)) {
56
58
  try {
57
59
  latest = await withTimeout(signal => (options.fetchLatest ?? registryLatest)(options.packageName, signal), options.timeoutMs ?? 1500);
58
60
  await writePrivateJson(file, { checkedAt: now.toISOString(), latestVersion: latest }).catch(() => { });
59
61
  }
60
62
  catch { /* offline or slow: keep the last known answer */ }
61
63
  }
64
+ if (latest !== null && isNewerRelease(options.currentVersion, latest))
65
+ latest = null;
62
66
  return { currentVersion: options.currentVersion, latestVersion: latest, available: latest !== null && isNewerRelease(latest, options.currentVersion) };
63
67
  }
64
68
  /** A line for the assistant's instructions, or null when there is nothing to say. */
package/docs/tools.md CHANGED
@@ -57,8 +57,8 @@ calls, for clients that do not pass structured content to the model.
57
57
  per-piece `secondaryPrice`; packaged goods show the pack price with a per-kg or per-litre
58
58
  `secondaryPrice`. A price per kg or per litre is not the price of one pack. An unknown or
59
59
  unrecognised price is `null`.
60
- - **Cart prices** are `{ amount, basis }`, where `basis` is `unit`, `line` (a line total) or
61
- `unknown`.
60
+ - **Cart prices** use the same `{ amount, per }` price tags: `per` is `item`, or `kg` for
61
+ products sold by the piece but priced by weight. Each cart line also has `lineTotal`.
62
62
  - **Quantities** are `{ value, unit }` with unit `un`, `kg`, `g`, `l` or `ml`. Quantities read
63
63
  from the website are either `{ status: "known", quantity }` or
64
64
  `{ status: "unknown", value, rawText, reason }`; an unknown unit is never guessed.
@@ -76,8 +76,13 @@ Search uses the local favorites snapshot and never signs in to refresh it.
76
76
 
77
77
  Each product has `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`,
78
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`.
79
+ `400 gr (peso escorrido 260 gr)`), `minimumText` (a minimum purchase such as `600 gr (3 un)`),
80
+ `recommendedPrice` and `favorite`.
81
+
82
+ `recommendedPrice` is the recommended retail price (PVPR) the website shows beside the price
83
+ on many promotions, or `null` when it shows none. Promotions such as multi-buy offers have no
84
+ PVPR, so it is not a complete list of promotions. Compare it with `price` rather than treating
85
+ its presence as a discount.
81
86
 
82
87
  A result the server cannot read does not fail the search. It is skipped, or kept with an
83
88
  unknown price when only the price is unreadable, and described in `failures`; the result is
@@ -88,7 +93,8 @@ then `partial`.
88
93
 
89
94
  ### `get_product`
90
95
 
91
- Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `availability`
96
+ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `recommendedPrice`
97
+ (as in `search_products`), `availability`
92
98
  (`available`, `unavailable` or `unknown`) and `saleRules`:
93
99
 
94
100
  ```json
@@ -96,9 +102,13 @@ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `a
96
102
  "minimum": { "value": 1, "unit": "un" }, "maximum": { "value": 10, "unit": "un" }, "step": { "value": 1, "unit": "un" } } }
97
103
  ```
98
104
 
99
- Cart quantities are always in `saleUnit`. If the website's rules cannot be interpreted
100
- safely, `saleRules` is `{ "status": "unavailable", "error": ... }` and the result is
101
- `partial`. Availability reflects the product page, not delivery-slot stock.
105
+ Cart quantities are always in `saleUnit`. Products priced per kilogram but sold by the piece
106
+ (fruit, meat, fish) have `saleUnit: "un"` and `storefrontUnit: "kg"`: their minimum, maximum and
107
+ step are whole pieces, and `storefrontUnitsPerSaleUnit` is the approximate weight of one piece.
108
+ If the website's rules cannot be interpreted safely, `saleRules` is
109
+ `{ "status": "unavailable", "error": ... }` and the result is `partial`. Availability reflects
110
+ the product page, not delivery-slot stock. A product the website no longer sells returns
111
+ `not_found`.
102
112
 
103
113
  ## Favorites
104
114
 
@@ -111,12 +121,36 @@ automatically when missing, is marked `stale` after 24 hours, and is replaced wh
111
121
  `data`: `favorites`, `fetchedAt`, `expiresAt`, `source` (`cache` or `website`), `freshness`
112
122
  (`fresh` or `stale`), `cacheState` (`empty` or `populated`), `completeness`.
113
123
 
124
+ Favorites can include products the website no longer sells, still with their last price. Check
125
+ with `get_product` before adding one to the cart; it returns `not_found` for those.
126
+
114
127
  ## Cart
115
128
 
116
129
  ### `get_cart`
117
130
 
118
- `data`: `customerAuthenticated`, `items` (each with `productId`, `cartItemId`, `name`,
119
- `quantity`, `price`) and `total`.
131
+ `data`: `customerAuthenticated`, `items` (each with `productId`, `cartItemId`, `name`, `kind`,
132
+ `quantity`, `price`, `lineTotal`) and `totals`.
133
+
134
+ - `kind` is `bag` for the shopping bags the website adds to the cart by itself, and `product`
135
+ for everything else.
136
+ - `quantity` is in the line's sale unit, read from the line's own sale rules: pieces (`un`) for
137
+ products sold by the piece but priced per kg, `un` for everything else. A line without
138
+ recognisable sale rules keeps its amount with an unknown unit.
139
+ - `price` is the unit price with what it is for: per item, or per kg for products sold by the
140
+ piece (2 apples at €2.99/kg cost less than €5.98). `null` when the line does not say what its
141
+ price is for. `lineTotal` is the line's total as the website reports it.
142
+ - `totals` holds each amount as the website reports it, or `null` when it reports none:
143
+
144
+ | Field | Meaning |
145
+ | --- | --- |
146
+ | `products` | Product lines only, without bags or deposit |
147
+ | `bags` | The bag lines |
148
+ | `deposit` | Bottle and can deposit (SDR) |
149
+ | `delivery` | Delivery cost, once the website has one |
150
+ | `discounts` | Amount taken off by order and delivery discounts |
151
+ | `grandTotal` | The amount to pay as the website shows it. It includes the bags. |
152
+
153
+ Report `grandTotal` as the amount to pay, and don't add the other fields to it yourself.
120
154
 
121
155
  ### Changing the cart
122
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continentept-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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": [
@@ -64,7 +64,8 @@
64
64
  "test:browser": "npm run build && tsc -p tsconfig.tests.json && node build/tools/run-tests.js --browser",
65
65
  "test:package": "npm run build:tools && node build/tools/package-smoke.js",
66
66
  "prepack": "npm run build",
67
- "build:bundle": "npm run build && node build/tools/build-bundle.js"
67
+ "build:bundle": "npm run build && node build/tools/build-bundle.js",
68
+ "check:site": "npm run build && node build/tools/site-check.js"
68
69
  },
69
70
  "dependencies": {
70
71
  "@modelcontextprotocol/sdk": "^1.32.1",
@@ -12,7 +12,7 @@ Turn the user's shopping intent into a product list or a verified basket using t
12
12
  1. 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.
13
13
  2. 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.
14
14
  3. 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.
15
- 4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct.
15
+ 4. Show unresolved choices. Missing prices, availability, quantities or sale rules stay unknown. Money is EUR in integer `minorUnits` (129 means €1.29). Keep product prices, basket totals and estimated list costs distinct. The amount to pay is `get_cart`'s `totals.grandTotal`, which includes the bags the website adds.
16
16
 
17
17
  Searches read a bounded number of result pages; respect `completeness` and `failures` and do not claim a search was exhaustive.
18
18