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 +186 -68
- mcp_stata/server.py +7 -0
- mcp_stata/stata_client.py +174 -48
- mcp_stata/ui_http.py +24 -5
- {mcp_stata-1.6.2.dist-info → mcp_stata-1.6.8.dist-info}/METADATA +9 -1
- mcp_stata-1.6.8.dist-info/RECORD +14 -0
- mcp_stata-1.6.2.dist-info/RECORD +0 -14
- {mcp_stata-1.6.2.dist-info → mcp_stata-1.6.8.dist-info}/WHEEL +0 -0
- {mcp_stata-1.6.2.dist-info → mcp_stata-1.6.8.dist-info}/entry_points.txt +0 -0
- {mcp_stata-1.6.2.dist-info → mcp_stata-1.6.8.dist-info}/licenses/LICENSE +0 -0
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
|
|
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("
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if
|
|
79
|
-
search_set =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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.
|
|
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,,
|
mcp_stata-1.6.2.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|