Skip to content
Closed
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
10 changes: 8 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04 AS builder

ENV PATH="/root/.local/bin:$PATH"
ENV UV_PYTHON_INSTALL_DIR=/opt/uv

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

Expand All @@ -20,19 +21,24 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04

ENV PATH="/root/.local/bin:$PATH"
ENV UV_PYTHON_INSTALL_DIR=/opt/uv

ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app

RUN useradd --system --create-home app

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /opt/uv /opt/uv
COPY --from=builder --chown=app:app /app/mcp_plex ./mcp_plex
COPY --from=builder --chown=app:app /app/entrypoint.sh ./entrypoint.sh
COPY --from=builder /app/pyproject.toml ./pyproject.toml
COPY --from=builder --chown=app:app /app/uv.lock ./uv.lock

RUN mkdir -p /app/mcp_plex.egg-info
RUN chown -R app:app /app

USER app

ENTRYPOINT ["./entrypoint.sh"]
Expand Down
2 changes: 1 addition & 1 deletion docker/pyproject.deps.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcp-plex"
version = "2.0.37"
version = "2.0.42"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
11 changes: 10 additions & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
#!/usr/bin/env sh
set -e

exec uv run "$@"
if [ "$#" -eq 0 ]; then
set -- load-data
fi

command="$1"
shift

command_path="/app/.venv/bin/$command"

exec "$command_path" "$@"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mcp-plex"
version = "2.0.37"
version = "2.0.42"

description = "Plex-Oriented Model Context Protocol Server"
requires-python = ">=3.11,<3.13"
Expand Down
77 changes: 75 additions & 2 deletions tests/test_docker_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
CUDA_IMAGE = "nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04"
BUILDER_STAGE_DESCRIPTOR = f"{CUDA_IMAGE} AS builder"
RUNTIME_STAGE_DESCRIPTOR = CUDA_IMAGE
UV_INSTALL_DIR = "/opt/uv"
UV_INSTALL_ENV_DIRECTIVE = f"ENV UV_PYTHON_INSTALL_DIR={UV_INSTALL_DIR}"
UV_INSTALL_COPY_DIRECTIVE = (
f"COPY --from=builder --chown=app:app {UV_INSTALL_DIR} {UV_INSTALL_DIR}"
)
APP_DIRECTORY = "/app"
APP_CHOWN_DIRECTIVE = f"RUN chown -R app:app {APP_DIRECTORY}"
APP_EGG_INFO_DIRECTORY = f"{APP_DIRECTORY}/mcp_plex.egg-info"
APP_EGG_INFO_DIRECTIVE = f"RUN mkdir -p {APP_EGG_INFO_DIRECTORY}"


def _extract_stage(contents: str, stage_descriptor: str) -> str:
Expand Down Expand Up @@ -40,6 +49,15 @@ def _runtime_section(contents: str) -> str:
return _extract_stage(contents, RUNTIME_STAGE_DESCRIPTOR)


def _directive_index(section: str, directive: str, *, missing_message: str) -> int:
"""Return the position of a directive within a stage or fail with context."""

try:
return section.index(directive)
except ValueError as exc: # pragma: no cover - defensive guard
raise AssertionError(missing_message) from exc


@pytest.fixture()
def dockerfile_contents() -> str:
"""Read the Dockerfile once so individual checks stay lean."""
Expand Down Expand Up @@ -91,7 +109,9 @@ def test_dockerfile_copies_project_metadata_for_uv(dockerfile_contents: str) ->
"""The runtime image must include pyproject metadata so uv can resolve scripts."""

runtime_section = _runtime_section(dockerfile_contents)
expected_phrase = "COPY --from=builder /app/pyproject.toml ./pyproject.toml"
expected_phrase = (
f"COPY --from=builder {APP_DIRECTORY}/pyproject.toml ./pyproject.toml"
)

assert (
expected_phrase in runtime_section
Expand All @@ -104,7 +124,8 @@ def test_builder_stage_copies_uv_binaries(dockerfile_contents: str) -> None:
builder_section = _builder_section(dockerfile_contents)

assert (
"COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/" in builder_section
"COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/"
in builder_section
), "Builder stage must populate uv tools from the upstream image"


Expand Down Expand Up @@ -157,3 +178,55 @@ def test_runtime_stage_copies_virtualenv_with_chown(dockerfile_contents: str) ->
), "Runtime stage should retain the project entrypoint"

assert "CMD" in runtime_section, "Runtime stage should define a default command"


def test_dockerfile_preserves_uv_python_installation(dockerfile_contents: str) -> None:
"""Runtime stage should retain the uv-managed Python installation for app user access."""

builder_section = _builder_section(dockerfile_contents)
runtime_section = _runtime_section(dockerfile_contents)

assert (
UV_INSTALL_ENV_DIRECTIVE in builder_section
), "Builder stage must pin the uv Python install directory"
assert (
UV_INSTALL_ENV_DIRECTIVE in runtime_section
), "Runtime stage must expose the uv Python install directory"
assert (
UV_INSTALL_COPY_DIRECTIVE in runtime_section
), "Runtime stage should copy the uv-managed Python interpreter into place"


def test_runtime_stage_chowns_app_directory(dockerfile_contents: str) -> None:
"""Runtime stage should grant the app user ownership of the project directory."""

runtime_section = _runtime_section(dockerfile_contents)

_directive_index(
runtime_section,
APP_CHOWN_DIRECTIVE,
missing_message="Runtime stage must chown /app so editable builds can create egg-info",
)


def test_runtime_stage_precreates_egg_info_directory(
dockerfile_contents: str,
) -> None:
"""Runtime stage should ensure the egg-info directory exists for editable installs."""

runtime_section = _runtime_section(dockerfile_contents)

precreate_index = _directive_index(
runtime_section,
APP_EGG_INFO_DIRECTIVE,
missing_message="Runtime stage must create the egg-info directory before ownership handoff",
)
chown_index = _directive_index(
runtime_section,
APP_CHOWN_DIRECTIVE,
missing_message="Runtime stage must chown /app so editable builds can create egg-info",
)

assert (
precreate_index < chown_index
), "Egg-info directory should be created before chowning /app"
22 changes: 21 additions & 1 deletion tests/test_dockerfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,33 @@
DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile"


def read_dockerfile_contents() -> str:
"""Load the Dockerfile contents for reuse across tests."""

return DOCKERFILE_PATH.read_text(encoding="utf-8")


def test_uv_binary_path_is_added_before_usage() -> None:
"""Ensure the Dockerfile exposes uv on PATH before it's invoked."""

dockerfile_contents = DOCKERFILE_PATH.read_text(encoding="utf-8")
dockerfile_contents = read_dockerfile_contents()

expected_line = 'ENV PATH="/root/.local/bin:$PATH"'

assert (
expected_line in dockerfile_contents
), "Dockerfile must expose uv installation directory before using uv"


def test_uv_binary_is_copied_into_usr_local_bin() -> None:
"""Ensure uv binaries are copied into a globally accessible location."""

dockerfile_contents = read_dockerfile_contents()

expected_instruction = (
"COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/"
)

assert (
expected_instruction in dockerfile_contents
), "Dockerfile must copy uv binaries into /usr/local/bin"
16 changes: 16 additions & 0 deletions tests/test_entrypoints.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from __future__ import annotations

from pathlib import Path
import runpy


ENTRYPOINT = Path("entrypoint.sh")
VENV_BIN = "/app/.venv/bin"


def test_loader_module_entrypoint(monkeypatch):
captured: dict[str, object] = {}

Expand All @@ -27,3 +32,14 @@ def fake_main(argv=None):
runpy.run_module("mcp_plex.server", run_name="__main__")

assert captured["argv"] is None


def test_docker_entrypoint_executes_virtualenv_commands() -> None:
"""Ensure the container entrypoint dispatches through the prepared virtualenv."""

contents = ENTRYPOINT.read_text(encoding="utf-8")

assert (
f'"{VENV_BIN}/$command"' in contents
), "Entrypoint should execute commands from the virtualenv bin directory"
assert "uv run" not in contents, "Entrypoint should not invoke uv run during dispatch"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.