continentept-mcp 0.6.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,15 @@ 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
+
9
18
  ## [0.6.0] - 2026-10-15
10
19
 
11
20
  ### Changed
@@ -88,7 +97,8 @@ All notable changes to this project are documented here. The format follows
88
97
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
89
98
  - The `basket-planner` skill for planning a shopping list and updating the basket.
90
99
 
91
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.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
92
102
  [0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
93
103
  [0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
94
104
  [0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
@@ -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);
@@ -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
  }
@@ -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
@@ -104,9 +104,11 @@ Returns `productId`, `productSlug`, `name`, `url`, `price`, `secondaryPrice`, `r
104
104
 
105
105
  Cart quantities are always in `saleUnit`. Products priced per kilogram but sold by the piece
106
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. If the website's rules cannot be interpreted
108
- safely, `saleRules` is `{ "status": "unavailable", "error": ... }` and the result is
109
- `partial`. Availability reflects the product page, not delivery-slot stock.
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`.
110
112
 
111
113
  ## Favorites
112
114
 
@@ -119,6 +121,9 @@ automatically when missing, is marked `stale` after 24 hours, and is replaced wh
119
121
  `data`: `favorites`, `fetchedAt`, `expiresAt`, `source` (`cache` or `website`), `freshness`
120
122
  (`fresh` or `stale`), `cacheState` (`empty` or `populated`), `completeness`.
121
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
+
122
127
  ## Cart
123
128
 
124
129
  ### `get_cart`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continentept-mcp",
3
- "version": "0.6.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": [