"""Canonical JSON runtime-profile configuration for Gitea MCP (issue #19). One canonical config file defines every Gitea runtime profile. Each LLM MCP launcher (Claude / Gemini / Codex) stays a *thin* launcher that only points at that file and names a profile — no duplicated ``GITEA_USER_*`` / ``GITEA_PASS_*`` blocks, no raw tokens in client configs: GITEA_MCP_CONFIG=/path/to/profiles.json # the canonical file GITEA_MCP_PROFILE=prgs # which named profile to use File shape (see ``gitea-mcp.example.json``):: { "version": 1, "profiles": { "prgs": { "base_url": "https://gitea.prgs.cc", "username": "jcwalker3", "auth": {"type": "keychain", "id": "prgs-gitea-token"}, "default_owner": "Scaled-Tech-Consulting", "execution_profile": "personal-prgs" } } } Auth is referenced *indirectly*, never inline: - ``{"type": "keychain", "id": "prgs-gitea-token"}`` — resolved from the macOS keychain at token-resolution time. - ``{"type": "env", "name": "GITEA_TOKEN_PRGS"}`` — read from that env var. 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 raw ``token``/``password`` key is rejected. Token values are resolved only via an auth *reference* and are never stored in, or returned as, profile metadata. - **No network.** Parsing only reads and decodes a local file. Token resolution (keychain/env) happens separately, on demand. - **Fail safely.** A missing file, invalid JSON, unsupported version, unknown/ unset selected profile, or an unresolvable secret reference raises :class:`ConfigError` with a message that never includes file contents, tokens, or passwords. """ import os import json import subprocess ENV_CONFIG_PATH = "GITEA_MCP_CONFIG" ENV_PROFILE = "GITEA_MCP_PROFILE" SUPPORTED_VERSION = 1 _AUTH_TYPES = ("keychain", "env") class ConfigError(Exception): """Raised for a bad config file, profile selection, or secret reference. Messages are safe to surface: they never include file contents, tokens, or passwords — only non-secret names/ids/positions. """ 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 canonical 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, declares an unsupported ``version``, 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") version = data.get("version", SUPPORTED_VERSION) if version != SUPPORTED_VERSION: raise ConfigError( f"{path} has unsupported version {version!r}; expected {SUPPORTED_VERSION}" ) return data def _validate_auth(name, auth): """Validate a profile's optional ``auth`` reference. Never echoes secrets.""" if auth is None: return if not isinstance(auth, dict): raise ConfigError(f"profile '{name}' has a non-object 'auth'") atype = auth.get("type") if atype not in _AUTH_TYPES: raise ConfigError( f"profile '{name}' has invalid auth type {atype!r}; " f"expected one of {list(_AUTH_TYPES)}" ) if atype == "keychain" and not auth.get("id"): raise ConfigError(f"profile '{name}' keychain auth requires an 'id'") if atype == "env" and not auth.get("name"): raise ConfigError(f"profile '{name}' env auth requires a 'name'") 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 or not an object, the profile embeds a raw ``token``/``password``, or its ``auth`` reference is malformed. """ 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") for secret_key in ("token", "password"): if secret_key in profile: # Never accept (or echo) an inline secret; require an auth reference. raise ConfigError( f"profile '{name}' must not contain an inline '{secret_key}'; " "use an 'auth' reference ({\"type\": \"keychain\"|\"env\", ...}) instead" ) _validate_auth(name, profile.get("auth")) 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 auth_source_name(profile): """Return a *non-secret* name for a profile's token source, or None. For env auth this is the env var name; for keychain auth, ``keychain:``. Safe to surface in profile metadata (never the token value). """ if not profile: return None auth = profile.get("auth") if not isinstance(auth, dict): return None if auth.get("type") == "env": return auth.get("name") if auth.get("type") == "keychain": return f"keychain:{auth.get('id')}" return None def _keychain_token(item_id): """Read a token from the macOS keychain by service *item_id*. Returns the secret string, or None if it cannot be found. Never logs the value; failures are swallowed so the caller can raise a safe error. """ try: proc = subprocess.run( ["security", "find-generic-password", "-s", item_id, "-w"], capture_output=True, text=True, ) except (OSError, subprocess.SubprocessError): return None if proc.returncode != 0: return None return proc.stdout.strip() or None def resolve_token(profile, keychain_lookup=_keychain_token): """Resolve the token for *profile* via its ``auth`` reference. Returns None when *profile* is None or has no ``auth``. Raises :class:`ConfigError` when the reference cannot be resolved (env var unset or keychain item missing). Never accepts an inline token and never logs, echoes, or includes the secret *value* in any error. *keychain_lookup* is injectable for testing. """ if not profile: return None auth = profile.get("auth") if not isinstance(auth, dict): return None atype = auth.get("type") if atype == "env": env_name = auth.get("name") value = os.environ.get(env_name) if env_name else None if not value: raise ConfigError( f"auth env var '{env_name}' referenced by the profile is not set" ) return value if atype == "keychain": item_id = auth.get("id") value = keychain_lookup(item_id) if item_id else None if not value: raise ConfigError( f"keychain item '{item_id}' referenced by the profile was not found" ) return value raise ConfigError(f"unsupported auth type {atype!r} in the selected profile")