feat: support separate Gitea MCP runtime profiles via env config (#19) #22
@@ -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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
.vscode/
|
.vscode/
|
||||||
graphify-out/
|
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.
|
No environment variables needed — auth is handled via macOS keychain.
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><strong>Codex / non-MCP tools</strong></summary>
|
<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):
|
def repo_api_url(host, org, repo):
|
||||||
"""Return the base API URL for a repo: https://host/api/v1/repos/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}"
|
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,
|
get_auth_header,
|
||||||
api_request,
|
api_request,
|
||||||
repo_api_url,
|
repo_api_url,
|
||||||
|
get_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp = FastMCP("gitea-tools", instructions=(
|
mcp = FastMCP("gitea-tools", instructions=(
|
||||||
@@ -617,7 +618,8 @@ def gitea_whoami(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'authenticated', 'username', 'display_name', 'user_id',
|
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:
|
if remote not in REMOTES:
|
||||||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(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}. "
|
f"Could not determine the authenticated Gitea identity for {h}. "
|
||||||
"Verify the configured token is valid for this instance."
|
"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 {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"username": data.get("login"),
|
"username": data.get("login"),
|
||||||
@@ -639,6 +644,10 @@ def gitea_whoami(
|
|||||||
"email": data.get("email") or None,
|
"email": data.get("email") or None,
|
||||||
"server": f"https://{h}",
|
"server": f"https://{h}",
|
||||||
"remote": remote,
|
"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_commit_files,
|
||||||
gitea_whoami,
|
gitea_whoami,
|
||||||
)
|
)
|
||||||
|
from gitea_auth import get_profile # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
@@ -514,5 +515,60 @@ class TestWhoami(unittest.TestCase):
|
|||||||
gitea_whoami(remote="nope")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user