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
@@ -86,6 +86,10 @@ ignore = [
|
|||||||
"B008", # function calls in argument defaults (pydantic Field style)
|
"B008", # function calls in argument defaults (pydantic Field style)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
# tests/mcp is a directory, not the SDK: keep "mcp" classified as third party.
|
||||||
|
known-third-party = ["mcp"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"__init__.py" = ["F401"]
|
"__init__.py" = ["F401"]
|
||||||
"tests/integration/harness.py" = [
|
"tests/integration/harness.py" = [
|
||||||
@@ -129,6 +133,7 @@ pythonpath = ["src"]
|
|||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: needs a live Gitea in Docker (opt-in via AEGIS_INTEGRATION=1)",
|
"integration: needs a live Gitea in Docker (opt-in via AEGIS_INTEGRATION=1)",
|
||||||
|
"mcp_smoke: MCP client smoke tests over real transports (opt-in via AEGIS_INTEGRATION=1)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,76 @@
|
|||||||
|
"""Opt-in fixtures for the MCP smoke tests.
|
||||||
|
|
||||||
|
Tests in this directory are marked ``mcp_smoke`` and skipped unless
|
||||||
|
``AEGIS_INTEGRATION=1``. They reuse the live-Gitea harness from
|
||||||
|
``tests/integration`` (one disposable Gitea per session, admin token with the
|
||||||
|
``all`` scope) and drive the server through a real MCP client over stdio and
|
||||||
|
streamable HTTP, from source and (when ``AEGIS_MCP_IMAGE`` is set) from the
|
||||||
|
built image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.integration.conftest import ENABLE_ENV, gitea, integration_enabled
|
||||||
|
from tests.integration.harness import GITEA_PORT, GiteaInstance
|
||||||
|
|
||||||
|
__all__ = ["ENABLE_ENV", "gitea"]
|
||||||
|
|
||||||
|
_HERE = Path(__file__).parent.resolve()
|
||||||
|
IMAGE_ENV = "AEGIS_MCP_IMAGE"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config) -> None:
|
||||||
|
"""Register the ``mcp_smoke`` marker (idempotent with pyproject)."""
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "mcp_smoke: MCP client smoke tests, opt-in via AEGIS_INTEGRATION=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mcp_smoke(item: pytest.Item) -> bool:
|
||||||
|
try:
|
||||||
|
return _HERE in Path(str(item.path)).resolve().parents
|
||||||
|
except (OSError, ValueError): # pragma: no cover - defensive
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||||
|
"""Mark and (unless opted in) skip every test under tests/mcp."""
|
||||||
|
selected = [item for item in items if _is_mcp_smoke(item)]
|
||||||
|
skip = pytest.mark.skip(reason=f"MCP smoke tests need {ENABLE_ENV}=1 and Docker")
|
||||||
|
for item in selected:
|
||||||
|
item.add_marker(pytest.mark.mcp_smoke)
|
||||||
|
if not integration_enabled():
|
||||||
|
item.add_marker(skip)
|
||||||
|
if selected and len(selected) == len(items):
|
||||||
|
# A smoke-only run starts the server in subprocesses, so the in-process
|
||||||
|
# coverage gate does not apply.
|
||||||
|
for namespace in (config.option, config.known_args_namespace):
|
||||||
|
if getattr(namespace, "cov_fail_under", None) is not None:
|
||||||
|
namespace.cov_fail_under = 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def gitea_internal_url(gitea: GiteaInstance) -> str:
|
||||||
|
"""URL of the Gitea container for other containers on the harness network."""
|
||||||
|
return f"http://{gitea.container}:{GITEA_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def mcp_image() -> str:
|
||||||
|
"""The built image under test; the test is skipped when ``AEGIS_MCP_IMAGE`` is unset."""
|
||||||
|
image = os.environ.get(IMAGE_ENV, "").strip()
|
||||||
|
if not image:
|
||||||
|
pytest.skip(f"{IMAGE_ENV} is not set; image smoke tests need a built image")
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bearer_token() -> str:
|
||||||
|
"""A random 48 character MCP bearer token."""
|
||||||
|
return secrets.token_urlsafe(36)
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""MCP smoke tests: the streamable HTTP transport from source, against a live Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx2
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
from tests.mcp.helpers import (
|
||||||
|
assert_no_secrets,
|
||||||
|
assert_rejects_without_valid_token,
|
||||||
|
exercise_server,
|
||||||
|
expected_tool_count,
|
||||||
|
run_source_http_server,
|
||||||
|
server_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_lists_reads_and_writes(
|
||||||
|
gitea: GiteaInstance, tmp_path: Path, bearer_token: str
|
||||||
|
) -> None:
|
||||||
|
"""A real MCP client over streamable HTTP with a bearer token, plus the 401 negatives."""
|
||||||
|
env = server_env(gitea, gitea.base_url, tmp_path, MCP_BEARER_TOKEN=bearer_token)
|
||||||
|
hidden = [gitea.admin_token, bearer_token]
|
||||||
|
with run_source_http_server(env, tmp_path) as server:
|
||||||
|
health = server.base_url + "/health"
|
||||||
|
body = httpx2.get(health, timeout=10.0).json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["tools"] == expected_tool_count()
|
||||||
|
|
||||||
|
async with httpx2.AsyncClient(
|
||||||
|
headers={"Authorization": f"Bearer {bearer_token}"}, timeout=60.0
|
||||||
|
) as http_client:
|
||||||
|
transport = streamable_http_client(server.mcp_url, 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(server.mcp_url, bearer_token)
|
||||||
|
assert server.alive()
|
||||||
|
|
||||||
|
assert_no_secrets(server.stderr_text(), hidden, "the server stderr")
|
||||||
|
stdout = server.stdout_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
assert_no_secrets(stdout, hidden, "the server stdout")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""MCP smoke tests: the stdio transport from source, against a live Gitea."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcp import Client, StdioServerParameters
|
||||||
|
from mcp.client.stdio import stdio_client
|
||||||
|
|
||||||
|
from tests.integration.harness import GiteaInstance
|
||||||
|
from tests.mcp.helpers import (
|
||||||
|
SOURCE_SERVE_ARGS,
|
||||||
|
assert_jsonrpc_only,
|
||||||
|
assert_no_secrets,
|
||||||
|
exercise_server,
|
||||||
|
server_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stdio_params(gitea: GiteaInstance, data_dir: Path) -> StdioServerParameters:
|
||||||
|
return StdioServerParameters(
|
||||||
|
command=SOURCE_SERVE_ARGS[0],
|
||||||
|
args=[*SOURCE_SERVE_ARGS[1:], "--transport", "stdio"],
|
||||||
|
# The working directory is the empty temp dir so no ambient .env is read.
|
||||||
|
env=server_env(gitea, gitea.base_url, data_dir),
|
||||||
|
cwd=data_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stdio_lists_reads_and_writes(gitea: GiteaInstance, tmp_path: Path) -> None:
|
||||||
|
"""A real MCP client over stdio: initialize, list 538 tools, read, create and delete a repo."""
|
||||||
|
stderr_path = tmp_path / "server.stderr"
|
||||||
|
with stderr_path.open("w", encoding="utf-8") as errlog:
|
||||||
|
transport = stdio_client(_stdio_params(gitea, tmp_path), errlog=errlog)
|
||||||
|
async with Client(transport, read_timeout_seconds=60.0) as client:
|
||||||
|
assert client.server_info is not None
|
||||||
|
assert client.server_info.name == "aegis-gitea-mcp"
|
||||||
|
await exercise_server(client, gitea, [gitea.admin_token])
|
||||||
|
|
||||||
|
stderr = stderr_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
assert_no_secrets(stderr, [gitea.admin_token], "the server stderr")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stdio_stdout_is_json_rpc_only(gitea: GiteaInstance, tmp_path: Path) -> None:
|
||||||
|
"""Every stdout line of the stdio server is JSON-RPC; logs go to stderr without secrets."""
|
||||||
|
from tests.mcp.helpers import run_raw_stdio
|
||||||
|
|
||||||
|
params = _stdio_params(gitea, tmp_path)
|
||||||
|
session = await run_raw_stdio([params.command, *params.args], params.env or {}, tmp_path)
|
||||||
|
assert_jsonrpc_only(session.stdout_lines)
|
||||||
|
assert session.returncode == 0
|
||||||
|
assert_no_secrets("\n".join(session.stdout_lines), [gitea.admin_token], "the server stdout")
|
||||||
|
assert_no_secrets(session.stderr, [gitea.admin_token], "the server stderr")
|
||||||
Reference in new issue
Block a user