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

+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