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,767 @@
1
+ """Session management for HEB authentication.
2
+
3
+ Uses Playwright MCP's storage state for authentication.
4
+ Provides cookie conversion for httpx-based API requests.
5
+ """
6
+
7
+ import json
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from contextlib import suppress
11
+ from datetime import UTC, datetime
12
+ from functools import wraps
13
+ from typing import Any, ParamSpec, TypedDict
14
+
15
+ import structlog
16
+
17
+ from texas_grocery_mcp.utils.config import get_settings
18
+
19
+ logger = structlog.get_logger()
20
+
21
+
22
+ # Track last auto-refresh to prevent rapid retries
23
+ _last_auto_refresh_attempt: float = 0
24
+ _auto_refresh_min_interval: float = 60.0 # Don't retry more than once per minute
25
+
26
+
27
+ class SessionStatus(TypedDict):
28
+ """Session status with lifecycle information."""
29
+
30
+ authenticated: bool
31
+ needs_refresh: bool
32
+ refresh_recommended: bool
33
+ time_remaining_hours: float | None
34
+ expires_at: str | None # ISO format
35
+ reese84_present: bool
36
+ message: str
37
+
38
+ # Module state for testing
39
+ _is_authenticated: bool = False
40
+
41
+ # Key cookies required for authenticated requests
42
+ REQUIRED_COOKIES = ["sat", "sst", "JSESSIONID"]
43
+ # Cookies that indicate an active session
44
+ SESSION_INDICATOR_COOKIES = ["sat", "DYN_USER_ID"]
45
+
46
+
47
+ def _reset_auth_state() -> None:
48
+ """Reset authentication state. For testing only."""
49
+ global _is_authenticated
50
+ _is_authenticated = False
51
+
52
+
53
+ def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
54
+ """Check if a cookie has expired.
55
+
56
+ Args:
57
+ cookie: Playwright-format cookie dict with 'expires' field
58
+
59
+ Returns:
60
+ True if cookie is expired, False otherwise
61
+ """
62
+ expires_raw = cookie.get("expires", -1)
63
+ # -1 means session cookie (no expiry)
64
+ if expires_raw == -1:
65
+ return False
66
+ try:
67
+ expires = float(expires_raw)
68
+ except (TypeError, ValueError):
69
+ # If we can't parse expiration, treat cookie as expired.
70
+ return True
71
+ # Check if expired (expires is Unix timestamp)
72
+ return time.time() > expires
73
+
74
+
75
+ def _is_reese84_valid(state: dict[str, Any]) -> bool:
76
+ """Check if reese84 bot detection token is present and not expired.
77
+
78
+ The reese84 token is stored in localStorage and contains a renewTime
79
+ that indicates when the token expires. HEB's WAF rejects requests
80
+ with expired tokens.
81
+
82
+ Args:
83
+ state: Playwright storage state dict with 'origins' containing localStorage
84
+
85
+ Returns:
86
+ True if reese84 token exists and is not expired, False otherwise
87
+ """
88
+ # Extract localStorage from origins
89
+ origins = state.get("origins", [])
90
+ if not origins:
91
+ return False
92
+
93
+ local_storage = origins[0].get("localStorage", [])
94
+
95
+ # Find reese84 token
96
+ reese84_data: dict[str, Any] | None = None
97
+ for item in local_storage:
98
+ if item.get("name") == "reese84":
99
+ with suppress(json.JSONDecodeError):
100
+ reese84_data = json.loads(item.get("value", "{}"))
101
+ break
102
+
103
+ if not reese84_data:
104
+ return False
105
+
106
+ # Check expiration via renewTime (absolute timestamp in milliseconds)
107
+ renew_time_ms = reese84_data.get("renewTime")
108
+ if renew_time_ms:
109
+ try:
110
+ expires_at = datetime.fromtimestamp(renew_time_ms / 1000, tz=UTC)
111
+ now = datetime.now(UTC)
112
+ if now >= expires_at:
113
+ return False # Token expired
114
+ except (ValueError, OSError):
115
+ return False
116
+
117
+ # Also check renewInSec + serverTimestamp as fallback
118
+ renew_in_sec = reese84_data.get("renewInSec")
119
+ server_timestamp = reese84_data.get("serverTimestamp")
120
+ if not renew_time_ms and renew_in_sec and server_timestamp:
121
+ try:
122
+ server_ts = server_timestamp / 1000 if server_timestamp > 1e12 else server_timestamp
123
+ expires_at = datetime.fromtimestamp(server_ts + renew_in_sec, tz=UTC)
124
+ now = datetime.now(UTC)
125
+ if now >= expires_at:
126
+ return False # Token expired
127
+ except (ValueError, OSError):
128
+ return False
129
+
130
+ return True
131
+
132
+
133
+ def is_authenticated() -> bool:
134
+ """Check if user is authenticated with valid, non-expired session.
135
+
136
+ Checks for:
137
+ 1. Valid auth state file from Playwright MCP
138
+ 2. Key session cookies present and not expired
139
+ 3. reese84 bot detection token present and not expired
140
+
141
+ All three conditions must be met for authenticated operations to succeed.
142
+ """
143
+ global _is_authenticated
144
+
145
+ # Check override for testing
146
+ if _is_authenticated:
147
+ return True
148
+
149
+ settings = get_settings()
150
+ auth_path = settings.auth_state_path
151
+
152
+ if not auth_path.exists():
153
+ return False
154
+
155
+ try:
156
+ with open(auth_path) as f:
157
+ state = json.load(f)
158
+
159
+ # Check for HEB session cookies
160
+ cookies = state.get("cookies", [])
161
+ heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
162
+
163
+ if not heb_cookies:
164
+ return False
165
+
166
+ # Check for session indicator cookies (not expired)
167
+ valid_session_cookies = []
168
+ for cookie in heb_cookies:
169
+ name = cookie.get("name", "")
170
+ if name in SESSION_INDICATOR_COOKIES and not _is_cookie_expired(cookie):
171
+ valid_session_cookies.append(name)
172
+
173
+ if not valid_session_cookies:
174
+ return False
175
+
176
+ # Check reese84 bot detection token (required for API calls to succeed)
177
+ return _is_reese84_valid(state)
178
+
179
+ except (json.JSONDecodeError, OSError) as e:
180
+ logger.warning("Failed to read auth state", error=str(e))
181
+ return False
182
+
183
+
184
+ def get_auth_instructions() -> list[str]:
185
+ """Get instructions for authenticating with Playwright MCP."""
186
+ settings = get_settings()
187
+ return [
188
+ "1. Use Playwright MCP: browser_navigate('https://www.heb.com/my-account/login')",
189
+ "2. Complete the login process in the browser",
190
+ "3. Use Playwright MCP: browser_run_code to save storage state:",
191
+ f" await page.context().storageState({{ path: '{settings.auth_state_path}' }})",
192
+ "4. Retry this operation",
193
+ ]
194
+
195
+
196
+ def check_auth() -> dict[str, Any]:
197
+ """Check authentication status and return appropriate response."""
198
+ if is_authenticated():
199
+ return {
200
+ "authenticated": True,
201
+ "message": "Authenticated with HEB",
202
+ }
203
+
204
+ return {
205
+ "authenticated": False,
206
+ "auth_required": True,
207
+ "message": "Login required for cart operations",
208
+ "instructions": get_auth_instructions(),
209
+ }
210
+
211
+
212
+ def get_cookies() -> list[dict[str, Any]]:
213
+ """Get cookies for authenticated requests (Playwright format)."""
214
+ settings = get_settings()
215
+ auth_path = settings.auth_state_path
216
+
217
+ if not auth_path.exists():
218
+ return []
219
+
220
+ try:
221
+ with open(auth_path) as f:
222
+ state = json.load(f)
223
+ return [c for c in state.get("cookies", []) if "heb.com" in c.get("domain", "")]
224
+ except (json.JSONDecodeError, OSError):
225
+ return []
226
+
227
+
228
+ def get_httpx_cookies() -> dict[str, str]:
229
+ """Get cookies in httpx-compatible format.
230
+
231
+ Converts Playwright storage state cookies to a simple dict
232
+ that can be passed to httpx.AsyncClient.
233
+
234
+ Returns:
235
+ Dict mapping cookie names to values for HEB domains
236
+ """
237
+ cookies = get_cookies()
238
+ httpx_cookies: dict[str, str] = {}
239
+
240
+ for cookie in cookies:
241
+ # Skip expired cookies
242
+ if _is_cookie_expired(cookie):
243
+ continue
244
+
245
+ name = cookie.get("name", "")
246
+ value = cookie.get("value", "")
247
+
248
+ if name and value:
249
+ httpx_cookies[name] = value
250
+
251
+ logger.debug("Loaded cookies for httpx", count=len(httpx_cookies))
252
+ return httpx_cookies
253
+
254
+
255
+ def save_browser_cookies(cookies: list[dict[str, Any]]) -> bool:
256
+ """Save browser cookies to auth state file.
257
+
258
+ Args:
259
+ cookies: List of Playwright-format cookies to save
260
+
261
+ Returns:
262
+ True if saved successfully, False otherwise
263
+ """
264
+ settings = get_settings()
265
+ auth_path = settings.auth_state_path
266
+
267
+ try:
268
+ # Ensure parent directory exists
269
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
270
+
271
+ # Load existing state or create new
272
+ state: dict[str, Any] = {"cookies": [], "origins": []}
273
+ if auth_path.exists():
274
+ with open(auth_path) as f:
275
+ state = json.load(f)
276
+
277
+ # Filter to only HEB cookies from input
278
+ heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
279
+
280
+ # Merge: replace existing HEB cookies with new ones
281
+ existing_non_heb = [
282
+ c for c in state.get("cookies", [])
283
+ if "heb.com" not in c.get("domain", "")
284
+ ]
285
+ state["cookies"] = existing_non_heb + heb_cookies
286
+
287
+ # Write back with secure permissions
288
+ from texas_grocery_mcp.utils.secure_file import write_secure_json
289
+
290
+ write_secure_json(auth_path, state)
291
+
292
+ logger.info("Saved browser cookies", count=len(heb_cookies), path=str(auth_path))
293
+ return True
294
+
295
+ except (OSError, json.JSONDecodeError) as e:
296
+ logger.error("Failed to save browser cookies", error=str(e))
297
+ return False
298
+
299
+
300
+ def get_reese84_info() -> dict[str, Any] | None:
301
+ """Extract reese84 bot detection token metadata if available.
302
+
303
+ The reese84 cookie is used by Incapsula WAF for bot detection.
304
+ It contains or references a token with renewal time information.
305
+
306
+ Returns:
307
+ Dict with expires/renewTime metadata, or None if not found
308
+ """
309
+ settings = get_settings()
310
+ auth_path = settings.auth_state_path
311
+
312
+ if not auth_path.exists():
313
+ return None
314
+
315
+ try:
316
+ with open(auth_path) as f:
317
+ state = json.load(f)
318
+
319
+ # Check cookies for reese84
320
+ cookies = state.get("cookies", [])
321
+ for cookie in cookies:
322
+ if cookie.get("name") == "reese84" and "heb.com" in cookie.get("domain", ""):
323
+ expires = cookie.get("expires", -1)
324
+ return {
325
+ "source": "cookie",
326
+ "expires": expires if expires > 0 else None,
327
+ "domain": cookie.get("domain"),
328
+ }
329
+
330
+ # Check localStorage (origins) for reese84 with renewTime
331
+ for origin in state.get("origins", []):
332
+ if "heb.com" in origin.get("origin", ""):
333
+ for item in origin.get("localStorage", []):
334
+ if item.get("name") == "reese84":
335
+ try:
336
+ value = json.loads(item.get("value", "{}"))
337
+ return {
338
+ "source": "localStorage",
339
+ "renew_time": value.get("renewTime"),
340
+ "renew_in_sec": value.get("renewInSec"),
341
+ "server_timestamp": value.get("serverTimestamp"),
342
+ }
343
+ except json.JSONDecodeError:
344
+ pass
345
+
346
+ return None
347
+
348
+ except (json.JSONDecodeError, OSError) as e:
349
+ logger.warning("Failed to read reese84 info", error=str(e))
350
+ return None
351
+
352
+
353
+ def check_session_freshness() -> dict[str, Any]:
354
+ """Check if the session needs refresh due to stale bot detection tokens.
355
+
356
+ Returns:
357
+ Dict with needs_refresh bool and diagnostic information
358
+ """
359
+ info: dict[str, Any] = {
360
+ "needs_refresh": False,
361
+ "authenticated": is_authenticated(),
362
+ "reason": None,
363
+ "bot_detection_status": "unknown",
364
+ }
365
+
366
+ if not info["authenticated"]:
367
+ info["needs_refresh"] = True
368
+ info["reason"] = "not_authenticated"
369
+ return info
370
+
371
+ # Check reese84 token
372
+ reese84_info = get_reese84_info()
373
+ if reese84_info is None:
374
+ info["bot_detection_status"] = "missing"
375
+ info["needs_refresh"] = True
376
+ info["reason"] = "reese84_missing"
377
+ return info
378
+
379
+ current_time = time.time()
380
+
381
+ # Check cookie expiration
382
+ expires = reese84_info.get("expires")
383
+ if expires and expires > 0 and current_time > expires - 300:
384
+ info["bot_detection_status"] = "cookie_expired"
385
+ info["needs_refresh"] = True
386
+ info["reason"] = "reese84_cookie_expired"
387
+ info["expired_at"] = expires
388
+ return info
389
+
390
+ # Check localStorage renewTime (milliseconds)
391
+ renew_time_ms = reese84_info.get("renew_time")
392
+ if renew_time_ms:
393
+ renew_time = renew_time_ms / 1000 # Convert to seconds
394
+ if current_time > renew_time:
395
+ info["bot_detection_status"] = "token_stale"
396
+ info["needs_refresh"] = True
397
+ info["reason"] = "reese84_renew_time_passed"
398
+ info["renew_time_passed_at"] = renew_time
399
+ return info
400
+
401
+ info["bot_detection_status"] = "valid"
402
+ return info
403
+
404
+
405
+ def get_session_refresh_instructions() -> list[str]:
406
+ """Get instructions for refreshing the session via Playwright MCP.
407
+
408
+ Returns:
409
+ List of step-by-step instructions
410
+ """
411
+ settings = get_settings()
412
+ return [
413
+ "To refresh your HEB session (solves bot detection challenges):",
414
+ "",
415
+ "1. Navigate to HEB homepage to trigger bot detection refresh:",
416
+ " browser_navigate('https://www.heb.com')",
417
+ "",
418
+ "2. Wait for page to fully load (bot detection runs in background):",
419
+ " browser_wait_for({ state: 'networkidle' })",
420
+ "",
421
+ "3. Perform a search to verify session is working:",
422
+ " browser_type('input[data-qe-id=\"headerSearchInput\"]', 'milk')",
423
+ " browser_press_key('Enter')",
424
+ "",
425
+ "4. Wait for search results:",
426
+ " browser_wait_for({ selector: '[data-qe-id=\"productCard\"]', timeout: 10000 })",
427
+ "",
428
+ "5. Save the refreshed session:",
429
+ (
430
+ " browser_run_code with: await page.context().storageState({ path: '"
431
+ f"{settings.auth_state_path}"
432
+ "' })"
433
+ ),
434
+ "",
435
+ "6. Verify with session_status and session_refresh",
436
+ ]
437
+
438
+
439
+ def get_session_info() -> dict[str, Any]:
440
+ """Get detailed session information.
441
+
442
+ Returns:
443
+ Dict with session status, expiration info, and store ID
444
+ """
445
+ settings = get_settings()
446
+ auth_path = settings.auth_state_path
447
+
448
+ info: dict[str, Any] = {
449
+ "authenticated": False,
450
+ "auth_path": str(auth_path),
451
+ "auth_file_exists": auth_path.exists(),
452
+ "cookies_count": 0,
453
+ "store_id": None,
454
+ "expires_at": None,
455
+ "user_id": None,
456
+ }
457
+
458
+ if not auth_path.exists():
459
+ return info
460
+
461
+ try:
462
+ with open(auth_path) as f:
463
+ state = json.load(f)
464
+
465
+ cookies = state.get("cookies", [])
466
+ heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
467
+ info["cookies_count"] = len(heb_cookies)
468
+
469
+ # Extract useful info from cookies
470
+ for cookie in heb_cookies:
471
+ name = cookie.get("name", "")
472
+ value = cookie.get("value", "")
473
+ expires = cookie.get("expires", -1)
474
+
475
+ if name == "CURR_SESSION_STORE":
476
+ info["store_id"] = value
477
+ elif name == "DYN_USER_ID":
478
+ info["user_id"] = value
479
+ elif name == "sat" and expires > 0:
480
+ # sat token expiration is a good indicator of session validity
481
+ info["expires_at"] = expires
482
+
483
+ info["authenticated"] = is_authenticated()
484
+
485
+ except (json.JSONDecodeError, OSError) as e:
486
+ logger.warning("Failed to read session info", error=str(e))
487
+
488
+ return info
489
+
490
+
491
+ # Refresh threshold: recommend refresh when less than this many hours remain
492
+ SESSION_REFRESH_THRESHOLD_HOURS = 4
493
+
494
+
495
+ def get_session_status() -> SessionStatus:
496
+ """Get comprehensive session status including token lifecycle.
497
+
498
+ Analyzes the reese84 bot detection token to determine:
499
+ - How much time remains before the token expires
500
+ - Whether refresh is recommended (< 4 hours remaining)
501
+ - Whether refresh is required (expired)
502
+
503
+ Returns:
504
+ SessionStatus with authentication state, time remaining, and recommendations.
505
+ """
506
+ settings = get_settings()
507
+ auth_path = settings.auth_state_path
508
+
509
+ # Check if auth file exists
510
+ if not auth_path.exists():
511
+ return SessionStatus(
512
+ authenticated=False,
513
+ needs_refresh=True,
514
+ refresh_recommended=True,
515
+ time_remaining_hours=None,
516
+ expires_at=None,
517
+ reese84_present=False,
518
+ message="No auth file found. Run session_refresh to authenticate.",
519
+ )
520
+
521
+ # Load and analyze auth state
522
+ try:
523
+ with open(auth_path) as f:
524
+ auth_data = json.load(f)
525
+ except (json.JSONDecodeError, OSError) as e:
526
+ logger.warning("Failed to read auth file", error=str(e))
527
+ return SessionStatus(
528
+ authenticated=False,
529
+ needs_refresh=True,
530
+ refresh_recommended=True,
531
+ time_remaining_hours=None,
532
+ expires_at=None,
533
+ reese84_present=False,
534
+ message=f"Auth file corrupted: {e}. Run session_refresh.",
535
+ )
536
+
537
+ # Extract reese84 info from localStorage
538
+ local_storage: list[dict[str, Any]] = []
539
+ origins = auth_data.get("origins", [])
540
+ if origins:
541
+ local_storage = origins[0].get("localStorage", [])
542
+
543
+ reese84_data: dict[str, Any] | None = None
544
+ for item in local_storage:
545
+ if item.get("name") == "reese84":
546
+ with suppress(json.JSONDecodeError):
547
+ reese84_data = json.loads(item.get("value", "{}"))
548
+ break
549
+
550
+ # Calculate time remaining
551
+ time_remaining_hours: float | None = None
552
+ expires_at: str | None = None
553
+ needs_refresh = True
554
+ refresh_recommended = True
555
+ reese84_present = reese84_data is not None
556
+
557
+ if reese84_data:
558
+ # reese84 has renewTime (Unix ms) or renewInSec
559
+ renew_time_ms = reese84_data.get("renewTime")
560
+ renew_in_sec = reese84_data.get("renewInSec")
561
+ server_timestamp = reese84_data.get("serverTimestamp")
562
+
563
+ now = time.time()
564
+
565
+ if renew_time_ms:
566
+ # renewTime is absolute Unix timestamp in milliseconds
567
+ renew_timestamp = renew_time_ms / 1000
568
+ remaining_seconds = renew_timestamp - now
569
+ time_remaining_hours = remaining_seconds / 3600
570
+ expires_at = datetime.fromtimestamp(
571
+ renew_timestamp, tz=UTC
572
+ ).isoformat()
573
+ elif renew_in_sec and server_timestamp:
574
+ # Calculate from relative values
575
+ server_ts = (
576
+ server_timestamp / 1000 if server_timestamp > 1e12 else server_timestamp
577
+ )
578
+ renew_timestamp = server_ts + renew_in_sec
579
+ remaining_seconds = renew_timestamp - now
580
+ time_remaining_hours = remaining_seconds / 3600
581
+ expires_at = datetime.fromtimestamp(
582
+ renew_timestamp, tz=UTC
583
+ ).isoformat()
584
+
585
+ if time_remaining_hours is not None:
586
+ needs_refresh = time_remaining_hours <= 0
587
+ # Recommend refresh when < threshold hours remaining
588
+ refresh_recommended = time_remaining_hours < SESSION_REFRESH_THRESHOLD_HOURS
589
+
590
+ # Check cookies for session validity
591
+ cookies = auth_data.get("cookies", [])
592
+ has_valid_cookies = any(
593
+ c.get("name") in ("sat", "DYN_USER_ID")
594
+ and "heb.com" in c.get("domain", "")
595
+ and (c.get("expires", 0) == -1 or c.get("expires", 0) > time.time())
596
+ for c in cookies
597
+ )
598
+
599
+ authenticated = reese84_present and has_valid_cookies and not needs_refresh
600
+
601
+ # Generate message
602
+ if not authenticated:
603
+ if not reese84_present:
604
+ message = "Session missing reese84 token. Run session_refresh."
605
+ elif needs_refresh:
606
+ message = "Session expired. Run session_refresh."
607
+ else:
608
+ message = "Session invalid. Run session_refresh."
609
+ elif refresh_recommended:
610
+ hours = round(time_remaining_hours, 1) if time_remaining_hours else 0
611
+ message = f"Session valid but expiring soon ({hours}h remaining). Consider session_refresh."
612
+ else:
613
+ hours_str = (
614
+ str(round(time_remaining_hours, 1))
615
+ if time_remaining_hours is not None
616
+ else "unknown"
617
+ )
618
+ message = f"Session healthy ({hours_str}h remaining)."
619
+
620
+ return SessionStatus(
621
+ authenticated=authenticated,
622
+ needs_refresh=needs_refresh,
623
+ refresh_recommended=refresh_recommended,
624
+ time_remaining_hours=(
625
+ round(time_remaining_hours, 2) if time_remaining_hours else None
626
+ ),
627
+ expires_at=expires_at,
628
+ reese84_present=reese84_present,
629
+ message=message,
630
+ )
631
+
632
+
633
+ async def auto_refresh_session_if_needed() -> dict[str, Any] | None:
634
+ """Check session status and auto-refresh if needed.
635
+
636
+ Called by the @ensure_session decorator before authenticated operations.
637
+
638
+ Returns:
639
+ None if session is healthy or refresh succeeded.
640
+ Error dict if manual login is required.
641
+ """
642
+ global _last_auto_refresh_attempt
643
+
644
+ settings = get_settings()
645
+
646
+ # Check if auto-refresh is enabled
647
+ if not settings.auto_refresh_enabled:
648
+ return None
649
+
650
+ # If the user has never logged in / saved storage state, don't block read-only tools.
651
+ # Auth-required tools will provide explicit login instructions on their own.
652
+ auth_path = settings.auth_state_path
653
+ if not auth_path.exists():
654
+ return None
655
+
656
+ # Get current session status
657
+ status = get_session_status()
658
+
659
+ # Check if refresh is needed
660
+ threshold = settings.auto_refresh_threshold_hours
661
+ needs_auto_refresh = (
662
+ status["needs_refresh"]
663
+ or (
664
+ status["time_remaining_hours"] is not None
665
+ and status["time_remaining_hours"] < threshold
666
+ )
667
+ )
668
+
669
+ if not needs_auto_refresh:
670
+ return None
671
+
672
+ # Prevent rapid retries
673
+ current_time = time.time()
674
+ if current_time - _last_auto_refresh_attempt < _auto_refresh_min_interval:
675
+ logger.debug(
676
+ "Skipping auto-refresh (too soon since last attempt)",
677
+ seconds_since_last=round(current_time - _last_auto_refresh_attempt, 1),
678
+ )
679
+ return None
680
+
681
+ _last_auto_refresh_attempt = current_time
682
+
683
+ # Attempt headless refresh
684
+ logger.info(
685
+ "Auto-refreshing session",
686
+ needs_refresh=status["needs_refresh"],
687
+ time_remaining_hours=status["time_remaining_hours"],
688
+ )
689
+
690
+ try:
691
+ # Import here to avoid circular imports
692
+ from texas_grocery_mcp.auth.browser_refresh import (
693
+ BrowserRefreshError,
694
+ LoginRequiredError,
695
+ is_playwright_available,
696
+ refresh_session_with_browser,
697
+ )
698
+
699
+ if not is_playwright_available():
700
+ logger.warning("Auto-refresh unavailable: Playwright not installed")
701
+ return None
702
+
703
+ result = await refresh_session_with_browser(
704
+ auth_path=auth_path,
705
+ headless=True,
706
+ timeout=30000,
707
+ )
708
+
709
+ logger.info(
710
+ "Session auto-refreshed successfully",
711
+ elapsed_seconds=result.get("elapsed_seconds"),
712
+ )
713
+ return None
714
+
715
+ except LoginRequiredError:
716
+ logger.warning("Auto-refresh failed: manual login required")
717
+ return {
718
+ "error": True,
719
+ "code": "LOGIN_REQUIRED",
720
+ "message": (
721
+ "Your HEB session has expired and requires manual login. "
722
+ "Run session_refresh(headless=False) to log in."
723
+ ),
724
+ "auto_refresh_attempted": True,
725
+ }
726
+
727
+ except BrowserRefreshError as e:
728
+ logger.warning("Auto-refresh failed", error=str(e))
729
+ # Don't block the operation - let it try with potentially stale session
730
+ return None
731
+
732
+ except Exception as e:
733
+ logger.warning("Auto-refresh failed with unexpected error", error=str(e))
734
+ return None
735
+
736
+
737
+ P = ParamSpec("P")
738
+ ToolResult = dict[str, Any]
739
+
740
+
741
+ def ensure_session(func: Callable[P, Awaitable[ToolResult]]) -> Callable[P, Awaitable[ToolResult]]:
742
+ """Decorator to ensure valid session before executing authenticated tools.
743
+
744
+ Checks session status and auto-refreshes if:
745
+ - Session is expired (needs_refresh=True)
746
+ - Session is expiring soon (< auto_refresh_threshold_hours remaining)
747
+
748
+ If auto-refresh fails and manual login is required, returns an error
749
+ instead of executing the tool.
750
+
751
+ Usage:
752
+ @ensure_session
753
+ async def cart_add(...):
754
+ ...
755
+ """
756
+
757
+ @wraps(func)
758
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> ToolResult:
759
+ # Check and refresh session if needed
760
+ error = await auto_refresh_session_if_needed()
761
+ if error:
762
+ return error
763
+
764
+ # Execute the original function
765
+ return await func(*args, **kwargs)
766
+
767
+ return wrapper