From 38c96d58157c8a54157a21be9c84e7960b145e9c Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 1 Jul 2026 13:41:14 -0400 Subject: [PATCH] feat: add read-only gitea_get_profile discovery tool (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only MCP tool that reports the active runtime execution profile so an LLM can inspect what the current process is configured to do before deciding whether to attempt an action later. - gitea_get_profile: returns profile_name, allowed/forbidden operation categories, audit_label, token_source_name (a NAME, never a value), base_url, remote, resolved server, and — optionally — the verified authenticated username. Identity resolution fails soft and marks identity_status (verified/unknown/unavailable/not_resolved); the profile config is always returned. Never mutates Gitea. - gitea_auth.get_profile(): extended with forbidden_operations, audit_label, token_source_name from env (non-secret metadata). - .env.example / README: document the new optional metadata vars and the discovery tool. - tests: metadata parsing, verified/unavailable/unknown identity paths, skip-identity, and secret-redaction. Read-only. No token exposure. No multi-token switching. No PR eligibility, review, or merge workflow. No Jenkins/Ops/GlitchTip/ Release/deploy behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 11 +++++ README.md | 11 +++-- gitea_auth.py | 23 +++++++++-- mcp_server.py | 70 ++++++++++++++++++++++++++++++++ tests/test_mcp_server.py | 88 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 093e238..52d9bc9 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,14 @@ GITEA_PROFILE_NAME=gitea-reviewer # Optional, comma-separated operation categories this profile is intended for # (descriptive only in this issue; enforcement is a later roadmap item). GITEA_ALLOWED_OPERATIONS=read,review,approve + +# Optional, comma-separated operation categories this profile must NOT perform +# (descriptive metadata; surfaced by gitea_get_profile). +GITEA_FORBIDDEN_OPERATIONS=merge,branch.push + +# Optional short label attached to this runtime for audit purposes. +GITEA_AUDIT_LABEL=reviewer-runtime + +# Optional NAME of the token's source (e.g. an env var name). This is a name +# only — never the token value. Surfaced by gitea_get_profile. +GITEA_TOKEN_SOURCE=GITEA_TOKEN diff --git a/README.md b/README.md index 90e4b7a..b32f68c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `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_get_profile` | Read-only: describe the active runtime execution profile (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 | @@ -161,16 +162,20 @@ Recognized environment fields (see [`.env.example`](.env.example) for placeholde | `GITEA_TOKEN` | API token for this runtime. Read only by the auth layer; **never** returned, logged, or committed. | | `GITEA_PROFILE_NAME` | Non-secret label for the running profile (e.g. `gitea-reviewer`). Surfaced by `gitea_whoami`. | | `GITEA_ALLOWED_OPERATIONS` | Optional, comma-separated operation categories (descriptive metadata only for now). | +| `GITEA_FORBIDDEN_OPERATIONS` | Optional, comma-separated categories this profile must not perform (descriptive). | +| `GITEA_AUDIT_LABEL` | Optional short label for this runtime, for audit purposes. | +| `GITEA_TOKEN_SOURCE` | Optional *name* of the token source (e.g. an env var name). A name only — never the token value. | | `GITEA_BASE_URL` | Optional informational base URL. | Notes: - This provides **one token + one profile per process**. It does not implement multi-token switching inside a single runtime, nor any approve/merge/eligibility - gating — those are later roadmap items (#13–#18). + gating — those are later roadmap items (#14–#18). - Profile name and allowed operations are **metadata only**; the token value is - never part of any tool output. `gitea_whoami` returns the profile name so a - workflow can see which runtime it is talking to. + never part of any tool output. `gitea_whoami` returns the profile name, and + `gitea_get_profile` returns the full non-secret profile metadata so a workflow + can inspect which runtime it is talking to before deciding to act. - See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for the full profile model. diff --git a/gitea_auth.py b/gitea_auth.py index 2890549..e8cf183 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -181,24 +181,39 @@ def get_profile(): environment variables. This function reads only the non-secret profile metadata: - - ``GITEA_PROFILE_NAME`` — a human label for the running profile. - - ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation + - ``GITEA_PROFILE_NAME`` — a human label for the running profile. + - ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation categories (descriptive only; not enforced here). - - ``GITEA_BASE_URL`` — optional informational base URL. + - ``GITEA_FORBIDDEN_OPERATIONS`` — optional comma-separated operation + categories this profile must not perform (descriptive only). + - ``GITEA_AUDIT_LABEL`` — optional short label for audit records. + - ``GITEA_TOKEN_SOURCE`` — optional *name* of the secret source + (e.g. an env var name). This is a name only, never a token value. + - ``GITEA_BASE_URL`` — optional informational base URL. It never reads, returns, or logs ``GITEA_TOKEN`` or any credential. The token continues to be resolved separately by ``get_auth_header`` and is never part of this metadata. Callers may surface the result safely. Returns: - dict with 'profile_name', 'allowed_operations' (list), and 'base_url'. + dict with 'profile_name', 'allowed_operations' (list), + 'forbidden_operations' (list), 'audit_label', 'token_source_name', + and 'base_url'. """ name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip() raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or "" ops = [o.strip() for o in raw_ops.split(",") if o.strip()] + raw_forbidden = os.environ.get("GITEA_FORBIDDEN_OPERATIONS") or "" + forbidden = [o.strip() for o in raw_forbidden.split(",") if o.strip()] + audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() or None + # A *name* of the token source (e.g. "GITEA_TOKEN"), never the token value. + token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() or None base_url = os.environ.get("GITEA_BASE_URL") or None return { "profile_name": name, "allowed_operations": ops, + "forbidden_operations": forbidden, + "audit_label": audit_label, + "token_source_name": token_source, "base_url": base_url, } diff --git a/mcp_server.py b/mcp_server.py index 06274e5..44b736a 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -651,6 +651,76 @@ def gitea_whoami( } +@mcp.tool() +def gitea_get_profile( + remote: str = "dadeschools", + host: str | None = None, + resolve_identity: bool = True, +) -> dict: + """Describe the active Gitea MCP execution profile for this runtime. + + Read-only. Reports the non-secret configuration of the running MCP + process (profile name, allowed/forbidden operation categories, audit + label, token *source name*, base URL) plus the resolved server for the + given remote. Optionally resolves the authenticated username via + ``gitea_whoami``'s endpoint so an LLM can see who this runtime acts as. + + This tool never mutates Gitea and never approves, merges, comments, or + creates anything. It never returns the token value, Authorization header, + password, raw environment, or credential file paths. Identity resolution + fails soft: if it cannot be determined, ``authenticated_username`` is null + and ``identity_status`` marks it, but the profile config is still returned. + + Args: + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + resolve_identity: If True, attempt a read-only identity lookup. + + Returns: + dict of safe profile metadata. ``identity_status`` is one of + 'verified', 'unknown', 'unavailable', or 'not_resolved'. + """ + profile = get_profile() + result = { + "profile_name": profile["profile_name"], + "allowed_operations": profile["allowed_operations"], + "forbidden_operations": profile["forbidden_operations"], + "audit_label": profile["audit_label"], + "token_source_name": profile["token_source_name"], + "base_url": profile["base_url"], + "remote": remote if remote in REMOTES else None, + "server": None, + "authenticated_username": None, + "identity_status": "not_resolved", + } + + if remote not in REMOTES: + # Mark ambiguity rather than raising: the tool stays inspectable. + result["identity_status"] = "unknown" + result["remote_error"] = f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}" + return result + + h = host or REMOTES[remote]["host"] + result["server"] = f"https://{h}" + + if resolve_identity: + try: + auth = _auth(h) + data = api_request("GET", f"https://{h}/api/v1/user", auth) + login = (data or {}).get("login") + if login: + result["authenticated_username"] = login + result["identity_status"] = "verified" + else: + result["identity_status"] = "unknown" + except Exception: + # Fail soft for the identity field only. Never surface the error + # detail or any credential material — just mark it unavailable. + result["identity_status"] = "unavailable" + + return result + + @mcp.tool() def gitea_mark_issue( issue_number: int, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index cd62680..86ead57 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -27,6 +27,7 @@ from mcp_server import ( # noqa: E402 gitea_get_file, gitea_commit_files, gitea_whoami, + gitea_get_profile, ) from gitea_auth import get_profile # noqa: E402 @@ -547,8 +548,10 @@ class TestRuntimeProfile(unittest.TestCase): with patch.dict(os.environ, env, clear=True): p = get_profile() blob = repr(p).lower() + # The token VALUE must never appear. (The field name + # 'token_source_name' is non-secret metadata and may exist.) self.assertNotIn("super-secret-token", blob) - self.assertNotIn("token", blob) + self.assertIsNone(p.get("token_source_name")) # GITEA_TOKEN_SOURCE unset @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -570,5 +573,88 @@ class TestRuntimeProfile(unittest.TestCase): self.assertNotIn(secret, blob) +# --------------------------------------------------------------------------- +# Profile discovery (read-only) — issue #13 +# --------------------------------------------------------------------------- +class TestProfileDiscovery(unittest.TestCase): + + def test_get_profile_new_fields_default_empty(self): + with patch.dict(os.environ, {}, clear=True): + p = get_profile() + self.assertEqual(p["forbidden_operations"], []) + self.assertIsNone(p["audit_label"]) + self.assertIsNone(p["token_source_name"]) + + def test_get_profile_reads_all_metadata(self): + env = { + "GITEA_PROFILE_NAME": "gitea-reviewer", + "GITEA_ALLOWED_OPERATIONS": "read,review,approve", + "GITEA_FORBIDDEN_OPERATIONS": "merge, branch.push", + "GITEA_AUDIT_LABEL": "reviewer-runtime", + "GITEA_TOKEN_SOURCE": "GITEA_TOKEN", + } + with patch.dict(os.environ, env, clear=True): + p = get_profile() + self.assertEqual(p["forbidden_operations"], ["merge", "branch.push"]) + self.assertEqual(p["audit_label"], "reviewer-runtime") + self.assertEqual(p["token_source_name"], "GITEA_TOKEN") + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_discovery_verified_identity(self, _auth, mock_api): + mock_api.return_value = {"id": 3, "login": "reviewer-bot"} + env = { + "GITEA_PROFILE_NAME": "gitea-reviewer", + "GITEA_ALLOWED_OPERATIONS": "read,review,approve", + "GITEA_TOKEN_SOURCE": "GITEA_TOKEN", + "GITEA_TOKEN": "super-secret-token", + } + with patch.dict(os.environ, env, clear=True): + result = gitea_get_profile(remote="prgs") + self.assertEqual(result["profile_name"], "gitea-reviewer") + self.assertEqual(result["allowed_operations"], ["read", "review", "approve"]) + self.assertEqual(result["authenticated_username"], "reviewer-bot") + self.assertEqual(result["identity_status"], "verified") + self.assertEqual(result["server"], "https://gitea.prgs.cc") + self.assertEqual(result["token_source_name"], "GITEA_TOKEN") + # Read-only: only a GET to the user endpoint was issued. + self.assertEqual(mock_api.call_args[0][0], "GET") + self.assertTrue(mock_api.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_discovery_never_exposes_secrets(self, _auth, mock_api): + mock_api.return_value = {"id": 3, "login": "reviewer-bot"} + env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_TOKEN": "super-secret-token"} + with patch.dict(os.environ, env, clear=True): + result = gitea_get_profile(remote="prgs") + blob = repr(result).lower() + for secret in ("super-secret-token", "token dgvzd", "authorization", "basic ", FAKE_AUTH.lower()): + self.assertNotIn(secret, blob) + + @patch("mcp_server.get_auth_header", return_value=None) + def test_discovery_identity_unavailable_fails_soft(self, _auth): + # No credentials -> _auth raises inside the tool; identity marked + # unavailable but the profile config is still returned (not raised). + with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-author"}, clear=True): + result = gitea_get_profile(remote="prgs") + self.assertEqual(result["profile_name"], "gitea-author") + self.assertIsNone(result["authenticated_username"]) + self.assertEqual(result["identity_status"], "unavailable") + + def test_discovery_can_skip_identity(self): + with patch.dict(os.environ, {"GITEA_PROFILE_NAME": "gitea-author"}, clear=True): + result = gitea_get_profile(remote="prgs", resolve_identity=False) + self.assertEqual(result["identity_status"], "not_resolved") + self.assertIsNone(result["authenticated_username"]) + + def test_discovery_unknown_remote_marks_unknown(self): + with patch.dict(os.environ, {}, clear=True): + result = gitea_get_profile(remote="nope") + self.assertEqual(result["identity_status"], "unknown") + self.assertIsNone(result["remote"]) + self.assertIn("remote_error", result) + + if __name__ == "__main__": unittest.main() -- 2.43.7