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
+66 -10
View File
@@ -26,6 +26,7 @@ from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaNotFoundError,
)
from aegis_gitea_mcp.logging_utils import configure_logging
from aegis_gitea_mcp.mcp_protocol import (
@@ -128,6 +129,39 @@ _REAUTH_GUIDANCE = (
"and in your client, then re-authorize."
)
_NOT_FOUND_MESSAGE = "Resource not found in Gitea (it may not exist or be inaccessible)."
def _find_not_found(exc: BaseException) -> GiteaNotFoundError | None:
"""Return the GiteaNotFoundError in an exception's cause chain, if any.
Tool handlers wrap backend ``GiteaError`` (including ``GiteaNotFoundError``)
in ``RuntimeError`` before it reaches the request layer, so a not-found
condition is preserved only via ``__cause__``. Walking the chain lets the
server return an actionable "not found" instead of an opaque internal error.
"""
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
if isinstance(current, GiteaNotFoundError):
return current
seen.add(id(current))
current = current.__cause__
return None
def _masked_internal_error(exc: BaseException, expose_details: bool) -> str:
"""Build a non-sensitive internal-error message.
The exception *type* name (e.g. ``TypeError``) carries no secrets or stack
detail, so it is always included to make masked failures diagnosable
client-side. The exception message is added only when explicitly enabled.
"""
if expose_details:
return f"Internal server error: {exc}"
return f"Internal server error ({type(exc).__name__})"
_repo_authz_cache: BoundedTTLCache[str, bool] | None = None
@@ -1337,12 +1371,25 @@ async def call_tool(request: MCPToolCallRequest) -> JSONResponse:
).model_dump(),
)
except Exception:
# Security decision: do not leak stack traces or raw exception messages.
error_message = "Internal server error"
if settings.expose_error_details:
error_message = "Internal server error (details hidden unless explicitly enabled)"
except Exception as exc:
if _find_not_found(exc) is not None:
audit.log_tool_invocation(
tool_name=request.tool,
correlation_id=correlation_id,
result_status="error",
error="gitea_not_found",
)
return JSONResponse(
status_code=404,
content=MCPToolCallResponse(
success=False,
error=_NOT_FOUND_MESSAGE,
correlation_id=correlation_id,
).model_dump(),
)
# Security decision: do not leak stack traces or raw exception messages;
# the exception type name alone is safe and aids diagnosis.
audit.log_tool_invocation(
tool_name=request.tool,
correlation_id=correlation_id,
@@ -1354,7 +1401,7 @@ async def call_tool(request: MCPToolCallRequest) -> JSONResponse:
status_code=500,
content=MCPToolCallResponse(
success=False,
error=error_message,
error=_masked_internal_error(exc, settings.expose_error_details),
correlation_id=correlation_id,
).model_dump(),
)
@@ -1503,14 +1550,23 @@ async def sse_message_handler(request: Request) -> JSONResponse:
result_status="error",
error=str(exc),
)
message = "Internal server error"
if settings.expose_error_details:
message = str(exc)
if _find_not_found(exc) is not None:
# -32000 (application error), matching the auth-error envelope.
return JSONResponse(
content={
"jsonrpc": "2.0",
"id": message_id,
"error": {"code": -32000, "message": _NOT_FOUND_MESSAGE},
}
)
return JSONResponse(
content={
"jsonrpc": "2.0",
"id": message_id,
"error": {"code": -32603, "message": message},
"error": {
"code": -32603,
"message": _masked_internal_error(exc, settings.expose_error_details),
},
}
)
+10 -2
View File
@@ -295,8 +295,16 @@ async def get_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[
"body": limit_text(str(issue.get("body", ""))),
"state": issue.get("state", ""),
"author": (issue.get("user") or {}).get("login", ""),
"labels": [label.get("name", "") for label in (issue.get("labels") or [])],
"assignees": [assignee.get("login", "") for assignee in (issue.get("assignees") or [])],
"labels": [
label.get("name", "")
for label in (issue.get("labels") or [])
if isinstance(label, dict)
],
"assignees": [
assignee.get("login", "")
for assignee in (issue.get("assignees") or [])
if isinstance(assignee, dict)
],
"created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"url": issue.get("html_url", ""),