basketeer 0.1.2 → 0.3.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/README.md CHANGED
@@ -93,6 +93,10 @@ if (top) {
93
93
  console.log(product.title, product.price.actual);
94
94
  // => "Tesco Wholemeal Bread 800G" 0.75
95
95
  }
96
+
97
+ // Fetch up to 15 SKUs in one throttled HTTP request. Duplicates are fetched
98
+ // once and missing products are omitted.
99
+ const products = await client.getProducts(["282822189", "275280804"]);
96
100
  ```
97
101
 
98
102
  ### Authenticated, sign in once, then pure HTTP
@@ -140,7 +144,7 @@ console.log("Finish payment in a browser:", url);
140
144
  The full grocery lifecycle, typed end to end:
141
145
 
142
146
  - **Nutrition** — typed macros and structured micros, normalized from a product's on-pack rows when present; filter and rank a search by nutrition (anonymous)
143
- - **Catalogue** — `search`, `getProduct`, `browseCategory` (anonymous); `favourites` / "my usuals" (authed)
147
+ - **Catalogue** — `search`, `getProduct`, batched `getProducts`, `browseCategory` (anonymous); `favourites` / "my usuals" (authed)
144
148
  - **Product images** — `imageUrl` on every product/result; `resizeImageUrl(url, { width, height })` for thumbnails (anonymous)
145
149
  - **Basket** — `add`, `set`, `remove`, `get`
146
150
  - **Slots** — delivery and collection: `list` / `book` / `release`
@@ -192,11 +196,16 @@ await client.orders.cancel(orders[0]!.orderNo);
192
196
  // "Reorder my usual shop":
193
197
  const last = await client.orders.lastFulfilled();
194
198
  for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.quantity, it.unit ?? "pcs");
199
+
200
+ // Completed orders, newest first, offset-paged (Tesco has no cursor or total).
201
+ // The result set is LIVE — dedupe by order.id and never persist nextOffset.
202
+ let page = await client.orders.history(); // { orders, nextOffset }
203
+ while (page.nextOffset !== null) page = await client.orders.history({ offset: page.nextOffset });
195
204
  ```
196
205
 
197
206
  ## MCP server (for AI agents)
198
207
 
199
- A stdio MCP server ships as the `basketeer-mcp` bin, exposing tools (`basketeer_search`, `basketeer_search_by_nutrition`, `basketeer_nutrition`, `basketeer_basket_set`, `basketeer_slots_list`, `basketeer_orders_list`, `basketeer_checkout`, …) so Claude Desktop or any MCP client can shop. Read-only tools carry `readOnlyHint`; mutating ones carry `destructiveHint`, and `basketeer_orders_cancel` / `basketeer_checkout` take a two-step confirm token. `basketeer_checkout` returns the payment URL for the human. There is no "pay" tool.
208
+ A stdio MCP server ships as the `basketeer-mcp` bin, exposing tools (`basketeer_search`, `basketeer_search_by_nutrition`, `basketeer_nutrition`, `basketeer_basket_set`, `basketeer_slots_list`, `basketeer_orders_list`, `basketeer_checkout`, …) so Claude Desktop or any MCP client can shop. Read-only tools carry `readOnlyHint`; mutating ones carry `destructiveHint`, and `basketeer_orders_cancel` / `basketeer_checkout` take a two-step confirm token. `basketeer_checkout` returns the payment URL for the human. There is no "pay" tool. The search tools take an optional `select` — an array of dot-notation paths (e.g. `["sku", "title", "price.actual", "promotions.description"]`) that trims each result to just those fields, keeping token usage down in agent loops.
200
209
 
201
210
  ```jsonc
202
211
  // claude_desktop_config.json — run `basketeer login` once first so it has a session.
package/dist/cli.js CHANGED
@@ -183,8 +183,15 @@ orders
183
183
  const items = last.items
184
184
  .filter((it) => it.productId)
185
185
  .map((it) => ({ id: it.productId, newValue: it.quantity, newUnitChoice: it.unit ?? "pcs" }));
186
- const basketResult = await client.basket.update(items);
187
- emit({ ok: true, reorderedFrom: last.orderNo, lines: items.length, basket: basketResult });
186
+ const { basket, rejected, unavailable } = await client.basket.update(items);
187
+ emit({
188
+ ok: !rejected.length && !unavailable.length,
189
+ reorderedFrom: last.orderNo,
190
+ lines: items.length,
191
+ rejected,
192
+ unavailable,
193
+ basket,
194
+ });
188
195
  });
189
196
  // --- checkout ---------------------------------------------------------------
190
197
  program
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AuthBackend, Credentials } from "./auth/types.js";
2
- import type { Basket, BookedSlot, NutritionFilter, NutritionSort, Order, Product, SearchPage, Session, Slot } from "./models.js";
2
+ import type { Basket, BasketUpdateResult, BookedSlot, NutritionFilter, NutritionSort, Order, OrderHistoryPage, Product, SearchPage, Session, Slot } from "./models.js";
3
3
  import { type FulfilmentType, type OrderContext } from "./operations.js";
4
4
  import type { TokenStore } from "./store/types.js";
5
5
  export interface BasketeerOptions {
@@ -16,6 +16,10 @@ export interface BasketeerOptions {
16
16
  /** Injected for tests; defaults to global fetch. */
17
17
  fetchImpl?: typeof fetch;
18
18
  }
19
+ /** Tesco's observed maximum number of aliased product fields in one operation. */
20
+ export declare const MAX_PRODUCT_BATCH_SIZE = 15;
21
+ /** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
22
+ export declare const MAX_HISTORY_PAGE_SIZE = 100;
19
23
  /** A raw basket line input for the low-level `basket.update`. */
20
24
  export interface BasketItemInput {
21
25
  id: string;
@@ -56,6 +60,11 @@ export declare class Basketeer {
56
60
  /** The order currently open for amendment via `orders.amend`, if any. */
57
61
  get amendingOrderNo(): string | null;
58
62
  getProduct(sku: string): Promise<Product>;
63
+ /**
64
+ * Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
65
+ * fetched once and missing products are omitted from the returned array.
66
+ */
67
+ getProducts(skus: readonly string[]): Promise<Product[]>;
59
68
  search(query: string, opts?: {
60
69
  limit?: number;
61
70
  page?: number;
@@ -105,16 +114,34 @@ export declare class Basketeer {
105
114
  set: (sku: string, quantity: number, unit?: string) => Promise<Basket>;
106
115
  /** Remove `sku` from the basket. */
107
116
  remove: (sku: string) => Promise<Basket>;
108
- /** Low-level: send raw basket line inputs (and an optional orderId). */
109
- update: (items: BasketItemInput[], orderId?: string) => Promise<Basket>;
117
+ /**
118
+ * Low-level: send raw basket line inputs (and an optional orderId).
119
+ * A batch can partly succeed, so line-level outcomes (`rejected`,
120
+ * `unavailable`) are reported on the result rather than thrown.
121
+ */
122
+ update: (items: BasketItemInput[], orderId?: string) => Promise<BasketUpdateResult>;
110
123
  };
111
124
  private getBasket;
125
+ /** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
126
+ private updateLine;
112
127
  private updateBasket;
113
128
  readonly orders: {
114
129
  /** List upcoming (pending) orders with their items, slot, and amend window. */
115
130
  list: (opts?: {
116
131
  contexts?: readonly OrderContext[];
117
132
  }) => Promise<Order[]>;
133
+ /**
134
+ * One page of completed (previous) orders, newest first. `limit` defaults
135
+ * to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
136
+ * LIVE result set: follow `nextOffset` within a single traversal, dedupe
137
+ * by `Order.id`, and never persist an offset across runs — a new completed
138
+ * order shifts every later offset.
139
+ */
140
+ history: (opts?: {
141
+ offset?: number;
142
+ limit?: number;
143
+ contexts?: readonly OrderContext[];
144
+ }) => Promise<OrderHistoryPage>;
118
145
  /**
119
146
  * Open an order for amendment. Returns an {@link Amendment} handle whose
120
147
  * basket ops apply to THAT order; finish by checking out again, or call
@@ -131,6 +158,7 @@ export declare class Basketeer {
131
158
  private cancelOrder;
132
159
  private lastFulfilledOrder;
133
160
  private listOrders;
161
+ private orderHistory;
134
162
  private orderAction;
135
163
  /**
136
164
  * Prepare for payment. Returns the current basket and the URL where payment
package/dist/client.js CHANGED
@@ -1,10 +1,14 @@
1
- import { BasketeerError, LineRejectedError, NotFoundError } from "./errors.js";
1
+ import { BasketeerError, ItemUnavailableError, LineRejectedError, NotFoundError, } from "./errors.js";
2
2
  import { GraphQLTransport } from "./graphql.js";
3
3
  import { filterByNutrition } from "./nutrition.js";
4
- import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, } from "./operations.js";
4
+ import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, } from "./operations.js";
5
5
  import { isoDate, parseBasket, parseBookedSlot, parseOrder, parseProduct, parseProductNode, parseSlot, } from "./parsers.js";
6
- import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_PRODUCT, GET_UPCOMING_ORDERS, SEARCH, UPDATE_BASKET, } from "./queries.js";
6
+ import { AMEND_ORDER, CANCEL_AMEND, CANCEL_ORDER, COLLECTION_SLOTS, DELIVERY_SLOTS, FULFILMENT, GET_BASKET, GET_CATEGORY_PRODUCTS, GET_FAVOURITES, GET_LAST_FULFILLED_ORDER, GET_ORDER_HISTORY, GET_PRODUCT, GET_UPCOMING_ORDERS, getProductsQuery, SEARCH, UPDATE_BASKET, } from "./queries.js";
7
7
  import { SessionManager } from "./session-manager.js";
8
+ /** Tesco's observed maximum number of aliased product fields in one operation. */
9
+ export const MAX_PRODUCT_BATCH_SIZE = 15;
10
+ /** Basketeer's policy cap on `orders.history` page size — not a discovered Tesco limit. */
11
+ export const MAX_HISTORY_PAGE_SIZE = 100;
8
12
  /** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
9
13
  function reqInt(v, name, min) {
10
14
  if (v === undefined)
@@ -85,6 +89,26 @@ export class Basketeer {
85
89
  throw new NotFoundError(`No product found for SKU ${sku}`);
86
90
  return parseProduct(data.product);
87
91
  }
92
+ /**
93
+ * Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
94
+ * fetched once and missing products are omitted from the returned array.
95
+ */
96
+ async getProducts(skus) {
97
+ const uniqueSkus = [...new Set(skus.map(String))];
98
+ if (uniqueSkus.length === 0)
99
+ return [];
100
+ if (uniqueSkus.length > MAX_PRODUCT_BATCH_SIZE) {
101
+ throw new RangeError(`skus must contain at most ${MAX_PRODUCT_BATCH_SIZE} unique values`);
102
+ }
103
+ const variables = Object.fromEntries(uniqueSkus.map((sku, i) => [`tpnc${i}`, sku]));
104
+ const data = await this.transport.execute({
105
+ operationName: "GetProducts",
106
+ query: getProductsQuery(uniqueSkus.length),
107
+ variables,
108
+ mfeName: MFE.product,
109
+ });
110
+ return uniqueSkus.flatMap((_, i) => (data[`p${i}`] ? [parseProduct(data[`p${i}`])] : []));
111
+ }
88
112
  async search(query, opts = {}) {
89
113
  reqInt(opts.limit, "limit", 1);
90
114
  reqInt(opts.page, "page", 1);
@@ -176,17 +200,21 @@ export class Basketeer {
176
200
  if (!Number.isInteger(quantity) || quantity < 1)
177
201
  throw new RangeError("quantity must be an integer >= 1");
178
202
  const current = (await this.getBasket()).items.find((i) => i.sku === sku)?.quantity ?? 0;
179
- return this.updateBasket([{ id: sku, newValue: current + quantity, newUnitChoice: unit }]);
203
+ return this.updateLine({ id: sku, newValue: current + quantity, newUnitChoice: unit });
180
204
  },
181
205
  /** Set the line for `sku` to an exact `quantity` (0 removes it). */
182
206
  set: (sku, quantity, unit = "pcs") => {
183
207
  if (!Number.isInteger(quantity) || quantity < 0)
184
208
  throw new RangeError("quantity must be an integer >= 0");
185
- return this.updateBasket([{ id: sku, newValue: quantity, newUnitChoice: unit }]);
209
+ return this.updateLine({ id: sku, newValue: quantity, newUnitChoice: unit });
186
210
  },
187
211
  /** Remove `sku` from the basket. */
188
- remove: (sku) => this.updateBasket([{ id: sku, newValue: 0 }]),
189
- /** Low-level: send raw basket line inputs (and an optional orderId). */
212
+ remove: (sku) => this.updateLine({ id: sku, newValue: 0 }),
213
+ /**
214
+ * Low-level: send raw basket line inputs (and an optional orderId).
215
+ * A batch can partly succeed, so line-level outcomes (`rejected`,
216
+ * `unavailable`) are reported on the result rather than thrown.
217
+ */
190
218
  update: (items, orderId) => this.updateBasket(items, orderId),
191
219
  };
192
220
  async getBasket() {
@@ -198,6 +226,15 @@ export class Basketeer {
198
226
  });
199
227
  return parseBasket(data.basket);
200
228
  }
229
+ /** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
230
+ async updateLine(item) {
231
+ const { basket, rejected, unavailable } = await this.updateBasket([item]);
232
+ if (rejected.length)
233
+ throw new LineRejectedError(rejected.join(", "));
234
+ if (unavailable.length)
235
+ throw new ItemUnavailableError(unavailable);
236
+ return basket;
237
+ }
201
238
  async updateBasket(items, orderId) {
202
239
  for (const item of items)
203
240
  reqQty(item.newValue, "quantity");
@@ -212,14 +249,34 @@ export class Basketeer {
212
249
  });
213
250
  const updates = data.basket?.updates?.items ?? [];
214
251
  const rejected = updates.filter((u) => u.successful === false).map((u) => String(u.id));
215
- if (rejected.length)
216
- throw new LineRejectedError(rejected.join(", "));
217
- return parseBasket(data.basket);
252
+ const basket = parseBasket(data.basket);
253
+ // Tesco accepts unavailable lines silently (updates.successful stays true)
254
+ // then drops them at checkout. Mirror its UI: among the SKUs we just added
255
+ // (newValue > 0), roll back any the basket reports as unavailable and
256
+ // report them on the result.
257
+ const added = new Set(items.filter((i) => i.newValue > 0).map((i) => String(i.id)));
258
+ const unavailable = basket.items
259
+ .filter((l) => l.sku != null && added.has(l.sku) && l.available === false)
260
+ .map((l) => l.sku);
261
+ if (unavailable.length) {
262
+ // The rollback lines are all newValue 0, so this cannot recurse further.
263
+ const rollback = await this.updateBasket(unavailable.map((id) => ({ id, newValue: 0 })), orderId);
264
+ return { basket: rollback.basket, rejected, unavailable };
265
+ }
266
+ return { basket, rejected, unavailable };
218
267
  }
219
268
  // --- orders (requires auth) -----------------------------------------------
220
269
  orders = {
221
270
  /** List upcoming (pending) orders with their items, slot, and amend window. */
222
271
  list: (opts = {}) => this.listOrders(opts),
272
+ /**
273
+ * One page of completed (previous) orders, newest first. `limit` defaults
274
+ * to 25 (max {@link MAX_HISTORY_PAGE_SIZE}). Offset pagination runs over a
275
+ * LIVE result set: follow `nextOffset` within a single traversal, dedupe
276
+ * by `Order.id`, and never persist an offset across runs — a new completed
277
+ * order shifts every later offset.
278
+ */
279
+ history: (opts = {}) => this.orderHistory(opts),
223
280
  /**
224
281
  * Open an order for amendment. Returns an {@link Amendment} handle whose
225
282
  * basket ops apply to THAT order; finish by checking out again, or call
@@ -270,6 +327,29 @@ export class Basketeer {
270
327
  });
271
328
  return (data.orderSearch?.orders ?? []).map(parseOrder);
272
329
  }
330
+ async orderHistory(opts) {
331
+ // Never forward surprising values: Tesco treats count: 0 as UNBOUNDED and
332
+ // returns an empty (terminal-looking) page for a negative offset.
333
+ reqInt(opts.offset, "offset", 0);
334
+ reqInt(opts.limit, "limit", 1);
335
+ if (opts.limit !== undefined && opts.limit > MAX_HISTORY_PAGE_SIZE)
336
+ throw new RangeError(`limit must be an integer <= ${MAX_HISTORY_PAGE_SIZE}`);
337
+ const offset = opts.offset ?? 0;
338
+ const limit = opts.limit ?? 25;
339
+ const data = await this.transport.execute({
340
+ operationName: "GetOrderHistory",
341
+ query: GET_ORDER_HISTORY,
342
+ variables: { orderContexts: opts.contexts ?? PREVIOUS_ORDER_CONTEXTS, count: limit, offset },
343
+ mfeName: MFE.orders,
344
+ });
345
+ // Strict, unlike list(): a short page is this API's only terminal signal,
346
+ // so a malformed response must not masquerade as one.
347
+ const raw = data.orderSearch?.orders;
348
+ if (!Array.isArray(raw))
349
+ throw new BasketeerError("GetOrderHistory returned no orderSearch.orders array");
350
+ const orders = raw.map(parseOrder);
351
+ return { orders, nextOffset: orders.length < limit ? null : offset + orders.length };
352
+ }
273
353
  async orderAction(query, operationName, orderNo) {
274
354
  await this.transport.execute({
275
355
  operationName,
package/dist/errors.d.ts CHANGED
@@ -38,3 +38,15 @@ export declare class LineRejectedError extends BasketeerError {
38
38
  readonly lineId: string;
39
39
  constructor(lineId: string);
40
40
  }
41
+ /**
42
+ * The SKU passed to `basket.add`/`set` is unavailable for the basket's
43
+ * slot/store (Tesco's `isForSale` is false). Tesco silently accepts these on
44
+ * write then drops them at checkout, so the client rolls the line back and
45
+ * throws this instead. The batch `basket.update` does not throw this — it
46
+ * reports unavailable SKUs on its result. Availability is slot-specific —
47
+ * see {@link Product.available}.
48
+ */
49
+ export declare class ItemUnavailableError extends BasketeerError {
50
+ readonly skus: string[];
51
+ constructor(skus: string[]);
52
+ }
package/dist/errors.js CHANGED
@@ -62,3 +62,18 @@ export class LineRejectedError extends BasketeerError {
62
62
  this.lineId = lineId;
63
63
  }
64
64
  }
65
+ /**
66
+ * The SKU passed to `basket.add`/`set` is unavailable for the basket's
67
+ * slot/store (Tesco's `isForSale` is false). Tesco silently accepts these on
68
+ * write then drops them at checkout, so the client rolls the line back and
69
+ * throws this instead. The batch `basket.update` does not throw this — it
70
+ * reports unavailable SKUs on its result. Availability is slot-specific —
71
+ * see {@link Product.available}.
72
+ */
73
+ export class ItemUnavailableError extends BasketeerError {
74
+ skus;
75
+ constructor(skus) {
76
+ super(`Unavailable for your slot, not added: ${skus.join(", ")}`);
77
+ this.skus = skus;
78
+ }
79
+ }
package/dist/index.d.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
2
2
  export type { AuthBackend, Credentials, OtpProvider } from "./auth/types.js";
3
3
  export type { Amendment, BasketeerOptions, BasketItemInput } from "./client.js";
4
- export { Basketeer } from "./client.js";
5
- export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
4
+ export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
5
+ export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
6
6
  export type { ImageSize } from "./images.js";
7
7
  export { resizeImageUrl } from "./images.js";
8
- export type { Basket, BasketLine, BookedSlot, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderItem, OrderSlot, PackSize, Price, Product, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
8
+ export type { Basket, BasketLine, BasketUpdateResult, BookedSlot, CatchWeightOption, MacroFilterKey, Macros, Micronutrient, Nutrition, NutritionBasis, NutritionFilter, NutritionSort, Order, OrderAddress, OrderHistoryPage, OrderItem, OrderSlot, PackSize, Price, Product, ProductQuantityRules, Promotion, Range, SearchPage, SearchResult, Session, Slot, } from "./models.js";
9
9
  export { filterByNutrition, parseNutrition } from "./nutrition.js";
10
10
  export type { FulfilmentType, OrderContext } from "./operations.js";
11
- export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
11
+ export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
12
12
  export { FileTokenStore } from "./store/file-store.js";
13
13
  export { MemoryTokenStore } from "./store/memory-store.js";
14
14
  export type { TokenStore } from "./store/types.js";
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  // Public API surface. Everything exported here is part of the semver contract;
2
2
  // internal modules (transport, queries, parsers, session-manager) are not.
3
3
  export { keepCookie, sessionFromCookies } from "./auth/harvest.js";
4
- export { Basketeer } from "./client.js";
4
+ export { Basketeer, MAX_HISTORY_PAGE_SIZE, MAX_PRODUCT_BATCH_SIZE } from "./client.js";
5
5
  // Errors
6
- export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
6
+ export { ApiKeyError, AuthExpiredError, BasketeerError, GraphQLRequestError, ItemUnavailableError, LineRejectedError, NotFoundError, RateLimitedError, } from "./errors.js";
7
7
  export { resizeImageUrl } from "./images.js";
8
8
  // Nutrition
9
9
  export { filterByNutrition, parseNutrition } from "./nutrition.js";
10
10
  // Config & helpers
11
- export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
11
+ export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PREVIOUS_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
12
12
  // Storage seam
13
13
  export { FileTokenStore } from "./store/file-store.js";
14
14
  export { MemoryTokenStore } from "./store/memory-store.js";
@@ -28,9 +28,15 @@ import { z } from "zod";
28
28
  import { BrowserAuthBackend } from "./auth/browser/playwright.js";
29
29
  import { Basketeer, BasketeerError, FileTokenStore } from "./index.js";
30
30
  import { isMainModule } from "./is-main.js";
31
- /** Wrap a value as MCP JSON text content. */
31
+ import { PRODUCT_FIELDS, projectResults, SEARCH_RESULT_FIELDS } from "./select.js";
32
+ /**
33
+ * Wrap a value as MCP JSON text content. `raw` GraphQL escape hatches are
34
+ * omitted at any depth — they duplicate the parsed fields and waste agent
35
+ * context (issue #9).
36
+ */
32
37
  function json(value) {
33
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
38
+ const text = JSON.stringify(value, (key, v) => (key === "raw" ? undefined : v), 2);
39
+ return { content: [{ type: "text", text }] };
34
40
  }
35
41
  /** Run a tool body, mapping SDK errors to a clean MCP error result. */
36
42
  async function run(fn) {
@@ -46,6 +52,30 @@ async function run(fn) {
46
52
  };
47
53
  }
48
54
  }
55
+ /** Zod param for `select` — the dot-notation projection on search results (issue #10). */
56
+ function selectParam(fields) {
57
+ return z
58
+ .array(z.string())
59
+ .nonempty()
60
+ .optional()
61
+ .describe("Optional list of dot-notation paths to keep in each result (token saver), " +
62
+ 'e.g. ["sku", "title", "price.actual", "promotions.description"]. ' +
63
+ `Top-level fields: ${fields.join(", ")}. Omit for full results.`);
64
+ }
65
+ /**
66
+ * Compact mutation receipt (issue #9): basket writes confirm rather than
67
+ * re-enumerate, so per-call output stays flat as the basket grows. Full state
68
+ * stays one `basketeer_basket_get` call away.
69
+ */
70
+ function receipt(basket, sku) {
71
+ return {
72
+ sku,
73
+ quantity: basket.items.find((i) => i.sku === sku)?.quantity ?? 0,
74
+ itemCount: basket.items.length,
75
+ guidePrice: basket.guidePrice,
76
+ isInAmend: basket.isInAmend,
77
+ };
78
+ }
49
79
  /** Short deterministic token an agent must echo back to confirm a destructive action. */
50
80
  export function confirmToken(parts) {
51
81
  return createHash("sha256").update(parts).digest("hex").slice(0, 8);
@@ -57,12 +87,18 @@ export function confirmToken(parts) {
57
87
  export function buildServer(client) {
58
88
  const server = new McpServer({ name: "basketeer", version: "0.1.1" });
59
89
  // --- reads (anonymous OK) -------------------------------------------------
60
- server.tool("basketeer_search", "Search the Tesco grocery catalogue. Returns matching products with SKU, title, price, and any offers.", {
90
+ server.tool("basketeer_search", "Search the Tesco grocery catalogue. Returns matching products with SKU, title, price, offers, and quantityRules (weight/bulk buy limits).", {
61
91
  query: z.string().describe("Search terms, e.g. 'semi skimmed milk'."),
62
92
  limit: z.number().int().positive().optional(),
63
- }, { readOnlyHint: true }, ({ query, limit }) => run(() => client.search(query, { limit })));
64
- server.tool("basketeer_product", "Fetch one product by SKU (tpnc), including price, pack size, promotions, and nutrition.", { sku: z.string().describe("The product SKU (tpnc), e.g. from a search result.") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku)));
65
- server.tool("basketeer_favourites", "List the signed-in customer's favourites ('my usuals'). Requires a saved session.", { limit: z.number().int().positive().optional() }, { readOnlyHint: true }, ({ limit }) => run(() => client.favourites({ limit })));
93
+ select: selectParam(SEARCH_RESULT_FIELDS),
94
+ }, { readOnlyHint: true }, ({ query, limit, select }) => run(async () => {
95
+ const page = await client.search(query, { limit });
96
+ return select
97
+ ? { ...page, results: projectResults(page.results, select, SEARCH_RESULT_FIELDS) }
98
+ : page;
99
+ }));
100
+ server.tool("basketeer_product", "Fetch one product by SKU (tpnc), including price, pack size, promotions, quantityRules (weight/bulk buy/catch weight options), and nutrition.", { sku: z.string().describe("The product SKU (tpnc), e.g. from a search result.") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku)));
101
+ server.tool("basketeer_favourites", "List the signed-in customer's favourites ('my usuals') including price, offers, and quantityRules. Requires a saved session.", { limit: z.number().int().positive().optional() }, { readOnlyHint: true }, ({ limit }) => run(() => client.favourites({ limit })));
66
102
  server.tool("basketeer_nutrition", "Normalized nutrition (typed macros + micros) for a product by SKU.", { sku: z.string().describe("The product SKU (tpnc).") }, { readOnlyHint: true }, ({ sku }) => run(() => client.getProduct(sku).then((p) => p.nutrition)));
67
103
  server.tool("basketeer_search_by_nutrition", "Search products by keyword, then filter/rank by nutrition (per 100g/ml). " +
68
104
  "Hydrates each candidate with a throttled product fetch; bounded by `hydrate`.", {
@@ -82,22 +118,28 @@ export function buildServer(client) {
82
118
  .positive()
83
119
  .optional()
84
120
  .describe("Max results to return after filtering."),
85
- }, { readOnlyHint: true }, ({ query, minProtein, maxSugar, sortBy, hydrate, limit }) => run(() => {
121
+ select: selectParam(PRODUCT_FIELDS),
122
+ }, { readOnlyHint: true }, ({ query, minProtein, maxSugar, sortBy, hydrate, limit, select }) => run(async () => {
86
123
  const where = {};
87
124
  if (minProtein != null)
88
125
  where.protein = { min: minProtein };
89
126
  if (maxSugar != null)
90
127
  where.sugars = { max: maxSugar };
91
128
  const sort = sortBy ? { by: sortBy, dir: "desc" } : undefined;
92
- return client.searchByNutrition(query, { where, sort, hydrate, limit });
129
+ const page = await client.searchByNutrition(query, { where, sort, hydrate, limit });
130
+ return select
131
+ ? { ...page, results: projectResults(page.results, select, PRODUCT_FIELDS) }
132
+ : page;
93
133
  }));
94
134
  // --- basket (requires a saved session) ------------------------------------
95
135
  server.tool("basketeer_basket_get", "Get the current basket: line items, quantities, and guide price.", {}, { readOnlyHint: true }, () => run(() => client.basket.get()));
96
- server.tool("basketeer_basket_set", "Set a SKU's basket line to an exact quantity (set-not-increment). quantity 0 removes the line.", {
136
+ server.tool("basketeer_basket_set", "Set a SKU's basket line to an exact quantity (set-not-increment). quantity 0 removes the line. " +
137
+ "Returns a compact receipt; call basketeer_basket_get for full basket contents.", {
97
138
  sku: z.string().describe("The product SKU (tpnc) to set."),
98
139
  quantity: z.number().int().nonnegative().describe("Exact quantity to set; 0 removes."),
99
- }, { readOnlyHint: false, destructiveHint: true }, ({ sku, quantity }) => run(() => client.basket.set(sku, quantity)));
100
- server.tool("basketeer_basket_remove", "Remove a SKU from the basket entirely.", { sku: z.string().describe("The product SKU (tpnc) to remove.") }, { readOnlyHint: false, destructiveHint: true }, ({ sku }) => run(() => client.basket.remove(sku)));
140
+ }, { readOnlyHint: false, destructiveHint: true }, ({ sku, quantity }) => run(async () => receipt(await client.basket.set(sku, quantity), sku)));
141
+ server.tool("basketeer_basket_remove", "Remove a SKU from the basket entirely. " +
142
+ "Returns a compact receipt; call basketeer_basket_get for full basket contents.", { sku: z.string().describe("The product SKU (tpnc) to remove.") }, { readOnlyHint: false, destructiveHint: true }, ({ sku }) => run(async () => receipt(await client.basket.remove(sku), sku)));
101
143
  // --- slots (requires a saved session) -------------------------------------
102
144
  server.tool("basketeer_slots_list", "List delivery slots over a date window (defaults to today..+6 days). Dates are YYYY-MM-DD.", {
103
145
  start: z.string().optional().describe("Window start, YYYY-MM-DD."),
package/dist/models.d.ts CHANGED
@@ -16,6 +16,20 @@ export interface PackSize {
16
16
  value: number;
17
17
  units: string;
18
18
  }
19
+ export interface CatchWeightOption {
20
+ price: number;
21
+ weight: number;
22
+ default: boolean;
23
+ }
24
+ export interface ProductQuantityRules {
25
+ productType: string | null;
26
+ averageWeight: number | null;
27
+ minWeight: number | null;
28
+ maxWeight: number | null;
29
+ increment: number | null;
30
+ bulkBuyLimit: number | null;
31
+ catchWeightOptions: CatchWeightOption[];
32
+ }
19
33
  export interface Product {
20
34
  /** tpnc — the SKU used for product lookup and basket ops. */
21
35
  sku: string;
@@ -24,7 +38,15 @@ export interface Product {
24
38
  brand: string | null;
25
39
  imageUrl: string | null;
26
40
  price: Price;
41
+ /**
42
+ * Tesco's `isForSale` for the read's context, or null if absent. Anonymous
43
+ * reads report the optimistic NATIONAL answer; reads on a session bound to a
44
+ * booked slot report the real per-store answer (the same SKU can flip to
45
+ * `false`). `null` when Tesco omitted the field.
46
+ */
47
+ available: boolean | null;
27
48
  packSize: PackSize | null;
49
+ quantityRules: ProductQuantityRules;
28
50
  promotions: Promotion[];
29
51
  /** Normalized nutrition, or null if Tesco returned none / it was unparseable. */
30
52
  nutrition: Nutrition | null;
@@ -39,6 +61,9 @@ export interface SearchResult {
39
61
  brand: string | null;
40
62
  imageUrl: string | null;
41
63
  price: Price;
64
+ quantityRules: ProductQuantityRules;
65
+ /** See {@link Product.available} — context-dependent (anonymous = national). */
66
+ available: boolean | null;
42
67
  onOffer: boolean;
43
68
  promotions: Promotion[];
44
69
  }
@@ -56,6 +81,12 @@ export interface BasketLine {
56
81
  quantity: number;
57
82
  unit: string | null;
58
83
  cost: number | null;
84
+ /**
85
+ * Tesco's `isForSale` for this line in the basket's slot/store context, or
86
+ * null if absent. `false` means Tesco will drop it at checkout — see
87
+ * {@link Product.available}.
88
+ */
89
+ available: boolean | null;
59
90
  }
60
91
  export interface Basket {
61
92
  id: string | null;
@@ -67,6 +98,24 @@ export interface Basket {
67
98
  items: BasketLine[];
68
99
  raw: unknown;
69
100
  }
101
+ /**
102
+ * Result of the low-level batch `basket.update`. Line-level outcomes are
103
+ * reported here rather than thrown, because a batch can partly succeed: the
104
+ * remote basket reflects the successful lines even when others fail. The
105
+ * single-SKU conveniences (`add`/`set`/`remove`) throw instead.
106
+ */
107
+ export interface BasketUpdateResult {
108
+ /** The basket after the update (and after any rollback of unavailable lines). */
109
+ basket: Basket;
110
+ /** SKUs Tesco rejected outright (`updates.items[].successful === false`). */
111
+ rejected: string[];
112
+ /**
113
+ * SKUs unavailable for the basket's slot/store (`isForSale` false). Tesco
114
+ * accepts these silently then drops them at checkout, so the client has
115
+ * already rolled these lines back — see {@link Product.available}.
116
+ */
117
+ unavailable: string[];
118
+ }
70
119
  export interface Slot {
71
120
  /** Opaque slot id — pass to slots.book(). */
72
121
  id: string;
@@ -127,6 +176,22 @@ export interface Order {
127
176
  items: OrderItem[];
128
177
  raw: unknown;
129
178
  }
179
+ /**
180
+ * One page of completed-order history. Pagination is offset-based over a
181
+ * LIVE, newest-first result set — a new completed order shifts every later
182
+ * offset — so keep `nextOffset` only for the duration of one traversal and
183
+ * deduplicate by `Order.id`.
184
+ */
185
+ export interface OrderHistoryPage {
186
+ orders: Order[];
187
+ /**
188
+ * Offset for the next page, or null when this page was short (terminal).
189
+ * Non-null only means "another request is needed to know" — Tesco supplies
190
+ * no authoritative has-next-page signal, so when the total is an exact
191
+ * multiple of `limit` the final page is empty.
192
+ */
193
+ nextOffset: number | null;
194
+ }
130
195
  /**
131
196
  * An authenticated session. For the browser-minted hybrid (v1), `accessToken`
132
197
  * is the harvested `OAuth.AccessToken` bearer and `customerUuid` is the `UUID`
package/dist/nutrition.js CHANGED
@@ -40,18 +40,43 @@ function basisFromHeader(v) {
40
40
  }
41
41
  const MACRO_LABELS = {
42
42
  fat: "fat",
43
+ "total fat": "fat",
43
44
  saturates: "saturates",
44
45
  "of which saturates": "saturates",
46
+ "saturated fat": "saturates",
47
+ "of which saturated fat": "saturates",
45
48
  carbohydrate: "carbs",
49
+ carbohydrates: "carbs",
46
50
  "available carbohydrate": "carbs",
47
51
  "total carbohydrate": "carbs",
48
52
  sugars: "sugars",
49
53
  "of which sugars": "sugars",
54
+ "total sugars": "sugars",
50
55
  fibre: "fibre",
51
56
  protein: "protein",
52
57
  salt: "salt",
53
58
  "salt equivalent": "salt",
54
59
  };
60
+ /** Trailing unit in a row name: "Fat (g)", "Energy (kcal)", "Calcium (mg)". */
61
+ const UNIT_IN_NAME = /\s*\(\s*(kj|kcal|g|mg|µg|μg)\s*\)\s*$/i;
62
+ /** Drop a leading dash or unbalanced paren: "- Saturates" / "(of which saturates". */
63
+ function cleanName(name) {
64
+ return name
65
+ .trim()
66
+ .replace(/^-\s*(?=[a-z])/i, "")
67
+ .replace(/^\((?![^(]*\))\s*/, "");
68
+ }
69
+ /** Lookup key for a row label: "(of which: sugars*)" / "Protein g" -> "of which sugars" / "protein". */
70
+ function labelKey(name) {
71
+ return name
72
+ .toLowerCase()
73
+ .replace(/[*†:,]+/g, " ")
74
+ .trim()
75
+ .replace(/(\s+|\s*\()(g|mg|µg|μg)\)?$/, "")
76
+ .replace(/^\((.*)\)$/, "$1")
77
+ .replace(/\s+/g, " ")
78
+ .trim();
79
+ }
55
80
  function isFootnote(name, value) {
56
81
  const n = name.trim();
57
82
  if (n.startsWith("*"))
@@ -62,7 +87,7 @@ function isFootnote(name, value) {
62
87
  return true;
63
88
  if (/reference intake|nutrient reference value/i.test(n))
64
89
  return true;
65
- const known = !!MACRO_LABELS[n.toLowerCase()] || /^energy/i.test(n);
90
+ const known = !!MACRO_LABELS[labelKey(n)] || /^energy/i.test(n);
66
91
  if (!known && (value === "-" || value === null || value === ""))
67
92
  return true;
68
93
  return false;
@@ -76,7 +101,7 @@ export function parseNutrition(rows) {
76
101
  let expectKcalNext = false;
77
102
  for (const r of rows) {
78
103
  const row = (r ?? {});
79
- const name = typeof row.name === "string" ? row.name.trim() : "";
104
+ const name = typeof row.name === "string" ? cleanName(row.name) : "";
80
105
  // Normalize a decimal comma so the energy kJ/kcal regexes (and num/nrvOf) see "1.5", not "1".
81
106
  const value1 = typeof row.value1 === "string" ? deComma(row.value1.trim()) : null;
82
107
  if (!name)
@@ -96,9 +121,18 @@ export function parseNutrition(rows) {
96
121
  basis = basisFromHeader(value1);
97
122
  continue;
98
123
  }
99
- // Energy (inline "486kJ / 115kcal" or split "257 kJ/")
100
- if (/^energy/i.test(name)) {
101
- if (value1) {
124
+ // Unit in the row name with a bare-number value: "Fat (g)" -> "3.0", "Energy (kcal)" -> "60".
125
+ const unitInName = name.match(UNIT_IN_NAME)?.[1]?.toLowerCase() ?? null;
126
+ const baseName = unitInName ? name.replace(UNIT_IN_NAME, "").trim() : name;
127
+ // Energy: unit in the name, inline "486kJ / 115kcal", or split "257 kJ/"
128
+ if (/^energy/i.test(baseName)) {
129
+ if (value1 && unitInName && !/kj|kcal/i.test(value1)) {
130
+ if (unitInName === "kcal")
131
+ macros.energyKcal = num(value1);
132
+ if (unitInName === "kj")
133
+ macros.energyKj = num(value1);
134
+ }
135
+ else if (value1) {
102
136
  const kj = value1.match(/(\d+(?:\.\d+)?)\s*kj/i);
103
137
  const kcal = value1.match(/(\d+(?:\.\d+)?)\s*kcal/i);
104
138
  if (kj)
@@ -110,16 +144,28 @@ export function parseNutrition(rows) {
110
144
  }
111
145
  continue;
112
146
  }
113
- if (isFootnote(name, value1))
147
+ // NRV on its own row named "-": "Calcium" => "124mg", then "-" => "(15.5% RI*)".
148
+ if (name === "-") {
149
+ const prev = micros[micros.length - 1];
150
+ if (prev && prev.nrvPercent == null && value1)
151
+ prev.nrvPercent = nrvOf(value1);
152
+ continue;
153
+ }
154
+ if (isFootnote(baseName, value1))
114
155
  continue;
115
- const key = MACRO_LABELS[name.toLowerCase()];
156
+ const key = MACRO_LABELS[labelKey(baseName)];
116
157
  if (key) {
117
158
  macros[key] = num(value1);
118
159
  continue;
119
160
  }
120
161
  // Anything else with a value is a micronutrient.
121
162
  if (value1) {
122
- micros.push({ name, amount: num(value1), unit: unitOf(value1), nrvPercent: nrvOf(value1) });
163
+ micros.push({
164
+ name: baseName,
165
+ amount: num(value1),
166
+ unit: unitOf(value1) ?? unitInName,
167
+ nrvPercent: nrvOf(value1),
168
+ });
123
169
  }
124
170
  }
125
171
  return { basis, macros, micros, raw: rows };
@@ -23,13 +23,15 @@ export declare const MFE: {
23
23
  readonly orders: "mfe-orders";
24
24
  readonly favourites: "mfe-favourites";
25
25
  };
26
- /** One context for `orders.list` — an order type plus the statuses to include. */
26
+ /** One context for the order queries — an order type plus the statuses to include. */
27
27
  export interface OrderContext {
28
28
  type: string;
29
29
  statuses: readonly string[];
30
30
  }
31
31
  /** Default contexts for listing upcoming (pending) orders across order types. */
32
32
  export declare const PENDING_ORDER_CONTEXTS: readonly OrderContext[];
33
+ /** Default contexts for `orders.history` — completed (previous) grocery orders. */
34
+ export declare const PREVIOUS_ORDER_CONTEXTS: readonly OrderContext[];
33
35
  /** Fulfilment method types accepted by the slot ops. */
34
36
  export declare const FULFILMENT_TYPE: {
35
37
  readonly delivery: "DELIVERY_VAN";
@@ -29,6 +29,10 @@ export const PENDING_ORDER_CONTEXTS = [
29
29
  { type: "MARKETPLACE", statuses: ["Pending"] },
30
30
  { type: "FNF", statuses: ["Pending"] },
31
31
  ];
32
+ /** Default contexts for `orders.history` — completed (previous) grocery orders. */
33
+ export const PREVIOUS_ORDER_CONTEXTS = [
34
+ { type: "GROCERY", statuses: ["Previous"] },
35
+ ];
32
36
  /** Fulfilment method types accepted by the slot ops. */
33
37
  export const FULFILMENT_TYPE = {
34
38
  delivery: "DELIVERY_VAN",
package/dist/parsers.js CHANGED
@@ -4,6 +4,7 @@ const arr = (v) => (Array.isArray(v) ? v : []);
4
4
  /** Array of objects, with null/non-object elements dropped. */
5
5
  const objs = (v) => arr(v).filter((x) => x !== null && typeof x === "object");
6
6
  const str = (v) => (typeof v === "string" ? v : null);
7
+ const bool = (v) => (typeof v === "boolean" ? v : null);
7
8
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
8
9
  const id = (v) => (v == null ? null : String(v));
9
10
  function parsePrice(v) {
@@ -34,6 +35,28 @@ function parsePackSize(v) {
34
35
  const value = Number(p.value); // Tesco sends packSize.value as a numeric string
35
36
  return units && Number.isFinite(value) ? { value, units } : null;
36
37
  }
38
+ function parseCatchWeightOptions(v) {
39
+ return objs(v)
40
+ .map((entry) => {
41
+ const price = num(entry.price);
42
+ const weight = num(entry.weight);
43
+ if (price === null || weight === null)
44
+ return null;
45
+ return { price, weight, default: Boolean(entry.default) };
46
+ })
47
+ .filter((entry) => entry !== null);
48
+ }
49
+ function parseQuantityRules(v) {
50
+ return {
51
+ productType: str(v.productType),
52
+ averageWeight: num(v.averageWeight),
53
+ minWeight: num(v.minWeight),
54
+ maxWeight: num(v.maxWeight),
55
+ increment: num(v.increment),
56
+ bulkBuyLimit: num(v.bulkBuyLimit),
57
+ catchWeightOptions: parseCatchWeightOptions(v.catchWeightList),
58
+ };
59
+ }
37
60
  export function parseProduct(v) {
38
61
  const node = obj(v);
39
62
  const details = obj(node.details);
@@ -45,7 +68,9 @@ export function parseProduct(v) {
45
68
  brand: str(node.brandName),
46
69
  imageUrl: str(node.defaultImageUrl),
47
70
  price: parsePrice(node.price),
71
+ available: bool(node.isForSale),
48
72
  packSize: parsePackSize(details.packSize),
73
+ quantityRules: parseQuantityRules(node),
49
74
  promotions: parsePromotions(node.promotions),
50
75
  nutrition,
51
76
  macros: nutrition?.macros ?? null,
@@ -70,6 +95,8 @@ export function parseProductNode(v) {
70
95
  brand: str(node.brandName),
71
96
  imageUrl: str(node.defaultImageUrl),
72
97
  price: parsePrice(seller.price),
98
+ quantityRules: parseQuantityRules(node),
99
+ available: bool(node.isForSale),
73
100
  onOffer: promotions.length > 0,
74
101
  promotions,
75
102
  };
@@ -82,6 +109,7 @@ export function parseBasket(v) {
82
109
  quantity: num(it.quantity) ?? 0,
83
110
  unit: str(it.unit),
84
111
  cost: num(it.cost),
112
+ available: bool(obj(it.product).isForSale),
85
113
  }));
86
114
  return {
87
115
  id: id(node.id),
package/dist/queries.d.ts CHANGED
@@ -3,12 +3,15 @@
3
3
  * Internal — not part of the public API. Config/constants live in operations.ts.
4
4
  */
5
5
  export declare const GET_PRODUCT: string;
6
+ /** Build one aliased GraphQL operation so a product set costs one HTTP request. */
7
+ export declare function getProductsQuery(count: number): string;
6
8
  export declare const SEARCH: string;
7
9
  export declare const GET_CATEGORY_PRODUCTS: string;
8
10
  export declare const GET_FAVOURITES: string;
9
11
  export declare const GET_BASKET: string;
10
12
  export declare const UPDATE_BASKET: string;
11
13
  export declare const GET_UPCOMING_ORDERS: string;
14
+ export declare const GET_ORDER_HISTORY: string;
12
15
  export declare const AMEND_ORDER: string;
13
16
  export declare const CANCEL_ORDER: string;
14
17
  export declare const CANCEL_AMEND: string;
package/dist/queries.js CHANGED
@@ -2,25 +2,36 @@
2
2
  * GraphQL operation strings for `xapi.tesco.com`, ported from observed traffic.
3
3
  * Internal — not part of the public API. Config/constants live in operations.ts.
4
4
  */
5
+ const PRODUCT_FIELDS = `
6
+ tpnb tpnc title brandName defaultImageUrl isForSale
7
+ productType averageWeight minWeight maxWeight increment bulkBuyLimit
8
+ catchWeightList { price weight default }
9
+ price { actual unitPrice unitOfMeasure }
10
+ promotions {
11
+ description
12
+ startDate
13
+ endDate
14
+ attributes
15
+ price { afterDiscount beforeDiscount }
16
+ }
17
+ details {
18
+ packSize { value units }
19
+ nutrition { name value1 value2 value3 }
20
+ ingredients
21
+ }
22
+ `.trim();
5
23
  export const GET_PRODUCT = `
6
24
  query GetProduct($tpnc: String!) {
7
25
  product(tpnc: $tpnc) {
8
- tpnb tpnc title brandName defaultImageUrl
9
- price { actual unitPrice unitOfMeasure }
10
- promotions {
11
- description
12
- startDate
13
- endDate
14
- attributes
15
- price { afterDiscount beforeDiscount }
16
- }
17
- details {
18
- packSize { value units }
19
- nutrition { name value1 value2 value3 }
20
- ingredients
21
- }
26
+ ${PRODUCT_FIELDS}
22
27
  }
23
28
  }`.trim();
29
+ /** Build one aliased GraphQL operation so a product set costs one HTTP request. */
30
+ export function getProductsQuery(count) {
31
+ const variables = Array.from({ length: count }, (_, i) => `$tpnc${i}: String!`).join(", ");
32
+ const products = Array.from({ length: count }, (_, i) => `p${i}: product(tpnc: $tpnc${i}) { ${PRODUCT_FIELDS} }`).join("\n ");
33
+ return `query GetProducts(${variables}) {\n ${products}\n}`;
34
+ }
24
35
  export const SEARCH = `
25
36
  query Search($query: String!, $page: Int = 1, $count: Int) {
26
37
  search(query: $query, page: $page, count: $count) {
@@ -28,7 +39,9 @@ query Search($query: String!, $page: Int = 1, $count: Int) {
28
39
  node {
29
40
  __typename
30
41
  ... on ProductInterface {
31
- tpnc tpnb title brandName defaultImageUrl
42
+ tpnc tpnb title brandName defaultImageUrl isForSale
43
+ productType averageWeight minWeight maxWeight increment bulkBuyLimit
44
+ catchWeightList { price weight default }
32
45
  sellers {
33
46
  results {
34
47
  price { actual unitPrice unitOfMeasure }
@@ -53,7 +66,9 @@ query GetCategoryProducts($facet: ID, $page: Int = 1, $count: Int) {
53
66
  node {
54
67
  __typename
55
68
  ... on ProductInterface {
56
- tpnc tpnb title brandName defaultImageUrl
69
+ tpnc tpnb title brandName defaultImageUrl isForSale
70
+ productType averageWeight minWeight maxWeight increment bulkBuyLimit
71
+ catchWeightList { price weight default }
57
72
  sellers {
58
73
  results {
59
74
  price { actual unitPrice unitOfMeasure }
@@ -71,7 +86,9 @@ query GetFavourites($page: Int = 1, $count: Int, $sortBy: String) {
71
86
  products {
72
87
  __typename
73
88
  ... on ProductInterface {
74
- tpnc tpnb title brandName defaultImageUrl
89
+ tpnc tpnb title brandName defaultImageUrl isForSale
90
+ productType averageWeight minWeight maxWeight increment bulkBuyLimit
91
+ catchWeightList { price weight default }
75
92
  sellers {
76
93
  results {
77
94
  price { actual unitPrice unitOfMeasure }
@@ -97,7 +114,7 @@ query GetBasket {
97
114
  weight
98
115
  cost
99
116
  quantity
100
- product { id __typename }
117
+ product { id __typename isForSale }
101
118
  }
102
119
  }
103
120
  }`.trim();
@@ -119,7 +136,7 @@ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
119
136
  weight
120
137
  cost
121
138
  quantity
122
- product { id __typename }
139
+ product { id __typename isForSale }
123
140
  }
124
141
  updates {
125
142
  items { id successful __typename }
@@ -127,10 +144,7 @@ mutation UpdateBasket($items: [BasketLineItemInputType], $orderId: ID) {
127
144
  }
128
145
  }`.trim();
129
146
  // ---- orders ----------------------------------------------------------------
130
- export const GET_UPCOMING_ORDERS = `
131
- query GetUpcomingOrders($orderContexts: [OrderContextType]) {
132
- orderSearch(orderContexts: $orderContexts) {
133
- orders {
147
+ const ORDER_FIELDS = `
134
148
  id
135
149
  orderNo
136
150
  status
@@ -142,7 +156,22 @@ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
142
156
  paymentMode
143
157
  slot { id start end charge }
144
158
  address { name city addressLine1 postcode }
145
- items { id quantity unit weight product { id title } }
159
+ items { id quantity unit weight product { id title } }`;
160
+ export const GET_UPCOMING_ORDERS = `
161
+ query GetUpcomingOrders($orderContexts: [OrderContextType]) {
162
+ orderSearch(orderContexts: $orderContexts) {
163
+ orders {${ORDER_FIELDS}
164
+ }
165
+ }
166
+ }`.trim();
167
+ // Offset pagination over a LIVE, newest-first result set. Tesco's orderSearch
168
+ // exposes only count/offset — no cursor, total, or page metadata — so terminal
169
+ // state is inferred from a short page. The schema accepts `page` but ignores
170
+ // it; never send it.
171
+ export const GET_ORDER_HISTORY = `
172
+ query GetOrderHistory($orderContexts: [OrderContextType], $count: Int, $offset: Int) {
173
+ orderSearch(orderContexts: $orderContexts, count: $count, offset: $offset) {
174
+ orders {${ORDER_FIELDS}
146
175
  }
147
176
  }
148
177
  }`.trim();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Field projection for MCP search tools (issue #10).
3
+ *
4
+ * Agents pass `select`, an array of dot-notation paths, to keep only the
5
+ * fields they need from each result — full search responses are token-heavy
6
+ * for LLM loops. A path segment that lands on an array projects each element
7
+ * (`promotions.description` keeps the description of every promotion).
8
+ */
9
+ export declare const SEARCH_RESULT_FIELDS: readonly string[];
10
+ export declare const PRODUCT_FIELDS: readonly string[];
11
+ /**
12
+ * Project each result down to `paths`. Throws `RangeError` on a path whose
13
+ * first segment is not in `validFields` (so a mistyped field tells the agent
14
+ * what is valid instead of silently returning empty objects); deeper segments
15
+ * that don't exist are simply omitted.
16
+ */
17
+ export declare function projectResults(results: readonly unknown[], paths: readonly string[], validFields: readonly string[]): unknown[];
package/dist/select.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Field projection for MCP search tools (issue #10).
3
+ *
4
+ * Agents pass `select`, an array of dot-notation paths, to keep only the
5
+ * fields they need from each result — full search responses are token-heavy
6
+ * for LLM loops. A path segment that lands on an array projects each element
7
+ * (`promotions.description` keeps the description of every promotion).
8
+ */
9
+ // Record<keyof T, true> so adding a model field without listing it here fails
10
+ // to compile — the valid-field lists can't drift from the interfaces.
11
+ const SEARCH_RESULT_KEYS = {
12
+ sku: true,
13
+ tpnb: true,
14
+ title: true,
15
+ brand: true,
16
+ imageUrl: true,
17
+ price: true,
18
+ available: true,
19
+ onOffer: true,
20
+ promotions: true,
21
+ quantityRules: true,
22
+ };
23
+ export const SEARCH_RESULT_FIELDS = Object.keys(SEARCH_RESULT_KEYS);
24
+ // `raw` is excluded: MCP outputs strip it at serialisation (issue #9), so
25
+ // offering it as a select field would advertise a field that never appears.
26
+ const PRODUCT_KEYS = {
27
+ sku: true,
28
+ tpnb: true,
29
+ title: true,
30
+ brand: true,
31
+ imageUrl: true,
32
+ price: true,
33
+ available: true,
34
+ packSize: true,
35
+ promotions: true,
36
+ nutrition: true,
37
+ macros: true,
38
+ quantityRules: true,
39
+ };
40
+ export const PRODUCT_FIELDS = Object.keys(PRODUCT_KEYS);
41
+ function buildTree(paths, validFields) {
42
+ const root = {};
43
+ for (const path of paths) {
44
+ const segments = path.split(".");
45
+ if (segments.some((s) => s === "")) {
46
+ throw new RangeError(`select path "${path}" has an empty segment`);
47
+ }
48
+ const head = segments[0] ?? "";
49
+ if (!validFields.includes(head)) {
50
+ throw new RangeError(`unknown select field "${head}" — valid fields: ${validFields.join(", ")}`);
51
+ }
52
+ let node = root;
53
+ for (const [i, seg] of segments.entries()) {
54
+ if (node[seg] === null)
55
+ break; // an ancestor path already keeps everything here
56
+ if (i === segments.length - 1) {
57
+ node[seg] = null; // keep-all wins over any deeper paths seen earlier
58
+ }
59
+ else {
60
+ node = node[seg] ??= {};
61
+ }
62
+ }
63
+ }
64
+ return root;
65
+ }
66
+ function apply(value, tree) {
67
+ if (tree === null)
68
+ return value;
69
+ if (Array.isArray(value))
70
+ return value.map((v) => apply(v, tree));
71
+ if (typeof value !== "object" || value === null)
72
+ return value;
73
+ const out = {};
74
+ for (const [key, sub] of Object.entries(tree)) {
75
+ if (key in value)
76
+ out[key] = apply(value[key], sub);
77
+ }
78
+ return out;
79
+ }
80
+ /**
81
+ * Project each result down to `paths`. Throws `RangeError` on a path whose
82
+ * first segment is not in `validFields` (so a mistyped field tells the agent
83
+ * what is valid instead of silently returning empty objects); deeper segments
84
+ * that don't exist are simply omitted.
85
+ */
86
+ export function projectResults(results, paths, validFields) {
87
+ const tree = buildTree(paths, validFields);
88
+ return results.map((r) => apply(r, tree));
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basketeer",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Typed, pure-HTTP TypeScript SDK for your own Tesco grocery account — search, basket, delivery slots, and orders over plain fetch. Browser only for login + payment.",
5
5
  "type": "module",
6
6
  "license": "MIT",