Skip to content

Commit e267d87

Browse files
authored
Add Dockerfile for the http transport (#10)
* Add Dockerfile for the http transport * Use alpine base image
1 parent 4fa65ba commit e267d87

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

.dockerignore

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Only what the Dockerfile COPYs should reach the build context. Everything else is
2+
# either regenerated inside the image (.venv), never shipped (tests, docs), or must not
3+
# be baked into an image at all (.env).
4+
5+
# Local environments and secrets
6+
.venv
7+
.env
8+
.env.*
9+
10+
# Python artefacts
11+
__pycache__
12+
*.py[cod]
13+
*.egg-info
14+
build/
15+
dist/
16+
.pytest_cache
17+
.mypy_cache
18+
.ruff_cache
19+
20+
# Not needed at runtime
21+
tests/
22+
docs/
23+
.github/
24+
*.md
25+
env.example
26+
requirements.txt
27+
28+
# Tooling
29+
.git
30+
.gitignore
31+
.vscode
32+
.idea
33+
.DS_Store

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,29 @@ jobs:
7878
run: uv sync --extra dev --frozen
7979
- name: Pytest
8080
run: uv run pytest -q
81+
82+
image:
83+
# Build-only. Publishing to the registry is a separate workflow, triggered by pushes
84+
# to main and by releases; this job exists so a Dockerfile that no longer builds on
85+
# either architecture cannot merge, rather than failing at publish time.
86+
name: Image (${{ matrix.platform }})
87+
runs-on: ${{ matrix.platform == 'amd64' && 'ubuntu-latest' || 'ubuntu-arm64' }}
88+
strategy:
89+
fail-fast: false
90+
matrix:
91+
platform: [amd64, arm64]
92+
steps:
93+
- uses: actions/checkout@v7.0.1
94+
- name: Set up Docker Buildx
95+
uses: docker/setup-buildx-action@v4.3.0
96+
- name: Build image
97+
uses: docker/build-push-action@v7.3.0
98+
with:
99+
context: .
100+
file: ./Dockerfile
101+
platforms: linux/${{ matrix.platform }}
102+
build-args: VERSION=${{ github.sha }}
103+
push: false
104+
provenance: false
105+
cache-from: type=gha,scope=${{ matrix.platform }}
106+
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}

Dockerfile

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,73 @@
1-
# Placeholder. Snyk's PR check only tests files already imported as projects from main, so
2-
# a Dockerfile has to exist here before the real one can be gated. Replaced by the image in
3-
# the follow-up PR for OPS-4767, which uses this same base.
4-
FROM python:3.13-slim-bookworm
1+
# syntax=docker/dockerfile:1
2+
#
3+
# The MCP server as its own container, serving the http transport for external clients.
4+
#
5+
# The stdio transport is not what this image is for: the API image vendors this repository
6+
# and spawns it per chat request. This image runs alongside the API as a separate service,
7+
# reached by agents at ${OPENOPS_MCP_RESOURCE_URL} and reaching the API at ${OPENOPS_API_URL}.
8+
#
9+
# Two stages so the runtime never carries uv, the lockfile, or a package cache. The
10+
# environment is resolved from uv.lock exactly as CI does (--frozen), so the image cannot
11+
# quietly drift from what was tested.
12+
13+
ARG PYTHON_VERSION=3.13
14+
15+
# ---- Builder stage: resolve the environment from the lockfile ----
16+
FROM python:${PYTHON_VERSION}-alpine AS builder
17+
18+
COPY --from=ghcr.io/astral-sh/uv:0.9.0 /uv /usr/local/bin/uv
19+
20+
ENV UV_COMPILE_BYTECODE=1 \
21+
UV_LINK_MODE=copy \
22+
UV_PYTHON_DOWNLOADS=never
23+
24+
WORKDIR /app
25+
26+
# Only the manifests: the environment layer stays cached across source-only changes.
27+
COPY pyproject.toml uv.lock ./
28+
29+
# --no-install-project: the server runs from source via main.py, the same way the API
30+
# image runs it, so the package itself is not installed into the environment.
31+
RUN --mount=type=cache,target=/root/.cache/uv \
32+
uv sync --frozen --no-dev --no-install-project
33+
34+
# ---- Final stage: runtime only ----
35+
FROM python:${PYTHON_VERSION}-alpine
36+
37+
ENV LANG=C.UTF-8 \
38+
LC_ALL=C.UTF-8 \
39+
PYTHONUNBUFFERED=1 \
40+
PYTHONDONTWRITEBYTECODE=1 \
41+
PATH="/app/.venv/bin:$PATH" \
42+
# The container is the http deployment. stdio is the API's business.
43+
MCP_TRANSPORT=http \
44+
MCP_HTTP_HOST=0.0.0.0 \
45+
MCP_HTTP_PORT=3020 \
46+
# No outbound call to pypi.org at startup, and no ASCII banner in collected logs.
47+
FASTMCP_CHECK_FOR_UPDATES=off \
48+
FASTMCP_SHOW_SERVER_BANNER=false
49+
50+
# No shell login, no home directory: the process needs neither.
51+
RUN addgroup -S -g 10001 openops \
52+
&& adduser -S -u 10001 -G openops -H -s /sbin/nologin openops
53+
54+
WORKDIR /app
55+
56+
COPY --from=builder --chown=openops:openops /app/.venv ./.venv
57+
COPY --chown=openops:openops main.py ./
58+
COPY --chown=openops:openops openops_mcp ./openops_mcp
59+
60+
ARG VERSION=unknown
61+
ENV OPENOPS_MCP_VERSION=$VERSION
62+
63+
LABEL service=openops-mcp \
64+
org.opencontainers.image.title="OpenOps MCP" \
65+
org.opencontainers.image.description="MCP server exposing OpenOps API operations as tools" \
66+
org.opencontainers.image.source="https://github.com/openops-cloud/openops-mcp" \
67+
org.opencontainers.image.version=$VERSION
68+
69+
USER openops
70+
71+
EXPOSE 3020
72+
73+
ENTRYPOINT ["python", "main.py"]

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,32 @@ as HTTP Basic and the resource URL is this server's advertised identity, so clea
172172
remote host is refused at startup. `OPENOPS_API_URL` is exempt, because tool calls are
173173
pod-to-pod inside a cluster.
174174

175+
## Docker
176+
177+
The image serves the http transport as its own service next to the API. stdio isn't what it's
178+
for: the API image vendors this repository and spawns it per chat request.
179+
180+
```bash
181+
docker build -t openops-mcp .
182+
183+
docker run --rm -p 3020:3020 \
184+
-e OPENOPS_API_URL=http://openops-api:3000 \
185+
-e OPENOPS_MCP_PROFILE=agent \
186+
-e OPENOPS_MCP_ISSUER=https://example.com/api \
187+
-e OPENOPS_MCP_RESOURCE_URL=https://example.com/mcp \
188+
-e OPENOPS_MCP_CLIENT_SECRET=<at least 32 characters> \
189+
openops-mcp
190+
```
191+
192+
`MCP_TRANSPORT=http`, `MCP_HTTP_HOST=0.0.0.0` and `MCP_HTTP_PORT=3020` are the image's
193+
defaults. The environment comes from `uv.lock` with the same `uv sync --frozen --no-dev` CI
194+
runs; the Dockerfile explains the rest of its choices inline.
195+
196+
To test against an API on your machine, the issuer has to be `localhost`, and inside a
197+
container that's the container. Run with `--network host` (on Docker Desktop, enable it under
198+
Resources → Network) and point `OPENOPS_API_URL` and `OPENOPS_MCP_ISSUER` at
199+
`http://localhost:3000`.
200+
175201
## Configuration reference
176202

177203
| Variable | Transport | Default | Purpose |

0 commit comments

Comments
 (0)