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