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,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,5 @@
1
+ """Services for Texas Grocery MCP."""
2
+
3
+ from texas_grocery_mcp.services.geocoding import GeocodingResult, GeocodingService
4
+
5
+ __all__ = ["GeocodingResult", "GeocodingService"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ """MCP tool definitions."""
2
+
3
+ from texas_grocery_mcp.tools import cart, coupon, product, session, store
4
+
5
+ __all__ = ["cart", "coupon", "product", "session", "store"]