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.
- texas_grocery_mcp/__init__.py +3 -0
- texas_grocery_mcp/auth/__init__.py +5 -0
- texas_grocery_mcp/auth/browser_refresh.py +1629 -0
- texas_grocery_mcp/auth/credentials.py +337 -0
- texas_grocery_mcp/auth/session.py +767 -0
- texas_grocery_mcp/clients/__init__.py +5 -0
- texas_grocery_mcp/clients/graphql.py +2400 -0
- texas_grocery_mcp/models/__init__.py +54 -0
- texas_grocery_mcp/models/cart.py +60 -0
- texas_grocery_mcp/models/coupon.py +44 -0
- texas_grocery_mcp/models/errors.py +43 -0
- texas_grocery_mcp/models/health.py +41 -0
- texas_grocery_mcp/models/product.py +274 -0
- texas_grocery_mcp/models/store.py +77 -0
- texas_grocery_mcp/observability/__init__.py +6 -0
- texas_grocery_mcp/observability/health.py +141 -0
- texas_grocery_mcp/observability/logging.py +73 -0
- texas_grocery_mcp/reliability/__init__.py +23 -0
- texas_grocery_mcp/reliability/cache.py +116 -0
- texas_grocery_mcp/reliability/circuit_breaker.py +138 -0
- texas_grocery_mcp/reliability/retry.py +96 -0
- texas_grocery_mcp/reliability/throttle.py +113 -0
- texas_grocery_mcp/server.py +211 -0
- texas_grocery_mcp/services/__init__.py +5 -0
- texas_grocery_mcp/services/geocoding.py +227 -0
- texas_grocery_mcp/state.py +166 -0
- texas_grocery_mcp/tools/__init__.py +5 -0
- texas_grocery_mcp/tools/cart.py +821 -0
- texas_grocery_mcp/tools/coupon.py +381 -0
- texas_grocery_mcp/tools/product.py +437 -0
- texas_grocery_mcp/tools/session.py +486 -0
- texas_grocery_mcp/tools/store.py +353 -0
- texas_grocery_mcp/utils/__init__.py +5 -0
- texas_grocery_mcp/utils/config.py +146 -0
- texas_grocery_mcp/utils/secure_file.py +123 -0
- texas_grocery_mcp-0.1.0.dist-info/METADATA +296 -0
- texas_grocery_mcp-0.1.0.dist-info/RECORD +40 -0
- texas_grocery_mcp-0.1.0.dist-info/WHEEL +4 -0
- texas_grocery_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|