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,337 @@
1
+ """Secure credential storage for automatic login.
2
+
3
+ This module provides cross-platform secure credential storage using:
4
+ 1. OS keyring (primary) - macOS Keychain, Windows Credential Manager, Linux Secret Service
5
+ 2. Encrypted file (fallback) - Fernet symmetric encryption when keyring unavailable
6
+
7
+ Security guarantees:
8
+ - Credentials are always encrypted at rest
9
+ - File permissions restricted to owner-only (0o600)
10
+ - Password values are never logged
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import stat
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import structlog
20
+
21
+ logger = structlog.get_logger()
22
+
23
+ SERVICE_NAME = "texas-grocery-mcp"
24
+ CREDENTIALS_FILENAME = ".credentials"
25
+ KEY_FILENAME = ".credentials.key"
26
+
27
+ # Check if keyring is available
28
+ try:
29
+ import keyring
30
+ from keyring.errors import KeyringError
31
+
32
+ KEYRING_AVAILABLE = True
33
+ except ImportError:
34
+ KEYRING_AVAILABLE = False
35
+ keyring = None # type: ignore[assignment]
36
+ KeyringError = Exception # type: ignore[misc, assignment]
37
+
38
+ # Check if cryptography is available for fallback
39
+ try:
40
+ from cryptography.fernet import Fernet, InvalidToken
41
+
42
+ CRYPTOGRAPHY_AVAILABLE = True
43
+ except ImportError:
44
+ CRYPTOGRAPHY_AVAILABLE = False
45
+ Fernet = None # type: ignore[misc, assignment]
46
+ InvalidToken = Exception # type: ignore[misc, assignment]
47
+
48
+
49
+ class CredentialError(Exception):
50
+ """Raised when credential operations fail."""
51
+
52
+ pass
53
+
54
+
55
+ class CredentialStore:
56
+ """Secure credential storage with keyring + encrypted file fallback.
57
+
58
+ Usage:
59
+ store = CredentialStore(auth_dir=Path("~/.texas-grocery-mcp"))
60
+ store.save("user@example.com", "password123")
61
+ creds = store.get() # Returns ("user@example.com", "password123") or None
62
+ store.clear()
63
+ """
64
+
65
+ def __init__(self, auth_dir: Path):
66
+ """Initialize credential store.
67
+
68
+ Args:
69
+ auth_dir: Directory for storing encrypted credentials file (fallback)
70
+ """
71
+ self.auth_dir = auth_dir.expanduser()
72
+ self._use_keyring = self._check_keyring_available()
73
+
74
+ if self._use_keyring:
75
+ logger.debug("Using OS keyring for credential storage")
76
+ elif CRYPTOGRAPHY_AVAILABLE:
77
+ logger.debug("Using encrypted file for credential storage")
78
+ else:
79
+ logger.warning(
80
+ "Neither keyring nor cryptography available - credential storage disabled"
81
+ )
82
+
83
+ def _check_keyring_available(self) -> bool:
84
+ """Check if OS keyring is available and functional."""
85
+ if not KEYRING_AVAILABLE:
86
+ return False
87
+
88
+ try:
89
+ # Try a test operation to verify keyring backend works
90
+ # Some systems have keyring installed but no working backend
91
+ keyring.get_password(SERVICE_NAME, "__availability_test__")
92
+ return True
93
+ except KeyringError:
94
+ logger.debug("Keyring backend not functional, using fallback")
95
+ return False
96
+ except Exception as e:
97
+ logger.debug("Keyring check failed", error=str(e))
98
+ return False
99
+
100
+ def _get_or_create_key(self) -> bytes:
101
+ """Get or create encryption key for file-based storage."""
102
+ if not CRYPTOGRAPHY_AVAILABLE:
103
+ raise CredentialError("cryptography library not installed")
104
+
105
+ self.auth_dir.mkdir(parents=True, exist_ok=True)
106
+ key_path = self.auth_dir / KEY_FILENAME
107
+
108
+ if key_path.exists():
109
+ return key_path.read_bytes()
110
+
111
+ # Generate new key
112
+ key = Fernet.generate_key()
113
+ key_path.write_bytes(key)
114
+
115
+ # Set restrictive permissions (owner read/write only)
116
+ os.chmod(key_path, stat.S_IRUSR | stat.S_IWUSR)
117
+
118
+ logger.debug("Created new encryption key", path=str(key_path))
119
+ return key
120
+
121
+ def _save_encrypted(self, email: str, password: str) -> dict[str, Any]:
122
+ """Save credentials to encrypted file."""
123
+ if not CRYPTOGRAPHY_AVAILABLE:
124
+ raise CredentialError(
125
+ "Cannot save credentials: cryptography library not installed. "
126
+ "Install with: pip install cryptography"
127
+ )
128
+
129
+ try:
130
+ key = self._get_or_create_key()
131
+ fernet = Fernet(key)
132
+
133
+ data = json.dumps({"email": email, "password": password})
134
+ encrypted = fernet.encrypt(data.encode())
135
+
136
+ creds_path = self.auth_dir / CREDENTIALS_FILENAME
137
+ creds_path.write_bytes(encrypted)
138
+
139
+ # Set restrictive permissions
140
+ os.chmod(creds_path, stat.S_IRUSR | stat.S_IWUSR)
141
+
142
+ logger.info(
143
+ "Credentials saved to encrypted file",
144
+ path=str(creds_path),
145
+ email_masked=self._mask_email(email),
146
+ )
147
+ return {"method": "encrypted_file", "success": True}
148
+
149
+ except Exception as e:
150
+ logger.error("Failed to save credentials to encrypted file", error=str(e))
151
+ raise CredentialError(f"Failed to save credentials: {e}") from e
152
+
153
+ def _get_encrypted(self) -> tuple[str, str] | None:
154
+ """Retrieve credentials from encrypted file."""
155
+ if not CRYPTOGRAPHY_AVAILABLE:
156
+ return None
157
+
158
+ creds_path = self.auth_dir / CREDENTIALS_FILENAME
159
+ key_path = self.auth_dir / KEY_FILENAME
160
+
161
+ if not creds_path.exists() or not key_path.exists():
162
+ return None
163
+
164
+ try:
165
+ key = key_path.read_bytes()
166
+ fernet = Fernet(key)
167
+ encrypted = creds_path.read_bytes()
168
+ decrypted = fernet.decrypt(encrypted)
169
+ data = json.loads(decrypted.decode())
170
+
171
+ email = data.get("email")
172
+ password = data.get("password")
173
+
174
+ if email and password:
175
+ logger.debug(
176
+ "Retrieved credentials from encrypted file",
177
+ email_masked=self._mask_email(email),
178
+ )
179
+ return (email, password)
180
+
181
+ return None
182
+
183
+ except InvalidToken:
184
+ logger.warning("Encrypted credentials file corrupted or key mismatch")
185
+ return None
186
+ except Exception as e:
187
+ logger.error("Failed to read encrypted credentials", error=str(e))
188
+ return None
189
+
190
+ def _clear_encrypted(self) -> None:
191
+ """Remove encrypted credentials file."""
192
+ creds_path = self.auth_dir / CREDENTIALS_FILENAME
193
+
194
+ if creds_path.exists():
195
+ creds_path.unlink()
196
+ logger.debug("Deleted encrypted credentials file")
197
+
198
+ # Optionally keep the key for future use, but remove credentials
199
+ # Key can be regenerated if needed
200
+
201
+ def _mask_email(self, email: str) -> str:
202
+ """Mask email for safe logging (e.g., u***r@example.com)."""
203
+ if not email or "@" not in email:
204
+ return "***"
205
+
206
+ local, domain = email.split("@", 1)
207
+ if len(local) <= 2:
208
+ masked_local = "*" * len(local)
209
+ else:
210
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
211
+
212
+ return f"{masked_local}@{domain}"
213
+
214
+ def save(self, email: str, password: str) -> dict[str, Any]:
215
+ """Save credentials securely.
216
+
217
+ Args:
218
+ email: HEB account email
219
+ password: HEB account password
220
+
221
+ Returns:
222
+ dict with success status and storage method used
223
+
224
+ Raises:
225
+ CredentialError: If credentials cannot be saved
226
+ """
227
+ if not email or not password:
228
+ raise CredentialError("Email and password are required")
229
+
230
+ if self._use_keyring:
231
+ try:
232
+ keyring.set_password(SERVICE_NAME, "email", email)
233
+ keyring.set_password(SERVICE_NAME, "password", password)
234
+
235
+ logger.info(
236
+ "Credentials saved to OS keyring",
237
+ email_masked=self._mask_email(email),
238
+ )
239
+ return {"method": "keyring", "success": True}
240
+
241
+ except KeyringError as e:
242
+ logger.warning(
243
+ "Keyring save failed, falling back to encrypted file",
244
+ error=str(e),
245
+ )
246
+ # Fall through to encrypted file
247
+ self._use_keyring = False
248
+
249
+ return self._save_encrypted(email, password)
250
+
251
+ def get(self) -> tuple[str, str] | None:
252
+ """Retrieve stored credentials.
253
+
254
+ Returns:
255
+ Tuple of (email, password) or None if no credentials stored
256
+ """
257
+ if self._use_keyring:
258
+ try:
259
+ email = keyring.get_password(SERVICE_NAME, "email")
260
+ password = keyring.get_password(SERVICE_NAME, "password")
261
+
262
+ if email and password:
263
+ logger.debug(
264
+ "Retrieved credentials from OS keyring",
265
+ email_masked=self._mask_email(email),
266
+ )
267
+ return (email, password)
268
+
269
+ except KeyringError as e:
270
+ logger.warning("Keyring read failed", error=str(e))
271
+
272
+ # Try encrypted file as fallback
273
+ return self._get_encrypted()
274
+
275
+ def clear(self) -> dict[str, Any]:
276
+ """Remove stored credentials.
277
+
278
+ Returns:
279
+ dict with success status
280
+ """
281
+ errors = []
282
+
283
+ if self._use_keyring:
284
+ try:
285
+ # keyring.delete_password raises if not found, so check first
286
+ if keyring.get_password(SERVICE_NAME, "email"):
287
+ keyring.delete_password(SERVICE_NAME, "email")
288
+ if keyring.get_password(SERVICE_NAME, "password"):
289
+ keyring.delete_password(SERVICE_NAME, "password")
290
+ logger.info("Cleared credentials from OS keyring")
291
+ except KeyringError as e:
292
+ errors.append(f"Keyring: {e}")
293
+
294
+ # Always try to clear encrypted file too
295
+ try:
296
+ self._clear_encrypted()
297
+ except Exception as e:
298
+ errors.append(f"Encrypted file: {e}")
299
+
300
+ if errors:
301
+ logger.warning("Some credential sources could not be cleared", errors=errors)
302
+
303
+ return {"success": True, "cleared": True}
304
+
305
+ def has_credentials(self) -> bool:
306
+ """Check if credentials are stored (without retrieving them).
307
+
308
+ Returns:
309
+ True if credentials are available
310
+ """
311
+ return self.get() is not None
312
+
313
+ def get_storage_info(self) -> dict[str, Any]:
314
+ """Get information about credential storage (for session_status).
315
+
316
+ Returns:
317
+ dict with storage method and availability, never exposes actual credentials
318
+ """
319
+ has_creds = self.has_credentials()
320
+
321
+ if self._use_keyring:
322
+ method = "keyring"
323
+ backend = "OS keyring (secure)"
324
+ elif CRYPTOGRAPHY_AVAILABLE:
325
+ method = "encrypted_file"
326
+ backend = "Encrypted file"
327
+ else:
328
+ method = "none"
329
+ backend = "Not available"
330
+
331
+ return {
332
+ "credentials_stored": has_creds,
333
+ "storage_method": method,
334
+ "storage_backend": backend,
335
+ "keyring_available": KEYRING_AVAILABLE and self._use_keyring,
336
+ "encryption_available": CRYPTOGRAPHY_AVAILABLE,
337
+ }