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
426 lines
14 KiB
Python
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)
|