From 03e28c159edcf1e11d08b8ee0c6c51424e13c8bb Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 1 Jul 2026 12:42:37 -0400 Subject: [PATCH] feat: add read-only gitea_whoami authenticated-user lookup (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only MCP tool that calls Gitea's authenticated-user endpoint (GET /api/v1/user) and returns safe identity metadata only: username, display name, user id, email, server, and remote. This lets future review/merge workflows prove which Gitea account the MCP server is authenticated as, so self-review/self-merge can be detected before acting — the blocker discovered during PR #8 dogfooding. - Never returns the token, Authorization header, password, or secrets. - Fails closed with a clear error if identity cannot be determined. - No mutation; no profile switching; no review/approve/merge behavior. Tests: identity mapping, secret-redaction, fail-closed, unknown-remote. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + mcp_server.py | 47 +++++++++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 48 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/README.md b/README.md index 9efc836..4831af7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `gitea_close_issue` | Close an issue by number | | `gitea_list_issues` | List issues with state/label filters | | `gitea_view_issue` | Get full details of a single issue | +| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) | | `gitea_mark_issue` | Claim/release an issue (start/done) | | `gitea_list_labels` | List all available labels in a repository | | `gitea_create_label` | Create a new label with custom color | diff --git a/mcp_server.py b/mcp_server.py index 4ee0e31..6712f5f 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -595,6 +595,53 @@ def gitea_view_issue( } +@mcp.tool() +def gitea_whoami( + remote: str = "dadeschools", + host: str | None = None, +) -> dict: + """Look up the Gitea account the MCP server is authenticated as. + + Read-only. Calls Gitea's authenticated-user endpoint (GET /api/v1/user) + with the configured token and returns safe identity metadata only. Use + this to prove which account a mutating workflow (e.g. review/merge) would + act as, so self-review/self-merge can be detected before acting. + + Never returns the token, Authorization header, password, or any other + secret material. Fails closed with a clear error if the identity cannot + be determined. + + Args: + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + + Returns: + dict with 'authenticated', 'username', 'display_name', 'user_id', + 'email', 'server', and 'remote'. + """ + if remote not in REMOTES: + raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}") + h = host or REMOTES[remote]["host"] + auth = _auth(h) + url = f"https://{h}/api/v1/user" + data = api_request("GET", url, auth) + if not data or not data.get("login"): + # Fail closed: never assume an identity we could not verify. + raise RuntimeError( + f"Could not determine the authenticated Gitea identity for {h}. " + "Verify the configured token is valid for this instance." + ) + return { + "authenticated": True, + "username": data.get("login"), + "display_name": data.get("full_name") or None, + "user_id": data.get("id"), + "email": data.get("email") or None, + "server": f"https://{h}", + "remote": remote, + } + + @mcp.tool() def gitea_mark_issue( issue_number: int, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e041871..7b7731a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -26,6 +26,7 @@ from mcp_server import ( # noqa: E402 gitea_edit_pr, gitea_get_file, gitea_commit_files, + gitea_whoami, ) FAKE_AUTH = "Basic dGVzdDp0ZXN0" @@ -466,5 +467,52 @@ class TestCommitFiles(unittest.TestCase): self.assertEqual(payload["files"], files) +# --------------------------------------------------------------------------- +# Whoami (authenticated-user identity lookup) +# --------------------------------------------------------------------------- +class TestWhoami(unittest.TestCase): + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_returns_safe_identity(self, _auth, mock_api): + mock_api.return_value = { + "id": 42, + "login": "reviewer-bot", + "full_name": "Reviewer Bot", + "email": "reviewer@example.com", + } + result = gitea_whoami(remote="prgs") + self.assertTrue(result["authenticated"]) + self.assertEqual(result["username"], "reviewer-bot") + self.assertEqual(result["display_name"], "Reviewer Bot") + self.assertEqual(result["user_id"], 42) + self.assertEqual(result["server"], "https://gitea.prgs.cc") + self.assertEqual(result["remote"], "prgs") + # Read-only: GET against the authenticated-user endpoint. + call_args = mock_api.call_args + self.assertEqual(call_args[0][0], "GET") + self.assertTrue(call_args[0][1].endswith("/api/v1/user")) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_never_exposes_secrets(self, _auth, mock_api): + mock_api.return_value = {"id": 1, "login": "someone"} + result = gitea_whoami(remote="prgs") + blob = repr(result).lower() + for secret in ("token", "authorization", "basic ", "password", FAKE_AUTH.lower()): + self.assertNotIn(secret, blob) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_fails_closed_without_login(self, _auth, mock_api): + mock_api.return_value = {"id": 1} # no 'login' + with self.assertRaises(RuntimeError): + gitea_whoami(remote="prgs") + + def test_rejects_unknown_remote(self): + with self.assertRaises(ValueError): + gitea_whoami(remote="nope") + + if __name__ == "__main__": unittest.main() -- 2.43.7