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 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
- Improved discovery.py with better error handling for intermittent failures.
2
+ Optimized discovery.py with fast auto-discovery and targeted retry logic.
3
3
  Key improvements:
4
- 1. Retry logic for file existence checks
5
- 2. Better diagnostic logging
6
- 3. Fuzzy path matching for common typos
7
- 4. Case-insensitive path resolution on Windows
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 = 3, delay: float = 0.1) -> bool:
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
- if not _exists_with_retry(path): # Use retry logic
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 _exists_with_retry(path): # Use retry logic
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 _exists_with_retry(alt_path): # Use retry logic
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 find_stata_path() -> Tuple[str, str]:
242
+ def find_stata_candidates() -> List[Tuple[str, str]]:
197
243
  """
198
- Attempts to automatically locate the Stata installation path.
199
- Returns (path_to_executable, edition_string).
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 Environment Variable (supports quoted values and directory targets)
231
- raw_env_path = os.environ.get("STATA_PATH")
232
- if raw_env_path:
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(raw_env_path, system)
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
- search_set = []
286
+ candidates_in_dir = []
241
287
  if system == "Windows":
242
- search_set = windows_binaries
243
- elif system == "Linux":
244
- search_set = linux_binaries
245
- elif system == "Darwin":
246
- search_set = [
247
- ("Contents/MacOS/stata-mp", "mp"),
248
- ("Contents/MacOS/stata-se", "se"),
249
- ("Contents/MacOS/stata", "be"),
250
- ("stata-mp", "mp"),
251
- ("stata-se", "se"),
252
- ("stata", "be"),
253
- ]
254
-
255
- for binary, edition in search_set:
256
- candidate = os.path.join(path, binary)
257
- if _is_executable(candidate, system):
258
- logger.info(
259
- "Found Stata via STATA_PATH directory: %s (%s)",
260
- candidate,
261
- edition,
262
- )
263
- return candidate, edition
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
- app_globs = [
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 app_globs:
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 _exists_with_retry(binary_dir): # Use retry logic
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 _exists_with_retry(full_path): # Use retry logic
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 _exists_with_retry(full_path): # Use retry logic
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 _exists_with_retry(full_path): # Use retry logic
485
+ if _exists_fast(full_path):
421
486
  candidates.append((full_path, edition))
422
487
 
423
- candidates = _dedupe_preserve(candidates)
488
+ candidates = _dedupe_preserve(candidates)
424
489
 
425
- for path, edition in candidates:
426
- if not _exists_with_retry(path): # Use retry logic
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
- return path, edition
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:
@@ -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 intentionally avoid using timestamps as the signature, since that makes
37
- every poll look like a modification.
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 or not hasattr(self._stata_client, "stata"):
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
- return ""
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
- try:
103
- from sfi import Macro
104
- self._stata_client.stata.run("quietly graph dir, memory")
105
- self._stata_client.stata.run("global mcp_graph_list `r(list)'")
106
- graph_list_str = Macro.getGlobal("mcp_graph_list")
107
- return graph_list_str.split() if graph_list_str else []
108
- except ImportError:
109
- logger.warning("sfi.Macro not available for fallback graph detection")
110
- return []
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
- self.detector = GraphCreationDetector(stata_client)
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
- for callback in self._cache_callbacks:
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
- for callback in self._cache_callbacks:
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
- for callback in self._cache_callbacks:
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
- for callback in self._cache_callbacks:
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):