test: add MCP smoke tests over stdio and streamable HTTP, from source and from the built image
ci / gitleaks (push) Successful in 9s
ci / docs-check (push) Failing after 30s
ci / lint (push) Successful in 41s
ci / test-integration (push) Successful in 46s
ci / security (push) Failing after 28s
ci / test-mcp (push) Successful in 1m16s
ci / test-unit (push) Successful in 1m52s
ci / build-and-push (push) Successful in 17s

This commit is contained in:
Latte committed 2026-10-14 16:26:16 +02:00
1 parent cfbf1fb893
commit 5e144772b2
7 files changed
+817

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
"""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-<hex>`` 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")