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