Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ cd mip-mcp
pip install -e .
```

#### Using uvx with GitHub Repository

For direct execution without local installation:

```bash
# Method 1: Automatic dependency installation (recommended)
uvx --from git+https://github.com/ohtaman/mip-mcp.git mip-mcp

# Method 2: Manual setup if automatic installation fails
git clone https://github.com/ohtaman/mip-mcp.git
cd mip-mcp
npm install # Install Node.js dependencies
uvx --from . mip-mcp
```

**Note**: When using uvx with GitHub repositories, Node.js dependencies (pyodide) are automatically installed on first run. If automatic installation fails, ensure you have npm installed and run `npm install` manually.

### Quick Start

Start the MCP server:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
"pulp>=2.7.0",
"pydantic>=2.0.0",
"pyyaml>=6.0",
"aiohttp>=3.8.0",
]

[project.optional-dependencies]
Expand Down
17 changes: 12 additions & 5 deletions src/mip_mcp/executor/pyodide_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ..models.responses import SolverProgress
from ..utils.library_detector import MIPLibrary, MIPLibraryDetector
from ..utils.logger import get_logger
from ..utils.pyodide_manager import PyodideManager

logger = get_logger(__name__)

Expand Down Expand Up @@ -156,12 +157,18 @@ async def _initialize_pyodide(self) -> None:
try:
logger.info("Initializing Pyodide environment...")

# Find pyodide path first
pyodide_path = await self._find_pyodide_path()
# Get pyodide path from manager (should be available from server startup)
pyodide_path = PyodideManager.get_pyodide_path()
if not pyodide_path:
raise RuntimeError(
"Pyodide module not found. Please install: npm install pyodide"
)
# Fallback: try to ensure pyodide is available
logger.info("Pyodide not ready, attempting to initialize...")
if await PyodideManager.ensure_pyodide_available():
pyodide_path = PyodideManager.get_pyodide_path()

if not pyodide_path:
raise RuntimeError(
"Pyodide module not found. Server may not have initialized properly."
)

logger.info(f"Found Pyodide at: {pyodide_path}")

Expand Down
4 changes: 2 additions & 2 deletions src/mip_mcp/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def shutdown_handler(signum, frame):
server = MIPMCPServer()

try:
# Let FastMCP handle everything naturally
server.app.run(show_banner=False)
# Use server.run() method for proper initialization
server.run(show_banner=False)
except KeyboardInterrupt:
# This shouldn't be reached due to our signal handler
print("\nShutdown complete.", file=sys.stderr)
Expand Down
62 changes: 62 additions & 0 deletions src/mip_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .utils.config_manager import ConfigManager
from .utils.executor_registry import ExecutorRegistry
from .utils.logger import get_logger, setup_logging
from .utils.pyodide_manager import PyodideManager

logger = get_logger(__name__)

Expand All @@ -44,6 +45,9 @@ def __init__(self, config_path: str | None = None):
# Initialize FastMCP app
self.app = FastMCP("mip-mcp")

# Initialize Pyodide (async, will be called during run)
self._pyodide_initialized = False

# Setup cleanup hooks
self._setup_cleanup_hooks()

Expand Down Expand Up @@ -180,8 +184,66 @@ async def get_mip_examples(ctx: Context) -> ExamplesResponse:

logger.info("MCP tools registered successfully")

async def _initialize_pyodide(self):
"""Initialize Pyodide during server startup."""
if self._pyodide_initialized:
return

logger.info("Initializing Pyodide environment...")
success = await PyodideManager.ensure_pyodide_available()

if success:
pyodide_path = PyodideManager.get_pyodide_path()
logger.info(f"Pyodide ready at: {pyodide_path}")
self._pyodide_initialized = True
else:
logger.warning(
"Pyodide initialization failed. Code execution may not work properly. "
"Consider installing Node.js and running 'npm install pyodide'"
)

def run(self, show_banner: bool = True):
"""Run the MCP server."""
logger.info("Starting MIP MCP Server...")

# Check Pyodide availability at startup for better user experience
self._check_pyodide_sync()

# Let FastMCP handle signals and shutdown naturally
self.app.run(show_banner=show_banner)

def _check_pyodide_sync(self):
"""Quick synchronous check for Pyodide availability."""
try:
# Check bundled first
bundled_path = PyodideManager._check_bundled_pyodide()
if bundled_path:
PyodideManager._pyodide_path = bundled_path
logger.info(f"βœ“ Pyodide ready (bundled): {bundled_path}")
return

# Check various locations for package.json
from pathlib import Path

possible_roots = [
Path.cwd(), # Current working directory
Path(
__file__
).parent.parent.parent, # Project root relative to this file
Path.cwd(), # Alternative current directory
]

package_json_found = False
for root in possible_roots:
if (root / "package.json").exists():
logger.info(f"βœ“ Pyodide setup ready - auto-install from {root}")
package_json_found = True
break

if not package_json_found:
logger.info(
"Pyodide will be downloaded automatically when needed. "
"For fastest startup, consider: npm install pyodide"
)
except Exception as e:
logger.error(f"Error during Pyodide availability check: {e}")
209 changes: 209 additions & 0 deletions src/mip_mcp/utils/pyodide_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""Pyodide installation and management utilities."""

import asyncio
import logging
import shutil
import tempfile
from pathlib import Path

logger = logging.getLogger(__name__)


class PyodideManager:
"""Manages Pyodide installation and availability."""

_pyodide_path: str | None = None
_initialization_lock = asyncio.Lock()

@classmethod
async def ensure_pyodide_available(cls) -> bool:
"""Ensure Pyodide is available, downloading if necessary.

Returns:
True if Pyodide is available, False otherwise.
"""
async with cls._initialization_lock:
if cls._pyodide_path:
return True

logger.info("Checking Pyodide availability...")

# Check bundled first
bundled_path = cls._check_bundled_pyodide()
if bundled_path:
cls._pyodide_path = bundled_path
logger.info(f"Using bundled Pyodide at: {bundled_path}")
return True

# Check npm installation
npm_path = await cls._find_npm_pyodide()
if npm_path:
cls._pyodide_path = npm_path
logger.info(f"Using npm-installed Pyodide at: {npm_path}")
return True

# Auto-install if possible
if await cls._auto_install_pyodide():
# Try npm path again after installation
npm_path = await cls._find_npm_pyodide()
if npm_path:
cls._pyodide_path = npm_path
logger.info(f"Auto-installed Pyodide at: {npm_path}")
return True

# Download directly as fallback
downloaded_path = await cls._download_pyodide()
if downloaded_path:
cls._pyodide_path = downloaded_path
logger.info(f"Downloaded Pyodide to: {downloaded_path}")
return True

logger.error("Failed to make Pyodide available")
return False

@classmethod
def get_pyodide_path(cls) -> str | None:
"""Get the current Pyodide path."""
return cls._pyodide_path

@classmethod
def _check_bundled_pyodide(cls) -> str | None:
"""Check for bundled pyodide installation (from wheel)."""
try:
import pkg_resources

pyodide_js_path = pkg_resources.resource_filename(
"mip_mcp", "data/pyodide/pyodide.js"
)
if Path(pyodide_js_path).exists():
return pyodide_js_path
except (ImportError, FileNotFoundError):
pass
return None

@classmethod
async def _find_npm_pyodide(cls) -> str | None:
"""Find pyodide installation via npm/node."""
try:
proc = await asyncio.create_subprocess_exec(
"node",
"-e",
"""
try {
console.log(require.resolve('pyodide'));
} catch (e) {
// Try alternative paths
const path = require('path');
const fs = require('fs');

const searchPaths = [
path.join(process.cwd(), 'node_modules', 'pyodide'),
path.join(process.cwd(), '..', 'node_modules', 'pyodide'),
path.join(process.cwd(), '..', '..', 'node_modules', 'pyodide'),
path.join(__dirname, '..', '..', 'node_modules', 'pyodide')
];

for (const searchPath of searchPaths) {
const pyodidePath = path.join(searchPath, 'pyodide.js');
if (fs.existsSync(pyodidePath)) {
console.log(pyodidePath);
process.exit(0);
}
}
process.exit(1);
}
""",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await proc.communicate()

if proc.returncode == 0:
pyodide_path = stdout.decode().strip()
if Path(pyodide_path).exists():
return pyodide_path

except Exception as e:
logger.debug(f"Failed to find npm pyodide: {e}")

return None

@classmethod
async def _auto_install_pyodide(cls) -> bool:
"""Auto-install pyodide via npm if possible."""
if not shutil.which("npm"):
logger.debug("npm not available for auto-installation")
return False

# Find project root with package.json
project_root = cls._find_project_root()
if not project_root or not (project_root / "package.json").exists():
logger.debug("No package.json found for npm install")
return False

try:
logger.info("Auto-installing Pyodide via npm...")
proc = await asyncio.create_subprocess_exec(
"npm",
"install",
cwd=project_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await proc.communicate()

if proc.returncode == 0:
logger.info("npm install completed successfully")
return True
else:
logger.warning(f"npm install failed: {stderr.decode()}")
return False

except Exception as e:
logger.warning(f"Auto-installation failed: {e}")
return False

@classmethod
def _find_project_root(cls) -> Path | None:
"""Find project root by looking for package.json."""
current = Path(__file__).parent
while current != current.parent:
if (current / "package.json").exists():
return current
current = current.parent
return None

@classmethod
async def _download_pyodide(cls) -> str | None:
"""Download Pyodide directly as a fallback."""
try:
import aiohttp

# Create a temporary directory for pyodide
temp_dir = Path(tempfile.gettempdir()) / "mip-mcp-pyodide"
temp_dir.mkdir(exist_ok=True)

pyodide_js = temp_dir / "pyodide.js"
if pyodide_js.exists():
return str(pyodide_js)

# Download Pyodide
logger.info("Downloading Pyodide...")
async with aiohttp.ClientSession() as session:
# Download pyodide.js directly (minimal version)
url = "https://cdn.jsdelivr.net/pyodide/v0.28.0/full/pyodide.js"
async with session.get(url) as response:
if response.status == 200:
with pyodide_js.open("wb") as f:
f.write(await response.read())
logger.info("Pyodide downloaded successfully")
return str(pyodide_js)

except ImportError:
logger.debug("aiohttp not available for download")
except Exception as e:
logger.warning(f"Failed to download Pyodide: {e}")

return None
Loading