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.
@@ -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
+