fix: surface Gitea auth errors and document the service PAT
Two related issues made the connected MCP server return a bare "Internal server error" for tools that need real Gitea API access (e.g. list_repositories), while public-repo-by-path reads worked: 1. Gitea OIDC access tokens only carry openid/profile/email and cannot call the repository REST API, so pure-OAuth mode fails for most tools. A service PAT (GITEA_TOKEN) is required in practice; per-user permission is still enforced before each call, so this does not weaken authorization. 2. The tool handlers caught GiteaError broadly and re-raised it as RuntimeError. Because GiteaAuthenticationError/GiteaAuthorizationError subclass GiteaError, a clean 401/403 was masked as a generic internal error and the server's re-authorization guidance never fired. Changes: - read_tools.py / repository.py / write_tools.py: re-raise the auth/authz subclasses before the broad GiteaError catch so server.py returns actionable guidance instead of a generic 500. - .env.example + README.md: document GITEA_TOKEN as a least-privilege bot PAT, explain why it's needed and that OAuth remains authoritative, and note that list_repositories is intentionally unavailable in service-PAT mode. - tests: assert tool handlers propagate auth errors unwrapped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -68,7 +68,16 @@ AUTOMATION_ENABLED=false
|
||||
AUTOMATION_SCHEDULER_ENABLED=false
|
||||
AUTOMATION_STALE_DAYS=30
|
||||
|
||||
# Legacy compatibility (not used for OAuth-protected MCP tool execution)
|
||||
# GITEA_TOKEN=
|
||||
# Service PAT for Gitea REST execution (recommended in OAuth mode).
|
||||
# Gitea's OIDC access tokens carry only openid/profile/email and CANNOT call the
|
||||
# repository REST API, so without this most tools fail. Set GITEA_TOKEN to a
|
||||
# Personal Access Token from a DEDICATED bot account with least privilege:
|
||||
# - scope: read:repository (add write:repository only if WRITE_MODE=true)
|
||||
# The user's OAuth identity is still authoritative: before every repository call
|
||||
# the server checks that the signed-in user has permission on the target repo and
|
||||
# denies it otherwise — the PAT only performs the API call after that check.
|
||||
GITEA_TOKEN=
|
||||
|
||||
# API-key mode only (used when OAUTH_MODE=false). Leave unset in OAuth mode.
|
||||
# MCP_API_KEYS=
|
||||
# AUTH_ENABLED=true
|
||||
|
||||
@@ -58,7 +58,23 @@ OAUTH_STATE_SECRET=<random-32-byte-minimum-secret>
|
||||
DCR_STORAGE_PATH=/var/lib/aegis-mcp/dcr_clients.json
|
||||
```
|
||||
|
||||
`GITEA_TOKEN` is optional in OAuth mode. Without it, Gitea REST calls use the user's OAuth access token directly, so Gitea enforces permissions on every API call. With it, the token acts as a service PAT for API execution, but the MCP server first checks the requesting user's permission on the target repository through Gitea and denies the call if the user lacks the required read/write permission.
|
||||
### 2b) Service PAT (`GITEA_TOKEN`) — needed in practice
|
||||
|
||||
Gitea issues **OIDC access tokens** that carry only `openid/profile/email`. They establish identity but **cannot call the repository REST API**, so in pure-OAuth mode most tools fail (you will see a generic error, or `list_repositories` returning nothing usable). Configure a service PAT so the tools actually work:
|
||||
|
||||
1. Create a **dedicated bot account** in Gitea (not a personal account).
|
||||
2. Generate a Personal Access Token with least privilege:
|
||||
- `read:repository`
|
||||
- `write:repository` only if you enable `WRITE_MODE`
|
||||
3. Set it in `.env`:
|
||||
|
||||
```env
|
||||
GITEA_TOKEN=<bot-personal-access-token>
|
||||
```
|
||||
|
||||
This does **not** weaken per-user security. OAuth remains authoritative: before every repository call the server verifies that the signed-in user has permission on the target repo through Gitea (`_verify_user_repository_access`) and denies it otherwise. The PAT only performs the API call after that check; OAuth provides identity, per-user authorization, and audit attribution.
|
||||
|
||||
Note: with a service PAT, `list_repositories` is intentionally blocked because it has no repository target to authorize per user — use the repository-scoped tools (`get_repository_info`, `get_file_contents`, `list_issues`, …) instead.
|
||||
|
||||
### 2a) Required writable volumes (read-only container)
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient, GiteaError
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
CommitDiffArgs,
|
||||
@@ -62,6 +67,10 @@ async def search_code_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dic
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to search code: {exc}") from exc
|
||||
|
||||
@@ -97,6 +106,10 @@ async def list_commits_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list commits: {exc}") from exc
|
||||
|
||||
@@ -135,6 +148,10 @@ async def get_commit_diff_tool(gitea: GiteaClient, arguments: dict[str, Any]) ->
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get commit diff: {exc}") from exc
|
||||
|
||||
@@ -181,6 +198,10 @@ async def compare_refs_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
||||
"omitted_commits": commit_omitted,
|
||||
"omitted_files": file_omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to compare refs: {exc}") from exc
|
||||
|
||||
@@ -220,6 +241,10 @@ async def list_issues_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dic
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list issues: {exc}") from exc
|
||||
|
||||
@@ -241,6 +266,10 @@ async def get_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get issue: {exc}") from exc
|
||||
|
||||
@@ -280,6 +309,10 @@ async def list_pull_requests_tool(gitea: GiteaClient, arguments: dict[str, Any])
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list pull requests: {exc}") from exc
|
||||
|
||||
@@ -303,6 +336,10 @@ async def get_pull_request_tool(gitea: GiteaClient, arguments: dict[str, Any]) -
|
||||
"updated_at": pull.get("updated_at", ""),
|
||||
"url": pull.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get pull request: {exc}") from exc
|
||||
|
||||
@@ -332,6 +369,10 @@ async def list_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dic
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list labels: {exc}") from exc
|
||||
|
||||
@@ -361,6 +402,10 @@ async def list_tags_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list tags: {exc}") from exc
|
||||
|
||||
@@ -398,5 +443,9 @@ async def list_releases_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> d
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list releases: {exc}") from exc
|
||||
|
||||
@@ -6,7 +6,12 @@ import base64
|
||||
import binascii
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient, GiteaError
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.response_limits import limit_items, limit_text
|
||||
from aegis_gitea_mcp.security import sanitize_untrusted_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
@@ -51,6 +56,10 @@ async def list_repositories_tool(gitea: GiteaClient, arguments: dict[str, Any])
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to list repositories: {exc}") from exc
|
||||
|
||||
@@ -78,6 +87,10 @@ async def get_repository_info_tool(gitea: GiteaClient, arguments: dict[str, Any]
|
||||
"url": repo_data.get("html_url", ""),
|
||||
"clone_url": repo_data.get("clone_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get repository info: {exc}") from exc
|
||||
|
||||
@@ -108,6 +121,10 @@ async def get_file_tree_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> d
|
||||
"count": len(bounded),
|
||||
"omitted": omitted,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get file tree: {exc}") from exc
|
||||
|
||||
@@ -155,5 +172,9 @@ async def get_file_contents_tool(gitea: GiteaClient, arguments: dict[str, Any])
|
||||
"sha": file_data.get("sha", ""),
|
||||
"url": file_data.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to get file contents: {exc}") from exc
|
||||
|
||||
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aegis_gitea_mcp.gitea_client import GiteaClient, GiteaError
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.response_limits import limit_text
|
||||
from aegis_gitea_mcp.tools.arguments import (
|
||||
AddLabelsArgs,
|
||||
@@ -34,6 +39,10 @@ async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
||||
"state": issue.get("state", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create issue: {exc}") from exc
|
||||
|
||||
@@ -56,6 +65,10 @@ async def update_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
||||
"state": issue.get("state", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to update issue: {exc}") from exc
|
||||
|
||||
@@ -78,6 +91,10 @@ async def create_issue_comment_tool(
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create issue comment: {exc}") from exc
|
||||
|
||||
@@ -98,6 +115,10 @@ async def create_pr_comment_tool(gitea: GiteaClient, arguments: dict[str, Any])
|
||||
"body": limit_text(str(comment.get("body", ""))),
|
||||
"url": comment.get("html_url", ""),
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to create PR comment: {exc}") from exc
|
||||
|
||||
@@ -116,6 +137,10 @@ async def add_labels_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict
|
||||
"issue_number": parsed.issue_number,
|
||||
"labels": label_names or parsed.labels,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to add labels: {exc}") from exc
|
||||
|
||||
@@ -137,5 +162,9 @@ async def assign_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> di
|
||||
"issue_number": parsed.issue_number,
|
||||
"assignees": assignees or parsed.assignees,
|
||||
}
|
||||
except (GiteaAuthenticationError, GiteaAuthorizationError):
|
||||
# Let auth/authz failures surface so the server returns actionable
|
||||
# re-authorization guidance instead of a generic internal error.
|
||||
raise
|
||||
except GiteaError as exc:
|
||||
raise RuntimeError(f"Failed to assign issue: {exc}") from exc
|
||||
|
||||
@@ -5,7 +5,11 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaError
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaError,
|
||||
)
|
||||
from aegis_gitea_mcp.tools.repository import (
|
||||
get_file_contents_tool,
|
||||
get_file_tree_tool,
|
||||
@@ -70,6 +74,23 @@ async def test_list_repositories_tool_failure_mode() -> None:
|
||||
await list_repositories_tool(RepoErrorStub(), {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_error", [GiteaAuthenticationError, GiteaAuthorizationError])
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_propagates_auth_errors_unwrapped(auth_error: type[GiteaError]) -> None:
|
||||
"""Auth/authz failures must surface as-is, not masked behind RuntimeError.
|
||||
|
||||
The server maps these to actionable re-authorization guidance; wrapping them
|
||||
in RuntimeError would hide that and return a generic internal error instead.
|
||||
"""
|
||||
|
||||
class AuthErrorStub(RepoStub):
|
||||
async def list_repositories(self):
|
||||
raise auth_error("token rejected")
|
||||
|
||||
with pytest.raises(auth_error):
|
||||
await list_repositories_tool(AuthErrorStub(), {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repository_info_tool_success() -> None:
|
||||
"""Repository info tool returns normalized metadata."""
|
||||
|
||||
Reference in New Issue
Block a user