diff --git a/.python-version b/.python-version index c8cfe39..b6d8b76 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.11.8 diff --git a/servers/matlab-server/.python-version b/servers/matlab-server/.python-version new file mode 100644 index 0000000..b6d8b76 --- /dev/null +++ b/servers/matlab-server/.python-version @@ -0,0 +1 @@ +3.11.8 diff --git a/servers/matlab-server/README.md b/servers/matlab-server/README.md new file mode 100644 index 0000000..1aaf85c --- /dev/null +++ b/servers/matlab-server/README.md @@ -0,0 +1,154 @@ +# MATLAB MCP Server (Python) + +This repository contains a Python-based Model Context Protocol (MCP) server that allows MCP clients (like Cursor or other AI agent environments) to execute MATLAB code and retrieve results, including plots. + +## Overview + +This server acts as a bridge, enabling applications that support MCP to leverage a local MATLAB installation for tasks such as: + +* Performing numerical computations and simulations. +* Generating plots and visualizations from MATLAB. +* Executing existing MATLAB scripts and functions. + +The server interacts with MATLAB using the `matlab -batch` command-line interface. + +## Prerequisites + +* **MATLAB Installation:** A licensed version of MATLAB must be installed on your system. +* **`matlab` in PATH:** The `matlab` executable command must be available in your system's PATH. You can test this by running `matlab -help` or `matlab -batch "disp('hello'), exit"` in your terminal. + * On macOS, the path is typically `/Applications/MATLAB_R20XXx.app/bin/`. + * On Linux, it's usually `/usr/local/MATLAB/R20XXx/bin/`. + * On Windows, it's often `C:\Program Files\MATLAB\R20XXx\bin\`. + Ensure the relevant directory is added to your system's PATH environment variable. +* **Python:** Python >= 3.11 (as specified in `pyproject.toml`). +* **`uv` (recommended):** For managing the Python environment (`pip` can also be used). + +## Installation + +1. **Clone the repository (if you haven't already):** + If you are working within a larger repository containing this server: + ```bash + # Navigate to the matlab-server directory + cd path/to/your/clone/servers/matlab-server + ``` + If obtaining it standalone (conceptual, as it's part of a larger structure): + ```bash + # git clone + # cd /servers/matlab-server + ``` + +2. **Set up the Python environment and install dependencies using `uv`:** + ```bash + # Create a virtual environment (from within servers/matlab-server) + uv venv + + # Activate the environment + # On macOS/Linux: + source .venv/bin/activate + # On Windows (Powershell): + # .\.venv\Scripts\Activate.ps1 + # On Windows (CMD): + # .\.venv\Scripts\activate.bat + + # Install dependencies (installs in editable mode using pyproject.toml) + uv pip install -e . + ``` + Alternatively, if not using editable mode or if you want to install it like a regular package (e.g., from a Git URL if it were published or directly accessible): + ```bash + # Example: uv pip install git+https://github.com/pathintegral-institute/mcp.science.git#subdirectory=servers/matlab-server + ``` + + +## Running the Server + +To start the MCP server, ensure your virtual environment (where `mcp-matlab-server` was installed) is activated, and then run: + +```bash +# Using the script defined in pyproject.toml +mcp-matlab-server +``` + +Or explicitly using the Python module: +```bash +python -m matlab_server.server +``` + +The server will start and listen for connections from MCP clients via standard input/output (stdio). + +## Integration with MCP Clients (e.g., Cursor) + +Follow the general steps for integrating MCP servers with your client: + +1. **Start the MATLAB MCP Server:** Run `mcp-matlab-server` in a terminal as described above. +2. **Configure Your MCP Client:** Add the server to your client's configuration (e.g., `settings.json` for Cursor). You'll need to provide the command to run the server within its environment. + + Example configuration snippet for Cursor's `settings.json` (adjust paths as necessary): + + ```json + { + "mcpServers": { + "matlab-server": { + // Option 1: Running the installed script (ensure .venv/bin is in PATH or use absolute path) + "command": "/path/to/your/project/servers/matlab-server/.venv/bin/mcp-matlab-server", + // "disabled": false, // Uncomment to enable + // "autoApprove": ["execute_matlab"] // Optional: auto-approve the tool + } + // ... other servers ... + } + } + ``` + *Replace `/path/to/your/project/...` with the absolute path to the `mcp-matlab-server` executable within its virtual environment on your system.* + +3. **Restart Your MCP Client:** Ensure the client detects and loads the new server. + +## Available Tools + +The server exposes the following tool: + +### 1. `execute_matlab` + +Executes MATLAB code and returns the result. + +**Input:** +* `code` (string): MATLAB code to execute. This should be a self-contained script. + * If generating a plot, the code should produce a figure (e.g., using `plot()`, `surf()`, etc.). The server will attempt to save the *current* figure. +* `output_format` (string, optional): Specifies the desired output format. + * `"text"` (default): Returns any text output (stdout) from the MATLAB script. + * `"plot"`: Attempts to save the current MATLAB figure as a PNG image and returns it as a base64 encoded string. + +**Output:** +* If `output_format` is `"text"`: `TextContent` containing the stdout from MATLAB. +* If `output_format` is `"plot"`: `ImageContent` containing the base64 encoded PNG image, if a plot was successfully generated. +* `ErrorContent`: If MATLAB is not found, the `matlab` command fails, the code is empty, or if `"plot"` is requested but no figure is generated or an error occurs during plotting. + +**Important Considerations for `execute_matlab`:** +* **Self-contained Code:** Each call to `execute_matlab` starts a new MATLAB session via `matlab -batch`. State (variables, workspace) is *not* preserved between calls. +* **Plotting:** When `output_format` is `"plot"`, your MATLAB code should generate a figure. The server automatically adds commands to save the *current active figure*. If your code generates multiple figures, ensure the desired one is active before the script finishes. If no figure is generated, an error or warning will be indicated. +* **MATLAB Errors:** Errors within your MATLAB script (e.g., syntax errors, runtime errors) will typically be captured and returned in the output or result in an `ErrorContent`. The server wraps the user's code in a basic `try...catch` block to help capture these. +* **`exit` Command:** The server appends an `exit;` command to the MATLAB script to ensure the `matlab -batch` process terminates. You do not need to include `exit` in your provided code. + +## Troubleshooting + +* **Server Not Found/Not Responding in Client:** + * Ensure the `mcp-matlab-server` is running in a terminal. + * Verify the Python virtual environment for the server is activated. + * Check if `matlab` is correctly installed and its `bin` directory is in your system's PATH. Test with `matlab -batch "disp('test'), exit;"` in a new terminal. + * Double-check the `command` path in your MCP client's configuration. It must point to the `mcp-matlab-server` executable *within its correct virtual environment*. +* **Tool Errors (`execute_matlab`):** + * Check the server's terminal output for logs and specific error messages from MATLAB. + * Verify the syntax of your MATLAB `code`. + * If requesting a plot, ensure your code actually generates a figure. + * If MATLAB itself crashes or exits with an error code, the server will report this. +* **Python/Dependency Issues:** Ensure dependencies are installed correctly in the virtual environment using `uv pip install -e .` within the `servers/matlab-server` directory. +* **MATLAB Licensing Issues:** `matlab -batch` may fail or hang if there are MATLAB licensing problems. Ensure your MATLAB license is active and configured correctly for command-line use. + +## Project Structure + +* `src/matlab_server/`: Python source code for the server. + * `server.py`: Main server logic, MCP tool definitions, and MATLAB interaction. +* `pyproject.toml`: Project metadata and dependencies. +* `setup.py`: Additional build and packaging information. +* `.python-version`: Specifies the Python version (e.g., for `pyenv`). +* `README.md`: This file. +* `uv.lock`: (Generated by `uv`) Lockfile for dependencies. +``` diff --git a/servers/matlab-server/pyproject.toml b/servers/matlab-server/pyproject.toml new file mode 100644 index 0000000..41630ba --- /dev/null +++ b/servers/matlab-server/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "matlab-server" +version = "0.1.0" +requires-python = ">=3.11" +description = "A Python MCP server for interacting with MATLAB." +authors = [{ name = "Jules", email = "jules@example.com" }] # Placeholder +license = { text = "MIT" } + +dependencies = ["mcp[cli]>=1.0.0"] + +[project.scripts] +mcp-matlab-server = "matlab_server.server:main" + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" diff --git a/servers/matlab-server/pytest.ini b/servers/matlab-server/pytest.ini new file mode 100644 index 0000000..da79ade --- /dev/null +++ b/servers/matlab-server/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = src +asyncio_mode = auto diff --git a/servers/matlab-server/setup.py b/servers/matlab-server/setup.py new file mode 100644 index 0000000..5ad42bf --- /dev/null +++ b/servers/matlab-server/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + +setup( + name="matlab-server", + version="0.1.0", + packages=find_packages(where="src"), + package_dir={"": "src"}, + include_package_data=True, + entry_points={ + "console_scripts": [ + "mcp-matlab-server=matlab_server.server:main", + ], + }, + install_requires=[ + "mcp[cli]>=1.0.0", + ], +) diff --git a/servers/matlab-server/src/matlab_server/__init__.py b/servers/matlab-server/src/matlab_server/__init__.py new file mode 100644 index 0000000..86a6494 --- /dev/null +++ b/servers/matlab-server/src/matlab_server/__init__.py @@ -0,0 +1,2 @@ +# This file can be empty +# It marks the directory as a Python package. diff --git a/servers/matlab-server/src/matlab_server/server.py b/servers/matlab-server/src/matlab_server/server.py new file mode 100644 index 0000000..341a9bd --- /dev/null +++ b/servers/matlab-server/src/matlab_server/server.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python + +""" +MCP server for executing local MATLAB code and returning the output. +""" + +import subprocess +import logging +import json +import asyncio +import os +import tempfile +import base64 +from typing import List, Literal, Optional + +from mcp.server.fastmcp import FastMCP +from mcp.types import TextContent, ImageContent, ErrorContent + +# --- Logging Setup --- +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s" +) +logger = logging.getLogger("matlab-server") + +# --- MCP Server Initialization --- +mcp = FastMCP( + name="matlab-server", + version="0.1.0", + description="An MCP server to execute MATLAB code.", +) + +# --- MATLAB Interaction Logic --- + +_matlab_checked = False +_matlab_available = False + + +async def check_matlab_installation() -> bool: + """Checks if MATLAB is installed and accessible.""" + global _matlab_checked, _matlab_available + if _matlab_checked: + return _matlab_available + + logger.info("Checking MATLAB installation...") + # Command to check MATLAB, e.g., by requesting help or version. + # Using -nodesktop and -nosplash for faster startup and no GUI. + # `exit` is often needed to make MATLAB actually quit after a simple command. + # Using `matlab -help` might be too verbose or platform-dependent in its success code. + # A simple, non-crashing command like `1+1; exit` is better. + try: + process = await asyncio.create_subprocess_exec( + "matlab", "-batch", "disp('MATLAB OK'); exit;", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0 and b"MATLAB OK" in stdout: + logger.info("MATLAB installation verified.") + _matlab_available = True + else: + logger.error( + f"MATLAB check command exited with code {process.returncode}. Stdout: {stdout.decode(errors='ignore')}, Stderr: {stderr.decode(errors='ignore')}" + ) + _matlab_available = False + except FileNotFoundError: + logger.error("MATLAB command not found in PATH. Please ensure MATLAB is installed and its bin directory is in the system PATH.") + _matlab_available = False + except Exception as e: + logger.error(f"Error checking MATLAB installation: {e}", exc_info=True) + _matlab_available = False + + _matlab_checked = True + return _matlab_available + + +async def execute_matlab_code(code: str, output_format: Literal["text", "plot"] = "text") -> tuple[str, Optional[str]]: + """ + Executes MATLAB code using 'matlab -batch'. + + Args: + code: The MATLAB code to execute. + output_format: "text" for textual output, "plot" for plot output (returns base64 PNG). + + Returns: + A tuple containing: + - The primary output (text or base64 PNG string). + - An optional string for stderr messages if any. + + Raises: + RuntimeError: If MATLAB execution fails or MATLAB is not found. + """ + if not await check_matlab_installation(): + raise RuntimeError( + "MATLAB is not installed, not found in PATH, or not configured correctly." + ) + + temp_plot_file = None + matlab_command_code = code + + if output_format == "plot": + # Create a temporary file for the plot + # MATLAB will save the plot to this file. + # We use a unique filename to avoid collisions. + # The .png extension is important for MATLAB's print command. + fd, temp_plot_file_path = tempfile.mkstemp(suffix=".png") + os.close(fd) # Close the file descriptor, MATLAB will open and write to it. + temp_plot_file = temp_plot_file_path + + # Sanitize the path for MATLAB (especially on Windows) + safe_plot_file_path = temp_plot_file.replace('\\', '/') + + # Add MATLAB commands to save the current figure and then exit + # The user's code should generate a figure. We then save it. + # `print('-dpng', '{filepath}')` saves the current figure. + # We add `exit;` to ensure MATLAB closes after execution. + matlab_command_code = f""" + try + {code} + % Check if a figure exists before trying to save + if ~isempty(get(groot,'CurrentFigure')) + print('-dpng', '{safe_plot_file_path}'); + else + disp('MATLAB_WARNING: No figure was generated to save.'); + end + catch ME + disp(['MATLAB_ERROR: ' ME.message]); + % Optional: rethrow(ME); % if we want MATLAB to exit with error code for script errors + end + exit; % Ensure MATLAB exits + """ + logger.info(f"Preparing to execute MATLAB for plot, saving to: {temp_plot_file}") + else: # text output + matlab_command_code = f""" + try + {code} + catch ME + disp(['MATLAB_ERROR: ' ME.message]); + end + exit; % Ensure MATLAB exits + """ + + command = ["matlab", "-batch", matlab_command_code] + logger.info(f"Executing MATLAB command: matlab -batch \"{matlab_command_code[:100]}...\"") + + try: + process = await asyncio.create_subprocess_exec( + *command, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + stdout_str = stdout.decode(errors='ignore').strip() + stderr_str = stderr.decode(errors='ignore').strip() + + if "MATLAB_ERROR:" in stdout_str: # Check for our custom error prefix + # If specific error handling from MATLAB output is needed + logger.error(f"MATLAB execution reported an error within the script: {stdout_str}") + # Depending on desired behavior, we might want to raise this as an error + # or return it as part of the output. For now, returning it in stdout. + + if process.returncode != 0: + # Even if code has `exit;`, MATLAB might return non-zero for internal errors + # or if the `exit;` command is not reached due to a script crash. + error_message = f"MATLAB process exited with code {process.returncode}." + if stdout_str: + error_message += f" Stdout: {stdout_str}" + if stderr_str: # This stderr is from the matlab process itself, not user script's stderr. + error_message += f" Stderr: {stderr_str}" + logger.error(error_message) + # Decide if this should always raise RuntimeError or if some non-zero exits are "ok" + # For now, let's be strict. + raise RuntimeError(f"MATLAB execution failed: {error_message}") + + if stderr_str: # Process-level stderr + logger.warning(f"MATLAB process produced stderr: {stderr_str}") + + + if output_format == "plot": + if temp_plot_file and os.path.exists(temp_plot_file) and os.path.getsize(temp_plot_file) > 0: + with open(temp_plot_file, "rb") as f: + image_data = base64.b64encode(f.read()).decode("utf-8") + logger.info(f"Successfully read plot image from {temp_plot_file}") + return image_data, stdout_str # stdout might contain warnings like "No figure generated" + elif "MATLAB_WARNING: No figure was generated to save." in stdout_str: + logger.warning("MATLAB script ran, but no plot was generated or saved.") + return "", stdout_str # Return empty image data, but include the warning + else: + logger.error(f"Plot file {temp_plot_file} was not created or is empty.") + # Include stdout in case it has error messages from MATLAB + raise RuntimeError(f"Failed to generate plot. MATLAB output: {stdout_str}") + else: # text output + return stdout_str, None # No separate stderr from script for text, it's part of stdout + + except FileNotFoundError: + logger.error("`matlab` command not found. Is MATLAB installed and in PATH?") + raise RuntimeError("`matlab` command not found. Ensure MATLAB is installed and its bin directory is in the system PATH.") + except Exception as e: + logger.error(f"Failed to execute MATLAB code: {e}", exc_info=True) + raise RuntimeError(f"An unexpected error occurred during MATLAB execution: {e}") + finally: + if temp_plot_file and os.path.exists(temp_plot_file): + os.remove(temp_plot_file) + logger.info(f"Cleaned up temporary plot file: {temp_plot_file}") + + +# --- MCP Tool Definitions --- + +@mcp.tool() +async def execute_matlab( + code: str, + output_format: Optional[Literal["text", "plot"]] = "text", +) -> TextContent | ImageContent | ErrorContent: + """ + Execute MATLAB code and return the result. + + Args: + code: MATLAB code to execute. Should be a complete, self-contained script. + For plots, the code should generate a figure. + output_format: "text" for textual output (default), or "plot" to capture + the current MATLAB figure as a PNG image. + + Returns: + TextContent if output_format is "text". + ImageContent if output_format is "plot" and a plot is successfully generated. + ErrorContent if MATLAB is not found, execution fails, or a plot is requested but not generated. + """ + logger.info(f"Received execute_matlab request (output_format: {output_format})") + + if not await check_matlab_installation(): + return ErrorContent( + type="error", + message="MATLAB (matlab command) is not installed or not accessible. Please ensure it's in your PATH and configured correctly.", + ) + + if not code: + return ErrorContent(type="error", message="MATLAB code cannot be empty.") + + try: + result, script_stdout_warnings = await execute_matlab_code(code, output_format) + + if output_format == "plot": + if result: # result is base64 image data + return ImageContent(type="image", format="png", base64=result) + else: # No image data, means no plot was generated + message = "MATLAB code executed, but no plot was generated or an error occurred." + if script_stdout_warnings and "MATLAB_WARNING: No figure was generated to save." in script_stdout_warnings : + message = "MATLAB code executed, but no plot was generated." + elif script_stdout_warnings: # Other warnings/errors from the script + message = f"MATLAB code executed, but no plot was generated. Output: {script_stdout_warnings}" + return ErrorContent(type="error", message=message) + else: # text output + # If script_stdout_warnings is not None, it implies it's actually stdout from plot generation mode + # that might contain warnings. For pure text mode, this should be None. + # The primary output is in 'result'. + return TextContent(type="text", text=result) + + except RuntimeError as e: + return ErrorContent(type="error", message=str(e)) + except ValueError as e: # Should not happen if checks are done above + return ErrorContent(type="error", message=str(e)) + except Exception as e: # Catchall for unexpected errors + logger.error(f"Unexpected error in execute_matlab tool: {e}", exc_info=True) + return ErrorContent(type="error", message=f"An unexpected server error occurred: {e}") + + +# --- Main Execution --- +def main(): + """Runs the MCP server.""" + logger.info("Starting MATLAB MCP server (Python)...") + # Ensure MATLAB check runs at least once at startup, if desired, + # though it's also run on first tool call. + # asyncio.run(check_matlab_installation()) # Optional: run once at startup + mcp.run("stdio") + + +if __name__ == "__main__": + main() diff --git a/servers/matlab-server/tests/test_server.py b/servers/matlab-server/tests/test_server.py new file mode 100644 index 0000000..76be021 --- /dev/null +++ b/servers/matlab-server/tests/test_server.py @@ -0,0 +1,316 @@ +import pytest +import asyncio +from unittest.mock import patch, MagicMock, mock_open, AsyncMock + +# Make sure the server module can be imported. +# This might require adjustments to sys.path or pytest configuration depending on project structure. +# For now, assuming direct import works or will be handled by pytest's path adjustments. +from matlab_server.server import ( + check_matlab_installation, + execute_matlab_code, + execute_matlab as execute_matlab_tool, # MCP tool + mcp, # For tool registration, if needed for testing context + _matlab_checked, _matlab_available # To reset state between tests +) +from mcp.types import TextContent, ImageContent, ErrorContent + +# Helper to reset global check state for MATLAB availability +def reset_matlab_check_state(): + global _matlab_checked, _matlab_available + # Need to directly assign to the global variables in the server module + import matlab_server.server + matlab_server.server._matlab_checked = False + matlab_server.server._matlab_available = False + +@pytest.fixture(autouse=True) +def reset_globals(): + reset_matlab_check_state() + yield + reset_matlab_check_state() + +@pytest.mark.asyncio +async def test_check_matlab_installation_success(): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"MATLAB OK", b"")) + mock_process.returncode = 0 + + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)) as mock_exec: + assert await check_matlab_installation() is True + mock_exec.assert_called_once_with( + "matlab", "-batch", "disp('MATLAB OK'); exit;", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + # Check that state is updated + import matlab_server.server + assert matlab_server.server._matlab_available is True + assert matlab_server.server._matlab_checked is True + +@pytest.mark.asyncio +async def test_check_matlab_installation_not_found(): + with patch("asyncio.create_subprocess_exec", AsyncMock(side_effect=FileNotFoundError)) as mock_exec: + assert await check_matlab_installation() is False + mock_exec.assert_called_once() + import matlab_server.server + assert matlab_server.server._matlab_available is False + assert matlab_server.server._matlab_checked is True + +@pytest.mark.asyncio +async def test_check_matlab_installation_command_error(): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"Some error", b"Failed")) + mock_process.returncode = 1 + + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)) as mock_exec: + assert await check_matlab_installation() is False + import matlab_server.server + assert matlab_server.server._matlab_available is False + assert matlab_server.server._matlab_checked is True + +@pytest.mark.asyncio +async def test_execute_matlab_code_text_success(): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"Hello from MATLAB", b"")) + mock_process.returncode = 0 + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)) as mock_exec: + code = "disp('Hello from MATLAB');" + output, stderr_msg = await execute_matlab_code(code, output_format="text") + assert output == "Hello from MATLAB" + assert stderr_msg is None + expected_matlab_code = f""" + try + {code} + catch ME + disp(['MATLAB_ERROR: ' ME.message]); + end + exit; % Ensure MATLAB exits + """ + mock_exec.assert_called_once_with( + "matlab", "-batch", expected_matlab_code, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + +@pytest.mark.asyncio +async def test_execute_matlab_code_text_script_error(): + # Simulate MATLAB itself reporting an error via our convention + matlab_output_with_error = "MATLAB_ERROR: Undefined function 'foo'." + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(matlab_output_with_error.encode(), b"")) + mock_process.returncode = 0 # MATLAB might exit 0 if try/catch handles the error + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)): + code = "foo;" # Some invalid code + output, _ = await execute_matlab_code(code, output_format="text") + assert "MATLAB_ERROR: Undefined function 'foo'." in output + +@pytest.mark.asyncio +async def test_execute_matlab_code_process_error(): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"", b"MATLAB process crashed")) + mock_process.returncode = 1 # Non-zero exit code + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)): + code = "some_code;" + with pytest.raises(RuntimeError, match="MATLAB process exited with code 1"): + await execute_matlab_code(code, output_format="text") + +@pytest.mark.asyncio +async def test_execute_matlab_code_matlab_not_found_error(): + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=False)): + with pytest.raises(RuntimeError, match="MATLAB is not installed"): + await execute_matlab_code("disp(1)", output_format="text") + +@pytest.mark.asyncio +async def test_execute_matlab_code_plot_success(): + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"Plot generated", b"")) # stdout from matlab + mock_process.returncode = 0 + + dummy_image_data = b"dummy_png_data" + encoded_dummy_image_data = "ZHVtbXlfcG5nX2RhdGE=" # base64 of "dummy_png_data" + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)) as mock_exec, \ + patch("tempfile.mkstemp", MagicMock(return_value=(0, "/tmp/fakeplot.png"))), \ + patch("os.path.exists", MagicMock(return_value=True)), \ + patch("os.path.getsize", MagicMock(return_value=len(dummy_image_data))), \ + patch("builtins.open", mock_open(read_data=dummy_image_data)), \ + patch("os.remove", MagicMock()) as mock_remove, \ + patch("os.close", MagicMock()): # Need to mock os.close for tempfile.mkstemp + + code = "plot(1:10);" + output, script_stdout = await execute_matlab_code(code, output_format="plot") + assert output == encoded_dummy_image_data + assert script_stdout == "Plot generated" # The stdout from the MATLAB process + + # Check that the MATLAB code includes plot saving + # The actual path will be /tmp/fakeplot.png + # The path in MATLAB command should be /tmp/fakeplot.png (already sanitized) + expected_matlab_code_fragment = "print('-dpng', '/tmp/fakeplot.png');" + called_matlab_code = mock_exec.call_args[0][2] + assert expected_matlab_code_fragment in called_matlab_code + mock_remove.assert_called_once_with("/tmp/fakeplot.png") + + +@pytest.mark.asyncio +async def test_execute_matlab_code_plot_no_figure_warning(): + matlab_output = "MATLAB_WARNING: No figure was generated to save." + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(matlab_output.encode(), b"")) + mock_process.returncode = 0 + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)), \ + patch("tempfile.mkstemp", MagicMock(return_value=(0, "/tmp/fakeplot.png"))), \ + patch("os.path.exists", MagicMock(return_value=False)), # Simulate plot file not created + patch("os.remove", MagicMock()) as mock_remove, \ + patch("os.close", MagicMock()): + + code = "disp('No plot');" # Code that doesn't plot + image_data, stdout_from_script = await execute_matlab_code(code, output_format="plot") + assert image_data == "" # Empty string for image data + assert "MATLAB_WARNING: No figure was generated to save." in stdout_from_script + mock_remove.assert_called_once_with("/tmp/fakeplot.png") # cleanup still attempted + +@pytest.mark.asyncio +async def test_execute_matlab_code_plot_file_not_created_error(): + mock_process = AsyncMock() + # Simulate MATLAB producing some output but no specific warning about no figure + mock_process.communicate = AsyncMock(return_value=(b"Some output from MATLAB", b"")) + mock_process.returncode = 0 + + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)), \ + patch("tempfile.mkstemp", MagicMock(return_value=(0, "/tmp/fakeplot.png"))), \ + patch("os.path.exists", MagicMock(return_value=False)), # Simulate plot file not created + patch("os.remove", MagicMock()) as mock_remove, \ + patch("os.close", MagicMock()): + + code = "plot(1:10);" # Assume this should create a plot + with pytest.raises(RuntimeError, match="Failed to generate plot"): + await execute_matlab_code(code, output_format="plot") + mock_remove.assert_called_once_with("/tmp/fakeplot.png") # cleanup still attempted + + +# --- Tests for the MCP Tool --- + +@pytest.mark.asyncio +async def test_execute_matlab_tool_text_success(): + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("matlab_server.server.execute_matlab_code", AsyncMock(return_value=("Test output", None))) as mock_exec_code: + + result = await execute_matlab_tool(code="disp('hello')", output_format="text") + assert isinstance(result, TextContent) + assert result.text == "Test output" + mock_exec_code.assert_called_once_with("disp('hello')", "text") + +@pytest.mark.asyncio +async def test_execute_matlab_tool_plot_success(): + base64_image_data = "fake_base64_image_data" + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("matlab_server.server.execute_matlab_code", AsyncMock(return_value=(base64_image_data, "Plot done"))) as mock_exec_code: + + result = await execute_matlab_tool(code="plot(1:10)", output_format="plot") + assert isinstance(result, ImageContent) + assert result.format == "png" + assert result.base64 == base64_image_data + mock_exec_code.assert_called_once_with("plot(1:10)", "plot") + +@pytest.mark.asyncio +async def test_execute_matlab_tool_matlab_not_installed(): + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=False)): + result = await execute_matlab_tool(code="disp(1)", output_format="text") + assert isinstance(result, ErrorContent) + assert "MATLAB (matlab command) is not installed" in result.message + +@pytest.mark.asyncio +async def test_execute_matlab_tool_empty_code(): + # check_matlab_installation will be called, so mock it + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)): + result = await execute_matlab_tool(code="", output_format="text") + assert isinstance(result, ErrorContent) + assert result.message == "MATLAB code cannot be empty." + +@pytest.mark.asyncio +async def test_execute_matlab_tool_execution_error(): + # This error is raised from execute_matlab_code + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("matlab_server.server.execute_matlab_code", AsyncMock(side_effect=RuntimeError("MATLAB crashed"))) as mock_exec_code: + + result = await execute_matlab_tool(code="bad_code", output_format="text") + assert isinstance(result, ErrorContent) + assert result.message == "MATLAB crashed" + +@pytest.mark.asyncio +async def test_execute_matlab_tool_plot_no_figure_generated_error(): + # Simulate execute_matlab_code returning empty image data and a warning + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("matlab_server.server.execute_matlab_code", AsyncMock(return_value=("", "MATLAB_WARNING: No figure was generated to save."))) as mock_exec_code: + + result = await execute_matlab_tool(code="disp('no plot')", output_format="plot") + assert isinstance(result, ErrorContent) + assert "MATLAB code executed, but no plot was generated." in result.message + # If the warning is more specific, it might be part of the message + # assert "Output: MATLAB_WARNING: No figure was generated to save." in result.message + +@pytest.mark.asyncio +async def test_execute_matlab_tool_plot_other_error_no_image(): + # Simulate execute_matlab_code returning empty image data and some other script output + with patch("matlab_server.server.check_matlab_installation", AsyncMock(return_value=True)), \ + patch("matlab_server.server.execute_matlab_code", AsyncMock(return_value=("", "Some other output"))) as mock_exec_code: + + result = await execute_matlab_tool(code="plot_maybe_fails", output_format="plot") + assert isinstance(result, ErrorContent) + assert "MATLAB code executed, but no plot was generated. Output: Some other output" in result.message + +# It might be useful to also test the main() function if it had more complex logic, +# but here it's just calling mcp.run() and logging. +# Testing mcp.run() itself is out of scope for this unit test. + +# Ensure that the patch for _matlab_checked and _matlab_available in the server module works +@pytest.mark.asyncio +async def test_global_state_reset_mechanism(): + import matlab_server.server + # Set to non-default values + matlab_server.server._matlab_checked = True + matlab_server.server._matlab_available = True + + # Fixture `reset_globals` runs before and after this test. + # We check the state before it runs its "after" part. + # This test itself doesn't do much other than verify the fixture's setup. + # The actual reset is tested by other tests running cleanly. + # If we want to be very explicit: + reset_matlab_check_state() # Manually call reset + assert matlab_server.server._matlab_checked is False + assert matlab_server.server._matlab_available is False + + # Simulate a check + mock_process = AsyncMock() + mock_process.communicate = AsyncMock(return_value=(b"MATLAB OK", b"")) + mock_process.returncode = 0 + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_process)): + await check_matlab_installation() + assert matlab_server.server._matlab_checked is True + assert matlab_server.server._matlab_available is True + + # The fixture will reset these after the test. + # We can add another test that runs after this one to ensure it sees the reset state, + # but pytest runs tests in isolation regarding fixtures like this when used with autouse=True. +``` + +I've created the `test_server.py` file with a comprehensive suite of tests covering `check_matlab_installation`, `execute_matlab_code` (for text and plot outputs, including various success and error scenarios), and the `execute_matlab` MCP tool. + +The tests use `unittest.mock.patch` and `unittest.mock.AsyncMock` extensively to simulate interactions with the MATLAB process and file system operations, making the tests independent of an actual MATLAB installation. +I also added a fixture to reset the global state variables `_matlab_checked` and `_matlab_available` between tests to ensure test isolation. + +To run these tests, one would typically navigate to the `servers/matlab-server` directory (or the repo root, depending on pytest configuration) and run `pytest`. The import `from matlab_server.server import ...` assumes that the `servers/matlab-server/src` directory is added to `PYTHONPATH` or that pytest handles it. This might require a `pytest.ini` or adjustments to `pyproject.toml` for pytest to correctly discover the `matlab_server` module if running pytest from the `servers/matlab-server` directory. + +A simple `pytest.ini` in `servers/matlab-server/` could be: +```ini +[pytest] +pythonpath = src +``` +I'll create this `pytest.ini` as well. diff --git a/servers/matlab-server/uv.lock b/servers/matlab-server/uv.lock new file mode 100644 index 0000000..e69de29