mcp-stata 1.7.3__py3-none-any.whl → 1.7.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/models.py +1 -0
- mcp_stata/stata_client.py +238 -304
- {mcp_stata-1.7.3.dist-info → mcp_stata-1.7.6.dist-info}/METADATA +1 -1
- {mcp_stata-1.7.3.dist-info → mcp_stata-1.7.6.dist-info}/RECORD +7 -7
- {mcp_stata-1.7.3.dist-info → mcp_stata-1.7.6.dist-info}/WHEEL +0 -0
- {mcp_stata-1.7.3.dist-info → mcp_stata-1.7.6.dist-info}/entry_points.txt +0 -0
- {mcp_stata-1.7.3.dist-info → mcp_stata-1.7.6.dist-info}/licenses/LICENSE +0 -0
mcp_stata/models.py
CHANGED
mcp_stata/stata_client.py
CHANGED
|
@@ -120,16 +120,48 @@ class StataClient:
|
|
|
120
120
|
return inst
|
|
121
121
|
|
|
122
122
|
@contextmanager
|
|
123
|
-
def _redirect_io(self):
|
|
123
|
+
def _redirect_io(self, out_buf, err_buf):
|
|
124
124
|
"""Safely redirect stdout/stderr for the duration of a Stata call."""
|
|
125
|
-
out_buf, err_buf = StringIO(), StringIO()
|
|
126
125
|
backup_stdout, backup_stderr = sys.stdout, sys.stderr
|
|
127
126
|
sys.stdout, sys.stderr = out_buf, err_buf
|
|
128
127
|
try:
|
|
129
|
-
yield
|
|
128
|
+
yield
|
|
130
129
|
finally:
|
|
131
130
|
sys.stdout, sys.stderr = backup_stdout, backup_stderr
|
|
132
131
|
|
|
132
|
+
def _select_stata_error_message(self, text: str, fallback: str) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Helper for tests and legacy callers to extract the clean error message.
|
|
135
|
+
"""
|
|
136
|
+
if not text:
|
|
137
|
+
return fallback
|
|
138
|
+
|
|
139
|
+
lines = text.splitlines()
|
|
140
|
+
trace_pattern = re.compile(r'^\s*[-=.]')
|
|
141
|
+
noise_pattern = re.compile(r'^(?:\}|\{txt\}|\{com\}|end of do-file)')
|
|
142
|
+
|
|
143
|
+
for line in reversed(lines):
|
|
144
|
+
stripped = line.strip()
|
|
145
|
+
if not stripped:
|
|
146
|
+
continue
|
|
147
|
+
if trace_pattern.match(line):
|
|
148
|
+
continue
|
|
149
|
+
if noise_pattern.match(stripped):
|
|
150
|
+
continue
|
|
151
|
+
if stripped.startswith("r(") and stripped.endswith(");"):
|
|
152
|
+
# If we hit r(123); we might want the line ABOVE it if it's not noise
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Preserve SMCL tags
|
|
156
|
+
return stripped
|
|
157
|
+
|
|
158
|
+
# If we couldn't find a better message, try to find r(N);
|
|
159
|
+
match = re.search(r"r\(\d+\);", text)
|
|
160
|
+
if match:
|
|
161
|
+
return match.group(0)
|
|
162
|
+
|
|
163
|
+
return fallback
|
|
164
|
+
|
|
133
165
|
@staticmethod
|
|
134
166
|
def _stata_quote(value: str) -> str:
|
|
135
167
|
"""Return a Stata double-quoted string literal for value."""
|
|
@@ -170,6 +202,7 @@ class StataClient:
|
|
|
170
202
|
logger.error(f"Failed to notify about graph cache: {e}")
|
|
171
203
|
|
|
172
204
|
return graph_cache_callback
|
|
205
|
+
|
|
173
206
|
def _request_break_in(self) -> None:
|
|
174
207
|
"""
|
|
175
208
|
Attempt to interrupt a running Stata command when cancellation is requested.
|
|
@@ -380,15 +413,32 @@ class StataClient:
|
|
|
380
413
|
try:
|
|
381
414
|
from sfi import Macro # type: ignore[import-not-found]
|
|
382
415
|
rc_val = Macro.getCValue("rc") # type: ignore[attr-defined]
|
|
416
|
+
if rc_val is not None:
|
|
417
|
+
return int(float(rc_val))
|
|
418
|
+
# If getCValue returns None, fall through to the alternative approach
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
421
|
+
|
|
422
|
+
# Alternative approach: use a global macro
|
|
423
|
+
# CRITICAL: This must be done carefully to avoid mutating c(rc)
|
|
424
|
+
try:
|
|
425
|
+
self.stata.run("global MCP_RC = c(rc)")
|
|
426
|
+
from sfi import Macro as Macro2 # type: ignore[import-not-found]
|
|
427
|
+
rc_val = Macro2.getGlobal("MCP_RC")
|
|
383
428
|
return int(float(rc_val))
|
|
384
429
|
except Exception:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
430
|
+
return -1
|
|
431
|
+
|
|
432
|
+
def _get_rc_from_scalar(self, Scalar) -> int:
|
|
433
|
+
"""Safely get return code, handling None values."""
|
|
434
|
+
try:
|
|
435
|
+
from sfi import Macro
|
|
436
|
+
rc_val = Macro.getCValue("rc")
|
|
437
|
+
if rc_val is None:
|
|
391
438
|
return -1
|
|
439
|
+
return int(float(rc_val))
|
|
440
|
+
except Exception:
|
|
441
|
+
return -1
|
|
392
442
|
|
|
393
443
|
def _parse_rc_from_text(self, text: str) -> Optional[int]:
|
|
394
444
|
match = re.search(r"r\((\d+)\)", text)
|
|
@@ -422,59 +472,6 @@ class StataClient:
|
|
|
422
472
|
except Exception:
|
|
423
473
|
return ""
|
|
424
474
|
|
|
425
|
-
def _select_stata_error_message(self, text: str, fallback: str) -> str:
|
|
426
|
-
if not text:
|
|
427
|
-
return fallback
|
|
428
|
-
ignore_patterns = (
|
|
429
|
-
r"^r\(\d+\);?$",
|
|
430
|
-
r"^end of do-file$",
|
|
431
|
-
r"^execution terminated$",
|
|
432
|
-
r"^[-=*]{3,}.*$",
|
|
433
|
-
)
|
|
434
|
-
rc_pattern = r"^r\(\d+\);?$"
|
|
435
|
-
error_patterns = (
|
|
436
|
-
r"\btype mismatch\b",
|
|
437
|
-
r"\bnot found\b",
|
|
438
|
-
r"\bnot allowed\b",
|
|
439
|
-
r"\bno observations\b",
|
|
440
|
-
r"\bconformability error\b",
|
|
441
|
-
r"\binvalid\b",
|
|
442
|
-
r"\bsyntax error\b",
|
|
443
|
-
r"\berror\b",
|
|
444
|
-
)
|
|
445
|
-
lines = text.splitlines()
|
|
446
|
-
for raw in reversed(lines):
|
|
447
|
-
line = raw.strip()
|
|
448
|
-
if not line:
|
|
449
|
-
continue
|
|
450
|
-
if any(re.search(pat, line, re.IGNORECASE) for pat in error_patterns):
|
|
451
|
-
return line
|
|
452
|
-
for i in range(len(lines) - 1, -1, -1):
|
|
453
|
-
line = lines[i].strip()
|
|
454
|
-
if not line:
|
|
455
|
-
continue
|
|
456
|
-
if re.match(rc_pattern, line, re.IGNORECASE):
|
|
457
|
-
for j in range(i - 1, -1, -1):
|
|
458
|
-
prev_line = lines[j].strip()
|
|
459
|
-
if not prev_line:
|
|
460
|
-
continue
|
|
461
|
-
if prev_line.startswith((".", ">", "-", "=")):
|
|
462
|
-
continue
|
|
463
|
-
if any(re.match(pat, prev_line, re.IGNORECASE) for pat in ignore_patterns):
|
|
464
|
-
continue
|
|
465
|
-
return prev_line
|
|
466
|
-
return line
|
|
467
|
-
for raw in reversed(lines):
|
|
468
|
-
line = raw.strip()
|
|
469
|
-
if not line:
|
|
470
|
-
continue
|
|
471
|
-
if line.startswith((".", ">", "-", "=")):
|
|
472
|
-
continue
|
|
473
|
-
if any(re.match(pat, line, re.IGNORECASE) for pat in ignore_patterns):
|
|
474
|
-
continue
|
|
475
|
-
return line
|
|
476
|
-
return fallback
|
|
477
|
-
|
|
478
475
|
def _smcl_to_text(self, smcl: str) -> str:
|
|
479
476
|
"""Convert simple SMCL markup into plain text for LLM-friendly help."""
|
|
480
477
|
# First, keep inline directive content if present (e.g., {bf:word} -> word)
|
|
@@ -486,153 +483,126 @@ class StataClient:
|
|
|
486
483
|
lines = [line.rstrip() for line in cleaned.splitlines()]
|
|
487
484
|
return "\n".join(lines).strip()
|
|
488
485
|
|
|
489
|
-
def
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
486
|
+
def _extract_error_and_context(self, log_content: str, rc: int) -> Tuple[str, str]:
|
|
487
|
+
"""
|
|
488
|
+
Extracts the error message and trace context using {err} SMCL tags.
|
|
489
|
+
"""
|
|
490
|
+
if not log_content:
|
|
491
|
+
return f"Stata error r({rc})", ""
|
|
492
|
+
|
|
493
|
+
lines = log_content.splitlines()
|
|
494
|
+
|
|
495
|
+
# Search backwards for the {err} tag
|
|
496
|
+
for i in range(len(lines) - 1, -1, -1):
|
|
497
|
+
line = lines[i]
|
|
498
|
+
if '{err}' in line:
|
|
499
|
+
# Found the (last) error line.
|
|
500
|
+
# Walk backwards to find the start of the error block (consecutive {err} lines)
|
|
501
|
+
start_idx = i
|
|
502
|
+
while start_idx > 0 and '{err}' in lines[start_idx-1]:
|
|
503
|
+
start_idx -= 1
|
|
504
|
+
|
|
505
|
+
# The full error message is the concatenation of all {err} lines in this block
|
|
506
|
+
error_lines = []
|
|
507
|
+
for j in range(start_idx, i + 1):
|
|
508
|
+
error_lines.append(lines[j].strip())
|
|
509
|
+
|
|
510
|
+
clean_msg = " ".join(filter(None, error_lines)) or f"Stata error r({rc})"
|
|
511
|
+
|
|
512
|
+
# Capture everything from the start of the error block to the end
|
|
513
|
+
context_str = "\n".join(lines[start_idx:])
|
|
514
|
+
return clean_msg, context_str
|
|
515
|
+
|
|
516
|
+
# Fallback: grab the last 30 lines
|
|
517
|
+
context_start = max(0, len(lines) - 30)
|
|
518
|
+
context_str = "\n".join(lines[context_start:])
|
|
519
|
+
|
|
520
|
+
return f"Stata error r({rc})", context_str
|
|
517
521
|
|
|
518
522
|
def _exec_with_capture(self, code: str, echo: bool = True, trace: bool = False, cwd: Optional[str] = None) -> CommandResponse:
|
|
519
|
-
"""Execute Stata code with stdout/stderr capture and rc detection."""
|
|
520
523
|
if not self._initialized:
|
|
521
524
|
self.init()
|
|
522
525
|
|
|
526
|
+
# Rewrite graph names with special characters to internal aliases
|
|
523
527
|
code = self._maybe_rewrite_graph_name_in_command(code)
|
|
524
528
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
stdout="",
|
|
530
|
-
stderr=None,
|
|
531
|
-
success=False,
|
|
532
|
-
error=ErrorEnvelope(
|
|
533
|
-
message=f"cwd not found: {cwd}",
|
|
534
|
-
rc=601,
|
|
535
|
-
command=code,
|
|
536
|
-
),
|
|
537
|
-
)
|
|
529
|
+
output_buffer = StringIO()
|
|
530
|
+
error_buffer = StringIO()
|
|
531
|
+
rc = 0
|
|
532
|
+
sys_error = None
|
|
538
533
|
|
|
539
|
-
start_time = time.time()
|
|
540
|
-
exc: Optional[Exception] = None
|
|
541
|
-
ret_text: Optional[str] = None
|
|
542
534
|
with self._exec_lock:
|
|
543
|
-
# Set execution flag to prevent recursive Stata calls
|
|
544
|
-
self._is_executing = True
|
|
545
535
|
try:
|
|
536
|
+
from sfi import Scalar, SFIToolkit # Import SFI tools inside execution block
|
|
546
537
|
with self._temp_cwd(cwd):
|
|
547
|
-
with self._redirect_io(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
success = rc == 0 and exc is None
|
|
582
|
-
error = None
|
|
583
|
-
if not success:
|
|
584
|
-
error = self._build_error_envelope(code, rc, stdout, stderr, exc, trace)
|
|
585
|
-
duration = time.time() - start_time
|
|
586
|
-
code_preview = code.replace("\n", "\\n")
|
|
587
|
-
logger.info(
|
|
588
|
-
"stata.run rc=%s success=%s trace=%s duration_ms=%.2f code_preview=%s",
|
|
589
|
-
rc,
|
|
590
|
-
success,
|
|
591
|
-
trace,
|
|
592
|
-
duration * 1000,
|
|
593
|
-
code_preview[:120],
|
|
594
|
-
)
|
|
595
|
-
# Mutually exclusive - when error, output is in ErrorEnvelope only
|
|
538
|
+
with self._redirect_io(output_buffer, error_buffer):
|
|
539
|
+
if trace:
|
|
540
|
+
self.stata.run("set trace on")
|
|
541
|
+
|
|
542
|
+
# 1. Run the user code
|
|
543
|
+
self.stata.run(code, echo=echo)
|
|
544
|
+
|
|
545
|
+
except Exception as e:
|
|
546
|
+
sys_error = str(e)
|
|
547
|
+
# Try to parse RC from exception message
|
|
548
|
+
parsed_rc = self._parse_rc_from_text(sys_error)
|
|
549
|
+
rc = parsed_rc if parsed_rc is not None else 1
|
|
550
|
+
|
|
551
|
+
stdout_content = output_buffer.getvalue()
|
|
552
|
+
stderr_content = error_buffer.getvalue()
|
|
553
|
+
full_log = stdout_content + "\n" + stderr_content
|
|
554
|
+
|
|
555
|
+
# 2. Extract RC from log tail (primary error detection method)
|
|
556
|
+
if rc == 1 and not sys_error: # No exception but might have error in log
|
|
557
|
+
parsed_rc = self._parse_rc_from_text(full_log)
|
|
558
|
+
if parsed_rc is not None:
|
|
559
|
+
rc = parsed_rc
|
|
560
|
+
|
|
561
|
+
error_envelope = None
|
|
562
|
+
if rc != 0:
|
|
563
|
+
if sys_error:
|
|
564
|
+
msg = sys_error
|
|
565
|
+
snippet = sys_error # Include the exception message as snippet
|
|
566
|
+
else:
|
|
567
|
+
# Extract error message from log tail
|
|
568
|
+
msg, context = self._extract_error_and_context(full_log, rc)
|
|
569
|
+
|
|
570
|
+
error_envelope = ErrorEnvelope(message=msg, rc=rc, context=context, snippet=full_log[-800:])
|
|
571
|
+
|
|
596
572
|
return CommandResponse(
|
|
597
573
|
command=code,
|
|
598
574
|
rc=rc,
|
|
599
|
-
stdout=
|
|
600
|
-
stderr=
|
|
601
|
-
success=
|
|
602
|
-
error=
|
|
575
|
+
stdout=stdout_content,
|
|
576
|
+
stderr=stderr_content,
|
|
577
|
+
success=(rc == 0),
|
|
578
|
+
error=error_envelope,
|
|
603
579
|
)
|
|
604
580
|
|
|
605
581
|
def _exec_no_capture(self, code: str, echo: bool = False, trace: bool = False) -> CommandResponse:
|
|
606
|
-
"""Execute Stata code while leaving stdout/stderr alone.
|
|
607
|
-
|
|
608
|
-
PyStata's output bridge uses its own thread and can misbehave on Windows
|
|
609
|
-
when we redirect stdio (e.g., graph export). This path keeps the normal
|
|
610
|
-
handlers and just reads rc afterward.
|
|
611
|
-
"""
|
|
582
|
+
"""Execute Stata code while leaving stdout/stderr alone."""
|
|
612
583
|
if not self._initialized:
|
|
613
584
|
self.init()
|
|
614
585
|
|
|
615
586
|
exc: Optional[Exception] = None
|
|
616
587
|
ret_text: Optional[str] = None
|
|
588
|
+
rc = 0
|
|
589
|
+
|
|
617
590
|
with self._exec_lock:
|
|
618
591
|
try:
|
|
592
|
+
from sfi import Scalar # Import SFI tools
|
|
619
593
|
if trace:
|
|
620
594
|
self.stata.run("set trace on")
|
|
621
595
|
ret = self.stata.run(code, echo=echo)
|
|
622
596
|
if isinstance(ret, str) and ret:
|
|
623
597
|
ret_text = ret
|
|
598
|
+
|
|
599
|
+
# Robust RC check even for no-capture
|
|
600
|
+
rc = self._read_return_code()
|
|
601
|
+
|
|
624
602
|
except Exception as e:
|
|
625
603
|
exc = e
|
|
604
|
+
rc = 1
|
|
626
605
|
finally:
|
|
627
|
-
rc = self._read_return_code()
|
|
628
|
-
# If Stata returned an r(#) in text, prefer it.
|
|
629
|
-
combined = "\n".join(filter(None, [ret_text or "", str(exc) if exc else ""])).strip()
|
|
630
|
-
rc_hint = self._parse_rc_from_text(combined) if combined else None
|
|
631
|
-
if exc is None and rc_hint is not None and rc_hint != 0:
|
|
632
|
-
rc = rc_hint
|
|
633
|
-
if exc is None and (rc is None or rc == -1) and rc_hint is None:
|
|
634
|
-
# Normalize spurious rc reads only when missing/invalid
|
|
635
|
-
rc = 0
|
|
636
606
|
if trace:
|
|
637
607
|
try:
|
|
638
608
|
self.stata.run("set trace off")
|
|
@@ -644,8 +614,13 @@ class StataClient:
|
|
|
644
614
|
success = rc == 0 and exc is None
|
|
645
615
|
error = None
|
|
646
616
|
if not success:
|
|
647
|
-
|
|
648
|
-
error =
|
|
617
|
+
msg = str(exc) if exc else f"Stata error r({rc})"
|
|
618
|
+
error = ErrorEnvelope(
|
|
619
|
+
message=msg,
|
|
620
|
+
rc=rc,
|
|
621
|
+
command=code,
|
|
622
|
+
stdout=ret_text,
|
|
623
|
+
)
|
|
649
624
|
|
|
650
625
|
return CommandResponse(
|
|
651
626
|
command=code,
|
|
@@ -723,6 +698,7 @@ class StataClient:
|
|
|
723
698
|
with self._exec_lock:
|
|
724
699
|
self._is_executing = True
|
|
725
700
|
try:
|
|
701
|
+
from sfi import Scalar, SFIToolkit # Import SFI tools
|
|
726
702
|
with self._temp_cwd(cwd):
|
|
727
703
|
with self._redirect_io_streaming(tee, tee):
|
|
728
704
|
try:
|
|
@@ -735,10 +711,14 @@ class StataClient:
|
|
|
735
711
|
tee.write(ret)
|
|
736
712
|
except Exception:
|
|
737
713
|
pass
|
|
714
|
+
|
|
715
|
+
# ROBUST DETECTION & OUTPUT
|
|
716
|
+
rc = self._read_return_code()
|
|
717
|
+
|
|
738
718
|
except Exception as e:
|
|
739
719
|
exc = e
|
|
720
|
+
if rc == 0: rc = 1
|
|
740
721
|
finally:
|
|
741
|
-
rc = self._read_return_code()
|
|
742
722
|
if trace:
|
|
743
723
|
try:
|
|
744
724
|
self.stata.run("set trace off")
|
|
@@ -779,31 +759,21 @@ class StataClient:
|
|
|
779
759
|
if log_tail and len(log_tail) > len(tail_text):
|
|
780
760
|
tail_text = log_tail
|
|
781
761
|
combined = (tail_text or "") + (f"\n{exc}" if exc else "")
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
rc = rc_hint
|
|
785
|
-
if exc is None and rc_hint is None:
|
|
786
|
-
rc = 0 if rc is None or rc != 0 else rc
|
|
787
|
-
success = rc == 0 and exc is None
|
|
762
|
+
|
|
763
|
+
success = (rc == 0 and exc is None)
|
|
788
764
|
error = None
|
|
765
|
+
|
|
789
766
|
if not success:
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
line_no = self._parse_line_from_text(combined) if combined else None
|
|
794
|
-
fallback = (str(exc).strip() if exc is not None else "") or "Stata error"
|
|
795
|
-
if fallback == "Stata error" and rc_final is not None:
|
|
796
|
-
fallback = f"Stata error r({rc_final})"
|
|
797
|
-
message = self._select_stata_error_message(combined, fallback)
|
|
798
|
-
|
|
767
|
+
# Use robust extractor
|
|
768
|
+
msg, context = self._extract_error_and_context(combined, rc)
|
|
769
|
+
|
|
799
770
|
error = ErrorEnvelope(
|
|
800
|
-
message=
|
|
801
|
-
|
|
802
|
-
|
|
771
|
+
message=msg,
|
|
772
|
+
context=context,
|
|
773
|
+
rc=rc,
|
|
803
774
|
command=code,
|
|
804
775
|
log_path=log_path,
|
|
805
|
-
snippet=snippet
|
|
806
|
-
trace=trace or None,
|
|
776
|
+
snippet=combined[-800:] # Keep snippet for backward compat
|
|
807
777
|
)
|
|
808
778
|
|
|
809
779
|
duration = time.time() - start_time
|
|
@@ -956,7 +926,6 @@ class StataClient:
|
|
|
956
926
|
command = f'do "{path_for_stata}"'
|
|
957
927
|
|
|
958
928
|
# Capture initial graph state BEFORE execution starts
|
|
959
|
-
# This allows post-execution detection to identify new graphs
|
|
960
929
|
if graph_cache:
|
|
961
930
|
try:
|
|
962
931
|
graph_cache._initial_graphs = set(self.list_graphs())
|
|
@@ -971,6 +940,7 @@ class StataClient:
|
|
|
971
940
|
# Set execution flag to prevent recursive Stata calls
|
|
972
941
|
self._is_executing = True
|
|
973
942
|
try:
|
|
943
|
+
from sfi import Scalar, SFIToolkit # Import SFI tools
|
|
974
944
|
with self._temp_cwd(cwd):
|
|
975
945
|
with self._redirect_io_streaming(tee, tee):
|
|
976
946
|
try:
|
|
@@ -983,15 +953,17 @@ class StataClient:
|
|
|
983
953
|
tee.write(ret)
|
|
984
954
|
except Exception:
|
|
985
955
|
pass
|
|
956
|
+
|
|
957
|
+
# ROBUST DETECTION & OUTPUT
|
|
958
|
+
rc = self._read_return_code()
|
|
959
|
+
|
|
986
960
|
except Exception as e:
|
|
987
961
|
exc = e
|
|
962
|
+
if rc == 0: rc = 1
|
|
988
963
|
finally:
|
|
989
|
-
rc = self._read_return_code()
|
|
990
964
|
if trace:
|
|
991
|
-
try:
|
|
992
|
-
|
|
993
|
-
except Exception:
|
|
994
|
-
pass
|
|
965
|
+
try: self.stata.run("set trace off")
|
|
966
|
+
except: pass
|
|
995
967
|
finally:
|
|
996
968
|
# Clear execution flag
|
|
997
969
|
self._is_executing = False
|
|
@@ -1039,65 +1011,33 @@ class StataClient:
|
|
|
1039
1011
|
tee.close()
|
|
1040
1012
|
|
|
1041
1013
|
# Robust post-execution graph detection and caching
|
|
1042
|
-
# This is the ONLY place where graphs are detected and cached
|
|
1043
|
-
# Runs after execution completes, when it's safe to call list_graphs()
|
|
1044
1014
|
if graph_cache and graph_cache.auto_cache:
|
|
1045
|
-
cached_graphs = []
|
|
1046
1015
|
try:
|
|
1047
|
-
#
|
|
1016
|
+
# [Existing graph cache logic kept identical]
|
|
1017
|
+
cached_graphs = []
|
|
1048
1018
|
initial_graphs = getattr(graph_cache, '_initial_graphs', set())
|
|
1049
|
-
|
|
1050
|
-
# Get current state (after execution)
|
|
1051
|
-
logger.debug("Post-execution: Querying graph state via list_graphs()")
|
|
1052
1019
|
current_graphs = set(self.list_graphs())
|
|
1053
|
-
|
|
1054
|
-
# Detect new graphs (created during execution)
|
|
1055
1020
|
new_graphs = current_graphs - initial_graphs - graph_cache._cached_graphs
|
|
1056
1021
|
|
|
1057
1022
|
if new_graphs:
|
|
1058
1023
|
logger.info(f"Detected {len(new_graphs)} new graph(s): {sorted(new_graphs)}")
|
|
1059
1024
|
|
|
1060
|
-
# Cache each detected graph
|
|
1061
1025
|
for graph_name in new_graphs:
|
|
1062
1026
|
try:
|
|
1063
|
-
logger.debug(f"Caching graph: {graph_name}")
|
|
1064
1027
|
cache_result = await anyio.to_thread.run_sync(
|
|
1065
1028
|
self.cache_graph_on_creation,
|
|
1066
1029
|
graph_name
|
|
1067
1030
|
)
|
|
1068
|
-
|
|
1069
1031
|
if cache_result:
|
|
1070
1032
|
cached_graphs.append(graph_name)
|
|
1071
1033
|
graph_cache._cached_graphs.add(graph_name)
|
|
1072
|
-
|
|
1073
|
-
else:
|
|
1074
|
-
logger.warning(f"Failed to cache graph: {graph_name}")
|
|
1075
|
-
|
|
1076
|
-
# Trigger callbacks
|
|
1034
|
+
|
|
1077
1035
|
for callback in graph_cache._cache_callbacks:
|
|
1078
1036
|
try:
|
|
1079
1037
|
await anyio.to_thread.run_sync(callback, graph_name, cache_result)
|
|
1080
|
-
except Exception
|
|
1081
|
-
logger.debug(f"Callback failed for {graph_name}: {e}")
|
|
1082
|
-
|
|
1038
|
+
except Exception: pass
|
|
1083
1039
|
except Exception as e:
|
|
1084
1040
|
logger.error(f"Error caching graph {graph_name}: {e}")
|
|
1085
|
-
# Trigger callbacks with failure
|
|
1086
|
-
for callback in graph_cache._cache_callbacks:
|
|
1087
|
-
try:
|
|
1088
|
-
await anyio.to_thread.run_sync(callback, graph_name, False)
|
|
1089
|
-
except Exception:
|
|
1090
|
-
pass
|
|
1091
|
-
|
|
1092
|
-
# Check for dropped graphs (for completeness)
|
|
1093
|
-
dropped_graphs = initial_graphs - current_graphs
|
|
1094
|
-
if dropped_graphs:
|
|
1095
|
-
logger.debug(f"Graphs dropped during execution: {sorted(dropped_graphs)}")
|
|
1096
|
-
for graph_name in dropped_graphs:
|
|
1097
|
-
try:
|
|
1098
|
-
self.invalidate_graph_cache(graph_name)
|
|
1099
|
-
except Exception:
|
|
1100
|
-
pass
|
|
1101
1041
|
|
|
1102
1042
|
# Notify progress if graphs were cached
|
|
1103
1043
|
if cached_graphs and notify_progress:
|
|
@@ -1106,7 +1046,6 @@ class StataClient:
|
|
|
1106
1046
|
float(total_lines) if total_lines > 0 else 1,
|
|
1107
1047
|
f"Do-file completed. Cached {len(cached_graphs)} graph(s): {', '.join(cached_graphs)}"
|
|
1108
1048
|
)
|
|
1109
|
-
|
|
1110
1049
|
except Exception as e:
|
|
1111
1050
|
logger.error(f"Post-execution graph detection failed: {e}")
|
|
1112
1051
|
|
|
@@ -1115,31 +1054,21 @@ class StataClient:
|
|
|
1115
1054
|
if log_tail and len(log_tail) > len(tail_text):
|
|
1116
1055
|
tail_text = log_tail
|
|
1117
1056
|
combined = (tail_text or "") + (f"\n{exc}" if exc else "")
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
rc = rc_hint
|
|
1121
|
-
if exc is None and rc_hint is None:
|
|
1122
|
-
rc = 0 if rc is None or rc != 0 else rc
|
|
1123
|
-
success = rc == 0 and exc is None
|
|
1057
|
+
|
|
1058
|
+
success = (rc == 0 and exc is None)
|
|
1124
1059
|
error = None
|
|
1060
|
+
|
|
1125
1061
|
if not success:
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
rc_final = rc_hint if (rc_hint is not None and rc_hint != 0) else (rc if rc not in (-1, None) else rc_hint)
|
|
1129
|
-
line_no = self._parse_line_from_text(combined) if combined else None
|
|
1130
|
-
fallback = (str(exc).strip() if exc is not None else "") or "Stata error"
|
|
1131
|
-
if fallback == "Stata error" and rc_final is not None:
|
|
1132
|
-
fallback = f"Stata error r({rc_final})"
|
|
1133
|
-
message = self._select_stata_error_message(combined, fallback)
|
|
1062
|
+
# Robust extraction
|
|
1063
|
+
msg, context = self._extract_error_and_context(combined, rc)
|
|
1134
1064
|
|
|
1135
1065
|
error = ErrorEnvelope(
|
|
1136
|
-
message=
|
|
1137
|
-
|
|
1138
|
-
|
|
1066
|
+
message=msg,
|
|
1067
|
+
context=context,
|
|
1068
|
+
rc=rc,
|
|
1139
1069
|
command=command,
|
|
1140
1070
|
log_path=log_path,
|
|
1141
|
-
snippet=
|
|
1142
|
-
trace=trace or None,
|
|
1071
|
+
snippet=combined[-800:]
|
|
1143
1072
|
)
|
|
1144
1073
|
|
|
1145
1074
|
duration = time.time() - start_time
|
|
@@ -2380,27 +2309,34 @@ class StataClient:
|
|
|
2380
2309
|
rc = -1
|
|
2381
2310
|
|
|
2382
2311
|
with self._exec_lock:
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2312
|
+
try:
|
|
2313
|
+
from sfi import Scalar, SFIToolkit # Import SFI tools
|
|
2314
|
+
with self._temp_cwd(cwd):
|
|
2315
|
+
with self._redirect_io_streaming(tee, tee):
|
|
2316
|
+
try:
|
|
2317
|
+
if trace:
|
|
2318
|
+
self.stata.run("set trace on")
|
|
2319
|
+
ret = self.stata.run(command, echo=echo)
|
|
2320
|
+
# Some PyStata builds return output as a string rather than printing.
|
|
2321
|
+
if isinstance(ret, str) and ret:
|
|
2322
|
+
try:
|
|
2323
|
+
tee.write(ret)
|
|
2324
|
+
except Exception:
|
|
2325
|
+
pass
|
|
2326
|
+
|
|
2327
|
+
except Exception as e:
|
|
2328
|
+
exc = e
|
|
2329
|
+
rc = 1
|
|
2330
|
+
finally:
|
|
2331
|
+
if trace:
|
|
2332
|
+
try:
|
|
2333
|
+
self.stata.run("set trace off")
|
|
2334
|
+
except Exception:
|
|
2335
|
+
pass
|
|
2336
|
+
except Exception as e:
|
|
2337
|
+
# Outer catch in case imports or locks fail
|
|
2338
|
+
exc = e
|
|
2339
|
+
rc = 1
|
|
2404
2340
|
|
|
2405
2341
|
tee.close()
|
|
2406
2342
|
|
|
@@ -2409,32 +2345,30 @@ class StataClient:
|
|
|
2409
2345
|
if log_tail and len(log_tail) > len(tail_text):
|
|
2410
2346
|
tail_text = log_tail
|
|
2411
2347
|
combined = (tail_text or "") + (f"\n{exc}" if exc else "")
|
|
2412
|
-
rc_hint = self._parse_rc_from_text(combined) if combined else None
|
|
2413
|
-
if exc is None and rc_hint is not None and rc_hint != 0:
|
|
2414
|
-
rc = rc_hint
|
|
2415
|
-
if exc is None and rc_hint is None:
|
|
2416
|
-
rc = 0 if rc is None or rc != 0 else rc
|
|
2417
|
-
success = rc == 0 and exc is None
|
|
2418
2348
|
|
|
2349
|
+
# Parse RC from log tail if no exception occurred
|
|
2350
|
+
if rc == -1 and not exc:
|
|
2351
|
+
parsed_rc = self._parse_rc_from_text(combined)
|
|
2352
|
+
rc = parsed_rc if parsed_rc is not None else 0
|
|
2353
|
+
elif exc:
|
|
2354
|
+
# Try to parse RC from exception message
|
|
2355
|
+
parsed_rc = self._parse_rc_from_text(str(exc))
|
|
2356
|
+
if parsed_rc is not None:
|
|
2357
|
+
rc = parsed_rc
|
|
2358
|
+
|
|
2359
|
+
success = (rc == 0 and exc is None)
|
|
2419
2360
|
error = None
|
|
2361
|
+
|
|
2420
2362
|
if not success:
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
rc_final = rc_hint if (rc_hint is not None and rc_hint != 0) else (rc if rc not in (-1, None) else rc_hint)
|
|
2424
|
-
line_no = self._parse_line_from_text(combined) if combined else None
|
|
2425
|
-
fallback = (str(exc).strip() if exc is not None else "") or "Stata error"
|
|
2426
|
-
if fallback == "Stata error" and rc_final is not None:
|
|
2427
|
-
fallback = f"Stata error r({rc_final})"
|
|
2428
|
-
message = self._select_stata_error_message(combined, fallback)
|
|
2363
|
+
# Robust extraction
|
|
2364
|
+
msg, context = self._extract_error_and_context(combined, rc)
|
|
2429
2365
|
|
|
2430
2366
|
error = ErrorEnvelope(
|
|
2431
|
-
message=
|
|
2432
|
-
rc=
|
|
2433
|
-
|
|
2367
|
+
message=msg,
|
|
2368
|
+
rc=rc,
|
|
2369
|
+
snippet=context,
|
|
2434
2370
|
command=command,
|
|
2435
|
-
log_path=log_path
|
|
2436
|
-
snippet=snippet,
|
|
2437
|
-
trace=trace or None,
|
|
2371
|
+
log_path=log_path
|
|
2438
2372
|
)
|
|
2439
2373
|
|
|
2440
2374
|
duration = time.time() - start_time
|
|
@@ -2509,4 +2443,4 @@ class StataClient:
|
|
|
2509
2443
|
error=result.error,
|
|
2510
2444
|
)
|
|
2511
2445
|
|
|
2512
|
-
return result
|
|
2446
|
+
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-stata
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.6
|
|
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
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
mcp_stata/__init__.py,sha256=kJKKRn7lGuVCuS2-GaN5VoVcvnxtNlfuswW_VOlYqwg,98
|
|
2
2
|
mcp_stata/discovery.py,sha256=jQN9uvBNHF_hCCU9k6BDtSdDxiUVpvXcOJwpWYwo55c,17430
|
|
3
3
|
mcp_stata/graph_detector.py,sha256=-dJIU1Dq_c1eQSk4eegUi0gU2N-tFqjFGM0tE1E32KM,16066
|
|
4
|
-
mcp_stata/models.py,sha256=
|
|
4
|
+
mcp_stata/models.py,sha256=EKFawioKBhtZhRQ3pFzrKV99ui9L-qzcAuRYuk0npVg,1235
|
|
5
5
|
mcp_stata/server.py,sha256=PV8ragGMeHT72zgVx5DJp3vt8CPqT8iwdvJ8GXSctds,15989
|
|
6
|
-
mcp_stata/stata_client.py,sha256=
|
|
6
|
+
mcp_stata/stata_client.py,sha256=Yd8SxtLf_JVxeOnqOwEM6lNGdhw4T3v2tjVxpTCf9xU,96397
|
|
7
7
|
mcp_stata/streaming_io.py,sha256=GVaXgTtxx8YLY6RWqdTcO2M3QSqxLsefqkmnlNO1nTI,6974
|
|
8
8
|
mcp_stata/ui_http.py,sha256=w1tYxNuwuhkjyfWHxUnpd1DcVBaakjPkEnWr-Fo1lWo,24193
|
|
9
9
|
mcp_stata/smcl/smcl2html.py,sha256=wi91mOMeV9MCmHtNr0toihNbaiDCNZ_NP6a6xEAzWLM,2624
|
|
10
|
-
mcp_stata-1.7.
|
|
11
|
-
mcp_stata-1.7.
|
|
12
|
-
mcp_stata-1.7.
|
|
13
|
-
mcp_stata-1.7.
|
|
14
|
-
mcp_stata-1.7.
|
|
10
|
+
mcp_stata-1.7.6.dist-info/METADATA,sha256=J9ki7OKvuj3Ve_kmr6sWGkZq3eQg24iDkpwqr5J-iLI,15951
|
|
11
|
+
mcp_stata-1.7.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
mcp_stata-1.7.6.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
|
|
13
|
+
mcp_stata-1.7.6.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
|
14
|
+
mcp_stata-1.7.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|