Files
Latte 5e144772b2
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
test: add MCP smoke tests over stdio and streamable HTTP, from source and from the built image
2026-10-14 16:26:16 +02:00

426 lines
14 KiB
Python

"""Shared helpers for the MCP smoke tests: client flows, subprocess and docker plumbing.
Secrets (Gitea token, MCP bearer token) are always passed through environment
variables, never through argv, and every captured stderr/stdout is checked for
them by the tests.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import secrets
import socket
import subprocess
import sys
import time
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import mcp.types as types
from mcp import Client
from aegis_gitea_mcp.catalog import load_catalog
from tests.integration.harness import NAME_PREFIX, GiteaInstance
# Raw-protocol version used for the hand-driven stdio session (handshake era).
RAW_PROTOCOL_VERSION = "2025-11-25"
SOURCE_SERVE_ARGS = [sys.executable, "-m", "aegis_gitea_mcp.cli", "serve"]
HEALTH_TIMEOUT_SECONDS = 60.0
RAW_READ_TIMEOUT_SECONDS = 60.0
# Environment variables a child process needs to find its interpreter and docker.
_PASSTHROUGH_ENV = (
"PATH",
"HOME",
"LANG",
"LC_ALL",
"TMPDIR",
"DOCKER_HOST",
"DOCKER_CONFIG",
"DOCKER_CONTEXT",
"XDG_RUNTIME_DIR",
)
def expected_tool_count() -> int:
"""Number of tools a full server exposes: every catalog operation plus the raw tool."""
return len(load_catalog().operations) + 1
def unique_container_name() -> str:
"""Return a unique ``aegis-it-mcp-<hex>`` container name."""
return f"{NAME_PREFIX}mcp-{secrets.token_hex(4)}"
def free_loopback_port() -> int:
"""Return a free TCP port on 127.0.0.1 (same approach as the Gitea harness)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def child_env(extra: Mapping[str, str]) -> dict[str, str]:
"""Minimal environment for a child process: passthrough variables plus ``extra``."""
env = {name: os.environ[name] for name in _PASSTHROUGH_ENV if name in os.environ}
env.update(extra)
return env
def server_env(
gitea: GiteaInstance, gitea_url: str, data_dir: Path, **extra: str
) -> dict[str, str]:
"""Server settings for a subprocess (``GITEA_TOKEN`` included, so keep it out of argv)."""
return child_env(
{
"GITEA_URL": gitea_url,
"GITEA_TOKEN": gitea.admin_token,
"DATA_DIR": str(data_dir),
"LOG_LEVEL": "DEBUG",
**extra,
}
)
# ---------------------------------------------------------------------------
# Client flows
# ---------------------------------------------------------------------------
def result_text(result: types.CallToolResult) -> str:
"""Return the text of the first content block of a tool result."""
assert result.content, "tool result has no content"
block = result.content[0]
assert isinstance(block, types.TextContent)
return block.text
def result_payload(result: types.CallToolResult) -> dict[str, Any]:
"""Parse the JSON envelope of a tool result."""
payload = json.loads(result_text(result))
assert isinstance(payload, dict)
return payload
async def exercise_server(
client: Client, gitea: GiteaInstance, secrets_to_hide: Sequence[str]
) -> None:
"""List tools, run a read call and a write call (create then delete a repo).
Asserts the tool count, the identity in the envelope, that the repo really
existed on Gitea after creation and was gone after deletion, and that no
secret appears in any tool result.
"""
listing = await client.list_tools()
names = {tool.name for tool in listing.tools}
assert len(listing.tools) == expected_tool_count()
assert {"gitea_user_get_current", "gitea_repo_delete", "gitea_api_request"} <= names
texts: list[str] = []
current = await client.call_tool("gitea_user_get_current", {})
texts.append(result_text(current))
assert not current.is_error
envelope = result_payload(current)
assert envelope["status"] == 200
assert envelope["identity"]["login"] == gitea.admin_login
assert envelope["identity"]["mode"] == "admin-token"
assert envelope["data"]["login"] == gitea.admin_login
repo = gitea.unique("mcp-smoke")
created = False
try:
create = await client.call_tool(
"gitea_user_current_create_repo",
{"body": {"name": repo, "auto_init": False, "private": True}},
)
texts.append(result_text(create))
assert not create.is_error, result_text(create)
created = True
assert result_payload(create)["status"] == 201
assert gitea.api("GET", f"/repos/{gitea.admin_login}/{repo}").status_code == 200
delete = await client.call_tool(
"gitea_repo_delete", {"owner": gitea.admin_login, "repo": repo}
)
texts.append(result_text(delete))
assert not delete.is_error, result_text(delete)
created = False
assert gitea.api("GET", f"/repos/{gitea.admin_login}/{repo}").status_code == 404
finally:
if created:
gitea.api("DELETE", f"/repos/{gitea.admin_login}/{repo}")
for secret in secrets_to_hide:
assert all(secret not in text for text in texts), "a secret leaked into a tool result"
def assert_no_secrets(text: str, secrets_to_hide: Sequence[str], where: str) -> None:
"""Fail when any secret appears in ``text`` (the message never prints the secret)."""
for index, secret in enumerate(secrets_to_hide):
assert secret not in text, f"secret #{index} found in {where}"
# ---------------------------------------------------------------------------
# Hand-driven stdio session (checks that stdout carries JSON-RPC only)
# ---------------------------------------------------------------------------
@dataclass
class RawSession:
"""Everything a hand-driven stdio run produced."""
stdout_lines: list[str]
stderr: str
returncode: int | None
async def _read_response(stdout: asyncio.StreamReader, request_id: int, lines: list[str]) -> None:
"""Read stdout lines until the response for ``request_id`` arrived (lines are recorded)."""
while True:
raw = await asyncio.wait_for(stdout.readline(), timeout=RAW_READ_TIMEOUT_SECONDS)
if not raw:
raise AssertionError("server closed stdout before answering")
line = raw.decode("utf-8").rstrip("\r\n")
lines.append(line)
message = json.loads(line)
if (
isinstance(message, dict)
and message.get("id") == request_id
and "method" not in message
):
assert "result" in message, f"request {request_id} failed"
return
async def run_raw_stdio(
argv: Sequence[str], env: Mapping[str, str], cwd: Path | None = None
) -> RawSession:
"""Start ``argv`` and run initialize + tools/list by hand, then close stdin.
Returns every stdout line the process wrote (so the caller can assert that
each one is a JSON-RPC message), its stderr and the exit code.
"""
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=dict(env),
cwd=cwd,
limit=64 * 1024 * 1024,
)
assert proc.stdin is not None
assert proc.stdout is not None
assert proc.stderr is not None
lines: list[str] = []
stderr_task = asyncio.create_task(proc.stderr.read())
try:
async def send(message: dict[str, Any]) -> None:
proc.stdin.write((json.dumps(message) + "\n").encode("utf-8")) # type: ignore[union-attr]
await proc.stdin.drain() # type: ignore[union-attr]
await send(
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": RAW_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "aegis-smoke", "version": "0"},
},
}
)
await _read_response(proc.stdout, 1, lines)
await send({"jsonrpc": "2.0", "method": "notifications/initialized"})
await send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
await _read_response(proc.stdout, 2, lines)
proc.stdin.close()
rest = await asyncio.wait_for(proc.stdout.read(), timeout=30.0)
lines.extend(rest.decode("utf-8").splitlines())
await asyncio.wait_for(proc.wait(), timeout=30.0)
finally:
if proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
proc.kill()
await proc.wait()
stderr = (await stderr_task).decode("utf-8", errors="replace")
return RawSession(stdout_lines=lines, stderr=stderr, returncode=proc.returncode)
def assert_jsonrpc_only(lines: Sequence[str]) -> None:
"""Assert that every non-empty stdout line is a JSON-RPC 2.0 message."""
assert lines, "no output on stdout"
for line in lines:
if not line.strip():
continue
message = json.loads(line)
assert isinstance(message, dict) and message.get("jsonrpc") == "2.0"
# ---------------------------------------------------------------------------
# HTTP readiness
# ---------------------------------------------------------------------------
def wait_for_health(
url: str, *, alive: Any = None, timeout: float = HEALTH_TIMEOUT_SECONDS
) -> httpx.Response:
"""Poll ``url`` until it answers 200 and return the response.
``alive`` is an optional callable returning False when the server died, so
the wait fails fast instead of running into the timeout.
"""
deadline = time.monotonic() + timeout
last = "no response"
while time.monotonic() < deadline:
if alive is not None and not alive():
raise AssertionError("the server process exited before /health answered")
try:
response = httpx.get(url, timeout=3.0)
if response.status_code == 200:
return response
last = f"HTTP {response.status_code}"
except httpx.HTTPError as exc:
last = type(exc).__name__
time.sleep(0.5)
raise AssertionError(f"{url} was not healthy within {timeout:.0f}s ({last})")
def assert_rejects_without_valid_token(mcp_url: str, bearer_token: str) -> None:
"""A missing and a wrong bearer token both get 401 (and the real token never echoes back)."""
initialize = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": RAW_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "aegis-smoke", "version": "0"},
},
}
base = {"Accept": "application/json, text/event-stream"}
anonymous = httpx.post(mcp_url, json=initialize, headers=base, timeout=10.0)
assert anonymous.status_code == 401
wrong = httpx.post(
mcp_url,
json=initialize,
headers={**base, "Authorization": f"Bearer {secrets.token_urlsafe(36)}"},
timeout=10.0,
)
assert wrong.status_code == 401
assert bearer_token not in anonymous.text + wrong.text
# ---------------------------------------------------------------------------
# Source HTTP server
# ---------------------------------------------------------------------------
@dataclass
class HttpServer:
"""A source-installed HTTP server subprocess."""
process: subprocess.Popen[bytes]
base_url: str
stderr_path: Path
stdout_path: Path
@property
def mcp_url(self) -> str:
"""URL of the MCP endpoint."""
return f"{self.base_url}/mcp"
def alive(self) -> bool:
"""Return True while the process is running."""
return self.process.poll() is None
def stderr_text(self) -> str:
"""Return what the server wrote to stderr so far."""
return self.stderr_path.read_text(encoding="utf-8", errors="replace")
@contextlib.contextmanager
def run_source_http_server(env: Mapping[str, str], workdir: Path) -> Iterator[HttpServer]:
"""Run ``aegis-gitea-mcp serve --transport http`` on a free loopback port.
The working directory is an empty temp dir so no ambient ``.env`` is read.
Waits for ``/health`` and always stops the process afterwards.
"""
port = free_loopback_port()
stderr_path = workdir / "server.stderr"
stdout_path = workdir / "server.stdout"
with stderr_path.open("wb") as err, stdout_path.open("wb") as out:
process = subprocess.Popen( # noqa: S603
[
*SOURCE_SERVE_ARGS,
"--transport",
"http",
"--host",
"127.0.0.1",
"--port",
str(port),
],
env=dict(env),
cwd=workdir,
stdin=subprocess.DEVNULL,
stdout=out,
stderr=err,
)
server = HttpServer(
process=process,
base_url=f"http://127.0.0.1:{port}",
stderr_path=stderr_path,
stdout_path=stdout_path,
)
try:
wait_for_health(f"{server.base_url}/health", alive=server.alive)
yield server
finally:
process.terminate()
try:
process.wait(timeout=15)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=15)
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
def docker(
*args: str,
env: Mapping[str, str] | None = None,
check: bool = True,
timeout: float = 120.0,
) -> subprocess.CompletedProcess[str]:
"""Run ``docker <args>``; ``env`` adds variables the CLI can forward with ``-e NAME``."""
full_env = {**os.environ, **(env or {})}
proc = subprocess.run( # noqa: S603
["docker", *args], # noqa: S607
capture_output=True,
text=True,
timeout=timeout,
check=False,
env=full_env,
)
if check and proc.returncode != 0:
# Never echo argv: it may carry non-secret config, and stderr is enough to debug.
raise AssertionError(
f"docker {' '.join(args[:2])} failed (exit {proc.returncode}): {proc.stderr.strip()}"
)
return proc
def remove_container(name: str) -> None:
"""Force-remove one container by its exact name (best effort, never prunes)."""
docker("rm", "-f", "-v", name, check=False, timeout=60)