"""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_write_failure_after_execution_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) # The call already ran (its side effect cannot be undone), so the result still goes out. assert not result.is_error assert "OSError" in caplog.text assert GITEA_TOKEN not in caplog.text async def test_unavailable_audit_log_refuses_the_call_before_gitea_is_contacted( httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: def broken() -> None: raise OSError("permission denied: " + GITEA_TOKEN) monkeypatch.setattr("aegis_gitea_mcp.server.get_audit_logger", broken) async with connected() as client: result = await client.call_tool("gitea_repo_get", REPO_ARGS) written = await client.call_tool("gitea_issue_create_issue", {**REPO_ARGS, "body": {}}) assert result.is_error and written.is_error error = payload_of(result)["error"] assert error["kind"] == "audit_unavailable" assert GITEA_TOKEN not in json.dumps(payload_of(result)) + caplog.text assert httpx_mock.get_requests() == [] async def test_audit_correlation_id_is_the_request_id(httpx_mock: HTTPXMock) -> None: mock_user(httpx_mock) httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={}) async with connected() as client: await client.call_tool("gitea_repo_get", REPO_ARGS) from aegis_gitea_mcp.audit import get_audit_logger line = get_audit_logger().log_path.read_text(encoding="utf-8").splitlines()[-1] entry = json.loads(line) assert entry["payload"]["correlation_id"] == entry["request_id"] != "-" async def test_rejected_raw_call_audits_no_unvalidated_path_or_sudo() -> None: secret = "SECRET-IN-PATH-9f8e7d6c5b" async with connected() as client: bad_path = await client.call_tool( "gitea_api_request", {"method": "GET", "path": secret, "sudo": secret} ) bad_sudo = await client.call_tool( "gitea_api_request", {"method": "GET", "path": "/user", "sudo": "bad name!"} ) assert bad_path.is_error and bad_sudo.is_error first, second = audit_entries() assert first["outcome"] == second["outcome"] == "invalid_argument" for entry in (first, second): assert "path" not in entry and "sudo" not in entry from aegis_gitea_mcp.audit import get_audit_logger assert secret not in get_audit_logger().log_path.read_text(encoding="utf-8") async def test_raw_call_audits_the_validated_method_and_path(httpx_mock: HTTPXMock) -> None: mock_user(httpx_mock) httpx_mock.add_response(url=f"{API}/version", json={}) async with connected() as client: await client.call_tool( "gitea_api_request", {"method": "GET", "path": "/version", "sudo": "alice"} ) entry = audit_entries()[-1] assert (entry["method"], entry["path"], entry["sudo"]) == ("GET", "/version", "alice") # 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