continentept-mcp 0.6.1 → 0.7.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,42 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] - 2026-10-16
10
+
11
+ ### Changed
12
+
13
+ - The server runs on the MCP server package (`@modelcontextprotocol/server` 2.x) instead of the full SDK, which also installed an HTTP server stack this stdio server never used. Installing it now brings 26 packages instead of 113, and the Claude Desktop extension is about 1.6 MB smaller. Tools, prompts and protocol versions are unchanged; task-augmented tool calls are still refused before they run.
14
+
15
+ ## [0.7.0] - 2026-10-16
16
+
17
+ ### Fixed
18
+
19
+ - `get_product` reported some products as available that could not be bought: the page's structured data said in stock while its add button was disabled. Availability now follows the page's own add button, with the structured data as a fallback.
20
+ - A product page the website redirects to its home page was reported as "no longer sold". It may only be hidden from this session: the same product can open signed in and redirect signed out, or not be sold in your area. The `not_found` message now says so.
21
+
22
+ - Some products sold by the piece but priced per kg, such as onions, were shown in the cart with the wrong number of pieces: 4 onions read as 1, so setting them to 4 always ended with an unknown outcome. For these products the cart's JSON does not count pieces; the quantity is now read from the kilograms on the cart page, and stays unknown when that is not a whole number of pieces.
23
+
24
+ ### Changed
25
+
26
+ - **Breaking:** `search_products` keeps the website's relevance order. Saved favorites are still marked `favorite: true`, but only move to the top with the new `favoritesFirst: true`, and then only within the returned products.
27
+
28
+ ### Added
29
+
30
+ - `search_products` filters by `category`, `excludeCategory` and `brand`, and sorts by `unit_price`. Results have `brand` and `category` (the website's category path), filtered searches read further pages to fill `limit`, and `unknownToFilter` counts products left out only because the results page gave no category or brand for them.
31
+ - `search_products` and `get_product` return `drainedPrice`, the price per kg of drained weight, for packs whose label states a drained weight.
32
+ - `get_product_labels`: ingredients, allergens and nutrition for up to 20 products, as the website's copy of each label declares them. It returns the ingredients verbatim, the words they print in capitals, the allergen and "may contain traces" statements, and nutrients per 100 g or ml. Nothing is inferred.
33
+ - Cart and `preview_basket` lines have `storefrontQuantity` for products sold by the piece but priced by weight: the kilograms in the cart, which are also the estimated weight. `get_cart` text shows it, as in "4 un (about 1.16 kg)".
34
+ - `search_products`, `get_product` and `preview_basket` return `catalogView`: whether the result is the signed-in or the signed-out catalogue, which can differ in availability and in which products open.
35
+ - Search results have `availability`, from the results page: `available` or `unavailable` when its "Indisponível" badge and add button agree, `unknown` otherwise. Unavailable results are marked in the text.
36
+ - `preview_basket`: for a list of products and desired totals, what the cart holds now, what `set_cart_item_quantity` would do, what would be refused and why, availability, pack details and the estimated cost. It changes nothing.
37
+ - `search_products` accepts `cursor`: pass `nextCursor` back with the same query to continue right after the last product returned. Following cursors never skips or repeats a result. Before, products read beyond `limit` were dropped and `nextCursor` could not be used.
38
+ - `search_products`, `get_product` and `get_cart` return `observedAt`, when the website was read.
39
+ - Cart lines and totals have `estimated`: products sold by the piece but priced by weight are charged by the weight picked, so their line total, and then the cart totals, are estimates. `get_cart` text says "about" for them.
40
+
41
+ - `search_products` and `get_product` return `package`, the pack label read into numbers: `count`, `each`, `net` (including any free extra), `drained` and `approximate`. For example, `6 x 1 lt` is 6 items of 1 l, 6 l in total. A label with an unknown part gives `null`.
42
+ - `get_product` returns `packageText` and `minimumText`, like `search_products`, and shows the pack size in its text.
43
+ - A `not_authenticated` error before any cart write carries `signIn`: whether credentials are configured, what to do next, the settings and command involved, and which tools work without signing in.
44
+
9
45
  ## [0.6.1] - 2026-10-15
10
46
 
11
47
  ### Fixed
@@ -97,7 +133,9 @@ All notable changes to this project are documented here. The format follows
97
133
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
98
134
  - The `basket-planner` skill for planning a shopping list and updating the basket.
99
135
 
100
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.6.1...HEAD
136
+ [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.7.1...HEAD
137
+ [0.7.1]: https://github.com/andredelgado/continente-mcp/compare/v0.7.0...v0.7.1
138
+ [0.7.0]: https://github.com/andredelgado/continente-mcp/compare/v0.6.1...v0.7.0
101
139
  [0.6.1]: https://github.com/andredelgado/continente-mcp/compare/v0.6.0...v0.6.1
102
140
  [0.6.0]: https://github.com/andredelgado/continente-mcp/compare/v0.5.0...v0.6.0
103
141
  [0.5.0]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...v0.5.0
package/README.md CHANGED
@@ -116,10 +116,12 @@ Continente cart"* or *"What do I usually buy? Build me a list for this week."*
116
116
 
117
117
  | Tool | What it does |
118
118
  | --- | --- |
119
- | `search_products` | Search the catalogue; favorites rank first |
120
- | `get_product` | Price, availability and sale rules for one product |
119
+ | `search_products` | Search the catalogue in relevance order, with favorites marked and a cursor for more |
120
+ | `get_product` | Price, pack size, availability and sale rules for one product |
121
+ | `get_product_labels` | Ingredients, allergens and nutrition as the label declares them |
121
122
  | `list_favorites` | Your saved favorites (`refresh: true` fetches the latest) |
122
123
  | `get_cart` | Cart lines, quantities, bags and totals |
124
+ | `preview_basket` | What a list of changes would do and cost, without changing the cart |
123
125
  | `add_cart_item` | Add an amount on top of what is in the cart |
124
126
  | `set_cart_item_quantity` | Make the cart hold exactly a quantity; zero removes |
125
127
  | `remove_cart_item` | Remove a line from the cart |
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
4
4
  import { createShoppingApplication } from './runtime.js';
5
5
  import { runAutoLogin } from './auto-login.js';
6
6
  import { isMainModule } from './main-module.js';
@@ -1,5 +1,5 @@
1
- import type { CatalogGateway, FavoritesGateway, CartGateway, OrdersGateway, PreparedCartMutation, RetrievedPages } from '../ports.js';
2
- import type { Result, ToolError } from '../domain/index.js';
1
+ import type { CatalogGateway, FavoritesGateway, CartGateway, OrdersGateway, PreparedCartMutation, RetrievedPages, SearchResume } from '../ports.js';
2
+ import type { Product, Result, ToolError } from '../domain/index.js';
3
3
  import type { BrowserLease, StorefrontSession } from '../session/browser.js';
4
4
  export declare const CART_SUMMARY_URL = "https://www.continente.pt/on/demandware.store/Sites-continente-Site/default/Cart-MiniCartShow";
5
5
  export declare class StorefrontClient {
@@ -26,8 +26,17 @@ export declare class CatalogClient implements CatalogGateway {
26
26
  html: string;
27
27
  url: string;
28
28
  }, ToolError>>;
29
- searchProducts(query: string, limit: number): Promise<Result<RetrievedPages, ToolError>>;
29
+ fetchProductLabel(url: string): Promise<Result<string, ToolError>>;
30
+ /**
31
+ * Read result pages until `limit` products are collected, at most five pages. `resume` continues
32
+ * at a position of the full list: from the results grid when its URL is known, otherwise from
33
+ * the first page, whose earlier results the caller drops.
34
+ */
35
+ searchProducts(query: string, limit: number, resume?: SearchResume, accept?: (product: Product) => boolean): Promise<Result<RetrievedPages, ToolError>>;
30
36
  }
37
+ /** The results grid URL without its position, so a search can continue anywhere in the list. */
38
+ export declare function searchGridBase(url: string): string;
39
+ export declare function searchGridUrl(base: string, start: number): string;
31
40
  export declare class FavoritesClient implements FavoritesGateway {
32
41
  private readonly site;
33
42
  constructor(site: StorefrontClient);
@@ -77,33 +77,57 @@ export class CatalogClient {
77
77
  this.site = site;
78
78
  }
79
79
  async fetchProductDetails(reference) {
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);
80
+ // The website redirects a product page it does not show to this session to its home page. It may be
81
+ // sold elsewhere: the same product can open signed in and redirect signed out.
82
+ const page = await this.site.readPage(productPath(reference), 'h1.product-name, .ct-pdp--prices', url => url.pathname.startsWith('/produto/') ? null : failure('not_found', 'The website does not show this product to this session: it may not be sold in your area, or no longer be sold').error);
82
83
  if (!page.ok)
83
84
  return page;
84
85
  return page.value.ready ? success({ html: page.value.html, url: page.value.url }) : failure('invalid_data', 'Product detail content is unavailable');
85
86
  }
86
- async searchProducts(query, limit) {
87
- const first = await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
87
+ async fetchProductLabel(url) {
88
+ const href = storefrontUrl(url);
89
+ if (!href || !new URL(href).pathname.endsWith('/Product-ProductNutritionalInfoTab'))
90
+ return failure('invalid_input', 'Invalid product label URL');
91
+ return readContinuation(this.site, href);
92
+ }
93
+ /**
94
+ * Read result pages until `limit` products are collected, at most five pages. `resume` continues
95
+ * at a position of the full list: from the results grid when its URL is known, otherwise from
96
+ * the first page, whose earlier results the caller drops.
97
+ */
98
+ async searchProducts(query, limit, resume, accept = () => true) {
99
+ const from = resume?.start ?? 0;
100
+ const resumeUrl = resume?.grid ? searchGridUrl(resume.grid, from) : null;
101
+ const first = resumeUrl ? await readContinuation(this.site, resumeUrl)
102
+ : await this.site.html(`/pesquisa/?q=${encodeURIComponent(query)}`, '.ct-inner-tile-wrap, .search-noresults-wrapper, .search-no-results, [data-search-empty]');
88
103
  if (!first.ok)
89
104
  return first;
90
105
  const pages = [];
106
+ const starts = [];
91
107
  const failures = [];
92
108
  const ids = new Set();
109
+ const seen = new Set();
93
110
  const visited = new Set();
94
111
  let html = first.value;
112
+ let start = resumeUrl ? from : 0;
113
+ let grid = resume?.grid ?? null;
95
114
  let nextCursor = null;
96
115
  for (let index = 0; index < 5; index++) {
97
- const parsed = parseProductSearchPage(html);
116
+ const parsed = parseProductSearchPage(html, start);
98
117
  if (!parsed.ok) {
99
118
  if (!pages.length)
100
119
  return parsed;
101
120
  failures.push(parsed.error);
102
121
  break;
103
122
  }
104
- const previousCount = ids.size;
105
- parsed.value.products.forEach(product => ids.add(product.productId));
123
+ const previousCount = seen.size;
124
+ parsed.value.products.forEach((product, position) => {
125
+ seen.add(product.productId);
126
+ if ((parsed.value.positions[position] ?? 0) >= from && accept(product))
127
+ ids.add(product.productId);
128
+ });
106
129
  pages.push(html);
130
+ starts.push(start);
107
131
  reportProgress(pages.length, 5, 'Reading catalogue pages');
108
132
  const continuation = parseSearchPagination(html);
109
133
  if (!continuation.ok) {
@@ -112,25 +136,41 @@ export class CatalogClient {
112
136
  break;
113
137
  }
114
138
  nextCursor = continuation.value?.url ?? null;
115
- if (!nextCursor || ids.size >= limit)
139
+ if (continuation.value)
140
+ grid ??= searchGridBase(continuation.value.url);
141
+ if (!continuation.value || ids.size >= limit)
116
142
  break;
117
- if (visited.has(nextCursor) || (index > 0 && ids.size === previousCount)) {
143
+ if (visited.has(continuation.value.url) || (index > 0 && seen.size === previousCount)) {
118
144
  failures.push(failure('invalid_data', 'Search pagination did not advance').error);
119
145
  break;
120
146
  }
121
147
  if (index === 4)
122
148
  break;
123
- visited.add(nextCursor);
124
- const response = await readContinuation(this.site, nextCursor);
149
+ visited.add(continuation.value.url);
150
+ const response = await readContinuation(this.site, continuation.value.url);
125
151
  if (!response.ok) {
126
152
  failures.push(response.error);
127
153
  break;
128
154
  }
129
155
  html = response.value;
156
+ start = continuation.value.start;
130
157
  }
131
- return success({ pages, failures, nextCursor, completeness: failures.length ? 'partial' : nextCursor ? 'unknown' : 'complete' });
158
+ return success({ pages, starts, grid, failures, nextCursor, completeness: failures.length ? 'partial' : nextCursor ? 'unknown' : 'complete' });
132
159
  }
133
160
  }
161
+ /** The results grid URL without its position, so a search can continue anywhere in the list. */
162
+ export function searchGridBase(url) {
163
+ const base = new URL(url);
164
+ base.searchParams.delete('start');
165
+ base.searchParams.delete('sz');
166
+ return base.href;
167
+ }
168
+ export function searchGridUrl(base, start) {
169
+ const url = new URL(base);
170
+ url.searchParams.set('start', String(start));
171
+ url.searchParams.set('sz', '35');
172
+ return url.href;
173
+ }
134
174
  async function readContinuation(site, url) {
135
175
  try {
136
176
  const lease = await site.session.ensureReady();