texas-grocery-mcp 0.1.0__py3-none-any.whl

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.
Files changed (40) hide show
  1. texas_grocery_mcp/__init__.py +3 -0
  2. texas_grocery_mcp/auth/__init__.py +5 -0
  3. texas_grocery_mcp/auth/browser_refresh.py +1629 -0
  4. texas_grocery_mcp/auth/credentials.py +337 -0
  5. texas_grocery_mcp/auth/session.py +767 -0
  6. texas_grocery_mcp/clients/__init__.py +5 -0
  7. texas_grocery_mcp/clients/graphql.py +2400 -0
  8. texas_grocery_mcp/models/__init__.py +54 -0
  9. texas_grocery_mcp/models/cart.py +60 -0
  10. texas_grocery_mcp/models/coupon.py +44 -0
  11. texas_grocery_mcp/models/errors.py +43 -0
  12. texas_grocery_mcp/models/health.py +41 -0
  13. texas_grocery_mcp/models/product.py +274 -0
  14. texas_grocery_mcp/models/store.py +77 -0
  15. texas_grocery_mcp/observability/__init__.py +6 -0
  16. texas_grocery_mcp/observability/health.py +141 -0
  17. texas_grocery_mcp/observability/logging.py +73 -0
  18. texas_grocery_mcp/reliability/__init__.py +23 -0
  19. texas_grocery_mcp/reliability/cache.py +116 -0
  20. texas_grocery_mcp/reliability/circuit_breaker.py +138 -0
  21. texas_grocery_mcp/reliability/retry.py +96 -0
  22. texas_grocery_mcp/reliability/throttle.py +113 -0
  23. texas_grocery_mcp/server.py +211 -0
  24. texas_grocery_mcp/services/__init__.py +5 -0
  25. texas_grocery_mcp/services/geocoding.py +227 -0
  26. texas_grocery_mcp/state.py +166 -0
  27. texas_grocery_mcp/tools/__init__.py +5 -0
  28. texas_grocery_mcp/tools/cart.py +821 -0
  29. texas_grocery_mcp/tools/coupon.py +381 -0
  30. texas_grocery_mcp/tools/product.py +437 -0
  31. texas_grocery_mcp/tools/session.py +486 -0
  32. texas_grocery_mcp/tools/store.py +353 -0
  33. texas_grocery_mcp/utils/__init__.py +5 -0
  34. texas_grocery_mcp/utils/config.py +146 -0
  35. texas_grocery_mcp/utils/secure_file.py +123 -0
  36. texas_grocery_mcp-0.1.0.dist-info/METADATA +296 -0
  37. texas_grocery_mcp-0.1.0.dist-info/RECORD +40 -0
  38. texas_grocery_mcp-0.1.0.dist-info/WHEEL +4 -0
  39. texas_grocery_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  40. texas_grocery_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,821 @@
1
+ """Cart-related MCP tools with human-in-the-loop confirmation."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any
4
+
5
+ from pydantic import Field
6
+
7
+ from texas_grocery_mcp.auth.session import (
8
+ check_auth,
9
+ ensure_session,
10
+ get_auth_instructions,
11
+ is_authenticated,
12
+ )
13
+ from texas_grocery_mcp.state import StateManager
14
+
15
+ if TYPE_CHECKING:
16
+ from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
17
+
18
+
19
+ def _get_client() -> "HEBGraphQLClient":
20
+ """Get or create GraphQL client."""
21
+ return StateManager.get_graphql_client_sync()
22
+
23
+
24
+ def _extract_sku_from_cart_item(item: dict[str, Any]) -> str | None:
25
+ """Extract SKU ID from a cart item.
26
+
27
+ Cart items have SKU in multiple possible locations.
28
+ """
29
+ # Primary: item.sku.id (nested object)
30
+ sku_obj = item.get("sku", {})
31
+ if isinstance(sku_obj, dict):
32
+ sku_id = sku_obj.get("id")
33
+ if sku_id:
34
+ return str(sku_id)
35
+
36
+ # Fallback: direct skuId field
37
+ sku_id = item.get("skuId") or item.get("sku_id")
38
+ if sku_id:
39
+ return str(sku_id)
40
+
41
+ # Fallback: product's SKUs array
42
+ product = item.get("product", {})
43
+ skus = product.get("SKUs", []) or product.get("skus", [])
44
+ if skus and isinstance(skus[0], dict):
45
+ sku_id = skus[0].get("id") or skus[0].get("skuId")
46
+ if sku_id:
47
+ return str(sku_id)
48
+
49
+ return None
50
+
51
+
52
+ def _extract_price_from_cart_item(item: dict[str, Any]) -> float:
53
+ """Extract price from a cart item.
54
+
55
+ Prices can be in multiple locations depending on API response.
56
+ """
57
+ # Primary: item.price.amount
58
+ price_obj = item.get("price", {})
59
+ if isinstance(price_obj, dict):
60
+ amount = price_obj.get("amount")
61
+ if amount is not None:
62
+ return float(amount)
63
+
64
+ # Fallback: item.unitPrice
65
+ unit_price = item.get("unitPrice")
66
+ if unit_price is not None:
67
+ return float(unit_price)
68
+
69
+ # Fallback: item.listPrice.amount
70
+ list_price = item.get("listPrice", {})
71
+ if isinstance(list_price, dict):
72
+ amount = list_price.get("amount")
73
+ if amount is not None:
74
+ return float(amount)
75
+
76
+ # Fallback: product's price
77
+ product = item.get("product", {})
78
+
79
+ # product.price
80
+ prod_price = product.get("price")
81
+ if prod_price is not None:
82
+ if isinstance(prod_price, dict):
83
+ amount = prod_price.get("amount")
84
+ if amount is not None:
85
+ return float(amount)
86
+ else:
87
+ try:
88
+ return float(prod_price)
89
+ except (TypeError, ValueError):
90
+ pass
91
+
92
+ # product.SKUs[0].contextPrices
93
+ skus = product.get("SKUs", []) or product.get("skus", [])
94
+ if skus and isinstance(skus[0], dict):
95
+ context_prices = skus[0].get("contextPrices", [])
96
+ for ctx in context_prices:
97
+ if ctx.get("context") in ("CURBSIDE", "CURBSIDE_PICKUP", "ONLINE"):
98
+ sale_price = ctx.get("salePrice", {})
99
+ list_price_ctx = ctx.get("listPrice", {})
100
+ amount = None
101
+ if isinstance(sale_price, dict):
102
+ amount = sale_price.get("amount")
103
+ if amount is None and isinstance(list_price_ctx, dict):
104
+ amount = list_price_ctx.get("amount")
105
+ if amount is not None:
106
+ return float(amount)
107
+
108
+ return 0.0
109
+
110
+
111
+ def cart_check_auth() -> dict[str, Any]:
112
+ """Check if authenticated for cart operations.
113
+
114
+ Returns authentication status and instructions if not authenticated.
115
+ Use this before attempting cart operations.
116
+ """
117
+ return check_auth()
118
+
119
+
120
+ @ensure_session
121
+ async def cart_add(
122
+ product_id: Annotated[
123
+ str,
124
+ Field(
125
+ description="HEB product ID (short numeric ID from search results)",
126
+ min_length=1,
127
+ ),
128
+ ],
129
+ sku_id: Annotated[
130
+ str | None,
131
+ Field(
132
+ description=(
133
+ "SKU ID (longer numeric ID). If not provided, uses product_id for both."
134
+ )
135
+ ),
136
+ ] = None,
137
+ quantity: Annotated[
138
+ int,
139
+ Field(description="Quantity to add", ge=1, le=99),
140
+ ] = 1,
141
+ confirm: Annotated[
142
+ bool, Field(description="Set to true to confirm the action")
143
+ ] = False,
144
+ ) -> dict[str, Any]:
145
+ """Add an item to the shopping cart with verification.
146
+
147
+ Without confirm=true, returns a preview of the action.
148
+ With confirm=true, executes the action and VERIFIES it worked.
149
+
150
+ IMPORTANT: Use both product_id and sku_id from product_search results:
151
+ - product_id: shorter ID (e.g., '127074')
152
+ - sku_id: longer ID (e.g., '4122071073')
153
+
154
+ Returns error if item wasn't actually added to cart.
155
+ """
156
+ # Validate product_id
157
+ product_id = product_id.strip()
158
+ if not product_id:
159
+ return {
160
+ "error": True,
161
+ "code": "INVALID_PRODUCT_ID",
162
+ "message": "Product ID cannot be empty or whitespace.",
163
+ }
164
+
165
+ # Use sku_id if provided, otherwise fall back to product_id
166
+ effective_sku_id = (sku_id.strip() if sku_id else None) or product_id
167
+
168
+ # Check authentication first
169
+ if not is_authenticated():
170
+ return {
171
+ "auth_required": True,
172
+ "message": "Login required for cart operations",
173
+ "instructions": get_auth_instructions(),
174
+ }
175
+
176
+ # If not confirmed, return preview
177
+ if not confirm:
178
+ return {
179
+ "preview": True,
180
+ "action": "add_to_cart",
181
+ "product_id": product_id,
182
+ "sku_id": effective_sku_id,
183
+ "quantity": quantity,
184
+ "message": "Set confirm=true to add this item to cart",
185
+ "note": (
186
+ "Ensure product_id is the SHORT ID and sku_id is the LONG ID from "
187
+ "product_search"
188
+ ),
189
+ }
190
+
191
+ client = _get_client()
192
+
193
+ try:
194
+ # Get cart state BEFORE adding (to compare later)
195
+ cart_before = await client.get_cart()
196
+ items_before: set[str] = set()
197
+ if not cart_before.get("error"):
198
+ cart_data = cart_before.get("cartV2", {})
199
+ for item in cart_data.get("items", []):
200
+ item_sku = _extract_sku_from_cart_item(item)
201
+ if item_sku:
202
+ items_before.add(item_sku)
203
+
204
+ # Execute cart addition via GraphQL API
205
+ result = await client.add_to_cart(
206
+ product_id=product_id,
207
+ sku_id=effective_sku_id,
208
+ quantity=quantity,
209
+ )
210
+
211
+ if result.get("error"):
212
+ return result
213
+
214
+ # VERIFY: Get cart state AFTER adding
215
+ cart_after = await client.get_cart()
216
+ if cart_after.get("error"):
217
+ return {
218
+ "warning": True,
219
+ "code": "VERIFICATION_UNAVAILABLE",
220
+ "message": (
221
+ "Item may have been added, but verification failed (could not fetch cart)."
222
+ ),
223
+ "product_id": product_id,
224
+ "sku_id": effective_sku_id,
225
+ "quantity": quantity,
226
+ }
227
+
228
+ # Check if item is now in cart
229
+ cart_data_after = cart_after.get("cartV2", {})
230
+ item_found = False
231
+ item_quantity = 0
232
+
233
+ for item in cart_data_after.get("items", []):
234
+ item_sku = _extract_sku_from_cart_item(item)
235
+ item_product_id = item.get("product", {}).get("id")
236
+
237
+ # Match by either SKU or product_id
238
+ if item_sku == effective_sku_id or item_product_id == product_id:
239
+ item_found = True
240
+ item_quantity = item.get("quantity", 0)
241
+ break
242
+
243
+ if not item_found:
244
+ # Item not in cart - the add failed silently
245
+ return {
246
+ "error": True,
247
+ "code": "CART_ADD_NOT_VERIFIED",
248
+ "message": (
249
+ "Item was NOT added to cart. The API returned success but the item "
250
+ "is not in your cart. This usually means the product_id/sku_id format is wrong."
251
+ ),
252
+ "product_id": product_id,
253
+ "sku_id": effective_sku_id,
254
+ "quantity": quantity,
255
+ "troubleshooting": [
256
+ "1. Ensure product_id is the SHORT numeric ID (e.g., '127074')",
257
+ "2. Ensure sku_id is the LONGER numeric ID (e.g., '4122071073')",
258
+ "3. Both IDs come from product_search results",
259
+ "4. The product may be out of stock at your selected store",
260
+ ],
261
+ "suggestion": (
262
+ "Run product_search again and use the exact product_id and sku values "
263
+ "returned."
264
+ ),
265
+ }
266
+
267
+ # SUCCESS - Item verified in cart
268
+ return {
269
+ "success": True,
270
+ "verified": True,
271
+ "action": "add_to_cart",
272
+ "product_id": product_id,
273
+ "sku_id": effective_sku_id,
274
+ "quantity": quantity,
275
+ "cart_quantity": item_quantity,
276
+ "message": f"Added {quantity}x product to cart (verified)",
277
+ }
278
+
279
+ except Exception as e:
280
+ return {
281
+ "error": True,
282
+ "code": "CART_ADD_FAILED",
283
+ "message": f"Failed to add item to cart: {e!s}",
284
+ }
285
+
286
+
287
+ @ensure_session
288
+ async def cart_remove(
289
+ product_id: Annotated[
290
+ str,
291
+ Field(description="Product SKU/ID to remove", min_length=1),
292
+ ],
293
+ sku_id: Annotated[
294
+ str | None,
295
+ Field(
296
+ description=(
297
+ "SKU ID if known (will be looked up from cart if not provided)"
298
+ )
299
+ ),
300
+ ] = None,
301
+ confirm: Annotated[
302
+ bool, Field(description="Set to true to confirm the action")
303
+ ] = False,
304
+ ) -> dict[str, Any]:
305
+ """Remove an item from the shopping cart.
306
+
307
+ Without confirm=true, returns a preview of the action.
308
+ With confirm=true, executes the action.
309
+ """
310
+ # Validate product_id
311
+ product_id = product_id.strip()
312
+ if not product_id:
313
+ return {
314
+ "error": True,
315
+ "code": "INVALID_PRODUCT_ID",
316
+ "message": "Product ID cannot be empty or whitespace.",
317
+ }
318
+
319
+ if not is_authenticated():
320
+ return {
321
+ "auth_required": True,
322
+ "message": "Login required for cart operations",
323
+ "instructions": get_auth_instructions(),
324
+ }
325
+
326
+ # If sku_id not provided, look it up from the cart
327
+ effective_sku_id = sku_id.strip() if sku_id else None
328
+ if not effective_sku_id:
329
+ # Fetch cart to find the SKU ID for this product
330
+ client = _get_client()
331
+ try:
332
+ cart_result = await client.get_cart()
333
+ if not cart_result.get("error"):
334
+ cart_data = cart_result.get("cartV2", {})
335
+ items = cart_data.get("items", [])
336
+ for item in items:
337
+ product = item.get("product", {})
338
+ if product.get("id") == product_id:
339
+ # Found the product - use helper to extract SKU
340
+ effective_sku_id = _extract_sku_from_cart_item(item)
341
+ break
342
+ except Exception:
343
+ pass # Will fall back to using product_id
344
+
345
+ # If still no SKU ID, use product_id as fallback
346
+ if not effective_sku_id:
347
+ effective_sku_id = product_id
348
+
349
+ if not confirm:
350
+ return {
351
+ "preview": True,
352
+ "action": "remove_from_cart",
353
+ "product_id": product_id,
354
+ "sku_id": effective_sku_id,
355
+ "message": "Set confirm=true to remove this item from cart",
356
+ }
357
+
358
+ # Execute removal via setting quantity to 0
359
+ client = _get_client()
360
+ try:
361
+ result = await client.add_to_cart(
362
+ product_id=product_id,
363
+ sku_id=effective_sku_id,
364
+ quantity=0, # Setting quantity to 0 removes the item
365
+ )
366
+
367
+ if result.get("error"):
368
+ return result
369
+
370
+ return {
371
+ "success": True,
372
+ "action": "remove_from_cart",
373
+ "product_id": product_id,
374
+ "sku_id": effective_sku_id,
375
+ "message": f"Removed product {product_id} from cart",
376
+ }
377
+ except Exception as e:
378
+ return {
379
+ "error": True,
380
+ "code": "CART_REMOVE_FAILED",
381
+ "message": f"Failed to remove item from cart: {e!s}",
382
+ }
383
+
384
+
385
+ @ensure_session
386
+ async def cart_get() -> dict[str, Any]:
387
+ """Get current cart contents.
388
+
389
+ Returns all items in the cart with quantities and prices.
390
+ """
391
+ if not is_authenticated():
392
+ return {
393
+ "auth_required": True,
394
+ "message": "Login required to view cart",
395
+ "instructions": get_auth_instructions(),
396
+ }
397
+
398
+ client = _get_client()
399
+ try:
400
+ result = await client.get_cart()
401
+
402
+ if result.get("error"):
403
+ return result
404
+
405
+ # Parse cart data from GraphQL response
406
+ cart_data = result.get("cartV2", {})
407
+ items = cart_data.get("items", [])
408
+
409
+ # Format items for response
410
+ formatted_items = []
411
+ subtotal = 0.0
412
+
413
+ for item in items:
414
+ product = item.get("product", {})
415
+ quantity = item.get("quantity", 0)
416
+
417
+ # Use helper for robust price extraction
418
+ price = _extract_price_from_cart_item(item)
419
+ item_total = price * quantity
420
+
421
+ # Use helper for robust SKU extraction
422
+ sku_id = _extract_sku_from_cart_item(item)
423
+
424
+ formatted_items.append({
425
+ "product_id": product.get("id"),
426
+ "sku_id": sku_id,
427
+ "name": product.get("displayName") or product.get("name"),
428
+ "quantity": quantity,
429
+ "price": price,
430
+ "total": round(item_total, 2),
431
+ })
432
+ subtotal += item_total
433
+
434
+ message = (
435
+ f"Cart has {len(formatted_items)} item(s)"
436
+ if formatted_items
437
+ else "Cart is empty"
438
+ )
439
+ return {
440
+ "items": formatted_items,
441
+ "item_count": len(formatted_items),
442
+ "subtotal": round(subtotal, 2),
443
+ "message": message,
444
+ }
445
+ except Exception as e:
446
+ return {
447
+ "error": True,
448
+ "code": "CART_GET_FAILED",
449
+ "message": f"Failed to get cart: {e!s}",
450
+ }
451
+
452
+
453
+ @ensure_session
454
+ async def cart_add_with_retry(
455
+ product_id: Annotated[str, Field(description="HEB product ID", min_length=1)],
456
+ sku_id: Annotated[str | None, Field(description="SKU ID")] = None,
457
+ quantity: Annotated[int, Field(description="Quantity to add", ge=1, le=99)] = 1,
458
+ confirm: Annotated[bool, Field(description="Set to true to confirm")] = False,
459
+ auto_correct_ids: Annotated[
460
+ bool, Field(description="Attempt to auto-correct IDs if add fails")
461
+ ] = True,
462
+ ) -> dict[str, Any]:
463
+ """Add item to cart with automatic ID correction.
464
+
465
+ If the initial add fails due to ID format issues and auto_correct_ids=True,
466
+ this will search for the product and retry with the correct IDs.
467
+
468
+ This is a more resilient version of cart_add that can recover from
469
+ incorrect ID formats by looking up the product.
470
+ """
471
+ # First attempt with provided IDs
472
+ result = await cart_add(
473
+ product_id=product_id,
474
+ sku_id=sku_id,
475
+ quantity=quantity,
476
+ confirm=confirm,
477
+ )
478
+
479
+ # If not confirming or if it succeeded, return result
480
+ if not confirm or result.get("success") or result.get("preview"):
481
+ return result
482
+
483
+ # If failed with CART_ADD_NOT_VERIFIED and auto-correct is enabled
484
+ if result.get("code") == "CART_ADD_NOT_VERIFIED" and auto_correct_ids:
485
+ from texas_grocery_mcp.tools.product import product_search
486
+ from texas_grocery_mcp.tools.store import get_default_store_id
487
+
488
+ # Try to find product by searching with the SKU/product_id as query
489
+ search_query = sku_id or product_id
490
+ store_id = get_default_store_id()
491
+
492
+ if not store_id:
493
+ result["auto_correct_attempted"] = False
494
+ result["auto_correct_reason"] = "No default store set"
495
+ return result
496
+
497
+ try:
498
+ search_result = await product_search(
499
+ query=search_query,
500
+ store_id=store_id,
501
+ limit=5,
502
+ )
503
+
504
+ products = search_result.get("products", [])
505
+ if not products:
506
+ result["auto_correct_attempted"] = True
507
+ result["auto_correct_reason"] = f"No products found for '{search_query}'"
508
+ return result
509
+
510
+ # Find a product with valid IDs
511
+ for product in products:
512
+ correct_product_id = product.get("product_id")
513
+ correct_sku = product.get("sku")
514
+
515
+ # Skip if IDs are missing or are suggestion placeholders
516
+ if not correct_product_id or str(correct_product_id).startswith("suggestion-"):
517
+ continue
518
+ if not correct_sku or str(correct_sku).startswith("suggestion-"):
519
+ continue
520
+
521
+ # Retry with corrected IDs
522
+ retry_result = await cart_add(
523
+ product_id=correct_product_id,
524
+ sku_id=correct_sku,
525
+ quantity=quantity,
526
+ confirm=True,
527
+ )
528
+
529
+ if retry_result.get("success"):
530
+ retry_result["auto_corrected"] = True
531
+ retry_result["original_ids"] = {
532
+ "product_id": product_id,
533
+ "sku_id": sku_id,
534
+ }
535
+ retry_result["corrected_ids"] = {
536
+ "product_id": correct_product_id,
537
+ "sku_id": correct_sku,
538
+ }
539
+ return retry_result
540
+
541
+ result["auto_correct_attempted"] = True
542
+ result["auto_correct_reason"] = "Found products but retry still failed"
543
+
544
+ except Exception as e:
545
+ result["auto_correct_attempted"] = True
546
+ result["auto_correct_reason"] = f"Search failed: {e!s}"
547
+
548
+ return result
549
+
550
+
551
+ @ensure_session
552
+ async def cart_add_many(
553
+ items: Annotated[
554
+ list[dict[str, Any]],
555
+ Field(
556
+ description=(
557
+ "List of items to add. Each item must have: "
558
+ "product_id (short ID), sku_id (full SKU), quantity (>=1). "
559
+ "Maximum 100 items per call."
560
+ ),
561
+ )
562
+ ],
563
+ confirm: Annotated[
564
+ bool,
565
+ Field(
566
+ description=(
567
+ "Set to True to execute the bulk add. "
568
+ "Default False shows preview of items to be added."
569
+ )
570
+ )
571
+ ] = False,
572
+ ) -> dict[str, Any]:
573
+ """Add multiple items to cart with a single confirmation.
574
+
575
+ This is more efficient than calling cart_add multiple times and provides
576
+ a single confirmation gate for the entire batch.
577
+
578
+ IMPORTANT: This operation uses STRICT success semantics. If ANY item
579
+ fails to add, the entire operation is reported as a FAILURE. Items that
580
+ were successfully added will remain in the cart, but you'll receive
581
+ a clear list of which items failed.
582
+
583
+ Args:
584
+ items: List of items, each with product_id, sku_id, and quantity
585
+ confirm: Must be True to actually add items (human-in-the-loop safety)
586
+
587
+ Returns:
588
+ On success: All items added with details
589
+ On failure: List of failed items with reasons (successful items stay in cart)
590
+ """
591
+ import structlog
592
+
593
+ logger = structlog.get_logger()
594
+
595
+ # Validate item count
596
+ if not items:
597
+ return {
598
+ "error": True,
599
+ "code": "NO_ITEMS",
600
+ "message": "No items provided. Provide a list of items to add.",
601
+ }
602
+
603
+ if len(items) > 100:
604
+ return {
605
+ "error": True,
606
+ "code": "TOO_MANY_ITEMS",
607
+ "message": f"Maximum 100 items per call. You provided {len(items)}.",
608
+ }
609
+
610
+ # Check authentication
611
+ if not is_authenticated():
612
+ return {
613
+ "auth_required": True,
614
+ "message": "Login required for cart operations",
615
+ "instructions": get_auth_instructions(),
616
+ }
617
+
618
+ # Validate each item and build normalized list
619
+ validated_items = []
620
+ validation_errors = []
621
+
622
+ for idx, item in enumerate(items):
623
+ item_errors = []
624
+
625
+ # Check required fields
626
+ product_id = item.get("product_id")
627
+ sku_id = item.get("sku_id")
628
+ quantity = item.get("quantity")
629
+
630
+ if not product_id:
631
+ item_errors.append("missing product_id")
632
+ elif not str(product_id).strip():
633
+ item_errors.append("product_id is empty")
634
+
635
+ if not sku_id:
636
+ item_errors.append("missing sku_id")
637
+ elif not str(sku_id).strip():
638
+ item_errors.append("sku_id is empty")
639
+
640
+ if quantity is None:
641
+ item_errors.append("missing quantity")
642
+ elif not isinstance(quantity, int) or quantity < 1:
643
+ item_errors.append("quantity must be integer >= 1")
644
+ elif quantity > 99:
645
+ item_errors.append("quantity must be <= 99")
646
+
647
+ if item_errors:
648
+ validation_errors.append({
649
+ "index": idx,
650
+ "item": item,
651
+ "errors": item_errors,
652
+ })
653
+ else:
654
+ validated_items.append({
655
+ "product_id": str(product_id).strip(),
656
+ "sku_id": str(sku_id).strip(),
657
+ "quantity": quantity,
658
+ })
659
+
660
+ # Return validation errors if any
661
+ if validation_errors:
662
+ return {
663
+ "error": True,
664
+ "code": "VALIDATION_ERROR",
665
+ "message": f"{len(validation_errors)} item(s) have validation errors.",
666
+ "validation_errors": validation_errors,
667
+ "valid_items": len(validated_items),
668
+ }
669
+
670
+ # Preview mode - return what would be added
671
+ if not confirm:
672
+ return {
673
+ "preview": True,
674
+ "items_to_add": validated_items,
675
+ "count": len(validated_items),
676
+ "message": (
677
+ f"Review {len(validated_items)} item(s) above. Call with confirm=True "
678
+ "to add all to cart."
679
+ ),
680
+ }
681
+
682
+ # Execute mode - add all items
683
+ client = _get_client()
684
+
685
+ # Get cart state before (for verification)
686
+ cart_before = await client.get_cart()
687
+ items_before: dict[str, int] = {} # sku -> quantity
688
+ if not cart_before.get("error"):
689
+ cart_data = cart_before.get("cartV2", {})
690
+ for cart_item in cart_data.get("items", []):
691
+ item_sku = _extract_sku_from_cart_item(cart_item)
692
+ if item_sku:
693
+ items_before[item_sku] = cart_item.get("quantity", 0)
694
+
695
+ # Track results
696
+ added_items = []
697
+ failed_items = []
698
+
699
+ # Add items one by one (respecting throttling)
700
+ for item in validated_items:
701
+ product_id = item["product_id"]
702
+ sku_id = item["sku_id"]
703
+ quantity = item["quantity"]
704
+
705
+ try:
706
+ result = await client.add_to_cart(
707
+ product_id=product_id,
708
+ sku_id=sku_id,
709
+ quantity=quantity,
710
+ )
711
+
712
+ if result.get("error"):
713
+ failed_items.append({
714
+ "product_id": product_id,
715
+ "sku_id": sku_id,
716
+ "quantity": quantity,
717
+ "error": result.get("message", "Add failed"),
718
+ "code": result.get("code", "ADD_FAILED"),
719
+ })
720
+ else:
721
+ # Tentatively mark as added (will verify later)
722
+ added_items.append({
723
+ "product_id": product_id,
724
+ "sku_id": sku_id,
725
+ "quantity": quantity,
726
+ })
727
+
728
+ except Exception as e:
729
+ logger.warning(
730
+ "cart_add_many item failed",
731
+ product_id=product_id,
732
+ error=str(e),
733
+ )
734
+ failed_items.append({
735
+ "product_id": product_id,
736
+ "sku_id": sku_id,
737
+ "quantity": quantity,
738
+ "error": str(e),
739
+ "code": "EXCEPTION",
740
+ })
741
+
742
+ # Verify items in cart after all adds
743
+ cart_after = await client.get_cart()
744
+ if not cart_after.get("error"):
745
+ cart_data = cart_after.get("cartV2", {})
746
+ items_after: dict[str, dict[str, Any]] = {} # sku -> {quantity, name, price}
747
+
748
+ for cart_item in cart_data.get("items", []):
749
+ item_sku = _extract_sku_from_cart_item(cart_item)
750
+ if item_sku:
751
+ product = cart_item.get("product", {})
752
+ items_after[item_sku] = {
753
+ "quantity": cart_item.get("quantity", 0),
754
+ "name": product.get("displayName") or product.get("name"),
755
+ "price": _extract_price_from_cart_item(cart_item),
756
+ }
757
+
758
+ # Verify each "added" item is actually in the cart
759
+ verified_added = []
760
+ for item in added_items:
761
+ sku_id = item["sku_id"]
762
+ if sku_id in items_after:
763
+ cart_info = items_after[sku_id]
764
+ verified_added.append({
765
+ "product_id": item["product_id"],
766
+ "sku_id": sku_id,
767
+ "name": cart_info["name"],
768
+ "quantity": item["quantity"],
769
+ "cart_quantity": cart_info["quantity"],
770
+ "price": cart_info["price"],
771
+ "line_total": round(cart_info["price"] * cart_info["quantity"], 2),
772
+ })
773
+ else:
774
+ # Item wasn't actually added
775
+ failed_items.append({
776
+ "product_id": item["product_id"],
777
+ "sku_id": sku_id,
778
+ "quantity": item["quantity"],
779
+ "error": "Item not found in cart after add (verification failed)",
780
+ "code": "VERIFICATION_FAILED",
781
+ })
782
+
783
+ added_items = verified_added
784
+
785
+ # Calculate totals
786
+ total_cost = sum(item.get("line_total", 0) for item in added_items)
787
+
788
+ # Build summary
789
+ summary = {
790
+ "requested": len(validated_items),
791
+ "added": len(added_items),
792
+ "failed": len(failed_items),
793
+ "total_cost": round(total_cost, 2),
794
+ }
795
+
796
+ # Determine success/failure (strict: any failure = operation failure)
797
+ if failed_items:
798
+ return {
799
+ "success": False,
800
+ "error": True,
801
+ "code": "PARTIAL_FAILURE",
802
+ "message": (
803
+ f"{len(failed_items)} of {len(validated_items)} item(s) could not be added "
804
+ "to cart."
805
+ ),
806
+ "added": added_items,
807
+ "failed": failed_items,
808
+ "summary": summary,
809
+ "note": (
810
+ "Successfully added items remain in cart. Review failed items and retry if "
811
+ "needed."
812
+ ),
813
+ }
814
+
815
+ # All items added successfully
816
+ return {
817
+ "success": True,
818
+ "added": added_items,
819
+ "summary": summary,
820
+ "message": f"All {len(added_items)} item(s) added to cart successfully.",
821
+ }