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:
Latte committed 2026-10-14 16:13:11 +02:00
1 parent 581ca67bf8
commit c9e2c481e7
11 files changed
+2846 -14

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
"""Tests for the static bearer verifier, composite verifier and identity resolution."""
from __future__ import annotations
import hmac
from typing import Any
import pytest
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import auth as auth_module
from aegis_gitea_mcp.auth import (
STATIC_CLIENT_ID,
AdminIdentityResolver,
CompositeVerifier,
StaticBearerVerifier,
)
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea import GiteaExecutor
from tests.test_server_helpers import API, BEARER_TOKEN, GITEA_TOKEN, server_settings
# Static bearer verifier
async def test_correct_token_yields_an_admin_access_token_without_the_secret() -> None:
access = await StaticBearerVerifier(BEARER_TOKEN).verify_token(BEARER_TOKEN)
assert access is not None
assert access.client_id == STATIC_CLIENT_ID == "aegis-static-bearer"
assert access.scopes == ["gitea"]
assert access.subject == "admin"
assert access.token == "static"
assert BEARER_TOKEN not in repr(access)
@pytest.mark.parametrize(
"presented",
[
"",
"x",
BEARER_TOKEN + "x",
BEARER_TOKEN[:-1],
BEARER_TOKEN.upper(),
"Bearer " + BEARER_TOKEN,
],
)
async def test_wrong_tokens_are_rejected(presented: str) -> None:
assert await StaticBearerVerifier(BEARER_TOKEN).verify_token(presented) is None
async def test_comparison_is_constant_time_even_for_different_lengths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[bytes, bytes]] = []
real = hmac.compare_digest
def spy(left: Any, right: Any) -> bool:
calls.append((left, right))
return real(left, right)
monkeypatch.setattr(auth_module.hmac, "compare_digest", spy)
verifier = StaticBearerVerifier(BEARER_TOKEN)
await verifier.verify_token("short")
await verifier.verify_token(BEARER_TOKEN + "-much-longer-than-expected")
await verifier.verify_token(BEARER_TOKEN)
assert len(calls) == 3
# Digests of equal length are compared, never the raw secrets.
assert all(len(left) == len(right) == 32 for left, right in calls)
assert all(BEARER_TOKEN.encode() not in pair for pair in calls)
async def test_composite_verifier_returns_first_match() -> None:
other = AccessToken(token="t", client_id="oauth-client", scopes=["x"])
class Accepts:
async def verify_token(self, token: str) -> AccessToken | None:
return other if token == "oauth-token" else None
composite = CompositeVerifier(StaticBearerVerifier(BEARER_TOKEN), Accepts())
static = await composite.verify_token(BEARER_TOKEN)
oauth = await composite.verify_token("oauth-token")
assert static is not None and static.client_id == STATIC_CLIENT_ID
assert oauth is other
assert await composite.verify_token("nothing") is None
assert await CompositeVerifier().verify_token(BEARER_TOKEN) is None
# Identity resolver
async def test_no_access_token_resolves_to_the_admin_identity(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
identity = await resolver.resolve(None)
assert (identity.login, identity.mode, identity.token) == ("root", "admin-token", GITEA_TOKEN)
assert GITEA_TOKEN not in repr(identity)
assert httpx_mock.get_requests()[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
async def test_login_is_looked_up_once_and_cached(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
access = AccessToken(token="static", client_id=STATIC_CLIENT_ID, scopes=["gitea"])
first = await resolver.resolve(access)
second = await resolver.resolve(access)
assert first.login == second.login == "root"
assert len(httpx_mock.get_requests()) == 1
@pytest.mark.parametrize(
"response",
[
{"status_code": 401, "json": {"message": "bad token"}},
{"status_code": 200, "json": ["not", "an", "object"]},
{"status_code": 200, "json": {"login": ""}},
{"status_code": 200, "json": {"login": 5}},
],
)
async def test_failed_lookup_leaves_the_login_unknown_and_is_throttled(
httpx_mock: HTTPXMock, response: dict[str, Any]
) -> None:
httpx_mock.add_response(url=f"{API}/user", **response)
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
first = await resolver.resolve(None)
second = await resolver.resolve(None)
assert first.login is None and second.login is None
assert first.mode == "admin-token" and first.token == GITEA_TOKEN
assert len(httpx_mock.get_requests()) == 1 # no retry inside the back-off window
async def test_lookup_is_retried_after_the_backoff(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
httpx_mock.add_response(url=f"{API}/user", status_code=500)
httpx_mock.add_response(url=f"{API}/user", json={"login": "root"})
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
clock = [1000.0]
monkeypatch.setattr(auth_module.time, "monotonic", lambda: clock[0])
assert (await resolver.resolve(None)).login is None
clock[0] += auth_module._LOGIN_RETRY_SECONDS + 1
assert (await resolver.resolve(None)).login == "root"
async def test_other_client_ids_are_refused() -> None:
settings = server_settings()
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
with pytest.raises(ToolError, match="unknown identity"):
await resolver.resolve(AccessToken(token="x", client_id="oauth-client", scopes=[]))
async def test_missing_gitea_token_is_refused() -> None:
settings = server_settings(gitea_token=None)
resolver = AdminIdentityResolver(settings, GiteaExecutor(settings))
with pytest.raises(ToolError, match="GITEA_TOKEN"):
await resolver.resolve(None)
+4 -6
View File
@@ -8,6 +8,7 @@ import pytest
from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.cli import build_parser, main
from tests.test_server_helpers import ANY_HOST
def test_version_prints_package_version(capsys: pytest.CaptureFixture[str]) -> None:
@@ -15,12 +16,9 @@ def test_version_prints_package_version(capsys: pytest.CaptureFixture[str]) -> N
assert capsys.readouterr().out.strip() == __version__
@pytest.mark.parametrize("argv", [[], ["serve"], ["serve", "--transport", "http"]])
def test_unbuilt_commands_exit_2(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
assert main(argv) == 2
captured = capsys.readouterr()
assert "not implemented yet" in captured.err
assert captured.out == ""
def test_serve_parses_host_and_port() -> None:
args = build_parser().parse_args(["serve", "--host", ANY_HOST, "--port", "9000"])
assert (args.host, args.port) == (ANY_HOST, 9000)
def test_serve_transport_choices() -> None:
+665
View File
@@ -0,0 +1,665 @@
"""Tests for the MCP server core: tool list, filters, dispatcher, audit."""
from __future__ import annotations
import base64
import json
from typing import Any
import pytest
from mcp import Client, MCPError
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.audit import validate_audit_log_integrity
from aegis_gitea_mcp.catalog import load_catalog
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME, UNTRUSTED_NOTICE, GiteaExecutor, ToolOutcome
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.server import (
RAW_INPUT_SCHEMA,
ServerConfigError,
ToolRegistry,
build_raw_tool,
build_server,
max_request_body_size,
validate_arguments,
)
from tests.test_server_helpers import (
API,
GITEA_TOKEN,
audit_entries,
connected,
mock_user,
server_settings,
)
CATALOG = load_catalog()
REPO_ARGS = {"owner": "octo", "repo": "demo"}
def payload_of(result: Any) -> dict[str, Any]:
"""Decode the JSON text content of a tool result."""
assert len(result.content) == 1
decoded: dict[str, Any] = json.loads(result.content[0].text)
return decoded
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""Every test here needs GITEA_URL for the audit logger's settings."""
# Tool list
async def test_list_tools_has_every_operation_plus_raw_tool() -> None:
async with connected() as client:
tools = (await client.list_tools()).tools
names = [tool.name for tool in tools]
assert len(tools) == len(CATALOG.operations) + 1 == 538
assert len(set(names)) == len(names)
assert names == sorted(names)
assert RAW_TOOL_NAME in names
for tool in tools:
assert tool.input_schema["type"] == "object"
assert tool.annotations is not None
assert tool.annotations.open_world_hint is False
async def test_list_tools_annotations_follow_the_catalog() -> None:
async with connected() as client:
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
get_repo = tools["gitea_repo_get"].annotations
delete_repo = tools["gitea_repo_delete"].annotations
create_issue = tools["gitea_issue_create_issue"].annotations
assert get_repo is not None and delete_repo is not None and create_issue is not None
assert (get_repo.read_only_hint, get_repo.destructive_hint, get_repo.idempotent_hint) == (
True,
False,
True,
)
assert (delete_repo.read_only_hint, delete_repo.destructive_hint) == (False, True)
assert (create_issue.destructive_hint, create_issue.idempotent_hint) == (False, False)
assert tools["gitea_repo_get"].title == get_repo.title
assert tools["gitea_repo_get"].description
async def test_raw_tool_definition() -> None:
async with connected() as client:
raw = next(t for t in (await client.list_tools()).tools if t.name == RAW_TOOL_NAME)
assert raw.input_schema == RAW_INPUT_SCHEMA
assert raw.input_schema["additionalProperties"] is False
assert raw.input_schema["properties"]["method"]["enum"] == [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
]
assert raw.input_schema["properties"]["path"]["maxLength"] == 2048
assert raw.annotations is not None
assert (raw.annotations.read_only_hint, raw.annotations.destructive_hint) == (False, True)
async def test_list_tools_ignores_cursor() -> None:
async with connected() as client:
result = await client.list_tools(cursor="whatever")
assert len(result.tools) == 538
assert result.next_cursor is None
# Filters
async def test_toolsets_limit_listing_and_calling(httpx_mock: HTTPXMock) -> None:
settings = server_settings(toolsets=["issue"])
async with connected(settings) as client:
tools = (await client.list_tools()).tools
assert {CATALOG.get(t.name).domain for t in tools if CATALOG.get(t.name)} == {"issue"}
assert RAW_TOOL_NAME not in {t.name for t in tools}
with pytest.raises(MCPError):
await client.call_tool("gitea_repo_get", REPO_ARGS)
assert httpx_mock.get_requests() == []
async def test_misc_toolset_includes_the_raw_tool() -> None:
async with connected(server_settings(toolsets=["misc"])) as client:
names = {t.name for t in (await client.list_tools()).tools}
assert RAW_TOOL_NAME in names
assert {CATALOG.get(n).domain for n in names - {RAW_TOOL_NAME}} == {"misc"} # type: ignore[union-attr]
async def test_read_only_lists_only_read_operations_and_keeps_the_raw_tool() -> None:
async with connected(server_settings(read_only=True)) as client:
tools = {t.name: t for t in (await client.list_tools()).tools}
assert RAW_TOOL_NAME in tools
read_ops = [op for op in CATALOG.operations if op.read_only]
assert len(tools) == len(read_ops) + 1
assert "gitea_repo_delete" not in tools
raw = tools[RAW_TOOL_NAME]
assert raw.annotations is not None and raw.annotations.read_only_hint is True
assert raw.description is not None and "only GET" in raw.description
async def test_read_only_rejects_non_get_raw_requests(httpx_mock: HTTPXMock) -> None:
async with connected(server_settings(read_only=True)) as client:
blocked = await client.call_tool(
RAW_TOOL_NAME, {"method": "DELETE", "path": "/repos/octo/demo"}
)
assert blocked.is_error
assert payload_of(blocked)["error"]["kind"] == "read_only"
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "read_only"
async def test_read_only_allows_get_raw_requests(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.22"})
async with connected(server_settings(read_only=True)) as client:
result = await client.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert not result.is_error
assert payload_of(result)["data"] == {"version": "1.22"}
async def test_disabled_tools_are_hidden_and_unknown_when_called() -> None:
settings = server_settings(disabled_tools=["gitea_repo_delete", RAW_TOOL_NAME])
async with connected(settings) as client:
names = {t.name for t in (await client.list_tools()).tools}
assert len(names) == 536
assert "gitea_repo_delete" not in names
assert RAW_TOOL_NAME not in names
with pytest.raises(MCPError) as caught:
await client.call_tool("gitea_repo_delete", REPO_ARGS)
assert caught.value.code == -32602
def test_unknown_disabled_tool_names_are_a_startup_error() -> None:
settings = server_settings(disabled_tools=["gitea_repo_get", "gitea_nope", "other_nope"])
with pytest.raises(ServerConfigError) as caught:
build_server(settings)
message = str(caught.value)
assert "gitea_nope" in message and "other_nope" in message
assert "gitea_repo_get" not in message
def test_disabled_tool_hidden_by_read_only_is_still_a_valid_name() -> None:
settings = server_settings(read_only=True, disabled_tools=["gitea_repo_delete"])
registry = ToolRegistry(settings, CATALOG)
assert registry.get("gitea_repo_delete") is None
assert len(registry) == sum(op.read_only for op in CATALOG.operations) + 1
def test_registry_caches_validators() -> None:
registry = ToolRegistry(server_settings(), CATALOG)
assert registry.validator("gitea_repo_get") is registry.validator("gitea_repo_get")
def test_build_raw_tool_read_only_flag() -> None:
assert build_raw_tool().annotations is not None
assert build_raw_tool(read_only=True).annotations.read_only_hint is True # type: ignore[union-attr]
# Calls: success and Gitea errors
async def test_call_success_envelope(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error is False
payload = payload_of(result)
assert payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None}
assert payload["untrusted_content"] == UNTRUSTED_NOTICE
assert payload["data"] == {"full_name": "octo/demo"}
assert result.structured_content == payload
request = httpx_mock.get_requests(url=f"{API}/repos/octo/demo")[0]
assert request.headers["Authorization"] == f"token {GITEA_TOKEN}"
async def test_gitea_404_is_an_error_result_with_kind(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", status_code=404, json={"message": "nope"})
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error is True
error = payload_of(result)["error"]
assert error["kind"] == "not_found" and error["status"] == 404
entry = audit_entries()[-1]
assert entry["outcome"] == "not_found" and entry["result_status"] == "error"
assert entry["params"]["http_status"] == 404
async def test_none_arguments_are_treated_as_empty(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"})
async with connected() as client:
result = await client.call_tool("gitea_user_get_current")
assert not result.is_error
assert payload_of(result)["data"] == {"login": "admin"}
# Calls: validation
@pytest.mark.parametrize(
("tool", "arguments", "path", "needle"),
[
("gitea_repo_get", {"owner": "octo"}, "$", "repo"),
("gitea_repo_get", {"owner": 5, "repo": "demo"}, "$.owner", "type"),
("gitea_repo_get", {**REPO_ARGS, "surprise": 1}, "$", "unexpected argument"),
("gitea_repo_get", {"owner": "no spaces!", "repo": "demo"}, "$.owner", "pattern"),
("gitea_repo_get", {"owner": "o" * 300, "repo": "demo"}, "$.owner", "maxLength"),
("gitea_repo_get", {"owner": "", "repo": "demo"}, "$.owner", "minLength"),
(RAW_TOOL_NAME, {"method": "TRACE", "path": "/x"}, "$.method", "one of"),
(RAW_TOOL_NAME, {"method": "GET", "path": "no-slash"}, "$.path", "pattern"),
(RAW_TOOL_NAME, {"method": "GET", "path": "/x", "query": {"a": {"b": 1}}}, "$.query.a", ""),
],
)
async def test_validation_errors_do_not_call_gitea(
httpx_mock: HTTPXMock, tool: str, arguments: dict[str, Any], path: str, needle: str
) -> None:
async with connected() as client:
result = await client.call_tool(tool, arguments)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "invalid_argument"
assert error["path"] == path
assert needle in error["message"]
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "invalid_argument"
async def test_validation_error_never_echoes_the_offending_value() -> None:
secret = "sk-live-SUPERSECRET-0123456789"
async with connected() as client:
results = [
await client.call_tool("gitea_repo_get", {"owner": secret, "repo": "demo"}),
await client.call_tool("gitea_repo_get", {"owner": 1, "repo": "demo", secret: secret}),
await client.call_tool("gitea_repo_get", {"owner": ["a", secret], "repo": "demo"}),
]
for result in results:
assert result.is_error
assert secret not in result.content[0].text # type: ignore[union-attr]
assert "unexpected argument" in payload_of(results[1])["error"]["message"]
def test_validate_arguments_returns_none_for_valid_input() -> None:
registry = ToolRegistry(server_settings(), CATALOG)
assert validate_arguments(registry.validator("gitea_repo_get"), REPO_ARGS) is None
def test_validate_arguments_covers_remaining_keyword_messages() -> None:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(
{
"type": "object",
"properties": {
"n": {"type": "integer", "minimum": 3},
"t": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
"k": {"type": ["string", "null"]},
},
}
)
low = validate_arguments(validator, {"n": 1})
anyof = validate_arguments(validator, {"t": [1]})
multi = validate_arguments(validator, {"k": 5})
assert low is not None and "minimum 3" in low["message"]
assert anyof is not None and "'anyOf'" in anyof["message"] and anyof["path"] == "$.t"
assert multi is not None and '["string", "null"]' in multi["message"]
def test_validate_arguments_array_index_paths() -> None:
from jsonschema import Draft202012Validator
validator = Draft202012Validator(
{"type": "object", "properties": {"ids": {"type": "array", "items": {"type": "integer"}}}}
)
error = validate_arguments(validator, {"ids": [1, "x"]})
assert error is not None and error["path"] == "$.ids[1]"
# Calls: unknown tools
async def test_unknown_tool_is_a_protocol_error_and_is_audited() -> None:
async with connected() as client:
with pytest.raises(MCPError) as caught:
await client.call_tool("gitea_does_not_exist", {"a": 1})
assert caught.value.code == -32602
assert "gitea_does_not_exist" in caught.value.message
entry = audit_entries()[-1]
assert entry["tool_name"] == "gitea_does_not_exist"
assert entry["outcome"] == "unknown_tool"
# Calls: raw tool
async def test_raw_tool_happy_path(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues?state=open&labels=a&labels=b",
method="POST",
status_code=201,
json={"number": 7},
)
async with connected() as client:
result = await client.call_tool(
RAW_TOOL_NAME,
{
"method": "post",
"path": "/repos/octo/demo/issues",
"query": {"state": "open", "labels": ["a", "b"]},
"body": {"title": "hello"},
"sudo": "alice",
},
)
# lower-case verbs are not in the enum
assert result.is_error
assert payload_of(result)["error"]["kind"] == "invalid_argument"
async with connected() as client:
result = await client.call_tool(
RAW_TOOL_NAME,
{
"method": "POST",
"path": "/repos/octo/demo/issues",
"query": {"state": "open", "labels": ["a", "b"]},
"body": {"title": "hello"},
"sudo": "alice",
},
)
assert not result.is_error
payload = payload_of(result)
assert payload["status"] == 201 and payload["data"] == {"number": 7}
assert payload["identity"]["sudo"] == "alice"
sent = httpx_mock.get_requests(method="POST")[0]
assert json.loads(sent.content) == {"title": "hello"}
assert sent.headers["Sudo"] == "alice"
entry = audit_entries()[-1]
assert entry["tool_name"] == RAW_TOOL_NAME
assert entry["method"] == "POST" and entry["path"] == "/repos/octo/demo/issues"
assert entry["sudo"] == "alice" and entry["domain"] == "misc"
async def test_raw_tool_without_body_sends_none(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/version", json={"version": "1"})
async with connected() as client:
result = await client.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert not result.is_error
assert httpx_mock.get_requests(url=f"{API}/version")[0].content == b""
# Audit
async def test_audit_event_per_call_never_contains_argument_values(
httpx_mock: HTTPXMock,
) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues", method="POST", status_code=201, json={"number": 1}
)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", status_code=404, json={"message": "x"})
secret = "BODY-SECRET-VALUE-9f8e7d6c5b4a"
async with connected() as client:
await client.call_tool(
"gitea_issue_create_issue",
{**REPO_ARGS, "body": {"title": secret, "body": secret}, "sudo": "alice"},
)
await client.call_tool("gitea_repo_get", REPO_ARGS)
await client.call_tool("gitea_repo_get", {"owner": secret + "!", "repo": "x"})
with pytest.raises(MCPError):
await client.call_tool("gitea_unknown", {"value": secret})
entries = audit_entries()
assert [e["outcome"] for e in entries] == [
"success",
"not_found",
"invalid_argument",
"unknown_tool",
]
first = entries[0]
assert first["tool_name"] == "gitea_issue_create_issue"
assert first["domain"] == "issue" and first["method"] == "POST"
assert first["path"] == "/repos/{owner}/{repo}/issues"
assert first["identity_login"] == "admin" and first["identity_mode"] == "admin-token"
assert first["sudo"] == "alice" and first["result_status"] == "success"
assert first["params"] == {
"argument_names": ["body", "owner", "repo", "sudo"],
"http_status": 201,
}
assert first["duration_ms"] >= 0 and first["correlation_id"]
assert len({e["correlation_id"] for e in entries}) == 4
from aegis_gitea_mcp.audit import get_audit_logger
log_path = get_audit_logger().log_path
assert secret not in log_path.read_text(encoding="utf-8")
assert validate_audit_log_integrity(log_path) == (True, [])
async def test_audit_failure_does_not_break_the_call(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
class BrokenAudit:
def log_tool_invocation(self, *args: Any, **kwargs: Any) -> str:
raise OSError("disk full: " + GITEA_TOKEN)
async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type]
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert not result.is_error
assert "OSError" in caplog.text
assert GITEA_TOKEN not in caplog.text
# Internal errors
class ExplodingExecutor(GiteaExecutor):
"""Executor whose calls raise an exception carrying a secret."""
async def execute(self, *args: Any, **kwargs: Any) -> ToolOutcome:
raise RuntimeError(f"boom with {GITEA_TOKEN} and a stack")
async def test_internal_error_is_generic_and_audited(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
) -> None:
mock_user(httpx_mock)
settings = server_settings()
async with connected(settings, executor=ExplodingExecutor(settings)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "internal_error"
assert "boom" not in result.content[0].text # type: ignore[union-attr]
assert "RuntimeError" not in error["message"]
assert GITEA_TOKEN not in caplog.text and "Traceback" not in caplog.text
assert audit_entries()[-1]["outcome"] == "internal_error"
async def test_internal_error_shows_class_name_when_details_are_exposed(
httpx_mock: HTTPXMock,
) -> None:
mock_user(httpx_mock)
settings = server_settings(expose_error_details=True)
async with connected(settings, executor=ExplodingExecutor(settings)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
message = payload_of(result)["error"]["message"]
assert "RuntimeError" in message and "boom" not in message
# Identity
class FixedResolver:
"""Resolver returning a per-user identity (what the OAuth mode will plug in)."""
def __init__(self) -> None:
self.seen: list[AccessToken | None] = []
async def resolve(self, access: AccessToken | None) -> Identity:
self.seen.append(access)
return Identity(login="alice", mode="oauth", token="user-token-abcdefgh")
async def test_custom_resolver_identity_reaches_gitea_and_audit(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
resolver = FixedResolver()
async with connected(resolver=resolver) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert payload_of(result)["identity"] == {"login": "alice", "mode": "oauth", "sudo": None}
assert httpx_mock.get_requests()[0].headers["Authorization"] == "Bearer user-token-abcdefgh"
assert resolver.seen == [None] # in-memory transport has no access token
entry = audit_entries()[-1]
assert entry["identity_login"] == "alice" and entry["identity_mode"] == "oauth"
async def test_identity_resolution_failure_is_an_error_result(httpx_mock: HTTPXMock) -> None:
class Refusing:
async def resolve(self, access: AccessToken | None) -> Identity:
raise ToolError("unknown identity", status_code=401)
async with connected(resolver=Refusing()) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
assert payload_of(result)["error"] == {"kind": "identity_error", "message": "unknown identity"}
assert httpx_mock.get_requests() == []
assert audit_entries()[-1]["outcome"] == "identity_error"
async def test_missing_gitea_token_is_an_identity_error(httpx_mock: HTTPXMock) -> None:
async with connected(server_settings(gitea_token=None)) as client:
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
assert result.is_error
assert "GITEA_TOKEN" in payload_of(result)["error"]["message"]
assert httpx_mock.get_requests() == []
# Uploads
def attachment_args(content: bytes) -> dict[str, Any]:
return {
**REPO_ARGS,
"index": 1,
"attachment": {
"filename": "a.bin",
"content_base64": base64.b64encode(content).decode("ascii"),
},
}
@pytest.mark.parametrize("size", [10, 9, 8])
async def test_upload_within_cap_is_sent(httpx_mock: HTTPXMock, size: int) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
url=f"{API}/repos/octo/demo/issues/1/assets", method="POST", status_code=201, json={"id": 1}
)
async with connected(server_settings(max_binary_bytes=10)) as client:
result = await client.call_tool(
"gitea_issue_create_issue_attachment", attachment_args(b"x" * size)
)
assert not result.is_error
@pytest.mark.parametrize("size", [11, 12, 13, 1000])
async def test_upload_over_cap_is_rejected_before_any_request(
httpx_mock: HTTPXMock, size: int
) -> None:
async with connected(server_settings(max_binary_bytes=10)) as client:
result = await client.call_tool(
"gitea_issue_create_issue_attachment", attachment_args(b"x" * size)
)
assert result.is_error
error = payload_of(result)["error"]
assert error["kind"] == "invalid_argument"
assert error["path"] == "$.attachment.content_base64"
assert "MAX_BINARY_BYTES" in error["message"]
assert httpx_mock.get_requests() == []
def test_max_request_body_size_covers_the_upload_cap() -> None:
default = max_request_body_size(server_settings(max_binary_bytes=1))
big = max_request_body_size(server_settings(max_binary_bytes=5_242_880))
assert default == 4 * 1024 * 1024
assert big >= 5_242_880 * 4 // 3 + 1024 * 1024
assert big > default
# Lifecycle
async def test_server_lifespan_closes_the_executor() -> None:
settings = server_settings()
executor = GiteaExecutor(settings)
server = build_server(settings, executor=executor)
async with Client(server) as client:
await client.list_tools()
assert executor._client.is_closed is False
assert executor._client.is_closed is True
def test_get_tool_input_schema_hook() -> None:
server = build_server(server_settings(disabled_tools=["gitea_repo_get"]))
assert server.get_tool_input_schema is not None
assert server.get_tool_input_schema("gitea_repo_delete")["type"] == "object" # type: ignore[index]
assert server.get_tool_input_schema("gitea_repo_get") is None
assert server.tool_count == 537
def test_upload_cap_ignores_absent_and_malformed_file_arguments() -> None:
from aegis_gitea_mcp.server import _upload_cap_error
operation = CATALOG.get("gitea_issue_create_issue_attachment")
assert operation is not None
assert _upload_cap_error(operation, REPO_ARGS, 10) is None
assert _upload_cap_error(operation, {"attachment": {"content_base64": 5}}, 10) is None
assert _upload_cap_error(operation, {"attachment": "not-an-object"}, 10) is None
+59
View File
@@ -0,0 +1,59 @@
"""Shared builders for the server, auth and transport tests."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from mcp import Client
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.server import AegisServer, build_server
from tests.test_executor_helpers import API, BEARER_TOKEN, GITEA_TOKEN
ANY_HOST = "0.0.0.0" # noqa: S104 (a wildcard bind address used as test input)
__all__ = [
"ANY_HOST",
"API",
"BEARER_TOKEN",
"GITEA_TOKEN",
"audit_entries",
"connected",
"mock_user",
"server_settings",
]
def server_settings(**overrides: Any) -> Settings:
"""Settings for server tests: admin token, no bearer, no OAuth secrets."""
values: dict[str, Any] = {
"gitea_url": "https://gitea.example.com",
"gitea_token": GITEA_TOKEN,
}
values.update(overrides)
return Settings(**values)
def mock_user(httpx_mock: HTTPXMock, login: str = "admin") -> None:
"""Answer the admin login lookup (``GET /user``) for as many calls as needed."""
httpx_mock.add_response(url=f"{API}/user", json={"login": login}, is_reusable=True)
@asynccontextmanager
async def connected(settings: Settings | None = None, **kwargs: Any) -> AsyncIterator[Client]:
"""An in-memory SDK client connected to a freshly built server."""
server: AegisServer = build_server(settings or server_settings(), **kwargs)
async with Client(server) as client:
yield client
def audit_entries() -> list[dict[str, Any]]:
"""Payloads of every tool invocation written to the (test) audit log."""
path = get_audit_logger().log_path
entries = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
return [entry["payload"] for entry in entries if entry["event_type"] == "tool_invocation"]
+450
View File
@@ -0,0 +1,450 @@
"""Tests for the streamable HTTP app: health, auth, DNS-rebinding, rate limiting, MCP e2e."""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
import httpx2
import pytest
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server.auth.provider import AccessToken
from pytest_httpx import HTTPXMock
from starlette.routing import Route
from starlette.testclient import TestClient
from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.transports import (
GiteaProbe,
build_http_app,
transport_security,
)
from tests.test_server_helpers import (
ANY_HOST,
API,
BEARER_TOKEN,
GITEA_TOKEN,
mock_user,
server_settings,
)
BASE = "http://127.0.0.1:8080"
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0"},
},
}
MCP_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and rate limiter read GITEA_URL from the environment."""
def http_settings(**overrides: Any) -> Settings:
"""Settings with a bearer token on port 8080."""
values: dict[str, Any] = {"mcp_bearer_token": BEARER_TOKEN, "mcp_port": 8080}
values.update(overrides)
return server_settings(**values)
@pytest.fixture
def client(request: pytest.FixtureRequest) -> Iterator[TestClient]:
settings = http_settings()
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
yield test_client
def auth(token: str = BEARER_TOKEN) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# Health and index
def test_health_ok_uses_the_admin_token_and_is_cached(
client: TestClient, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(url=f"{API}/version", json={"version": "1.22"})
first = client.get("/health")
second = client.get("/health")
assert first.status_code == second.status_code == 200
assert first.json() == {
"status": "ok",
"version": __version__,
"tools": 538,
"gitea": "reachable",
}
requests = httpx_mock.get_requests()
assert len(requests) == 1 # 10 second cache
assert requests[0].headers["Authorization"] == f"token {GITEA_TOKEN}"
@pytest.mark.parametrize(
"outcome",
[
httpx.Response(500, text="gitea exploded at https://gitea.example.com " + GITEA_TOKEN),
httpx.Response(302, headers={"Location": "https://elsewhere.example/"}),
httpx.ConnectError("connection refused to https://gitea.example.com"),
httpx.ReadTimeout("slow"),
],
)
def test_health_degraded_leaks_nothing(
client: TestClient, httpx_mock: HTTPXMock, outcome: httpx.Response | Exception
) -> None:
if isinstance(outcome, Exception):
httpx_mock.add_exception(outcome)
else:
httpx_mock.add_response(
status_code=outcome.status_code, headers=outcome.headers, content=outcome.content
)
response = client.get("/health")
assert response.status_code == 503
assert response.json() == {"status": "degraded", "gitea": "unreachable"}
assert "gitea.example.com" not in response.text and GITEA_TOKEN not in response.text
def test_health_probe_without_a_token_is_anonymous(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/version", json={})
settings = http_settings(gitea_token=None)
with TestClient(build_http_app(settings), base_url=BASE) as test_client:
assert test_client.get("/health").status_code == 200
assert "Authorization" not in httpx_mock.get_requests()[0].headers
def test_health_recovers_after_the_cache_expires(
client: TestClient, httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
from aegis_gitea_mcp import transports
clock = [100.0]
monkeypatch.setattr(transports.time, "monotonic", lambda: clock[0])
httpx_mock.add_exception(httpx.ConnectError("down"))
httpx_mock.add_response(url=f"{API}/version", json={})
assert client.get("/health").status_code == 503
assert client.get("/health").status_code == 503 # still cached
clock[0] += 11
assert client.get("/health").status_code == 200
def test_health_and_index_need_no_token(client: TestClient, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(url=f"{API}/version", json={})
assert client.get("/").json() == {
"name": "aegis-gitea-mcp",
"version": __version__,
"mcp_endpoint": "/mcp",
}
assert client.get("/health").status_code == 200
async def test_probe_closes_only_its_own_client() -> None:
settings = http_settings()
own = GiteaProbe(settings)
await own.aclose()
assert own._client.is_closed
shared = httpx.AsyncClient()
borrowed = GiteaProbe(settings, shared)
await borrowed.aclose()
assert not shared.is_closed
await shared.aclose()
# Authentication
def test_mcp_without_token_is_401_with_resource_metadata(client: TestClient) -> None:
response = client.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert response.status_code == 401
challenge = response.headers["WWW-Authenticate"]
assert challenge.startswith("Bearer ")
assert (
'resource_metadata="http://127.0.0.1:8080/.well-known/oauth-protected-resource/mcp"'
in challenge
)
@pytest.mark.parametrize("token", ["wrong", BEARER_TOKEN + "x", ""])
def test_mcp_with_wrong_token_is_401(client: TestClient, token: str) -> None:
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(token)})
assert response.status_code == 401
assert "resource_metadata" in response.headers["WWW-Authenticate"]
def test_mcp_initialize_with_the_right_token(client: TestClient) -> None:
response = client.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
assert response.status_code == 200
body = response.text
assert "aegis-gitea-mcp" in body
assert BEARER_TOKEN not in body
def test_protected_resource_metadata_names_the_mcp_endpoint(client: TestClient) -> None:
response = client.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert response.json()["resource"] == "http://127.0.0.1:8080/mcp"
@asynccontextmanager
async def sdk_client(app: Any, token: str | None = BEARER_TOKEN) -> AsyncIterator[Client]:
"""The SDK client talking to ``app`` over an in-process ASGI transport."""
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with (
app.router.lifespan_context(app),
httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app), headers=headers, base_url=BASE
) as http_client,
Client(streamable_http_client(f"{BASE}/mcp", http_client=http_client)) as c,
):
yield c
async def test_sdk_client_lists_and_calls_tools_as_the_admin(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock, "admin")
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={"full_name": "octo/demo"})
app = build_http_app(http_settings())
async with sdk_client(app) as c:
assert len((await c.list_tools()).tools) == 538
result = await c.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
assert not result.is_error
payload = json.loads(result.content[0].text) # type: ignore[union-attr]
assert payload["identity"] == {"login": "admin", "mode": "admin-token", "sudo": None}
assert payload["data"] == {"full_name": "octo/demo"}
async def test_access_token_reaches_the_identity_resolver() -> None:
seen: list[AccessToken | None] = []
class Spy:
async def resolve(self, access: AccessToken | None) -> Identity:
seen.append(access)
return Identity(login="spy", mode="admin-token", token=GITEA_TOKEN)
from aegis_gitea_mcp.server import build_server
settings = http_settings()
app = build_http_app(settings, server=build_server(settings, resolver=Spy()))
async with sdk_client(app) as c:
await c.call_tool("gitea_repo_get", {"owner": "bad owner", "repo": "x"}) # invalid
await c.call_tool(RAW_TOOL_NAME, {"method": "GET", "path": "/version"})
assert len(seen) == 1 and seen[0] is not None
assert seen[0].client_id == STATIC_CLIENT_ID
assert BEARER_TOKEN not in repr(seen[0])
# DNS rebinding and origin
def test_bad_host_header_is_rejected(httpx_mock: HTTPXMock) -> None:
with TestClient(build_http_app(http_settings()), base_url="http://evil.example:8080") as c:
response = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
assert response.status_code == 421
def test_bad_origin_is_rejected(client: TestClient) -> None:
response = client.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth(), "Origin": "https://evil.example"}
)
assert response.status_code == 403
def test_public_host_is_allowed_when_configured() -> None:
settings = http_settings(public_base_url="https://mcp.example.com")
with TestClient(build_http_app(settings), base_url="https://mcp.example.com") as c:
ok = c.post(
"/mcp",
json=INITIALIZE,
headers={**MCP_HEADERS, **auth(), "Origin": "https://mcp.example.com"},
)
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
challenge = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert ok.status_code == 200
assert metadata.json()["resource"] == "https://mcp.example.com/mcp"
assert (
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
in (challenge.headers["WWW-Authenticate"])
)
def test_transport_security_hosts_and_origins() -> None:
settings = http_settings(
mcp_host="192.168.1.5",
public_base_url="https://mcp.example.com:8443/",
allowed_hosts=["proxy.internal:9000"],
)
security = transport_security(settings)
assert security.enable_dns_rebinding_protection is True
assert security.allowed_hosts == [
"127.0.0.1:8080",
"localhost:8080",
"[::1]:8080",
"192.168.1.5:8080",
"mcp.example.com:8443",
"mcp.example.com",
"proxy.internal:9000",
]
assert "https://mcp.example.com:8443" in security.allowed_origins
assert "https://proxy.internal:9000" in security.allowed_origins
assert "http://127.0.0.1:8080" in security.allowed_origins
def test_transport_security_wildcard_bind_adds_no_bind_host() -> None:
security = transport_security(http_settings(mcp_host=ANY_HOST))
assert security.allowed_hosts == ["127.0.0.1:8080", "localhost:8080", "[::1]:8080"]
# Rate limiting
def test_rate_limit_returns_429_on_the_mcp_path_only(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
# The limiter reads the process-wide settings.
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True)
settings = http_settings(rate_limit_per_minute=2)
with TestClient(build_http_app(settings), base_url=BASE) as c:
statuses = [
c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code for _ in range(3)
]
limited = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
health = c.get("/health")
assert statuses == [401, 401, 429]
assert limited.status_code == 429
assert limited.json()["error"]["kind"] == "rate_limited"
assert limited.headers["Retry-After"] == "60"
assert health.status_code == 200
def test_rate_limit_is_keyed_per_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "1")
settings = http_settings(rate_limit_per_minute=1)
with TestClient(build_http_app(settings), base_url=BASE) as c:
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
assert first.status_code == 200
from aegis_gitea_mcp.rate_limit import get_rate_limiter
limiter = get_rate_limiter()
assert limiter._token_limiter.max_requests == 1
# The raw header is never used as a key, only its hash.
keys = list(limiter._token_limiter._events)
assert keys and all(BEARER_TOKEN not in key for key in keys)
# Auth wiring
def test_no_auth_on_loopback_works_and_warns(caplog: pytest.LogCaptureFixture) -> None:
settings = http_settings(mcp_bearer_token=None)
with caplog.at_level(logging.WARNING):
app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
response = c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
metadata = c.get("/.well-known/oauth-protected-resource/mcp")
assert response.status_code == 200
assert metadata.status_code == 404
assert "WITHOUT authentication" in caplog.text
def test_extra_verifier_is_combined_with_the_static_bearer() -> None:
class Other:
async def verify_token(self, token: str) -> AccessToken | None:
if token == "oauth-token":
return AccessToken(token="t", client_id="oauth-client", scopes=[])
return None
app = build_http_app(http_settings(), token_verifier=Other())
with TestClient(app, base_url=BASE) as c:
static = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
oauth = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("oauth-token")})
denied = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth("nope")})
assert (static.status_code, oauth.status_code, denied.status_code) == (200, 200, 401)
def test_only_an_extra_verifier_protects_the_endpoint() -> None:
class Other:
async def verify_token(self, token: str) -> AccessToken | None:
return None
app = build_http_app(http_settings(mcp_bearer_token=None), token_verifier=Other())
with TestClient(app, base_url=BASE) as c:
assert c.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS).status_code == 401
def test_auth_provider_without_a_verifier_is_refused() -> None:
with pytest.raises(ValueError, match="token verifier"):
build_http_app(
http_settings(mcp_bearer_token=None),
auth_server_provider=object(), # type: ignore[arg-type]
)
def test_extra_routes_are_served_without_mcp_auth() -> None:
from starlette.responses import PlainTextResponse
async def callback(request: Any) -> PlainTextResponse:
return PlainTextResponse("ok")
app = build_http_app(http_settings(), extra_routes=[Route("/oauth/callback", callback)])
with TestClient(app, base_url=BASE) as c:
assert c.get("/oauth/callback").text == "ok"
def test_request_body_cap_admits_a_full_size_upload() -> None:
settings = http_settings(max_binary_bytes=5_242_880)
app = build_http_app(settings)
with TestClient(app, base_url=BASE) as c:
padding = "x" * (6 * 1024 * 1024)
body = {**INITIALIZE, "params": {**INITIALIZE["params"], "padding": padding}}
response = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
assert response.status_code != 413
small = build_http_app(http_settings(max_binary_bytes=1))
with TestClient(small, base_url=BASE) as c:
rejected = c.post("/mcp", json=body, headers={**MCP_HEADERS, **auth()})
assert rejected.status_code == 413
+335
View File
@@ -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