"""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.oauth import OAuthSetupError from aegis_gitea_mcp.transports import ( ForwardedHeadersMiddleware, 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 # Proxy headers are applied by ForwardedHeadersMiddleware, not by uvicorn. assert kwargs["proxy_headers"] is False assert isinstance(app, ForwardedHeadersMiddleware) assert app.trusted == "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_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None: assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0 assert uvicorn_spy.calls[0][0].trusted == "10.0.0.0/8,172.18.0.2" def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert server_settings().forwarded_allow_ips == "127.0.0.1" assert run_http(server_settings(forwarded_allow_ips="*")) == 0 assert uvicorn_spy.calls[0][0].trusted == "*" assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err def test_non_loopback_bind_without_public_host_warns_which_variables_to_set( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN)) == 0 err = capsys.readouterr().err assert "PUBLIC_BASE_URL" in err and "ALLOWED_HOSTS" in err and "421" in err @pytest.mark.parametrize( "extra", [{"public_base_url": "https://mcp.example.com"}, {"allowed_hosts": ["mcp.example.com"]}], ) def test_no_host_warning_when_a_public_host_is_configured( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], extra: dict[str, Any] ) -> None: settings = server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN, **extra) assert run_http(settings) == 0 assert "421" not in capsys.readouterr().err def test_no_host_warning_on_loopback( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings()) == 0 assert capsys.readouterr().err == "" def test_oauth_enabled_with_a_bearer_starts_without_warnings( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0 assert capsys.readouterr().err == "" @pytest.mark.parametrize("value", ["*,10.0.0.1", "not-an-ip", "10.0.0.1/99"]) def test_invalid_forwarded_allow_ips_is_rejected(value: str) -> None: with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"): server_settings(forwarded_allow_ips=value) def test_blank_forwarded_allow_ips_means_the_default() -> None: assert server_settings(forwarded_allow_ips=" ").forwarded_allow_ips == "127.0.0.1" # Audit log at startup @pytest.fixture def unwritable_audit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """An audit path whose parent is a regular file, so it can never be opened.""" blocker = tmp_path / "blocker" blocker.write_text("x", encoding="utf-8") path = blocker / "audit.log" monkeypatch.setenv("AUDIT_LOG_PATH", str(path)) return path def test_http_exits_2_when_the_audit_log_cannot_be_opened( uvicorn_spy: UvicornSpy, unwritable_audit: Path, capsys: pytest.CaptureFixture[str] ) -> None: assert run_http(server_settings(audit_log_path=unwritable_audit)) == 2 captured = capsys.readouterr() assert uvicorn_spy.calls == [] assert "audit log" in captured.err and "AUDIT_LOG_PATH" in captured.err assert str(unwritable_audit) in captured.err assert captured.out == "" def test_stdio_exits_2_when_the_audit_log_cannot_be_opened( unwritable_audit: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(transports.anyio, "run", lambda *a: pytest.fail("must not serve")) assert run_stdio(server_settings(audit_log_path=unwritable_audit)) == 2 captured = capsys.readouterr() assert "AUDIT_LOG_PATH" in captured.err assert captured.out == "" 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( uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: def broken(settings: Settings) -> HttpAuthExtras: raise OAuthSetupError("token store unreadable") monkeypatch.setattr(transports, "load_oauth_components", broken) assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 2 assert "per-user OAuth cannot start: token store unreadable" in capsys.readouterr().err assert uvicorn_spy.calls == [] def test_oauth_enabled_with_a_bearer_serves_both(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_builds_the_oauth_components() -> None: extras = load_oauth_components(oauth_settings()) assert extras.auth_server_provider is not None assert extras.token_verifier is not None and extras.resolver is not None def test_startup_errors_without_oauth_components() -> None: errors = http_startup_errors(oauth_settings(mcp_bearer_token=BEARER_TOKEN), None) assert errors == ["OAUTH_ENABLED=true but per-user OAuth could not be set up"] 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() events = [json.loads(line)["event_type"] for line in audit] # A write-ahead entry, then the one tool_invocation entry with the result. assert events == ["tool_call_started", "tool_invocation"] assert all(GITEA_TOKEN not in line for line in audit) # 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