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,211 @@
|
|
|
1
|
+
"""Texas Grocery MCP Server - FastMCP entry point."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from texas_grocery_mcp.observability.health import health_live, health_ready
|
|
10
|
+
from texas_grocery_mcp.observability.logging import configure_logging
|
|
11
|
+
from texas_grocery_mcp.tools.cart import (
|
|
12
|
+
cart_add,
|
|
13
|
+
cart_add_many,
|
|
14
|
+
cart_add_with_retry,
|
|
15
|
+
cart_check_auth,
|
|
16
|
+
cart_get,
|
|
17
|
+
cart_remove,
|
|
18
|
+
)
|
|
19
|
+
from texas_grocery_mcp.tools.coupon import (
|
|
20
|
+
coupon_categories,
|
|
21
|
+
coupon_clip,
|
|
22
|
+
coupon_clipped,
|
|
23
|
+
coupon_list,
|
|
24
|
+
coupon_search,
|
|
25
|
+
)
|
|
26
|
+
from texas_grocery_mcp.tools.product import product_get, product_search, product_search_batch
|
|
27
|
+
from texas_grocery_mcp.tools.session import (
|
|
28
|
+
session_clear,
|
|
29
|
+
session_clear_credentials,
|
|
30
|
+
session_refresh,
|
|
31
|
+
session_save_credentials,
|
|
32
|
+
session_save_instructions,
|
|
33
|
+
session_status,
|
|
34
|
+
)
|
|
35
|
+
from texas_grocery_mcp.tools.store import (
|
|
36
|
+
store_change,
|
|
37
|
+
store_get_default,
|
|
38
|
+
store_search,
|
|
39
|
+
)
|
|
40
|
+
from texas_grocery_mcp.utils.config import get_settings
|
|
41
|
+
|
|
42
|
+
# Configure logging before anything else
|
|
43
|
+
configure_logging()
|
|
44
|
+
|
|
45
|
+
logger = structlog.get_logger()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@asynccontextmanager
|
|
49
|
+
async def lifespan(app: FastMCP) -> AsyncIterator[None]:
|
|
50
|
+
"""Lifespan hook for startup/shutdown tasks.
|
|
51
|
+
|
|
52
|
+
On startup:
|
|
53
|
+
- Checks session status
|
|
54
|
+
- Auto-refreshes if enabled and session needs refresh
|
|
55
|
+
"""
|
|
56
|
+
settings = get_settings()
|
|
57
|
+
|
|
58
|
+
# Startup: Check and refresh session if needed
|
|
59
|
+
if settings.auto_refresh_on_startup:
|
|
60
|
+
try:
|
|
61
|
+
from texas_grocery_mcp.auth.session import get_session_status
|
|
62
|
+
|
|
63
|
+
status = get_session_status()
|
|
64
|
+
logger.info(
|
|
65
|
+
"Startup session check",
|
|
66
|
+
authenticated=status["authenticated"],
|
|
67
|
+
needs_refresh=status["needs_refresh"],
|
|
68
|
+
time_remaining_hours=status["time_remaining_hours"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Auto-refresh if needed
|
|
72
|
+
if status["needs_refresh"] or (
|
|
73
|
+
status["time_remaining_hours"] is not None
|
|
74
|
+
and status["time_remaining_hours"] < settings.auto_refresh_threshold_hours
|
|
75
|
+
):
|
|
76
|
+
logger.info("Startup auto-refresh triggered")
|
|
77
|
+
try:
|
|
78
|
+
result = await session_refresh(headless=True)
|
|
79
|
+
if result.get("success"):
|
|
80
|
+
logger.info(
|
|
81
|
+
"Startup session refresh successful",
|
|
82
|
+
elapsed_seconds=result.get("elapsed_seconds"),
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Startup session refresh failed",
|
|
87
|
+
error=result.get("error"),
|
|
88
|
+
error_type=result.get("error_type"),
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.warning("Startup session refresh error", error=str(e))
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.warning("Startup session check failed", error=str(e))
|
|
95
|
+
|
|
96
|
+
yield # Server runs here
|
|
97
|
+
|
|
98
|
+
# Shutdown: cleanup if needed
|
|
99
|
+
logger.info("MCP server shutting down")
|
|
100
|
+
|
|
101
|
+
MCP_INSTRUCTIONS = """
|
|
102
|
+
## Texas Grocery MCP - Session Management
|
|
103
|
+
|
|
104
|
+
This MCP requires an authenticated HEB.com session for most operations.
|
|
105
|
+
|
|
106
|
+
### Before using cart, coupon, or store_change tools:
|
|
107
|
+
1. Call `session_status` to check authentication state
|
|
108
|
+
2. If `authenticated: false` or `needs_refresh: true`, call `session_refresh`
|
|
109
|
+
3. If session_refresh fails with headless mode, retry with `headless=False` for manual login
|
|
110
|
+
|
|
111
|
+
### Session states:
|
|
112
|
+
- `authenticated: true, needs_refresh: false` → Ready to use all tools
|
|
113
|
+
- `authenticated: true, refresh_recommended: true` → Works but consider refreshing soon
|
|
114
|
+
- `authenticated: false` or `needs_refresh: true` → Must refresh before cart/coupon operations
|
|
115
|
+
|
|
116
|
+
### Tools that work WITHOUT authentication:
|
|
117
|
+
- `store_search` - Find stores by address
|
|
118
|
+
- `product_search` / `product_search_batch` - Search products (uses local store default)
|
|
119
|
+
- `product_get` - Get detailed product info (ingredients, nutrition, warnings)
|
|
120
|
+
- `session_status` - Check session state
|
|
121
|
+
- `session_refresh` - Refresh/login
|
|
122
|
+
|
|
123
|
+
### Tools that REQUIRE authentication:
|
|
124
|
+
- `store_change` - Change store on HEB.com account
|
|
125
|
+
- `cart_get`, `cart_add`, `cart_add_many`, `cart_remove` - Cart operations
|
|
126
|
+
- `coupon_list`, `coupon_clip`, `coupon_clipped` - Coupon operations
|
|
127
|
+
|
|
128
|
+
### Typical workflow:
|
|
129
|
+
1. `session_status` → Check if authenticated
|
|
130
|
+
2. If not authenticated: `session_refresh(headless=False)` → User logs in via browser
|
|
131
|
+
3. `store_search("address")` → Find nearby stores
|
|
132
|
+
4. `store_change(store_id)` → Set preferred store
|
|
133
|
+
5. `product_search("query")` → Search for products
|
|
134
|
+
6. `cart_add(sku)` → Add to cart
|
|
135
|
+
|
|
136
|
+
### Automatic Login (Optional)
|
|
137
|
+
Save your HEB credentials once for automatic login when sessions expire:
|
|
138
|
+
|
|
139
|
+
1. `session_save_credentials(email, password)` → Store credentials securely
|
|
140
|
+
2. Now `session_refresh` will auto-login when session expires
|
|
141
|
+
3. `session_clear_credentials()` → Remove stored credentials if needed
|
|
142
|
+
|
|
143
|
+
### Human Handoff (Login/CAPTCHA/2FA/WAF)
|
|
144
|
+
When login requires human action (login form, CAPTCHA, 2FA, or a bot/WAF interstitial),
|
|
145
|
+
`session_refresh` returns immediately with:
|
|
146
|
+
- `status: "human_action_required"` - Clear indicator that human action is needed
|
|
147
|
+
- `action: "login" | "captcha" | "2fa" | "waf"` - What type of action is needed
|
|
148
|
+
- `screenshot_path: "/tmp/heb-login-<action>-123456.png"` - Screenshot of what's shown
|
|
149
|
+
|
|
150
|
+
**Workflow when human action is required:**
|
|
151
|
+
1. `session_refresh` returns with `status: "human_action_required"`
|
|
152
|
+
2. Read the screenshot at `screenshot_path` to see what's shown
|
|
153
|
+
3. Tell the user what's needed (log in, solve CAPTCHA, enter 2FA, or clear the WAF prompt)
|
|
154
|
+
4. User completes the action in the browser window that's open
|
|
155
|
+
5. User tells you "done" when finished
|
|
156
|
+
6. Call `session_refresh()` again to continue the login
|
|
157
|
+
7. Repeat until `status: "success"` or `status: "failed"`
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
mcp = FastMCP(
|
|
161
|
+
name="texas-grocery-mcp",
|
|
162
|
+
version="0.1.0",
|
|
163
|
+
instructions=MCP_INSTRUCTIONS,
|
|
164
|
+
lifespan=lifespan,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Register store tools
|
|
168
|
+
mcp.tool(annotations={"readOnlyHint": True})(store_search)
|
|
169
|
+
mcp.tool(annotations={"readOnlyHint": True})(store_get_default)
|
|
170
|
+
mcp.tool()(store_change) # Changes store on HEB.com when authenticated, or sets local default
|
|
171
|
+
|
|
172
|
+
# Register product tools
|
|
173
|
+
mcp.tool(annotations={"readOnlyHint": True})(product_search)
|
|
174
|
+
mcp.tool(annotations={"readOnlyHint": True})(product_search_batch)
|
|
175
|
+
mcp.tool(annotations={"readOnlyHint": True})(product_get)
|
|
176
|
+
|
|
177
|
+
# Register coupon tools
|
|
178
|
+
mcp.tool(annotations={"readOnlyHint": True})(coupon_list)
|
|
179
|
+
mcp.tool(annotations={"readOnlyHint": True})(coupon_search)
|
|
180
|
+
mcp.tool(annotations={"readOnlyHint": True})(coupon_categories)
|
|
181
|
+
mcp.tool(annotations={"destructiveHint": True})(coupon_clip)
|
|
182
|
+
mcp.tool(annotations={"readOnlyHint": True})(coupon_clipped)
|
|
183
|
+
|
|
184
|
+
# Register cart tools (destructive operations require confirmation)
|
|
185
|
+
mcp.tool(annotations={"readOnlyHint": True})(cart_check_auth)
|
|
186
|
+
mcp.tool(annotations={"readOnlyHint": True})(cart_get)
|
|
187
|
+
mcp.tool(annotations={"destructiveHint": True})(cart_add)
|
|
188
|
+
mcp.tool(annotations={"destructiveHint": True})(cart_add_many)
|
|
189
|
+
mcp.tool(annotations={"destructiveHint": True})(cart_add_with_retry)
|
|
190
|
+
mcp.tool(annotations={"destructiveHint": True})(cart_remove)
|
|
191
|
+
|
|
192
|
+
# Register session tools
|
|
193
|
+
mcp.tool(annotations={"readOnlyHint": True})(session_status)
|
|
194
|
+
mcp.tool(annotations={"readOnlyHint": True})(session_save_instructions)
|
|
195
|
+
mcp.tool()(session_refresh) # Uses embedded Playwright when available, falls back to commands
|
|
196
|
+
mcp.tool()(session_clear)
|
|
197
|
+
mcp.tool()(session_save_credentials) # Store HEB credentials for auto-login
|
|
198
|
+
mcp.tool()(session_clear_credentials) # Remove stored credentials
|
|
199
|
+
|
|
200
|
+
# Register health check tools
|
|
201
|
+
mcp.tool(annotations={"readOnlyHint": True})(health_live)
|
|
202
|
+
mcp.tool(annotations={"readOnlyHint": True})(health_ready)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main() -> None:
|
|
206
|
+
"""Run the MCP server."""
|
|
207
|
+
mcp.run()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Geocoding service using Nominatim (OpenStreetMap)."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger()
|
|
11
|
+
|
|
12
|
+
# Nominatim API endpoint
|
|
13
|
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
14
|
+
|
|
15
|
+
# Nominatim requires a valid User-Agent. They block custom app names
|
|
16
|
+
# without real contact info. Using curl format as a pragmatic workaround.
|
|
17
|
+
USER_AGENT = "curl/8.7.1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GeocodingResult:
|
|
22
|
+
"""Result from geocoding an address."""
|
|
23
|
+
|
|
24
|
+
latitude: float
|
|
25
|
+
longitude: float
|
|
26
|
+
city: str | None
|
|
27
|
+
state: str | None
|
|
28
|
+
postcode: str | None
|
|
29
|
+
display_name: str
|
|
30
|
+
|
|
31
|
+
def get_query_variations(self, original_query: str) -> list[str]:
|
|
32
|
+
"""Generate query variations to try against HEB's API.
|
|
33
|
+
|
|
34
|
+
Returns queries in priority order:
|
|
35
|
+
1. Zip code (most reliable)
|
|
36
|
+
2. City, State format
|
|
37
|
+
3. Original query
|
|
38
|
+
"""
|
|
39
|
+
variations = []
|
|
40
|
+
|
|
41
|
+
# Priority 1: Zip code
|
|
42
|
+
if self.postcode:
|
|
43
|
+
# Extract just the 5-digit zip if it has extension
|
|
44
|
+
zip_code = self.postcode.split("-")[0].strip()
|
|
45
|
+
if zip_code:
|
|
46
|
+
variations.append(zip_code)
|
|
47
|
+
|
|
48
|
+
# Priority 2: City, State
|
|
49
|
+
if self.city and self.state:
|
|
50
|
+
variations.append(f"{self.city}, {self.state}")
|
|
51
|
+
|
|
52
|
+
# Priority 3: Original query (if not already covered)
|
|
53
|
+
original_lower = original_query.lower().strip()
|
|
54
|
+
if not any(v.lower() == original_lower for v in variations):
|
|
55
|
+
variations.append(original_query)
|
|
56
|
+
|
|
57
|
+
return variations
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GeocodingService:
|
|
61
|
+
"""Geocoding via Nominatim (OpenStreetMap).
|
|
62
|
+
|
|
63
|
+
Converts addresses, neighborhoods, and landmarks to coordinates
|
|
64
|
+
and structured address components.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, timeout: float = 15.0):
|
|
68
|
+
"""Initialize geocoding service.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
timeout: Request timeout in seconds (default 15s for Nominatim)
|
|
72
|
+
"""
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
self._client: httpx.AsyncClient | None = None
|
|
75
|
+
|
|
76
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
77
|
+
"""Get or create HTTP client."""
|
|
78
|
+
if self._client is None:
|
|
79
|
+
self._client = httpx.AsyncClient(
|
|
80
|
+
timeout=httpx.Timeout(self.timeout),
|
|
81
|
+
headers={"User-Agent": USER_AGENT},
|
|
82
|
+
http2=False, # Nominatim doesn't handle HTTP/2 well
|
|
83
|
+
)
|
|
84
|
+
return self._client
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
"""Close HTTP client."""
|
|
88
|
+
if self._client:
|
|
89
|
+
await self._client.aclose()
|
|
90
|
+
self._client = None
|
|
91
|
+
|
|
92
|
+
async def geocode(self, address: str) -> GeocodingResult | None:
|
|
93
|
+
"""Convert address to coordinates and structured components.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
address: Address, zip code, neighborhood, or landmark to geocode
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
GeocodingResult with coordinates and address components,
|
|
100
|
+
or None if geocoding failed
|
|
101
|
+
"""
|
|
102
|
+
if not address or not address.strip():
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
client = await self._get_client()
|
|
106
|
+
|
|
107
|
+
params = {
|
|
108
|
+
"q": address.strip(),
|
|
109
|
+
"format": "json",
|
|
110
|
+
"addressdetails": "1",
|
|
111
|
+
"limit": "1",
|
|
112
|
+
"countrycodes": "us",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
logger.debug("Geocoding address", address=address)
|
|
117
|
+
response = await client.get(NOMINATIM_URL, params=params)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
|
|
120
|
+
results = response.json()
|
|
121
|
+
if not results:
|
|
122
|
+
logger.info("Geocoding returned no results", address=address)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
return self._parse_result(results[0])
|
|
126
|
+
|
|
127
|
+
except httpx.TimeoutException:
|
|
128
|
+
logger.warning("Geocoding timeout", address=address)
|
|
129
|
+
return None
|
|
130
|
+
except httpx.HTTPError as e:
|
|
131
|
+
logger.warning("Geocoding HTTP error", address=address, error=str(e))
|
|
132
|
+
return None
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error("Geocoding unexpected error", address=address, error=str(e))
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _parse_result(self, result: dict[str, Any]) -> GeocodingResult:
|
|
138
|
+
"""Parse Nominatim API result into GeocodingResult.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
result: Single result from Nominatim API
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
GeocodingResult with extracted data
|
|
145
|
+
"""
|
|
146
|
+
address_details = result.get("address", {})
|
|
147
|
+
|
|
148
|
+
# Extract city - Nominatim uses various fields
|
|
149
|
+
city = (
|
|
150
|
+
address_details.get("city")
|
|
151
|
+
or address_details.get("town")
|
|
152
|
+
or address_details.get("village")
|
|
153
|
+
or address_details.get("municipality")
|
|
154
|
+
or address_details.get("county")
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Extract state
|
|
158
|
+
state = address_details.get("state")
|
|
159
|
+
# Convert full state name to abbreviation if needed
|
|
160
|
+
state = self._abbreviate_state(state) if state else None
|
|
161
|
+
|
|
162
|
+
return GeocodingResult(
|
|
163
|
+
latitude=float(result["lat"]),
|
|
164
|
+
longitude=float(result["lon"]),
|
|
165
|
+
city=city,
|
|
166
|
+
state=state,
|
|
167
|
+
postcode=address_details.get("postcode"),
|
|
168
|
+
display_name=result.get("display_name", ""),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _abbreviate_state(self, state: str) -> str:
|
|
172
|
+
"""Convert state name to abbreviation.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
state: Full state name or abbreviation
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Two-letter state abbreviation
|
|
179
|
+
"""
|
|
180
|
+
# If already abbreviated, return as-is
|
|
181
|
+
if len(state) == 2:
|
|
182
|
+
return state.upper()
|
|
183
|
+
|
|
184
|
+
state_abbrevs = {
|
|
185
|
+
"texas": "TX",
|
|
186
|
+
"california": "CA",
|
|
187
|
+
"new york": "NY",
|
|
188
|
+
"florida": "FL",
|
|
189
|
+
"louisiana": "LA",
|
|
190
|
+
"oklahoma": "OK",
|
|
191
|
+
"new mexico": "NM",
|
|
192
|
+
"arkansas": "AR",
|
|
193
|
+
"arizona": "AZ",
|
|
194
|
+
"colorado": "CO",
|
|
195
|
+
# Add more as needed
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return state_abbrevs.get(state.lower(), state)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
202
|
+
"""Calculate distance between two points using Haversine formula.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
lat1, lon1: First point coordinates (degrees)
|
|
206
|
+
lat2, lon2: Second point coordinates (degrees)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Distance in miles
|
|
210
|
+
"""
|
|
211
|
+
# Earth radius in miles
|
|
212
|
+
earth_radius_miles = 3959
|
|
213
|
+
|
|
214
|
+
# Convert to radians
|
|
215
|
+
lat1_rad = math.radians(lat1)
|
|
216
|
+
lat2_rad = math.radians(lat2)
|
|
217
|
+
delta_lat = math.radians(lat2 - lat1)
|
|
218
|
+
delta_lon = math.radians(lon2 - lon1)
|
|
219
|
+
|
|
220
|
+
# Haversine formula
|
|
221
|
+
a = (
|
|
222
|
+
math.sin(delta_lat / 2) ** 2
|
|
223
|
+
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2
|
|
224
|
+
)
|
|
225
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
226
|
+
|
|
227
|
+
return earth_radius_miles * c
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Thread-safe state management for MCP tools.
|
|
2
|
+
|
|
3
|
+
Uses contextvars for request-scoped state and locks for shared resources.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
|
|
16
|
+
|
|
17
|
+
# Request-scoped state using contextvars
|
|
18
|
+
_request_store_id: ContextVar[str | None] = ContextVar("request_store_id", default=None)
|
|
19
|
+
|
|
20
|
+
# Shared state with proper locking
|
|
21
|
+
_state_lock = asyncio.Lock()
|
|
22
|
+
_shared_state: dict[str, Any] = {
|
|
23
|
+
"default_store_id": None,
|
|
24
|
+
"found_stores": {},
|
|
25
|
+
"graphql_client": None,
|
|
26
|
+
"pending_login": None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StateManager:
|
|
31
|
+
"""Thread-safe state manager for MCP tools.
|
|
32
|
+
|
|
33
|
+
Provides:
|
|
34
|
+
- Request-scoped store ID via contextvars
|
|
35
|
+
- Shared GraphQL client with lazy initialization
|
|
36
|
+
- Thread-safe store cache
|
|
37
|
+
- Login state management with locking
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# --- GraphQL Client (singleton, thread-safe) ---
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
async def get_graphql_client() -> "HEBGraphQLClient":
|
|
44
|
+
"""Get or create the shared GraphQL client.
|
|
45
|
+
|
|
46
|
+
Thread-safe lazy initialization.
|
|
47
|
+
"""
|
|
48
|
+
from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
|
|
49
|
+
|
|
50
|
+
async with _state_lock:
|
|
51
|
+
if _shared_state["graphql_client"] is None:
|
|
52
|
+
_shared_state["graphql_client"] = HEBGraphQLClient()
|
|
53
|
+
return cast(HEBGraphQLClient, _shared_state["graphql_client"])
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def get_graphql_client_sync() -> "HEBGraphQLClient":
|
|
57
|
+
"""Get or create GraphQL client (sync version for non-async contexts).
|
|
58
|
+
|
|
59
|
+
Note: Initialization is not fully thread-safe, but acceptable for
|
|
60
|
+
MCP's primarily single-threaded execution model.
|
|
61
|
+
"""
|
|
62
|
+
from texas_grocery_mcp.clients.graphql import HEBGraphQLClient
|
|
63
|
+
|
|
64
|
+
if _shared_state["graphql_client"] is None:
|
|
65
|
+
_shared_state["graphql_client"] = HEBGraphQLClient()
|
|
66
|
+
return cast(HEBGraphQLClient, _shared_state["graphql_client"])
|
|
67
|
+
|
|
68
|
+
# --- Default Store ID ---
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def get_default_store_id() -> str | None:
|
|
72
|
+
"""Get the current default store ID."""
|
|
73
|
+
# Check request-scoped override first
|
|
74
|
+
request_store = _request_store_id.get()
|
|
75
|
+
if request_store is not None:
|
|
76
|
+
return request_store
|
|
77
|
+
return cast(str | None, _shared_state["default_store_id"])
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
async def set_default_store_id(store_id: str | None) -> None:
|
|
81
|
+
"""Set the default store ID (thread-safe)."""
|
|
82
|
+
async with _state_lock:
|
|
83
|
+
_shared_state["default_store_id"] = store_id
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def set_default_store_id_sync(store_id: str | None) -> None:
|
|
87
|
+
"""Set default store ID (sync version)."""
|
|
88
|
+
_shared_state["default_store_id"] = store_id
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def set_request_store_id(store_id: str | None) -> None:
|
|
92
|
+
"""Set store ID for the current request only."""
|
|
93
|
+
_request_store_id.set(store_id)
|
|
94
|
+
|
|
95
|
+
# --- Found Stores Cache ---
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
async def cache_stores(stores: dict[str, Any]) -> None:
|
|
99
|
+
"""Cache stores found via search (thread-safe)."""
|
|
100
|
+
async with _state_lock:
|
|
101
|
+
_shared_state["found_stores"].update(stores)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def cache_stores_sync(stores: dict[str, Any]) -> None:
|
|
105
|
+
"""Cache stores found via search (sync version)."""
|
|
106
|
+
_shared_state["found_stores"].update(stores)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def get_cached_store(store_id: str) -> Any | None:
|
|
110
|
+
"""Get a cached store by ID."""
|
|
111
|
+
return _shared_state["found_stores"].get(store_id)
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def get_all_cached_stores() -> dict[str, Any]:
|
|
115
|
+
"""Get all cached stores (returns a copy for safety)."""
|
|
116
|
+
return dict(cast(dict[str, Any], _shared_state["found_stores"]))
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def get_cached_stores_values() -> Any:
|
|
120
|
+
"""Get cached stores values (for iteration)."""
|
|
121
|
+
return _shared_state["found_stores"].values()
|
|
122
|
+
|
|
123
|
+
# --- Pending Login State ---
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
async def get_pending_login() -> dict[str, Any] | None:
|
|
127
|
+
"""Get pending login state (thread-safe)."""
|
|
128
|
+
async with _state_lock:
|
|
129
|
+
return cast(dict[str, Any] | None, _shared_state["pending_login"])
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def get_pending_login_sync() -> dict[str, Any] | None:
|
|
133
|
+
"""Get pending login state (sync version)."""
|
|
134
|
+
return cast(dict[str, Any] | None, _shared_state["pending_login"])
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
async def set_pending_login(state: dict[str, Any] | None) -> None:
|
|
138
|
+
"""Set pending login state (thread-safe)."""
|
|
139
|
+
async with _state_lock:
|
|
140
|
+
_shared_state["pending_login"] = state
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def set_pending_login_sync(state: dict[str, Any] | None) -> None:
|
|
144
|
+
"""Set pending login state (sync version)."""
|
|
145
|
+
_shared_state["pending_login"] = state
|
|
146
|
+
|
|
147
|
+
# --- Reset (for testing) ---
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
async def reset() -> None:
|
|
151
|
+
"""Reset all state. For testing only."""
|
|
152
|
+
async with _state_lock:
|
|
153
|
+
_shared_state["default_store_id"] = None
|
|
154
|
+
_shared_state["found_stores"] = {}
|
|
155
|
+
_shared_state["graphql_client"] = None
|
|
156
|
+
_shared_state["pending_login"] = None
|
|
157
|
+
_request_store_id.set(None)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def reset_sync() -> None:
|
|
161
|
+
"""Reset all state synchronously. For testing only."""
|
|
162
|
+
_shared_state["default_store_id"] = None
|
|
163
|
+
_shared_state["found_stores"] = {}
|
|
164
|
+
_shared_state["graphql_client"] = None
|
|
165
|
+
_shared_state["pending_login"] = None
|
|
166
|
+
_request_store_id.set(None)
|