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
+143
View File
@@ -0,0 +1,143 @@
"""JSON runtime-profile configuration for Gitea MCP.
Lets one MCP server select among multiple named runtime profiles defined in a
JSON file, instead of editing code or juggling many ``.env`` files:
GITEA_MCP_CONFIG=/path/to/gitea-mcp.json # the file
GITEA_MCP_PROFILE=dev # which named profile to use
File shape (see ``gitea-mcp.example.json``)::
{
"profiles": {
"dev": {
"base_url": "https://gitea.dev.example",
"profile_name": "gitea-author",
"token_env": "GITEA_TOKEN_DEV",
"owner": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"allowed_operations": ["read", "pr.create"],
"forbidden_operations": ["merge"],
"audit_label": "dev-author"
}
}
}
Design constraints:
- **Backwards compatible.** With ``GITEA_MCP_CONFIG`` unset, every entry point
returns ``None`` and callers fall back to pure environment behaviour.
- **No inline secrets.** A profile references its token by *env var name*
(``token_env``); a raw ``token`` key is rejected. The token value is resolved
by reading that env var and is never stored in, or returned as, profile
metadata.
- **No network.** Parsing only reads and decodes a local file.
- **Fail safely.** A missing file, invalid JSON, or missing/unknown selected
profile raises :class:`ConfigError` with a clear message that never includes
file contents or credential values.
"""
import os
import json
ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
ENV_PROFILE = "GITEA_MCP_PROFILE"
class ConfigError(Exception):
"""Raised for a missing/invalid config file or a bad profile selection.
Messages are safe to surface: they never include file contents or tokens.
"""
def config_path():
"""Return the configured JSON path, or None when the JSON layer is off."""
return (os.environ.get(ENV_CONFIG_PATH) or "").strip() or None
def selected_profile_name():
"""Return the selected profile name from the environment, or None."""
return (os.environ.get(ENV_PROFILE) or "").strip() or None
def load_config(path=None):
"""Load and minimally validate the JSON config.
Returns the parsed dict, or None when no config path is configured (so
env-only usage is never broken). Raises :class:`ConfigError` when a path is
configured but the file is missing, unreadable, not valid JSON, or lacks a
``profiles`` object.
"""
path = path or config_path()
if not path:
return None
if not os.path.isfile(path):
raise ConfigError(f"{ENV_CONFIG_PATH} points to a missing file: {path}")
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except json.JSONDecodeError as exc:
# Report position only (msg/line/col). `from None` suppresses the
# original exception, whose `.doc` holds the raw file text — which could
# contain a token — so it never reaches a traceback.
raise ConfigError(
f"invalid JSON in {path} (line {exc.lineno}, column {exc.colno}): {exc.msg}"
) from None
except OSError as exc:
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
return data
def select_profile(config, name=None):
"""Return the selected profile dict from a loaded *config*.
Returns None when *config* is None. Raises :class:`ConfigError` when no
profile is selected, the selected profile is unknown, the profile is not an
object, or the profile embeds a raw ``token``.
"""
if config is None:
return None
profiles = config.get("profiles", {})
name = name or selected_profile_name()
available = sorted(profiles)
if not name:
raise ConfigError(
f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; "
f"available profiles: {available}"
)
if name not in profiles:
raise ConfigError(
f"profile '{name}' not found in config; available profiles: {available}"
)
profile = profiles[name]
if not isinstance(profile, dict):
raise ConfigError(f"profile '{name}' must be a JSON object")
if "token" in profile:
# Never accept (or echo) an inline secret; require an env var reference.
raise ConfigError(
f"profile '{name}' must not contain an inline 'token'; "
"use 'token_env' (the NAME of an environment variable) instead"
)
return profile
def resolve_profile(path=None, name=None):
"""Load the config and return the selected profile dict, or None if off."""
return select_profile(load_config(path), name)
def resolve_token(profile):
"""Resolve the token for *profile* via its ``token_env`` name.
Reads the named environment variable; returns None if the profile is None,
has no ``token_env``, or the variable is unset. Never accepts an inline
token and never logs the value.
"""
if not profile:
return None
env_name = profile.get("token_env")
if not env_name:
return None
return os.environ.get(env_name) or None