feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
No files matched your search
@@ -336,10 +336,11 @@ class Settings(BaseSettings):
|
||||
"""Accept only granular Gitea scopes (unknown scopes make Gitea grant full access)."""
|
||||
unknown = sorted({scope for scope in value if scope not in GITEA_SCOPES})
|
||||
if unknown:
|
||||
# The offending values are not echoed: a misplaced secret must not reach stderr.
|
||||
raise ValueError(
|
||||
f"OAUTH_SCOPES contains unknown scope(s): {', '.join(unknown)}. "
|
||||
"Gitea grants full access for unknown scopes, so only granular "
|
||||
"read:/write: scopes are accepted"
|
||||
f"OAUTH_SCOPES contains {len(unknown)} unknown scope(s). Gitea grants full "
|
||||
"access for unknown scopes, so only granular read:/write: scopes are accepted "
|
||||
f"({', '.join(sorted(GITEA_SCOPES))})"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("OAUTH_SCOPES must not be empty")
|
||||
@@ -365,9 +366,7 @@ class Settings(BaseSettings):
|
||||
unknown = sorted({item for item in lowered if item != "all" and item not in TOOL_DOMAINS})
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(TOOL_DOMAINS | {"all"}))
|
||||
raise ValueError(
|
||||
f"TOOLSETS contains unknown domain(s): {', '.join(unknown)}; use {allowed}"
|
||||
)
|
||||
raise ValueError(f"TOOLSETS contains {len(unknown)} unknown domain(s); use {allowed}")
|
||||
if not lowered:
|
||||
raise ValueError("TOOLSETS must not be empty (use 'all')")
|
||||
return list(dict.fromkeys(lowered))
|
||||
|
||||
@@ -304,3 +304,85 @@ def test_secrets_in_object_keys_are_masked_like_values() -> None:
|
||||
assert token not in json.dumps(masked) and token not in json.dumps(blocked)
|
||||
assert masked["outer"][0]["plain"] != token
|
||||
assert blocked != nested
|
||||
|
||||
|
||||
# Configuration errors and a real stdio process
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variable", "value"),
|
||||
[
|
||||
("GITEA_URL", "https://user:" + GITEA_TOKEN + "@gitea.example.com"),
|
||||
("GITEA_URL", "https://gitea.example.com?token=" + GITEA_TOKEN),
|
||||
("MCP_BEARER_TOKEN", "short-" + "x" * 5),
|
||||
("PUBLIC_BASE_URL", "http://mcp.example.com/" + GITEA_TOKEN),
|
||||
("OAUTH_SCOPES", GITEA_TOKEN),
|
||||
("TOKEN_ENCRYPTION_KEY", "tooshort-" + ENCRYPTION_KEY[:5]),
|
||||
("MCP_PORT", GITEA_TOKEN),
|
||||
("MCP_PATH", GITEA_TOKEN),
|
||||
("FORWARDED_ALLOW_IPS", GITEA_TOKEN),
|
||||
("LOG_LEVEL", GITEA_TOKEN),
|
||||
],
|
||||
)
|
||||
def test_configuration_errors_name_the_setting_but_never_echo_its_value(
|
||||
variable: str,
|
||||
value: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
from aegis_gitea_mcp import cli
|
||||
from aegis_gitea_mcp.cli import main
|
||||
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", GITEA_TOKEN)
|
||||
monkeypatch.setenv(variable, value)
|
||||
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||
|
||||
assert main(["serve", "--transport", "http"]) == 2
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert variable in err
|
||||
assert value not in err and GITEA_TOKEN not in err
|
||||
assert "Traceback" not in err
|
||||
|
||||
|
||||
async def test_a_real_stdio_process_keeps_every_secret_out_of_stdout_stderr_and_the_audit_file(
|
||||
tmp_path: Any,
|
||||
) -> None:
|
||||
import sys
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
|
||||
secrets = [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY]
|
||||
env = {
|
||||
"GITEA_URL": "http://127.0.0.1:9", # nothing listens: every call fails
|
||||
"GITEA_TOKEN": GITEA_TOKEN,
|
||||
"MCP_BEARER_TOKEN": BEARER_TOKEN,
|
||||
"GITEA_OAUTH_CLIENT_SECRET": APP_CLIENT_SECRET,
|
||||
"TOKEN_ENCRYPTION_KEY": ENCRYPTION_KEY,
|
||||
"DATA_DIR": str(tmp_path / "state"),
|
||||
"AUDIT_LOG_PATH": str(tmp_path / "state" / "audit.log"),
|
||||
"REQUEST_TIMEOUT_SECONDS": "2",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
}
|
||||
params = StdioServerParameters(
|
||||
command=sys.executable,
|
||||
args=["-m", "aegis_gitea_mcp.cli", "serve", "--transport", "stdio"],
|
||||
env=env,
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
stderr_path = tmp_path / "stderr.log"
|
||||
with stderr_path.open("w", encoding="utf-8") as errlog:
|
||||
async with Client(stdio_client(params, errlog=errlog)) as client:
|
||||
results = [
|
||||
await client.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"}),
|
||||
await client.call_tool("gitea_repo_get", {"owner": 5}),
|
||||
await client.call_tool("gitea_api_request", {"method": "GET", "path": "/user"}),
|
||||
]
|
||||
|
||||
for result in results:
|
||||
assert result.is_error
|
||||
assert_clean(result.content[0].text, secrets) # type: ignore[union-attr]
|
||||
assert_clean(stderr_path.read_text(encoding="utf-8"), secrets)
|
||||
assert_clean((tmp_path / "state" / "audit.log").read_text(encoding="utf-8"), secrets)
|
||||
Reference in new issue
Block a user