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,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
|
+
}
|