mcp-stata 1.6.2__py3-none-any.whl → 1.7.3__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/discovery.py CHANGED
@@ -1,30 +1,143 @@
1
+ """
2
+ Improved discovery.py with better error handling for intermittent failures.
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
8
+ """
9
+
1
10
  import os
11
+ import sys
2
12
  import platform
3
13
  import glob
4
14
  import logging
5
15
  import shutil
6
-
7
- from typing import Tuple, List
16
+ import ntpath
17
+ import time
18
+ from typing import Tuple, List, Optional
8
19
 
9
20
  logger = logging.getLogger("mcp_stata.discovery")
10
21
 
11
22
 
12
- def _normalize_env_path(raw: str) -> str:
13
- """Strip quotes/whitespace and expand variables for STATA_PATH."""
23
+ def _exists_with_retry(path: str, max_attempts: int = 3, delay: float = 0.1) -> bool:
24
+ """
25
+ Check if file exists with retry logic to handle transient failures.
26
+ This helps with antivirus scans, file locks, and other temporary issues.
27
+ """
28
+ for attempt in range(max_attempts):
29
+ if os.path.exists(path):
30
+ return True
31
+ if attempt < max_attempts - 1:
32
+ logger.debug(
33
+ f"File existence check attempt {attempt + 1} failed for: {path}"
34
+ )
35
+ time.sleep(delay)
36
+ return False
37
+
38
+
39
+ def _find_similar_stata_dirs(target_path: str) -> List[str]:
40
+ """
41
+ Find similar Stata directories to help diagnose path typos.
42
+ Useful when user has 'Stata19Now' instead of 'StataNow19'.
43
+ """
44
+ parent = os.path.dirname(target_path)
45
+
46
+ # If parent doesn't exist, try grandparent (for directory name typos)
47
+ search_dir = parent
48
+ if not os.path.exists(parent):
49
+ search_dir = os.path.dirname(parent)
50
+
51
+ if not os.path.exists(search_dir):
52
+ return []
53
+
54
+ try:
55
+ subdirs = [
56
+ d for d in os.listdir(search_dir)
57
+ if os.path.isdir(os.path.join(search_dir, d))
58
+ ]
59
+ # Filter to Stata-related directories (case-insensitive)
60
+ stata_dirs = [
61
+ os.path.join(search_dir, d)
62
+ for d in subdirs
63
+ if 'stata' in d.lower()
64
+ ]
65
+ return stata_dirs
66
+ except (OSError, PermissionError) as e:
67
+ logger.debug(f"Could not list directory {search_dir}: {e}")
68
+ return []
69
+
70
+
71
+ def _validate_path_with_diagnostics(path: str, system: str) -> Tuple[bool, str]:
72
+ """
73
+ Validate path exists and provide detailed diagnostics if not.
74
+ Returns (exists, diagnostic_message)
75
+ """
76
+ if _exists_with_retry(path):
77
+ return True, ""
78
+
79
+ # Build diagnostic message
80
+ diagnostics = []
81
+ diagnostics.append(f"File not found: '{path}'")
82
+
83
+ parent_dir = os.path.dirname(path)
84
+ filename = os.path.basename(path)
85
+
86
+ if _exists_with_retry(parent_dir):
87
+ diagnostics.append(f"✓ Parent directory exists: '{parent_dir}'")
88
+ try:
89
+ files_in_parent = os.listdir(parent_dir)
90
+ # Look for similar filenames
91
+ similar_files = [
92
+ f for f in files_in_parent
93
+ if 'stata' in f.lower() and f.lower().endswith('.exe' if system == 'Windows' else '')
94
+ ]
95
+ if similar_files:
96
+ diagnostics.append(f"Found {len(similar_files)} Stata file(s) in parent:")
97
+ for f in similar_files[:5]: # Show max 5
98
+ diagnostics.append(f" - {f}")
99
+ else:
100
+ diagnostics.append(f"No Stata executables found in parent directory")
101
+ diagnostics.append(f"Files present: {', '.join(files_in_parent[:10])}")
102
+ except (OSError, PermissionError) as e:
103
+ diagnostics.append(f"✗ Could not list parent directory: {e}")
104
+ else:
105
+ diagnostics.append(f"✗ Parent directory does not exist: '{parent_dir}'")
106
+
107
+ # Check for similar directories (typo detection)
108
+ similar_dirs = _find_similar_stata_dirs(path)
109
+ if similar_dirs:
110
+ diagnostics.append("\nDid you mean one of these directories?")
111
+ for dir_path in similar_dirs[:5]:
112
+ diagnostics.append(f" - {dir_path}")
113
+
114
+ return False, "\n".join(diagnostics)
115
+
116
+
117
+ def _normalize_env_path(raw: str, system: str) -> str:
118
+ """Strip quotes/whitespace, expand variables, and normalize slashes for STATA_PATH."""
14
119
  cleaned = raw.strip()
15
- if (cleaned.startswith("\"") and cleaned.endswith("\"")) or (
120
+ if (cleaned.startswith('"') and cleaned.endswith('"')) or (
16
121
  cleaned.startswith("'") and cleaned.endswith("'")
17
122
  ):
18
123
  cleaned = cleaned[1:-1].strip()
19
- return os.path.expandvars(os.path.expanduser(cleaned))
124
+
125
+ expanded = os.path.expandvars(os.path.expanduser(cleaned))
126
+
127
+ # Always normalize path separators for the intended platform. This is especially
128
+ # important when running Windows discovery tests on non-Windows hosts where
129
+ # os.path (PosixPath) would otherwise leave backslashes untouched.
130
+ if system == "Windows":
131
+ return ntpath.normpath(expanded)
132
+ return os.path.normpath(expanded)
20
133
 
21
134
 
22
135
  def _is_executable(path: str, system: str) -> bool:
23
- if not os.path.exists(path):
136
+ if not _exists_with_retry(path): # Use retry logic
24
137
  return False
25
138
  if system == "Windows":
26
139
  # On Windows, check if it's a file and has .exe extension
27
- return os.path.isfile(path) and path.lower().endswith('.exe')
140
+ return os.path.isfile(path) and path.lower().endswith(".exe")
28
141
  return os.access(path, os.X_OK)
29
142
 
30
143
 
@@ -39,12 +152,60 @@ def _dedupe_preserve(items: List[tuple]) -> List[tuple]:
39
152
  return unique
40
153
 
41
154
 
155
+ def _dedupe_str_preserve(items: List[str]) -> List[str]:
156
+ seen = set()
157
+ out: List[str] = []
158
+ for s in items:
159
+ if not s:
160
+ continue
161
+ if s in seen:
162
+ continue
163
+ seen.add(s)
164
+ out.append(s)
165
+ return out
166
+
167
+
168
+ def _resolve_windows_host_path(path: str, system: str) -> str:
169
+ """
170
+ On non-Windows hosts running Windows-discovery code, a Windows-style path
171
+ (with backslashes) won't match the real filesystem layout. If the normalized
172
+ path does not exist and we're emulating Windows, try swapping backslashes for
173
+ the host separator so tests can interact with the temp filesystem.
174
+ """
175
+ if system != "Windows":
176
+ return path
177
+ if _exists_with_retry(path): # Use retry logic
178
+ return path
179
+ if os.sep != "\\" and "\\" in path:
180
+ alt_path = path.replace("\\", os.sep)
181
+ if _exists_with_retry(alt_path): # Use retry logic
182
+ return alt_path
183
+ return path
184
+
185
+
186
+ def _detect_system() -> str:
187
+ """
188
+ Prefer Windows detection via os.name / sys.platform instead of platform.system()
189
+ because some environments (e.g., Cygwin/MSYS) do not return "Windows".
190
+ """
191
+ if os.name == "nt" or sys.platform.startswith(("cygwin", "msys")):
192
+ return "Windows"
193
+ return platform.system()
194
+
195
+
42
196
  def find_stata_path() -> Tuple[str, str]:
43
197
  """
44
198
  Attempts to automatically locate the Stata installation path.
45
199
  Returns (path_to_executable, edition_string).
200
+
201
+ Behavior:
202
+ - If STATA_PATH is set and valid, use it.
203
+ - If STATA_PATH is set but invalid, provide detailed diagnostics and fall back.
204
+ - If auto-discovery fails, raise an error with helpful suggestions.
46
205
  """
47
- system = platform.system()
206
+ system = _detect_system()
207
+ stata_path_error: Optional[Exception] = None
208
+ stata_path_diagnostics: Optional[str] = None
48
209
 
49
210
  windows_binaries = [
50
211
  ("StataMP-64.exe", "mp"),
@@ -67,63 +228,90 @@ def find_stata_path() -> Tuple[str, str]:
67
228
  ]
68
229
 
69
230
  # 1. Check Environment Variable (supports quoted values and directory targets)
70
- if os.environ.get("STATA_PATH"):
71
- raw_path = os.environ["STATA_PATH"]
72
- path = _normalize_env_path(raw_path)
73
- logger.info("Using STATA_PATH override (normalized): %s", path)
74
-
75
- # If a directory is provided, try standard binaries for the platform
76
- if os.path.isdir(path):
77
- search_set = []
78
- if system == "Windows":
79
- search_set = windows_binaries
80
- elif system == "Linux":
81
- search_set = linux_binaries
82
- elif system == "Darwin":
83
- search_set = [
84
- ("Contents/MacOS/stata-mp", "mp"),
85
- ("Contents/MacOS/stata-se", "se"),
86
- ("Contents/MacOS/stata", "be"),
87
- ("stata-mp", "mp"),
88
- ("stata-se", "se"),
89
- ("stata", "be"),
90
- ]
91
-
92
- for binary, edition in search_set:
93
- candidate = os.path.join(path, binary)
94
- if _is_executable(candidate, system):
95
- logger.info("Found Stata via STATA_PATH directory: %s (%s)", candidate, edition)
96
- return candidate, edition
231
+ raw_env_path = os.environ.get("STATA_PATH")
232
+ if raw_env_path:
233
+ try:
234
+ path = _normalize_env_path(raw_env_path, system)
235
+ path = _resolve_windows_host_path(path, system)
236
+ logger.info("Trying STATA_PATH override (normalized): %s", path)
237
+
238
+ # If a directory is provided, try standard binaries for the platform
239
+ if os.path.isdir(path):
240
+ search_set = []
241
+ 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
+ ]
97
254
 
98
- raise FileNotFoundError(
99
- f"STATA_PATH points to directory '{path}', but no Stata executable was found within. "
100
- "Point STATA_PATH directly to the Stata binary (e.g., C:\\Program Files\\Stata18\\StataMP-64.exe)."
101
- )
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
264
+
265
+ # Enhanced error with diagnostics
266
+ exists, diagnostics = _validate_path_with_diagnostics(path, system)
267
+ error_msg = (
268
+ f"STATA_PATH points to directory '{path}', but no Stata executable was found within.\n"
269
+ f"{diagnostics}\n\n"
270
+ "Point STATA_PATH directly to the Stata binary "
271
+ "(e.g., C:\\Program Files\\StataNow19\\StataMP-64.exe)."
272
+ )
273
+ raise FileNotFoundError(error_msg)
102
274
 
103
- edition = "be"
104
- lower_path = path.lower()
105
- if "mp" in lower_path:
106
- edition = "mp"
107
- elif "se" in lower_path:
108
- edition = "se"
109
- elif "be" in lower_path:
110
275
  edition = "be"
111
- if not os.path.exists(path):
112
- raise FileNotFoundError(
113
- f"STATA_PATH points to '{path}', but that file does not exist. "
114
- "Update STATA_PATH to your Stata binary (e.g., "
115
- "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp or /usr/local/stata18/stata-mp)."
116
- )
117
- if not _is_executable(path, system):
118
- raise PermissionError(
119
- f"STATA_PATH points to '{path}', but it is not executable. "
120
- "Ensure this is the Stata binary, not the .app directory."
276
+ lower_path = path.lower()
277
+ if "mp" in lower_path:
278
+ edition = "mp"
279
+ elif "se" in lower_path:
280
+ edition = "se"
281
+ elif "be" in lower_path:
282
+ edition = "be"
283
+
284
+ # Use enhanced validation with diagnostics
285
+ exists, diagnostics = _validate_path_with_diagnostics(path, system)
286
+ if not exists:
287
+ error_msg = (
288
+ f"STATA_PATH points to '{path}', but that file does not exist.\n"
289
+ f"{diagnostics}\n\n"
290
+ "Update STATA_PATH to your Stata binary (e.g., "
291
+ "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, "
292
+ "/usr/local/stata19/stata-mp or C:\\Program Files\\StataNow19\\StataMP-64.exe)."
293
+ )
294
+ raise FileNotFoundError(error_msg)
295
+
296
+ if not _is_executable(path, system):
297
+ raise PermissionError(
298
+ f"STATA_PATH points to '{path}', but it is not executable. "
299
+ "Ensure this is the Stata binary, not the .app directory."
300
+ )
301
+
302
+ logger.info("Using STATA_PATH override: %s (%s)", path, edition)
303
+ return path, edition
304
+
305
+ except Exception as exc:
306
+ stata_path_error = exc
307
+ stata_path_diagnostics = str(exc)
308
+ logger.warning(
309
+ "STATA_PATH override failed (%s). Falling back to auto-discovery.",
310
+ exc,
121
311
  )
122
- logger.info("Using STATA_PATH override: %s (%s)", path, edition)
123
- return path, edition
124
312
 
125
313
  # 2. Platform-specific search
126
- candidates = [] # List of (path, edition)
314
+ candidates: List[Tuple[str, str]] = [] # List of (path, edition)
127
315
 
128
316
  if system == "Darwin": # macOS
129
317
  app_globs = [
@@ -139,25 +327,60 @@ def find_stata_path() -> Tuple[str, str]:
139
327
  for pattern in app_globs:
140
328
  for app_dir in glob.glob(pattern):
141
329
  binary_dir = os.path.join(app_dir, "Contents", "MacOS")
142
- if not os.path.exists(binary_dir):
330
+ if not _exists_with_retry(binary_dir): # Use retry logic
143
331
  continue
144
332
  for binary, edition in [("stata-mp", "mp"), ("stata-se", "se"), ("stata", "be")]:
145
333
  full_path = os.path.join(binary_dir, binary)
146
- if os.path.exists(full_path):
334
+ if _exists_with_retry(full_path): # Use retry logic
147
335
  candidates.append((full_path, edition))
148
336
 
149
337
  elif system == "Windows":
338
+ # Include ProgramW6432 (real 64-bit Program Files) and hardcode fallbacks.
339
+ base_dirs = _dedupe_str_preserve(
340
+ [
341
+ os.environ.get("ProgramW6432", r"C:\Program Files"),
342
+ os.environ.get("ProgramFiles", r"C:\Program Files"),
343
+ os.environ.get("ProgramFiles(Arm)", r"C:\Program Files (Arm)"),
344
+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
345
+ r"C:\Program Files",
346
+ r"C:\Program Files (Arm)",
347
+ r"C:\Program Files (x86)",
348
+ ]
349
+ )
350
+
351
+ # Resolve for non-Windows hosts running Windows discovery tests.
150
352
  base_dirs = [
151
- os.environ.get("ProgramFiles", "C:\\Program Files"),
152
- os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
353
+ _resolve_windows_host_path(ntpath.normpath(bd), system) for bd in base_dirs
153
354
  ]
355
+ base_dirs = _dedupe_str_preserve(base_dirs)
154
356
 
357
+ # Look in a few plausible layouts:
358
+ # base\Stata*\...
359
+ # base\*\Stata*\... (e.g., base\StataCorp\Stata19Now)
360
+ # base\Stata*\*\... (e.g., base\Stata\Stata19Now)
361
+ dir_globs: List[str] = []
155
362
  for base_dir in base_dirs:
156
- for stata_dir in glob.glob(os.path.join(base_dir, "Stata*")):
157
- for exe, edition in windows_binaries:
158
- full_path = os.path.join(stata_dir, exe)
159
- if os.path.exists(full_path):
160
- candidates.append((full_path, edition))
363
+ dir_globs.extend(
364
+ [
365
+ os.path.join(base_dir, "Stata*"),
366
+ os.path.join(base_dir, "*", "Stata*"),
367
+ os.path.join(base_dir, "Stata*", "Stata*"),
368
+ ]
369
+ )
370
+ dir_globs = _dedupe_str_preserve(dir_globs)
371
+
372
+ stata_dirs: List[str] = []
373
+ for pattern in dir_globs:
374
+ stata_dirs.extend(glob.glob(pattern))
375
+ stata_dirs = _dedupe_str_preserve(stata_dirs)
376
+
377
+ for stata_dir in stata_dirs:
378
+ if not os.path.isdir(stata_dir):
379
+ continue
380
+ for exe, edition in windows_binaries:
381
+ full_path = os.path.join(stata_dir, exe)
382
+ if _exists_with_retry(full_path): # Use retry logic
383
+ candidates.append((full_path, edition))
161
384
 
162
385
  elif system == "Linux":
163
386
  home_base = os.environ.get("HOME") or os.path.expanduser("~")
@@ -194,14 +417,13 @@ def find_stata_path() -> Tuple[str, str]:
194
417
  continue
195
418
  for binary, edition in linux_binaries:
196
419
  full_path = os.path.join(base_dir, binary)
197
- if os.path.exists(full_path):
420
+ if _exists_with_retry(full_path): # Use retry logic
198
421
  candidates.append((full_path, edition))
199
422
 
200
-
201
423
  candidates = _dedupe_preserve(candidates)
202
424
 
203
425
  for path, edition in candidates:
204
- if not os.path.exists(path):
426
+ if not _exists_with_retry(path): # Use retry logic
205
427
  logger.warning("Discovered candidate missing on disk: %s", path)
206
428
  continue
207
429
  if not _is_executable(path, system):
@@ -210,11 +432,27 @@ def find_stata_path() -> Tuple[str, str]:
210
432
  logger.info("Auto-discovered Stata at %s (%s)", path, edition)
211
433
  return path, edition
212
434
 
213
- raise FileNotFoundError(
214
- "Could not automatically locate Stata. "
215
- "Set STATA_PATH to your Stata executable (e.g., "
216
- "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp, or C:\\Program Files\\Stata18\\StataMP-64.exe)."
435
+ # Build comprehensive error message
436
+ error_parts = ["Could not automatically locate Stata."]
437
+
438
+ if stata_path_error is not None:
439
+ error_parts.append(
440
+ f"\nSTATA_PATH was set but failed:\n{stata_path_diagnostics}"
441
+ )
442
+
443
+ error_parts.append(
444
+ "\nTo fix this issue:\n"
445
+ "1. Set STATA_PATH to point to your Stata executable, for example:\n"
446
+ " - Windows: C:\\Program Files\\StataNow19\\StataMP-64.exe\n"
447
+ " - macOS: /Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp\n"
448
+ " - Linux: /usr/local/stata19/stata-mp\n"
449
+ "\n2. Or install Stata in a standard location where it can be auto-discovered."
217
450
  )
451
+
452
+ if stata_path_error is not None:
453
+ raise FileNotFoundError("\n".join(error_parts)) from stata_path_error
454
+ else:
455
+ raise FileNotFoundError("\n".join(error_parts))
218
456
 
219
457
 
220
458
  def main() -> int:
@@ -230,4 +468,4 @@ def main() -> int:
230
468
 
231
469
 
232
470
  if __name__ == "__main__": # pragma: no cover - manual utility
233
- raise SystemExit(main())
471
+ raise SystemExit(main())
mcp_stata/server.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import anyio
2
+ from importlib.metadata import PackageNotFoundError, version
2
3
  from mcp.server.fastmcp import Context, FastMCP
3
4
  import mcp.types as types
4
5
  from .stata_client import StataClient
@@ -17,6 +18,12 @@ from .ui_http import UIChannelManager
17
18
 
18
19
  LOG_LEVEL = os.getenv("MCP_STATA_LOGLEVEL", "INFO").upper()
19
20
  logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s - %(message)s")
21
+ try:
22
+ _mcp_stata_version = version("mcp-stata")
23
+ except PackageNotFoundError:
24
+ _mcp_stata_version = "unknown"
25
+ logging.info("mcp-stata version: %s", _mcp_stata_version)
26
+ logging.info("STATA_PATH env at startup: %s", os.getenv("STATA_PATH", "<not set>"))
20
27
 
21
28
  # Initialize FastMCP
22
29
  mcp = FastMCP("mcp_stata")