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,437 @@
1
+ """Product-related MCP tools."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
4
+
5
+ import structlog
6
+ from pydantic import Field
7
+
8
+ from texas_grocery_mcp.auth.session import ensure_session
9
+ from texas_grocery_mcp.state import StateManager
10
+
11
+ logger = structlog.get_logger()
12
+
13
+ if TYPE_CHECKING:
14
+ from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
15
+
16
+
17
+ def _get_client() -> "HEBGraphQLClient":
18
+ """Get or create GraphQL client."""
19
+ return StateManager.get_graphql_client_sync()
20
+
21
+
22
+ def get_default_store_id() -> str | None:
23
+ """Get default store ID."""
24
+ return StateManager.get_default_store_id()
25
+
26
+
27
+ @ensure_session
28
+ async def product_search(
29
+ query: Annotated[
30
+ str,
31
+ Field(
32
+ description="Search query (e.g., 'milk', 'chicken breast')",
33
+ min_length=1,
34
+ ),
35
+ ],
36
+ store_id: Annotated[
37
+ str | None,
38
+ Field(
39
+ description="Store ID for pricing/availability. Uses default if not provided."
40
+ ),
41
+ ] = None,
42
+ limit: Annotated[
43
+ int, Field(description="Maximum results to return", ge=1, le=50)
44
+ ] = 20,
45
+ fields: Annotated[
46
+ list[Literal["minimal", "standard", "all"]] | None,
47
+ Field(
48
+ description=(
49
+ "Field set to return: minimal (sku, name, price), "
50
+ "standard (+brand, size, image), all (+nutrition)"
51
+ )
52
+ ),
53
+ ] = None,
54
+ ) -> dict[str, Any]:
55
+ """Search for products at an HEB store.
56
+
57
+ Returns products matching the query with pricing and availability
58
+ for the specified store.
59
+ """
60
+ # Validate query
61
+ query = query.strip()
62
+ if not query:
63
+ return {
64
+ "error": True,
65
+ "code": "INVALID_QUERY",
66
+ "message": "Search query cannot be empty or whitespace.",
67
+ }
68
+
69
+ # Resolve store ID
70
+ effective_store_id = store_id or get_default_store_id()
71
+
72
+ if not effective_store_id:
73
+ return {
74
+ "error": True,
75
+ "code": "NO_STORE_SET",
76
+ "message": (
77
+ "No store specified. Set a default store with store_change or provide "
78
+ "store_id."
79
+ ),
80
+ }
81
+
82
+ client = _get_client()
83
+ search_result = await client.search_products(
84
+ query=query,
85
+ store_id=effective_store_id,
86
+ limit=limit,
87
+ )
88
+
89
+ # Determine field set (default to standard)
90
+ field_set = (fields or ["standard"])[0] if fields else "standard"
91
+
92
+ result_products = []
93
+ for p in search_result.products:
94
+ # Always include both IDs at the top for cart operations
95
+ product_data: dict[str, Any] = {
96
+ "product_id": p.product_id, # Short ID - REQUIRED for cart_add
97
+ "sku": p.sku, # Long ID - REQUIRED for cart_add as sku_id
98
+ "name": p.name,
99
+ "price": p.price,
100
+ "available": p.available,
101
+ "has_coupon": p.has_coupon, # Coupon availability flag
102
+ }
103
+
104
+ # Warn if product_id is missing (typeahead fallback)
105
+ if not p.product_id or p.product_id.startswith("suggestion-"):
106
+ product_data["_warning"] = (
107
+ "No product_id available - cannot add to cart. "
108
+ "Try a more specific search or refresh session."
109
+ )
110
+
111
+ if field_set in ("standard", "all"):
112
+ product_data.update({
113
+ "brand": p.brand,
114
+ "size": p.size,
115
+ "price_per_unit": p.price_per_unit,
116
+ "image_url": p.image_url,
117
+ "aisle": p.aisle,
118
+ "section": p.section,
119
+ })
120
+
121
+ if field_set == "all":
122
+ product_data.update({
123
+ "nutrition": p.nutrition.model_dump() if p.nutrition else None,
124
+ "ingredients": p.ingredients,
125
+ "on_sale": p.on_sale,
126
+ "original_price": p.original_price,
127
+ "rating": p.rating,
128
+ })
129
+
130
+ result_products.append(product_data)
131
+
132
+ # Build response with diagnostic information
133
+ result: dict[str, Any] = {
134
+ "products": result_products,
135
+ "count": len(result_products),
136
+ "store_id": effective_store_id,
137
+ "query": query,
138
+ "data_source": search_result.data_source,
139
+ "authenticated": search_result.authenticated,
140
+ }
141
+
142
+ # Add search URL for manual verification
143
+ if search_result.search_url:
144
+ result["search_url"] = search_result.search_url
145
+
146
+ # Add diagnostic fields when fallback was used
147
+ if search_result.data_source == "typeahead_suggestions":
148
+ result["note"] = (
149
+ "These are search suggestions only (no prices/inventory). "
150
+ f"Reason: {search_result.fallback_reason or 'SSR search unsuccessful'}. "
151
+ )
152
+ result["auth_required_for_full_data"] = not search_result.authenticated
153
+
154
+ # Add security challenge information
155
+ if search_result.security_challenge_detected:
156
+ result["security_challenge_detected"] = True
157
+ result["note"] = (
158
+ "Security challenge (WAF/captcha) blocked API requests. "
159
+ "Use session_refresh tool or Playwright MCP to refresh your session."
160
+ )
161
+
162
+ # Add fallback reason if present
163
+ if search_result.fallback_reason:
164
+ result["fallback_reason"] = search_result.fallback_reason
165
+
166
+ # Add Playwright fallback instructions when available
167
+ if search_result.playwright_fallback_available and search_result.playwright_instructions:
168
+ result["playwright_fallback"] = {
169
+ "available": True,
170
+ "instructions": search_result.playwright_instructions,
171
+ }
172
+
173
+ # Add search attempts for debugging (summarized)
174
+ if search_result.attempts:
175
+ result["attempts_summary"] = {
176
+ "total": len(search_result.attempts),
177
+ "successful": sum(
178
+ 1 for a in search_result.attempts if a.result == "success"
179
+ ),
180
+ "security_challenges": sum(
181
+ 1
182
+ for a in search_result.attempts
183
+ if a.result == "security_challenge"
184
+ ),
185
+ "empty": sum(1 for a in search_result.attempts if a.result == "empty"),
186
+ "errors": sum(1 for a in search_result.attempts if a.result == "error"),
187
+ }
188
+
189
+ # Add cart usage instructions when products with valid IDs are found
190
+ valid_products = [
191
+ p for p in result_products
192
+ if p.get("product_id") and not str(p.get("product_id", "")).startswith("suggestion-")
193
+ ]
194
+ if valid_products:
195
+ result["cart_usage"] = {
196
+ "instructions": "To add products to cart, use both IDs:",
197
+ "example": "cart_add(product_id=<product_id>, sku_id=<sku>, quantity=1, confirm=True)",
198
+ "note": "product_id is the shorter ID, sku is the longer ID",
199
+ }
200
+
201
+ return result
202
+
203
+
204
+ @ensure_session
205
+ async def product_search_batch(
206
+ queries: Annotated[
207
+ list[str],
208
+ Field(description="List of search queries (e.g., ['milk', 'eggs', 'bread'])"),
209
+ ],
210
+ store_id: Annotated[
211
+ str | None,
212
+ Field(description="Store ID for pricing/availability. Uses default if not provided."),
213
+ ] = None,
214
+ limit_per_query: Annotated[
215
+ int,
216
+ Field(description="Maximum results per query", ge=1, le=20),
217
+ ] = 5,
218
+ ) -> dict[str, Any]:
219
+ """Search for multiple products at once.
220
+
221
+ More efficient than calling product_search multiple times.
222
+ Handles throttling internally to prevent rate limiting.
223
+
224
+ Returns results for each query, with availability at the specified store.
225
+ """
226
+ # Validate queries
227
+ if not queries:
228
+ return {
229
+ "error": "No queries provided",
230
+ "results": [],
231
+ }
232
+
233
+ if len(queries) > 20:
234
+ return {
235
+ "error": "Maximum 20 queries per batch",
236
+ "results": [],
237
+ }
238
+
239
+ # Resolve store ID
240
+ effective_store_id = store_id or get_default_store_id()
241
+
242
+ if not effective_store_id:
243
+ return {
244
+ "error": (
245
+ "No store_id provided and no default store set. Use store_search to find "
246
+ "a store."
247
+ ),
248
+ "results": [],
249
+ }
250
+
251
+ client = _get_client()
252
+ results = []
253
+ successful = 0
254
+ failed = 0
255
+
256
+ # Process queries sequentially (throttling happens inside client)
257
+ for query in queries:
258
+ query = query.strip()
259
+ if not query:
260
+ results.append({
261
+ "query": query,
262
+ "success": False,
263
+ "error": "Empty query",
264
+ "products": [],
265
+ })
266
+ failed += 1
267
+ continue
268
+
269
+ try:
270
+ search_result = await client.search_products(
271
+ query=query,
272
+ store_id=effective_store_id,
273
+ limit=limit_per_query,
274
+ )
275
+
276
+ # Extract product data
277
+ product_list = []
278
+ for p in search_result.products[:limit_per_query]:
279
+ product_dict = {
280
+ "product_id": p.product_id, # Short ID for cart_add
281
+ "sku": p.sku, # Long ID for cart_add as sku_id
282
+ "name": p.name,
283
+ "price": p.price,
284
+ "available": p.available,
285
+ "brand": p.brand,
286
+ "size": p.size,
287
+ }
288
+ # Warn if IDs are missing (typeahead fallback)
289
+ if not p.product_id or p.product_id.startswith("suggestion-"):
290
+ product_dict["_warning"] = "Cannot add to cart - missing product_id"
291
+ product_list.append(product_dict)
292
+
293
+ results.append({
294
+ "query": query,
295
+ "success": True,
296
+ "products": product_list,
297
+ "count": len(product_list),
298
+ "data_source": search_result.data_source,
299
+ })
300
+ successful += 1
301
+
302
+ except Exception as e:
303
+ logger.warning(
304
+ "Batch search query failed",
305
+ query=query,
306
+ error=str(e),
307
+ )
308
+ results.append({
309
+ "query": query,
310
+ "success": False,
311
+ "error": str(e),
312
+ "products": [],
313
+ })
314
+ failed += 1
315
+
316
+ return {
317
+ "results": results,
318
+ "summary": {
319
+ "total_queries": len(queries),
320
+ "successful": successful,
321
+ "failed": failed,
322
+ "store_id": effective_store_id,
323
+ },
324
+ }
325
+
326
+
327
+ @ensure_session
328
+ async def product_get(
329
+ product_id: Annotated[
330
+ str,
331
+ Field(description="Product ID from product_search results (e.g., '127074')")
332
+ ],
333
+ store_id: Annotated[
334
+ str | None,
335
+ Field(description="Store ID for pricing/availability. Uses default if not provided.")
336
+ ] = None,
337
+ ) -> dict[str, Any]:
338
+ """Get comprehensive details for a single product.
339
+
340
+ Returns detailed product information including:
341
+ - Full description
342
+ - Complete ingredients text
343
+ - Safety/allergen warnings
344
+ - Nutritional information (full FDA panel for packaged food)
345
+ - Storage and preparation instructions
346
+ - Dietary attributes (Gluten-Free, Organic, Vegan, etc.)
347
+ - Store location (aisle or section)
348
+
349
+ Use this when you need more information than product_search provides,
350
+ such as checking ingredients for dietary restrictions or allergens.
351
+
352
+ Args:
353
+ product_id: The product ID from product_search results
354
+ store_id: Optional store ID for store-specific pricing
355
+
356
+ Returns:
357
+ Comprehensive product details or error response
358
+ """
359
+ # Validate product_id
360
+ if not product_id or not product_id.strip():
361
+ return {
362
+ "error": True,
363
+ "code": "INVALID_PRODUCT_ID",
364
+ "message": "Product ID cannot be empty.",
365
+ }
366
+
367
+ product_id = product_id.strip()
368
+
369
+ # Warn if it looks like a suggestion ID (not a real product)
370
+ if product_id.startswith("suggestion-"):
371
+ return {
372
+ "error": True,
373
+ "code": "INVALID_PRODUCT_ID",
374
+ "message": (
375
+ "This is a search suggestion, not a real product ID. Use product_search "
376
+ "with a more specific query to get real product IDs."
377
+ ),
378
+ }
379
+
380
+ # Resolve store ID
381
+ effective_store_id = store_id or get_default_store_id()
382
+
383
+ client = _get_client()
384
+
385
+ try:
386
+ details = await client.get_product_details(
387
+ product_id=product_id,
388
+ store_id=effective_store_id,
389
+ )
390
+
391
+ if not details:
392
+ return {
393
+ "error": True,
394
+ "code": "PRODUCT_NOT_FOUND",
395
+ "message": f"Product with ID '{product_id}' not found.",
396
+ "suggestion": (
397
+ "Verify the product_id from product_search results. Product IDs are "
398
+ "numeric (e.g., '127074')."
399
+ ),
400
+ }
401
+
402
+ # Convert to dict, excluding None values for cleaner output
403
+ result = details.model_dump(exclude_none=True)
404
+
405
+ # Convert nutrition to dict if present
406
+ if details.nutrition:
407
+ result["nutrition"] = details.nutrition.model_dump(exclude_none=True)
408
+
409
+ # Add helpful metadata
410
+ result["_meta"] = {
411
+ "store_id": effective_store_id,
412
+ "source": "ssr_product_detail",
413
+ }
414
+
415
+ # Add cart usage hint
416
+ result["cart_usage"] = {
417
+ "instructions": "To add this product to cart:",
418
+ "example": (
419
+ f"cart_add(product_id='{details.product_id}', sku_id='{details.sku}', "
420
+ "quantity=1, confirm=True)"
421
+ ),
422
+ }
423
+
424
+ return result
425
+
426
+ except Exception as e:
427
+ logger.error(
428
+ "Error getting product details",
429
+ product_id=product_id,
430
+ error=str(e),
431
+ )
432
+ return {
433
+ "error": True,
434
+ "code": "FETCH_ERROR",
435
+ "message": f"Error fetching product details: {str(e)}",
436
+ "suggestion": "Try refreshing your session with session_refresh if this persists.",
437
+ }