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 CHANGED
@@ -8,6 +8,7 @@ class ErrorEnvelope(BaseModel):
8
8
  line: Optional[int] = None
9
9
  command: Optional[str] = None
10
10
  log_path: Optional[str] = None
11
+ context: Optional[str] = None
11
12
  stdout: Optional[str] = None
12
13
  stderr: Optional[str] = None
13
14
  snippet: Optional[str] = None
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 out_buf, err_buf
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
- try:
386
- self.stata.run("global MCP_RC = c(rc)")
387
- from sfi import Macro as Macro2 # type: ignore[import-not-found]
388
- rc_val = Macro2.getGlobal("MCP_RC")
389
- return int(float(rc_val))
390
- except Exception:
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 _build_error_envelope(
490
- self,
491
- command: str,
492
- rc: int,
493
- stdout: str,
494
- stderr: str,
495
- exc: Optional[Exception],
496
- trace: bool,
497
- ) -> ErrorEnvelope:
498
- combined = "\n".join(filter(None, [stdout, stderr, str(exc) if exc else ""])).strip()
499
- rc_hint = self._parse_rc_from_text(combined) if combined else None
500
- 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)
501
- line_no = self._parse_line_from_text(combined) if combined else None
502
- snippet = combined[-800:] if combined else None
503
- fallback = (stderr or (str(exc) if exc else "") or stdout or "Stata error").strip()
504
- if fallback == "Stata error" and rc_final is not None:
505
- fallback = f"Stata error r({rc_final})"
506
- message = self._select_stata_error_message(combined, fallback)
507
- return ErrorEnvelope(
508
- message=message,
509
- rc=rc_final,
510
- line=line_no,
511
- command=command,
512
- stdout=stdout or None,
513
- stderr=stderr or None,
514
- snippet=snippet,
515
- trace=trace or None,
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
- if cwd is not None and not os.path.isdir(cwd):
526
- return CommandResponse(
527
- command=code,
528
- rc=601,
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() as (out_buf, err_buf):
548
- try:
549
- if trace:
550
- self.stata.run("set trace on")
551
- ret = self.stata.run(code, echo=echo)
552
- if isinstance(ret, str) and ret:
553
- ret_text = ret
554
- except Exception as e:
555
- exc = e
556
- finally:
557
- rc = self._read_return_code()
558
- if trace:
559
- try:
560
- self.stata.run("set trace off")
561
- except Exception:
562
- pass
563
- finally:
564
- # Clear execution flag
565
- self._is_executing = False
566
-
567
- stdout = out_buf.getvalue()
568
- # Some PyStata builds return output as a string rather than printing.
569
- if (not stdout or not stdout.strip()) and ret_text:
570
- stdout = ret_text
571
- stderr = err_buf.getvalue()
572
- combined = "\n".join(filter(None, [stdout, stderr, str(exc) if exc else ""])).strip()
573
- rc_hint = self._parse_rc_from_text(combined) if combined else None
574
- if exc is None and rc_hint is not None and rc_hint != 0:
575
- # Prefer r(#) parsed from the current command output when present.
576
- rc = rc_hint
577
- # If no exception and stderr is empty and no r(#) is present, treat rc anomalies as success
578
- # (e.g., stale/spurious c(rc) reads).
579
- if exc is None and (not stderr or not stderr.strip()) and rc_hint is None:
580
- rc = 0 if rc is None or rc != 0 else rc
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="" if not success else stdout,
600
- stderr=None,
601
- success=success,
602
- error=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
- # Pass ret_text as stdout for snippet parsing.
648
- error = self._build_error_envelope(code, rc, ret_text or "", stderr, exc, trace)
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
- rc_hint = self._parse_rc_from_text(combined) if combined else None
783
- if exc is None and rc_hint is not None and rc_hint != 0:
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
- snippet = (tail_text[-800:] if tail_text else None) or (str(exc) if exc else None)
791
- rc_hint = self._parse_rc_from_text(combined) if combined else None
792
- 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)
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=message,
801
- rc=rc_final,
802
- line=line_no,
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
- self.stata.run("set trace off")
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
- # Get initial state (before execution)
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
- logger.debug(f"Successfully cached graph: {graph_name}")
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 as e:
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
- rc_hint = self._parse_rc_from_text(combined) if combined else None
1119
- if exc is None and rc_hint is not None and rc_hint != 0:
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
- snippet = (tail_text[-800:] if tail_text else None) or (str(exc) if exc else None)
1127
- rc_hint = self._parse_rc_from_text(combined) if combined else None
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=message,
1137
- rc=rc_final,
1138
- line=line_no,
1066
+ message=msg,
1067
+ context=context,
1068
+ rc=rc,
1139
1069
  command=command,
1140
1070
  log_path=log_path,
1141
- snippet=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
- with self._temp_cwd(cwd):
2384
- with self._redirect_io_streaming(tee, tee):
2385
- try:
2386
- if trace:
2387
- self.stata.run("set trace on")
2388
- ret = self.stata.run(command, echo=echo)
2389
- # Some PyStata builds return output as a string rather than printing.
2390
- if isinstance(ret, str) and ret:
2391
- try:
2392
- tee.write(ret)
2393
- except Exception:
2394
- pass
2395
- except Exception as e:
2396
- exc = e
2397
- finally:
2398
- rc = self._read_return_code()
2399
- if trace:
2400
- try:
2401
- self.stata.run("set trace off")
2402
- except Exception:
2403
- pass
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
- snippet = (tail_text[-800:] if tail_text else None) or (str(exc) if exc else None)
2422
- rc_hint = self._parse_rc_from_text(combined) if combined else None
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=message,
2432
- rc=rc_final,
2433
- line=line_no,
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
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=QETpYKO3yILy_L6mhouVEanvUIvu4ww_CAAFuiP2YdM,1201
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=06cA5K4vwXc_kNCwIifUL8eSSYsIYtM5zArhJcLcUlo,101267
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.3.dist-info/METADATA,sha256=cOSWlFgl296f5UhvozBLCPpe7tWS7kcVWGBNlnqO2Hs,15951
11
- mcp_stata-1.7.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- mcp_stata-1.7.3.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
13
- mcp_stata-1.7.3.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
14
- mcp_stata-1.7.3.dist-info/RECORD,,
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,,