mcp-stata 1.7.3__py3-none-any.whl → 1.13.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.

Potentially problematic release.


This version of mcp-stata might be problematic. Click here for more details.

mcp_stata/config.py ADDED
@@ -0,0 +1,20 @@
1
+
2
+ """
3
+ Central configuration for mcp-stata server and UI channel.
4
+ """
5
+ from typing import Final
6
+
7
+ # Server Limits
8
+ MAX_LIMIT: Final[int] = 500 # Default row limit for JSON endpoints
9
+ MAX_VARS: Final[int] = 32_767 # Max variables in Stata
10
+ MAX_CHARS: Final[int] = 500 # Max chars per string cell to return
11
+ MAX_REQUEST_BYTES: Final[int] = 1_000_000 # Max size of HTTP request body
12
+ MAX_ARROW_LIMIT: Final[int] = 1_000_000 # Default row limit for Arrow IPC streams
13
+
14
+ # Timeouts (seconds)
15
+ TOKEN_TTL_S: Final[int] = 20 * 60 # Bearer token validity
16
+ VIEW_TTL_S: Final[int] = 30 * 60 # Filtered view handle validity
17
+
18
+ # Network
19
+ DEFAULT_HOST: Final[str] = "127.0.0.1"
20
+ DEFAULT_PORT: Final[int] = 0 # 0 = random ephemeral port
mcp_stata/discovery.py CHANGED
@@ -1,10 +1,11 @@
1
1
  """
2
- Improved discovery.py with better error handling for intermittent failures.
2
+ Optimized discovery.py with fast auto-discovery and targeted retry logic.
3
3
  Key improvements:
4
- 1. Retry logic for file existence checks
5
- 2. Better diagnostic logging
6
- 3. Fuzzy path matching for common typos
7
- 4. Case-insensitive path resolution on Windows
4
+ 1. Fast path checking during discovery (no retries)
5
+ 2. Retry logic only for validation of user-provided paths
6
+ 3. Better diagnostic logging
7
+ 4. Fuzzy path matching for common typos
8
+ 5. Case-insensitive path resolution on Windows
8
9
  """
9
10
 
10
11
  import os
@@ -15,15 +16,17 @@ import logging
15
16
  import shutil
16
17
  import ntpath
17
18
  import time
19
+ import re
18
20
  from typing import Tuple, List, Optional
19
21
 
20
22
  logger = logging.getLogger("mcp_stata.discovery")
21
23
 
22
24
 
23
- def _exists_with_retry(path: str, max_attempts: int = 3, delay: float = 0.1) -> bool:
25
+ def _exists_with_retry(path: str, max_attempts: int = 1, delay: float = 0.01) -> bool:
24
26
  """
25
27
  Check if file exists with retry logic to handle transient failures.
26
28
  This helps with antivirus scans, file locks, and other temporary issues.
29
+ Only use this for validating user-provided paths, not during discovery.
27
30
  """
28
31
  for attempt in range(max_attempts):
29
32
  if os.path.exists(path):
@@ -36,6 +39,11 @@ def _exists_with_retry(path: str, max_attempts: int = 3, delay: float = 0.1) ->
36
39
  return False
37
40
 
38
41
 
42
+ def _exists_fast(path: str) -> bool:
43
+ """Fast existence check without retries for auto-discovery."""
44
+ return os.path.exists(path)
45
+
46
+
39
47
  def _find_similar_stata_dirs(target_path: str) -> List[str]:
40
48
  """
41
49
  Find similar Stata directories to help diagnose path typos.
@@ -72,6 +80,7 @@ def _validate_path_with_diagnostics(path: str, system: str) -> Tuple[bool, str]:
72
80
  """
73
81
  Validate path exists and provide detailed diagnostics if not.
74
82
  Returns (exists, diagnostic_message)
83
+ Uses retry logic for validation since this is for user-provided paths.
75
84
  """
76
85
  if _exists_with_retry(path):
77
86
  return True, ""
@@ -132,8 +141,14 @@ def _normalize_env_path(raw: str, system: str) -> str:
132
141
  return os.path.normpath(expanded)
133
142
 
134
143
 
135
- def _is_executable(path: str, system: str) -> bool:
136
- if not _exists_with_retry(path): # Use retry logic
144
+ def _is_executable(path: str, system: str, use_retry: bool = True) -> bool:
145
+ """
146
+ Check if path is executable.
147
+ use_retry: Use retry logic for user-provided paths, fast check for discovery.
148
+ """
149
+ exists_check = _exists_with_retry if use_retry else _exists_fast
150
+
151
+ if not exists_check(path):
137
152
  return False
138
153
  if system == "Windows":
139
154
  # On Windows, check if it's a file and has .exe extension
@@ -165,6 +180,37 @@ def _dedupe_str_preserve(items: List[str]) -> List[str]:
165
180
  return out
166
181
 
167
182
 
183
+ def _extract_version_number(path: str) -> int:
184
+ """
185
+ Extract the highest Stata version number found in path components that
186
+ mention 'stata'. Returns 0 if no version is found.
187
+ """
188
+ version = 0
189
+ normalized = path.lower().replace("\\", os.sep)
190
+ for part in normalized.split(os.sep):
191
+ if "stata" not in part:
192
+ continue
193
+ for match in re.findall(r"(\d{1,3})", part):
194
+ try:
195
+ version = max(version, int(match))
196
+ except ValueError:
197
+ continue
198
+ return version
199
+
200
+
201
+ def _sort_candidates(candidates: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
202
+ """Sort candidates by version desc, edition (mp>se>be), then path for stability."""
203
+ edition_rank = {"mp": 3, "se": 2, "be": 1}
204
+
205
+ def sort_key(item: Tuple[str, str]):
206
+ path, edition = item
207
+ version = _extract_version_number(path)
208
+ rank = edition_rank.get((edition or "").lower(), 0)
209
+ return (-version, -rank, path)
210
+
211
+ return sorted(candidates, key=sort_key)
212
+
213
+
168
214
  def _resolve_windows_host_path(path: str, system: str) -> str:
169
215
  """
170
216
  On non-Windows hosts running Windows-discovery code, a Windows-style path
@@ -174,11 +220,11 @@ def _resolve_windows_host_path(path: str, system: str) -> str:
174
220
  """
175
221
  if system != "Windows":
176
222
  return path
177
- if _exists_with_retry(path): # Use retry logic
223
+ if _exists_fast(path):
178
224
  return path
179
225
  if os.sep != "\\" and "\\" in path:
180
226
  alt_path = path.replace("\\", os.sep)
181
- if _exists_with_retry(alt_path): # Use retry logic
227
+ if _exists_fast(alt_path):
182
228
  return alt_path
183
229
  return path
184
230
 
@@ -193,13 +239,18 @@ def _detect_system() -> str:
193
239
  return platform.system()
194
240
 
195
241
 
196
- def find_stata_path() -> Tuple[str, str]:
242
+ def find_stata_candidates() -> List[Tuple[str, str]]:
197
243
  """
198
- Attempts to automatically locate the Stata installation path.
199
- Returns (path_to_executable, edition_string).
244
+ Locate all viable Stata installations ordered by preference.
245
+
246
+ Returns:
247
+ List of (path_to_executable, edition_string) sorted by:
248
+ - Newest version number found in path (desc)
249
+ - Edition preference: mp > se > be
250
+ - Path name (stable tie-breaker)
200
251
 
201
252
  Behavior:
202
- - If STATA_PATH is set and valid, use it.
253
+ - If STATA_PATH is set and valid, use it (may yield multiple binaries in dir).
203
254
  - If STATA_PATH is set but invalid, provide detailed diagnostics and fall back.
204
255
  - If auto-discovery fails, raise an error with helpful suggestions.
205
256
  """
@@ -215,52 +266,62 @@ def find_stata_path() -> Tuple[str, str]:
215
266
  ("Stata-64.exe", "be"),
216
267
  ("Stata.exe", "be"),
217
268
  ]
218
-
219
269
  linux_binaries = [
220
270
  ("stata-mp", "mp"),
221
271
  ("stata-se", "se"),
222
- ("stata-ic", "be"),
223
272
  ("stata", "be"),
224
273
  ("xstata-mp", "mp"),
225
274
  ("xstata-se", "se"),
226
- ("xstata-ic", "be"),
227
275
  ("xstata", "be"),
228
276
  ]
229
277
 
230
- # 1. Check Environment Variable (supports quoted values and directory targets)
231
- raw_env_path = os.environ.get("STATA_PATH")
232
- if raw_env_path:
278
+ # 1. Check STATA_PATH override with enhanced diagnostics
279
+ raw_stata_path = os.environ.get("STATA_PATH")
280
+ if raw_stata_path:
233
281
  try:
234
- path = _normalize_env_path(raw_env_path, system)
282
+ path = _normalize_env_path(raw_stata_path, system)
235
283
  path = _resolve_windows_host_path(path, system)
236
- logger.info("Trying STATA_PATH override (normalized): %s", path)
237
284
 
238
- # If a directory is provided, try standard binaries for the platform
239
285
  if os.path.isdir(path):
240
- search_set = []
286
+ candidates_in_dir = []
241
287
  if system == "Windows":
242
- search_set = windows_binaries
243
- elif system == "Linux":
244
- search_set = linux_binaries
245
- elif system == "Darwin":
246
- search_set = [
247
- ("Contents/MacOS/stata-mp", "mp"),
248
- ("Contents/MacOS/stata-se", "se"),
249
- ("Contents/MacOS/stata", "be"),
250
- ("stata-mp", "mp"),
251
- ("stata-se", "se"),
252
- ("stata", "be"),
253
- ]
254
-
255
- for binary, edition in search_set:
256
- candidate = os.path.join(path, binary)
257
- if _is_executable(candidate, system):
258
- logger.info(
259
- "Found Stata via STATA_PATH directory: %s (%s)",
260
- candidate,
261
- edition,
262
- )
263
- return candidate, edition
288
+ for exe, edition in windows_binaries:
289
+ candidate = os.path.join(path, exe)
290
+ if _is_executable(candidate, system, use_retry=True):
291
+ candidates_in_dir.append((candidate, edition))
292
+ elif system == "Darwin" or (system != "Windows" and path.endswith(".app")):
293
+ # macOS app bundle logic
294
+ sub_path = os.path.join(path, "Contents", "MacOS")
295
+ if os.path.isdir(sub_path):
296
+ for binary, edition in [("stata-mp", "mp"), ("stata-se", "se"), ("stata", "be")]:
297
+ candidate = os.path.join(sub_path, binary)
298
+ if _is_executable(candidate, system, use_retry=True):
299
+ candidates_in_dir.append((candidate, edition))
300
+
301
+ # Also try direct if not in a bundle
302
+ if not candidates_in_dir:
303
+ for binary, edition in linux_binaries:
304
+ candidate = os.path.join(path, binary)
305
+ if _is_executable(candidate, system, use_retry=True):
306
+ candidates_in_dir.append((candidate, edition))
307
+ else:
308
+ for binary, edition in linux_binaries:
309
+ candidate = os.path.join(path, binary)
310
+ if _is_executable(candidate, system, use_retry=True):
311
+ candidates_in_dir.append((candidate, edition))
312
+
313
+ if candidates_in_dir:
314
+ resolved = []
315
+ for candidate, edition in _sort_candidates(candidates_in_dir):
316
+ if _is_executable(candidate, system, use_retry=True):
317
+ logger.info(
318
+ "Found Stata via STATA_PATH directory: %s (%s)",
319
+ candidate,
320
+ edition,
321
+ )
322
+ resolved.append((candidate, edition))
323
+ if resolved:
324
+ return resolved
264
325
 
265
326
  # Enhanced error with diagnostics
266
327
  exists, diagnostics = _validate_path_with_diagnostics(path, system)
@@ -281,7 +342,7 @@ def find_stata_path() -> Tuple[str, str]:
281
342
  elif "be" in lower_path:
282
343
  edition = "be"
283
344
 
284
- # Use enhanced validation with diagnostics
345
+ # Use enhanced validation with diagnostics (with retry for user path)
285
346
  exists, diagnostics = _validate_path_with_diagnostics(path, system)
286
347
  if not exists:
287
348
  error_msg = (
@@ -293,14 +354,14 @@ def find_stata_path() -> Tuple[str, str]:
293
354
  )
294
355
  raise FileNotFoundError(error_msg)
295
356
 
296
- if not _is_executable(path, system):
357
+ if not _is_executable(path, system, use_retry=True):
297
358
  raise PermissionError(
298
359
  f"STATA_PATH points to '{path}', but it is not executable. "
299
360
  "Ensure this is the Stata binary, not the .app directory."
300
361
  )
301
362
 
302
363
  logger.info("Using STATA_PATH override: %s (%s)", path, edition)
303
- return path, edition
364
+ return [(path, edition)]
304
365
 
305
366
  except Exception as exc:
306
367
  stata_path_error = exc
@@ -310,7 +371,7 @@ def find_stata_path() -> Tuple[str, str]:
310
371
  exc,
311
372
  )
312
373
 
313
- # 2. Platform-specific search
374
+ # 2. Platform-specific search (using fast checks, no retries)
314
375
  candidates: List[Tuple[str, str]] = [] # List of (path, edition)
315
376
 
316
377
  if system == "Darwin": # macOS
@@ -321,17 +382,18 @@ def find_stata_path() -> Tuple[str, str]:
321
382
  "/Applications/Stata/StataMP.app",
322
383
  "/Applications/Stata/StataSE.app",
323
384
  "/Applications/Stata/Stata.app",
385
+ "/Applications/Stata*.app",
324
386
  "/Applications/Stata*/Stata*.app",
325
387
  ]
326
388
 
327
389
  for pattern in app_globs:
328
390
  for app_dir in glob.glob(pattern):
329
391
  binary_dir = os.path.join(app_dir, "Contents", "MacOS")
330
- if not _exists_with_retry(binary_dir): # Use retry logic
392
+ if not _exists_fast(binary_dir):
331
393
  continue
332
394
  for binary, edition in [("stata-mp", "mp"), ("stata-se", "se"), ("stata", "be")]:
333
395
  full_path = os.path.join(binary_dir, binary)
334
- if _exists_with_retry(full_path): # Use retry logic
396
+ if _exists_fast(full_path):
335
397
  candidates.append((full_path, edition))
336
398
 
337
399
  elif system == "Windows":
@@ -379,7 +441,7 @@ def find_stata_path() -> Tuple[str, str]:
379
441
  continue
380
442
  for exe, edition in windows_binaries:
381
443
  full_path = os.path.join(stata_dir, exe)
382
- if _exists_with_retry(full_path): # Use retry logic
444
+ if _exists_fast(full_path):
383
445
  candidates.append((full_path, edition))
384
446
 
385
447
  elif system == "Linux":
@@ -417,20 +479,25 @@ def find_stata_path() -> Tuple[str, str]:
417
479
  continue
418
480
  for binary, edition in linux_binaries:
419
481
  full_path = os.path.join(base_dir, binary)
420
- if _exists_with_retry(full_path): # Use retry logic
482
+ if _exists_fast(full_path):
421
483
  candidates.append((full_path, edition))
422
484
 
423
- candidates = _dedupe_preserve(candidates)
485
+ candidates = _dedupe_preserve(candidates)
424
486
 
425
- for path, edition in candidates:
426
- if not _exists_with_retry(path): # Use retry logic
487
+ # Final validation of candidates (still using fast checks)
488
+ validated: List[Tuple[str, str]] = []
489
+ for path, edition in _sort_candidates(candidates):
490
+ if not _exists_fast(path):
427
491
  logger.warning("Discovered candidate missing on disk: %s", path)
428
492
  continue
429
- if not _is_executable(path, system):
493
+ if not _is_executable(path, system, use_retry=False):
430
494
  logger.warning("Discovered candidate is not executable: %s", path)
431
495
  continue
432
496
  logger.info("Auto-discovered Stata at %s (%s)", path, edition)
433
- return path, edition
497
+ validated.append((path, edition))
498
+
499
+ if validated:
500
+ return validated
434
501
 
435
502
  # Build comprehensive error message
436
503
  error_parts = ["Could not automatically locate Stata."]
@@ -455,6 +522,14 @@ def find_stata_path() -> Tuple[str, str]:
455
522
  raise FileNotFoundError("\n".join(error_parts))
456
523
 
457
524
 
525
+ def find_stata_path() -> Tuple[str, str]:
526
+ """
527
+ Backward-compatible wrapper returning the top-ranked candidate.
528
+ """
529
+ candidates = find_stata_candidates()
530
+ return candidates[0]
531
+
532
+
458
533
  def main() -> int:
459
534
  """CLI helper to print discovered Stata binary and edition."""
460
535
  try:
@@ -6,6 +6,7 @@ during Stata command execution and automatically cache them.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import inspect
9
10
  import re
10
11
  import threading
11
12
  import time
@@ -39,11 +40,14 @@ class GraphCreationDetector:
39
40
  if not self._stata_client or not hasattr(self._stata_client, "stata"):
40
41
  return ""
41
42
  try:
42
- # Capture output so we can hash it deterministically.
43
- resp = self._stata_client.run_command_structured(f"graph describe {graph_name}", echo=False)
43
+ # Use lightweight execution to avoid heavy FS I/O for high-frequency polling
44
+ resp = self._stata_client.exec_lightweight(f"graph describe {graph_name}")
45
+
44
46
  if resp.success and resp.stdout:
45
47
  return resp.stdout
46
48
  if resp.error and resp.error.snippet:
49
+ # If using lightweight, error might be None or just string in stderr,
50
+ # but run_command_structured returns proper error envelope.
47
51
  return resp.error.snippet
48
52
  except Exception:
49
53
  return ""
@@ -95,15 +99,21 @@ class GraphCreationDetector:
95
99
  try:
96
100
  # Use pystata to get graph list directly
97
101
  if self._stata_client and hasattr(self._stata_client, 'list_graphs'):
98
- return self._stata_client.list_graphs()
102
+ return self._stata_client.list_graphs(force_refresh=True)
99
103
  else:
100
104
  # Fallback to sfi Macro interface - only if stata is available
101
105
  if self._stata_client and hasattr(self._stata_client, 'stata'):
102
106
  try:
103
107
  from sfi import Macro
104
- self._stata_client.stata.run("quietly graph dir, memory")
105
- self._stata_client.stata.run("global mcp_graph_list `r(list)'")
106
- graph_list_str = Macro.getGlobal("mcp_graph_list")
108
+ hold_name = f"_mcp_detector_hold_{int(time.time() * 1000 % 1000000)}"
109
+ self._stata_client.stata.run(f"capture _return hold {hold_name}", echo=False)
110
+ try:
111
+ self._stata_client.stata.run("macro define mcp_graph_list \"\"", echo=False)
112
+ self._stata_client.stata.run("quietly graph dir, memory", echo=False)
113
+ self._stata_client.stata.run("macro define mcp_graph_list `r(list)'", echo=False)
114
+ graph_list_str = Macro.getGlobal("mcp_graph_list")
115
+ finally:
116
+ self._stata_client.stata.run(f"capture _return restore {hold_name}", echo=False)
107
117
  return graph_list_str.split() if graph_list_str else []
108
118
  except ImportError:
109
119
  logger.warning("sfi.Macro not available for fallback graph detection")
@@ -259,6 +269,15 @@ class StreamingGraphCache:
259
269
  with self._lock:
260
270
  self._cache_callbacks.append(callback)
261
271
 
272
+ async def _notify_cache_callbacks(self, graph_name: str, success: bool) -> None:
273
+ for callback in self._cache_callbacks:
274
+ try:
275
+ result = callback(graph_name, success)
276
+ if inspect.isawaitable(result):
277
+ await result
278
+ except Exception as e:
279
+ logger.warning(f"Cache callback failed for {graph_name}: {e}")
280
+
262
281
 
263
282
  async def cache_detected_graphs_with_pystata(self) -> List[str]:
264
283
  """Enhanced caching method that uses pystata for real-time graph detection."""
@@ -304,20 +323,12 @@ class StreamingGraphCache:
304
323
  self._cached_graphs.add(graph_name)
305
324
 
306
325
  # Notify callbacks
307
- for callback in self._cache_callbacks:
308
- try:
309
- callback(graph_name, success)
310
- except Exception as e:
311
- logger.warning(f"Cache callback failed for {graph_name}: {e}")
326
+ await self._notify_cache_callbacks(graph_name, success)
312
327
 
313
328
  except Exception as e:
314
329
  logger.warning(f"Failed to cache graph {graph_name}: {e}")
315
330
  # Still notify callbacks of failure
316
- for callback in self._cache_callbacks:
317
- try:
318
- callback(graph_name, False)
319
- except Exception:
320
- pass
331
+ await self._notify_cache_callbacks(graph_name, False)
321
332
 
322
333
  return cached_names
323
334
 
@@ -349,20 +360,12 @@ class StreamingGraphCache:
349
360
  self._cached_graphs.add(graph_name)
350
361
 
351
362
  # Notify callbacks
352
- for callback in self._cache_callbacks:
353
- try:
354
- callback(graph_name, success)
355
- except Exception as e:
356
- logger.warning(f"Cache callback failed for {graph_name}: {e}")
363
+ await self._notify_cache_callbacks(graph_name, success)
357
364
 
358
365
  except Exception as e:
359
366
  logger.warning(f"Failed to cache graph {graph_name}: {e}")
360
367
  # Still notify callbacks of failure
361
- for callback in self._cache_callbacks:
362
- try:
363
- callback(graph_name, False)
364
- except Exception:
365
- pass
368
+ await self._notify_cache_callbacks(graph_name, False)
366
369
 
367
370
  return cached_names
368
371
 
mcp_stata/models.py CHANGED
@@ -8,10 +8,12 @@ class ErrorEnvelope(BaseModel):
8
8
  line: Optional[int] = None
9
9
  command: Optional[str] = None
10
10
  log_path: Optional[str] = None
11
+ context: Optional[str] = None
11
12
  stdout: Optional[str] = None
12
13
  stderr: Optional[str] = None
13
14
  snippet: Optional[str] = None
14
15
  trace: Optional[bool] = None
16
+ smcl_output: Optional[str] = None
15
17
 
16
18
 
17
19
  class CommandResponse(BaseModel):
@@ -22,6 +24,7 @@ class CommandResponse(BaseModel):
22
24
  log_path: Optional[str] = None
23
25
  success: bool
24
26
  error: Optional[ErrorEnvelope] = None
27
+ smcl_output: Optional[str] = None
25
28
 
26
29
 
27
30
  class DataResponse(BaseModel):