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
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:
1 parent
cfbf1fb893
commit
5e144772b2
7 files changed
+817
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user