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,353 @@
1
+ """Store-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
8
+ from texas_grocery_mcp.clients.graphql import KNOWN_STORES
9
+ from texas_grocery_mcp.state import StateManager
10
+
11
+ if TYPE_CHECKING:
12
+ from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
13
+
14
+
15
+ def _get_client() -> "HEBGraphQLClient":
16
+ """Get or create GraphQL client."""
17
+ return StateManager.get_graphql_client_sync()
18
+
19
+
20
+ async def store_search(
21
+ address: Annotated[str, Field(description="Address or zip code to search near")],
22
+ radius_miles: Annotated[
23
+ int, Field(description="Search radius in miles", ge=1, le=100)
24
+ ] = 25,
25
+ ) -> dict[str, Any]:
26
+ """Search for HEB stores near an address.
27
+
28
+ Returns stores sorted by distance, including store ID, name,
29
+ address, and distance from the search location.
30
+ """
31
+ client = _get_client()
32
+ result = await client.search_stores(address=address, radius_miles=radius_miles)
33
+
34
+ # Cache found stores for later use in store_change
35
+ stores_dict = {s.store_id: s for s in result.stores}
36
+ StateManager.cache_stores_sync(stores_dict)
37
+
38
+ # Build response with geocoding metadata
39
+ response: dict[str, Any] = {
40
+ "stores": [
41
+ {
42
+ "store_id": s.store_id,
43
+ "name": s.name,
44
+ "address": s.address,
45
+ "distance_miles": round(s.distance_miles, 2) if s.distance_miles else None,
46
+ "phone": s.phone,
47
+ "supports_curbside": s.supports_curbside,
48
+ "supports_delivery": s.supports_delivery,
49
+ }
50
+ for s in result.stores
51
+ ],
52
+ "count": result.count,
53
+ "search_address": result.search_address,
54
+ }
55
+
56
+ # Add geocoded location if available
57
+ if result.geocoded:
58
+ response["geocoded"] = {
59
+ "latitude": result.geocoded.latitude,
60
+ "longitude": result.geocoded.longitude,
61
+ "display_name": result.geocoded.display_name,
62
+ }
63
+
64
+ # Add feedback for failed/partial searches
65
+ if result.error:
66
+ response["error"] = result.error
67
+
68
+ if result.suggestions:
69
+ response["suggestions"] = result.suggestions
70
+
71
+ if result.attempts:
72
+ response["attempts"] = [
73
+ {"query": a.query, "result": a.result}
74
+ for a in result.attempts
75
+ ]
76
+
77
+ # Add helpful note for successful searches
78
+ if result.stores:
79
+ response["note"] = "Use store_change with a store_id to select one."
80
+
81
+ return response
82
+
83
+
84
+ def store_get_default() -> dict[str, Any]:
85
+ """Get the currently set default store.
86
+
87
+ Returns the default store ID if set, otherwise indicates no default.
88
+ """
89
+ default_store_id = StateManager.get_default_store_id()
90
+
91
+ if default_store_id is None:
92
+ # Suggest a known store
93
+ suggested = list(KNOWN_STORES.values())[0]
94
+ return {
95
+ "store_id": None,
96
+ "message": "Default store not set. Use store_change to set one.",
97
+ "suggestion": {
98
+ "store_id": suggested.store_id,
99
+ "name": suggested.name,
100
+ "address": suggested.address,
101
+ },
102
+ "available_stores": [
103
+ {"store_id": s.store_id, "name": s.name}
104
+ for s in KNOWN_STORES.values()
105
+ ],
106
+ }
107
+
108
+ # Return info about the default store - check found stores first
109
+ cached_store = StateManager.get_cached_store(default_store_id)
110
+ if cached_store:
111
+ return {
112
+ "store_id": default_store_id,
113
+ "store_name": cached_store.name,
114
+ "store_address": cached_store.address,
115
+ "message": f"Default store is {cached_store.name}",
116
+ }
117
+
118
+ # Fall back to known stores
119
+ if default_store_id in KNOWN_STORES:
120
+ store = KNOWN_STORES[default_store_id]
121
+ return {
122
+ "store_id": default_store_id,
123
+ "store_name": store.name,
124
+ "store_address": store.address,
125
+ "message": f"Default store is {store.name}",
126
+ }
127
+
128
+ return {
129
+ "store_id": default_store_id,
130
+ "message": f"Default store is {default_store_id}",
131
+ }
132
+
133
+
134
+ def get_default_store_id() -> str | None:
135
+ """Get default store ID for internal use."""
136
+ return StateManager.get_default_store_id()
137
+
138
+
139
+ def set_default_store_id(store_id: str | None) -> None:
140
+ """Set default store ID for internal/test use.
141
+
142
+ This is an internal function - use store_change for the public API.
143
+ """
144
+ StateManager.set_default_store_id_sync(store_id)
145
+
146
+
147
+ @ensure_session
148
+ async def store_change(
149
+ store_id: Annotated[str, Field(description="Store ID to change to", min_length=1)],
150
+ ignore_conflicts: Annotated[
151
+ bool,
152
+ Field(
153
+ description=(
154
+ "Force store change even if cart has conflicts (items unavailable, "
155
+ "price changes). Default False - will fail safely and report conflicts."
156
+ ),
157
+ ),
158
+ ] = False,
159
+ ) -> dict[str, Any]:
160
+ """Change the active store for HEB operations.
161
+
162
+ When authenticated: Changes the store on HEB.com via their API with verification.
163
+ When not authenticated: Sets a local default for product searches.
164
+
165
+ The store change is VERIFIED by checking the cart's actual store after the
166
+ mutation. This ensures we never return success when the store didn't actually
167
+ change (e.g., due to cart conflicts).
168
+
169
+ Args:
170
+ store_id: The store ID to change to
171
+ ignore_conflicts: If True, force store change even if cart has items
172
+ unavailable at the new store or with price changes. Default False.
173
+ """
174
+ from texas_grocery_mcp.auth.session import is_authenticated
175
+
176
+ store_id = store_id.strip()
177
+
178
+ # Look up store info if available
179
+ store_name = None
180
+ store_address = None
181
+ supports_curbside = True # Default to True if unknown
182
+ store = None
183
+
184
+ cached_store = StateManager.get_cached_store(store_id)
185
+ if cached_store:
186
+ store = cached_store
187
+ store_name = store.name
188
+ store_address = store.address
189
+ supports_curbside = store.supports_curbside
190
+ elif store_id in KNOWN_STORES:
191
+ store = KNOWN_STORES[store_id]
192
+ store_name = store.name
193
+ store_address = store.address
194
+ supports_curbside = store.supports_curbside
195
+
196
+ # Check if store supports curbside/online shopping
197
+ if not supports_curbside:
198
+ # Find nearest eligible store to suggest
199
+ suggestion = None
200
+ for s in StateManager.get_cached_stores_values():
201
+ if s.supports_curbside and s.store_id != store_id:
202
+ suggestion = {
203
+ "store_id": s.store_id,
204
+ "name": s.name,
205
+ "address": s.address,
206
+ "distance_miles": s.distance_miles,
207
+ }
208
+ break
209
+
210
+ store_label = store_name or f"Store {store_id}"
211
+ message = (
212
+ f"{store_label} doesn't support online shopping (curbside pickup). "
213
+ "This store is in-store only."
214
+ )
215
+
216
+ if suggestion:
217
+ message = f"{message} Try {suggestion['name']} instead."
218
+
219
+ result: dict[str, Any] = {
220
+ "error": True,
221
+ "code": "STORE_NOT_ELIGIBLE",
222
+ "message": message,
223
+ "store_id": store_id,
224
+ "store_name": store_name,
225
+ }
226
+ if suggestion:
227
+ result["suggestion"] = suggestion
228
+ return result
229
+
230
+ # If not authenticated, set local default only
231
+ if not is_authenticated():
232
+ StateManager.set_default_store_id_sync(store_id)
233
+ return {
234
+ "success": True,
235
+ "store_id": store_id,
236
+ "store_name": store_name,
237
+ "store_address": store_address,
238
+ "message": f"Local default set to {store_name or store_id}",
239
+ "method": "local_only",
240
+ "warning": "Not logged in - store set locally for product searches only.",
241
+ "how_to_sync": (
242
+ "Run session_refresh to log in, then call store_change again to sync with "
243
+ "HEB.com."
244
+ ),
245
+ }
246
+
247
+ # Call the GraphQL API to change the store (with verification)
248
+ client = _get_client()
249
+ result = await client.select_store(store_id, ignore_conflicts=ignore_conflicts)
250
+
251
+ if result.get("error"):
252
+ # API failed or verification failed - return the error details
253
+ error_response = {
254
+ "error": True,
255
+ "code": result.get("code", "STORE_CHANGE_FAILED"),
256
+ "message": result.get("message", "Failed to change store via API"),
257
+ "store_id": store_id,
258
+ "store_name": store_name,
259
+ }
260
+
261
+ # Include additional context from the API response
262
+ if result.get("expected_store"):
263
+ error_response["expected_store"] = result["expected_store"]
264
+ if result.get("actual_store"):
265
+ error_response["actual_store"] = result["actual_store"]
266
+ if result.get("suggestion"):
267
+ error_response["suggestion"] = result["suggestion"]
268
+
269
+ # For cart conflicts, add specific guidance
270
+ if result.get("code") == "CART_CONFLICT":
271
+ error_response["help"] = (
272
+ "Your cart has items that may be unavailable or priced differently at the "
273
+ "new store. "
274
+ "Options: (1) Call store_change with ignore_conflicts=True to force the change, "
275
+ "(2) Clear your cart first, or (3) Keep your current store."
276
+ )
277
+
278
+ return error_response
279
+
280
+ # SUCCESS - Store change verified!
281
+ # Now safe to update local state since we know server state matches
282
+ StateManager.set_default_store_id_sync(store_id)
283
+
284
+ # Update the cookie in auth.json so session_status reflects the change
285
+ _update_store_cookie(store_id)
286
+
287
+ return {
288
+ "success": True,
289
+ "store_id": store_id,
290
+ "store_name": store_name,
291
+ "store_address": store_address,
292
+ "message": f"Store successfully changed to {store_name or store_id}",
293
+ "method": "api",
294
+ "verified": result.get("verified", False),
295
+ }
296
+
297
+
298
+ def _update_store_cookie(store_id: str) -> bool:
299
+ """Update the CURR_SESSION_STORE cookie in auth.json.
300
+
301
+ This ensures session_status reflects the new store immediately.
302
+
303
+ Args:
304
+ store_id: The new store ID
305
+
306
+ Returns:
307
+ True if updated successfully, False otherwise
308
+ """
309
+ import json
310
+
311
+ from texas_grocery_mcp.utils.config import get_settings
312
+
313
+ settings = get_settings()
314
+ auth_path = settings.auth_state_path
315
+
316
+ if not auth_path.exists():
317
+ return False
318
+
319
+ try:
320
+ with open(auth_path) as f:
321
+ state = json.load(f)
322
+
323
+ cookies = state.get("cookies", [])
324
+ found = False
325
+
326
+ for cookie in cookies:
327
+ if cookie.get("name") == "CURR_SESSION_STORE" and "heb.com" in cookie.get("domain", ""):
328
+ cookie["value"] = store_id
329
+ found = True
330
+ break
331
+
332
+ if not found:
333
+ # Add the cookie if it doesn't exist
334
+ cookies.append({
335
+ "name": "CURR_SESSION_STORE",
336
+ "value": store_id,
337
+ "domain": "www.heb.com",
338
+ "path": "/",
339
+ "expires": -1,
340
+ "httpOnly": True,
341
+ "secure": True,
342
+ "sameSite": "Lax",
343
+ })
344
+ state["cookies"] = cookies
345
+
346
+ from texas_grocery_mcp.utils.secure_file import write_secure_json
347
+
348
+ write_secure_json(auth_path, state)
349
+
350
+ return True
351
+
352
+ except (json.JSONDecodeError, OSError):
353
+ return False
@@ -0,0 +1,5 @@
1
+ """Utility modules for Texas Grocery MCP."""
2
+
3
+ from texas_grocery_mcp.utils.config import Settings, get_settings
4
+
5
+ __all__ = ["Settings", "get_settings"]
@@ -0,0 +1,146 @@
1
+ """Configuration management using Pydantic Settings."""
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import Field
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ """Application settings loaded from environment variables."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env",
16
+ env_file_encoding="utf-8",
17
+ extra="ignore",
18
+ )
19
+
20
+ # HEB Configuration
21
+ heb_default_store: str | None = Field(
22
+ default=None,
23
+ description="Default HEB store ID for operations",
24
+ )
25
+ heb_graphql_url: str = Field(
26
+ default="https://www.heb.com/graphql",
27
+ description="HEB GraphQL API endpoint",
28
+ )
29
+
30
+ # Auth State
31
+ auth_state_path: Path = Field(
32
+ default=Path("~/.texas-grocery-mcp/auth.json").expanduser(),
33
+ description="Path to Playwright auth state file",
34
+ )
35
+
36
+ # Redis Configuration
37
+ redis_url: str | None = Field(
38
+ default=None,
39
+ description="Redis connection URL for caching",
40
+ )
41
+
42
+ # Observability
43
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
44
+ default="INFO",
45
+ description="Logging level",
46
+ )
47
+ environment: Literal["development", "staging", "production"] = Field(
48
+ default="development",
49
+ description="Deployment environment",
50
+ )
51
+
52
+ # Reliability
53
+ retry_attempts: int = Field(
54
+ default=3,
55
+ ge=1,
56
+ le=10,
57
+ description="Number of retry attempts for failed requests",
58
+ )
59
+ circuit_breaker_threshold: int = Field(
60
+ default=5,
61
+ ge=1,
62
+ description="Failures before circuit breaker opens",
63
+ )
64
+ circuit_breaker_timeout: int = Field(
65
+ default=30,
66
+ ge=5,
67
+ description="Seconds before circuit breaker attempts recovery",
68
+ )
69
+
70
+ # Throttling - SSR
71
+ max_concurrent_ssr_searches: int = Field(
72
+ default=3,
73
+ ge=1,
74
+ le=20,
75
+ description="Maximum concurrent SSR product searches",
76
+ )
77
+ min_ssr_delay_ms: int = Field(
78
+ default=200,
79
+ ge=0,
80
+ le=5000,
81
+ description="Minimum delay between SSR requests in milliseconds",
82
+ )
83
+ ssr_jitter_ms: int = Field(
84
+ default=200,
85
+ ge=0,
86
+ le=1000,
87
+ description="Random jitter added to SSR delay (0 to N ms)",
88
+ )
89
+
90
+ # Throttling - GraphQL
91
+ max_concurrent_graphql: int = Field(
92
+ default=5,
93
+ ge=1,
94
+ le=20,
95
+ description="Maximum concurrent GraphQL API calls",
96
+ )
97
+ min_graphql_delay_ms: int = Field(
98
+ default=100,
99
+ ge=0,
100
+ le=5000,
101
+ description="Minimum delay between GraphQL requests in milliseconds",
102
+ )
103
+ graphql_jitter_ms: int = Field(
104
+ default=100,
105
+ ge=0,
106
+ le=1000,
107
+ description="Random jitter added to GraphQL delay (0 to N ms)",
108
+ )
109
+
110
+ # Throttling - Global
111
+ throttling_enabled: bool = Field(
112
+ default=True,
113
+ description="Enable/disable request throttling globally",
114
+ )
115
+
116
+ # Session Auto-Refresh
117
+ auto_refresh_enabled: bool = Field(
118
+ default=True,
119
+ description="Enable automatic session refresh before tool execution",
120
+ )
121
+ auto_refresh_threshold_hours: float = Field(
122
+ default=4.0,
123
+ ge=0.5,
124
+ le=24.0,
125
+ description="Refresh session when less than this many hours remaining",
126
+ )
127
+ auto_refresh_on_startup: bool = Field(
128
+ default=False,
129
+ description=(
130
+ "Check and refresh session on MCP server startup (disabled by default - "
131
+ "login should be explicit)"
132
+ ),
133
+ )
134
+
135
+ def model_post_init(self, __context: Any) -> None:
136
+ """Ensure auth state path is expanded."""
137
+ if "~" in str(self.auth_state_path):
138
+ object.__setattr__(
139
+ self, "auth_state_path", Path(str(self.auth_state_path)).expanduser()
140
+ )
141
+
142
+
143
+ @lru_cache
144
+ def get_settings() -> Settings:
145
+ """Get cached settings instance."""
146
+ return Settings()
@@ -0,0 +1,123 @@
1
+ """Secure file operations for sensitive data."""
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import structlog
11
+
12
+ logger = structlog.get_logger()
13
+
14
+ # File permissions: owner read/write only (0o600)
15
+ SECURE_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR
16
+
17
+ # Directory permissions: owner read/write/execute only (0o700)
18
+ SECURE_DIR_MODE = stat.S_IRWXU
19
+
20
+
21
+ def write_secure_json(path: Path, data: Any, indent: int = 2) -> None:
22
+ """Write JSON data to a file with secure permissions.
23
+
24
+ Creates the file with 0o600 permissions (owner read/write only).
25
+ If the file exists, ensures permissions are correct before writing.
26
+
27
+ Args:
28
+ path: Path to write to
29
+ data: Data to serialize as JSON
30
+ indent: JSON indentation level
31
+
32
+ Raises:
33
+ OSError: If file operations fail
34
+ """
35
+ # Ensure path is a Path object
36
+ path = Path(path)
37
+
38
+ # Ensure parent directory exists with secure permissions
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Set directory permissions to 0o700 (owner only)
42
+ try:
43
+ os.chmod(path.parent, SECURE_DIR_MODE)
44
+ except OSError as e:
45
+ logger.warning(
46
+ "Could not set directory permissions",
47
+ path=str(path.parent),
48
+ error=str(e),
49
+ )
50
+
51
+ # Write to a temp file first, then rename (atomic on POSIX)
52
+ temp_path = path.with_suffix(".tmp")
53
+
54
+ try:
55
+ # Create file with secure permissions using os.open
56
+ fd = os.open(
57
+ temp_path,
58
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
59
+ SECURE_FILE_MODE,
60
+ )
61
+
62
+ try:
63
+ with os.fdopen(fd, "w") as f:
64
+ json.dump(data, f, indent=indent)
65
+ except Exception:
66
+ # fd is closed by fdopen even on error, but if fdopen fails
67
+ # we need to close it manually
68
+ with suppress(OSError):
69
+ os.close(fd)
70
+ raise
71
+
72
+ # Atomic rename
73
+ os.replace(temp_path, path)
74
+
75
+ logger.debug(
76
+ "Wrote secure file",
77
+ path=str(path),
78
+ mode=oct(SECURE_FILE_MODE),
79
+ )
80
+
81
+ except Exception:
82
+ # Clean up temp file if it exists
83
+ if temp_path.exists():
84
+ with suppress(OSError):
85
+ temp_path.unlink()
86
+ raise
87
+
88
+
89
+ def ensure_secure_permissions(path: Path) -> bool:
90
+ """Ensure a file has secure permissions (0o600).
91
+
92
+ Args:
93
+ path: Path to check/fix
94
+
95
+ Returns:
96
+ True if permissions are now secure, False if unable to fix
97
+ """
98
+ path = Path(path)
99
+
100
+ if not path.exists():
101
+ return True
102
+
103
+ try:
104
+ current_mode = path.stat().st_mode & 0o777
105
+
106
+ if current_mode != SECURE_FILE_MODE:
107
+ os.chmod(path, SECURE_FILE_MODE)
108
+ logger.info(
109
+ "Fixed file permissions",
110
+ path=str(path),
111
+ old_mode=oct(current_mode),
112
+ new_mode=oct(SECURE_FILE_MODE),
113
+ )
114
+
115
+ return True
116
+
117
+ except OSError as e:
118
+ logger.warning(
119
+ "Could not fix file permissions",
120
+ path=str(path),
121
+ error=str(e),
122
+ )
123
+ return False