diff --git a/.github/workflows/mcp-python-release.yml b/.github/workflows/mcp-python-release.yml new file mode 100644 index 0000000..6e39f83 --- /dev/null +++ b/.github/workflows/mcp-python-release.yml @@ -0,0 +1,55 @@ +name: Docker Release (mcp-run-python) + +# Build and publish the MCP Run Python server Docker image to GHCR + +on: + push: + tags: + - "v*" + +permissions: + contents: read # allows reading from repo + packages: write # allows pushing to GHCR + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU (multi-arch builds) # needed to build for both amd64 and arm64 + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version/tag + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + VERSION=${TAG#v} + else + VERSION="latest" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository }}:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a5f005 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.13-slim-bookworm +COPY --from=ghcr.io/astral-sh/uv:0.8.8 /uv /uvx /bin/ +COPY --from=docker.io/denoland/deno:bin-2.5.6 /deno /bin + +# Copy the project into the image +ADD . /app + +# Sync the project into a new environment, using the frozen lockfile +WORKDIR /app + +# Prepare the python bits +# 'make install' also does something with precommit +RUN uv sync --frozen --compile-bytecode +# or rather 'make build'? +RUN uv run build/build.py + +# Prepare the deno bits - install all dependencies including npm packages +WORKDIR /app/mcp_run_python/deno +# Use deno install to download all dependencies into node_modules +RUN deno install --allow-scripts --entrypoint src/main.ts +# Also cache to ensure all deps are resolved +RUN deno cache src/main.ts + +# Pre-cache pyodide packages (micropip, pydantic) by running noop mode +# This downloads pyodide Python packages during build so they're available offline +# Skip on ARM64 due to Pyodide enum initialization bug +RUN if [ "$(uname -m)" != "aarch64" ]; then \ + deno run --allow-net --allow-read --allow-write=./node_modules --node-modules-dir=auto src/main.ts noop; \ + fi + +WORKDIR /app + +# Define default executable with --offline to prevent network calls +ENTRYPOINT ["uv", "run", "mcp-run-python", "--offline"] + +# Advertise default port used in default CMD +EXPOSE 3001 + +# By default start streamable-http on port 3001 +CMD ["--port=3001", "streamable-http"] diff --git a/mcp_run_python/_cli.py b/mcp_run_python/_cli.py index 69bab42..7d4c0bf 100644 --- a/mcp_run_python/_cli.py +++ b/mcp_run_python/_cli.py @@ -26,6 +26,9 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: parser.add_argument( '--disable-networking', action='store_true', help='Disable networking during execution of python code' ) + parser.add_argument( + '--offline', action='store_true', help='Run in offline mode using pre-cached dependencies (no network access)' + ) parser.add_argument('--verbose', action='store_true', help='Enable verbose logging') parser.add_argument('--version', action='store_true', help='Show version and exit') parser.add_argument( @@ -54,6 +57,7 @@ def cli_logic(args_list: Sequence[str] | None = None) -> int: dependencies=deps, deps_log_handler=deps_log_handler, verbose=bool(args.verbose), + offline=bool(args.offline), ) return return_code else: diff --git a/mcp_run_python/deno/src/runCode.ts b/mcp_run_python/deno/src/runCode.ts index 1be8681..42bbde0 100644 --- a/mcp_run_python/deno/src/runCode.ts +++ b/mcp_run_python/deno/src/runCode.ts @@ -2,6 +2,16 @@ import { loadPyodide, type PyodideInterface } from 'pyodide' import { preparePythonCode } from './prepareEnvCode.ts' import type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js' +import { dirname, fromFileUrl, join } from '@std/path' + +// Get the path to the pyodide package in node_modules for offline use +function getPyodideIndexURL(): string { + // Resolve the path to node_modules/pyodide relative to this file + const thisDir = dirname(fromFileUrl(import.meta.url)) + const pyodidePath = join(thisDir, '..', 'node_modules', 'pyodide') + // Return path with trailing slash (pyodide expects this format) + return pyodidePath + '/' +} export interface CodeFile { name: string @@ -86,6 +96,8 @@ export class RunCode { log: (level: LoggingLevel, data: string) => void, ): Promise { const pyodide = await loadPyodide({ + // Use local pyodide packages from node_modules for offline support + indexURL: getPyodideIndexURL(), stdout: (msg) => { log('info', msg) this.output.push(msg) diff --git a/mcp_run_python/main.py b/mcp_run_python/main.py index 1b95224..5dad3b0 100644 --- a/mcp_run_python/main.py +++ b/mcp_run_python/main.py @@ -28,6 +28,7 @@ def run_mcp_server( deps_log_handler: LogHandler | None = None, allow_networking: bool = True, verbose: bool = False, + offline: bool = False, ) -> int: """Install dependencies then run the mcp-run-python server. @@ -39,6 +40,7 @@ def run_mcp_server( deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether to allow networking when running provided python code. verbose: Log deno outputs to CLI + offline: Run in offline mode using pre-cached dependencies (no network access). """ stdout, stderr = None, None @@ -52,6 +54,7 @@ def run_mcp_server( return_mode=return_mode, deps_log_handler=deps_log_handler, allow_networking=allow_networking, + offline=offline, ) as env: if mode in ('streamable_http', 'streamable_http_stateless'): logger.info('Running mcp-run-python via %s on port %d...', mode, http_port) @@ -82,6 +85,7 @@ def prepare_deno_env( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + offline: bool = False, ) -> Iterator[DenoEnv]: """Prepare the deno environment for running the mcp-run-python server with Deno. @@ -97,6 +101,7 @@ def prepare_deno_env( deps_log_handler: Optional function to receive logs emitted while installing dependencies. allow_networking: Whether the prepared DenoEnv should allow networking when running code. Note that we always allow networking during environment initialization to install dependencies. + offline: Run in offline mode using pre-cached dependencies (no network access). Returns: Yields the deno environment details. @@ -105,23 +110,28 @@ def prepare_deno_env( try: src = Path(__file__).parent / 'deno' logger.debug('Copying from %s to %s...', src, cwd) - shutil.copytree(src, cwd, ignore=shutil.ignore_patterns('node_modules')) - logger.info('Installing dependencies %s...', dependencies) - - args = 'deno', *_deno_install_args(dependencies) - p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - stdout: list[str] = [] - if p.stdout is not None: - for line in p.stdout: - line = line.strip() - if deps_log_handler: - parts = line.split('|', 1) - level, msg = parts if len(parts) == 2 else ('info', line) - deps_log_handler(cast(LoggingLevel, level), msg) - stdout.append(line) - p.wait() - if p.returncode != 0: - raise RuntimeError(f'`deno run ...` returned a non-zero exit code {p.returncode}: {"".join(stdout)}') + if offline: + # In offline mode, include node_modules to use pre-cached dependencies + shutil.copytree(src, cwd) + logger.info('Running in offline mode with pre-cached dependencies') + else: + shutil.copytree(src, cwd, ignore=shutil.ignore_patterns('node_modules')) + logger.info('Installing dependencies %s...', dependencies) + + args = 'deno', *_deno_install_args(dependencies) + p = subprocess.Popen(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + stdout: list[str] = [] + if p.stdout is not None: + for line in p.stdout: + line = line.strip() + if deps_log_handler: + parts = line.split('|', 1) + level, msg = parts if len(parts) == 2 else ('info', line) + deps_log_handler(cast(LoggingLevel, level), msg) + stdout.append(line) + p.wait() + if p.returncode != 0: + raise RuntimeError(f'`deno run ...` returned a non-zero exit code {p.returncode}: {"".join(stdout)}') args = _deno_run_args( mode, @@ -129,6 +139,7 @@ def prepare_deno_env( dependencies=dependencies, return_mode=return_mode, allow_networking=allow_networking, + offline=offline, ) yield DenoEnv(cwd, args) @@ -145,6 +156,7 @@ async def async_prepare_deno_env( return_mode: Literal['json', 'xml'] = 'xml', deps_log_handler: LogHandler | None = None, allow_networking: bool = True, + offline: bool = False, ) -> AsyncIterator[DenoEnv]: """Async variant of `prepare_deno_env`.""" ct = await _asyncify( @@ -155,6 +167,7 @@ async def async_prepare_deno_env( return_mode=return_mode, deps_log_handler=deps_log_handler, allow_networking=allow_networking, + offline=offline, ) try: yield await _asyncify(ct.__enter__) @@ -184,9 +197,14 @@ def _deno_run_args( dependencies: list[str] | None = None, return_mode: Literal['json', 'xml'] = 'xml', allow_networking: bool = True, + offline: bool = False, ) -> list[str]: args = ['run'] + if offline: + # In offline mode, use cached dependencies only (no network access for downloads) + args += ['--cached-only'] if allow_networking: + # Allow network for server to listen on port (even in offline mode) args += ['--allow-net'] args += [ '--allow-read=./node_modules', diff --git a/smoke_test.sh b/smoke_test.sh new file mode 100755 index 0000000..945fb0f --- /dev/null +++ b/smoke_test.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# Smoke test for mcp-run-python server + +set -e + +HOST="${HOST:-localhost}" +PORT="${PORT:-3001}" +BASE_URL="http://${HOST}:${PORT}" + +echo "=== MCP Run Python Smoke Test ===" +echo "Testing server at ${BASE_URL}" +echo + +# Test 1: Check server is responding with initialize request +echo "[1/3] Checking server health (initialize)..." +curl -s --max-time 10 "${BASE_URL}/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -D /tmp/mcp_headers.txt \ + -o /tmp/mcp_init_response.txt \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}}' \ + 2>&1 || true + +echo "Response: $(cat /tmp/mcp_init_response.txt)" + +if grep -q '"serverInfo"' /tmp/mcp_init_response.txt; then + echo "PASS: Server initialized successfully" +else + echo "FAIL: Unexpected response" + exit 1 +fi + +# Extract session ID from response headers +SESSION_ID=$(grep -i 'mcp-session-id' /tmp/mcp_headers.txt | tr -d '\r' | awk '{print $2}' || true) +echo "Session ID: ${SESSION_ID:-none}" + +if [ -z "$SESSION_ID" ]; then + echo "WARN: No session ID found, trying stateless mode..." +fi + +# Build headers for subsequent requests +HEADERS=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") +if [ -n "$SESSION_ID" ]; then + HEADERS+=(-H "Mcp-Session-Id: ${SESSION_ID}") +fi + +# Test 2: List tools +echo +echo "[2/3] Listing available tools..." +curl -s --max-time 10 "${BASE_URL}/mcp" -X POST \ + "${HEADERS[@]}" \ + -o /tmp/mcp_tools_response.txt \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + 2>&1 || true + +echo "Response: $(cat /tmp/mcp_tools_response.txt)" + +if grep -q 'run_python_code' /tmp/mcp_tools_response.txt; then + echo "PASS: run_python_code tool available" +else + echo "FAIL: run_python_code tool not found" + exit 1 +fi + +# Test 3: Execute simple Python code +echo +echo "[3/3] Running Python code (1 + 1)..." +curl -s --max-time 30 "${BASE_URL}/mcp" -X POST \ + "${HEADERS[@]}" \ + -o /tmp/mcp_run_response.txt \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_python_code","arguments":{"python_code":"12 * 12"}}}' \ + 2>&1 || true + +echo "Response: $(cat /tmp/mcp_run_response.txt)" + +if grep -qE '(success|"2")' /tmp/mcp_run_response.txt; then + echo "PASS: Python code executed successfully" +else + echo "FAIL: Python execution failed" + exit 1 +fi + +echo +echo "=== All smoke tests passed ===" + +# Cleanup +rm -f /tmp/mcp_headers.txt /tmp/mcp_init_response.txt /tmp/mcp_tools_response.txt /tmp/mcp_run_response.txt