continente-mcp 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -8,7 +8,8 @@
8
8
  # CONTINENTE_EMAIL=you@example.com
9
9
  # CONTINENTE_PASSWORD=your-password
10
10
 
11
- # Optional: point the tools at a different private env file.
11
+ # Optional: use a different private env file. This one cannot go in the file
12
+ # itself; set it in your MCP client's server "env" instead.
12
13
  # CONTINENTE_ENV_PATH=/absolute/path/to/credentials.env
13
14
 
14
15
  # Set to false only for local debugging, so you can watch the login browser.
@@ -20,7 +21,8 @@
20
21
 
21
22
  # If you run the MCP server on a second machine and sync cookies via a shared
22
23
  # folder (e.g. Obsidian vault, Syncthing, Dropbox), set this to the shared path
23
- # for automatic login to also write cookies there.
24
+ # for automatic login to also write cookies there. These cookies give access
25
+ # to your account: only use a private folder you control.
24
26
  # CONTINENTE_VAULT_COOKIE_PATH=/path/to/shared/folder/cookies.json
25
27
 
26
28
  # Override the state directory (preferences and default cookies)
package/README.md CHANGED
@@ -1,102 +1,64 @@
1
1
  # Continente MCP
2
2
 
3
- A local MCP server for Continente.pt: search products, rank favourites, manage your basket, and read order history. Checkout and payment stay on Continente.pt.
3
+ Let your AI assistant search Continente.pt and fill your basket with the products you usually buy.
4
4
 
5
- ## Setup
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Requires **Node.js 20.18.1 or later** and a Continente account for favourites, basket and order history. Public product search works without login.
7
+ ## Features
8
8
 
9
- ```bash
10
- git clone https://github.com/davidmarqu3s/continente-mcp
11
- cd continente-mcp
12
- npm install
13
- npm run setup
14
- mkdir -p ~/.continente
15
- cp .env.example ~/.continente/credentials.env
16
- chmod 600 ~/.continente/credentials.env
17
- ```
9
+ - **Search that knows your habits** — catalogue results put your saved favourites first.
10
+ - **Hands-free basket** — add, change and remove items; every change is checked against the basket afterwards.
11
+ - **Order history** — read recent orders and see your most-bought products.
12
+ - **Automatic login** — expired sessions sign in again on their own from a private credentials file.
13
+ - **You stay in control** — checkout and payment always happen on Continente.pt.
14
+
15
+ ## Install
18
16
 
19
- Edit the private credentials file and set `CONTINENTE_EMAIL` and `CONTINENTE_PASSWORD`, then verify login:
17
+ Requires Node.js 20.18.1 or later. Install the browser the server uses (on Linux, add `--with-deps`):
20
18
 
21
19
  ```bash
22
- node continente-auto-login.js
20
+ npx -y -p continente-mcp playwright install chromium
23
21
  ```
24
22
 
25
- Login uses Playwright Chromium and saves a private session cache in `~/.continente/cookies.json`. Protected tool calls automatically log in again when the session expires. Python and scheduled keepalive jobs are not required to use the MCP server.
23
+ For favourites, basket and order history, create `~/.continente/credentials.env` with your Continente login and make it private to your user (`chmod 600` on macOS/Linux). Search works without it.
24
+
25
+ ```
26
+ CONTINENTE_EMAIL=you@example.com
27
+ CONTINENTE_PASSWORD=your-password
28
+ ```
26
29
 
27
- Add the server to your MCP client using its local-server configuration. Clients using JSON `mcpServers` configuration can use:
30
+ Then add the server to your MCP client and restart it:
28
31
 
29
32
  ```json
30
33
  {
31
34
  "mcpServers": {
32
- "continente": {
33
- "command": "node",
34
- "args": ["/absolute/path/to/continente-mcp/src/index.js"]
35
- }
35
+ "continente": { "command": "npx", "args": ["-y", "continente-mcp"] }
36
36
  }
37
37
  }
38
38
  ```
39
39
 
40
- Restart the client after configuration or server changes. The published package can also run with `npx continente-mcp`; the local clone configuration above runs the code you just installed and tested. This is a local stdio server, not a hosted MCP endpoint.
40
+ To run from source instead, clone the repository, run `npm install && npm run setup`, and use `node /absolute/path/to/continente-mcp/src/index.js` as the command.
41
41
 
42
- For Windows, use your user profile directory instead of `~` and restrict the credentials/state directory through Windows filesystem permissions. On Linux, `npm run setup` may need administrator permission to install Chromium system dependencies; if already installed, `npx playwright install chromium` installs only the browser.
42
+ ## Use
43
43
 
44
- ## Tools
44
+ Ask your assistant things like "add milk, eggs and bread to my Continente basket". Run `refresh_favorites` once so searches know what you buy.
45
45
 
46
46
  | Tool | Purpose |
47
47
  | --- | --- |
48
- | `search_products` | Search the catalogue, with saved favourites ranked first |
49
- | `get_favorites` | Read cached favourites |
50
- | `refresh_favorites` | Refresh favourites from your account |
51
- | `get_cart` | Read the account basket |
52
- | `add_to_cart` | Add a product by ID |
53
- | `update_cart_item` | Set a basket quantity; zero removes the item |
54
- | `get_order_history` | Read recent orders |
55
- | `get_most_bought` | Aggregate products from order history |
48
+ | `search_products` | Search the catalogue, favourites first |
49
+ | `get_favorites` / `refresh_favorites` | Read cached favourites / refresh them from your account |
50
+ | `get_cart` | Read the basket |
51
+ | `add_to_cart` / `update_cart_item` | Add a product / set its quantity (zero removes it) |
52
+ | `get_order_history` / `get_most_bought` | Read recent orders / your most-bought products |
56
53
  | `close_session` | Close the browser session |
57
54
 
58
- Run `refresh_favorites` once to personalise searches. Read the basket before changing it and verify quantities afterwards. An unrecognised basket page is an error, never proof that the basket is empty. Order aggregation can be slow because it visits order detail pages.
55
+ ## More
59
56
 
60
- The bundled [groceries skill](skills/groceries/SKILL.md) guides agents through matching names to favourites and previous purchases. Add that skill directory through your client's supported skill installation mechanism.
61
-
62
- ## Configuration and security
63
-
64
- Keep real credentials and cookie values out of the repository, client configuration, logs and screenshots. Both credentials and session cookies grant access to private account data. On macOS/Linux, use owner-only permissions (`chmod 600`) for secret files.
65
-
66
- See [`.env.example`](.env.example). Process environment settings override the private credentials file.
67
-
68
- | Variable | Purpose |
69
- | --- | --- |
70
- | `CONTINENTE_ENV_PATH` | Private credentials file; defaults to `~/.continente/credentials.env` |
71
- | `CONTINENTE_EMAIL`, `CONTINENTE_PASSWORD` | Account credentials for automatic login |
72
- | `CONTINENTE_STATE_DIR` | Local state directory; defaults to `~/.continente` |
73
- | `CONTINENTE_COOKIES_PATH` | Custom cookie cache path |
74
- | `CONTINENTE_LOGIN_HEADLESS` | Set `false` to inspect login in a visible browser |
75
- | `CONTINENTE_VAULT_COOKIE_PATH` | Optional secondary cookie file for private multi-machine sync |
76
-
77
- Prefer absolute paths for custom settings.
78
-
79
- ## Troubleshooting and development
80
-
81
- - **Login required:** check the private credentials file and run `node continente-auto-login.js`.
82
- - **Browser missing:** run `npm run setup`.
83
- - **No favourites:** run `refresh_favorites`; catalogue search still works without them.
84
- - **Unexpected cart or product page:** report the tool and error, excluding account details and secrets.
85
-
86
- ```bash
87
- npm test
88
- npm audit --omit=dev
89
- npm pack --dry-run
90
- ```
91
-
92
- Unit tests do not establish that Continente's live website still matches every selector. Verify the affected tool against the live site after parser changes; do not place orders during testing.
93
-
94
- Personal backup, keepalive, browser-cookie export and monitoring scripts are maintained separately in the private operations repository. They are not part of the npm package or the normal login workflow. Existing operators must migrate scheduled script paths before switching to this checkout.
57
+ - **Agents:** the [groceries skill](skills/groceries/SKILL.md) teaches assistants to pick the products you usually buy.
58
+ - **Settings:** custom credential, state and cookie paths are listed in [`.env.example`](.env.example).
59
+ - **Troubleshooting:** [docs/troubleshooting.md](docs/troubleshooting.md).
60
+ - **Developers:** [docs/development.md](docs/development.md) and [docs/releases.md](docs/releases.md).
95
61
 
96
62
  ## License
97
63
 
98
64
  [MIT](LICENSE)
99
-
100
- ## Releases
101
-
102
- See the [changelog](CHANGELOG.md) and [release instructions](docs/releases.md). Version tags run the platform checks and publish matching npm/GitHub releases. Release candidates use npm's `next` channel; `latest` stays on the last stable release.
@@ -13,9 +13,10 @@ function log(message) {
13
13
  console.error(`[continente-auto-login] ${message}`);
14
14
  }
15
15
 
16
- function writeCookies(path, cookies) {
17
- mkdirSync(dirname(path), { recursive: true });
18
- writeFileSync(path, JSON.stringify(cookies, null, 2));
16
+ export function writeCookies(path, cookies) {
17
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
18
+ // Create owner-only; chmod also tightens files created by older versions.
19
+ writeFileSync(path, JSON.stringify(cookies, null, 2), { mode: 0o600 });
19
20
  chmodSync(path, 0o600);
20
21
  }
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continente-mcp",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "MCP server for Continente.pt — search products, manage your cart, and browse order history.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -46,7 +46,7 @@
46
46
  "README.md"
47
47
  ],
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "@modelcontextprotocol/sdk": "^1.32.1",
50
50
  "cheerio": "^1.2.0",
51
51
  "playwright": "^1.59.1"
52
52
  }
@@ -22,11 +22,11 @@ The goal is to pick the product the user actually buys, not just any product wit
22
22
 
23
23
  **1. Search with favourites context**
24
24
 
25
- Call `search_products` with the item name. Results are automatically ranked by the favourites list — the top result is usually correct. Note the top 3.
25
+ Call `search_products` with the item name. Favourites are ranked first and marked ⭐ — the top result is usually correct. Note the top 3.
26
26
 
27
- **2. Cross-check favourites**
27
+ **2. Prefer favourites**
28
28
 
29
- Call `get_favorites` once (reuse for all items in this request). If any favourite matches the item name, prefer it — even if not the top search result. A favourite is a stronger signal than search rank.
29
+ If any result is marked ⭐, prefer it — even if it is not the top result. A favourite is a stronger signal than search rank. If nothing is marked and the user seems to have favourites, call `refresh_favorites` once and search again.
30
30
 
31
31
  **3. Fallback: order history**
32
32
 
@@ -40,7 +40,10 @@ If confidence is high (clear favourite or obvious top result), add silently and
40
40
 
41
41
  Call `add_to_cart` with `product_id`. Default quantity is 1 unless specified (e.g. "2 pacotes de leite").
42
42
 
43
- After adding products with a requested quantity greater than 1, call `get_cart` and verify the displayed quantity. Some Continente products use internal weighted units, so the quantity returned by `add_to_cart` can differ from the requested display quantity. If the cart quantity is wrong, call `update_cart_item` with the same `product_id` and the desired displayed quantity, then call `get_cart` again.
43
+ `add_to_cart` and `update_cart_item` both check the basket afterwards and only report success when it holds the requested quantity. Products sold by weight (e.g. bananas) are counted in units, like on the website.
44
+
45
+ - `below_minimum_quantity` — the product has a minimum, shown as ⚖️ in search results and in the error. Nothing was added; ask whether to add the minimum instead.
46
+ - `cart_add_not_confirmed` or `cart_quantity_not_confirmed` — the change may still have happened. Call `get_cart`, never repeat `add_to_cart`, and correct with `update_cart_item` if needed.
44
47
 
45
48
  ## Output format
46
49
 
@@ -56,9 +59,9 @@ After all items are processed, print a compact summary:
56
59
  ## Error handling
57
60
 
58
61
  - No search results → report as not found, skip, continue with others
59
- - `add_to_cart` fails → report the error, move on
62
+ - `add_to_cart` fails → handle the errors above, otherwise report the error and move on
60
63
  - Never stop the whole run because one item failed
61
64
 
62
65
  ## Performance
63
66
 
64
- `get_favorites` is slow. Call it once and reuse across all items. Only call `get_order_history` when favourites give no clear signal.
67
+ `get_order_history` opens each order and is slow. Only call it when search and favourites give no clear signal.
package/src/cart-utils.js CHANGED
@@ -16,9 +16,22 @@ export function quantityForCartUpdate(displayQuantity, measureOptions = {}) {
16
16
  throw new Error('Quantity must be a non-negative number');
17
17
  }
18
18
 
19
- const conversion = Number(measureOptions.primaryToSecondary || measureOptions.unitConversionRate);
19
+ const conversion = Number(measureOptions.primaryToSecondary ?? measureOptions.unitConversionRate);
20
20
  const hasAlternativeUnit = measureOptions.hasAlternativeSaleUnit || measureOptions.hasConversionRate;
21
- const cartQuantity = hasAlternativeUnit && conversion > 0 ? quantity * conversion : quantity;
21
+ if ((hasAlternativeUnit && (!Number.isFinite(conversion) || conversion <= 0)) ||
22
+ // Without an alternative sale unit, quantities are counted in the primary unit;
23
+ // secondaryunit is only the price reference (e.g. €/kg for a unit product).
24
+ (!hasAlternativeUnit && ((measureOptions.primaryunit && measureOptions.primaryunit !== 'un') ||
25
+ (measureOptions.selectedunit && measureOptions.selectedunit !== 'primary')))) {
26
+ throw new Error('quantity_semantics_unknown');
27
+ }
28
+ const cartQuantity = hasAlternativeUnit ? quantity * conversion : quantity;
29
+ if (!Number.isFinite(cartQuantity)) throw new Error('quantity_semantics_unknown');
30
+ const minimum = Number(measureOptions.minOrderQuantity);
31
+ if (quantity > 0 && minimum > 0 && cartQuantity < minimum - 0.0005) {
32
+ const displayMinimum = hasAlternativeUnit ? minimum / conversion : minimum;
33
+ throw new Error(`below_minimum_quantity: the minimum is ${Number(displayMinimum.toFixed(3))}`);
34
+ }
22
35
 
23
36
  return Number(cartQuantity.toFixed(3)).toString();
24
37
  }
@@ -74,3 +87,27 @@ function moneyOrNull(value) {
74
87
  const amount = Number(value);
75
88
  return Number.isFinite(amount) && amount >= 0 ? amount : null;
76
89
  }
90
+
91
+ // Order lines show "12 un", or "1.2 kg" for products bought by weight.
92
+ export function parseOrderQuantity(text) {
93
+ const match = String(text ?? '').trim().match(/^(\d+(?:[.,]\d+)?)\s*([a-zA-Z]*)/);
94
+ if (!match) return { qty: 1, unit: 'un' };
95
+ return { qty: Number(match[1].replace(',', '.')), unit: match[2].toLowerCase() || 'un' };
96
+ }
97
+
98
+ // Units and weights cannot be added together, so totals are kept per unit.
99
+ // Sorted by how many orders include the product, then by units bought.
100
+ export function tallyMostBought(orders) {
101
+ const tally = new Map();
102
+ for (const products of orders) {
103
+ const seenInOrder = new Set();
104
+ for (const { name, qty, unit } of products) {
105
+ const entry = tally.get(name) ?? { name, quantities: {}, orders: 0 };
106
+ entry.quantities[unit] = Number(((entry.quantities[unit] ?? 0) + qty).toFixed(3));
107
+ if (!seenInOrder.has(name)) { entry.orders += 1; seenInOrder.add(name); }
108
+ tally.set(name, entry);
109
+ }
110
+ }
111
+ return Array.from(tally.values())
112
+ .sort((a, b) => b.orders - a.orders || (b.quantities.un ?? 0) - (a.quantities.un ?? 0));
113
+ }
package/src/index.js CHANGED
@@ -5,10 +5,11 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
5
5
  import { chromium } from 'playwright';
6
6
  import * as cheerio from 'cheerio';
7
7
  import { pathToFileURL } from 'node:url';
8
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
8
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync } from 'fs';
9
9
  import { normalizeCookies } from './utils.js';
10
- import { normalizeCartProductId, quantityForCartUpdate, summarizeCartState } from './cart-utils.js';
10
+ import { normalizeCartProductId, parseOrderQuantity, quantityForCartUpdate, summarizeCartState, tallyMostBought } from './cart-utils.js';
11
11
  import { refreshAuthCookies, resolveStatePaths } from './auth-session.js';
12
+ import { createQueue, normalizeFavoriteId, retryAuthentication, validateToolInput } from './reliability.js';
12
13
 
13
14
  const CONTINENTE_BASE = 'https://www.continente.pt';
14
15
  const { stateDir: STATE_DIR, cookieFile: COOKIE_FILE } = resolveStatePaths();
@@ -39,6 +40,8 @@ async function refreshAuthSession() {
39
40
  return refreshed;
40
41
  }
41
42
 
43
+ const NOT_LOGGED_IN = 'Not logged in. Add CONTINENTE_EMAIL and CONTINENTE_PASSWORD to ~/.continente/credentials.env (or the server environment), then try again.';
44
+
42
45
  async function ensureBrowser() {
43
46
  const cookieFile = COOKIE_FILE;
44
47
  if (!existsSync(cookieFile)) {
@@ -52,7 +55,12 @@ async function ensureBrowser() {
52
55
  }
53
56
 
54
57
  if (!browser) {
55
- browser = await chromium.launch({ headless: true });
58
+ const launched = await chromium.launch({ headless: true });
59
+ // After a crash, start a fresh browser on the next call instead of failing until close_session.
60
+ launched.on('disconnected', () => {
61
+ if (browser === launched) { browser = context = page = null; loadedCookieMtimeMs = null; }
62
+ });
63
+ browser = launched;
56
64
  }
57
65
 
58
66
  if (!context) {
@@ -67,14 +75,15 @@ async function ensureBrowser() {
67
75
  const cookies = JSON.parse(readFileSync(cookieFile, 'utf8'));
68
76
  const normalized = normalizeCookies(cookies);
69
77
  await context.addCookies(normalized);
70
- loadedCookieMtimeMs = nextCookieMtimeMs;
71
78
  } catch (e) {
72
79
  console.error('Could not read the cookie cache; refresh login or replace the cache.');
73
80
  }
81
+ // Record the attempt even when it fails, so an unreadable cache does not reset the session on every call.
82
+ loadedCookieMtimeMs = nextCookieMtimeMs;
74
83
  }
75
84
  }
76
85
 
77
- if (!page) {
86
+ if (!page || page.isClosed()) {
78
87
  page = await context.newPage();
79
88
  }
80
89
  return browser;
@@ -88,17 +97,8 @@ async function goto(url, wait = 'networkidle') {
88
97
  return page;
89
98
  }
90
99
 
91
- async function retryAfterAutoLogin(operation) {
92
- const first = await operation();
93
- if (first?.error !== 'not_authenticated') {
94
- return first;
95
- }
96
-
97
- if (!(await refreshAuthSession())) {
98
- return first;
99
- }
100
-
101
- return operation();
100
+ async function retryAfterAutoLogin(operation, mutation = false) {
101
+ return retryAuthentication(operation, refreshAuthSession, mutation);
102
102
  }
103
103
 
104
104
  async function closeBrowser() {
@@ -112,7 +112,7 @@ async function closeBrowser() {
112
112
 
113
113
  async function fetchFavorites() {
114
114
  await goto(`${CONTINENTE_BASE}/conta/lista-produtos/?list=favorites`);
115
- if (page.url().includes('/login')) return { error: 'not_authenticated' };
115
+ if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
116
116
  const html = await page.content();
117
117
  const $ = cheerio.load(html);
118
118
  const products = [];
@@ -123,7 +123,7 @@ async function fetchFavorites() {
123
123
  const href = $el.attr('href') || '';
124
124
  if (text.length > 5 && href) {
125
125
  const idMatch = href.match(/\/produto\/([^\/\?]+)/);
126
- const productId = idMatch ? idMatch[1] : null;
126
+ const productId = idMatch ? normalizeFavoriteId(idMatch[1]) : null;
127
127
  const priceMatch = text.match(/(\d+[,.]\d+€)/);
128
128
  const name = text.replace(/\d+[,.]\d+€/g, '').trim().substring(0, 120);
129
129
  if (name && name.length > 3 && productId) {
@@ -143,10 +143,30 @@ async function fetchFavorites() {
143
143
 
144
144
  // ─── Product Search ────────────────────────────────────────────────────────────
145
145
 
146
- async function searchProducts(query, limit = 10) {
146
+ async function searchProducts(query, extraPages = 0) {
147
147
  await goto(`${CONTINENTE_BASE}/pesquisa/?q=${encodeURIComponent(query)}`);
148
148
  const html = await page.content();
149
- return parseProducts(html, limit);
149
+ const products = parseProducts(html, Infinity);
150
+
151
+ // The search page holds one page of results (35); the grid endpoint serves the next ones.
152
+ const moreUrl = extraPages > 0 ? cheerio.load(html)('[data-url*="Search-UpdateGrid"]').attr('data-url') : null;
153
+ if (moreUrl) {
154
+ const url = new URL(moreUrl, CONTINENTE_BASE);
155
+ const start = Number(url.searchParams.get('start')) || products.length;
156
+ const size = Number(url.searchParams.get('sz')) || products.length;
157
+ if (url.origin === CONTINENTE_BASE && size > 0) {
158
+ const pages = await Promise.all(Array.from({ length: extraPages }, (_, i) => {
159
+ url.searchParams.set('start', String(start + i * size));
160
+ return context.request.get(url.href, { timeout: 10000 })
161
+ .then(res => res.ok() ? res.text() : '')
162
+ .catch(() => '');
163
+ }));
164
+ for (const pageHtml of pages) products.push(...parseProducts(pageHtml, Infinity));
165
+ }
166
+ }
167
+
168
+ const seen = new Set();
169
+ return products.filter(p => !seen.has(p.product_id) && seen.add(p.product_id));
150
170
  }
151
171
 
152
172
  function parseProducts(html, limit = 30) {
@@ -173,6 +193,9 @@ function parseProducts(html, limit = 30) {
173
193
  const secondaryText = $el.find('.pwc-tile--price-secondary').first().text().replace(/\s+/g, ' ').trim();
174
194
  const unitMatch = secondaryText.match(/(\d+[,.]\d+€)\/([a-zA-Z]+)/);
175
195
 
196
+ // e.g. "Quant. Mínima = 600 gr (3 un)" on products sold by weight
197
+ const minimumMatch = $el.find('.pwc-tile--quantity').first().text().replace(/\s+/g, ' ').match(/M[ií]nima\s*=\s*(.+?)\s*$/i);
198
+
176
199
  const nameLink = $el.find('a[href*="/produto/"]').filter((_, a) => $(a).text().trim().length > 0).first();
177
200
  let name = nameLink.text().replace(/\s+/g, ' ').trim().substring(0, 100);
178
201
  if (name.length < 3) return;
@@ -183,7 +206,8 @@ function parseProducts(html, limit = 30) {
183
206
  currency: '€',
184
207
  product_id: productId,
185
208
  url: href.startsWith('http') ? href : CONTINENTE_BASE + href,
186
- unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null
209
+ unit: unitMatch ? `${unitMatch[1]}/${unitMatch[2]}` : null,
210
+ minimum: minimumMatch ? minimumMatch[1] : null
187
211
  });
188
212
  });
189
213
 
@@ -222,10 +246,10 @@ async function getCart() {
222
246
 
223
247
  async function updateCartItem(productId, quantity) {
224
248
  const before = await getCart();
225
- if (before.error) return { success: false, error: before.error };
249
+ if (before.error) return { success: false, error: before.error, writeAttempted: false };
226
250
  await goto(`${CONTINENTE_BASE}/checkout/carrinho/`);
227
251
 
228
- if (page.url().includes('/login')) return { error: 'not_authenticated' };
252
+ if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
229
253
 
230
254
  const cartPid = normalizeCartProductId(productId);
231
255
  const updateData = await page.evaluate(({ cartPid }) => {
@@ -268,7 +292,7 @@ async function updateCartItem(productId, quantity) {
268
292
 
269
293
  const params = new URLSearchParams({ pid: cartPid, uuid: updateData.removeUuid });
270
294
  const result = await requestCartChange(updateData.removeUrl, params);
271
- if (result.error) return { success: false, error: result.error };
295
+ if (result.error) return { success: false, error: result.error, writeAttempted: true };
272
296
  return verifyCartQuantity(cartPid, 0);
273
297
  }
274
298
 
@@ -293,7 +317,7 @@ async function updateCartItem(productId, quantity) {
293
317
  });
294
318
 
295
319
  const result = await requestCartChange(updateData.updateUrl, params);
296
- if (result.error) return { success: false, error: result.error };
320
+ if (result.error) return { success: false, error: result.error, writeAttempted: true };
297
321
  return verifyCartQuantity(cartPid, quantity);
298
322
  }
299
323
 
@@ -315,18 +339,32 @@ async function verifyCartQuantity(productId, expectedQuantity) {
315
339
  const actual = cart.items?.find(item => item.id === productId)?.qty ?? 0;
316
340
  if (cart.error || Math.abs(actual - expectedQuantity) > 0.0005) {
317
341
  // A write may have happened. Never automatically repeat it after this point.
318
- return { success: false, error: 'cart_quantity_not_confirmed' };
342
+ return { success: false, error: 'cart_quantity_not_confirmed', writeAttempted: true };
319
343
  }
320
344
  return { success: true, product_id: productId, cart_quantity: actual };
321
345
  }
322
346
 
323
347
  async function addToCart(productId, quantity = 1) {
324
348
  const before = await getCart();
325
- if (before.error) return { success: false, error: before.error };
349
+ if (before.error) return { success: false, error: before.error, writeAttempted: false };
326
350
  const slug = productId.endsWith('.html') ? productId : `${productId}.html`;
327
351
  await goto(`${CONTINENTE_BASE}/produto/${slug}`, 'domcontentloaded');
328
352
  await page.waitForSelector('input.add-to-cart-url', { state: 'attached', timeout: 10000 });
329
353
 
354
+ const measureOptions = await page.evaluate(() => {
355
+ const options = document.querySelector('.ct-tile-quantity-update[data-measure-options]')?.dataset.measureOptions;
356
+ try { return options ? JSON.parse(options) : {}; } catch { return null; }
357
+ });
358
+ if (!measureOptions) return { success: false, error: 'quantity_semantics_unknown', writeAttempted: false };
359
+ const numericPid = normalizeCartProductId(productId);
360
+ const previousQuantity = before.items.find(item => item.id === numericPid)?.qty ?? 0;
361
+ // Rejects unclear units and basket totals below the product minimum.
362
+ quantityForCartUpdate(previousQuantity + quantity, measureOptions);
363
+ // Like the website, weighted products are added at their minimum (-1), then set to the exact quantity.
364
+ const weighted = Boolean(measureOptions.hasAlternativeSaleUnit || measureOptions.hasConversionRate);
365
+ if (weighted && previousQuantity > 0) return updateCartItem(numericPid, previousQuantity + quantity);
366
+ const cartQuantity = weighted ? -1 : quantity;
367
+
330
368
  // Extract numeric PID and Cart-AddProduct URL from page
331
369
  const result = await page.evaluate(async (qty) => {
332
370
  const pidInput = document.querySelector('input[name="productID"]');
@@ -338,6 +376,8 @@ async function addToCart(productId, quantity = 1) {
338
376
  const pid = pidInput.value;
339
377
  const cartUrl = urlInput.value;
340
378
  const csrf = csrfInput ? csrfInput.value : '';
379
+ // Only send the session and CSRF token to Continente itself.
380
+ if (new URL(cartUrl, location.href).origin !== location.origin) return { success: false, message: 'invalid_cart_action' };
341
381
 
342
382
  const body = new URLSearchParams({ pid, quantity: String(qty), options: '[]' });
343
383
  if (csrf) body.append('csrf_token', csrf);
@@ -355,14 +395,15 @@ async function addToCart(productId, quantity = 1) {
355
395
  } catch (e) {
356
396
  return { success: false, message: e.message };
357
397
  }
358
- }, quantity);
398
+ }, cartQuantity);
359
399
 
360
400
  if (!result.success || result.payload?.error || result.payload?.resources?.customerAuthenticated === false) {
361
- return { success: false, error: 'cart_add_not_confirmed' };
401
+ return { success: false, error: 'cart_add_not_confirmed', writeAttempted: true };
362
402
  }
363
- const numericPid = normalizeCartProductId(productId);
364
- const previousQuantity = before.items.find(item => item.id === numericPid)?.qty ?? 0;
365
- return verifyCartQuantity(numericPid, previousQuantity + quantity);
403
+ if (!weighted) return verifyCartQuantity(numericPid, previousQuantity + quantity);
404
+ // The add already wrote to the basket, so any failure from here must not be retried.
405
+ const updated = await updateCartItem(numericPid, previousQuantity + quantity);
406
+ return updated.success ? updated : { ...updated, writeAttempted: true };
366
407
  }
367
408
 
368
409
  // ─── Order History ────────────────────────────────────────────────────────────
@@ -370,7 +411,7 @@ async function addToCart(productId, quantity = 1) {
370
411
  async function getOrderHistory(limit = 5) {
371
412
  await goto(`${CONTINENTE_BASE}/conta/encomendas/`);
372
413
 
373
- if (page.url().includes('/login')) return { error: 'not_authenticated' };
414
+ if (page.url().includes('/login')) return { error: 'not_authenticated', writeAttempted: false };
374
415
 
375
416
  const text = await page.evaluate(() => document.body.innerText);
376
417
  const rawLines = text.split('\n').map(l => l.trim()).filter(Boolean);
@@ -391,17 +432,22 @@ async function getOrderHistory(limit = 5) {
391
432
  const seen = new Set();
392
433
  return Array.from(document.querySelectorAll('a[href*="detalhe-encomenda"]'))
393
434
  .map(a => a.href)
435
+ .filter(h => h.startsWith(`${location.origin}/`))
394
436
  .filter(h => { if (seen.has(h)) return false; seen.add(h); return true; });
395
437
  });
396
438
 
397
- // Fetch product lines for each order up to limit
398
- const detailLinks = orderLinks.slice(0, limit);
399
- for (let idx = 0; idx < detailLinks.length; idx++) {
439
+ // Links carry an internal ID, not the order number, so they are paired by position.
440
+ // If the counts disagree, show orders without items rather than attach the wrong ones.
441
+ if (orderLinks.length !== orders.length) {
442
+ orders.forEach(order => { order.linesUnavailable = true; });
443
+ return orders;
444
+ }
445
+ for (let idx = 0; idx < Math.min(limit, orders.length); idx++) {
400
446
  try {
401
- const products = await getOrderProducts(detailLinks[idx]);
402
- if (orders[idx]) orders[idx].lines = products.map(p => `${p.qty}x ${p.name}`);
447
+ const products = await getOrderProducts(orderLinks[idx]);
448
+ orders[idx].lines = products.map(p => `${p.unit === 'un' ? `${p.qty}x` : `${p.qty} ${p.unit}`} ${p.name}`);
403
449
  } catch (e) {
404
- // skip failed pages
450
+ orders[idx].linesUnavailable = true;
405
451
  }
406
452
  }
407
453
 
@@ -409,9 +455,10 @@ async function getOrderHistory(limit = 5) {
409
455
  }
410
456
 
411
457
  async function getOrderProducts(orderDetailUrl) {
412
- await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
458
+ const response = await page.goto(orderDetailUrl, { waitUntil: 'networkidle', timeout: 20000 });
459
+ if (!response?.ok() || page.url().includes('/login')) throw new Error('order_detail_unavailable');
413
460
 
414
- return page.evaluate(() => {
461
+ const products = await page.evaluate(() => {
415
462
  const products = [];
416
463
  document.querySelectorAll('[class*="product-line"]').forEach(el => {
417
464
  const qtyEl = el.querySelector('[class*="qty"], [class*="quantity"], [class*="amount"]');
@@ -422,13 +469,11 @@ async function getOrderProducts(orderDetailUrl) {
422
469
  // Take only first line — rest is brand/subcopy
423
470
  const name = rawName.split('\n')[0].trim();
424
471
 
425
- const qtyMatch = qtyEl.textContent.trim().match(/^(\d+)/);
426
- const qty = qtyMatch ? parseInt(qtyMatch[1], 10) : 1;
427
-
428
- if (name && name.length > 3) products.push({ name, qty });
472
+ if (name && name.length > 3) products.push({ name, qtyText: qtyEl.textContent.trim() });
429
473
  });
430
474
  return products;
431
475
  });
476
+ return products.map(({ name, qtyText }) => ({ name, ...parseOrderQuantity(qtyText) }));
432
477
  }
433
478
 
434
479
  async function getMostBought(limit = 10) {
@@ -446,29 +491,21 @@ async function getMostBought(limit = 10) {
446
491
 
447
492
  if (orderLinks.length === 0) return { error: 'no_orders' };
448
493
 
449
- // Tally products across recent orders
450
- const tally = new Map(); // name -> { qty, orders }
494
+ const orderProducts = [];
451
495
 
452
- for (const link of orderLinks.slice(0, limit)) {
496
+ const scanned = orderLinks.slice(0, limit);
497
+ let failed = 0;
498
+ for (const link of scanned) {
453
499
  try {
454
500
  const products = await getOrderProducts(link);
455
- for (const { name, qty } of products) {
456
- const existing = tally.get(name);
457
- if (existing) {
458
- existing.qty += qty;
459
- existing.orders += 1;
460
- } else {
461
- tally.set(name, { qty, orders: 1 });
462
- }
463
- }
501
+ orderProducts.push(products);
464
502
  } catch (e) {
465
- // skip failed order pages
503
+ failed++;
466
504
  }
467
505
  }
506
+ if (failed === scanned.length) return { error: 'order_details_unavailable' };
468
507
 
469
- return Array.from(tally.entries())
470
- .map(([name, { qty, orders }]) => ({ name, qty, orders }))
471
- .sort((a, b) => b.qty - a.qty);
508
+ return { products: tallyMostBought(orderProducts), scanned: scanned.length, failed };
472
509
  }
473
510
 
474
511
  // ─── Preferences ───────────────────────────────────────────────────────────────
@@ -484,9 +521,10 @@ async function getPreferences() {
484
521
  }
485
522
 
486
523
  async function savePreferences(prefs) {
487
- try { mkdirSync(STATE_DIR, { recursive: true }); } catch (e) {}
524
+ try { mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); } catch (e) {}
488
525
  const prefsFile = `${STATE_DIR}/preferences.json`;
489
- writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
526
+ writeFileSync(prefsFile, JSON.stringify(prefs, null, 2), { mode: 0o600 });
527
+ chmodSync(prefsFile, 0o600);
490
528
  }
491
529
 
492
530
  async function updatePreferencesFromFavorites() {
@@ -526,7 +564,7 @@ export class ContinenteServer {
526
564
  { capabilities: { tools: {} } }
527
565
  );
528
566
  this.server.onclose = () => { void closeBrowser(); };
529
- this.pending = Promise.resolve();
567
+ this.pending = createQueue();
530
568
  this.setupTools();
531
569
  }
532
570
 
@@ -618,22 +656,12 @@ export class ContinenteServer {
618
656
 
619
657
  callTool(name, args = {}) {
620
658
  // All tools share one browser page, including close_session.
621
- const result = this.pending.then(() => this.executeTool(name, args));
622
- this.pending = result.catch(() => {});
623
- return result;
659
+ return this.pending(() => this.executeTool(name, args));
624
660
  }
625
661
 
626
662
  async executeTool(name, args) {
627
663
  try {
628
- if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Arguments must be an object');
629
- if (['search_products', 'get_order_history', 'get_most_bought'].includes(name) && args.limit !== undefined &&
630
- (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50)) throw new Error('limit must be an integer from 1 to 50');
631
- if (name === 'search_products' && (typeof args.query !== 'string' || !args.query.trim())) throw new Error('query must be a non-empty string');
632
- if (['add_to_cart', 'update_cart_item'].includes(name)) {
633
- if (typeof args.product_id !== 'string' || !/^[a-zA-Z0-9_-]+(?:\.html)?$/.test(args.product_id)) throw new Error('Invalid product_id');
634
- const quantity = args.quantity ?? (name === 'add_to_cart' ? 1 : undefined);
635
- if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 || (name === 'add_to_cart' && quantity === 0)) throw new Error('Invalid quantity');
636
- }
664
+ args = validateToolInput(name, args);
637
665
  switch (name) {
638
666
  case 'search_products':
639
667
  return await this.handle_search(args.query, args.limit || 10);
@@ -667,18 +695,21 @@ export class ContinenteServer {
667
695
  const fav = preferences?.favorites?.find(f => normalizeCartProductId(f.productId) === normalizeCartProductId(p.product_id));
668
696
  const favBadge = fav ? ' ⭐ (favorite)' : '';
669
697
  const unitStr = p.unit ? ` (${p.unit})` : '';
670
- return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}\n 🆔 ${p.product_id}`;
698
+ const minimumStr = p.minimum ? `\n ⚖️ Minimum ${p.minimum}` : '';
699
+ return `${i + 1}. ${p.name}${favBadge}\n 💰 ${p.price?.toFixed(2) || '?'}€${unitStr}${minimumStr}\n 🆔 ${p.product_id}`;
671
700
  }).join('\n\n');
672
701
  }
673
702
 
674
703
  async handle_search(query, limit) {
675
704
  const prefs = await getPreferences();
676
- const products = await searchProducts(query, limit * 2);
705
+ // Look further down the results when there are favourites to rank first.
706
+ const products = await searchProducts(query, prefs?.favorites?.length ? 2 : 0);
677
707
  const ranked = rankByPreference(products, prefs);
678
708
  const top = ranked.slice(0, limit);
679
709
 
680
710
  const favCount = prefs?.favorites?.length || 0;
681
711
  return {
712
+ structuredContent: { products: top },
682
713
  content: [{
683
714
  type: 'text',
684
715
  text: `Found ${products.length} products for "${query}" (${favCount} favorites loaded)${top.some(p => p.score > 0) ? ' — ⭐ = in your favorites' : ''}:\n\n${this.formatProducts(top, prefs)}\n\nUse add_to_cart with the product_id.`
@@ -717,13 +748,13 @@ export class ContinenteServer {
717
748
  async handle_get_cart() {
718
749
  const cart = await retryAfterAutoLogin(() => getCart());
719
750
  if (cart?.error === 'not_authenticated') {
720
- return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
751
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
721
752
  }
722
753
  if (cart?.error) {
723
754
  return { content: [{ type: 'text', text: `Could not load cart. (${cart.error})` }], isError: true };
724
755
  }
725
756
  if (!Array.isArray(cart.items) || cart.items.length === 0) {
726
- return { content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
757
+ return { structuredContent: cart, content: [{ type: 'text', text: '🛒 Cart is empty.' }] };
727
758
  }
728
759
  const total = cart.total;
729
760
  const totalText = total === null ? 'Unavailable' : `${total.toFixed(2)}€`;
@@ -731,6 +762,7 @@ export class ContinenteServer {
731
762
  `${i + 1}. ${item.name}\n Qtd: ${item.qty} × ${item.price?.toFixed(2) || '?'}€\n 🆔 ${item.id}`
732
763
  ).join('\n');
733
764
  return {
765
+ structuredContent: cart,
734
766
  content: [{
735
767
  type: 'text',
736
768
  text: `🛒 Cart (${cart.items.length} items):\n\n${list}\n\n💶 Total: ${totalText}\n\nGo to https://www.continente.pt/checkout/carrinho/ to checkout.`
@@ -739,23 +771,23 @@ export class ContinenteServer {
739
771
  }
740
772
 
741
773
  async handle_add_to_cart(productId, quantity) {
742
- const result = await retryAfterAutoLogin(() => addToCart(productId, quantity));
774
+ const result = await retryAfterAutoLogin(() => addToCart(productId, quantity), true);
743
775
  if (result.success) {
744
- return { content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
776
+ return { structuredContent: result, content: [{ type: 'text', text: `✅ Added to cart! (${quantity}x)\n\nUse get_cart to review.` }] };
745
777
  }
746
778
  return { content: [{ type: 'text', text: `⚠️ ${result.error || result.message || 'Could not add to cart.'}` }], isError: true };
747
779
  }
748
780
 
749
781
  async handle_update_cart_item(productId, quantity) {
750
- const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity));
782
+ const result = await retryAfterAutoLogin(() => updateCartItem(productId, quantity), true);
751
783
  if (result?.error === 'not_authenticated') {
752
- return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
784
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
753
785
  }
754
786
  if (result?.error === 'not_found') {
755
787
  return { content: [{ type: 'text', text: `Product ${productId} is not in the cart.` }], isError: true };
756
788
  }
757
789
  if (result.success) {
758
- return { content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
790
+ return { structuredContent: result, content: [{ type: 'text', text: `✅ Updated cart item ${productId} to ${quantity}.\n\nUse get_cart to review.` }] };
759
791
  }
760
792
  return { content: [{ type: 'text', text: `⚠️ ${result.error || 'Could not update cart item.'}` }], isError: true };
761
793
  }
@@ -763,7 +795,7 @@ export class ContinenteServer {
763
795
  async handle_order_history(limit) {
764
796
  const orders = await retryAfterAutoLogin(() => getOrderHistory(limit));
765
797
  if (orders?.error === 'not_authenticated') {
766
- return { content: [{ type: 'text', text: 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' }], isError: true };
798
+ return { content: [{ type: 'text', text: NOT_LOGGED_IN }], isError: true };
767
799
  }
768
800
  if (!Array.isArray(orders) || orders.length === 0) {
769
801
  return { content: [{ type: 'text', text: 'Could not load order history. Check https://www.continente.pt/conta/encomendas/' }] };
@@ -772,7 +804,7 @@ export class ContinenteServer {
772
804
  if (o.raw) return `${i + 1}. ${o.raw}`;
773
805
  const date = o.date ? `📅 ${o.date}` : '';
774
806
  const total = o.total ? ` — ${o.total}` : '';
775
- const lines = o.lines ? o.lines.join(' · ') : '';
807
+ const lines = o.lines ? o.lines.join(' · ') : o.linesUnavailable ? '(items could not be read)' : '';
776
808
  return `${i + 1}. ${date}${total}\n ${lines}`;
777
809
  }).join('\n\n');
778
810
  return {
@@ -786,19 +818,20 @@ export class ContinenteServer {
786
818
  async handle_most_bought(limit) {
787
819
  const result = await retryAfterAutoLogin(() => getMostBought(limit));
788
820
  if (result.error) {
789
- return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? 'Not logged in. Set CONTINENTE_EMAIL and CONTINENTE_PASSWORD in the MCP server environment, or refresh cookies manually.' : `Error: ${result.error}` }], isError: true };
821
+ return { content: [{ type: 'text', text: result.error === 'not_authenticated' ? NOT_LOGGED_IN : `Error: ${result.error}` }], isError: true };
790
822
  }
791
- if (!Array.isArray(result) || result.length === 0) {
823
+ if (!Array.isArray(result.products) || result.products.length === 0) {
792
824
  return { content: [{ type: 'text', text: 'Could not calculate most bought items from order history.' }] };
793
825
  }
794
- const top = result.slice(0, 25);
826
+ const top = result.products.slice(0, 25);
795
827
  const list = top.map((item, i) =>
796
- `${i + 1}. ${item.name} — ${item.qty} units across ${item.orders} order${item.orders > 1 ? 's' : ''}`
828
+ `${i + 1}. ${item.name} — ${Object.entries(item.quantities).map(([unit, qty]) => `${qty} ${unit}`).join(' + ')} across ${item.orders} order${item.orders > 1 ? 's' : ''}`
797
829
  ).join('\n');
798
830
  return {
799
831
  content: [{
800
832
  type: 'text',
801
- text: `Most bought products (calculated from your order history):\n\n${list}`
833
+ text: `Most bought products (calculated from your order history):\n\n${list}` +
834
+ (result.failed ? `\n\n⚠️ ${result.failed} of ${result.scanned} orders could not be read and are not counted.` : '')
802
835
  }]
803
836
  };
804
837
  }
@@ -810,7 +843,8 @@ export class ContinenteServer {
810
843
  }
811
844
  }
812
845
 
813
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
846
+ // npm bins run through symlinks, so compare the resolved path.
847
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
814
848
  const server = new ContinenteServer();
815
849
  server.start().catch(error => { console.error(error.message); process.exitCode = 1; });
816
850
  }
@@ -0,0 +1,44 @@
1
+ export const normalizeFavoriteId = value => String(value ?? '').replace(/\.html$/, '');
2
+
3
+ export function createQueue() {
4
+ let tail = Promise.resolve();
5
+ return operation => {
6
+ const result = tail.then(operation);
7
+ tail = result.catch(() => {});
8
+ return result;
9
+ };
10
+ }
11
+
12
+ export function validateToolInput(name, args = {}) {
13
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Invalid arguments');
14
+ if (['add_to_cart', 'update_cart_item'].includes(name)) {
15
+ if (typeof args.product_id !== 'string' || args.product_id.length > 250 ||
16
+ !/^(?:[a-zA-Z0-9]+-)*\d+(?:-master)?(?:\.html)?$/.test(args.product_id)) {
17
+ throw new Error('Invalid product_id: expected a numeric PID or product slug ending in a numeric PID');
18
+ }
19
+ const quantity = args.quantity === undefined && name === 'add_to_cart' ? 1 : args.quantity;
20
+ if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 ||
21
+ (name === 'add_to_cart' && quantity === 0)) {
22
+ throw new Error(name === 'add_to_cart'
23
+ ? 'Quantity must be finite and greater than zero'
24
+ : 'Quantity must be finite and non-negative; zero removes the item');
25
+ }
26
+ }
27
+ if (['search_products', 'get_order_history', 'get_most_bought'].includes(name) && args.limit !== undefined &&
28
+ (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 50)) {
29
+ throw new Error('Limit must be an integer from 1 to 50');
30
+ }
31
+ if (name === 'search_products' &&
32
+ (typeof args.query !== 'string' || !args.query.trim() || args.query.length > 250 || /[\x00-\x1f\x7f]/.test(args.query))) {
33
+ throw new Error('Query must be nonempty text, at most 250 characters, without control characters');
34
+ }
35
+ return args;
36
+ }
37
+
38
+ export async function retryAuthentication(operation, refresh, mutation = false) {
39
+ const first = await operation();
40
+ if (first?.error !== 'not_authenticated' || first.writeAttempted === true ||
41
+ (mutation && first.writeAttempted !== false)) return first;
42
+ if (!(await refresh())) return first;
43
+ return operation();
44
+ }