feat: support separate Gitea MCP runtime profiles via env config (#19) #22

Merged
jcwalker3 merged 1 commits from feature/19-gitea-runtime-profiles-env into master 2026-07-01 12:35:41 -05:00
6 changed files with 175 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
# Gitea MCP runtime profile — EXAMPLE / PLACEHOLDERS ONLY.
#
# Copy to a real, gitignored env file (e.g. .env.reviewer) per runtime profile.
# The same MCP server code is launched as separate MCP entries, each pointed at
# a different env file so each process authenticates as ONE token and carries
# ONE profile name. Do NOT put real tokens in this file.
#
# The token is read only by the auth layer; it is never returned, logged, or
# committed. Profile name and allowed operations are non-secret metadata.
# Base URL of the Gitea instance (informational).
GITEA_BASE_URL=https://gitea.example.invalid
# The API token for THIS runtime profile. Placeholder only — replace in a real,
# gitignored env file. Never commit a real token.
GITEA_TOKEN=replace-with-token
# Human label for the running profile (non-secret metadata).
# Examples: gitea-author, gitea-reviewer, gitea-merger, gitea-issue-manager.
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
+1
View File
@@ -2,5 +2,6 @@ venv/
__pycache__/
*.pyc
.env*
!.env.example
.vscode/
graphify-out/
+52
View File
@@ -123,6 +123,58 @@ The server is a standard MCP stdio server. Point your client at:
No environment variables needed — auth is handled via macOS keychain.
</details>
<details>
<summary><strong>Runtime profiles (multiple env-configured entries)</strong></summary>
The same server can run as **separate MCP entries**, each authenticating as its
own Gitea token and carrying its own profile name. This keeps roles task-scoped:
*the profile is the role, not the LLM.* Point each entry at a different
gitignored env file.
```json
{
"mcpServers": {
"gitea-tools-reviewer": {
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
"args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
"env": {
"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"
}
},
"gitea-tools-merger": {
"command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
"args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
"env": {
"GITEA_PROFILE_NAME": "gitea-merger",
"GITEA_ALLOWED_OPERATIONS": "read,merge"
}
}
}
}
```
Recognized environment fields (see [`.env.example`](.env.example) for placeholders):
| Variable | Purpose |
|----------|---------|
| `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_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).
- 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.
- See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for
the full profile model.
</details>
<details>
<summary><strong>Codex / non-MCP tools</strong></summary>
+32
View File
@@ -170,3 +170,35 @@ def api_request(method, url, auth_header, payload=None):
def repo_api_url(host, org, repo):
"""Return the base API URL for a repo: https://host/api/v1/repos/org/repo"""
return f"https://{host}/api/v1/repos/{org}/{repo}"
def get_profile():
"""Return safe runtime *profile* metadata for this MCP process.
A runtime profile is how the same server code is launched as separate MCP
entries (e.g. ``gitea-tools-author`` vs ``gitea-tools-reviewer``): each
process is configured with its own token *and* its own profile name via
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
categories (descriptive only; not enforced here).
- ``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'.
"""
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()]
base_url = os.environ.get("GITEA_BASE_URL") or None
return {
"profile_name": name,
"allowed_operations": ops,
"base_url": base_url,
}
+10 -1
View File
@@ -36,6 +36,7 @@ from gitea_auth import ( # noqa: E402
get_auth_header,
api_request,
repo_api_url,
get_profile,
)
mcp = FastMCP("gitea-tools", instructions=(
@@ -617,7 +618,8 @@ def gitea_whoami(
Returns:
dict with 'authenticated', 'username', 'display_name', 'user_id',
'email', 'server', and 'remote'.
'email', 'server', 'remote', and 'profile' (safe runtime profile
metadata: profile_name + allowed_operations; never the token).
"""
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
@@ -631,6 +633,9 @@ def gitea_whoami(
f"Could not determine the authenticated Gitea identity for {h}. "
"Verify the configured token is valid for this instance."
)
# Runtime profile metadata is non-secret (name + allowed op categories).
# The token is resolved separately and is never included here.
profile = get_profile()
return {
"authenticated": True,
"username": data.get("login"),
@@ -639,6 +644,10 @@ def gitea_whoami(
"email": data.get("email") or None,
"server": f"https://{h}",
"remote": remote,
"profile": {
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
},
}
+56
View File
@@ -28,6 +28,7 @@ from mcp_server import ( # noqa: E402
gitea_commit_files,
gitea_whoami,
)
from gitea_auth import get_profile # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
@@ -514,5 +515,60 @@ class TestWhoami(unittest.TestCase):
gitea_whoami(remote="nope")
# ---------------------------------------------------------------------------
# Runtime profile (env-configured profile metadata) — issue #19
# ---------------------------------------------------------------------------
class TestRuntimeProfile(unittest.TestCase):
def test_defaults_when_unset(self):
with patch.dict(os.environ, {}, clear=True):
p = get_profile()
self.assertEqual(p["profile_name"], "gitea-default")
self.assertEqual(p["allowed_operations"], [])
self.assertIsNone(p["base_url"])
def test_reads_env_metadata(self):
env = {
"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read, review , approve",
"GITEA_BASE_URL": "https://gitea.example.invalid",
}
with patch.dict(os.environ, env, clear=True):
p = get_profile()
self.assertEqual(p["profile_name"], "gitea-reviewer")
self.assertEqual(p["allowed_operations"], ["read", "review", "approve"])
self.assertEqual(p["base_url"], "https://gitea.example.invalid")
def test_never_includes_token(self):
env = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_TOKEN": "super-secret-token",
}
with patch.dict(os.environ, env, clear=True):
p = get_profile()
blob = repr(p).lower()
self.assertNotIn("super-secret-token", blob)
self.assertNotIn("token", blob)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_whoami_surfaces_profile_without_token(self, _auth, mock_api):
mock_api.return_value = {"id": 7, "login": "rev"}
env = {
"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
"GITEA_TOKEN": "super-secret-token",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_whoami(remote="prgs")
self.assertEqual(result["profile"]["profile_name"], "gitea-reviewer")
self.assertEqual(
result["profile"]["allowed_operations"], ["read", "review", "approve"]
)
blob = repr(result).lower()
for secret in ("super-secret-token", "token", "authorization", "basic "):
self.assertNotIn(secret, blob)
if __name__ == "__main__":
unittest.main()