feat: add read-only gitea_get_profile discovery tool (#13)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:41:14 -04:00
parent 769bec05e7
commit 38c96d5815
5 changed files with 195 additions and 8 deletions
+11
View File
@@ -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
+8 -3
View File
@@ -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.
</details>
+19 -4
View File
@@ -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,
}
+70
View File
@@ -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,
+87 -1
View File
@@ -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()