"""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-`` 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 ``; ``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)