From e31612027d5e03c49e2d92308d28cca95de8b18d Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 1 Jul 2026 13:21:59 -0400 Subject: [PATCH] feat: support separate Gitea MCP runtime profiles via env config (#19) Allow the same MCP server to run as separate MCP entries, each with its own token and profile name, so roles stay task-scoped (the profile is the role, not the LLM). - gitea_auth.get_profile(): reads GITEA_PROFILE_NAME, GITEA_ALLOWED_OPERATIONS, GITEA_BASE_URL as non-secret metadata. Never reads/returns/logs the token. - gitea_whoami now surfaces the safe profile metadata (name + allowed operations) alongside identity; token still never exposed. - .env.example: placeholder-only template for a runtime profile. - .gitignore: track .env.example while keeping real .env* ignored. - README: document multiple env-configured MCP entries. - tests: profile defaults/parsing, token-never-included, whoami surfaces profile without leaking token. One token + one profile per process. No multi-token switching in a single runtime. No approve/merge/eligibility workflow. No Jenkins/Ops/GlitchTip/Release/deploy behavior. No real secrets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 24 +++++++++++++++++ .gitignore | 1 + README.md | 52 +++++++++++++++++++++++++++++++++++++ gitea_auth.py | 32 +++++++++++++++++++++++ mcp_server.py | 11 +++++++- tests/test_mcp_server.py | 56 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..093e238 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index dc5fc3f..ed6386f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ venv/ __pycache__/ *.pyc .env* +!.env.example .vscode/ graphify-out/ diff --git a/README.md b/README.md index 4831af7..90e4b7a 100644 --- a/README.md +++ b/README.md @@ -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. +
+Runtime profiles (multiple env-configured entries) + +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. +
+
Codex / non-MCP tools diff --git a/gitea_auth.py b/gitea_auth.py index 9007c3b..2890549 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -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, + } diff --git a/mcp_server.py b/mcp_server.py index 6712f5f..06274e5 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -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"], + }, } diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 7b7731a..cd62680 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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() -- 2.43.7