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:
+51
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user