basketeer 0.1.2 → 0.2.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`
@@ -196,7 +200,7 @@ for (const it of last?.items ?? []) await client.basket.set(it.productId!, it.qu
196
200
 
197
201
  ## MCP server (for AI agents)
198
202
 
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.
203
+ 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
204
 
201
205
  ```jsonc
202
206
  // 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, 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,8 @@ 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;
19
21
  /** A raw basket line input for the low-level `basket.update`. */
20
22
  export interface BasketItemInput {
21
23
  id: string;
@@ -56,6 +58,11 @@ export declare class Basketeer {
56
58
  /** The order currently open for amendment via `orders.amend`, if any. */
57
59
  get amendingOrderNo(): string | null;
58
60
  getProduct(sku: string): Promise<Product>;
61
+ /**
62
+ * Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
63
+ * fetched once and missing products are omitted from the returned array.
64
+ */
65
+ getProducts(skus: readonly string[]): Promise<Product[]>;
59
66
  search(query: string, opts?: {
60
67
  limit?: number;
61
68
  page?: number;
@@ -105,10 +112,16 @@ export declare class Basketeer {
105
112
  set: (sku: string, quantity: number, unit?: string) => Promise<Basket>;
106
113
  /** Remove `sku` from the basket. */
107
114
  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>;
115
+ /**
116
+ * Low-level: send raw basket line inputs (and an optional orderId).
117
+ * A batch can partly succeed, so line-level outcomes (`rejected`,
118
+ * `unavailable`) are reported on the result rather than thrown.
119
+ */
120
+ update: (items: BasketItemInput[], orderId?: string) => Promise<BasketUpdateResult>;
110
121
  };
111
122
  private getBasket;
123
+ /** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
124
+ private updateLine;
112
125
  private updateBasket;
113
126
  readonly orders: {
114
127
  /** List upcoming (pending) orders with their items, slot, and amend window. */
package/dist/client.js CHANGED
@@ -1,10 +1,12 @@
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
4
  import { CHECKOUT_URL, FULFILMENT_TYPE, MFE, PENDING_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_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;
8
10
  /** Guard for numeric SDK inputs. Throws `RangeError` for non-integer or out-of-range values. */
9
11
  function reqInt(v, name, min) {
10
12
  if (v === undefined)
@@ -85,6 +87,26 @@ export class Basketeer {
85
87
  throw new NotFoundError(`No product found for SKU ${sku}`);
86
88
  return parseProduct(data.product);
87
89
  }
90
+ /**
91
+ * Fetch up to 15 products in one throttled HTTP request. Duplicate SKUs are
92
+ * fetched once and missing products are omitted from the returned array.
93
+ */
94
+ async getProducts(skus) {
95
+ const uniqueSkus = [...new Set(skus.map(String))];
96
+ if (uniqueSkus.length === 0)
97
+ return [];
98
+ if (uniqueSkus.length > MAX_PRODUCT_BATCH_SIZE) {
99
+ throw new RangeError(`skus must contain at most ${MAX_PRODUCT_BATCH_SIZE} unique values`);
100
+ }
101
+ const variables = Object.fromEntries(uniqueSkus.map((sku, i) => [`tpnc${i}`, sku]));
102
+ const data = await this.transport.execute({
103
+ operationName: "GetProducts",
104
+ query: getProductsQuery(uniqueSkus.length),
105
+ variables,
106
+ mfeName: MFE.product,
107
+ });
108
+ return uniqueSkus.flatMap((_, i) => (data[`p${i}`] ? [parseProduct(data[`p${i}`])] : []));
109
+ }
88
110
  async search(query, opts = {}) {
89
111
  reqInt(opts.limit, "limit", 1);
90
112
  reqInt(opts.page, "page", 1);
@@ -176,17 +198,21 @@ export class Basketeer {
176
198
  if (!Number.isInteger(quantity) || quantity < 1)
177
199
  throw new RangeError("quantity must be an integer >= 1");
178
200
  const current = (await this.getBasket()).items.find((i) => i.sku === sku)?.quantity ?? 0;
179
- return this.updateBasket([{ id: sku, newValue: current + quantity, newUnitChoice: unit }]);
201
+ return this.updateLine({ id: sku, newValue: current + quantity, newUnitChoice: unit });
180
202
  },
181
203
  /** Set the line for `sku` to an exact `quantity` (0 removes it). */
182
204
  set: (sku, quantity, unit = "pcs") => {
183
205
  if (!Number.isInteger(quantity) || quantity < 0)
184
206
  throw new RangeError("quantity must be an integer >= 0");
185
- return this.updateBasket([{ id: sku, newValue: quantity, newUnitChoice: unit }]);
207
+ return this.updateLine({ id: sku, newValue: quantity, newUnitChoice: unit });
186
208
  },
187
209
  /** 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). */
210
+ remove: (sku) => this.updateLine({ id: sku, newValue: 0 }),
211
+ /**
212
+ * Low-level: send raw basket line inputs (and an optional orderId).
213
+ * A batch can partly succeed, so line-level outcomes (`rejected`,
214
+ * `unavailable`) are reported on the result rather than thrown.
215
+ */
190
216
  update: (items, orderId) => this.updateBasket(items, orderId),
191
217
  };
192
218
  async getBasket() {
@@ -198,6 +224,15 @@ export class Basketeer {
198
224
  });
199
225
  return parseBasket(data.basket);
200
226
  }
227
+ /** Single-line path for `add`/`set`/`remove`: line-level failures throw. */
228
+ async updateLine(item) {
229
+ const { basket, rejected, unavailable } = await this.updateBasket([item]);
230
+ if (rejected.length)
231
+ throw new LineRejectedError(rejected.join(", "));
232
+ if (unavailable.length)
233
+ throw new ItemUnavailableError(unavailable);
234
+ return basket;
235
+ }
201
236
  async updateBasket(items, orderId) {
202
237
  for (const item of items)
203
238
  reqQty(item.newValue, "quantity");
@@ -212,9 +247,21 @@ export class Basketeer {
212
247
  });
213
248
  const updates = data.basket?.updates?.items ?? [];
214
249
  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);
250
+ const basket = parseBasket(data.basket);
251
+ // Tesco accepts unavailable lines silently (updates.successful stays true)
252
+ // then drops them at checkout. Mirror its UI: among the SKUs we just added
253
+ // (newValue > 0), roll back any the basket reports as unavailable and
254
+ // report them on the result.
255
+ const added = new Set(items.filter((i) => i.newValue > 0).map((i) => String(i.id)));
256
+ const unavailable = basket.items
257
+ .filter((l) => l.sku != null && added.has(l.sku) && l.available === false)
258
+ .map((l) => l.sku);
259
+ if (unavailable.length) {
260
+ // The rollback lines are all newValue 0, so this cannot recurse further.
261
+ const rollback = await this.updateBasket(unavailable.map((id) => ({ id, newValue: 0 })), orderId);
262
+ return { basket: rollback.basket, rejected, unavailable };
263
+ }
264
+ return { basket, rejected, unavailable };
218
265
  }
219
266
  // --- orders (requires auth) -----------------------------------------------
220
267
  orders = {
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,11 +1,11 @@
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_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, 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
11
  export { CHECKOUT_URL, categoryFacet, ENDPOINT, FULFILMENT_TYPE, PENDING_ORDER_CONTEXTS, PUBLIC_API_KEY, } from "./operations.js";
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
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_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";
@@ -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;
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,6 +3,8 @@
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;
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 }
@@ -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.2.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",