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,486 @@
1
+ """Session management tools for MCP."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+ from texas_grocery_mcp.auth.browser_refresh import (
9
+ BrowserRefreshError,
10
+ LoginRequiredError,
11
+ PlaywrightNotInstalledError,
12
+ auto_login_with_credentials,
13
+ is_playwright_available,
14
+ refresh_session_with_browser,
15
+ )
16
+ from texas_grocery_mcp.auth.credentials import CredentialError, CredentialStore
17
+ from texas_grocery_mcp.auth.session import (
18
+ check_session_freshness,
19
+ get_session_info,
20
+ get_session_status,
21
+ is_authenticated,
22
+ )
23
+ from texas_grocery_mcp.utils.config import get_settings
24
+
25
+ logger = structlog.get_logger()
26
+
27
+
28
+ async def session_status() -> dict[str, Any]:
29
+ """Get current session status including token lifecycle and credential storage.
30
+
31
+ Returns comprehensive session information:
32
+ - authenticated: Whether session is valid
33
+ - needs_refresh: Whether refresh is required now (token expired)
34
+ - refresh_recommended: Whether proactive refresh is advised (< 4 hours remaining)
35
+ - time_remaining_hours: Hours until token expires
36
+ - expires_at: ISO timestamp of expiration
37
+ - message: Human-readable status
38
+ - credentials_stored: Whether HEB credentials are saved for auto-login
39
+
40
+ Use this to check session health before operations or to decide
41
+ when to proactively refresh.
42
+ """
43
+ status = get_session_status()
44
+
45
+ # Also include basic session info
46
+ basic_info = get_session_info()
47
+
48
+ # Check credential storage status
49
+ settings = get_settings()
50
+ auth_dir = Path(settings.auth_state_path).expanduser().parent
51
+ cred_store = CredentialStore(auth_dir)
52
+ cred_info = cred_store.get_storage_info()
53
+
54
+ return {
55
+ # Lifecycle status (new fields)
56
+ "authenticated": status["authenticated"],
57
+ "needs_refresh": status["needs_refresh"],
58
+ "refresh_recommended": status["refresh_recommended"],
59
+ "time_remaining_hours": status["time_remaining_hours"],
60
+ "expires_at": status["expires_at"],
61
+ "reese84_present": status["reese84_present"],
62
+ "message": status["message"],
63
+ # Basic info
64
+ "auth_path": basic_info.get("auth_path"),
65
+ "store_id": basic_info.get("store_id"),
66
+ "user_id": basic_info.get("user_id"),
67
+ "cookies_count": basic_info.get("cookies_count", 0),
68
+ # Credential storage info
69
+ "credentials_stored": cred_info["credentials_stored"],
70
+ "credential_storage_method": cred_info["storage_method"],
71
+ }
72
+
73
+
74
+ async def session_refresh(
75
+ headless: bool = True,
76
+ timeout: int = 30000,
77
+ login_timeout: int = 300000,
78
+ use_saved_credentials: bool = True,
79
+ ) -> dict[str, Any]:
80
+ """Refresh HEB session cookies and tokens.
81
+
82
+ Uses embedded browser when available (fast: ~10-15 seconds).
83
+ If credentials are saved and login is required, attempts automatic login.
84
+ Falls back to returning Playwright MCP commands if browser
85
+ dependencies aren't installed.
86
+
87
+ Args:
88
+ headless: Run browser without visible window (default True).
89
+ Set to False if you need to complete a manual login
90
+ (e.g., when your session has fully expired).
91
+ timeout: Maximum time to wait for page load in milliseconds.
92
+ Default 30000 (30 seconds).
93
+ login_timeout: Maximum time to wait for manual login in milliseconds.
94
+ Default 300000 (5 minutes). Only used when headless=False.
95
+ use_saved_credentials: If True and credentials are stored, attempt
96
+ automatic login when session is expired.
97
+ Default True.
98
+
99
+ Returns:
100
+ dict with one of these statuses:
101
+ - {"status": "success", ...} - Login/refresh completed successfully
102
+ - {"status": "human_action_required", "action": "login" | "captcha" | "2fa" | "waf", ...}
103
+ Human intervention required (login form, CAPTCHA, 2FA, or a WAF/security interstitial).
104
+ The browser remains open; complete the action, then call session_refresh() again.
105
+ - {"status": "failed", ...} - Login/refresh failed with error details
106
+
107
+ Use this tool when:
108
+ - session_status shows needs_refresh: true
109
+ - session_status shows refresh_recommended: true
110
+ - product_search returns security_challenge_detected: true
111
+ - You want to proactively refresh before token expires
112
+
113
+ CAPTCHA/2FA handling:
114
+ - When CAPTCHA or 2FA is detected, returns immediately with screenshot_path
115
+ - The screenshot shows exactly what the user sees in the browser
116
+ - Use the Read tool to view the screenshot and describe it to the user
117
+ - The browser stays open - user solves CAPTCHA/enters code in that window
118
+ - After solving, call session_refresh() again to continue the login flow
119
+ - Repeat until status is "success" or "failed"
120
+ """
121
+ settings = get_settings()
122
+ auth_path = Path(settings.auth_state_path).expanduser()
123
+ auth_dir = auth_path.parent
124
+
125
+ # Check for saved credentials
126
+ cred_store = CredentialStore(auth_dir)
127
+ has_credentials = cred_store.has_credentials() if use_saved_credentials else False
128
+
129
+ # Try embedded Playwright first (fast path)
130
+ if is_playwright_available():
131
+ try:
132
+ result = await refresh_session_with_browser(
133
+ auth_path=auth_path,
134
+ headless=headless,
135
+ timeout=timeout,
136
+ login_timeout=login_timeout,
137
+ )
138
+ return result
139
+
140
+ except PlaywrightNotInstalledError:
141
+ # Fall through to command-based approach
142
+ pass
143
+
144
+ except LoginRequiredError as e:
145
+ # Session expired - try auto-login if we have credentials
146
+ if has_credentials:
147
+ logger.info("Session expired, attempting auto-login with saved credentials")
148
+ credentials = cred_store.get()
149
+ if credentials:
150
+ email, password = credentials
151
+ # Use visible browser for auto-login (needed for CAPTCHA handoff)
152
+ result = await auto_login_with_credentials(
153
+ auth_path=auth_path,
154
+ email=email,
155
+ password=password,
156
+ headless=False, # Always visible for human handoff
157
+ timeout=timeout,
158
+ login_timeout=login_timeout,
159
+ )
160
+ return result
161
+
162
+ # No credentials or auto-login not attempted
163
+ suggestion = (
164
+ "Your session has fully expired. Try:\n"
165
+ " session_refresh(headless=False)\n"
166
+ "to login in a visible browser window."
167
+ )
168
+ if not has_credentials:
169
+ suggestion += (
170
+ "\n\nTip: Save your credentials with session_save_credentials() "
171
+ "for automatic login next time."
172
+ )
173
+
174
+ return {
175
+ "success": False,
176
+ "status": "failed",
177
+ "error": str(e),
178
+ "error_type": "login_required",
179
+ "credentials_available": has_credentials,
180
+ "suggestion": suggestion,
181
+ }
182
+
183
+ except BrowserRefreshError as e:
184
+ return {
185
+ "success": False,
186
+ "status": "failed",
187
+ "error": str(e),
188
+ "error_type": "browser_error",
189
+ "suggestion": "Check your internet connection and try again.",
190
+ }
191
+
192
+ # Fallback: return Playwright MCP commands for external execution
193
+ expanded_auth_path = str(auth_path)
194
+ freshness = check_session_freshness()
195
+
196
+ # Build the JavaScript code to extract and save session
197
+ extract_code = f"""
198
+ // Extract session data and save to auth.json
199
+ const fs = require('fs');
200
+ const path = require('path');
201
+
202
+ // Get cookies
203
+ const cookies = await page.context().cookies();
204
+
205
+ // Get localStorage
206
+ const localStorage = await page.evaluate(() => {{
207
+ const items = [];
208
+ for (let i = 0; i < window.localStorage.length; i++) {{
209
+ const name = window.localStorage.key(i);
210
+ const value = window.localStorage.getItem(name);
211
+ items.push({{ name, value }});
212
+ }}
213
+ return items;
214
+ }});
215
+
216
+ // Build auth state matching Playwright's storageState format
217
+ const authState = {{
218
+ cookies: cookies,
219
+ origins: [{{
220
+ origin: "https://www.heb.com",
221
+ localStorage: localStorage
222
+ }}]
223
+ }};
224
+
225
+ // Ensure directory exists
226
+ const authPath = '{expanded_auth_path}';
227
+ const dir = path.dirname(authPath);
228
+ if (!fs.existsSync(dir)) {{
229
+ fs.mkdirSync(dir, {{ recursive: true }});
230
+ }}
231
+
232
+ // Save auth state
233
+ fs.writeFileSync(authPath, JSON.stringify(authState, null, 2));
234
+
235
+ return {{
236
+ success: true,
237
+ message: 'Session saved to ' + authPath,
238
+ cookies_count: cookies.length,
239
+ localStorage_count: localStorage.length
240
+ }};
241
+ """
242
+
243
+ return {
244
+ "message": (
245
+ "Playwright not installed. Execute these Playwright MCP commands to refresh "
246
+ "session:"
247
+ ),
248
+ "install_for_fast_refresh": (
249
+ "pip install texas-grocery-mcp[browser] && playwright install chromium"
250
+ ),
251
+ "current_status": {
252
+ "authenticated": freshness.get("authenticated", False),
253
+ "needs_refresh": freshness.get("needs_refresh", True),
254
+ "reason": freshness.get("reason"),
255
+ },
256
+ "commands": [
257
+ {
258
+ "tool": "browser_navigate",
259
+ "parameters": {"url": "https://www.heb.com"},
260
+ "description": "Navigate to HEB homepage to trigger reese84 token generation",
261
+ },
262
+ {
263
+ "tool": "browser_wait_for",
264
+ "parameters": {"time": 5},
265
+ "description": "Wait for page load and reese84 token initialization",
266
+ },
267
+ {
268
+ "tool": "browser_run_code",
269
+ "parameters": {"code": extract_code},
270
+ "description": "Extract cookies and localStorage, save to auth.json",
271
+ },
272
+ ],
273
+ "after_refresh": "Call session_status to verify the refresh succeeded.",
274
+ "auth_path": expanded_auth_path,
275
+ "troubleshooting": {
276
+ "no_playwright": "Install Playwright MCP: https://github.com/microsoft/playwright-mcp",
277
+ "still_failing": (
278
+ "Try browser_navigate with a longer wait, or check if HEB requires login"
279
+ ),
280
+ "login_required": (
281
+ "If HEB prompts for login, use browser_fill_form to enter credentials or "
282
+ "complete login manually"
283
+ ),
284
+ },
285
+ }
286
+
287
+
288
+ def session_save_instructions() -> dict[str, Any]:
289
+ """Get instructions for saving browser session cookies.
290
+
291
+ Call this to get step-by-step instructions for authenticating
292
+ via Playwright MCP and saving the session for fast API access.
293
+
294
+ For automatic session extraction, use session_refresh instead.
295
+ """
296
+ settings = get_settings()
297
+
298
+ return {
299
+ "instructions": [
300
+ "1. Navigate to HEB login page:",
301
+ " browser_navigate('https://www.heb.com/my-account/login')",
302
+ "",
303
+ "2. Complete the login process in the browser",
304
+ " (Enter credentials and click Sign In)",
305
+ "",
306
+ "3. After successful login, save the browser state:",
307
+ " browser_run_code with this code:",
308
+ f" await page.context().storageState({{ path: '{settings.auth_state_path}' }})",
309
+ "",
310
+ "4. Verify session was saved:",
311
+ " Call session_status to confirm authentication",
312
+ ],
313
+ "auth_path": str(settings.auth_state_path),
314
+ "current_status": {
315
+ "authenticated": is_authenticated(),
316
+ },
317
+ "alternative": "Use session_refresh for automatic session extraction without manual login.",
318
+ }
319
+
320
+
321
+ def session_clear() -> dict[str, Any]:
322
+ """Clear saved session cookies.
323
+
324
+ Use this to log out or clear invalid session data.
325
+ After clearing, you will need to run session_refresh again.
326
+
327
+ Note: This does NOT clear saved credentials. Use session_clear_credentials()
328
+ to remove stored login credentials.
329
+ """
330
+ settings = get_settings()
331
+ auth_path = settings.auth_state_path
332
+
333
+ if not auth_path.exists():
334
+ return {
335
+ "success": True,
336
+ "message": "No session file to clear.",
337
+ }
338
+
339
+ try:
340
+ auth_path.unlink()
341
+ return {
342
+ "success": True,
343
+ "message": "Session cleared. Run session_refresh to re-authenticate.",
344
+ "cleared_path": str(auth_path),
345
+ }
346
+ except OSError as e:
347
+ return {
348
+ "error": True,
349
+ "code": "CLEAR_FAILED",
350
+ "message": f"Failed to clear session: {e!s}",
351
+ }
352
+
353
+
354
+ async def session_save_credentials(email: str, password: str) -> dict[str, Any]:
355
+ """Save HEB login credentials for automatic login.
356
+
357
+ Credentials are stored securely using:
358
+ - OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service)
359
+ - Encrypted file fallback when keyring is unavailable
360
+
361
+ After saving, session_refresh will automatically use these credentials
362
+ when your session expires, eliminating manual browser login.
363
+
364
+ Args:
365
+ email: Your HEB.com account email address
366
+ password: Your HEB.com account password
367
+
368
+ Returns:
369
+ dict with success status and storage method used
370
+
371
+ Security notes:
372
+ - Credentials are encrypted at rest
373
+ - Password is never logged or exposed in output
374
+ - Use session_clear_credentials() to remove stored credentials
375
+
376
+ Example:
377
+ session_save_credentials("user@example.com", "mypassword")
378
+ # Now session_refresh will auto-login when session expires
379
+ """
380
+ if not email or not password:
381
+ return {
382
+ "success": False,
383
+ "error": "Email and password are required",
384
+ }
385
+
386
+ # Basic email validation
387
+ if "@" not in email or "." not in email:
388
+ return {
389
+ "success": False,
390
+ "error": "Invalid email format",
391
+ }
392
+
393
+ settings = get_settings()
394
+ auth_dir = Path(settings.auth_state_path).expanduser().parent
395
+ cred_store = CredentialStore(auth_dir)
396
+
397
+ try:
398
+ result = cred_store.save(email, password)
399
+ storage_info = cred_store.get_storage_info()
400
+
401
+ # Mask email for response
402
+ masked_email = _mask_email(email)
403
+
404
+ logger.info("Credentials saved successfully", email_masked=masked_email)
405
+
406
+ return {
407
+ "success": True,
408
+ "message": f"Credentials saved for {masked_email}",
409
+ "storage_method": result.get("method", "unknown"),
410
+ "storage_backend": storage_info.get("storage_backend", "unknown"),
411
+ "next_steps": (
412
+ "Your credentials are now saved. When your session expires, "
413
+ "session_refresh will automatically log you in. "
414
+ "If CAPTCHA or 2FA is required, you'll be prompted to complete it."
415
+ ),
416
+ }
417
+
418
+ except CredentialError as e:
419
+ logger.error("Failed to save credentials", error=str(e))
420
+ return {
421
+ "success": False,
422
+ "error": str(e),
423
+ "suggestion": "Check that your system supports secure credential storage.",
424
+ }
425
+ except Exception as e:
426
+ logger.error("Unexpected error saving credentials", error=str(e))
427
+ return {
428
+ "success": False,
429
+ "error": f"Failed to save credentials: {e}",
430
+ }
431
+
432
+
433
+ def session_clear_credentials() -> dict[str, Any]:
434
+ """Remove stored HEB login credentials.
435
+
436
+ After clearing, session_refresh will fall back to manual browser login
437
+ when your session expires.
438
+
439
+ Returns:
440
+ dict with success status
441
+
442
+ Note: This does NOT clear your current session. Use session_clear()
443
+ to remove session cookies.
444
+ """
445
+ settings = get_settings()
446
+ auth_dir = Path(settings.auth_state_path).expanduser().parent
447
+ cred_store = CredentialStore(auth_dir)
448
+
449
+ try:
450
+ had_credentials = cred_store.has_credentials()
451
+ cred_store.clear()
452
+
453
+ if had_credentials:
454
+ logger.info("Credentials cleared successfully")
455
+ return {
456
+ "success": True,
457
+ "message": "Credentials cleared. Auto-login is now disabled.",
458
+ "had_credentials": True,
459
+ }
460
+ else:
461
+ return {
462
+ "success": True,
463
+ "message": "No credentials were stored.",
464
+ "had_credentials": False,
465
+ }
466
+
467
+ except Exception as e:
468
+ logger.error("Failed to clear credentials", error=str(e))
469
+ return {
470
+ "success": False,
471
+ "error": f"Failed to clear credentials: {e}",
472
+ }
473
+
474
+
475
+ def _mask_email(email: str) -> str:
476
+ """Mask email for safe display (e.g., u***r@example.com)."""
477
+ if not email or "@" not in email:
478
+ return "***"
479
+
480
+ local, domain = email.split("@", 1)
481
+ if len(local) <= 2:
482
+ masked_local = "*" * len(local)
483
+ else:
484
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
485
+
486
+ return f"{masked_local}@{domain}"