diff --git a/.github/actions/build-and-push-container-image-test/action.yml b/.github/actions/build-and-push-container-image-test/action.yml new file mode 100644 index 0000000..45e4bfd --- /dev/null +++ b/.github/actions/build-and-push-container-image-test/action.yml @@ -0,0 +1,146 @@ +--- +# This local action builds an image and pushes it to private ECR for testing +name: "Build and push image (Test)" +author: "MCP Proxy for AWS" +description: "Builds an image and pushes it to private ECR for testing" + +inputs: + image: + description: 'The image' + type: string + required: true + version: + default: '' + description: 'The version to associate to the image' + type: string + required: false + ecr-role-to-assume: + description: 'The ECR role to use to push the image' + type: string + required: true + ecr-repository: + description: 'The ECR repository name' + type: string + required: true + ecr-aws-region: + default: 'us-east-1' + description: 'The region to login' + type: string + required: false + +outputs: + version: + description: 'The version uploaded' + value: ${{ steps.get-version.outputs.version }} + +runs: + using: "composite" + steps: + - name: Setup AWS Credentials + id: setup-aws-credentials + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + role-to-assume: ${{ inputs.ecr-role-to-assume }} + aws-region: ${{ inputs.ecr-aws-region }} + role-duration-seconds: 7200 + role-session-name: GitHubActions${{ github.run_id }} + mask-aws-account-id: true + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 + + - name: Docker meta + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: | + ${{ steps.login-ecr.outputs.registry }}/${{ inputs.ecr-repository }} + # Disable all but the raw and sha + tags: | + type=schedule,enable=false + type=semver,pattern={{raw}},enable=false + type=pep440,pattern={{raw}},enable=false + type=match,pattern=(.*),group=1,enable=false + type=edge,enable=false + type=ref,event=branch,enable=false + type=ref,event=tag,enable=false + type=ref,event=pr,enable=false + type=sha,format=long,enable=true + type=raw,value=latest,enable=true + type=raw,value=${{ inputs.version || github.sha }},enable=${{ (inputs.version && true) || 'false' }} + labels: | + maintainer=MCP Proxy for AWS + org.opencontainers.image.description=MCP Proxy for AWS (Test Build) + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.title=${{ inputs.image }} + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ inputs.version || github.sha }} + org.opencontainers.image.vendor=Amazon Web Services, Inc. + + - name: Set up QEMU + id: setup-qemu + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + id: setup-buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + with: + buildkitd-flags: --debug + + - name: Build and push by digest + id: build + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + with: + platforms: 'linux/amd64,linux/arm64' + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.login-ecr.outputs.registry }}/${{ inputs.ecr-repository }} + context: . + file: ./Dockerfile + push: true + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests/${{ inputs.image }} + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${{ inputs.image }}/${digest#sha256:}" + shell: bash + + - name: Upload digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: digests-${{ inputs.image }} + path: ${{ runner.temp }}/digests/${{ inputs.image }}/* + if-no-files-found: error + retention-days: 1 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests/${{ inputs.image }} + env: + IMAGE: ${{ inputs.image }} + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: ${{ inputs.ecr-repository }} + run: | + echo "DOCKER_METADATA_OUTPUT_JSON=$DOCKER_METADATA_OUTPUT_JSON" + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf ''$REGISTRY'/'$REPOSITORY'@sha256:%s ' *) + shell: bash + + - name: Inspect image + env: + IMAGE: ${{ inputs.image }} + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: ${{ inputs.ecr-repository }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + docker buildx imagetools inspect $REGISTRY/$REPOSITORY:$VERSION + shell: bash + + - name: Get version + id: get-version + run: | + echo version="${{ steps.meta.outputs.version }}" >>"$GITHUB_OUTPUT" + shell: bash diff --git a/.github/actions/build-and-push-container-image/action.yml b/.github/actions/build-and-push-container-image/action.yml new file mode 100644 index 0000000..150614d --- /dev/null +++ b/.github/actions/build-and-push-container-image/action.yml @@ -0,0 +1,147 @@ +--- +# This local action builds an image and pushes it to registries +name: "Build and push image" +author: "MCP Proxy for AWS" +description: "Builds an image and pushes it to registries" + +inputs: + image: + description: 'The image' + type: string + required: true + version: + default: '' + description: 'The version to associate to the image' + type: string + required: false + public-ecr-role-to-assume: + description: 'The public ECR role to use to push the image' + type: string + required: true + public-ecr-registry-alias: + description: 'The registry alias' + type: string + required: true + public-ecr-aws-region: + default: 'us-east-1' + description: 'The region to login' + type: string + required: false + +outputs: + version: + description: 'The version uploaded' + value: ${{ steps.get-version.outputs.version }} + +runs: + using: "composite" + steps: + - name: Docker meta + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: | + public.ecr.aws/${{ inputs.public-ecr-registry-alias }}/${{ github.repository_owner }}/${{ inputs.image }} + # Disable all but the raw and sha + tags: | + type=schedule,enable=false + type=semver,pattern={{raw}},enable=false + type=pep440,pattern={{raw}},enable=false + type=match,pattern=(.*),group=1,enable=false + type=edge,enable=false + type=ref,event=branch,enable=false + type=ref,event=tag,enable=false + type=ref,event=pr,enable=false + type=sha,format=long,enable=true + type=raw,value=latest,enable=true + type=raw,value=${{ inputs.version || github.sha }},enable=${{ (inputs.version && true) || 'false' }} + labels: | + maintainer=MCP Proxy for AWS + org.opencontainers.image.description=MCP Proxy for AWS + org.opencontainers.image.source=https://github.com/${{ github.repository_owner }}/${{ inputs.image }} + org.opencontainers.image.title=aws.${{ inputs.image }} + org.opencontainers.image.url=https://github.com/${{ github.repository_owner }}/${{ inputs.image }} + org.opencontainers.image.version=${{ inputs.version || github.sha }} + org.opencontainers.image.vendor=Amazon Web Services, Inc. + + - name: Setup AWS Credentials + id: setup-aws-credentials + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + role-to-assume: ${{ inputs.public-ecr-role-to-assume }} + aws-region: ${{ inputs.public-ecr-aws-region }} + role-duration-seconds: 7200 + role-session-name: GitHubActions${{ github.run_id }} + mask-aws-account-id: true + + - name: Login to Public ECR + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: public.ecr.aws + + - name: Set up QEMU + id: setup-qemu + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + id: setup-buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + with: + buildkitd-flags: --debug + + - name: Build and push by digest + id: build + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + with: + platforms: 'linux/amd64,linux/arm64' + labels: ${{ steps.meta.outputs.labels }} + tags: public.ecr.aws/${{ inputs.public-ecr-registry-alias }}/${{ github.repository_owner }}/${{ inputs.image }} + context: . + file: ./Dockerfile + push: true + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests/${{ inputs.image }} + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${{ inputs.image }}/${digest#sha256:}" + shell: bash + + - name: Upload digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: digests-${{ inputs.image }} + path: ${{ runner.temp }}/digests/${{ inputs.image }}/* + if-no-files-found: error + retention-days: 1 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests/${{ inputs.image }} + env: + IMAGE: ${{ inputs.image }} + ALIAS: ${{ inputs.public-ecr-registry-alias }} + OWNER: ${{ github.repository_owner }} + run: | + echo "DOCKER_METADATA_OUTPUT_JSON=$DOCKER_METADATA_OUTPUT_JSON" + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'public.ecr.aws/'$ALIAS'/'$OWNER'/'$IMAGE'@sha256:%s ' *) + shell: bash + + - name: Inspect image + env: + IMAGE: ${{ inputs.image }} + ALIAS: ${{ inputs.public-ecr-registry-alias }} + OWNER: ${{ github.repository_owner }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + docker buildx imagetools inspect public.ecr.aws/$ALIAS/$OWNER/$IMAGE:$VERSION + shell: bash + + - name: Get version + id: get-version + run: | + echo version="${{ steps.meta.outputs.version }}" >>"$GITHUB_OUTPUT" + shell: bash diff --git a/.github/workflows/pypi-publish-on-release.yml b/.github/workflows/pypi-publish-on-release.yml index d7567c9..9fb16c4 100644 --- a/.github/workflows/pypi-publish-on-release.yml +++ b/.github/workflows/pypi-publish-on-release.yml @@ -59,15 +59,55 @@ jobs: url: https://pypi.org/p/mcp-proxy-for-aws permissions: id-token: write + contents: read steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + - name: Download distribution packages - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Set up uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 + + - name: Get version from package + id: get-package-version + run: | + set -euo pipefail + + # Get version from uv + VERSION="$(uv tree 2>/dev/null | grep mcp-proxy-for-aws | sed -e 's/^.*[[:space:]]v\(.*\)/\1/g' | head -1)" + + if [[ -z "$VERSION" ]]; then + echo "::error::Failed to extract version from package" >&2 + exit 1 + fi + + # Validate version format + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format: $VERSION" >&2 + exit 1 + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "::debug::Package version: $VERSION" - name: Publish to PyPI - run: uv publish + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + print-hash: true + + - name: Build and Publish Container + id: build-and-publish + uses: ./.github/actions/build-and-push-container-image + with: + image: mcp-proxy-for-aws + version: ${{ steps.get-package-version.outputs.version }} + public-ecr-role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + public-ecr-registry-alias: awslabs-mcp + public-ecr-aws-region: us-east-1 diff --git a/.github/workflows/test-ecr-publish.yml b/.github/workflows/test-ecr-publish.yml new file mode 100644 index 0000000..58e77ac --- /dev/null +++ b/.github/workflows/test-ecr-publish.yml @@ -0,0 +1,81 @@ +name: Test ECR Publish + +on: workflow_dispatch + +permissions: {} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + + - name: Build distribution packages + run: uv build + + - name: Upload distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + test-container-build: + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Set up uv + uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5 + + - name: Get version from package + id: get-package-version + run: | + set -euo pipefail + + # Get version from uv + VERSION="$(uv tree 2>/dev/null | grep mcp-proxy-for-aws | sed -e 's/^.*[[:space:]]v\(.*\)/\1/g' | head -1)" + + if [[ -z "$VERSION" ]]; then + echo "::error::Failed to extract version from package" >&2 + exit 1 + fi + + # Validate version format + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version format: $VERSION" >&2 + exit 1 + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "::debug::Package version: $VERSION" + + - name: Build and Publish Container to Private ECR + id: build-and-publish + uses: ./.github/actions/build-and-push-container-image-test + with: + image: mcp-proxy-for-aws-test + version: ${{ steps.get-package-version.outputs.version }} + ecr-role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + ecr-repository: mcp-proxy-for-aws-test + ecr-aws-region: eu-north-1 diff --git a/Dockerfile b/Dockerfile index 369fcab..75d2fa9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,16 +33,30 @@ ENV UV_FROZEN=true # Copy the required files first COPY pyproject.toml uv.lock ./ +# Python optimization and uv configuration +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Install system dependencies and Python package manager +RUN apk update && \ + apk add --no-cache --virtual .build-deps \ + build-base \ + gcc \ + musl-dev \ + libffi-dev \ + openssl-dev \ + cargo + # Install the project's dependencies using the lockfile and settings RUN --mount=type=cache,target=/root/.cache/uv \ pip install uv && \ - uv sync --frozen --no-install-project --no-dev --no-editable + uv sync --python 3.14 --frozen --no-install-project --no-dev --no-editable # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev --no-editable + uv sync --python 3.14 --frozen --no-dev --no-editable # Make the directory just in case it doesn't exist RUN mkdir -p /root/.local @@ -51,19 +65,17 @@ RUN mkdir -p /root/.local FROM public.ecr.aws/docker/library/python:3.14.0-alpine3.22@sha256:8373231e1e906ddfb457748bfc032c4c06ada8c759b7b62d9c73ec2a3c56e710 # Place executables in the environment at the front of the path and include other binaries -ENV PATH="/app/.venv/bin:$PATH:/usr/sbin" +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 -# Install lsof for the healthcheck -# Install other tools as needed for the MCP server -# Add non-root user and ability to change directory into /root +# Install runtime dependencies and create application user RUN apk update && \ - apk --no-cache add lsof && \ + apk add --no-cache ca-certificates libgcc && \ + update-ca-certificates && \ addgroup -S app && \ - adduser -S app -G app -h /app && \ - chmod o+x /root + adduser -S app -G app -h /app -# Get the project from the uv layer -COPY --from=uv --chown=app:app /root/.local /root/.local +# Copy application artifacts from build stage COPY --from=uv --chown=app:app /app/.venv /app/.venv # Get healthcheck script @@ -73,5 +85,5 @@ COPY ./docker-healthcheck.sh /usr/local/bin/docker-healthcheck.sh USER app # When running the container, add --db-path and a bind mount to the host's db file -HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "docker-healthcheck.sh" ] +HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 CMD ["docker-healthcheck.sh"] ENTRYPOINT ["mcp-proxy-for-aws"] diff --git a/README.md b/README.md index 478babe..75ca28a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,20 @@ uv run mcp_proxy_for_aws/server.py #### Using Docker +Docker images are published to the [public AWS ECR registry](https://gallery.ecr.aws/awslabs-mcp). + +You can use the pre-built image: + +```bash +# Pull the latest image +docker pull public.ecr.aws/awslabs-mcp/aws/mcp-proxy-for-aws:latest + +# Or pull a specific version +docker pull public.ecr.aws/awslabs-mcp/aws/mcp-proxy-for-aws:1.1.4 +``` + +Or build the image locally: + ```bash # Build the Docker image docker build -t mcp-proxy-for-aws . @@ -151,6 +165,29 @@ Add the following configuration to your MCP client config file (e.g., for Amazon #### Using Docker +Using the pre-built public ECR image: + +```json +{ + "mcpServers": { + "": { + "command": "docker", + "args": [ + "run", + "--rm", + "--volume", + "/full/path/to/.aws:/app/.aws:ro", + "public.ecr.aws/awslabs-mcp/aws/mcp-proxy-for-aws:latest", + "" + ], + "env": {} + } + } +} +``` + +Or using a locally built image: + ```json { "mcpServers": { diff --git a/examples/mcp-client/llamaindex/pyproject.toml b/examples/mcp-client/llamaindex/pyproject.toml index 4456218..4fd28a9 100644 --- a/examples/mcp-client/llamaindex/pyproject.toml +++ b/examples/mcp-client/llamaindex/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "mcp-client-example-llamaindex" version = "0.1.0" -requires-python = ">=3.10,<3.14" +requires-python = ">=3.10,<3.15" dependencies = [ "llama-index", "llama-index-llms-openai", diff --git a/pyproject.toml b/pyproject.toml index ca00bbd..82d4a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ version = "1.1.4" description = "MCP Proxy for AWS" readme = "README.md" -requires-python = ">=3.10,<3.14" +requires-python = ">=3.10,<3.15" dependencies = [ "fastmcp>=2.13.1", "boto3>=1.41.0", @@ -35,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.urls] diff --git a/uv.lock b/uv.lock index 4f4435b..68c7cbf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10, <3.14" +requires-python = ">=3.10, <3.15" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'",