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.
- matlab_mcp/__init__.py +2 -0
- matlab_mcp/config.py +212 -0
- matlab_mcp/jobs/__init__.py +0 -0
- matlab_mcp/jobs/executor.py +366 -0
- matlab_mcp/jobs/models.py +95 -0
- matlab_mcp/jobs/tracker.py +95 -0
- matlab_mcp/matlab_helpers/mcp_checkcode.m +17 -0
- matlab_mcp/matlab_helpers/mcp_extract_props.m +440 -0
- matlab_mcp/matlab_helpers/mcp_progress.m +16 -0
- matlab_mcp/output/__init__.py +0 -0
- matlab_mcp/output/formatter.py +234 -0
- matlab_mcp/output/plotly_convert.py +59 -0
- matlab_mcp/output/plotly_style_mapper.py +552 -0
- matlab_mcp/output/thumbnail.py +69 -0
- matlab_mcp/pool/__init__.py +0 -0
- matlab_mcp/pool/engine.py +216 -0
- matlab_mcp/pool/manager.py +227 -0
- matlab_mcp/security/__init__.py +0 -0
- matlab_mcp/security/validator.py +154 -0
- matlab_mcp/server.py +755 -0
- matlab_mcp/session/__init__.py +0 -0
- matlab_mcp/session/manager.py +210 -0
- matlab_mcp/tools/__init__.py +0 -0
- matlab_mcp/tools/admin.py +28 -0
- matlab_mcp/tools/core.py +144 -0
- matlab_mcp/tools/custom.py +241 -0
- matlab_mcp/tools/discovery.py +137 -0
- matlab_mcp/tools/files.py +456 -0
- matlab_mcp/tools/jobs.py +184 -0
- matlab_mcp/tools/monitoring.py +50 -0
- matlab_mcp_python-1.0.0.dist-info/METADATA +779 -0
- matlab_mcp_python-1.0.0.dist-info/RECORD +35 -0
- matlab_mcp_python-1.0.0.dist-info/WHEEL +4 -0
- matlab_mcp_python-1.0.0.dist-info/entry_points.txt +2 -0
- matlab_mcp_python-1.0.0.dist-info/licenses/LICENSE +21 -0
|
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()
|
matlab_mcp/tools/core.py
ADDED
|
@@ -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
|