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.
File without changes
@@ -0,0 +1,210 @@
1
+ """Session manager for MATLAB MCP Server.
2
+
3
+ Manages the lifecycle of user sessions, each of which owns a temporary
4
+ directory and a set of associated jobs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import shutil
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _DEFAULT_SESSION_ID = "default"
20
+
21
+
22
+ @dataclass
23
+ class Session:
24
+ """Represents a single user session.
25
+
26
+ Parameters
27
+ ----------
28
+ session_id:
29
+ Unique identifier for this session.
30
+ temp_dir:
31
+ Path to the session's temporary working directory.
32
+ """
33
+
34
+ session_id: str
35
+ temp_dir: str
36
+ created_at: float = field(default_factory=time.time)
37
+ last_active: float = field(default_factory=time.time)
38
+
39
+ # ------------------------------------------------------------------
40
+ # Methods
41
+ # ------------------------------------------------------------------
42
+
43
+ def touch(self) -> None:
44
+ """Update last_active to the current time."""
45
+ self.last_active = time.time()
46
+
47
+ @property
48
+ def idle_seconds(self) -> float:
49
+ """Seconds since the session was last active."""
50
+ return time.time() - self.last_active
51
+
52
+
53
+ class SessionManager:
54
+ """Manages the lifecycle of user sessions.
55
+
56
+ Parameters
57
+ ----------
58
+ config:
59
+ The full :class:`~matlab_mcp.config.AppConfig` instance.
60
+ """
61
+
62
+ def __init__(self, config: Any = None, collector: Any = None) -> None:
63
+ self._config = config
64
+ self._collector = collector
65
+ self._sessions: Dict[str, Session] = {}
66
+
67
+ # Derive limits from config or use sensible defaults
68
+ if config is not None:
69
+ self._max_sessions: int = config.sessions.max_sessions
70
+ self._session_timeout: int = config.sessions.session_timeout
71
+ base_temp: str = config.execution.temp_dir
72
+ else:
73
+ self._max_sessions = 50
74
+ self._session_timeout = 3600
75
+ base_temp = "/tmp/matlab_mcp"
76
+
77
+ self._base_temp = Path(base_temp)
78
+ self._lock = threading.Lock()
79
+
80
+ # ------------------------------------------------------------------
81
+ # Public API
82
+ # ------------------------------------------------------------------
83
+
84
+ def create_session(self, *, session_id: Optional[str] = None) -> Session:
85
+ """Create a new session with a temporary directory.
86
+
87
+ Parameters
88
+ ----------
89
+ session_id
90
+ Optional explicit ID for the session. When *None* (the default),
91
+ a random UUID is generated.
92
+
93
+ Raises
94
+ ------
95
+ RuntimeError
96
+ If the maximum number of sessions has been reached.
97
+ """
98
+ with self._lock:
99
+ if len(self._sessions) >= self._max_sessions:
100
+ raise RuntimeError(
101
+ f"Maximum number of sessions reached ({self._max_sessions})"
102
+ )
103
+
104
+ session_id = session_id or str(uuid.uuid4())
105
+ temp_dir = self._base_temp / session_id
106
+ temp_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ session = Session(session_id=session_id, temp_dir=str(temp_dir))
109
+ self._sessions[session_id] = session
110
+ logger.info("Session created: %s (temp_dir=%s, total=%d/%d)",
111
+ session_id[:8], temp_dir, len(self._sessions), self._max_sessions)
112
+ if self._collector:
113
+ self._collector.record_event("session_created", {"session_id_short": session.session_id[-8:]})
114
+ return session
115
+
116
+ def get_session(self, session_id: str) -> Optional[Session]:
117
+ """Return the session with the given ID, or None if not found."""
118
+ with self._lock:
119
+ return self._sessions.get(session_id)
120
+
121
+ def get_or_create_default(self) -> Session:
122
+ """Return (or create) the default session for single-user stdio mode."""
123
+ with self._lock:
124
+ session = self._sessions.get(_DEFAULT_SESSION_ID)
125
+ if session is not None:
126
+ return session
127
+ return self.create_session(session_id=_DEFAULT_SESSION_ID)
128
+
129
+ def destroy_session(self, session_id: str) -> bool:
130
+ """Destroy a session and remove its temporary directory.
131
+
132
+ Returns True if the session existed and was destroyed, False otherwise.
133
+ """
134
+ with self._lock:
135
+ session = self._sessions.pop(session_id, None)
136
+ remaining = len(self._sessions)
137
+ if session is None:
138
+ return False
139
+
140
+ idle_s = session.idle_seconds
141
+ logger.info("Destroying session %s (idle=%.0fs, remaining=%d)",
142
+ session_id[:8], idle_s, remaining)
143
+
144
+ # Clean up temp directory
145
+ temp_path = Path(session.temp_dir)
146
+ if temp_path.exists():
147
+ try:
148
+ shutil.rmtree(temp_path)
149
+ logger.info("Removed temp dir %s for session %s", temp_path, session_id[:8])
150
+ except Exception:
151
+ logger.warning(
152
+ "Failed to remove temp dir %s for session %s",
153
+ temp_path,
154
+ session_id[:8],
155
+ )
156
+ return True
157
+
158
+ def cleanup_expired(
159
+ self,
160
+ has_active_jobs_fn: Optional[Callable[[str], bool]] = None,
161
+ ) -> int:
162
+ """Remove sessions that have been idle beyond the session timeout.
163
+
164
+ Sessions with active jobs are skipped.
165
+
166
+ Parameters
167
+ ----------
168
+ has_active_jobs_fn:
169
+ A callable that takes a session_id and returns True if the session
170
+ has active (PENDING or RUNNING) jobs. If None, all idle sessions
171
+ are eligible for removal.
172
+
173
+ Returns
174
+ -------
175
+ int
176
+ The number of sessions removed.
177
+ """
178
+ # Collect idle candidates under lock, then check external callback outside
179
+ # to avoid holding self._lock while calling into JobTracker.
180
+ with self._lock:
181
+ candidates = [
182
+ sid for sid, s in self._sessions.items()
183
+ if s.idle_seconds >= self._session_timeout
184
+ ]
185
+
186
+ to_destroy = []
187
+ for session_id in candidates:
188
+ if has_active_jobs_fn is not None and has_active_jobs_fn(session_id):
189
+ logger.debug(
190
+ "Skipping cleanup of session %s — has active jobs", session_id
191
+ )
192
+ continue
193
+ to_destroy.append(session_id)
194
+
195
+ for session_id in to_destroy:
196
+ self.destroy_session(session_id)
197
+
198
+ if to_destroy:
199
+ logger.info("Cleaned up %d expired sessions", len(to_destroy))
200
+ return len(to_destroy)
201
+
202
+ # ------------------------------------------------------------------
203
+ # Properties
204
+ # ------------------------------------------------------------------
205
+
206
+ @property
207
+ def session_count(self) -> int:
208
+ """Current number of active sessions."""
209
+ with self._lock:
210
+ return len(self._sessions)
File without changes
@@ -0,0 +1,28 @@
1
+ """Admin MCP tool implementations.
2
+
3
+ Provides:
4
+ - get_pool_status_impl — delegate to pool.get_status()
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def get_pool_status_impl(pool: Any) -> dict:
15
+ """Return the current status of the engine pool.
16
+
17
+ Parameters
18
+ ----------
19
+ pool:
20
+ An :class:`~matlab_mcp.pool.manager.EnginePoolManager` instance
21
+ (or any object with a ``get_status()`` method).
22
+
23
+ Returns
24
+ -------
25
+ dict
26
+ Status summary with ``total``, ``available``, ``busy``, and ``max`` keys.
27
+ """
28
+ return pool.get_status()
@@ -0,0 +1,144 @@
1
+ """Core MCP tool implementations for MATLAB MCP Server.
2
+
3
+ Provides the primary execution tools:
4
+ - execute_code_impl — run MATLAB code (with security check)
5
+ - check_code_impl — lint MATLAB code via checkcode/mlint
6
+ - get_workspace_impl — retrieve workspace variables via 'whos'
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from matlab_mcp.security.validator import BlockedFunctionError
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def execute_code_impl(
21
+ code: str,
22
+ session_id: str,
23
+ executor: Any,
24
+ security: Any,
25
+ temp_dir: Optional[str] = None,
26
+ ) -> dict:
27
+ """Execute MATLAB code with security check.
28
+
29
+ Parameters
30
+ ----------
31
+ code:
32
+ MATLAB source code to execute.
33
+ session_id:
34
+ ID of the owning session.
35
+ executor:
36
+ A :class:`~matlab_mcp.jobs.executor.JobExecutor` instance.
37
+ security:
38
+ A :class:`~matlab_mcp.security.validator.SecurityValidator` instance.
39
+
40
+ Returns
41
+ -------
42
+ dict
43
+ Result dict with at minimum ``status`` and ``job_id`` keys.
44
+ On security violation returns ``{"status": "failed", "error": {...}}``.
45
+ """
46
+ # Check security blocklist first
47
+ try:
48
+ security.check_code(code)
49
+ except BlockedFunctionError as exc:
50
+ return {
51
+ "status": "failed",
52
+ "error": {
53
+ "type": "ValidationError",
54
+ "message": f"Blocked: {exc}",
55
+ "matlab_id": None,
56
+ "stack_trace": None,
57
+ },
58
+ }
59
+
60
+ # Delegate to executor
61
+ return await executor.execute(session_id=session_id, code=code, temp_dir=temp_dir)
62
+
63
+
64
+ async def check_code_impl(
65
+ code: str,
66
+ session_id: str,
67
+ executor: Any,
68
+ temp_dir: str,
69
+ ) -> dict:
70
+ """Run checkcode/mlint on MATLAB code.
71
+
72
+ Writes *code* to a temporary ``.m`` file, calls ``mcp_checkcode()`` via
73
+ the executor, parses the JSON result, and cleans up the temp file.
74
+
75
+ Parameters
76
+ ----------
77
+ code:
78
+ MATLAB source code to check.
79
+ session_id:
80
+ ID of the owning session.
81
+ executor:
82
+ A :class:`~matlab_mcp.jobs.executor.JobExecutor` instance.
83
+ temp_dir:
84
+ Temporary directory path for writing the ``.m`` file.
85
+
86
+ Returns
87
+ -------
88
+ dict
89
+ Parsed result from ``mcp_checkcode`` or an error dict.
90
+ """
91
+ # Write code to a temp .m file
92
+ td = Path(temp_dir)
93
+ td.mkdir(parents=True, exist_ok=True)
94
+
95
+ tmp_file = td / f"_check_{session_id}.m"
96
+ try:
97
+ tmp_file.write_text(code, encoding="utf-8")
98
+
99
+ # Build the MATLAB call
100
+ escaped_path = str(tmp_file).replace("\\", "\\\\").replace("'", "''")
101
+ matlab_cmd = f"mcp_checkcode('{escaped_path}')"
102
+
103
+ result = await executor.execute(session_id=session_id, code=matlab_cmd)
104
+
105
+ # Try to parse JSON from the output text
106
+ if result.get("status") == "completed":
107
+ raw_text = result.get("text", "")
108
+ try:
109
+ parsed = json.loads(raw_text)
110
+ return {"status": "completed", "issues": parsed}
111
+ except (json.JSONDecodeError, ValueError):
112
+ # Return raw text if not valid JSON
113
+ return {"status": "completed", "issues": [], "raw": raw_text}
114
+
115
+ return result
116
+ finally:
117
+ # Clean up temp file
118
+ try:
119
+ if tmp_file.exists():
120
+ tmp_file.unlink()
121
+ except Exception:
122
+ logger.debug("Failed to remove temp file %s", tmp_file)
123
+
124
+
125
+ async def get_workspace_impl(
126
+ session_id: str,
127
+ executor: Any,
128
+ ) -> dict:
129
+ """Get workspace variables via 'whos'.
130
+
131
+ Parameters
132
+ ----------
133
+ session_id:
134
+ ID of the owning session.
135
+ executor:
136
+ A :class:`~matlab_mcp.jobs.executor.JobExecutor` instance.
137
+
138
+ Returns
139
+ -------
140
+ dict
141
+ Result dict with variables list or raw whos output.
142
+ """
143
+ result = await executor.execute(session_id=session_id, code="whos")
144
+ return result
@@ -0,0 +1,241 @@
1
+ """Custom tool support for MATLAB MCP Server.
2
+
3
+ Provides:
4
+ - CustomToolParam — pydantic model for a tool parameter definition
5
+ - CustomToolDef — pydantic model for a complete custom tool definition
6
+ - load_custom_tools — load custom tools from a YAML config file
7
+ - make_custom_tool_handler — factory that creates a typed async handler function
8
+ with a proper ``inspect.Signature`` so FastMCP can introspect it correctly.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, Field
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Pydantic models
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _TYPE_MAP: Dict[str, type] = {
27
+ "str": str,
28
+ "string": str,
29
+ "int": int,
30
+ "integer": int,
31
+ "float": float,
32
+ "number": float,
33
+ "bool": bool,
34
+ "boolean": bool,
35
+ "list": list,
36
+ "dict": dict,
37
+ "any": Any,
38
+ }
39
+
40
+
41
+ class CustomToolParam(BaseModel):
42
+ """Definition of a single parameter for a custom tool.
43
+
44
+ Parameters
45
+ ----------
46
+ name:
47
+ Parameter name (valid Python identifier).
48
+ type:
49
+ Parameter type as a string (e.g. ``"str"``, ``"int"``, ``"float"``).
50
+ required:
51
+ Whether the parameter is required. Defaults to True.
52
+ default:
53
+ Default value when ``required`` is False.
54
+ """
55
+
56
+ name: str
57
+ type: str = "str"
58
+ required: bool = True
59
+ default: Optional[Any] = None
60
+
61
+
62
+ class CustomToolDef(BaseModel):
63
+ """Definition of a custom MATLAB-backed tool.
64
+
65
+ Parameters
66
+ ----------
67
+ name:
68
+ Tool name (used as the MCP tool name).
69
+ matlab_function:
70
+ MATLAB function to call when this tool is invoked.
71
+ description:
72
+ Human-readable description shown in MCP tool listings.
73
+ parameters:
74
+ List of :class:`CustomToolParam` definitions.
75
+ returns:
76
+ Description of the return value.
77
+ """
78
+
79
+ name: str
80
+ matlab_function: str
81
+ description: str = ""
82
+ parameters: List[CustomToolParam] = Field(default_factory=list)
83
+ returns: str = ""
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Loader
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def load_custom_tools(config_path: str) -> List[CustomToolDef]:
91
+ """Load custom tool definitions from a YAML file.
92
+
93
+ Parameters
94
+ ----------
95
+ config_path:
96
+ Path to the YAML configuration file.
97
+
98
+ Returns
99
+ -------
100
+ list of CustomToolDef
101
+ Parsed tool definitions, or an empty list if the file does not exist
102
+ or contains no ``tools`` section.
103
+ """
104
+ path = Path(config_path)
105
+ if not path.exists():
106
+ logger.debug("Custom tools config not found: %s", path)
107
+ return []
108
+
109
+ try:
110
+ with open(path, "r", encoding="utf-8") as fh:
111
+ data = yaml.safe_load(fh) or {}
112
+ except Exception as exc:
113
+ logger.error("Failed to load custom tools from %s: %s", path, exc)
114
+ return []
115
+
116
+ raw_tools = data.get("tools", []) or []
117
+ tools: List[CustomToolDef] = []
118
+ for raw in raw_tools:
119
+ try:
120
+ tools.append(CustomToolDef.model_validate(raw))
121
+ except Exception as exc:
122
+ logger.warning("Invalid custom tool definition %r: %s", raw, exc)
123
+
124
+ return tools
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Handler factory
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def make_custom_tool_handler(
132
+ tool_def: CustomToolDef,
133
+ server_state: Any,
134
+ ) -> Callable:
135
+ """Create an async handler function for a custom tool.
136
+
137
+ FastMCP introspects the function signature to determine parameter names and
138
+ types. This factory builds a proper ``inspect.Signature`` with:
139
+ - ``ctx: Context`` as the first parameter
140
+ - One parameter per entry in ``tool_def.parameters``
141
+
142
+ The returned function delegates to the executor stored in
143
+ ``server_state.executor``, building a MATLAB function call from
144
+ ``tool_def.matlab_function`` and the supplied arguments.
145
+
146
+ Parameters
147
+ ----------
148
+ tool_def:
149
+ The :class:`CustomToolDef` that describes this tool.
150
+ server_state:
151
+ An object with at minimum:
152
+ - ``executor`` — a :class:`~matlab_mcp.jobs.executor.JobExecutor`
153
+ - ``session_id`` — default session ID string
154
+
155
+ Returns
156
+ -------
157
+ callable
158
+ An async function with ``__name__``, ``__doc__``, and ``__signature__``
159
+ properly set.
160
+ """
161
+ # Try to import Context from fastmcp; fall back to Any for tests without fastmcp
162
+ try:
163
+ from fastmcp import Context as _Context
164
+ except ImportError: # pragma: no cover
165
+ _Context = Any # type: ignore[misc,assignment]
166
+
167
+ # Build the inspect.Parameter list
168
+ params: List[inspect.Parameter] = [
169
+ inspect.Parameter(
170
+ "ctx",
171
+ kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
172
+ annotation=_Context,
173
+ )
174
+ ]
175
+
176
+ for p in tool_def.parameters:
177
+ py_type = _TYPE_MAP.get(p.type.lower(), str)
178
+ if p.required:
179
+ params.append(
180
+ inspect.Parameter(
181
+ p.name,
182
+ kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
183
+ annotation=py_type,
184
+ )
185
+ )
186
+ else:
187
+ params.append(
188
+ inspect.Parameter(
189
+ p.name,
190
+ kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
191
+ annotation=py_type,
192
+ default=p.default,
193
+ )
194
+ )
195
+
196
+ sig = inspect.Signature(params, return_annotation=dict)
197
+
198
+ # Build the actual handler
199
+ # Capture tool_def and server_state in closure
200
+ _tool_def = tool_def
201
+ _server_state = server_state
202
+
203
+ async def _handler(*args, **kwargs):
204
+ # Bind arguments to the signature (skip ctx which is first positional)
205
+ # args[0] is ctx
206
+ # remaining args / kwargs are tool params
207
+ bound = sig.bind(*args, **kwargs)
208
+ bound.apply_defaults()
209
+ arguments = dict(bound.arguments)
210
+ # Remove ctx
211
+ arguments.pop("ctx", None)
212
+
213
+ # Build MATLAB function call: func(arg1, arg2, ...)
214
+ matlab_args = []
215
+ for param in _tool_def.parameters:
216
+ val = arguments.get(param.name)
217
+ py_type = _TYPE_MAP.get(param.type.lower(), str)
218
+ if py_type in (str,):
219
+ # Escape single quotes to prevent MATLAB injection
220
+ safe_val = str(val).replace("'", "''")
221
+ matlab_args.append(f"'{safe_val}'")
222
+ elif py_type in (int, float):
223
+ matlab_args.append(str(val))
224
+ elif py_type is bool:
225
+ matlab_args.append("true" if val else "false")
226
+ else:
227
+ matlab_args.append(str(val))
228
+
229
+ matlab_call = f"{_tool_def.matlab_function}({', '.join(matlab_args)})"
230
+
231
+ session_id = getattr(_server_state, "session_id", "default")
232
+ executor = _server_state.executor
233
+
234
+ return await executor.execute(session_id=session_id, code=matlab_call)
235
+
236
+ # Set metadata so FastMCP can use it
237
+ _handler.__name__ = tool_def.name
238
+ _handler.__doc__ = tool_def.description or f"Custom tool: {tool_def.name}"
239
+ _handler.__signature__ = sig
240
+
241
+ return _handler