feat: JSON multi-profile runtime config for Gitea MCP (roadmap #10)

Let one MCP server select among named Gitea runtime profiles from a JSON file
instead of editing code or juggling many .env files:

    GITEA_MCP_CONFIG=/path/to/gitea-mcp.json
    GITEA_MCP_PROFILE=dev

- New gitea_config.py: load/validate the JSON, select the named profile, and
  resolve its token by env-var reference. Profiles supply base_url,
  profile_name, token_env, owner/repo, allowed/forbidden operations, and audit
  label.
- gitea_auth.get_profile() now overlays env over the selected JSON profile:
  explicit env vars win, the JSON profile fills only what env leaves unset.
- gitea_auth.get_auth_header() gains a JSON token_env fallback after explicit
  env tokens (env still wins).

Security / safety:
- Tokens are referenced by env-var NAME (token_env); an inline "token" is
  rejected and never echoed. The value is never stored in or returned as
  profile metadata.
- Fail-safe errors: missing file / invalid JSON / unknown or unset selected
  profile raise a clear ConfigError that never prints file contents or tokens
  (JSONDecodeError context is suppressed so the raw file text can't surface).
- No network calls during config parsing.
- Real config files are gitignored (gitea-mcp*.json), example kept.

Backwards compatible: with GITEA_MCP_CONFIG unset, behaviour is exactly the
prior env-only behaviour (all existing get_profile/get_auth_header tests pass
unchanged).

Docs: README JSON-profiles section + env table rows, .env.example placeholders,
gitea-mcp.example.json.
Tests: tests/test_config.py (22 cases) — env-only, selection, multiple
profiles, env-override precedence, missing file, invalid JSON, missing/unset
profile, inline-token rejection + redaction, and no-network-during-parse.

Refs #10. Note: issue #19 (env-based profiles) was already implemented and
closed; this JSON-file capability is adjacent new scope tracked under the
roadmap rather than reopening #19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:44:31 -04:00
parent d4251c5c47
commit 3aaba73127
7 changed files with 507 additions and 10 deletions
+51 -10
View File
@@ -17,6 +17,8 @@ import urllib.error
from email.utils import parsedate_to_datetime
from dotenv import dotenv_values, load_dotenv
import gitea_config
# Load standard .env if present
load_dotenv()
@@ -119,10 +121,20 @@ def get_auth_header(host):
if not token:
token = os.environ.get("GITEA_TOKEN")
# 3. Fall back to a JSON runtime-profile token reference (token_env).
# Explicit env tokens above take precedence. A broken config never breaks
# auth here — it fails closed to "no token"; the clear error surfaces via
# get_profile() / startup instead.
if not token:
try:
token = gitea_config.resolve_token(gitea_config.resolve_profile())
except gitea_config.ConfigError:
token = None
if token:
return f"token {token}"
# 3. Try User/Password Basic auth
# 4. Try User/Password Basic auth
user, password = get_credentials(host)
if user and password:
token_b64 = base64.b64encode(f"{user}:{password}".encode()).decode()
@@ -299,20 +311,49 @@ def get_profile():
token continues to be resolved separately by ``get_auth_header`` and is
never part of this metadata. Callers may surface the result safely.
A JSON runtime-profile config (``GITEA_MCP_CONFIG`` + ``GITEA_MCP_PROFILE``,
see ``gitea_config``) may supply these same fields as a base layer. Explicit
environment variables always override the JSON profile; the JSON profile
only fills fields the environment leaves unset. With no config configured,
behaviour is exactly the environment-only behaviour above.
Returns:
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()
raw_ops = os.environ.get("GITEA_ALLOWED_OPERATIONS") or ""
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
# JSON layer (base). None when GITEA_MCP_CONFIG is unset; raises ConfigError
# on a misconfigured file/profile so the problem surfaces clearly at startup.
jp = gitea_config.resolve_profile() or {}
def _env_csv(env_key):
raw = os.environ.get(env_key)
if raw is None:
return None
return [o.strip() for o in raw.split(",") if o.strip()]
def _json_list(key):
val = jp.get(key)
return list(val) if isinstance(val, (list, tuple)) else []
# profile_name: env > JSON > default.
name = (os.environ.get("GITEA_PROFILE_NAME")
or jp.get("profile_name") or "gitea-default")
name = str(name).strip() or "gitea-default"
ops = _env_csv("GITEA_ALLOWED_OPERATIONS")
if ops is None:
ops = _json_list("allowed_operations")
forbidden = _env_csv("GITEA_FORBIDDEN_OPERATIONS")
if forbidden is None:
forbidden = _json_list("forbidden_operations")
audit_label = (os.environ.get("GITEA_AUDIT_LABEL") or "").strip() \
or (jp.get("audit_label") or None)
# A *name* of the token source (env var name / JSON token_env), never a value.
token_source = (os.environ.get("GITEA_TOKEN_SOURCE") or "").strip() \
or (jp.get("token_env") or None)
base_url = os.environ.get("GITEA_BASE_URL") or jp.get("base_url") or None
return {
"profile_name": name,
"allowed_operations": ops,