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