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
213 lines
7.1 KiB
Python
213 lines
7.1 KiB
Python
"""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")
|