mcp-stata 1.6.2__py3-none-any.whl → 1.6.8__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,22 +1,32 @@
1
1
  import os
2
+ import sys
2
3
  import platform
3
4
  import glob
4
5
  import logging
5
6
  import shutil
7
+ import ntpath
6
8
 
7
- from typing import Tuple, List
9
+ from typing import Tuple, List, Optional
8
10
 
9
11
  logger = logging.getLogger("mcp_stata.discovery")
10
12
 
11
13
 
12
- def _normalize_env_path(raw: str) -> str:
13
- """Strip quotes/whitespace and expand variables for STATA_PATH."""
14
+ def _normalize_env_path(raw: str, system: str) -> str:
15
+ """Strip quotes/whitespace, expand variables, and normalize slashes for STATA_PATH."""
14
16
  cleaned = raw.strip()
15
- if (cleaned.startswith("\"") and cleaned.endswith("\"")) or (
17
+ if (cleaned.startswith('"') and cleaned.endswith('"')) or (
16
18
  cleaned.startswith("'") and cleaned.endswith("'")
17
19
  ):
18
20
  cleaned = cleaned[1:-1].strip()
19
- return os.path.expandvars(os.path.expanduser(cleaned))
21
+
22
+ expanded = os.path.expandvars(os.path.expanduser(cleaned))
23
+
24
+ # Always normalize path separators for the intended platform. This is especially
25
+ # important when running Windows discovery tests on non-Windows hosts where
26
+ # os.path (PosixPath) would otherwise leave backslashes untouched.
27
+ if system == "Windows":
28
+ return ntpath.normpath(expanded)
29
+ return os.path.normpath(expanded)
20
30
 
21
31
 
22
32
  def _is_executable(path: str, system: str) -> bool:
@@ -24,7 +34,7 @@ def _is_executable(path: str, system: str) -> bool:
24
34
  return False
25
35
  if system == "Windows":
26
36
  # On Windows, check if it's a file and has .exe extension
27
- return os.path.isfile(path) and path.lower().endswith('.exe')
37
+ return os.path.isfile(path) and path.lower().endswith(".exe")
28
38
  return os.access(path, os.X_OK)
29
39
 
30
40
 
@@ -39,12 +49,59 @@ def _dedupe_preserve(items: List[tuple]) -> List[tuple]:
39
49
  return unique
40
50
 
41
51
 
52
+ def _dedupe_str_preserve(items: List[str]) -> List[str]:
53
+ seen = set()
54
+ out: List[str] = []
55
+ for s in items:
56
+ if not s:
57
+ continue
58
+ if s in seen:
59
+ continue
60
+ seen.add(s)
61
+ out.append(s)
62
+ return out
63
+
64
+
65
+ def _resolve_windows_host_path(path: str, system: str) -> str:
66
+ """
67
+ On non-Windows hosts running Windows-discovery code, a Windows-style path
68
+ (with backslashes) won't match the real filesystem layout. If the normalized
69
+ path does not exist and we're emulating Windows, try swapping backslashes for
70
+ the host separator so tests can interact with the temp filesystem.
71
+ """
72
+ if system != "Windows":
73
+ return path
74
+ if os.path.exists(path):
75
+ return path
76
+ if os.sep != "\\" and "\\" in path:
77
+ alt_path = path.replace("\\", os.sep)
78
+ if os.path.exists(alt_path):
79
+ return alt_path
80
+ return path
81
+
82
+
83
+ def _detect_system() -> str:
84
+ """
85
+ Prefer Windows detection via os.name / sys.platform instead of platform.system()
86
+ because some environments (e.g., Cygwin/MSYS) do not return "Windows".
87
+ """
88
+ if os.name == "nt" or sys.platform.startswith(("cygwin", "msys")):
89
+ return "Windows"
90
+ return platform.system()
91
+
92
+
42
93
  def find_stata_path() -> Tuple[str, str]:
43
94
  """
44
95
  Attempts to automatically locate the Stata installation path.
45
96
  Returns (path_to_executable, edition_string).
97
+
98
+ Behavior:
99
+ - If STATA_PATH is set and valid, use it.
100
+ - If STATA_PATH is set but invalid, fall back to auto-discovery.
101
+ - If auto-discovery fails, raise an error (including STATA_PATH failure context, if any).
46
102
  """
47
- system = platform.system()
103
+ system = _detect_system()
104
+ stata_path_error: Optional[Exception] = None
48
105
 
49
106
  windows_binaries = [
50
107
  ("StataMP-64.exe", "mp"),
@@ -67,63 +124,80 @@ def find_stata_path() -> Tuple[str, str]:
67
124
  ]
68
125
 
69
126
  # 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
127
+ raw_env_path = os.environ.get("STATA_PATH")
128
+ if raw_env_path:
129
+ try:
130
+ path = _normalize_env_path(raw_env_path, system)
131
+ path = _resolve_windows_host_path(path, system)
132
+ logger.info("Trying STATA_PATH override (normalized): %s", path)
133
+
134
+ # If a directory is provided, try standard binaries for the platform
135
+ if os.path.isdir(path):
136
+ search_set = []
137
+ if system == "Windows":
138
+ search_set = windows_binaries
139
+ elif system == "Linux":
140
+ search_set = linux_binaries
141
+ elif system == "Darwin":
142
+ search_set = [
143
+ ("Contents/MacOS/stata-mp", "mp"),
144
+ ("Contents/MacOS/stata-se", "se"),
145
+ ("Contents/MacOS/stata", "be"),
146
+ ("stata-mp", "mp"),
147
+ ("stata-se", "se"),
148
+ ("stata", "be"),
149
+ ]
97
150
 
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
- )
151
+ for binary, edition in search_set:
152
+ candidate = os.path.join(path, binary)
153
+ if _is_executable(candidate, system):
154
+ logger.info(
155
+ "Found Stata via STATA_PATH directory: %s (%s)",
156
+ candidate,
157
+ edition,
158
+ )
159
+ return candidate, edition
160
+
161
+ raise FileNotFoundError(
162
+ f"STATA_PATH points to directory '{path}', but no Stata executable was found within. "
163
+ "Point STATA_PATH directly to the Stata binary "
164
+ "(e.g., C:\\Program Files\\Stata19\\StataMP-64.exe)."
165
+ )
102
166
 
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
167
  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."
168
+ lower_path = path.lower()
169
+ if "mp" in lower_path:
170
+ edition = "mp"
171
+ elif "se" in lower_path:
172
+ edition = "se"
173
+ elif "be" in lower_path:
174
+ edition = "be"
175
+
176
+ if not os.path.exists(path):
177
+ raise FileNotFoundError(
178
+ f"STATA_PATH points to '{path}', but that file does not exist. "
179
+ "Update STATA_PATH to your Stata binary (e.g., "
180
+ "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, "
181
+ "/usr/local/stata19/stata-mp or C:\\Program Files\\Stata19Now\\StataSE-64.exe)."
182
+ )
183
+ if not _is_executable(path, system):
184
+ raise PermissionError(
185
+ f"STATA_PATH points to '{path}', but it is not executable. "
186
+ "Ensure this is the Stata binary, not the .app directory."
187
+ )
188
+
189
+ logger.info("Using STATA_PATH override: %s (%s)", path, edition)
190
+ return path, edition
191
+
192
+ except Exception as exc:
193
+ stata_path_error = exc
194
+ logger.warning(
195
+ "STATA_PATH override failed (%s). Falling back to auto-discovery.",
196
+ exc,
121
197
  )
122
- logger.info("Using STATA_PATH override: %s (%s)", path, edition)
123
- return path, edition
124
198
 
125
199
  # 2. Platform-specific search
126
- candidates = [] # List of (path, edition)
200
+ candidates: List[Tuple[str, str]] = [] # List of (path, edition)
127
201
 
128
202
  if system == "Darwin": # macOS
129
203
  app_globs = [
@@ -147,17 +221,52 @@ def find_stata_path() -> Tuple[str, str]:
147
221
  candidates.append((full_path, edition))
148
222
 
149
223
  elif system == "Windows":
224
+ # Include ProgramW6432 (real 64-bit Program Files) and hardcode fallbacks.
225
+ base_dirs = _dedupe_str_preserve(
226
+ [
227
+ os.environ.get("ProgramW6432", r"C:\Program Files"),
228
+ os.environ.get("ProgramFiles", r"C:\Program Files"),
229
+ os.environ.get("ProgramFiles(Arm)", r"C:\Program Files (Arm)"),
230
+ os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
231
+ r"C:\Program Files",
232
+ r"C:\Program Files (Arm)",
233
+ r"C:\Program Files (x86)",
234
+ ]
235
+ )
236
+
237
+ # Resolve for non-Windows hosts running Windows discovery tests.
150
238
  base_dirs = [
151
- os.environ.get("ProgramFiles", "C:\\Program Files"),
152
- os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
239
+ _resolve_windows_host_path(ntpath.normpath(bd), system) for bd in base_dirs
153
240
  ]
241
+ base_dirs = _dedupe_str_preserve(base_dirs)
154
242
 
243
+ # Look in a few plausible layouts:
244
+ # base\Stata*\...
245
+ # base\*\Stata*\... (e.g., base\StataCorp\Stata19Now)
246
+ # base\Stata*\*\... (e.g., base\Stata\Stata19Now)
247
+ dir_globs: List[str] = []
155
248
  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))
249
+ dir_globs.extend(
250
+ [
251
+ os.path.join(base_dir, "Stata*"),
252
+ os.path.join(base_dir, "*", "Stata*"),
253
+ os.path.join(base_dir, "Stata*", "Stata*"),
254
+ ]
255
+ )
256
+ dir_globs = _dedupe_str_preserve(dir_globs)
257
+
258
+ stata_dirs: List[str] = []
259
+ for pattern in dir_globs:
260
+ stata_dirs.extend(glob.glob(pattern))
261
+ stata_dirs = _dedupe_str_preserve(stata_dirs)
262
+
263
+ for stata_dir in stata_dirs:
264
+ if not os.path.isdir(stata_dir):
265
+ continue
266
+ for exe, edition in windows_binaries:
267
+ full_path = os.path.join(stata_dir, exe)
268
+ if os.path.exists(full_path):
269
+ candidates.append((full_path, edition))
161
270
 
162
271
  elif system == "Linux":
163
272
  home_base = os.environ.get("HOME") or os.path.expanduser("~")
@@ -197,7 +306,6 @@ def find_stata_path() -> Tuple[str, str]:
197
306
  if os.path.exists(full_path):
198
307
  candidates.append((full_path, edition))
199
308
 
200
-
201
309
  candidates = _dedupe_preserve(candidates)
202
310
 
203
311
  for path, edition in candidates:
@@ -210,10 +318,20 @@ def find_stata_path() -> Tuple[str, str]:
210
318
  logger.info("Auto-discovered Stata at %s (%s)", path, edition)
211
319
  return path, edition
212
320
 
321
+ if stata_path_error is not None:
322
+ raise FileNotFoundError(
323
+ "Could not automatically locate Stata after STATA_PATH failed. "
324
+ f"STATA_PATH error was: {stata_path_error}. "
325
+ "Fix STATA_PATH to point to the Stata executable, or install Stata in a standard location "
326
+ "(e.g., /Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp, "
327
+ "or C:\\Program Files\\Stata18\\StataMP-64.exe)."
328
+ ) from stata_path_error
329
+
213
330
  raise FileNotFoundError(
214
331
  "Could not automatically locate Stata. "
215
332
  "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)."
333
+ "/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp, /usr/local/stata18/stata-mp, "
334
+ "or C:\\Program Files\\Stata18\\StataMP-64.exe)."
217
335
  )
218
336
 
219
337
 
@@ -230,4 +348,4 @@ def main() -> int:
230
348
 
231
349
 
232
350
  if __name__ == "__main__": # pragma: no cover - manual utility
233
- raise SystemExit(main())
351
+ 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")
mcp_stata/stata_client.py CHANGED
@@ -6,13 +6,15 @@ import re
6
6
  import subprocess
7
7
  import sys
8
8
  import threading
9
+ from importlib.metadata import PackageNotFoundError, version
9
10
  import tempfile
10
11
  import time
11
12
  from contextlib import contextmanager
12
13
  from io import StringIO
13
- from typing import Any, Awaitable, Callable, Dict, List, Optional
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
14
15
 
15
16
  import anyio
17
+ from anyio import get_cancelled_exc_class
16
18
 
17
19
  from .discovery import find_stata_path
18
20
  from .models import (
@@ -32,6 +34,74 @@ from .graph_detector import StreamingGraphCache
32
34
  logger = logging.getLogger("mcp_stata")
33
35
 
34
36
 
37
+ # ============================================================================
38
+ # MODULE-LEVEL DISCOVERY CACHE
39
+ # ============================================================================
40
+ # This cache ensures Stata discovery runs exactly once per process lifetime
41
+ _discovery_lock = threading.Lock()
42
+ _discovery_result: Optional[Tuple[str, str]] = None # (path, edition)
43
+ _discovery_attempted = False
44
+ _discovery_error: Optional[Exception] = None
45
+
46
+
47
+ def _get_discovered_stata() -> Tuple[str, str]:
48
+ """
49
+ Get the discovered Stata path and edition, running discovery only once.
50
+
51
+ Returns:
52
+ Tuple of (stata_executable_path, edition)
53
+
54
+ Raises:
55
+ RuntimeError: If Stata discovery fails
56
+ """
57
+ global _discovery_result, _discovery_attempted, _discovery_error
58
+
59
+ with _discovery_lock:
60
+ # If we've already successfully discovered Stata, return cached result
61
+ if _discovery_result is not None:
62
+ return _discovery_result
63
+
64
+ # If we've already attempted and failed, re-raise the cached error
65
+ if _discovery_attempted and _discovery_error is not None:
66
+ raise RuntimeError(f"Stata binary not found: {_discovery_error}") from _discovery_error
67
+
68
+ # This is the first attempt - run discovery
69
+ _discovery_attempted = True
70
+
71
+ try:
72
+ # Log environment state once at first discovery
73
+ env_path = os.getenv("STATA_PATH")
74
+ if env_path:
75
+ logger.info("STATA_PATH env provided (raw): %s", env_path)
76
+ else:
77
+ logger.info("STATA_PATH env not set; attempting auto-discovery")
78
+
79
+ try:
80
+ pkg_version = version("mcp-stata")
81
+ except PackageNotFoundError:
82
+ pkg_version = "unknown"
83
+ logger.info("mcp-stata version: %s", pkg_version)
84
+
85
+ # Run discovery
86
+ stata_exec_path, edition = find_stata_path()
87
+
88
+ # Cache the successful result
89
+ _discovery_result = (stata_exec_path, edition)
90
+ logger.info("Discovery found Stata at: %s (%s)", stata_exec_path, edition)
91
+
92
+ return _discovery_result
93
+
94
+ except FileNotFoundError as e:
95
+ _discovery_error = e
96
+ raise RuntimeError(f"Stata binary not found: {e}") from e
97
+ except PermissionError as e:
98
+ _discovery_error = e
99
+ raise RuntimeError(
100
+ f"Stata binary is not executable: {e}. "
101
+ "Point STATA_PATH directly to the Stata binary (e.g., .../Contents/MacOS/stata-mp)."
102
+ ) from e
103
+
104
+
35
105
  class StataClient:
36
106
  _initialized = False
37
107
  _exec_lock: threading.Lock
@@ -100,6 +170,62 @@ class StataClient:
100
170
  logger.error(f"Failed to notify about graph cache: {e}")
101
171
 
102
172
  return graph_cache_callback
173
+ def _request_break_in(self) -> None:
174
+ """
175
+ Attempt to interrupt a running Stata command when cancellation is requested.
176
+
177
+ Uses the Stata sfi.breakIn hook when available; errors are swallowed because
178
+ cancellation should never crash the host process.
179
+ """
180
+ try:
181
+ import sfi # type: ignore[import-not-found]
182
+
183
+ break_fn = getattr(sfi, "breakIn", None) or getattr(sfi, "break_in", None)
184
+ if callable(break_fn):
185
+ try:
186
+ break_fn()
187
+ logger.info("Sent breakIn() to Stata for cancellation")
188
+ except Exception as e: # pragma: no cover - best-effort
189
+ logger.warning(f"Failed to send breakIn() to Stata: {e}")
190
+ else: # pragma: no cover - environment without Stata runtime
191
+ logger.debug("sfi.breakIn not available; cannot interrupt Stata")
192
+ except Exception as e: # pragma: no cover - import failure or other
193
+ logger.debug(f"Unable to import sfi for cancellation: {e}")
194
+
195
+ async def _wait_for_stata_stop(self, timeout: float = 2.0) -> bool:
196
+ """
197
+ After requesting a break, poll the Stata interface so it can surface BreakError
198
+ and return control. This is best-effort and time-bounded.
199
+ """
200
+ deadline = time.monotonic() + timeout
201
+ try:
202
+ import sfi # type: ignore[import-not-found]
203
+
204
+ toolkit = getattr(sfi, "SFIToolkit", None)
205
+ poll = getattr(toolkit, "pollnow", None) or getattr(toolkit, "pollstd", None)
206
+ BreakError = getattr(sfi, "BreakError", None)
207
+ except Exception: # pragma: no cover
208
+ return False
209
+
210
+ if not callable(poll):
211
+ return False
212
+
213
+ last_exc: Optional[Exception] = None
214
+ while time.monotonic() < deadline:
215
+ try:
216
+ poll()
217
+ except Exception as e: # pragma: no cover - depends on Stata runtime
218
+ last_exc = e
219
+ if BreakError is not None and isinstance(e, BreakError):
220
+ logger.info("Stata BreakError detected; cancellation acknowledged by Stata")
221
+ return True
222
+ # If Stata already stopped, break on any other exception.
223
+ break
224
+ await anyio.sleep(0.05)
225
+
226
+ if last_exc:
227
+ logger.debug(f"Cancellation poll exited with {last_exc}")
228
+ return False
103
229
 
104
230
  @contextmanager
105
231
  def _temp_cwd(self, cwd: Optional[str]):
@@ -114,24 +240,15 @@ class StataClient:
114
240
  os.chdir(prev)
115
241
 
116
242
  def init(self):
117
- """Initializes usage of pystata."""
243
+ """Initializes usage of pystata using cached discovery results."""
118
244
  if self._initialized:
119
245
  return
120
246
 
121
247
  try:
122
248
  import stata_setup
123
-
124
- try:
125
- stata_exec_path, edition = find_stata_path()
126
- except FileNotFoundError as e:
127
- raise RuntimeError(f"Stata binary not found: {e}") from e
128
- except PermissionError as e:
129
- raise RuntimeError(
130
- f"Stata binary is not executable: {e}. "
131
- "Point STATA_PATH directly to the Stata binary (e.g., .../Contents/MacOS/stata-mp)."
132
- ) from e
133
249
 
134
- logger.info(f"Discovery found Stata at: {stata_exec_path} ({edition})")
250
+ # Get discovered Stata path (cached from first call)
251
+ stata_exec_path, edition = _get_discovered_stata()
135
252
 
136
253
  candidates = []
137
254
 
@@ -171,6 +288,7 @@ class StataClient:
171
288
  try:
172
289
  stata_setup.config(path, edition)
173
290
  success = True
291
+ logger.debug("stata_setup.config succeeded with path: %s", path)
174
292
  break
175
293
  except Exception:
176
294
  continue
@@ -187,14 +305,6 @@ class StataClient:
187
305
  from pystata import stata # type: ignore[import-not-found]
188
306
  self.stata = stata
189
307
  self._initialized = True
190
-
191
- # Ensure a clean graph state for a fresh client. PyStata's backend is
192
- # effectively global, so graph memory can otherwise leak across tests
193
- # and separate StataClient instances.
194
- try:
195
- self.stata.run("capture graph drop _all", quietly=True)
196
- except Exception:
197
- pass
198
308
 
199
309
  # Initialize list_graphs TTL cache
200
310
  self._list_graphs_cache = None
@@ -205,11 +315,14 @@ class StataClient:
205
315
  # internal Stata graph names.
206
316
  self._graph_name_aliases: Dict[str, str] = {}
207
317
  self._graph_name_reverse: Dict[str, str] = {}
318
+
319
+ logger.info("StataClient initialized successfully with %s (%s)", stata_exec_path, edition)
208
320
 
209
- except ImportError:
210
- # Fallback for when stata_setup isn't in PYTHONPATH yet?
211
- # Usually users must have it installed. We rely on discovery logic.
212
- raise RuntimeError("Could not import `stata_setup`. Ensure pystata is installed.")
321
+ except ImportError as e:
322
+ raise RuntimeError(
323
+ f"Failed to import stata_setup or pystata: {e}. "
324
+ "Ensure they are installed (pip install pystata stata-setup)."
325
+ ) from e
213
326
 
214
327
  def _make_valid_stata_name(self, name: str) -> str:
215
328
  """Create a valid Stata name (<=32 chars, [A-Za-z_][A-Za-z0-9_]*)."""
@@ -538,33 +651,42 @@ class StataClient:
538
651
  def _run_blocking() -> None:
539
652
  nonlocal rc, exc
540
653
  with self._exec_lock:
541
- with self._temp_cwd(cwd):
542
- with self._redirect_io_streaming(tee, tee):
543
- try:
544
- if trace:
545
- self.stata.run("set trace on")
546
- ret = self.stata.run(code, echo=echo)
547
- # Some PyStata builds return output as a string rather than printing.
548
- if isinstance(ret, str) and ret:
549
- try:
550
- tee.write(ret)
551
- except Exception:
552
- pass
553
- except Exception as e:
554
- exc = e
555
- finally:
556
- rc = self._read_return_code()
557
- if trace:
558
- try:
559
- self.stata.run("set trace off")
560
- except Exception:
561
- pass
654
+ self._is_executing = True
655
+ try:
656
+ with self._temp_cwd(cwd):
657
+ with self._redirect_io_streaming(tee, tee):
658
+ try:
659
+ if trace:
660
+ self.stata.run("set trace on")
661
+ ret = self.stata.run(code, echo=echo)
662
+ # Some PyStata builds return output as a string rather than printing.
663
+ if isinstance(ret, str) and ret:
664
+ try:
665
+ tee.write(ret)
666
+ except Exception:
667
+ pass
668
+ except Exception as e:
669
+ exc = e
670
+ finally:
671
+ rc = self._read_return_code()
672
+ if trace:
673
+ try:
674
+ self.stata.run("set trace off")
675
+ except Exception:
676
+ pass
677
+ finally:
678
+ self._is_executing = False
562
679
 
563
680
  try:
564
681
  if notify_progress is not None:
565
682
  await notify_progress(0, None, "Running Stata command")
566
683
 
567
- await anyio.to_thread.run_sync(_run_blocking)
684
+ await anyio.to_thread.run_sync(_run_blocking, abandon_on_cancel=True)
685
+ except get_cancelled_exc_class():
686
+ # Best-effort cancellation: signal Stata to break, wait briefly, then propagate.
687
+ self._request_break_in()
688
+ await self._wait_for_stata_stop()
689
+ raise
568
690
  finally:
569
691
  tee.close()
570
692
 
@@ -838,7 +960,11 @@ class StataClient:
838
960
  await notify_progress(0, None, "Running do-file")
839
961
 
840
962
  try:
841
- await anyio.to_thread.run_sync(_run_blocking)
963
+ await anyio.to_thread.run_sync(_run_blocking, abandon_on_cancel=True)
964
+ except get_cancelled_exc_class():
965
+ self._request_break_in()
966
+ await self._wait_for_stata_stop()
967
+ raise
842
968
  finally:
843
969
  done.set()
844
970
  tee.close()
mcp_stata/ui_http.py CHANGED
@@ -455,16 +455,35 @@ def handle_page_request(manager: UIChannelManager, body: dict[str, Any], *, view
455
455
  dataset_id = view.dataset_id
456
456
  frame = view.frame
457
457
 
458
- offset = int(body.get("offset", 0) or 0)
459
- limit = int(body.get("limit", 0) or 0)
458
+ # Parse offset (default 0 is valid since offset >= 0)
459
+ try:
460
+ offset = int(body.get("offset") or 0)
461
+ except (ValueError, TypeError) as e:
462
+ raise HTTPError(400, "invalid_request", f"offset must be a valid integer, got: {body.get('offset')!r}")
463
+
464
+ # Parse limit (no default - must be explicitly provided)
465
+ limit_raw = body.get("limit")
466
+ if limit_raw is None:
467
+ raise HTTPError(400, "invalid_request", "limit is required")
468
+ try:
469
+ limit = int(limit_raw)
470
+ except (ValueError, TypeError) as e:
471
+ raise HTTPError(400, "invalid_request", f"limit must be a valid integer, got: {limit_raw!r}")
472
+
460
473
  vars_req = body.get("vars", [])
461
474
  include_obs_no = bool(body.get("includeObsNo", False))
462
- max_chars_req = int(body.get("maxChars", max_chars) or max_chars)
475
+
476
+ # Parse maxChars
477
+ max_chars_raw = body.get("maxChars", max_chars)
478
+ try:
479
+ max_chars_req = int(max_chars_raw or max_chars)
480
+ except (ValueError, TypeError) as e:
481
+ raise HTTPError(400, "invalid_request", f"maxChars must be a valid integer, got: {max_chars_raw!r}")
463
482
 
464
483
  if offset < 0:
465
- raise HTTPError(400, "invalid_request", "offset must be >= 0")
484
+ raise HTTPError(400, "invalid_request", f"offset must be >= 0, got: {offset}")
466
485
  if limit <= 0:
467
- raise HTTPError(400, "invalid_request", "limit must be > 0")
486
+ raise HTTPError(400, "invalid_request", f"limit must be > 0, got: {limit}")
468
487
  if limit > max_limit:
469
488
  raise HTTPError(400, "request_too_large", f"limit must be <= {max_limit}")
470
489
  if max_chars_req <= 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-stata
3
- Version: 1.6.2
3
+ Version: 1.6.8
4
4
  Summary: A lightweight Model Context Protocol (MCP) server for Stata. Execute commands, inspect data, retrieve stored results (`r()`/`e()`), and view graphs in your chat interface. Built for economists who want to integrate LLM assistance into their Stata workflow.
5
5
  Project-URL: Homepage, https://github.com/tmonk/mcp-stata
6
6
  Project-URL: Repository, https://github.com/tmonk/mcp-stata
@@ -259,6 +259,14 @@ VS Code documents `.vscode/mcp.json` and the `servers` schema, including `type`
259
259
  * `get_stored_results()`: Get `r()` and `e()` scalars/macros as JSON.
260
260
  * `get_variable_list()`: JSON list of variables and labels.
261
261
 
262
+ ### Cancellation
263
+
264
+ - Clients may cancel an in-flight request by sending the MCP notification `notifications/cancelled` with `params.requestId` set to the original tool call ID.
265
+ - Client guidance:
266
+ 1. Pass a `_meta.progressToken` when invoking the tool if you want progress updates (optional).
267
+ 2. If you need to cancel, send `notifications/cancelled` with the same requestId. You may also stop tailing the log file path once you receive cancellation confirmation (the tool call will return an error indicating cancellation).
268
+ 3. Be prepared for partial output in the log file; cancellation is best-effort and depends on Stata surfacing `BreakError`.
269
+
262
270
  Resources exposed for MCP clients:
263
271
 
264
272
  * `stata://data/summary` → `summarize`
@@ -0,0 +1,14 @@
1
+ mcp_stata/__init__.py,sha256=kJKKRn7lGuVCuS2-GaN5VoVcvnxtNlfuswW_VOlYqwg,98
2
+ mcp_stata/discovery.py,sha256=J_XU1_AXRpqWg_ULV8xf4lT6RRN8MxOdpr1ioTi5TjQ,12951
3
+ mcp_stata/graph_detector.py,sha256=-dJIU1Dq_c1eQSk4eegUi0gU2N-tFqjFGM0tE1E32KM,16066
4
+ mcp_stata/models.py,sha256=QETpYKO3yILy_L6mhouVEanvUIvu4ww_CAAFuiP2YdM,1201
5
+ mcp_stata/server.py,sha256=PV8ragGMeHT72zgVx5DJp3vt8CPqT8iwdvJ8GXSctds,15989
6
+ mcp_stata/stata_client.py,sha256=TNJnlkZ0IoNoVXhKUw0_IYLiRNOwyL2wVmb1gWdiRUY,95981
7
+ mcp_stata/streaming_io.py,sha256=GVaXgTtxx8YLY6RWqdTcO2M3QSqxLsefqkmnlNO1nTI,6974
8
+ mcp_stata/ui_http.py,sha256=kkPYpqp-lQDXs_9qcs7hb16FtvNcag3rKSH7wvQX7Qo,22013
9
+ mcp_stata/smcl/smcl2html.py,sha256=wi91mOMeV9MCmHtNr0toihNbaiDCNZ_NP6a6xEAzWLM,2624
10
+ mcp_stata-1.6.8.dist-info/METADATA,sha256=V5mN_9vRL5f1aja0zrhMatBKb-_ZC6Ok3uOXfRBfYw4,13794
11
+ mcp_stata-1.6.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ mcp_stata-1.6.8.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
13
+ mcp_stata-1.6.8.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
14
+ mcp_stata-1.6.8.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- mcp_stata/__init__.py,sha256=kJKKRn7lGuVCuS2-GaN5VoVcvnxtNlfuswW_VOlYqwg,98
2
- mcp_stata/discovery.py,sha256=K8SK4oEnLESxnJWcBsLK76s85yNVdbLlfOql_Rf94B4,8390
3
- mcp_stata/graph_detector.py,sha256=-dJIU1Dq_c1eQSk4eegUi0gU2N-tFqjFGM0tE1E32KM,16066
4
- mcp_stata/models.py,sha256=QETpYKO3yILy_L6mhouVEanvUIvu4ww_CAAFuiP2YdM,1201
5
- mcp_stata/server.py,sha256=9_0i8xux11NpZ6yJwOniqQo-Cs3ph2mrkr0gwZ9GZ1I,15671
6
- mcp_stata/stata_client.py,sha256=floQMie0j7xI1PUeVu3mbmv4_eztw2CLjviZ7fdmClo,90673
7
- mcp_stata/streaming_io.py,sha256=GVaXgTtxx8YLY6RWqdTcO2M3QSqxLsefqkmnlNO1nTI,6974
8
- mcp_stata/ui_http.py,sha256=UhIPzCpBelVpZWUgwBnGpOCAEpG2vFVvX-TxRuyhkR0,21210
9
- mcp_stata/smcl/smcl2html.py,sha256=wi91mOMeV9MCmHtNr0toihNbaiDCNZ_NP6a6xEAzWLM,2624
10
- mcp_stata-1.6.2.dist-info/METADATA,sha256=buX6iqLIsnmtBlVueebezon9o3OYxL6I0DYmmwHOV0I,13141
11
- mcp_stata-1.6.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- mcp_stata-1.6.2.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
13
- mcp_stata-1.6.2.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
14
- mcp_stata-1.6.2.dist-info/RECORD,,