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,381 @@
1
+ """Coupon-related MCP tools."""
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any
4
+
5
+ from pydantic import Field
6
+
7
+ from texas_grocery_mcp.auth.session import ensure_session, is_authenticated
8
+ from texas_grocery_mcp.state import StateManager
9
+
10
+ if TYPE_CHECKING:
11
+ from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
12
+
13
+ # Category name to ID mapping for convenience
14
+ CATEGORY_IDS = {
15
+ "baby": 489924,
16
+ "baby & kids": 489924,
17
+ "bakery": 490014,
18
+ "bakery & bread": 490014,
19
+ "beverages": 490015,
20
+ "dairy": 490016,
21
+ "dairy & eggs": 490016,
22
+ "deli": 490017,
23
+ "deli & prepared food": 490017,
24
+ "everyday": 490018,
25
+ "everyday essentials": 490018,
26
+ "frozen": 490019,
27
+ "frozen food": 490019,
28
+ "produce": 490020,
29
+ "fruit & vegetables": 490020,
30
+ "health": 490021,
31
+ "health & beauty": 490021,
32
+ "beauty": 490021,
33
+ "home": 490022,
34
+ "home & outdoor": 490022,
35
+ "meat": 490023,
36
+ "meat & seafood": 490023,
37
+ "seafood": 490023,
38
+ "pantry": 490024,
39
+ "pets": 490025,
40
+ }
41
+
42
+
43
+ def _get_client() -> "HEBGraphQLClient":
44
+ """Get or create GraphQL client."""
45
+ return StateManager.get_graphql_client_sync()
46
+
47
+
48
+ def _resolve_category(category: str | None) -> int | None:
49
+ """Resolve category name to ID.
50
+
51
+ Args:
52
+ category: Category name or ID
53
+
54
+ Returns:
55
+ Category ID or None
56
+ """
57
+ if not category:
58
+ return None
59
+
60
+ # If it's already a number, use it
61
+ if category.isdigit():
62
+ return int(category)
63
+
64
+ # Look up by name (case-insensitive)
65
+ return CATEGORY_IDS.get(category.lower())
66
+
67
+
68
+ @ensure_session
69
+ async def coupon_list(
70
+ category: Annotated[
71
+ str | None,
72
+ Field(
73
+ description="Filter by category name (e.g., 'pantry', 'health & beauty') or ID"
74
+ ),
75
+ ] = None,
76
+ limit: Annotated[
77
+ int,
78
+ Field(description="Maximum coupons to return", ge=1, le=60),
79
+ ] = 20,
80
+ ) -> dict[str, Any]:
81
+ """List available HEB digital coupons.
82
+
83
+ Returns coupons with discount details, descriptions, and expiration dates.
84
+ Filter by category to find coupons in specific departments.
85
+ """
86
+ # Check authentication
87
+ if not is_authenticated():
88
+ return {
89
+ "error": True,
90
+ "code": "AUTH_REQUIRED",
91
+ "message": (
92
+ "Authentication required to view coupons. Use session_save_instructions "
93
+ "to log in."
94
+ ),
95
+ "coupons": [],
96
+ "count": 0,
97
+ }
98
+
99
+ # Resolve category
100
+ category_id = _resolve_category(category)
101
+
102
+ client = _get_client()
103
+ result = await client.get_coupons(
104
+ category_id=category_id,
105
+ limit=limit,
106
+ )
107
+
108
+ # Format response
109
+ coupons_data = []
110
+ for c in result.coupons:
111
+ coupon_dict = {
112
+ "coupon_id": c.coupon_id,
113
+ "headline": c.headline,
114
+ "description": c.description,
115
+ "expires": c.expires_display or c.expires,
116
+ "type": c.coupon_type,
117
+ "clipped": c.clipped,
118
+ "usage_limit": c.usage_limit,
119
+ }
120
+
121
+ # Add optional fields if present
122
+ if c.image_url:
123
+ coupon_dict["image_url"] = c.image_url
124
+ if c.digital_only:
125
+ coupon_dict["digital_only"] = True
126
+
127
+ coupons_data.append(coupon_dict)
128
+
129
+ response = {
130
+ "coupons": coupons_data,
131
+ "count": result.count,
132
+ "total": result.total,
133
+ }
134
+
135
+ # Include categories in first request
136
+ if result.categories:
137
+ response["categories"] = [
138
+ {"name": cat.name, "id": cat.id, "count": cat.count}
139
+ for cat in result.categories
140
+ ]
141
+
142
+ if category:
143
+ response["filtered_by"] = category
144
+
145
+ return response
146
+
147
+
148
+ @ensure_session
149
+ async def coupon_search(
150
+ query: Annotated[
151
+ str,
152
+ Field(description="Search term (e.g., 'chips', 'dove', '25% off')", min_length=1),
153
+ ],
154
+ limit: Annotated[
155
+ int,
156
+ Field(description="Maximum coupons to return", ge=1, le=60),
157
+ ] = 20,
158
+ ) -> dict[str, Any]:
159
+ """Search for HEB coupons by keyword.
160
+
161
+ Search for coupons by product name, brand, or discount type.
162
+ """
163
+ # Check authentication
164
+ if not is_authenticated():
165
+ return {
166
+ "error": True,
167
+ "code": "AUTH_REQUIRED",
168
+ "message": (
169
+ "Authentication required to search coupons. Use session_save_instructions "
170
+ "to log in."
171
+ ),
172
+ "coupons": [],
173
+ "count": 0,
174
+ }
175
+
176
+ query = query.strip()
177
+ if not query:
178
+ return {
179
+ "error": True,
180
+ "code": "INVALID_QUERY",
181
+ "message": "Search query cannot be empty.",
182
+ "coupons": [],
183
+ "count": 0,
184
+ }
185
+
186
+ client = _get_client()
187
+ result = await client.get_coupons(
188
+ search_query=query,
189
+ limit=limit,
190
+ )
191
+
192
+ # Format response
193
+ coupons_data = []
194
+ for c in result.coupons:
195
+ coupon_dict = {
196
+ "coupon_id": c.coupon_id,
197
+ "headline": c.headline,
198
+ "description": c.description,
199
+ "expires": c.expires_display or c.expires,
200
+ "type": c.coupon_type,
201
+ "clipped": c.clipped,
202
+ "usage_limit": c.usage_limit,
203
+ }
204
+
205
+ if c.image_url:
206
+ coupon_dict["image_url"] = c.image_url
207
+ if c.digital_only:
208
+ coupon_dict["digital_only"] = True
209
+
210
+ coupons_data.append(coupon_dict)
211
+
212
+ return {
213
+ "coupons": coupons_data,
214
+ "count": result.count,
215
+ "total": result.total,
216
+ "query": query,
217
+ }
218
+
219
+
220
+ @ensure_session
221
+ async def coupon_categories() -> dict[str, Any]:
222
+ """Get available coupon categories/departments.
223
+
224
+ Returns a list of categories with the number of coupons in each.
225
+ Use category names with coupon_list to filter coupons.
226
+ """
227
+ # Check authentication
228
+ if not is_authenticated():
229
+ return {
230
+ "error": True,
231
+ "code": "AUTH_REQUIRED",
232
+ "message": (
233
+ "Authentication required to view coupon categories. Use "
234
+ "session_save_instructions to log in."
235
+ ),
236
+ "categories": [],
237
+ }
238
+
239
+ client = _get_client()
240
+ result = await client.get_coupons(limit=1) # Just need categories
241
+
242
+ if not result.categories:
243
+ return {
244
+ "categories": [],
245
+ "note": "No categories available. Try again later.",
246
+ }
247
+
248
+ categories_data = [
249
+ {
250
+ "name": cat.name,
251
+ "id": cat.id,
252
+ "count": cat.count,
253
+ }
254
+ for cat in sorted(result.categories, key=lambda x: x.name)
255
+ ]
256
+
257
+ return {
258
+ "categories": categories_data,
259
+ "total_coupons": result.total,
260
+ "note": "Use category name with coupon_list to filter coupons.",
261
+ }
262
+
263
+
264
+ @ensure_session
265
+ async def coupon_clip(
266
+ coupon_id: Annotated[
267
+ int,
268
+ Field(description="Coupon ID to clip (from coupon_list or coupon_search)"),
269
+ ],
270
+ confirm: Annotated[
271
+ bool,
272
+ Field(description="Set to true to confirm the action"),
273
+ ] = False,
274
+ ) -> dict[str, Any]:
275
+ """Clip a coupon to your HEB account.
276
+
277
+ Without confirm=true, returns a preview of the action.
278
+ With confirm=true, clips the coupon (requires authentication).
279
+
280
+ Clipped coupons automatically apply at checkout when you buy eligible items.
281
+ """
282
+ # Check authentication
283
+ if not is_authenticated():
284
+ return {
285
+ "error": True,
286
+ "code": "AUTH_REQUIRED",
287
+ "message": (
288
+ "Authentication required to clip coupons. Use session_save_instructions "
289
+ "to log in."
290
+ ),
291
+ }
292
+
293
+ client = _get_client()
294
+
295
+ # Without confirm, show preview
296
+ if not confirm:
297
+ # Try to get coupon details for preview
298
+ coupons_result = await client.get_coupons(limit=60)
299
+ coupon_info = None
300
+ for c in coupons_result.coupons:
301
+ if c.coupon_id == coupon_id:
302
+ coupon_info = {
303
+ "coupon_id": c.coupon_id,
304
+ "headline": c.headline,
305
+ "description": c.description,
306
+ "expires": c.expires_display or c.expires,
307
+ "clipped": c.clipped,
308
+ }
309
+ break
310
+
311
+ if coupon_info and coupon_info.get("clipped"):
312
+ return {
313
+ "preview": True,
314
+ "coupon": coupon_info,
315
+ "message": "This coupon is already clipped to your account.",
316
+ }
317
+
318
+ return {
319
+ "preview": True,
320
+ "coupon_id": coupon_id,
321
+ "coupon": coupon_info,
322
+ "message": "Ready to clip this coupon. Set confirm=true to proceed.",
323
+ }
324
+
325
+ # Execute clip
326
+ clip_result = await client.clip_coupon(coupon_id)
327
+ return clip_result
328
+
329
+
330
+ @ensure_session
331
+ async def coupon_clipped(
332
+ limit: Annotated[
333
+ int,
334
+ Field(description="Maximum coupons to return", ge=1, le=60),
335
+ ] = 20,
336
+ ) -> dict[str, Any]:
337
+ """List your clipped coupons.
338
+
339
+ Returns all coupons you've clipped to your HEB account.
340
+ Clipped coupons automatically apply at checkout when you buy eligible items.
341
+ """
342
+ # Check authentication
343
+ if not is_authenticated():
344
+ return {
345
+ "error": True,
346
+ "code": "AUTH_REQUIRED",
347
+ "message": (
348
+ "Authentication required to view clipped coupons. Use "
349
+ "session_save_instructions to log in."
350
+ ),
351
+ "coupons": [],
352
+ "count": 0,
353
+ }
354
+
355
+ client = _get_client()
356
+ result = await client.get_clipped_coupons(limit=limit)
357
+
358
+ # Format response
359
+ coupons_data = []
360
+ for c in result.coupons:
361
+ coupon_dict = {
362
+ "coupon_id": c.coupon_id,
363
+ "headline": c.headline,
364
+ "description": c.description,
365
+ "expires": c.expires_display or c.expires,
366
+ "type": c.coupon_type,
367
+ "usage_limit": c.usage_limit,
368
+ }
369
+
370
+ if c.image_url:
371
+ coupon_dict["image_url"] = c.image_url
372
+ if c.digital_only:
373
+ coupon_dict["digital_only"] = True
374
+
375
+ coupons_data.append(coupon_dict)
376
+
377
+ return {
378
+ "clipped_coupons": coupons_data,
379
+ "count": result.count,
380
+ "total": result.total,
381
+ }