matlab-simulink-mcp 0.1.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_simulink_mcp/__init__.py +0 -0
- matlab_simulink_mcp/__main__.py +13 -0
- matlab_simulink_mcp/data/__init__.py +0 -0
- matlab_simulink_mcp/data/blacklist.txt +39 -0
- matlab_simulink_mcp/data/simlib_db.json +20023 -0
- matlab_simulink_mcp/functions.py +194 -0
- matlab_simulink_mcp/installer/__init__.py +0 -0
- matlab_simulink_mcp/installer/installer.py +160 -0
- matlab_simulink_mcp/installer/launcher.py +45 -0
- matlab_simulink_mcp/installer/win_elevate.py +18 -0
- matlab_simulink_mcp/log_utils.py +112 -0
- matlab_simulink_mcp/security.py +56 -0
- matlab_simulink_mcp/server.py +54 -0
- matlab_simulink_mcp/state.py +106 -0
- matlab_simulink_mcp-0.1.0.dist-info/METADATA +169 -0
- matlab_simulink_mcp-0.1.0.dist-info/RECORD +19 -0
- matlab_simulink_mcp-0.1.0.dist-info/WHEEL +5 -0
- matlab_simulink_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- matlab_simulink_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,194 @@
|
|
1
|
+
import asyncio, tempfile
|
2
|
+
from pathlib import Path
|
3
|
+
from difflib import SequenceMatcher
|
4
|
+
|
5
|
+
from fastmcp.exceptions import ToolError
|
6
|
+
from fastmcp.utilities.types import Image
|
7
|
+
from fastmcp.server.dependencies import get_context
|
8
|
+
|
9
|
+
from matlab_simulink_mcp.state import logger
|
10
|
+
from matlab_simulink_mcp.security import check_path, check_code
|
11
|
+
|
12
|
+
# TODO: figure out how to undo stuff in simulink
|
13
|
+
# TODO: maybe add system prompt as a server resource
|
14
|
+
# TODO: Later implement a canvas based editor
|
15
|
+
|
16
|
+
def _get_state() -> dict:
|
17
|
+
return get_context().request_context.lifespan_context
|
18
|
+
|
19
|
+
def _get_engine():
|
20
|
+
eng = _get_state().eng
|
21
|
+
if eng is None:
|
22
|
+
raise ToolError("Could not access MATLAB. Run matlab.engine.shareEngine"
|
23
|
+
" in MATLAB, and then use access_matlab tool to reconnect.")
|
24
|
+
return eng
|
25
|
+
|
26
|
+
def _raise_error(e: Exception):
|
27
|
+
logger.exception(e)
|
28
|
+
raise ToolError(str(e).strip().splitlines()[-1])
|
29
|
+
|
30
|
+
def _clean_evalc(s: str) -> str:
|
31
|
+
return "\n".join(line.strip() for line in s.splitlines() if line.strip())
|
32
|
+
|
33
|
+
def _get_image(path) -> Image:
|
34
|
+
path = Path(str(path))
|
35
|
+
data = path.read_bytes()
|
36
|
+
path.unlink(missing_ok=True)
|
37
|
+
return Image(data=data, format="png")
|
38
|
+
|
39
|
+
|
40
|
+
async def access_matlab() -> str:
|
41
|
+
"""Connect to MATLAB."""
|
42
|
+
|
43
|
+
try:
|
44
|
+
_get_engine()
|
45
|
+
return f"Already connected to MATLAB session: {_get_state().session}"
|
46
|
+
except ToolError:
|
47
|
+
try:
|
48
|
+
await asyncio.to_thread(_get_state().connect_engine)
|
49
|
+
_get_engine()
|
50
|
+
return f"Connected to MATLAB session: {_get_state().session}"
|
51
|
+
except ToolError as e:
|
52
|
+
raise
|
53
|
+
except Exception as e:
|
54
|
+
_raise_error(e)
|
55
|
+
|
56
|
+
|
57
|
+
async def read_simulink_system(path: str, detail: bool=False, open: bool=False) -> Image | dict:
|
58
|
+
"""
|
59
|
+
View a Simulink system/subsystem as either a PNG image or a detailed dictionary (if detail=True).
|
60
|
+
Optionally open the object in MATLAB desktop.
|
61
|
+
Detail only recommended when exact port tags or other details are needed, as it can be verbose.
|
62
|
+
"""
|
63
|
+
|
64
|
+
eng = _get_engine()
|
65
|
+
check_path(path)
|
66
|
+
|
67
|
+
parent, _, rest = path.partition("/")
|
68
|
+
parent = parent.removesuffix(".slx")
|
69
|
+
path = parent if not rest else f"{parent}/{rest}"
|
70
|
+
|
71
|
+
try:
|
72
|
+
if detail:
|
73
|
+
return await asyncio.to_thread(eng.describe_system, path, parent, open, nargout=1)
|
74
|
+
else:
|
75
|
+
ss_path = await asyncio.to_thread(eng.snapshot_system, path, parent, open, nargout=1)
|
76
|
+
return _get_image(ss_path)
|
77
|
+
except Exception as e:
|
78
|
+
_raise_error(e)
|
79
|
+
|
80
|
+
|
81
|
+
async def read_matlab_code(path: str, open: bool=False) -> str:
|
82
|
+
"""
|
83
|
+
Read the contents of a MATLAB script (.m) or text file.
|
84
|
+
Optionally open the file in MATLAB desktop.
|
85
|
+
"""
|
86
|
+
|
87
|
+
eng = _get_engine()
|
88
|
+
check_path(path)
|
89
|
+
|
90
|
+
try:
|
91
|
+
if open:
|
92
|
+
await asyncio.to_thread(eng.edit, path, nargout=0)
|
93
|
+
return await asyncio.to_thread(eng.fileread, path, nargout=1)
|
94
|
+
except Exception as e:
|
95
|
+
_raise_error(e)
|
96
|
+
|
97
|
+
|
98
|
+
async def save_matlab_code(code: str, path: str, overwrite: bool=False) -> str:
|
99
|
+
"""
|
100
|
+
Validate and save MATLAB code to a .m file.
|
101
|
+
Optionally overwrite if the file already exists.
|
102
|
+
"""
|
103
|
+
|
104
|
+
eng = _get_engine()
|
105
|
+
check_path(path)
|
106
|
+
blacklist = _get_state().blacklist
|
107
|
+
check_code(code, blacklist)
|
108
|
+
|
109
|
+
mode = "w" if overwrite else "x"
|
110
|
+
|
111
|
+
try:
|
112
|
+
cwd = await asyncio.to_thread(eng.pwd, nargout=1)
|
113
|
+
abs_path = Path(str(cwd)) / path
|
114
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
115
|
+
with abs_path.open(mode) as f:
|
116
|
+
f.write(code)
|
117
|
+
|
118
|
+
issues = await asyncio.to_thread(eng.validate_code, path)
|
119
|
+
if issues:
|
120
|
+
return "Code saved but failed validation with errors:\n" + "\n".join(issues)
|
121
|
+
else:
|
122
|
+
return "Code saved and validated successfully."
|
123
|
+
|
124
|
+
except Exception as e:
|
125
|
+
_raise_error(e)
|
126
|
+
|
127
|
+
|
128
|
+
async def run_matlab_code(code: str, get_images: bool=False) -> tuple[str, *tuple[Image, ...]]:
|
129
|
+
"""
|
130
|
+
Execute MATLAB code and return command window output as a string and images (if asked).
|
131
|
+
Interact programatically with Simulink if the action is not covered by a tool.
|
132
|
+
"""
|
133
|
+
|
134
|
+
eng = _get_engine()
|
135
|
+
blacklist = _get_state().blacklist
|
136
|
+
check_code(code, blacklist)
|
137
|
+
|
138
|
+
imgs: list[Image] = []
|
139
|
+
|
140
|
+
try:
|
141
|
+
if get_images:
|
142
|
+
await asyncio.to_thread(eng.close, 'all', nargout=0)
|
143
|
+
|
144
|
+
try:
|
145
|
+
cwd = await asyncio.to_thread(eng.pwd, nargout=1)
|
146
|
+
abs_path = Path(str(cwd)) / "canvas.m"
|
147
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
148
|
+
with abs_path.open("w") as f:
|
149
|
+
f.write(code)
|
150
|
+
except PermissionError:
|
151
|
+
with tempfile.NamedTemporaryFile("w", suffix=".m", delete=False) as f:
|
152
|
+
f.write(code)
|
153
|
+
abs_path = Path(f.name)
|
154
|
+
pretext = "Could not run from current working directory. Running from temporary directory:\n"
|
155
|
+
|
156
|
+
text = _clean_evalc(await asyncio.to_thread(eng.evalc, f"run('{str(abs_path)}')", nargout=1))
|
157
|
+
if pretext:
|
158
|
+
text = pretext + text
|
159
|
+
|
160
|
+
abs_path.unlink(missing_ok=True)
|
161
|
+
|
162
|
+
await asyncio.to_thread(eng.format_system, nargout=0)
|
163
|
+
|
164
|
+
img_paths = await asyncio.to_thread(eng.get_images, nargout=1)
|
165
|
+
imgs = [_get_image(p) for p in img_paths]
|
166
|
+
|
167
|
+
return (text, *imgs)
|
168
|
+
|
169
|
+
except Exception as e:
|
170
|
+
_raise_error(e)
|
171
|
+
|
172
|
+
|
173
|
+
def search_library(query: str) -> list:
|
174
|
+
"""
|
175
|
+
Search the Simulink block library for a block name and return matching source paths.
|
176
|
+
"""
|
177
|
+
|
178
|
+
try:
|
179
|
+
simlib = _get_state().simlib
|
180
|
+
candidates = [(name, path) for name, entry in simlib.items() for path in entry["paths"]]
|
181
|
+
ranked = sorted(
|
182
|
+
candidates,
|
183
|
+
key=lambda item: SequenceMatcher(None, query.lower(), item[0].lower()).ratio(),
|
184
|
+
reverse=True
|
185
|
+
)
|
186
|
+
return [path for _, path in ranked[:3]]
|
187
|
+
|
188
|
+
except Exception as e:
|
189
|
+
_raise_error(e)
|
190
|
+
|
191
|
+
# TODO remember the newline thing for \n
|
192
|
+
# ['VehicleWithFourSpeedTransmission/Inertia', newline, 'Impeller']
|
193
|
+
|
194
|
+
|
File without changes
|
@@ -0,0 +1,160 @@
|
|
1
|
+
import sys, ctypes, platform, time, subprocess
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
|
5
|
+
system = platform.system()
|
6
|
+
|
7
|
+
def verify_matlab_path(user_input: str) -> Path | None:
|
8
|
+
"""Validate that a user-specified directory contains MATLAB Engine setup."""
|
9
|
+
while True:
|
10
|
+
if not user_input:
|
11
|
+
print("Aborting installation operation.")
|
12
|
+
return None
|
13
|
+
|
14
|
+
path = Path(user_input).expanduser().resolve()
|
15
|
+
if path.is_dir():
|
16
|
+
setup_path = path / "extern" / "engines" / "python" / "setup.py"
|
17
|
+
if setup_path.exists():
|
18
|
+
return setup_path.parent
|
19
|
+
else:
|
20
|
+
user_input = input(
|
21
|
+
f"MATLAB Python engine setup not found at {setup_path}.\n"
|
22
|
+
f"Please enter a valid MATLAB installation directory or press Enter to abort: "
|
23
|
+
).strip()
|
24
|
+
else:
|
25
|
+
user_input = input(
|
26
|
+
"Invalid directory. Please enter a correct path to a MATLAB installation, or press Enter to abort: "
|
27
|
+
).strip()
|
28
|
+
|
29
|
+
|
30
|
+
def get_matlab_path() -> Path | None:
|
31
|
+
system = platform.system()
|
32
|
+
if system == "Windows":
|
33
|
+
parent = Path("C:/Program Files/MATLAB")
|
34
|
+
elif system == "Linux":
|
35
|
+
parent = Path("/usr/local/MATLAB")
|
36
|
+
elif system == "Darwin":
|
37
|
+
parent = Path("/Applications")
|
38
|
+
else:
|
39
|
+
raise OSError(f"Unsupported OS: {system}.")
|
40
|
+
|
41
|
+
installations = [p for p in parent.glob("R20[2-9][0-9][ab]*") if p.is_dir()]
|
42
|
+
installations.sort()
|
43
|
+
|
44
|
+
try:
|
45
|
+
if not installations:
|
46
|
+
print("No MATLAB installations found in default directories.")
|
47
|
+
choice = input("Please enter a path to MATLAB installation (or press Enter to abort): ").strip()
|
48
|
+
return verify_matlab_path(choice)
|
49
|
+
|
50
|
+
elif len(installations) == 1:
|
51
|
+
matlab_path = installations[0]
|
52
|
+
print(f"Found a MATLAB installation at {matlab_path}")
|
53
|
+
choice = input(
|
54
|
+
"Enter y to install MATLAB Engine from this installation (requires admin permissions), \n "
|
55
|
+
"or enter another path (or press Enter to abort): "
|
56
|
+
).strip()
|
57
|
+
if choice.lower() == "y":
|
58
|
+
return verify_matlab_path(str(matlab_path))
|
59
|
+
else:
|
60
|
+
return verify_matlab_path(choice)
|
61
|
+
|
62
|
+
else: # multiple installs
|
63
|
+
print("Multiple MATLAB installations found:")
|
64
|
+
for i, inst in enumerate(installations, 1):
|
65
|
+
print(f"[{i}] {inst}")
|
66
|
+
|
67
|
+
while True:
|
68
|
+
choice = input("Select installation by number (installing requires admin permissions), \n "
|
69
|
+
"Or, Enter another installation path (or press Enter to abort): ").strip()
|
70
|
+
if not choice:
|
71
|
+
print("Aborting by user choice.")
|
72
|
+
return None
|
73
|
+
if choice.isdigit():
|
74
|
+
idx = int(choice)
|
75
|
+
if 1 <= idx <= len(installations):
|
76
|
+
return verify_matlab_path(str(installations[idx - 1]))
|
77
|
+
else:
|
78
|
+
print("Invalid selection.")
|
79
|
+
continue
|
80
|
+
else:
|
81
|
+
return verify_matlab_path(choice)
|
82
|
+
|
83
|
+
except KeyboardInterrupt:
|
84
|
+
print("Aborted by user (Ctrl+C).")
|
85
|
+
return None
|
86
|
+
|
87
|
+
|
88
|
+
from matlab_simulink_mcp.installer import win_elevate
|
89
|
+
win_install_log = "install.log"
|
90
|
+
|
91
|
+
def install_engine_win(setup_path: Path):
|
92
|
+
script = Path(win_elevate.__file__).resolve()
|
93
|
+
status = script.with_name(win_install_log)
|
94
|
+
|
95
|
+
args = subprocess.list2cmdline([str(script), str(setup_path)])
|
96
|
+
ret = ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1)
|
97
|
+
if ret <= 32:
|
98
|
+
raise RuntimeError("Installation failed due to permission issues or some other error.")
|
99
|
+
|
100
|
+
for _ in range(15):
|
101
|
+
if status.exists():
|
102
|
+
lines = open(status).read().strip().splitlines()
|
103
|
+
if lines:
|
104
|
+
last = lines[-1]
|
105
|
+
if last == "0":
|
106
|
+
#status.unlink() # had to comment because claude sends request twice in init so it couldnt delete this
|
107
|
+
return
|
108
|
+
elif last.isdigit():
|
109
|
+
e = "\n".join(lines[:-1])
|
110
|
+
#status.unlink()
|
111
|
+
raise RuntimeError(f"Failed to install MATLAB engine. \n: {e}")
|
112
|
+
time.sleep(1)
|
113
|
+
raise TimeoutError("Installer did not finish in time.")
|
114
|
+
|
115
|
+
|
116
|
+
def install_engine_mac_linux(setup_path: Path):
|
117
|
+
subprocess.run(
|
118
|
+
["sudo", sys.executable, "setup.py", "install"],
|
119
|
+
cwd=setup_path,
|
120
|
+
check=True)
|
121
|
+
|
122
|
+
|
123
|
+
def install_engine():
|
124
|
+
try:
|
125
|
+
print("***MATLAB Engine for Python API Package Installer***")
|
126
|
+
print("This process will install the matlab.engine package from a MATLAB installation on this machine. \n")
|
127
|
+
setup_path = get_matlab_path()
|
128
|
+
if not setup_path:
|
129
|
+
sys.exit(1)
|
130
|
+
|
131
|
+
print(f"Installing MATLAB engine into current Python environment from: \n {setup_path}")
|
132
|
+
|
133
|
+
time.sleep(2) # Wait for a while so that user sees that they need to grant permission.
|
134
|
+
|
135
|
+
if system == "Windows":
|
136
|
+
install_engine_win(setup_path)
|
137
|
+
elif system in ["Linux", "Darwin"]:
|
138
|
+
install_engine_mac_linux(setup_path)
|
139
|
+
else:
|
140
|
+
raise OSError(f"Unsupported OS: {system}.")
|
141
|
+
|
142
|
+
print(f"Installation completed successfully.")
|
143
|
+
sys.exit(0)
|
144
|
+
|
145
|
+
except KeyboardInterrupt:
|
146
|
+
print("Aborted by user (Ctrl+C).")
|
147
|
+
sys.exit(1)
|
148
|
+
|
149
|
+
except Exception as e:
|
150
|
+
print(str(e))
|
151
|
+
quit = input("Press any key to close.")
|
152
|
+
if quit:
|
153
|
+
sys.exit(1)
|
154
|
+
|
155
|
+
if __name__ == "__main__":
|
156
|
+
install_engine()
|
157
|
+
|
158
|
+
|
159
|
+
|
160
|
+
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import subprocess, sys, time, platform
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
|
5
|
+
def run() -> int:
|
6
|
+
import matlab_simulink_mcp.installer.installer as installer
|
7
|
+
|
8
|
+
# Prevent multiple installation script launches
|
9
|
+
lockfile = Path.home() / ".matlab_engine_install.lock"
|
10
|
+
if lockfile.exists():
|
11
|
+
return -1 # wait command, essentially proceed but wait.
|
12
|
+
lockfile.write_text("running")
|
13
|
+
|
14
|
+
try:
|
15
|
+
time.sleep(2)
|
16
|
+
# Launch installation process
|
17
|
+
system = platform.system()
|
18
|
+
if system == "Windows":
|
19
|
+
proc = subprocess.Popen(
|
20
|
+
[sys.executable, "-m", installer.__name__],
|
21
|
+
creationflags=subprocess.CREATE_NEW_CONSOLE
|
22
|
+
)
|
23
|
+
elif system == "Darwin":
|
24
|
+
proc = subprocess.Popen(["open", "-a", "Terminal", sys.executable, "-m", installer.__name__])
|
25
|
+
elif system == "Linux":
|
26
|
+
proc = subprocess.Popen(["x-terminal-emulator", "-e", sys.executable, "-m", installer.__name__])
|
27
|
+
|
28
|
+
# Wait for installation to finish
|
29
|
+
ret = proc.wait()
|
30
|
+
|
31
|
+
time.sleep(2) # ensuring that installed package is now recognized
|
32
|
+
|
33
|
+
# Return installation status (ret returns 1 if error)
|
34
|
+
if ret == 1:
|
35
|
+
return 0
|
36
|
+
if ret == 0:
|
37
|
+
return 1
|
38
|
+
|
39
|
+
finally:
|
40
|
+
if lockfile.exists():
|
41
|
+
lockfile.unlink()
|
42
|
+
|
43
|
+
if __name__ == "__main__":
|
44
|
+
run()
|
45
|
+
|
@@ -0,0 +1,18 @@
|
|
1
|
+
import subprocess, sys, time
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
def install(engine_dir):
|
5
|
+
log_file = Path(__file__).with_name("install.log")
|
6
|
+
with open(log_file, "w") as f:
|
7
|
+
proc = subprocess.run(
|
8
|
+
[sys.executable, "setup.py", "install"],
|
9
|
+
cwd=engine_dir,
|
10
|
+
stdout=f,
|
11
|
+
stderr=subprocess.STDOUT
|
12
|
+
)
|
13
|
+
f.write(f"\n{proc.returncode}\n")
|
14
|
+
time.sleep(2)
|
15
|
+
|
16
|
+
if __name__ == "__main__":
|
17
|
+
engine_dir = sys.argv[1]
|
18
|
+
install(engine_dir)
|
@@ -0,0 +1,112 @@
|
|
1
|
+
import sys, logging, platform, subprocess, shutil
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from platformdirs import user_log_dir
|
5
|
+
from logging.handlers import RotatingFileHandler
|
6
|
+
|
7
|
+
|
8
|
+
def create_log_file(filename: str, dir: Path | None) -> Path:
|
9
|
+
"""Creates a log file in the given directory or user log directory (if former fails or isn't specified)."""
|
10
|
+
|
11
|
+
filename = Path(filename)
|
12
|
+
if filename.suffix != ".log":
|
13
|
+
filename = filename.with_suffix(".log")
|
14
|
+
|
15
|
+
if dir:
|
16
|
+
try:
|
17
|
+
dir = Path(dir)
|
18
|
+
dir.mkdir(parents=True, exist_ok=True)
|
19
|
+
except PermissionError:
|
20
|
+
dir = None
|
21
|
+
|
22
|
+
if dir is None:
|
23
|
+
dir = user_log_dir(filename.stem, appauthor=False)
|
24
|
+
dir.mkdir(parents=True, exist_ok=True)
|
25
|
+
|
26
|
+
return dir / filename
|
27
|
+
|
28
|
+
|
29
|
+
def create_logger(name: str, log_file: Path) -> logging.Logger:
|
30
|
+
"""Sets up a logger with stderr console and rotating file handlers."""
|
31
|
+
logger = logging.getLogger(name)
|
32
|
+
if logger.handlers:
|
33
|
+
return logger
|
34
|
+
|
35
|
+
logger.setLevel(logging.DEBUG)
|
36
|
+
logger.propagate = False
|
37
|
+
fmt = logging.Formatter("%(asctime)s %(levelname)-7s %(message)s")
|
38
|
+
|
39
|
+
ch = logging.StreamHandler(sys.stderr)
|
40
|
+
ch.setLevel(logging.DEBUG)
|
41
|
+
ch.setFormatter(fmt)
|
42
|
+
logger.addHandler(ch)
|
43
|
+
|
44
|
+
fh = RotatingFileHandler(log_file, maxBytes=1_000_000, backupCount=3, encoding="utf-8")
|
45
|
+
fh.setLevel(logging.DEBUG)
|
46
|
+
fh.setFormatter(fmt)
|
47
|
+
logger.addHandler(fh)
|
48
|
+
|
49
|
+
return logger
|
50
|
+
|
51
|
+
|
52
|
+
class TrailingConsole:
|
53
|
+
def __init__(self, log_file: Path):
|
54
|
+
self.log_file = str(log_file)
|
55
|
+
self.viewer_process = None
|
56
|
+
|
57
|
+
def open(self):
|
58
|
+
"""Open a console window that tails the log file."""
|
59
|
+
|
60
|
+
if self.viewer_process and self.viewer_process.poll() is None:
|
61
|
+
return # already open
|
62
|
+
|
63
|
+
system = platform.system()
|
64
|
+
|
65
|
+
if system == "Windows":
|
66
|
+
cmd = [
|
67
|
+
"powershell",
|
68
|
+
"-NoExit",
|
69
|
+
"-Command",
|
70
|
+
f'Get-Content -Path "{self.log_file}" -Wait -Tail 0'
|
71
|
+
]
|
72
|
+
self.viewer_process = subprocess.Popen(
|
73
|
+
cmd, creationflags=subprocess.CREATE_NEW_CONSOLE
|
74
|
+
)
|
75
|
+
|
76
|
+
if self.viewer_process.poll() is not None:
|
77
|
+
raise RuntimeError("Failed to launch trailing console process")
|
78
|
+
|
79
|
+
elif system == "Linux":
|
80
|
+
candidates = [
|
81
|
+
"x-terminal-emulator",
|
82
|
+
"gnome-terminal",
|
83
|
+
"konsole",
|
84
|
+
"xfce4-terminal",
|
85
|
+
"xterm",
|
86
|
+
]
|
87
|
+
terminal = next((t for t in candidates if shutil.which(t)), None)
|
88
|
+
|
89
|
+
if terminal is None:
|
90
|
+
return
|
91
|
+
|
92
|
+
self.viewer_process = subprocess.Popen([terminal, "-e", f"tail -n 0 -f '{self.log_file}'"])
|
93
|
+
|
94
|
+
elif system == "Darwin": # macOS
|
95
|
+
full_cmd = f"tail -n 0 -f '{self.log_file}'"
|
96
|
+
subprocess.run([
|
97
|
+
"osascript", "-e",
|
98
|
+
f'tell application "Terminal" to do script "{full_cmd}"'
|
99
|
+
])
|
100
|
+
self.viewer_process = None
|
101
|
+
|
102
|
+
def close(self):
|
103
|
+
if self.viewer_process and self.viewer_process.poll() is None:
|
104
|
+
self.viewer_process.terminate()
|
105
|
+
self.viewer_process = None
|
106
|
+
|
107
|
+
|
108
|
+
def create_console(log_file: Path) -> TrailingConsole:
|
109
|
+
return TrailingConsole(log_file)
|
110
|
+
|
111
|
+
|
112
|
+
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import re
|
2
|
+
from pathlib import Path
|
3
|
+
from fastmcp.exceptions import ToolError
|
4
|
+
|
5
|
+
def strip_matlab_comments(code: str) -> str:
|
6
|
+
"""Removes comments from MATLAB code which could then be checked."""
|
7
|
+
lines = []
|
8
|
+
for line in code.splitlines():
|
9
|
+
line = line.split("%", 1)[0]
|
10
|
+
lines.append(line)
|
11
|
+
return "\n".join(lines)
|
12
|
+
|
13
|
+
def tokenize(code: str) -> list[str]:
|
14
|
+
"""Tokenizes MATLAB code into commands and identifiers."""
|
15
|
+
return re.findall(r"[A-Za-z_]\w*|[^\s]", code)
|
16
|
+
|
17
|
+
def check_for_commands(code: str, blacklist: set[str]):
|
18
|
+
"""Checks code for forbidden commands and raises error if found."""
|
19
|
+
clean_code = strip_matlab_comments(code)
|
20
|
+
tokens = tokenize(clean_code)
|
21
|
+
|
22
|
+
for token in tokens:
|
23
|
+
if token in blacklist:
|
24
|
+
return f"Use of '{token}' command is not allowed."
|
25
|
+
return None
|
26
|
+
|
27
|
+
def check_for_paths(code: str):
|
28
|
+
"""Checks string literals in code for forbidden path usage and raises error if found."""
|
29
|
+
|
30
|
+
literals = re.findall(r"(?:'[^']*'|\"[^\"]*\")", code)
|
31
|
+
|
32
|
+
for literal in literals:
|
33
|
+
path_str = literal[1:-1]
|
34
|
+
if not path_str:
|
35
|
+
continue
|
36
|
+
|
37
|
+
path = Path(path_str)
|
38
|
+
|
39
|
+
if path.is_absolute():
|
40
|
+
return "Absolute paths are not allowed. Only files on MATLAB path are accessible."
|
41
|
+
if ".." in path.parts:
|
42
|
+
return "Paths with .. are not allowed. Only files on MATLAB path are accessible."
|
43
|
+
if "*" in path_str or "?" in path_str:
|
44
|
+
return "Paths with * or ? are not allowed."
|
45
|
+
|
46
|
+
def check_code(code: str, blacklist: set[str]):
|
47
|
+
"""Checks a given code for forbidden commands or paths and raises error if any found."""
|
48
|
+
issues = check_for_commands(code, blacklist) or check_for_paths(code)
|
49
|
+
if issues:
|
50
|
+
raise ToolError(issues)
|
51
|
+
|
52
|
+
def check_path(file: str):
|
53
|
+
"""Checks a given file path for absolute or parent paths and raises error if found."""
|
54
|
+
f = Path(file)
|
55
|
+
if f.is_absolute() or ".." in f.parts:
|
56
|
+
raise ToolError("Access to absolute or parent paths is forbidden. Only files on MATLAB path are usable.")
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import inspect, time
|
2
|
+
from fastmcp import FastMCP
|
3
|
+
from contextlib import asynccontextmanager
|
4
|
+
|
5
|
+
from matlab_simulink_mcp.state import EngineState, logger, log_console
|
6
|
+
from matlab_simulink_mcp import functions
|
7
|
+
|
8
|
+
console = False
|
9
|
+
|
10
|
+
# Create lifespan function
|
11
|
+
@asynccontextmanager
|
12
|
+
async def lifespan(server): # do not remove server argument as it will break stuff
|
13
|
+
log_console.open()
|
14
|
+
time.sleep(1)
|
15
|
+
|
16
|
+
state = EngineState()
|
17
|
+
state.initialize()
|
18
|
+
|
19
|
+
if not console:
|
20
|
+
time.sleep(1)
|
21
|
+
log_console.close()
|
22
|
+
|
23
|
+
yield state
|
24
|
+
|
25
|
+
|
26
|
+
# Compile tools
|
27
|
+
def collect_tools():
|
28
|
+
tools = []
|
29
|
+
for name, fn in inspect.getmembers(functions, inspect.isfunction):
|
30
|
+
if fn.__module__ == functions.__name__ and not name.startswith("_"):
|
31
|
+
tools.append(fn)
|
32
|
+
return tools
|
33
|
+
|
34
|
+
# Define server
|
35
|
+
mcp = FastMCP(
|
36
|
+
name="MATLAB_Simulink_MCP",
|
37
|
+
lifespan=lifespan,
|
38
|
+
tools=collect_tools()
|
39
|
+
)
|
40
|
+
|
41
|
+
# Run server
|
42
|
+
def run(console: bool=False):
|
43
|
+
try:
|
44
|
+
console = console
|
45
|
+
mcp.run(transport="stdio")
|
46
|
+
except Exception as e:
|
47
|
+
if hasattr(e, "exceptions"):
|
48
|
+
for sub in e.exceptions:
|
49
|
+
logger.error(f"{sub}")
|
50
|
+
else:
|
51
|
+
logger.error(f"{e}")
|
52
|
+
#sys.exit(1)
|
53
|
+
|
54
|
+
|