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(
+27
View File
@@ -303,6 +303,33 @@ async def test_get_issue_tolerates_null_collections() -> None:
assert result["assignees"] == []
@pytest.mark.asyncio
async def test_get_issue_skips_non_dict_collection_elements() -> None:
"""Defense-in-depth for #27: tolerate non-dict entries inside labels/assignees.
A stray null/non-object element would otherwise raise AttributeError when
`.get()` is called on it, surfacing as an opaque internal error.
"""
class MalformedElementsGitea(StubGitea):
async def get_issue(self, owner, repo, index):
return {
"number": index,
"title": "Issue",
"body": "Body",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}, None, "weird"],
"assignees": [{"login": "bob"}, None, 42],
}
result = await get_issue_tool(
MalformedElementsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1}
)
assert result["labels"] == ["bug"]
assert result["assignees"] == ["bob"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool,args,expected_key",