feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71

Merged
Latte merged 70 commits from feat/full-api-coverage into dev 2026-10-14 17:25:25 +00:00
2 changed files with 500 additions and 1 deletions
Showing only changes of commit 984673e8cf - Show all commits

No files matched your search

+2 -1
View File
@@ -273,7 +273,8 @@ def validate_sudo(sudo: Any) -> str | None:
"""Validate the optional ``sudo`` login (None when not given).""" """Validate the optional ``sudo`` login (None when not given)."""
if sudo is None: if sudo is None:
return None return None
if not isinstance(sudo, str) or not _SUDO_PATTERN.match(sudo): # fullmatch: ``$`` would also accept a trailing newline.
if not isinstance(sudo, str) or not _SUDO_PATTERN.fullmatch(sudo):
raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')") raise ArgumentError("sudo must be a Gitea username (letters, digits, '-', '_', '.')")
return sudo return sudo
+498
View File
@@ -0,0 +1,498 @@
"""Malformed input is rejected at the boundary, before Gitea is contacted."""
from __future__ import annotations
import base64
import json
from typing import Any
import pytest
from mcp import Client, MCPError
from pytest_httpx import HTTPXMock
from starlette.testclient import TestClient
from aegis_gitea_mcp import server as server_module
from aegis_gitea_mcp.server import max_request_body_size
from aegis_gitea_mcp.transports import build_http_app
from tests.test_server_helpers import API, BEARER_TOKEN, connected, mock_user, server_settings
# Many cases are refused before anything is sent, others only after the admin identity lookup:
# what matters here is which requests reach Gitea, asserted explicitly in every test.
pytestmark = pytest.mark.httpx_mock(
assert_all_responses_were_requested=False, assert_all_requests_were_expected=False
)
REPO = {"owner": "octo", "repo": "demo"}
UPLOAD = "gitea_release_create_release_attachment"
UPLOAD_ARGS = {**REPO, "id": 1}
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger reads the process-wide settings."""
def error_of(result: Any) -> dict[str, Any]:
assert result.is_error, result.content[0].text
error: dict[str, Any] = json.loads(result.content[0].text)["error"]
return error
def gitea_calls(httpx_mock: HTTPXMock) -> list[str]:
"""Requests that reached Gitea, without the admin identity lookup."""
return [
str(r.url) for r in httpx_mock.get_requests() if not str(r.url).endswith("/api/v1/user")
]
# Schema: unknown arguments, wrong types, missing arguments
@pytest.mark.parametrize(
("tool", "arguments", "fragment"),
[
("gitea_repo_get", {**REPO, "unexpected": 1}, "unexpected argument"),
("gitea_repo_get", {"owner": "octo"}, "missing required"),
("gitea_repo_get", {}, "missing required"),
("gitea_repo_get", {"owner": 7, "repo": "demo"}, "type"),
("gitea_repo_get", {"owner": ["octo"], "repo": "demo"}, "type"),
("gitea_repo_get", {"owner": None, "repo": "demo"}, "type"),
("gitea_repo_get", {"owner": "", "repo": "demo"}, "minLength"),
("gitea_repo_get", {"owner": "a" * 256, "repo": "demo"}, "maxLength"),
("gitea_repo_get", {"owner": "bad owner", "repo": "demo"}, "pattern"),
("gitea_repo_get", {"owner": "octo/evil", "repo": "demo"}, "pattern"),
("gitea_repo_get", {"owner": "octo", "repo": "de%6Dmo"}, "pattern"),
("gitea_repo_get", {**REPO, "sudo": 1}, "type"),
("gitea_issue_get_issue", {**REPO, "index": "one"}, "type"),
("gitea_issue_get_issue", {**REPO, "index": 1.5}, "type"),
("gitea_issue_get_issue", {**REPO, "index": True}, "type"),
(
"gitea_issue_create_issue",
{**REPO, "body": {"title": "x", "labels": ["a"]}},
"type",
),
("gitea_issue_create_issue", {**REPO, "body": {"title": "x", "extra": 1}}, "unexpected"),
("gitea_issue_create_issue", {**REPO, "body": {"body": "no title"}}, "missing required"),
("gitea_issue_create_issue", {**REPO, "body": "not an object"}, "type"),
("gitea_api_request", {"method": "GET"}, "missing required"),
("gitea_api_request", {"method": "GET", "path": "/x", "extra": 1}, "unexpected"),
("gitea_api_request", {"method": "GET", "path": 5}, "type"),
("gitea_api_request", {"method": "GET", "path": ""}, "minLength"),
("gitea_api_request", {"method": "GET", "path": "x"}, "pattern"),
("gitea_api_request", {"method": "GET", "path": "/" + "a" * 2048}, "maxLength"),
("gitea_api_request", {"method": "GET", "path": "/x", "query": [1]}, "type"),
("gitea_api_request", {"method": "GET", "path": "/x", "query": {"a": {"b": 1}}}, ""),
],
)
async def test_invalid_arguments_are_rejected_before_gitea(
httpx_mock: HTTPXMock, tool: str, arguments: dict[str, Any], fragment: str
) -> None:
async with connected() as client:
result = await client.call_tool(tool, arguments)
error = error_of(result)
assert error["kind"] == "invalid_argument"
assert fragment in error["message"]
assert httpx_mock.get_requests() == []
async def test_a_trailing_newline_passes_the_schema_but_not_the_executor(
httpx_mock: HTTPXMock,
) -> None:
# Python's `$` also matches before a trailing newline, so the schema pattern lets
# this through and the executor's own character check is what refuses it.
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo\n"})
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
async def test_rejection_messages_never_echo_the_values() -> None:
planted = "planted-secret-value-123456"
async with connected() as client:
results = [
await client.call_tool("gitea_repo_get", {"owner": planted + " x", "repo": "demo"}),
await client.call_tool("gitea_repo_get", {"owner": 1, "repo": planted}),
await client.call_tool("gitea_repo_get", {**REPO, planted: planted}),
]
for result in results:
assert planted not in result.content[0].text
# Path values (catalog tools and the raw tool)
BAD_PATH_VALUES = [
"..",
"a/../b",
"../a",
"a/..",
".",
"./a",
"a/./b",
"a//b",
"/a",
"a/",
"a\\b",
"a?b",
"a#b",
"a%b",
"a%2e%2e/b",
"%2e%2e",
"a%2Fb",
"a\x00b",
"a\rb",
"a\nb",
"a" + chr(0x2028) + "b",
"a" + chr(0x2029) + "b",
"a\tb",
"a\x7fb",
]
@pytest.mark.parametrize("value", BAD_PATH_VALUES, ids=lambda v: repr(v))
async def test_unsafe_path_values_are_rejected_for_catalog_tools(
httpx_mock: HTTPXMock, value: str
) -> None:
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool("gitea_repo_get_raw_file", {**REPO, "filepath": value})
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
@pytest.mark.parametrize("value", [v for v in BAD_PATH_VALUES if v not in {"/a"}], ids=repr)
async def test_unsafe_path_values_are_rejected_for_the_raw_tool(
httpx_mock: HTTPXMock, value: str
) -> None:
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": f"/repos/{value}/x"}
)
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
@pytest.mark.parametrize("path", ["//evil.example/x", "///x", "//", "/a/../b", "/.", "/.."])
async def test_raw_paths_that_reshape_the_url_are_rejected(
httpx_mock: HTTPXMock, path: str
) -> None:
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool("gitea_api_request", {"method": "GET", "path": path})
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
async def test_safe_path_values_are_encoded_not_interpreted(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo/raw/docs/a%20b.md", text="ok")
async with connected() as client:
result = await client.call_tool(
"gitea_repo_get_raw_file", {**REPO, "filepath": "docs/a b.md"}
)
assert not result.is_error
# Query names and sudo
@pytest.mark.parametrize(
"name", ["token", "access_token", "sudo", "_sudo", "TOKEN", "Access_Token", "SuDo", "_SUDO"]
)
async def test_credential_query_names_are_refused_on_the_raw_tool(
httpx_mock: HTTPXMock, name: str
) -> None:
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool(
"gitea_api_request", {"method": "GET", "path": "/user", "query": {name: "x"}}
)
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
@pytest.mark.parametrize(
"sudo",
[
"bob\r\nX-Injected: 1",
"bob\n",
"bob ",
" bob",
"bo b",
"a" * 256,
"",
"b" + chr(0xF6) + "b",
"bob;",
"bob\x00",
],
ids=repr,
)
async def test_sudo_values_that_could_inject_headers_are_rejected(
httpx_mock: HTTPXMock, sudo: str
) -> None:
async with connected() as client:
for tool, arguments in (
("gitea_repo_get", {**REPO, "sudo": sudo}),
("gitea_api_request", {"method": "GET", "path": "/user", "sudo": sudo}),
):
result = await client.call_tool(tool, arguments)
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
# Uploads
async def test_an_oversized_upload_is_rejected_before_it_is_decoded(
httpx_mock: HTTPXMock,
) -> None:
# Not valid base64 at all: only a size check that runs before decoding reports "limit".
huge = "!" * 4000
async with connected(server_settings(max_binary_bytes=1000)) as client:
result = await client.call_tool(
UPLOAD, {**UPLOAD_ARGS, "attachment": {"filename": "a.bin", "content_base64": huge}}
)
error = error_of(result)
assert error["kind"] == "invalid_argument"
assert "limit" in error["message"] and "MAX_BINARY_BYTES" in error["message"]
assert httpx_mock.get_requests() == []
async def test_an_upload_at_the_limit_is_accepted(httpx_mock: HTTPXMock) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
method="POST", url=f"{API}/repos/octo/demo/releases/1/assets", status_code=201, json={}
)
content = base64.b64encode(b"x" * 1000).decode()
async with connected(server_settings(max_binary_bytes=1000)) as client:
result = await client.call_tool(
UPLOAD, {**UPLOAD_ARGS, "attachment": {"filename": "a.bin", "content_base64": content}}
)
assert not result.is_error
@pytest.mark.parametrize(
"attachment",
[
{"filename": "a.bin", "content_base64": "not base64!!"},
{"filename": "a.bin", "content_base64": "YQ"}, # missing padding
{"filename": 'a".bin', "content_base64": "YQ=="},
{"filename": "a\r\nb.bin", "content_base64": "YQ=="},
{"filename": "a\nb.bin", "content_base64": "YQ=="},
{"filename": "a" + chr(0x2028) + "b.bin", "content_base64": "YQ=="},
{"filename": "", "content_base64": "YQ=="},
{"filename": "x" * 300, "content_base64": "YQ=="},
{"filename": "a.bin", "content_base64": "YQ==", "content_type": "text/plain\r\nX: 1"},
{"filename": "a.bin", "content_base64": "YQ==", "content_type": 5},
{"filename": "a.bin", "content_base64": "YQ==", "path": "/etc/passwd"},
{"filename": "a.bin", "content_base64": "YQ==", "unknown": 1},
{"filename": "a.bin"},
{"content_base64": "YQ=="},
{"filename": 1, "content_base64": "YQ=="},
{"filename": "a.bin", "content_base64": 1},
"just a string",
["a.bin", "YQ=="],
],
ids=lambda a: json.dumps(a)[:60],
)
async def test_malformed_uploads_are_rejected_before_gitea(
httpx_mock: HTTPXMock, attachment: Any
) -> None:
mock_user(httpx_mock)
async with connected() as client:
result = await client.call_tool(UPLOAD, {**UPLOAD_ARGS, "attachment": attachment})
assert error_of(result)["kind"] == "invalid_argument"
assert gitea_calls(httpx_mock) == []
# Raw method and READ_ONLY
@pytest.mark.parametrize(
"method", ["TRACE", "CONNECT", "OPTIONS", "HEAD", "get", "G ET", "", "GET\r\n"]
)
async def test_raw_methods_outside_the_enum_are_rejected(
httpx_mock: HTTPXMock, method: str
) -> None:
async with connected() as client:
result = await client.call_tool("gitea_api_request", {"method": method, "path": "/user"})
assert error_of(result)["kind"] == "invalid_argument"
assert httpx_mock.get_requests() == []
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
async def test_read_only_mode_refuses_every_non_get_raw_call(
httpx_mock: HTTPXMock, method: str
) -> None:
async with connected(server_settings(read_only=True)) as client:
result = await client.call_tool(
"gitea_api_request", {"method": method, "path": "/repos/o/r/issues", "body": {}}
)
assert error_of(result)["kind"] == "read_only"
assert httpx_mock.get_requests() == []
async def test_read_only_mode_does_not_list_or_run_write_tools(httpx_mock: HTTPXMock) -> None:
async with connected(server_settings(read_only=True)) as client:
names = {tool.name for tool in (await client.list_tools()).tools}
with pytest.raises(MCPError):
await client.call_tool("gitea_issue_create_issue", {**REPO, "body": {"title": "x"}})
with pytest.raises(MCPError):
await client.call_tool("gitea_repo_delete", REPO)
assert "gitea_issue_create_issue" not in names and "gitea_repo_delete" not in names
assert "gitea_repo_get" in names
assert httpx_mock.get_requests() == []
# Deep JSON
def nest(depth: int, leaf: Any = "x") -> Any:
value = leaf
for _ in range(depth):
value = {"a": value}
return value
@pytest.mark.parametrize("depth", [10, 60, 95])
async def test_deeply_nested_arguments_give_a_result_not_an_exception(
httpx_mock: HTTPXMock, depth: int
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
method="POST", url=f"{API}/repos/o/r/issues", status_code=422, json={"message": "no"}
)
async with connected() as client:
result = await client.call_tool(
"gitea_api_request",
{"method": "POST", "path": "/repos/o/r/issues", "body": nest(depth)},
)
assert result.is_error
assert json.loads(result.content[0].text)["error"]["kind"] == "validation_failed"
async def test_a_recursion_error_during_validation_is_a_generic_error_result(
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
) -> None:
def explode(*args: Any, **kwargs: Any) -> None:
raise RecursionError("maximum recursion depth exceeded")
monkeypatch.setattr(server_module, "validate_arguments", explode)
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO)
assert error_of(result)["kind"] == "internal_error"
assert "RecursionError" not in result.content[0].text
assert httpx_mock.get_requests() == []
@pytest.mark.parametrize("depth", [101, 2000, 50_000])
async def test_deeply_nested_gitea_responses_are_returned_as_text(
httpx_mock: HTTPXMock, depth: int
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(
url=f"{API}/repos/octo/demo",
content=("[" * depth + "]" * depth).encode(),
headers={"content-type": "application/json"},
)
async with connected() as client:
result = await client.call_tool("gitea_repo_get", REPO)
assert not result.is_error
assert isinstance(result.structured_content["data"], str) # type: ignore[index]
# HTTP boundary
def http_client(app: Any) -> TestClient:
return TestClient(app, base_url="http://127.0.0.1:8080")
def mcp_headers(**extra: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {BEARER_TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**extra,
}
def test_a_body_over_the_cap_gets_413() -> None:
settings = server_settings(mcp_bearer_token=BEARER_TOKEN)
with http_client(build_http_app(settings)) as client:
response = client.post(
"/mcp", content=b"x" * (max_request_body_size(settings) + 1), headers=mcp_headers()
)
assert response.status_code == 413
def test_a_non_json_content_type_gets_400() -> None:
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
for content_type in (
"text/plain",
"application/x-www-form-urlencoded",
"multipart/form-data",
):
response = client.post(
"/mcp", content=b"{}", headers=mcp_headers(**{"Content-Type": content_type})
)
assert response.status_code == 400, content_type
def test_a_foreign_host_header_gets_421() -> None:
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
for host in ("evil.example.com", "evil.example.com:8080", "127.0.0.1.evil.example.com"):
response = client.post("/mcp", content=b"{}", headers=mcp_headers(Host=host))
assert response.status_code == 421, host
def test_a_foreign_origin_gets_403() -> None:
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
for origin in ("https://evil.example.com", "http://127.0.0.1.evil.example.com", "null"):
response = client.post("/mcp", content=b"{}", headers=mcp_headers(Origin=origin))
assert response.status_code == 403, origin
def test_malformed_and_deeply_nested_json_rpc_gets_a_parse_error_not_a_crash() -> None:
deep = "[" * 5000 + "]" * 5000
bodies = [
b"not json",
b"{",
b"[]",
b'{"jsonrpc":"2.0"}',
(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"gitea_api_request",'
f'"arguments":{{"method":"POST","path":"/x","body":{deep}}}}}}}'
).encode(),
]
for body in bodies:
with http_client(build_http_app(server_settings(mcp_bearer_token=BEARER_TOKEN))) as client:
response = client.post("/mcp", content=body, headers=mcp_headers())
assert response.status_code in {400, 422}, body[:20]
assert "Traceback" not in response.text
async def test_the_listing_is_unaffected_by_all_of_the_above() -> None:
async with connected() as client:
assert isinstance(client, Client)
assert len((await client.list_tools()).tools) == 538