fix: harden get_issue parsing and surface real errors (#27); align CI image publish

get_issue raised 'NoneType' object is not iterable on issues whose
labels/assignees Gitea returns as null or with non-dict elements (the #13
class), which reached clients as an opaque JSON-RPC -32603 with no detail.

- read_tools: skip non-dict label/assignee entries in get_issue_tool
- server: detect a wrapped GiteaNotFoundError via the __cause__ chain and
  return 404 / JSON-RPC -32000 with a clear message; include the exception
  type name in masked internal errors so future masked failures are
  diagnosable without exposing messages or stack traces
- tests: cover non-dict collection elements and the not-found / typed-error
  responses
- ci: rewrite docker.yml to build, smoke-test and push the image to the
  Gitea container registry on merge to main/dev, matching the hiddenden.cafe
  pattern (only REGISTRY_TOKEN required)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:51:58 +02:00
parent 026f3a654f
commit 41749fd7b4
5 changed files with 295 additions and 156 deletions
+80
View File
@@ -499,6 +499,86 @@ def test_sse_tools_call_http_exception(client: TestClient, monkeypatch: pytest.M
assert "insufficient scope" in body["error"]["message"].lower()
def test_tool_call_not_found_maps_to_404(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A GiteaNotFoundError wrapped in RuntimeError surfaces as a clear 404."""
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
try:
raise GiteaNotFoundError("Resource not found")
except GiteaNotFoundError as exc:
raise RuntimeError("Failed to get issue: Resource not found") from exc
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
)
assert response.status_code == 404
assert "not found" in response.json()["error"].lower()
def test_tool_call_internal_error_includes_exception_type(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Masked internal errors name the exception type (safe) but never the message."""
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
raise TypeError("'NoneType' object is not iterable")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
)
assert response.status_code == 500
error = response.json()["error"]
assert "TypeError" in error
assert "NoneType" not in error
def test_sse_tools_call_not_found_returns_jsonrpc_error(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A wrapped GiteaNotFoundError in the SSE path returns -32000 with a clear message."""
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
try:
raise GiteaNotFoundError("Resource not found")
except GiteaNotFoundError as exc:
raise RuntimeError("Failed to get issue: Resource not found") from exc
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={
"jsonrpc": "2.0",
"id": "nf-1",
"method": "tools/call",
"params": {
"name": "get_issue",
"arguments": {"owner": "a", "repo": "b", "issue_number": 1},
},
},
)
assert response.status_code == 200
body = response.json()
assert body["error"]["code"] == -32000
assert "not found" in body["error"]["message"].lower()
def test_call_nonexistent_tool(client: TestClient) -> None:
"""Unknown tools return 404 after successful auth."""
response = client.post(