matlab-mcp-python 1.0.0__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.
@@ -0,0 +1,216 @@
1
+ """MATLAB engine wrapper with lifecycle management.
2
+
3
+ Wraps a single matlab.engine instance with state tracking, health checks,
4
+ and workspace reset capabilities. Uses lazy import so tests can mock the
5
+ matlab.engine module.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import logging
11
+ import time
12
+ from enum import Enum, auto
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class EngineState(Enum):
19
+ STOPPED = auto()
20
+ STARTING = auto()
21
+ IDLE = auto()
22
+ BUSY = auto()
23
+
24
+
25
+ class MatlabEngineWrapper:
26
+ """Wraps a single MATLAB engine instance.
27
+
28
+ Parameters
29
+ ----------
30
+ engine_id:
31
+ Unique identifier for this engine (e.g., ``"engine-0"``).
32
+ pool_config:
33
+ ``PoolConfig`` instance (used for matlab_root if needed).
34
+ workspace_config:
35
+ ``WorkspaceConfig`` instance with default_paths and startup_commands.
36
+ """
37
+
38
+ def __init__(self, engine_id: str, pool_config: Any, workspace_config: Any) -> None:
39
+ self.engine_id = engine_id
40
+ self._pool_config = pool_config
41
+ self._workspace_config = workspace_config
42
+
43
+ self._engine: Any = None
44
+ self._state: EngineState = EngineState.STOPPED
45
+ self._idle_since: float = time.monotonic()
46
+
47
+ # ------------------------------------------------------------------
48
+ # State properties
49
+ # ------------------------------------------------------------------
50
+
51
+ @property
52
+ def state(self) -> EngineState:
53
+ return self._state
54
+
55
+ @property
56
+ def idle_seconds(self) -> float:
57
+ """Seconds since the engine last became idle (0 if not idle)."""
58
+ if self._state == EngineState.IDLE:
59
+ return time.monotonic() - self._idle_since
60
+ return 0.0
61
+
62
+ @property
63
+ def is_alive(self) -> bool:
64
+ """True if the underlying engine object exists and reports alive."""
65
+ if self._engine is None:
66
+ return False
67
+ alive_attr = getattr(self._engine, "is_alive", None)
68
+ if alive_attr is None:
69
+ # Real matlab.engine doesn't have is_alive — assume alive
70
+ return True
71
+ if callable(alive_attr):
72
+ return bool(alive_attr())
73
+ return bool(alive_attr)
74
+
75
+ # ------------------------------------------------------------------
76
+ # Lifecycle
77
+ # ------------------------------------------------------------------
78
+
79
+ def _get_matlab_engine_module(self) -> Any:
80
+ """Lazily import matlab.engine so tests can mock it."""
81
+ return importlib.import_module("matlab.engine")
82
+
83
+ def start(self) -> None:
84
+ """Start the MATLAB engine and apply default paths and startup commands."""
85
+ logger.info("[%s] Starting MATLAB engine", self.engine_id)
86
+ self._state = EngineState.STARTING
87
+
88
+ matlab_engine = self._get_matlab_engine_module()
89
+ self._engine = matlab_engine.start_matlab()
90
+
91
+ # Apply default workspace paths
92
+ for path in self._workspace_config.default_paths:
93
+ try:
94
+ self._engine.addpath(path)
95
+ except Exception:
96
+ logger.warning("[%s] Failed to addpath: %s", self.engine_id, path)
97
+
98
+ # Run startup commands
99
+ for cmd in self._workspace_config.startup_commands:
100
+ try:
101
+ self._engine.eval(cmd, nargout=0)
102
+ except Exception:
103
+ logger.warning("[%s] Startup command failed: %s", self.engine_id, cmd)
104
+
105
+ self._state = EngineState.IDLE
106
+ self._idle_since = time.monotonic()
107
+ logger.info("[%s] MATLAB engine started", self.engine_id)
108
+
109
+ def stop(self) -> None:
110
+ """Quit the MATLAB engine."""
111
+ if self._engine is not None:
112
+ try:
113
+ self._engine.quit()
114
+ except Exception:
115
+ logger.warning("[%s] Exception during engine quit", self.engine_id)
116
+ finally:
117
+ self._engine = None
118
+ self._state = EngineState.STOPPED
119
+ logger.info("[%s] MATLAB engine stopped", self.engine_id)
120
+
121
+ # ------------------------------------------------------------------
122
+ # Operations
123
+ # ------------------------------------------------------------------
124
+
125
+ def health_check(self) -> bool:
126
+ """Run a trivial eval to confirm the engine is responsive."""
127
+ if self._engine is None:
128
+ return False
129
+ try:
130
+ self._engine.eval("1", nargout=0)
131
+ return True
132
+ except Exception:
133
+ return False
134
+
135
+ def execute(self, code: str, nargout: int = 0, background: bool = False,
136
+ stdout: Any = None, stderr: Any = None) -> Any:
137
+ """Run MATLAB code on the engine.
138
+
139
+ Parameters
140
+ ----------
141
+ code: MATLAB code to evaluate.
142
+ nargout: Number of output arguments expected.
143
+ background: If True, return a future-like object immediately.
144
+ stdout: Stream to capture standard output (e.g. ``io.StringIO``).
145
+ stderr: Stream to capture standard error (e.g. ``io.StringIO``).
146
+ """
147
+ if self._engine is None:
148
+ raise RuntimeError(f"[{self.engine_id}] Engine is not started")
149
+ kwargs: dict[str, Any] = {"nargout": nargout, "background": background}
150
+ if stdout is not None:
151
+ kwargs["stdout"] = stdout
152
+ if stderr is not None:
153
+ kwargs["stderr"] = stderr
154
+ return self._engine.eval(code, **kwargs)
155
+
156
+ def reset_workspace(self) -> None:
157
+ """Reset the MATLAB workspace to a clean state.
158
+
159
+ Sequence: clear all, clear global, clear functions, fclose all,
160
+ restoredefaultpath, re-add configured paths, re-run startup commands.
161
+ """
162
+ if self._engine is None:
163
+ raise RuntimeError(f"[{self.engine_id}] Engine is not started")
164
+
165
+ cleanup_commands = [
166
+ "clear all",
167
+ "clear global",
168
+ "clear functions",
169
+ "fclose all",
170
+ ]
171
+ for cmd in cleanup_commands:
172
+ try:
173
+ self._engine.eval(cmd, nargout=0)
174
+ except Exception:
175
+ logger.warning("[%s] Reset command failed: %s", self.engine_id, cmd)
176
+
177
+ try:
178
+ self._engine.restoredefaultpath()
179
+ except Exception:
180
+ logger.warning("[%s] restoredefaultpath failed", self.engine_id)
181
+
182
+ # Re-apply configured paths
183
+ for path in self._workspace_config.default_paths:
184
+ try:
185
+ self._engine.addpath(path)
186
+ except Exception:
187
+ logger.warning("[%s] Re-addpath failed: %s", self.engine_id, path)
188
+
189
+ # Re-run startup commands
190
+ for cmd in self._workspace_config.startup_commands:
191
+ try:
192
+ self._engine.eval(cmd, nargout=0)
193
+ except Exception:
194
+ logger.warning("[%s] Startup command failed on reset: %s", self.engine_id, cmd)
195
+
196
+ logger.debug("[%s] Workspace reset complete", self.engine_id)
197
+
198
+ # ------------------------------------------------------------------
199
+ # State transitions
200
+ # ------------------------------------------------------------------
201
+
202
+ def mark_busy(self) -> None:
203
+ """Transition engine to BUSY state."""
204
+ self._state = EngineState.BUSY
205
+
206
+ def mark_idle(self) -> None:
207
+ """Transition engine to IDLE state and record timestamp."""
208
+ self._state = EngineState.IDLE
209
+ self._idle_since = time.monotonic()
210
+
211
+ # ------------------------------------------------------------------
212
+ # Dunder
213
+ # ------------------------------------------------------------------
214
+
215
+ def __repr__(self) -> str: # pragma: no cover
216
+ return f"MatlabEngineWrapper(id={self.engine_id!r}, state={self._state.name})"
@@ -0,0 +1,227 @@
1
+ """MATLAB engine pool manager.
2
+
3
+ Manages a pool of ``MatlabEngineWrapper`` instances, handling:
4
+ - Startup of a minimum number of engines in parallel
5
+ - Acquire/release with queueing when all engines are busy
6
+ - Scale-up on demand up to max_engines
7
+ - Scale-down of idle engines beyond min_engines
8
+ - Periodic health checks that replace dead engines
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from typing import Any, Dict, List
15
+
16
+ from matlab_mcp.pool.engine import EngineState, MatlabEngineWrapper
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class EnginePoolManager:
22
+ """Manages a pool of MATLAB engine wrappers.
23
+
24
+ Parameters
25
+ ----------
26
+ config:
27
+ ``AppConfig`` instance. Uses ``config.pool`` and ``config.workspace``.
28
+ """
29
+
30
+ def __init__(self, config: Any, collector: Any = None) -> None:
31
+ self._config = config
32
+ self._pool_config = config.pool
33
+ self._workspace_config = config.workspace
34
+ self._collector = collector
35
+
36
+ # All engines (available + busy)
37
+ self._all_engines: List[MatlabEngineWrapper] = []
38
+ # Queue of engines available for acquisition
39
+ self._available: asyncio.Queue = asyncio.Queue()
40
+ # Lock to guard scale-up logic
41
+ self._scale_lock: asyncio.Lock = asyncio.Lock()
42
+ self._next_id: int = 0
43
+
44
+ # ------------------------------------------------------------------
45
+ # Internal helpers
46
+ # ------------------------------------------------------------------
47
+
48
+ def _make_engine(self) -> MatlabEngineWrapper:
49
+ engine_id = f"engine-{self._next_id}"
50
+ self._next_id += 1
51
+ return MatlabEngineWrapper(engine_id, self._pool_config, self._workspace_config)
52
+
53
+ async def _start_engine_async(self) -> MatlabEngineWrapper:
54
+ """Start a single engine in a thread executor and return it."""
55
+ loop = asyncio.get_running_loop()
56
+ engine = self._make_engine()
57
+ self._all_engines.append(engine)
58
+ await loop.run_in_executor(None, engine.start)
59
+ return engine
60
+
61
+ # ------------------------------------------------------------------
62
+ # Public API
63
+ # ------------------------------------------------------------------
64
+
65
+ async def start(self) -> None:
66
+ """Start min_engines engines in parallel."""
67
+ min_engines = self._pool_config.min_engines
68
+ logger.info("Starting pool with %d engine(s)", min_engines)
69
+
70
+ tasks = [self._start_engine_async() for _ in range(min_engines)]
71
+ engines = await asyncio.gather(*tasks)
72
+
73
+ for engine in engines:
74
+ await self._available.put(engine)
75
+
76
+ logger.info("Pool started: %d engine(s) ready", min_engines)
77
+
78
+ async def acquire(self) -> MatlabEngineWrapper:
79
+ """Return an available engine, scaling up if needed.
80
+
81
+ Blocks if the pool is at max capacity and all engines are busy.
82
+ """
83
+ # Try to get an immediately available engine
84
+ try:
85
+ engine = self._available.get_nowait()
86
+ engine.mark_busy()
87
+ logger.info("Acquired engine %s (available=%d, busy=%d)",
88
+ engine.engine_id, self._available.qsize(),
89
+ len(self._all_engines) - self._available.qsize())
90
+ return engine
91
+ except asyncio.QueueEmpty:
92
+ pass
93
+
94
+ # Attempt scale-up under a lock to avoid races
95
+ async with self._scale_lock:
96
+ total = len(self._all_engines)
97
+ if total < self._pool_config.max_engines:
98
+ logger.info("Scaling up pool: starting new engine (%d/%d)",
99
+ total + 1, self._pool_config.max_engines)
100
+ engine = await self._start_engine_async()
101
+ engine.mark_busy()
102
+ if self._collector:
103
+ self._collector.record_event("engine_scale_up", {"engine_id": engine.engine_id, "total_after": len(self._all_engines)})
104
+ return engine
105
+
106
+ # At max capacity — wait for one to become available
107
+ logger.warning("Pool at max capacity (%d/%d busy) — waiting for available engine",
108
+ len(self._all_engines), self._pool_config.max_engines)
109
+ engine = await self._available.get()
110
+ engine.mark_busy()
111
+ logger.info("Acquired engine %s after wait (pool was full)", engine.engine_id)
112
+ return engine
113
+
114
+ async def release(self, engine: MatlabEngineWrapper) -> None:
115
+ """Return an engine to the pool."""
116
+ logger.info("Releasing engine %s — resetting workspace", engine.engine_id)
117
+ loop = asyncio.get_running_loop()
118
+ await loop.run_in_executor(None, engine.reset_workspace)
119
+ engine.mark_idle()
120
+ await self._available.put(engine)
121
+ logger.info("Engine %s returned to pool (available=%d)",
122
+ engine.engine_id, self._available.qsize())
123
+
124
+ async def stop(self) -> None:
125
+ """Stop all engines in the pool."""
126
+ logger.info("Stopping pool (%d engines)", len(self._all_engines))
127
+ loop = asyncio.get_running_loop()
128
+ stop_tasks = [
129
+ loop.run_in_executor(None, engine.stop)
130
+ for engine in self._all_engines
131
+ ]
132
+ await asyncio.gather(*stop_tasks, return_exceptions=True)
133
+ self._all_engines.clear()
134
+ # Drain the queue
135
+ while not self._available.empty():
136
+ try:
137
+ self._available.get_nowait()
138
+ except asyncio.QueueEmpty:
139
+ break
140
+ logger.info("Pool stopped")
141
+
142
+ async def run_health_checks(self) -> None:
143
+ """Check idle engines, replace dead ones, scale down excess idle engines.
144
+
145
+ - Engines that fail health check are stopped and replaced.
146
+ - Idle engines beyond min_engines are stopped and removed.
147
+ """
148
+ loop = asyncio.get_running_loop()
149
+ min_engines = self._pool_config.min_engines
150
+ idle_timeout = self._pool_config.scale_down_idle_timeout
151
+
152
+ # Collect currently available (idle) engines by draining the queue
153
+ idle_engines: List[MatlabEngineWrapper] = []
154
+ while not self._available.empty():
155
+ try:
156
+ idle_engines.append(self._available.get_nowait())
157
+ except asyncio.QueueEmpty:
158
+ break
159
+
160
+ to_replace: List[MatlabEngineWrapper] = []
161
+ to_remove: List[MatlabEngineWrapper] = []
162
+ to_keep: List[MatlabEngineWrapper] = []
163
+
164
+ # How many busy engines do we have?
165
+ busy_count = sum(1 for e in self._all_engines if e.state == EngineState.BUSY)
166
+ idle_count = len(idle_engines)
167
+ total = busy_count + idle_count
168
+
169
+ for engine in idle_engines:
170
+ # Check health
171
+ healthy = await loop.run_in_executor(None, engine.health_check)
172
+ if not healthy:
173
+ logger.warning("[%s] Health check failed; replacing", engine.engine_id)
174
+ if self._collector:
175
+ self._collector.record_event("health_check_fail", {"engine_id": engine.engine_id, "error": "health check failed"})
176
+ to_replace.append(engine)
177
+ continue
178
+
179
+ # Scale down if idle beyond timeout and above min
180
+ if (engine.idle_seconds > idle_timeout
181
+ and total > min_engines
182
+ and len(to_keep) + busy_count >= min_engines):
183
+ logger.info("[%s] Scaling down idle engine (idle %.0fs)",
184
+ engine.engine_id, engine.idle_seconds)
185
+ if self._collector:
186
+ self._collector.record_event("engine_scale_down", {"engine_id": engine.engine_id, "total_after": total - 1})
187
+ to_remove.append(engine)
188
+ total -= 1
189
+ else:
190
+ to_keep.append(engine)
191
+
192
+ # Stop engines to remove / replace
193
+ engines_to_stop = to_replace + to_remove
194
+ stop_tasks = [loop.run_in_executor(None, e.stop) for e in engines_to_stop]
195
+ await asyncio.gather(*stop_tasks, return_exceptions=True)
196
+ for e in engines_to_stop:
197
+ try:
198
+ self._all_engines.remove(e)
199
+ except ValueError:
200
+ pass
201
+
202
+ # Start replacement engines for failed ones
203
+ replacement_tasks = [self._start_engine_async() for _ in to_replace]
204
+ new_engines = await asyncio.gather(*replacement_tasks, return_exceptions=True)
205
+
206
+ # Return healthy engines + replacements to queue
207
+ for engine in to_keep:
208
+ await self._available.put(engine)
209
+ for old_engine, new_engine in zip(to_replace, new_engines):
210
+ if isinstance(new_engine, Exception):
211
+ logger.error("Replacement engine failed to start: %s", new_engine)
212
+ else:
213
+ if self._collector:
214
+ self._collector.record_event("engine_replaced", {"old_id": old_engine.engine_id, "new_id": new_engine.engine_id})
215
+ await self._available.put(new_engine)
216
+
217
+ def get_status(self) -> Dict[str, int]:
218
+ """Return pool status summary."""
219
+ total = len(self._all_engines)
220
+ available = self._available.qsize()
221
+ busy = total - available
222
+ return {
223
+ "total": total,
224
+ "available": available,
225
+ "busy": max(busy, 0),
226
+ "max": self._pool_config.max_engines,
227
+ }
File without changes
@@ -0,0 +1,154 @@
1
+ """Security validator for MATLAB code and filenames.
2
+
3
+ Provides:
4
+ - ``BlockedFunctionError`` — raised when blocked MATLAB code is detected.
5
+ - ``SecurityValidator`` — checks code and sanitizes filenames.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import re
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class BlockedFunctionError(Exception):
17
+ """Raised when MATLAB code contains a blocked function or construct."""
18
+
19
+
20
+ # Precompiled patterns for _strip_string_literals
21
+ _DOUBLE_QUOTED_RE = re.compile(r'"[^"\n]*"')
22
+ _SINGLE_QUOTED_RE = re.compile(r"(?<![a-zA-Z0-9_\)\]])'[^'\n]*'")
23
+ _COMMENT_RE = re.compile(r'%')
24
+
25
+
26
+ class SecurityValidator:
27
+ """Validates MATLAB code and filenames against a security policy.
28
+
29
+ Parameters
30
+ ----------
31
+ security_config:
32
+ ``SecurityConfig`` instance with ``blocked_functions_enabled`` and
33
+ ``blocked_functions`` attributes.
34
+ """
35
+
36
+ def __init__(self, security_config: Any, collector: Any = None) -> None:
37
+ self._config = security_config
38
+ self._collector = collector
39
+
40
+ # Precompile blocked-function patterns for check_code hot path
41
+ self._call_patterns: dict[str, re.Pattern] = {}
42
+ self._cmd_patterns: dict[str, re.Pattern] = {}
43
+ for func in security_config.blocked_functions:
44
+ if func != "!":
45
+ self._call_patterns[func] = re.compile(rf"\b{re.escape(func)}\s*\(")
46
+ self._cmd_patterns[func] = re.compile(rf"(?:^|;)\s*{re.escape(func)}\s+\S", re.MULTILINE)
47
+
48
+ # ------------------------------------------------------------------
49
+ # Code checking
50
+ # ------------------------------------------------------------------
51
+
52
+ @staticmethod
53
+ def _strip_string_literals(code: str) -> str:
54
+ """Remove MATLAB string literals and comments to avoid false positives.
55
+
56
+ Processing order per line:
57
+ 1. Remove double-quoted strings "..."
58
+ 2. Remove single-quoted strings '...' (MATLAB char arrays)
59
+ - A quote preceded by [a-zA-Z0-9_)] is a transpose operator, not a string.
60
+ 3. Remove MATLAB comments (% to end of line)
61
+
62
+ Note: This is a best-effort heuristic; it handles the common cases
63
+ tested without a full MATLAB parser.
64
+ """
65
+ processed_lines = []
66
+ for line in code.splitlines():
67
+ line = _DOUBLE_QUOTED_RE.sub('""', line)
68
+ line = _SINGLE_QUOTED_RE.sub("''", line)
69
+ comment_match = _COMMENT_RE.search(line)
70
+ if comment_match:
71
+ line = line[:comment_match.start()]
72
+ processed_lines.append(line)
73
+ return "\n".join(processed_lines)
74
+
75
+ def check_code(self, code: str) -> None:
76
+ """Scan *code* for blocked functions/constructs.
77
+
78
+ Parameters
79
+ ----------
80
+ code:
81
+ MATLAB source code to check.
82
+
83
+ Raises
84
+ ------
85
+ BlockedFunctionError
86
+ If a blocked construct is found (and blocking is enabled).
87
+ """
88
+ if not self._config.blocked_functions_enabled:
89
+ logger.debug("Security check skipped (blocked_functions disabled)")
90
+ return
91
+
92
+ # Strip string literals to avoid false positives
93
+ sanitized = self._strip_string_literals(code)
94
+
95
+ for func in self._config.blocked_functions:
96
+ if func == "!":
97
+ # Shell escape: lines starting with ! (after optional whitespace)
98
+ for line in sanitized.splitlines():
99
+ if line.lstrip().startswith("!"):
100
+ logger.warning("BLOCKED: shell escape '!' in code: %s", repr(code[:120]))
101
+ if self._collector:
102
+ self._collector.record_event("blocked_function", {"function": "!"})
103
+ raise BlockedFunctionError(
104
+ "Shell escape '!' is not allowed"
105
+ )
106
+ else:
107
+ call_re = self._call_patterns[func]
108
+ cmd_re = self._cmd_patterns[func]
109
+ if call_re.search(sanitized) or cmd_re.search(sanitized):
110
+ logger.warning("BLOCKED: function '%s' in code: %s", func, repr(code[:120]))
111
+ if self._collector:
112
+ self._collector.record_event("blocked_function", {"function": func})
113
+ raise BlockedFunctionError(
114
+ f"Function '{func}' is not allowed"
115
+ )
116
+
117
+ # ------------------------------------------------------------------
118
+ # Filename sanitization
119
+ # ------------------------------------------------------------------
120
+
121
+ _SAFE_FILENAME_RE = re.compile(r'^[a-zA-Z0-9._-]+$')
122
+
123
+ def sanitize_filename(self, filename: str) -> str:
124
+ """Validate and return *filename* if safe.
125
+
126
+ Parameters
127
+ ----------
128
+ filename:
129
+ Proposed filename (basename only, no directory separators).
130
+
131
+ Returns
132
+ -------
133
+ str
134
+ The filename unchanged if it passes validation.
135
+
136
+ Raises
137
+ ------
138
+ ValueError
139
+ If the filename is empty, contains path traversal (``..``),
140
+ or contains characters outside ``[a-zA-Z0-9._-]``.
141
+ """
142
+ if not filename:
143
+ raise ValueError("Filename must not be empty")
144
+
145
+ if ".." in filename:
146
+ raise ValueError(f"Path traversal not allowed in filename: {filename!r}")
147
+
148
+ if not self._SAFE_FILENAME_RE.match(filename):
149
+ raise ValueError(
150
+ f"Filename contains invalid characters: {filename!r}. "
151
+ "Only [a-zA-Z0-9._-] are allowed."
152
+ )
153
+
154
+ return filename