mcp-stata 1.7.6__py3-none-any.whl → 1.16.6__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 +20 -0
- mcp_stata/discovery.py +140 -61
- mcp_stata/graph_detector.py +60 -44
- mcp_stata/models.py +2 -1
- mcp_stata/server.py +729 -28
- mcp_stata/stata_client.py +2023 -817
- mcp_stata/streaming_io.py +3 -1
- mcp_stata/test_stata.py +54 -0
- mcp_stata/ui_http.py +178 -19
- {mcp_stata-1.7.6.dist-info → mcp_stata-1.16.6.dist-info}/METADATA +28 -6
- mcp_stata-1.16.6.dist-info/RECORD +16 -0
- mcp_stata-1.7.6.dist-info/RECORD +0 -14
- {mcp_stata-1.7.6.dist-info → mcp_stata-1.16.6.dist-info}/WHEEL +0 -0
- {mcp_stata-1.7.6.dist-info → mcp_stata-1.16.6.dist-info}/entry_points.txt +0 -0
- {mcp_stata-1.7.6.dist-info → mcp_stata-1.16.6.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
2
|
+
Optimized discovery.py with fast auto-discovery and targeted retry logic.
|
|
3
3
|
Key improvements:
|
|
4
|
-
1.
|
|
5
|
-
2.
|
|
6
|
-
3.
|
|
7
|
-
4.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
242
|
+
def find_stata_candidates() -> List[Tuple[str, str]]:
|
|
197
243
|
"""
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
if
|
|
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(
|
|
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
|
-
|
|
286
|
+
candidates_in_dir = []
|
|
241
287
|
if system == "Windows":
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
("stata-mp", "mp"),
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,11 +371,12 @@ 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
|
|
317
|
-
|
|
378
|
+
# Search targets specific to macOS installation patterns
|
|
379
|
+
patterns = [
|
|
318
380
|
"/Applications/StataNow/StataMP.app",
|
|
319
381
|
"/Applications/StataNow/StataSE.app",
|
|
320
382
|
"/Applications/StataNow/Stata.app",
|
|
@@ -322,17 +384,19 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
322
384
|
"/Applications/Stata/StataSE.app",
|
|
323
385
|
"/Applications/Stata/Stata.app",
|
|
324
386
|
"/Applications/Stata*/Stata*.app",
|
|
387
|
+
"/Applications/Stata*.app",
|
|
325
388
|
]
|
|
326
389
|
|
|
327
|
-
for pattern in
|
|
390
|
+
for pattern in patterns:
|
|
328
391
|
for app_dir in glob.glob(pattern):
|
|
329
392
|
binary_dir = os.path.join(app_dir, "Contents", "MacOS")
|
|
330
|
-
if not
|
|
393
|
+
if not _exists_fast(binary_dir):
|
|
331
394
|
continue
|
|
332
395
|
for binary, edition in [("stata-mp", "mp"), ("stata-se", "se"), ("stata", "be")]:
|
|
333
396
|
full_path = os.path.join(binary_dir, binary)
|
|
334
|
-
if
|
|
397
|
+
if _exists_fast(full_path):
|
|
335
398
|
candidates.append((full_path, edition))
|
|
399
|
+
candidates = _dedupe_preserve(candidates)
|
|
336
400
|
|
|
337
401
|
elif system == "Windows":
|
|
338
402
|
# Include ProgramW6432 (real 64-bit Program Files) and hardcode fallbacks.
|
|
@@ -379,8 +443,9 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
379
443
|
continue
|
|
380
444
|
for exe, edition in windows_binaries:
|
|
381
445
|
full_path = os.path.join(stata_dir, exe)
|
|
382
|
-
if
|
|
446
|
+
if _exists_fast(full_path):
|
|
383
447
|
candidates.append((full_path, edition))
|
|
448
|
+
candidates = _dedupe_preserve(candidates)
|
|
384
449
|
|
|
385
450
|
elif system == "Linux":
|
|
386
451
|
home_base = os.environ.get("HOME") or os.path.expanduser("~")
|
|
@@ -417,20 +482,26 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
417
482
|
continue
|
|
418
483
|
for binary, edition in linux_binaries:
|
|
419
484
|
full_path = os.path.join(base_dir, binary)
|
|
420
|
-
if
|
|
485
|
+
if _exists_fast(full_path):
|
|
421
486
|
candidates.append((full_path, edition))
|
|
422
487
|
|
|
423
|
-
|
|
488
|
+
candidates = _dedupe_preserve(candidates)
|
|
424
489
|
|
|
425
|
-
|
|
426
|
-
|
|
490
|
+
# Final validation of candidates (still using fast checks)
|
|
491
|
+
validated: List[Tuple[str, str]] = []
|
|
492
|
+
unique_candidates = _dedupe_preserve(candidates)
|
|
493
|
+
for path, edition in _sort_candidates(unique_candidates):
|
|
494
|
+
if not _exists_fast(path):
|
|
427
495
|
logger.warning("Discovered candidate missing on disk: %s", path)
|
|
428
496
|
continue
|
|
429
|
-
if not _is_executable(path, system):
|
|
497
|
+
if not _is_executable(path, system, use_retry=False):
|
|
430
498
|
logger.warning("Discovered candidate is not executable: %s", path)
|
|
431
499
|
continue
|
|
432
500
|
logger.info("Auto-discovered Stata at %s (%s)", path, edition)
|
|
433
|
-
|
|
501
|
+
validated.append((path, edition))
|
|
502
|
+
|
|
503
|
+
if validated:
|
|
504
|
+
return validated
|
|
434
505
|
|
|
435
506
|
# Build comprehensive error message
|
|
436
507
|
error_parts = ["Could not automatically locate Stata."]
|
|
@@ -455,6 +526,14 @@ def find_stata_path() -> Tuple[str, str]:
|
|
|
455
526
|
raise FileNotFoundError("\n".join(error_parts))
|
|
456
527
|
|
|
457
528
|
|
|
529
|
+
def find_stata_path() -> Tuple[str, str]:
|
|
530
|
+
"""
|
|
531
|
+
Backward-compatible wrapper returning the top-ranked candidate.
|
|
532
|
+
"""
|
|
533
|
+
candidates = find_stata_candidates()
|
|
534
|
+
return candidates[0]
|
|
535
|
+
|
|
536
|
+
|
|
458
537
|
def main() -> int:
|
|
459
538
|
"""CLI helper to print discovered Stata binary and edition."""
|
|
460
539
|
try:
|
mcp_stata/graph_detector.py
CHANGED
|
@@ -6,6 +6,8 @@ during Stata command execution and automatically cache them.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import inspect
|
|
9
11
|
import re
|
|
10
12
|
import threading
|
|
11
13
|
import time
|
|
@@ -33,21 +35,27 @@ class GraphCreationDetector:
|
|
|
33
35
|
def _describe_graph_signature(self, graph_name: str) -> str:
|
|
34
36
|
"""Return a stable signature for a graph.
|
|
35
37
|
|
|
36
|
-
We
|
|
37
|
-
every
|
|
38
|
+
We avoid using Stata calls like 'graph describe' here because they are slow
|
|
39
|
+
(each call takes ~35ms) and would be called for every graph on every poll,
|
|
40
|
+
bottlenecking the streaming output.
|
|
41
|
+
|
|
42
|
+
Instead, we use name-based tracking tied to the Stata command execution
|
|
43
|
+
context. The signature is stable within a single command execution but
|
|
44
|
+
changes when a new command starts, allowing us to detect modifications
|
|
45
|
+
between commands without any Stata overhead.
|
|
38
46
|
"""
|
|
39
|
-
if not self._stata_client
|
|
40
|
-
return ""
|
|
41
|
-
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)
|
|
44
|
-
if resp.success and resp.stdout:
|
|
45
|
-
return resp.stdout
|
|
46
|
-
if resp.error and resp.error.snippet:
|
|
47
|
-
return resp.error.snippet
|
|
48
|
-
except Exception:
|
|
47
|
+
if not self._stata_client:
|
|
49
48
|
return ""
|
|
50
|
-
|
|
49
|
+
|
|
50
|
+
# Access command_idx from stata_client if available
|
|
51
|
+
# NOTE: We only use command_idx for the default 'Graph' name to detect
|
|
52
|
+
# modifications. For named graphs, we only detect creation (name change)
|
|
53
|
+
# to avoid triggering redundant notifications for all existing graphs
|
|
54
|
+
# on every command (since command_idx changes globally).
|
|
55
|
+
cmd_idx = getattr(self._stata_client, "_command_idx", 0)
|
|
56
|
+
if graph_name.lower() == "graph":
|
|
57
|
+
return f"{graph_name}_{cmd_idx}"
|
|
58
|
+
return graph_name
|
|
51
59
|
|
|
52
60
|
def _detect_graphs_via_pystata(self) -> List[str]:
|
|
53
61
|
"""Detect newly created graphs using direct pystata state access."""
|
|
@@ -95,19 +103,30 @@ class GraphCreationDetector:
|
|
|
95
103
|
try:
|
|
96
104
|
# Use pystata to get graph list directly
|
|
97
105
|
if self._stata_client and hasattr(self._stata_client, 'list_graphs'):
|
|
98
|
-
return self._stata_client.list_graphs()
|
|
106
|
+
return self._stata_client.list_graphs(force_refresh=True)
|
|
99
107
|
else:
|
|
100
108
|
# Fallback to sfi Macro interface - only if stata is available
|
|
101
109
|
if self._stata_client and hasattr(self._stata_client, 'stata'):
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
# Access the lock from client to prevent concurrency issues with pystata
|
|
111
|
+
exec_lock = getattr(self._stata_client, "_exec_lock", None)
|
|
112
|
+
ctx = exec_lock if exec_lock else contextlib.nullcontext()
|
|
113
|
+
|
|
114
|
+
with ctx:
|
|
115
|
+
try:
|
|
116
|
+
from sfi import Macro
|
|
117
|
+
hold_name = f"_mcp_detector_hold_{int(time.time() * 1000 % 1000000)}"
|
|
118
|
+
self._stata_client.stata.run(f"capture _return hold {hold_name}", echo=False)
|
|
119
|
+
try:
|
|
120
|
+
self._stata_client.stata.run("macro define mcp_graph_list \"\"", echo=False)
|
|
121
|
+
self._stata_client.stata.run("quietly graph dir, memory", echo=False)
|
|
122
|
+
self._stata_client.stata.run("macro define mcp_graph_list `r(list)'", echo=False)
|
|
123
|
+
graph_list_str = Macro.getGlobal("mcp_graph_list")
|
|
124
|
+
finally:
|
|
125
|
+
self._stata_client.stata.run(f"capture _return restore {hold_name}", echo=False)
|
|
126
|
+
return graph_list_str.split() if graph_list_str else []
|
|
127
|
+
except ImportError:
|
|
128
|
+
logger.warning("sfi.Macro not available for fallback graph detection")
|
|
129
|
+
return []
|
|
111
130
|
else:
|
|
112
131
|
return []
|
|
113
132
|
except Exception as e:
|
|
@@ -246,7 +265,11 @@ class StreamingGraphCache:
|
|
|
246
265
|
def __init__(self, stata_client, auto_cache: bool = False):
|
|
247
266
|
self.stata_client = stata_client
|
|
248
267
|
self.auto_cache = auto_cache
|
|
249
|
-
|
|
268
|
+
# Use persistent detector from client if available, else create local one
|
|
269
|
+
if hasattr(stata_client, "_graph_detector"):
|
|
270
|
+
self.detector = stata_client._graph_detector
|
|
271
|
+
else:
|
|
272
|
+
self.detector = GraphCreationDetector(stata_client)
|
|
250
273
|
self._lock = threading.Lock()
|
|
251
274
|
self._cache_callbacks: List[Callable[[str, bool], None]] = []
|
|
252
275
|
self._graphs_to_cache: List[str] = []
|
|
@@ -259,6 +282,15 @@ class StreamingGraphCache:
|
|
|
259
282
|
with self._lock:
|
|
260
283
|
self._cache_callbacks.append(callback)
|
|
261
284
|
|
|
285
|
+
async def _notify_cache_callbacks(self, graph_name: str, success: bool) -> None:
|
|
286
|
+
for callback in self._cache_callbacks:
|
|
287
|
+
try:
|
|
288
|
+
result = callback(graph_name, success)
|
|
289
|
+
if inspect.isawaitable(result):
|
|
290
|
+
await result
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.warning(f"Cache callback failed for {graph_name}: {e}")
|
|
293
|
+
|
|
262
294
|
|
|
263
295
|
async def cache_detected_graphs_with_pystata(self) -> List[str]:
|
|
264
296
|
"""Enhanced caching method that uses pystata for real-time graph detection."""
|
|
@@ -304,20 +336,12 @@ class StreamingGraphCache:
|
|
|
304
336
|
self._cached_graphs.add(graph_name)
|
|
305
337
|
|
|
306
338
|
# Notify callbacks
|
|
307
|
-
|
|
308
|
-
try:
|
|
309
|
-
callback(graph_name, success)
|
|
310
|
-
except Exception as e:
|
|
311
|
-
logger.warning(f"Cache callback failed for {graph_name}: {e}")
|
|
339
|
+
await self._notify_cache_callbacks(graph_name, success)
|
|
312
340
|
|
|
313
341
|
except Exception as e:
|
|
314
342
|
logger.warning(f"Failed to cache graph {graph_name}: {e}")
|
|
315
343
|
# Still notify callbacks of failure
|
|
316
|
-
|
|
317
|
-
try:
|
|
318
|
-
callback(graph_name, False)
|
|
319
|
-
except Exception:
|
|
320
|
-
pass
|
|
344
|
+
await self._notify_cache_callbacks(graph_name, False)
|
|
321
345
|
|
|
322
346
|
return cached_names
|
|
323
347
|
|
|
@@ -349,20 +373,12 @@ class StreamingGraphCache:
|
|
|
349
373
|
self._cached_graphs.add(graph_name)
|
|
350
374
|
|
|
351
375
|
# Notify callbacks
|
|
352
|
-
|
|
353
|
-
try:
|
|
354
|
-
callback(graph_name, success)
|
|
355
|
-
except Exception as e:
|
|
356
|
-
logger.warning(f"Cache callback failed for {graph_name}: {e}")
|
|
376
|
+
await self._notify_cache_callbacks(graph_name, success)
|
|
357
377
|
|
|
358
378
|
except Exception as e:
|
|
359
379
|
logger.warning(f"Failed to cache graph {graph_name}: {e}")
|
|
360
380
|
# Still notify callbacks of failure
|
|
361
|
-
|
|
362
|
-
try:
|
|
363
|
-
callback(graph_name, False)
|
|
364
|
-
except Exception:
|
|
365
|
-
pass
|
|
381
|
+
await self._notify_cache_callbacks(graph_name, False)
|
|
366
382
|
|
|
367
383
|
return cached_names
|
|
368
384
|
|
mcp_stata/models.py
CHANGED
|
@@ -13,6 +13,7 @@ class ErrorEnvelope(BaseModel):
|
|
|
13
13
|
stderr: Optional[str] = None
|
|
14
14
|
snippet: Optional[str] = None
|
|
15
15
|
trace: Optional[bool] = None
|
|
16
|
+
smcl_output: Optional[str] = None
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class CommandResponse(BaseModel):
|
|
@@ -23,6 +24,7 @@ class CommandResponse(BaseModel):
|
|
|
23
24
|
log_path: Optional[str] = None
|
|
24
25
|
success: bool
|
|
25
26
|
error: Optional[ErrorEnvelope] = None
|
|
27
|
+
smcl_output: Optional[str] = None
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
class DataResponse(BaseModel):
|
|
@@ -53,7 +55,6 @@ class GraphListResponse(BaseModel):
|
|
|
53
55
|
class GraphExport(BaseModel):
|
|
54
56
|
name: str
|
|
55
57
|
file_path: Optional[str] = None
|
|
56
|
-
image_base64: Optional[str] = None
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
class GraphExportResponse(BaseModel):
|