Merge pull request 'feat: add read-only gitea_get_profile discovery tool (#13)' (#23) from feature/13-gitea-profile-discovery into master
This commit was merged in pull request #23.
This commit is contained in:
@@ -22,3 +22,14 @@ GITEA_PROFILE_NAME=gitea-reviewer
|
|||||||
# Optional, comma-separated operation categories this profile is intended for
|
# Optional, comma-separated operation categories this profile is intended for
|
||||||
# (descriptive only in this issue; enforcement is a later roadmap item).
|
# (descriptive only in this issue; enforcement is a later roadmap item).
|
||||||
GITEA_ALLOWED_OPERATIONS=read,review,approve
|
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
|
||||||
|
|||||||
@@ -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_list_issues` | List issues with state/label filters |
|
||||||
| `gitea_view_issue` | Get full details of a single issue |
|
| `gitea_view_issue` | Get full details of a single issue |
|
||||||
| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) |
|
| `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_mark_issue` | Claim/release an issue (start/done) |
|
||||||
| `gitea_list_labels` | List all available labels in a repository |
|
| `gitea_list_labels` | List all available labels in a repository |
|
||||||
| `gitea_create_label` | Create a new label with custom color |
|
| `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_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_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_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. |
|
| `GITEA_BASE_URL` | Optional informational base URL. |
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- This provides **one token + one profile per process**. It does not implement
|
- This provides **one token + one profile per process**. It does not implement
|
||||||
multi-token switching inside a single runtime, nor any approve/merge/eligibility
|
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
|
- 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
|
never part of any tool output. `gitea_whoami` returns the profile name, and
|
||||||
workflow can see which runtime it is talking to.
|
`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
|
- See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for
|
||||||
the full profile model.
|
the full profile model.
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+16
-1
@@ -184,6 +184,11 @@ def get_profile():
|
|||||||
- ``GITEA_PROFILE_NAME`` — a human label for the running profile.
|
- ``GITEA_PROFILE_NAME`` — a human label for the running profile.
|
||||||
- ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation
|
- ``GITEA_ALLOWED_OPERATIONS`` — optional comma-separated operation
|
||||||
categories (descriptive only; not enforced here).
|
categories (descriptive only; not enforced here).
|
||||||
|
- ``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.
|
- ``GITEA_BASE_URL`` — optional informational base URL.
|
||||||
|
|
||||||
It never reads, returns, or logs ``GITEA_TOKEN`` or any credential. The
|
It never reads, returns, or logs ``GITEA_TOKEN`` or any credential. The
|
||||||
@@ -191,14 +196,24 @@ def get_profile():
|
|||||||
never part of this metadata. Callers may surface the result safely.
|
never part of this metadata. Callers may surface the result safely.
|
||||||
|
|
||||||
Returns:
|
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()
|
name = (os.environ.get("GITEA_PROFILE_NAME") or "gitea-default").strip()
|
||||||
raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or ""
|
raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or ""
|
||||||
ops = [o.strip() for o in raw_ops.split(",") if o.strip()]
|
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
|
base_url = os.environ.get("GITEA_BASE_URL") or None
|
||||||
return {
|
return {
|
||||||
"profile_name": name,
|
"profile_name": name,
|
||||||
"allowed_operations": ops,
|
"allowed_operations": ops,
|
||||||
|
"forbidden_operations": forbidden,
|
||||||
|
"audit_label": audit_label,
|
||||||
|
"token_source_name": token_source,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
@mcp.tool()
|
||||||
def gitea_mark_issue(
|
def gitea_mark_issue(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from mcp_server import ( # noqa: E402
|
|||||||
gitea_get_file,
|
gitea_get_file,
|
||||||
gitea_commit_files,
|
gitea_commit_files,
|
||||||
gitea_whoami,
|
gitea_whoami,
|
||||||
|
gitea_get_profile,
|
||||||
)
|
)
|
||||||
from gitea_auth import get_profile # noqa: E402
|
from gitea_auth import get_profile # noqa: E402
|
||||||
|
|
||||||
@@ -547,8 +548,10 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
p = get_profile()
|
p = get_profile()
|
||||||
blob = repr(p).lower()
|
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("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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@@ -570,5 +573,88 @@ class TestRuntimeProfile(unittest.TestCase):
|
|||||||
self.assertNotIn(secret, blob)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user