"""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