"""Tests for the transport runners: fail-closed startup, uvicorn wiring, stdio, serve CLI.""" from __future__ import annotations import json import sys from pathlib import Path from typing import Any import pytest from mcp import Client from mcp.client.stdio import StdioServerParameters, stdio_client from aegis_gitea_mcp import cli, transports from aegis_gitea_mcp.cli import main from aegis_gitea_mcp.config import Settings from aegis_gitea_mcp.transports import ( HttpAuthExtras, http_startup_errors, load_oauth_components, run_http, run_stdio, ) from tests.test_server_helpers import ANY_HOST, BEARER_TOKEN, GITEA_TOKEN, server_settings @pytest.fixture(autouse=True) def _env(mock_env: None) -> None: """Settings objects here are built explicitly; the audit logger still needs GITEA_URL.""" class UvicornSpy: """Replaces ``uvicorn.run`` and records its arguments.""" def __init__(self) -> None: self.calls: list[tuple[Any, dict[str, Any]]] = [] def __call__(self, app: Any, **kwargs: Any) -> None: self.calls.append((app, kwargs)) @pytest.fixture def uvicorn_spy(monkeypatch: pytest.MonkeyPatch) -> UvicornSpy: spy = UvicornSpy() monkeypatch.setattr(transports.uvicorn, "run", spy) return spy def oauth_settings(**overrides: Any) -> Settings: values: dict[str, Any] = { "oauth_enabled": True, "gitea_oauth_client_id": "client", "gitea_oauth_client_secret": "s" * 32, "token_encryption_key": "k" * 32, "public_base_url": "https://mcp.example.com", } values.update(overrides) return server_settings(**values) # HTTP startup checks def test_non_loopback_bind_without_auth_is_refused( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings(mcp_host=ANY_HOST)) == 2 captured = capsys.readouterr() assert uvicorn_spy.calls == [] assert "without HTTP authentication" in captured.err assert "MCP_BEARER_TOKEN" in captured.err assert captured.out == "" def test_non_loopback_bind_with_bearer_is_allowed(uvicorn_spy: UvicornSpy) -> None: settings = server_settings(mcp_host=ANY_HOST, mcp_port=9000, mcp_bearer_token=BEARER_TOKEN) assert run_http(settings) == 0 ((app, kwargs),) = uvicorn_spy.calls assert callable(app) assert kwargs["host"] == ANY_HOST assert kwargs["port"] == 9000 assert kwargs["proxy_headers"] is True assert kwargs["forwarded_allow_ips"] == "127.0.0.1" assert kwargs["log_level"] == "info" assert kwargs["server_header"] is False def test_loopback_without_auth_runs(uvicorn_spy: UvicornSpy) -> None: assert run_http(server_settings(log_level="DEBUG")) == 0 assert uvicorn_spy.calls[0][1]["host"] == "127.0.0.1" assert uvicorn_spy.calls[0][1]["log_level"] == "debug" def test_forwarded_allow_ips_setting_is_honoured_when_present( uvicorn_spy: UvicornSpy, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(Settings, "forwarded_allow_ips", "10.0.0.0/8", raising=False) assert run_http(server_settings()) == 0 assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8" def test_oauth_misconfiguration_lists_every_missing_setting( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: settings = server_settings(oauth_enabled=True, mcp_bearer_token=BEARER_TOKEN) assert run_http(settings) == 2 err = capsys.readouterr().err for name in ( "GITEA_OAUTH_CLIENT_ID", "GITEA_OAUTH_CLIENT_SECRET", "TOKEN_ENCRYPTION_KEY", "PUBLIC_BASE_URL", ): assert name in err assert uvicorn_spy.calls == [] def test_oauth_enabled_but_unavailable_does_not_start_without_a_bearer( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(oauth_settings()) == 2 assert "not available in this build" in capsys.readouterr().err assert uvicorn_spy.calls == [] def test_oauth_enabled_with_a_bearer_falls_back_to_the_bearer(uvicorn_spy: UvicornSpy) -> None: assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0 assert len(uvicorn_spy.calls) == 1 def test_oauth_components_are_used_when_available( uvicorn_spy: UvicornSpy, monkeypatch: pytest.MonkeyPatch ) -> None: class Verifier: async def verify_token(self, token: str) -> None: return None extras = HttpAuthExtras(token_verifier=Verifier()) monkeypatch.setattr(transports, "load_oauth_components", lambda settings: extras) settings = oauth_settings(mcp_host=ANY_HOST, gitea_token=None) assert run_http(settings) == 0 assert len(uvicorn_spy.calls) == 1 def test_default_oauth_components_hook_returns_none() -> None: assert load_oauth_components(oauth_settings()) is None def test_bearer_without_gitea_token_is_refused( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings(mcp_bearer_token=BEARER_TOKEN, gitea_token=None)) == 2 assert "GITEA_TOKEN" in capsys.readouterr().err def test_http_startup_errors_is_empty_for_a_valid_setup() -> None: assert http_startup_errors(server_settings(mcp_bearer_token=BEARER_TOKEN), None) == [] def test_bad_disabled_tools_stop_http_startup( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings(disabled_tools=["gitea_nope"])) == 2 assert "gitea_nope" in capsys.readouterr().err assert uvicorn_spy.calls == [] # stdio def test_stdio_without_a_token_exits_2(capsys: pytest.CaptureFixture[str]) -> None: assert run_stdio(server_settings(gitea_token=None)) == 2 captured = capsys.readouterr() assert "GITEA_TOKEN" in captured.err assert captured.out == "" def test_stdio_with_bad_disabled_tools_exits_2(capsys: pytest.CaptureFixture[str]) -> None: assert run_stdio(server_settings(disabled_tools=["nope"])) == 2 assert "nope" in capsys.readouterr().err def test_stdio_serves_the_built_server(monkeypatch: pytest.MonkeyPatch) -> None: served: list[Any] = [] def fake_run(function: Any, server: Any) -> None: served.append((function, server)) monkeypatch.setattr(transports.anyio, "run", fake_run) assert run_stdio(server_settings()) == 0 function, server = served[0] assert function is transports._serve_stdio assert server.tool_count == 538 async def test_real_stdio_subprocess_serves_the_catalog(tmp_path: Path) -> None: env = { "GITEA_URL": "http://127.0.0.1:9", "GITEA_TOKEN": GITEA_TOKEN, "DATA_DIR": str(tmp_path / "state"), "AUDIT_LOG_PATH": str(tmp_path / "state" / "audit.log"), "REQUEST_TIMEOUT_SECONDS": "2", } params = StdioServerParameters( command=sys.executable, args=["-m", "aegis_gitea_mcp.cli", "serve", "--transport", "stdio"], env=env, cwd=str(tmp_path), ) async with Client(stdio_client(params)) as client: tools = (await client.list_tools()).tools result = await client.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"}) assert len(tools) == 538 # Gitea is unreachable: the call fails as an error result, stdout stayed valid JSON-RPC. assert result.is_error payload = json.loads(result.content[0].text) # type: ignore[union-attr] assert "error" in payload and GITEA_TOKEN not in json.dumps(payload) audit = (tmp_path / "state" / "audit.log").read_text(encoding="utf-8").splitlines() assert len(audit) == 1 assert GITEA_TOKEN not in audit[0] # serve command def test_serve_command_applies_flags_and_runs_http(monkeypatch: pytest.MonkeyPatch) -> None: # Set first so monkeypatch restores whatever ``serve`` writes into the environment. monkeypatch.setenv("MCP_TRANSPORT", "stdio") monkeypatch.setenv("MCP_HOST", "127.0.0.1") monkeypatch.setenv("MCP_PORT", "8080") seen: list[Settings] = [] monkeypatch.setattr(transports, "run_http", lambda settings: seen.append(settings) or 0) monkeypatch.setattr(transports, "run_stdio", lambda settings: pytest.fail("wrong transport")) code = main(["serve", "--transport", "http", "--host", "127.0.0.2", "--port", "9123"]) assert code == 0 assert (seen[0].mcp_transport, seen[0].mcp_host, seen[0].mcp_port) == ( "http", "127.0.0.2", 9123, ) def test_serve_defaults_to_the_configured_transport(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MCP_TRANSPORT", "http") seen: list[str] = [] monkeypatch.setattr(transports, "run_http", lambda settings: seen.append("http") or 0) monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append("stdio") or 0) assert main([]) == 0 assert seen == ["http"] def test_serve_defaults_to_stdio(monkeypatch: pytest.MonkeyPatch) -> None: seen: list[str] = [] monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append("stdio") or 0) assert main(["serve"]) == 0 assert seen == ["stdio"] def test_serve_with_invalid_configuration_never_echoes_values( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setenv("MCP_BEARER_TOKEN", "too-short-secret-VALUE") monkeypatch.setattr(transports, "run_stdio", lambda settings: pytest.fail("must not run")) assert main(["serve"]) == 2 captured = capsys.readouterr() assert "MCP_BEARER_TOKEN" in captured.err assert "too-short-secret-VALUE" not in captured.err + captured.out def test_serve_without_gitea_url_exits_2( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.delenv("GITEA_URL") assert main(["serve"]) == 2 assert "GITEA_URL" in capsys.readouterr().err def test_serve_rejects_an_out_of_range_port() -> None: with pytest.raises(SystemExit): cli.build_parser().parse_args(["serve", "--port", "70000"]) async def test_serve_stdio_runs_the_server_over_the_stdio_streams( monkeypatch: pytest.MonkeyPatch, ) -> None: """``_serve_stdio`` hands the stdio streams to the server (streams replaced by memory).""" from contextlib import asynccontextmanager import anyio from mcp.client.session import ClientSession from mcp.shared.memory import create_client_server_memory_streams from aegis_gitea_mcp.server import build_server async with create_client_server_memory_streams() as (client_streams, server_streams): @asynccontextmanager async def fake_stdio() -> Any: yield server_streams monkeypatch.setattr(transports, "stdio_server", fake_stdio) server = build_server(server_settings()) async with anyio.create_task_group() as group: group.start_soon(transports._serve_stdio, server) async with ClientSession(*client_streams) as session: await session.initialize() tools = await session.list_tools() group.cancel_scope.cancel() assert len(tools.tools) == 538