"""MCP smoke tests against the built Docker image (``AEGIS_MCP_IMAGE``). The MCP container joins the harness's per-run network and reaches Gitea by its container name. Secrets go into the container with ``-e NAME`` (the value comes from the docker CLI's environment, never from argv). Every container started here has a unique ``aegis-it-mcp-`` name and is removed in a ``finally``. """ from __future__ import annotations import contextlib import os from collections.abc import Iterator from pathlib import Path import httpx2 import pytest from mcp import Client, StdioServerParameters from mcp.client.stdio import stdio_client from tests.integration.harness import NETWORK_MODE_ENV, GiteaInstance from tests.mcp.helpers import ( assert_jsonrpc_only, assert_no_secrets, assert_rejects_without_valid_token, child_env, docker, exercise_server, expected_tool_count, free_loopback_port, remove_container, run_raw_stdio, unique_container_name, wait_for_health, ) CONTAINER_HTTP_PORT = 8080 HTTP_TIMEOUT_SECONDS = 60.0 @pytest.fixture(scope="module", autouse=True) def _image_present(mcp_image: str) -> None: """Fail (not skip) when the configured image does not exist locally.""" proc = docker("image", "inspect", mcp_image, check=False, timeout=60) assert proc.returncode == 0, f"AEGIS_MCP_IMAGE={mcp_image!r} is not available locally" def _stdio_argv(name: str, network: str, gitea_url: str, image: str) -> list[str]: return [ "run", "--rm", "-i", "--name", name, "--network", network, "--label", "aegis-it=1", "-e", f"GITEA_URL={gitea_url}", "-e", "GITEA_TOKEN", image, "serve", "--transport", "stdio", ] async def test_image_stdio_lists_reads_and_writes( gitea: GiteaInstance, gitea_internal_url: str, mcp_image: str, tmp_path: Path ) -> None: """The image over stdio (docker run -i): list, read, create and delete a repo.""" name = unique_container_name() argv = _stdio_argv(name, gitea.network, gitea_internal_url, mcp_image) params = StdioServerParameters( command="docker", args=argv, env=child_env({"GITEA_TOKEN": gitea.admin_token}) ) stderr_path = tmp_path / "container.stderr" try: with stderr_path.open("w", encoding="utf-8") as errlog: async with Client(stdio_client(params, errlog=errlog), read_timeout_seconds=60.0) as c: await exercise_server(c, gitea, [gitea.admin_token]) finally: remove_container(name) assert_no_secrets( stderr_path.read_text(encoding="utf-8", errors="replace"), [gitea.admin_token], "the container stderr", ) async def test_image_stdio_stdout_is_json_rpc_only( gitea: GiteaInstance, gitea_internal_url: str, mcp_image: str ) -> None: """Nothing but JSON-RPC reaches the image's stdout; stderr carries no secret.""" name = unique_container_name() argv = ["docker", *_stdio_argv(name, gitea.network, gitea_internal_url, mcp_image)] try: session = await run_raw_stdio(argv, child_env({"GITEA_TOKEN": gitea.admin_token})) finally: remove_container(name) assert_jsonrpc_only(session.stdout_lines) assert session.returncode == 0 assert_no_secrets(session.stderr, [gitea.admin_token], "the container stderr") @contextlib.contextmanager def _image_http_server( gitea: GiteaInstance, gitea_internal_url: str, image: str, bearer_token: str ) -> Iterator[tuple[str, str]]: """Run the image in HTTP mode; yield ``(base_url, container_name)``. Local mode publishes the port on 127.0.0.1 only; container mode (the job container shares the per-run network) reaches the MCP container by name. """ name = unique_container_name() container_mode = os.environ.get(NETWORK_MODE_ENV, "").lower() == "container" if container_mode: base_url = f"http://{name}:{CONTAINER_HTTP_PORT}" allowed_host = f"{name}:{CONTAINER_HTTP_PORT}" publish: list[str] = [] else: port = free_loopback_port() base_url = f"http://127.0.0.1:{port}" allowed_host = f"127.0.0.1:{port}" publish = ["-p", f"127.0.0.1:{port}:{CONTAINER_HTTP_PORT}"] try: docker( "run", "-d", "--name", name, "--network", gitea.network, "--label", "aegis-it=1", *publish, "-e", f"GITEA_URL={gitea_internal_url}", "-e", f"ALLOWED_HOSTS={allowed_host}", "-e", "GITEA_TOKEN", "-e", "MCP_BEARER_TOKEN", image, env={"GITEA_TOKEN": gitea.admin_token, "MCP_BEARER_TOKEN": bearer_token}, ) def alive() -> bool: state = docker("inspect", "--format", "{{.State.Running}}", name, check=False) return state.stdout.strip() == "true" wait_for_health(f"{base_url}/health", alive=alive) yield base_url, name finally: remove_container(name) async def test_image_http_lists_reads_and_writes( gitea: GiteaInstance, gitea_internal_url: str, mcp_image: str, bearer_token: str ) -> None: """The image over streamable HTTP: list, read, write, 401 negatives, no secret in the logs.""" from mcp.client.streamable_http import streamable_http_client hidden = [gitea.admin_token, bearer_token] with _image_http_server(gitea, gitea_internal_url, mcp_image, bearer_token) as (base, name): health = httpx2.get(f"{base}/health", timeout=10.0).json() assert health["status"] == "ok" assert health["tools"] == expected_tool_count() async with httpx2.AsyncClient( headers={"Authorization": f"Bearer {bearer_token}"}, timeout=HTTP_TIMEOUT_SECONDS ) as http_client: transport = streamable_http_client(f"{base}/mcp", http_client=http_client) async with Client(transport, read_timeout_seconds=60.0) as client: await exercise_server(client, gitea, hidden) assert_rejects_without_valid_token(f"{base}/mcp", bearer_token) logs = docker("logs", name, check=False, timeout=60) assert_no_secrets(logs.stdout + logs.stderr, hidden, "the container logs") def test_image_refuses_open_bind_without_auth( gitea: GiteaInstance, gitea_internal_url: str, mcp_image: str ) -> None: """The image binds 0.0.0.0 by default and must exit 2 without MCP_BEARER_TOKEN.""" name = unique_container_name() try: proc = docker( "run", "--rm", "--name", name, "--label", "aegis-it=1", "-e", f"GITEA_URL={gitea_internal_url}", "-e", "GITEA_TOKEN", mcp_image, "serve", env={"GITEA_TOKEN": gitea.admin_token}, check=False, timeout=90, ) finally: remove_container(name) assert proc.returncode == 2 assert "MCP_BEARER_TOKEN" in proc.stderr assert_no_secrets(proc.stdout + proc.stderr, [gitea.admin_token], "the container output")