From 9d52955af7d29346144d0baa3f855e28f656963d Mon Sep 17 00:00:00 2001 From: Luke Markie Date: Mon, 18 Aug 2025 17:43:31 +0100 Subject: [PATCH 1/4] chore(docker): add opentrace support --- .github/dependabot.yml | 6 -- .github/workflows/cd.yaml | 115 ++++++--------------------------- .github/workflows/ci.yaml | 77 ++-------------------- Dockerfile | 40 +++++------- src/mcp_proxy/config_loader.py | 70 +++++++++++++------- 5 files changed, 90 insertions(+), 218 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b38df29..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 3d28f62..1c2e6f6 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -1,21 +1,9 @@ -name: Publish to container registries +name: Build and Publish Docker Image on: - release: - types: [published] - workflow_dispatch: push: - branches: - - main - paths: - - src/** - - Dockerfile - - pyproject.toml - pull_request: - paths: - - src/** - - Dockerfile - - pyproject.toml + tags: + - 'v*' jobs: docker-hub: @@ -25,103 +13,38 @@ jobs: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Checkout code + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 #v3.4.0 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca #v3.9.0 - - - name: Extract tags and labels for Docker - id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 - with: - images: ${{ github.repository }} - tags: | - type=sha,format=short,prefix=commit- - type=ref,event=tag - labels: | - maintainer="Sergey Parfenyuk" - org.opencontainers.image.source=https://github.com/sparfenyuk/mcp-proxy - org.opencontainers.image.description="Connect to MCP servers that run on SSE transport, or expose stdio servers as an SSE server using the MCP Proxy server." - org.opencontainers.image.licenses=MIT + uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 - if: github.event_name != 'pull_request' + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Build and push Docker image - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Clean Docker cache - if: github.event_name != 'pull_request' - run: | - docker system prune --force - - ghcr-io: - name: Push multi-arch Docker image to ghcr.io - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@4574d27a4764455b42196d70a065bc6853246a25 #v3.4.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca #v3.9.0 - - - name: Extract tags and labels for Docker + + - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }} + images: opentrace/mcp-proxy tags: | - type=sha,format=short,prefix=commit- - type=ref,event=tag - labels: | - maintainer="Sergey Parfenyuk" - org.opencontainers.image.source=https://github.com/sparfenyuk/mcp-proxy - org.opencontainers.image.description="Connect to MCP servers that run on SSE transport, or expose stdio servers as an SSE server using the MCP Proxy server." - org.opencontainers.image.licenses=MIT - - - name: Log in to GHCR - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 - if: github.event_name != 'pull_request' - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch - name: Build and push Docker image - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} + push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - cache-from: type=local,src=/tmp/.buildx-cache - cache-to: type=local,dest=/tmp/.buildx-cache - - - name: Clean Docker cache - if: github.event_name != 'pull_request' - run: | - docker system prune --force + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5738d92..e83ea29 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,12 +1,10 @@ name: CI on: - push: - branches: - - main - tags: - - "**" - pull_request: {} + workflow_dispatch: + pull_request: + branches: + - main env: CI: true @@ -67,50 +65,14 @@ jobs: with: python-version: ${{ matrix.python-version }} - - run: mkdir coverage + - run: uv run --frozen pytest - - run: uv run --frozen coverage run -m pytest - env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-standard - - - name: store coverage files - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: coverage-${{ matrix.python-version }} - path: coverage - include-hidden-files: true - - coverage: - runs-on: ubuntu-latest - needs: [test] - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: get coverage files - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - with: - merge-multiple: true - path: coverage - - - uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - with: - python-version: "3.12" - - - run: uv sync --frozen - - run: uv run --frozen coverage combine coverage - - run: uv run --frozen coverage xml - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - run: uv run --frozen coverage report --fail-under 83 # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [lint, test, coverage, mypy] + needs: [lint, test, mypy] runs-on: ubuntu-latest steps: @@ -120,30 +82,3 @@ jobs: jobs: ${{ toJSON(needs) }} allowed-skips: test-live - release: - needs: [check] - if: "success() && startsWith(github.ref, 'refs/tags/')" - runs-on: ubuntu-latest - environment: release - - permissions: - id-token: write - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - with: - python-version: "3.12" - - - name: check GITHUB_REF matches package version - uses: samuelcolvin/check-python-version@758a13b52c26833cffda0f2ed4f3c9e54d9186d9 # v4.1 - with: - version_file_path: pyproject.toml - - - run: uv build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 - with: - skip-existing: true diff --git a/Dockerfile b/Dockerfile index ff2bcdb..65601a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,27 @@ -# Build stage with explicit platform specification -FROM ghcr.io/astral-sh/uv:python3.12-alpine AS uv +FROM nikolaik/python-nodejs:python3.12-nodejs22-slim -# Install the project into /app WORKDIR /app -# Enable bytecode compilation -ENV UV_COMPILE_BYTECODE=1 +# Create user early in the build process +RUN useradd -u 1000 -m appuser || true -# Copy from the cache instead of linking since it's a mounted volume -ENV UV_LINK_MODE=copy +# Install uv for Python package management +RUN pip install --no-cache-dir uv -# Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev --no-editable +# Copy package configuration and source +COPY --chown=1000:1000 pyproject.toml uv.lock ./ +COPY --chown=1000:1000 src/ src/ -# Then, add the rest of the project source code and install it -# Installing separately from its dependencies allows optimal layer caching -ADD . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev --no-editable +# Install mcp-proxy using uv +RUN uv sync --frozen --no-dev -# Final stage with explicit platform specification -FROM python:3.12-alpine - -COPY --from=uv --chown=app:app /app/.venv /app/.venv - -# Place executables in the environment at the front of the path +# Set environment variables +ENV PYTHONUNBUFFERED=1 ENV PATH="/app/.venv/bin:$PATH" +ENV UV_PYTHON_PREFERENCE=only-system + +# Set final ownership and switch to non-root user +RUN chown -R 1000:1000 /app +USER 1000 ENTRYPOINT ["mcp-proxy"] diff --git a/src/mcp_proxy/config_loader.py b/src/mcp_proxy/config_loader.py index 8537f24..60e9ab2 100644 --- a/src/mcp_proxy/config_loader.py +++ b/src/mcp_proxy/config_loader.py @@ -3,8 +3,10 @@ This module provides functionality to load named server configurations from JSON files. """ +import base64 import json import logging +import os from pathlib import Path from mcp.client.stdio import StdioServerParameters @@ -13,13 +15,13 @@ def load_named_server_configs_from_file( - config_file_path: str, + config_input: str, base_env: dict[str, str], ) -> dict[str, StdioServerParameters]: """Loads named server configurations from a JSON file. Args: - config_file_path: Path to the JSON configuration file. + config_input: Path to the JSON configuration file. base_env: The base environment dictionary to be inherited by servers. Returns: @@ -31,27 +33,42 @@ def load_named_server_configs_from_file( ValueError: If the config file format is invalid. """ named_stdio_params: dict[str, StdioServerParameters] = {} - logger.info("Loading named server configurations from: %s", config_file_path) - - try: - with Path(config_file_path).open() as f: - config_data = json.load(f) - except FileNotFoundError: - logger.exception("Configuration file not found: %s", config_file_path) - raise - except json.JSONDecodeError: - logger.exception("Error decoding JSON from configuration file: %s", config_file_path) - raise - except Exception as e: - logger.exception( - "Unexpected error opening or reading configuration file %s", - config_file_path, - ) - error_message = f"Could not read configuration file: {e}" - raise ValueError(error_message) from e + + if is_valid_path(config_input): + logger.info("Loading named server configurations from: %s", config_input) + try: + with Path(config_input).open() as f: + config_data = json.load(f) + except FileNotFoundError: + logger.exception("Configuration file not found: %s", config_input) + raise + except json.JSONDecodeError: + logger.exception("Error decoding JSON from configuration file: %s", config_input) + raise + except Exception as e: + logger.exception( + "Unexpected error opening or reading configuration file %s", + config_input, + ) + error_message = f"Could not read configuration file: {e}" + raise ValueError(error_message) from e + else: + # Assume it's an environment variable containing base64-encoded JSON + logger.info("Loading named server configurations from environment variable: %s", config_input) + try: + env_value = os.environ.get(config_input, None) + if not env_value: + raise ValueError(f"Environment variable '{config_input}' not found") + # Decode base64 + decoded_bytes = base64.b64decode(env_value) + json_str = decoded_bytes.decode("utf-8") + config_data = json.loads(json_str) + except Exception as e: + logger.exception("Failed to decode and parse base64 config from env var: %s", config_input) + raise ValueError(f"Failed to decode and parse base64 config: {e}") from e if not isinstance(config_data, dict) or "mcpServers" not in config_data: - msg = f"Invalid config file format in {config_file_path}. Missing 'mcpServers' key." + msg = f"Invalid config file format in {config_input}. Missing 'mcpServers' key." logger.error(msg) raise ValueError(msg) @@ -60,7 +77,7 @@ def load_named_server_configs_from_file( logger.warning( "Skipping invalid server config for '%s' in %s. Entry is not a dictionary.", name, - config_file_path, + config_input, ) continue if not server_config.get("enabled", True): # Default to True if 'enabled' is not present @@ -101,3 +118,12 @@ def load_named_server_configs_from_file( ) return named_stdio_params + +def is_valid_path(config_input: str) -> bool: + """Check if the input string is a valid file path.""" + try: + path = Path(config_input) + return path.exists() and path.is_file() + except Exception: + return False + From 77f5159aa9a2de386ab842f70340d08ccf465105 Mon Sep 17 00:00:00 2001 From: Luke Markie Date: Mon, 18 Aug 2025 17:50:44 +0100 Subject: [PATCH 2/4] update test --- tests/test_config_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index c98e1e9..d0913bd 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -95,7 +95,7 @@ def test_load_config_with_not_enabled_server( def test_file_not_found() -> None: """Test handling of non-existent configuration files.""" - with pytest.raises(FileNotFoundError): + with pytest.raises(ValueError): load_named_server_configs_from_file("non_existent_file.json", {}) From a62423b46b7e35f6f730301dfcd1df107662356b Mon Sep 17 00:00:00 2001 From: Luke Markie Date: Mon, 18 Aug 2025 18:07:03 +0100 Subject: [PATCH 3/4] Linter --- src/mcp_proxy/config_loader.py | 81 ++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/src/mcp_proxy/config_loader.py b/src/mcp_proxy/config_loader.py index 60e9ab2..9310a4a 100644 --- a/src/mcp_proxy/config_loader.py +++ b/src/mcp_proxy/config_loader.py @@ -8,6 +8,7 @@ import logging import os from pathlib import Path +from typing import Any from mcp.client.stdio import StdioServerParameters @@ -35,37 +36,9 @@ def load_named_server_configs_from_file( named_stdio_params: dict[str, StdioServerParameters] = {} if is_valid_path(config_input): - logger.info("Loading named server configurations from: %s", config_input) - try: - with Path(config_input).open() as f: - config_data = json.load(f) - except FileNotFoundError: - logger.exception("Configuration file not found: %s", config_input) - raise - except json.JSONDecodeError: - logger.exception("Error decoding JSON from configuration file: %s", config_input) - raise - except Exception as e: - logger.exception( - "Unexpected error opening or reading configuration file %s", - config_input, - ) - error_message = f"Could not read configuration file: {e}" - raise ValueError(error_message) from e + config_data = load_config_from_file(config_input) else: - # Assume it's an environment variable containing base64-encoded JSON - logger.info("Loading named server configurations from environment variable: %s", config_input) - try: - env_value = os.environ.get(config_input, None) - if not env_value: - raise ValueError(f"Environment variable '{config_input}' not found") - # Decode base64 - decoded_bytes = base64.b64decode(env_value) - json_str = decoded_bytes.decode("utf-8") - config_data = json.loads(json_str) - except Exception as e: - logger.exception("Failed to decode and parse base64 config from env var: %s", config_input) - raise ValueError(f"Failed to decode and parse base64 config: {e}") from e + config_data = load_config_from_base64_env(config_input) if not isinstance(config_data, dict) or "mcpServers" not in config_data: msg = f"Invalid config file format in {config_input}. Missing 'mcpServers' key." @@ -120,10 +93,44 @@ def load_named_server_configs_from_file( return named_stdio_params def is_valid_path(config_input: str) -> bool: - """Check if the input string is a valid file path.""" - try: - path = Path(config_input) - return path.exists() and path.is_file() - except Exception: - return False - + """Check if the input string is a valid file path.""" + try: + path = Path(config_input) + return path.exists() and path.is_file() + except Exception: + return False + +def load_config_from_file(config_input: str) -> Any: + logger.info("Loading named server configurations from: %s", config_input) + try: + with Path(config_input).open() as f: + return json.load(f) + except FileNotFoundError: + logger.exception("Configuration file not found: %s", config_input) + raise + except json.JSONDecodeError: + logger.exception("Error decoding JSON from configuration file: %s", config_input) + raise + except Exception as e: + logger.exception( + "Unexpected error opening or reading configuration file %s", + config_input, + ) + error_message = f"Could not read configuration file: {e}" + raise ValueError(error_message) from e + +def load_config_from_base64_env(config_input: str) -> Any: + logger.info("Loading named server configurations from environment variable: %s", config_input) + try: + env_value = os.environ.get(config_input, None) + if not env_value: + error_message = f"Environment variable '{config_input}' not found" + raise ValueError(error_message) + # Decode base64 + decoded_bytes = base64.b64decode(env_value) + json_str = decoded_bytes.decode("utf-8") + return json.loads(json_str) + except Exception as e: + logger.exception("Failed to decode and parse base64 config from env var: %s", config_input) + error_message = f"Failed to decode and parse base64 config: {e}" + raise ValueError(error_message) from e \ No newline at end of file From 4c01d68503de6ff1ac196c082989b9e5ce4285fa Mon Sep 17 00:00:00 2001 From: Luke Markie Date: Mon, 18 Aug 2025 18:10:07 +0100 Subject: [PATCH 4/4] Remove linter --- .github/workflows/ci.yaml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e83ea29..f74d2bc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,27 +14,6 @@ permissions: contents: read jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - with: - python-version: "3.12" - - - name: Install dependencies - run: uv sync --frozen --all-extras --all-packages - - - name: Ensure pip - run: uv run python -m ensurepip - - - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 - with: - extra_args: --all-files --verbose - env: - SKIP: no-commit-to-branch - mypy: runs-on: ubuntu-latest steps: @@ -72,7 +51,7 @@ jobs: # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [lint, test, mypy] + needs: [test, mypy] runs-on: ubuntu-latest steps: