From 50cb34ce490d9b3cdf391dd70496bfc658a9780f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:00:53 +0000 Subject: [PATCH 1/3] Initial plan From c04b3c9761aef9ca507ff18f46df558de7463a44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:04:36 +0000 Subject: [PATCH 2/3] Implement Injector class with full functionality and tests Co-authored-by: benoit-cty <6603048+benoit-cty@users.noreply.github.com> --- README.md | 149 ++++++++++++++++++++ examples/basic_usage.py | 154 +++++++++++++++++++++ pyproject.toml | 43 ++++++ src/mcp_remote_run/__init__.py | 6 + src/mcp_remote_run/injector.py | 243 +++++++++++++++++++++++++++++++++ tests/__init__.py | 1 + tests/test_injector.py | 205 +++++++++++++++++++++++++++ 7 files changed, 801 insertions(+) create mode 100644 examples/basic_usage.py create mode 100644 pyproject.toml create mode 100644 src/mcp_remote_run/__init__.py create mode 100644 src/mcp_remote_run/injector.py create mode 100644 tests/__init__.py create mode 100644 tests/test_injector.py diff --git a/README.md b/README.md index 2124eb2..6069871 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,151 @@ # mcp-remote-run + MCP Server to run code on a remote server and monitor it with CodeCarbon. + +## Features + +- **Code Injection**: Inject variables, dependencies, and code into Python scripts using AST manipulation +- **Temporary File Management**: Automatic cleanup of temporary files +- **Method Chaining**: Fluent API for multiple operations +- **Context Manager Support**: Use with `with` statement for automatic cleanup + +## Installation + +```bash +pip install mcp-remote-run +``` + +For development: + +```bash +pip install -e ".[dev]" +``` + +## Usage + +### Basic Variable Injection + +```python +from mcp_remote_run import Injector + +# Inject variables into code +with Injector(code="print(x + y)") as injector: + injector.inject_variables({"x": 10, "y": 20}) + print(injector.get_code()) + # Output: + # x = 10 + # y = 20 + # print(x + y) +``` + +### Adding Dependencies + +```python +from mcp_remote_run import Injector + +code = "import requests\nprint(requests.__version__)" +with Injector(code=code) as injector: + injector.add_dependency(["requests", "numpy"]) + print(injector.get_code()) + # Output: + # import os + # os.system("pip install requests numpy") + # import requests + # print(requests.__version__) +``` + +### Function Injection + +```python +from mcp_remote_run import Injector + +code = """ +def calculate(): + pass +""" + +with Injector(code=code) as injector: + new_body = "return 42" + injector.inject_function(new_body, "calculate") + print(injector.get_code()) + # Output: + # def calculate(): + # return 42 +``` + +### Method Chaining + +```python +from mcp_remote_run import Injector + +code = """ +def process(): + pass +""" + +with Injector(code=code) as injector: + injector.inject_variables({"x": 5}) \ + .add_dependency(["pandas"]) \ + .inject_function("return x * 2", "process") + + # Execute the modified code + temp_file = injector.get_temp_file_path() + # ... run temp_file remotely +``` + +### Working with Files + +```python +from mcp_remote_run import Injector + +# Load from file +injector = Injector(python_file_path="script.py") +injector.inject_variables({"api_key": "secret"}) + +# Get temporary file path for execution +temp_path = injector.get_temp_file_path() +print(f"Modified script at: {temp_path}") + +# Clean up when done +injector.destroy() +``` + +## API Reference + +### `Injector` + +#### Constructor + +```python +Injector( + python_file_path: str = None, + code: str = None, + module: cst.Module = None, + filename: str = "script.py" +) +``` + +- `python_file_path`: Path to existing Python file +- `code`: Python code as string +- `module`: Pre-parsed libcst Module object +- `filename`: Name for temporary file (when using `code` or `module`) + +#### Methods + +- `inject_variables(variables: Dict[str, any])`: Inject variable assignments +- `add_dependency(packages: list)`: Add pip install commands +- `inject_function(code: str, func_name: str)`: Replace function body +- `get_code()`: Get modified code as string +- `get_temp_file_path()`: Get path to temporary file +- `get_temp_dir()`: Get path to temporary directory +- `destroy()`: Clean up temporary files + +## Testing + +```bash +pytest tests/ -v +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..2035b9a --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,154 @@ +""" +Example usage of the Injector class for code injection and modification. +""" + +from mcp_remote_run import Injector + + +def example_variable_injection(): + """Demonstrate variable injection""" + print("=== Example 1: Variable Injection ===") + + code = """ +def main(): + print(f"x = {x}, y = {y}") + print(f"Sum: {x + y}") + +if __name__ == "__main__": + main() +""" + + with Injector(code=code) as injector: + injector.inject_variables({"x": 10, "y": 20}) + print("Modified code:") + print(injector.get_code()) + print() + + +def example_dependency_injection(): + """Demonstrate dependency injection""" + print("=== Example 2: Dependency Injection ===") + + code = """ +import json + +def process_data(): + data = {"message": "Hello, World!"} + print(json.dumps(data)) + +if __name__ == "__main__": + process_data() +""" + + with Injector(code=code) as injector: + injector.add_dependency(["requests", "pandas"]) + print("Modified code:") + print(injector.get_code()) + print() + + +def example_function_injection(): + """Demonstrate function body injection""" + print("=== Example 3: Function Injection ===") + + code = """ +def calculate(): + pass + +def main(): + result = calculate() + print(f"Result: {result}") + +if __name__ == "__main__": + main() +""" + + with Injector(code=code) as injector: + new_body = """ +a = 10 +b = 20 +return a * b +""" + injector.inject_function(new_body.strip(), "calculate") + print("Modified code:") + print(injector.get_code()) + print() + + +def example_chaining(): + """Demonstrate method chaining""" + print("=== Example 4: Method Chaining ===") + + code = """ +def process(): + pass + +if __name__ == "__main__": + result = process() + print(f"Result: {result}") +""" + + with Injector(code=code) as injector: + injector.inject_variables({"multiplier": 5}) \ + .add_dependency(["numpy"]) \ + .inject_function("return multiplier * 10", "process") + + print("Modified code:") + print(injector.get_code()) + print() + + +def example_complete_workflow(): + """Demonstrate a complete workflow""" + print("=== Example 5: Complete Workflow ===") + + code = """ +def run_experiment(): + pass + +if __name__ == "__main__": + run_experiment() +""" + + with Injector(code=code) as injector: + # Step 1: Inject configuration variables + config = { + "epochs": 10, + "batch_size": 32, + "learning_rate": 0.001 + } + injector.inject_variables(config) + + # Step 2: Add required dependencies + injector.add_dependency(["codecarbon", "torch"]) + + # Step 3: Inject experiment code + experiment_code = """ +from codecarbon import EmissionsTracker + +tracker = EmissionsTracker() +tracker.start() + +# Simulate training +for epoch in range(epochs): + print(f"Epoch {epoch + 1}/{epochs}") + print(f"Batch size: {batch_size}, LR: {learning_rate}") + +tracker.stop() +print("Experiment complete!") +""" + injector.inject_function(experiment_code.strip(), "run_experiment") + + print("Final modified code:") + print(injector.get_code()) + print() + print(f"Temporary file created at: {injector.get_temp_file_path()}") + print("This file can be sent to a remote server for execution.") + + +if __name__ == "__main__": + example_variable_injection() + example_dependency_injection() + example_function_injection() + example_chaining() + example_complete_workflow() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..47e38d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-remote-run" +version = "0.1.0" +description = "MCP Server to run code on a remote server and monitor it with CodeCarbon" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "MLCo2", email = "contact@mlco2.org"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "libcst>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + +[tool.setuptools] +packages = ["mcp_remote_run"] +package-dir = {"" = "src"} + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/src/mcp_remote_run/__init__.py b/src/mcp_remote_run/__init__.py new file mode 100644 index 0000000..d43d254 --- /dev/null +++ b/src/mcp_remote_run/__init__.py @@ -0,0 +1,6 @@ +"""MCP Remote Run - MCP Server to run code on a remote server and monitor it with CodeCarbon.""" + +from .injector import Injector + +__version__ = "0.1.0" +__all__ = ["Injector"] diff --git a/src/mcp_remote_run/injector.py b/src/mcp_remote_run/injector.py new file mode 100644 index 0000000..1a30ece --- /dev/null +++ b/src/mcp_remote_run/injector.py @@ -0,0 +1,243 @@ +import libcst as cst +import tempfile +import os +import shutil +from typing import Dict, Optional + + +class Injector: + """ + Unified injector for Python files using libcst. + Handles both variable and function injection. + All operations work on temp files automatically. + """ + + def __init__( + self, + python_file_path: str = None, + code: str = None, + module: cst.Module = None, + filename: str = "script.py" + ): + """ + Args: + python_file_path: Path to original Python file (read-only, copied to temp) + code: Python code as string (alternative to file_path) + module: CST Module object (most efficient - no parsing needed) + filename: Name for temp file (used when code/module is provided) + """ + # Validate arguments + provided = sum([bool(python_file_path), bool(code), bool(module)]) + if provided > 1: + raise ValueError("Cannot provide multiple sources (python_file_path, code, or module)") + if provided == 0: + raise ValueError("Must provide either python_file_path, code, or module") + + # Get module from file, string, or use provided CST Module + if python_file_path: + self.python_file_path = python_file_path + # Read original file (read-only) + with open(python_file_path, 'r', encoding='utf-8') as f: + self._original_code = f.read() + temp_filename = os.path.basename(python_file_path) + # Parse using libcst + self._module = cst.parse_module(self._original_code) + elif module: + self.python_file_path = None + # Use provided CST Module (no parsing needed!) + self._module = module + self._original_code = module.code + temp_filename = filename + else: # code string + self.python_file_path = None + self._original_code = code + # Parse using libcst + self._module = cst.parse_module(code) + temp_filename = filename + + # Create temp directory and file immediately + self._temp_dir = tempfile.mkdtemp() + self._temp_file_path = os.path.join(self._temp_dir, temp_filename) + + # Write initial copy to temp file + with open(self._temp_file_path, 'w', encoding='utf-8') as f: + f.write(self._original_code) + + # File pointer is closed, all future ops use temp file + + def _create_value_node(self, value): + """Helper to create CST value node from Python value""" + type_map = { + str: lambda v: cst.SimpleString(f'"{v}"'), + int: lambda v: cst.Integer(str(v)), + float: lambda v: cst.Float(str(v)), + bool: lambda v: cst.Name("True" if v else "False"), + type(None): lambda v: cst.Name("None"), + } + return type_map.get(type(value), lambda v: cst.SimpleString(f'"{str(v)}"'))(value) + + def inject_variables(self, variables: Dict[str, any]): + """ + Inject variable assignments into the file. + + Args: + variables: Dictionary of variable names and values + at_top: If True, injects at top of file; if False, at end + + Returns: + self (for chaining) + """ + assignments = [ + cst.SimpleStatementLine(body=[ + cst.Assign( + targets=[cst.AssignTarget(target=cst.Name(var_name))], + value=self._create_value_node(var_value) + ) + ]) + for var_name, var_value in variables.items() + ] + + # Apply transformation directly by modifying module body + new_body = list(self._module.body) + # Insert at beginning + new_body = assignments + new_body + + self._module = self._module.with_changes(body=new_body) + self._save_to_temp() + + return self + + def add_dependency(self, packages: list): + """ + Add pip install command at the top of the file using os.system. + Also ensures 'import os' is present. + + Args: + packages: List of package names to install + + Returns: + self (for chaining) + """ + if not packages: + return self + + # Check if 'import os' already exists + has_os_import = False + for item in self._module.body: + if isinstance(item, cst.SimpleStatementLine): + for stmt in item.body: + if isinstance(stmt, cst.Import): + for alias in stmt.names: + if alias.name.value == 'os': + has_os_import = True + break + elif isinstance(stmt, cst.ImportFrom) and stmt.module and stmt.module.value == 'os': + has_os_import = True + break + + # Create pip install command + packages_str = ' '.join(packages) + pip_command = f'pip install {packages_str}' + + # Create os.system call + os_system_call = cst.SimpleStatementLine(body=[ + cst.Expr(value=cst.Call( + func=cst.Attribute( + value=cst.Name('os'), + attr=cst.Name('system') + ), + args=[cst.Arg(value=cst.SimpleString(f'"{pip_command}"'))] + )) + ]) + + # Build new body + new_body = list(self._module.body) + + # Add import os if not present + if not has_os_import: + os_import = cst.SimpleStatementLine(body=[ + cst.Import(names=[cst.ImportAlias(name=cst.Name('os'))]) + ]) + new_body.insert(0, os_import) + # Insert os.system call after import + new_body.insert(1, os_system_call) + else: + # Just insert os.system call at top + new_body.insert(0, os_system_call) + + self._module = self._module.with_changes(body=new_body) + self._save_to_temp() + + return self + + def inject_function(self, code: str, func_name: str): + """ + Inject code into existing function's body by replacing its body content. + + Args: + code: Python code string to inject into function body + func_name: Name of the existing function to modify + + Returns: + self (for chaining) + """ + # Parse injected code as module to get statements + injected_module = cst.parse_module(code) + body_statements = list(injected_module.body) + + # Replace function body directly + new_body = [ + item.with_changes(body=cst.IndentedBlock(body=body_statements)) + if isinstance(item, cst.FunctionDef) and item.name.value == func_name + else item + for item in self._module.body + ] + self._module = self._module.with_changes(body=new_body) + + self._save_to_temp() + return self + + def _save_to_temp(self): + """Internal: Save modified code to temp file""" + with open(self._temp_file_path, 'w', encoding='utf-8') as f: + f.write(self._module.code) + + def get_temp_file_path(self) -> str: + """Get path to temporary file""" + return self._temp_file_path + + def get_temp_dir(self) -> str: + """Get path to temporary directory""" + return self._temp_dir + + def get_code(self) -> str: + """Get the modified code as string (for inspection)""" + return self._module.code + + def destroy(self): + """ + Destroy all temporary files and directory. + Call this when done with temp files. + """ + if self._temp_dir and os.path.exists(self._temp_dir): + shutil.rmtree(self._temp_dir) + self._temp_dir = None + self._temp_file_path = None + + def __del__(self): + """Automatically clean up temp files when object is destroyed""" + # Only destroy if temp_dir still exists (destroy() not already called) + if hasattr(self, '_temp_dir') and self._temp_dir and os.path.exists(self._temp_dir): + try: + shutil.rmtree(self._temp_dir) + except (OSError, AttributeError): + # Ignore errors during destruction (temp files may already be cleaned up) + pass + + def __enter__(self): + """Context manager support""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager cleanup""" + self.destroy() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d6cb620 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for mcp-remote-run diff --git a/tests/test_injector.py b/tests/test_injector.py new file mode 100644 index 0000000..d0d5436 --- /dev/null +++ b/tests/test_injector.py @@ -0,0 +1,205 @@ +import pytest +import os +import tempfile +from mcp_remote_run import Injector + + +def test_injector_with_code_string(): + """Test creating Injector with code string""" + code = "x = 1\nprint(x)" + injector = Injector(code=code) + + assert injector.get_code() == code + assert os.path.exists(injector.get_temp_file_path()) + + temp_dir = injector.get_temp_dir() + injector.destroy() + assert not os.path.exists(temp_dir) + + +def test_injector_with_file(): + """Test creating Injector with a file""" + # Create a temporary file + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write("x = 1\nprint(x)") + temp_file = f.name + + try: + injector = Injector(python_file_path=temp_file) + assert "x = 1" in injector.get_code() + assert os.path.exists(injector.get_temp_file_path()) + injector.destroy() + finally: + os.unlink(temp_file) + + +def test_inject_variables(): + """Test variable injection""" + code = "print('hello')" + injector = Injector(code=code) + + injector.inject_variables({"x": 10, "y": "test"}) + + result = injector.get_code() + assert "x = 10" in result + assert 'y = "test"' in result + assert "print('hello')" in result + + injector.destroy() + + +def test_inject_variables_different_types(): + """Test variable injection with different types""" + code = "pass" + injector = Injector(code=code) + + injector.inject_variables({ + "int_var": 42, + "float_var": 3.14, + "str_var": "hello", + "bool_var": True, + "none_var": None + }) + + result = injector.get_code() + assert "int_var = 42" in result + assert "float_var = 3.14" in result + assert 'str_var = "hello"' in result + assert "bool_var = True" in result + assert "none_var = None" in result + + injector.destroy() + + +def test_add_dependency_no_os_import(): + """Test adding dependencies when os is not imported""" + code = "print('hello')" + injector = Injector(code=code) + + injector.add_dependency(["requests", "numpy"]) + + result = injector.get_code() + assert "import os" in result + assert 'os.system("pip install requests numpy")' in result + assert "print('hello')" in result + + injector.destroy() + + +def test_add_dependency_with_os_import(): + """Test adding dependencies when os is already imported""" + code = "import os\nprint('hello')" + injector = Injector(code=code) + + injector.add_dependency(["pandas"]) + + result = injector.get_code() + # Should only have one import os + assert result.count("import os") == 1 + assert 'os.system("pip install pandas")' in result + + injector.destroy() + + +def test_add_dependency_empty_list(): + """Test adding dependencies with empty list""" + code = "print('hello')" + injector = Injector(code=code) + + injector.add_dependency([]) + + result = injector.get_code() + # Should not add any dependency-related code + assert result == code + + injector.destroy() + + +def test_inject_function(): + """Test injecting code into function""" + code = """def my_function(): + pass +""" + injector = Injector(code=code) + + new_code = "x = 10\nreturn x" + injector.inject_function(new_code, "my_function") + + result = injector.get_code() + assert "def my_function():" in result + assert "x = 10" in result + assert "return x" in result + assert "pass" not in result + + injector.destroy() + + +def test_method_chaining(): + """Test chaining multiple operations""" + code = """def my_function(): + pass +""" + injector = Injector(code=code) + + injector.inject_variables({"x": 5}).add_dependency(["requests"]).inject_function("return x * 2", "my_function") + + result = injector.get_code() + assert "x = 5" in result + assert "import os" in result + assert 'os.system("pip install requests")' in result + assert "return x * 2" in result + + injector.destroy() + + +def test_context_manager(): + """Test using Injector as context manager""" + code = "x = 1" + + with Injector(code=code) as injector: + temp_dir = injector.get_temp_dir() + assert os.path.exists(temp_dir) + + # After exiting context, temp dir should be cleaned up + assert not os.path.exists(temp_dir) + + +def test_get_temp_file_path(): + """Test getting temporary file path""" + code = "x = 1" + injector = Injector(code=code, filename="test.py") + + temp_file = injector.get_temp_file_path() + assert os.path.exists(temp_file) + assert temp_file.endswith("test.py") + + injector.destroy() + + +def test_invalid_multiple_sources(): + """Test that providing multiple sources raises error""" + with pytest.raises(ValueError, match="Cannot provide multiple sources"): + Injector(code="x = 1", python_file_path="test.py") + + +def test_invalid_no_source(): + """Test that providing no source raises error""" + with pytest.raises(ValueError, match="Must provide either"): + Injector() + + +def test_temp_file_modification(): + """Test that modifications are saved to temp file""" + code = "x = 1" + injector = Injector(code=code) + + injector.inject_variables({"y": 2}) + + # Read the temp file directly + with open(injector.get_temp_file_path(), 'r') as f: + file_content = f.read() + + assert "y = 2" in file_content + assert "x = 1" in file_content + + injector.destroy() From 1ffbed30ecc0010ad10f14acc8c68a12a07ec153 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:05:25 +0000 Subject: [PATCH 3/3] Fix type hints and documentation per code review feedback Co-authored-by: benoit-cty <6603048+benoit-cty@users.noreply.github.com> --- README.md | 2 +- src/mcp_remote_run/injector.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6069871..2a7cfb1 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Injector( #### Methods -- `inject_variables(variables: Dict[str, any])`: Inject variable assignments +- `inject_variables(variables: Dict[str, Any])`: Inject variable assignments - `add_dependency(packages: list)`: Add pip install commands - `inject_function(code: str, func_name: str)`: Replace function body - `get_code()`: Get modified code as string diff --git a/src/mcp_remote_run/injector.py b/src/mcp_remote_run/injector.py index 1a30ece..f7fdd0b 100644 --- a/src/mcp_remote_run/injector.py +++ b/src/mcp_remote_run/injector.py @@ -2,7 +2,7 @@ import tempfile import os import shutil -from typing import Dict, Optional +from typing import Dict, Optional, Any class Injector: @@ -76,13 +76,12 @@ def _create_value_node(self, value): } return type_map.get(type(value), lambda v: cst.SimpleString(f'"{str(v)}"'))(value) - def inject_variables(self, variables: Dict[str, any]): + def inject_variables(self, variables: Dict[str, Any]): """ Inject variable assignments into the file. Args: variables: Dictionary of variable names and values - at_top: If True, injects at top of file; if False, at end Returns: self (for chaining)