Merge pull request 'feat: support separate Gitea MCP runtime profiles via env config (#19)' (#22) from feature/19-gitea-runtime-profiles-env into master
This commit was merged in pull request #22.
This commit is contained in:
@@ -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
|
||||
@@ -2,5 +2,6 @@ venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env*
|
||||
!.env.example
|
||||
.vscode/
|
||||
graphify-out/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user