feat: add MCP server core, stdio and streamable HTTP transports and serve command
Low-level SDK server built from the catalog plus gitea_api_request, with TOOLSETS/READ_ONLY/DISABLED_TOOLS filters, schema validation without value echo, one audit event per call, static bearer verifier, identity resolver hook, /health, DNS-rebinding protection, rate limiting and fail-closed startup checks.
This commit is contained in:
1 parent
581ca67bf8
commit
c9e2c481e7
11 files changed
+2846
-14
No files matched your search
@@ -0,0 +1,335 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user