3aaba73127
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>
144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
"""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
|