""" Aetheel Configuration ===================== Loads configuration from ~/.aetheel/config.json. Secrets (tokens, API keys) live in config.json using ``${VAR}`` env-var references or directly in the ``env.vars`` block — no separate ``.env`` file required. Config hierarchy (highest priority wins): 1. CLI arguments (--model, --claude, etc.) 2. Process environment variables 3. ``env.vars`` block in config.json (applied to process env when not already set) 4. ``${VAR}`` substitution in any config string value 5. ~/.aetheel/config.json static values 6. Defaults Example config.json snippet:: { "env": { "vars": { "DISCORD_BOT_TOKEN": "MTQ3...", "SLACK_BOT_TOKEN": "xoxb-..." } }, "discord": { "enabled": true, "bot_token": "${DISCORD_BOT_TOKEN}" } } Usage: from config import load_config, AetheelConfig cfg = load_config() print(cfg.runtime.model) print(cfg.slack.bot_token) # resolved from ${SLACK_BOT_TOKEN} """ import json import logging import os import re from dataclasses import dataclass, field from pathlib import Path logger = logging.getLogger("aetheel.config") CONFIG_DIR = os.path.expanduser("~/.aetheel") CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") # --------------------------------------------------------------------------- # ${VAR} environment variable substitution (openclaw-style) # --------------------------------------------------------------------------- _ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}") _ESCAPED_ENV_VAR = re.compile(r"\$\$\{([A-Z_][A-Z0-9_]*)\}") class MissingEnvVarError(Exception): """Raised when a ${VAR} reference cannot be resolved.""" def __init__(self, var_name: str, config_path: str = ""): self.var_name = var_name self.config_path = config_path super().__init__( f'Missing env var "{var_name}" referenced at config path: {config_path}' ) def _substitute_string(value: str, env: dict[str, str], path: str = "") -> str: """Resolve ``${VAR}`` references in a single string. - ``${VAR}`` → env value (warns and keeps original if missing) - ``$${VAR}`` → literal ``${VAR}`` (escape sequence) """ if "$" not in value: return value # First handle escapes: $${VAR} → ${VAR} result = _ESCAPED_ENV_VAR.sub(r"${\1}", value) def _replace(m: re.Match) -> str: var_name = m.group(1) env_val = env.get(var_name) if env_val is None or env_val == "": logger.warning( "Config %s references ${%s} but it is not set — keeping literal", path, var_name, ) return m.group(0) return env_val return _ENV_VAR_PATTERN.sub(_replace, result) def _substitute_any(value, env: dict[str, str], path: str = ""): """Recursively resolve ``${VAR}`` references in a parsed JSON structure.""" if isinstance(value, str): return _substitute_string(value, env, path) if isinstance(value, list): return [_substitute_any(item, env, f"{path}[{i}]") for i, item in enumerate(value)] if isinstance(value, dict): return { k: _substitute_any(v, env, f"{path}.{k}" if path else k) for k, v in value.items() } return value def _apply_env_vars_block(data: dict, env: dict[str, str] | None = None) -> None: """Apply ``env.vars`` from config to process env (if not already set). This mirrors openclaw's ``applyConfigEnvVars``: inline vars are injected into the process environment so that ``${VAR}`` references elsewhere in the config (and in the rest of the application) can resolve them. """ if env is None: env = os.environ env_block = data.get("env", {}) if not isinstance(env_block, dict): return vars_block = env_block.get("vars", {}) if not isinstance(vars_block, dict): return for key, value in vars_block.items(): if not isinstance(value, str) or not value.strip(): continue # Only set if not already present in the environment if env.get(key, "").strip(): continue env[key] = value # --------------------------------------------------------------------------- # Config Dataclasses # --------------------------------------------------------------------------- @dataclass class RuntimeConfig: """AI runtime configuration.""" engine: str = "opencode" # "opencode" or "claude" mode: str = "cli" # "cli" or "sdk" model: str | None = None # e.g. "anthropic/claude-sonnet-4-20250514" provider: str | None = None timeout_seconds: int = 120 server_url: str = "http://localhost:4096" workspace: str | None = None format: str = "json" agent: str | None = None # OpenCode agent name attach: str | None = None # Attach to running server (e.g. "http://localhost:4096") @dataclass class ClaudeConfig: """Claude Code runtime configuration.""" model: str | None = None timeout_seconds: int = 120 max_turns: int = 3 no_tools: bool = False allowed_tools: list[str] = field(default_factory=lambda: [ "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch", "Task", "TaskOutput", "TaskStop", "Skill", "TeamCreate", "TeamDelete", "SendMessage", ]) @dataclass class SlackConfig: """Slack adapter configuration. Tokens via config.json ${VAR} or env.vars.""" enabled: bool = True bot_token: str = "" # ${SLACK_BOT_TOKEN} or env.vars app_token: str = "" # ${SLACK_APP_TOKEN} or env.vars @dataclass class TelegramConfig: """Telegram adapter configuration.""" enabled: bool = False bot_token: str = "" # ${TELEGRAM_BOT_TOKEN} or env.vars @dataclass class DiscordChannelOverride: """Per-channel config overrides for Discord.""" history_limit: int | None = None # override history for this channel history_enabled: bool | None = None # enable/disable history for this channel @dataclass class DiscordConfig: """Discord adapter configuration.""" enabled: bool = False bot_token: str = "" # ${DISCORD_BOT_TOKEN} or env.vars listen_channels: list[str] = field(default_factory=list) # Reply threading: "off", "first", "all" reply_to_mode: str = "first" # History context injection history_enabled: bool = True history_limit: int = 20 # messages to inject as context # Per-channel overrides: {"channel_id": {"history_limit": 10, "history_enabled": false}} channel_overrides: dict[str, DiscordChannelOverride] = field(default_factory=dict) # Ack reaction emoji sent while processing (empty string to disable) ack_reaction: str = "👀" # Typing indicator while processing typing_indicator: bool = True # Reaction handling: "off", "own", "all" reaction_mode: str = "own" # Exec approvals: require button confirmation for AI tool use exec_approvals: bool = False exec_approval_tools: list[str] = field(default_factory=lambda: [ "Bash", "Write", "Edit", ]) # Slash commands slash_commands: bool = True # Interactive components (buttons, selects, modals) components_enabled: bool = True @dataclass class MemoryConfig: """Memory system configuration.""" workspace: str = "~/.aetheel/workspace" db_path: str = "~/.aetheel/memory.db" @dataclass class SchedulerConfig: """Scheduler configuration.""" db_path: str = "~/.aetheel/scheduler.db" @dataclass class MCPServerConfig: """Single MCP server entry.""" command: str = "" args: list[str] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) @dataclass class MCPConfig: """MCP server configuration.""" servers: dict[str, MCPServerConfig] = field(default_factory=dict) @dataclass class ModelRouteConfig: """Per-task model override. When set, the task uses a dedicated runtime with this model/engine instead of the global default. Leave fields as ``None`` to inherit from the top-level ``runtime`` / ``claude`` config. """ engine: str | None = None # "opencode" or "claude" — None inherits global model: str | None = None # e.g. "ollama/llama3.2", "minimax/minimax-m1" provider: str | None = None # e.g. "ollama", "minimax" timeout_seconds: int | None = None # None inherits global @dataclass class ModelRoutingConfig: """Named model routes for different task types. Example config.json:: "models": { "heartbeat": { "engine": "opencode", "model": "ollama/llama3.2", "provider": "ollama" }, "subagent": { "engine": "opencode", "model": "minimax/minimax-m1", "provider": "minimax" }, "default": null } Any task type not listed (or set to ``null``) uses the global runtime. """ heartbeat: ModelRouteConfig | None = None subagent: ModelRouteConfig | None = None default: ModelRouteConfig | None = None @dataclass class HeartbeatConfig: """Heartbeat / proactive system configuration.""" enabled: bool = True default_channel: str = "slack" default_channel_id: str = "" silent: bool = False @dataclass class ModelRouteConfig: """Per-task model override. When set, the task uses a dedicated runtime with this model/engine instead of the global default. Leave fields as ``None`` to inherit from the top-level ``runtime`` / ``claude`` config. """ engine: str | None = None # "opencode" or "claude" — None inherits global model: str | None = None # e.g. "ollama/llama3.2", "minimax/minimax-m1" provider: str | None = None # e.g. "ollama", "minimax" timeout_seconds: int | None = None # None inherits global @dataclass class ModelRoutingConfig: """Named model routes for different task types. Example config.json:: "models": { "heartbeat": { "engine": "opencode", "model": "ollama/llama3.2", "provider": "ollama" }, "subagent": { "engine": "opencode", "model": "minimax/minimax-m1", "provider": "minimax" }, "default": null } Any task type not listed (or set to ``null``) uses the global runtime. """ heartbeat: ModelRouteConfig | None = None subagent: ModelRouteConfig | None = None default: ModelRouteConfig | None = None @dataclass class WebChatConfig: """WebChat adapter configuration.""" enabled: bool = False port: int = 8080 host: str = "127.0.0.1" @dataclass class HooksConfig: """Lifecycle hooks configuration.""" enabled: bool = True @dataclass class WebhookConfig: """Webhook receiver configuration.""" enabled: bool = False port: int = 8090 host: str = "127.0.0.1" token: str = "" # Bearer token for auth @dataclass class AetheelConfig: """Top-level configuration for Aetheel.""" log_level: str = "INFO" runtime: RuntimeConfig = field(default_factory=RuntimeConfig) claude: ClaudeConfig = field(default_factory=ClaudeConfig) models: ModelRoutingConfig = field(default_factory=ModelRoutingConfig) slack: SlackConfig = field(default_factory=SlackConfig) telegram: TelegramConfig = field(default_factory=TelegramConfig) discord: DiscordConfig = field(default_factory=DiscordConfig) memory: MemoryConfig = field(default_factory=MemoryConfig) scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) heartbeat: HeartbeatConfig = field(default_factory=HeartbeatConfig) webchat: WebChatConfig = field(default_factory=WebChatConfig) mcp: MCPConfig = field(default_factory=MCPConfig) hooks: HooksConfig = field(default_factory=HooksConfig) webhooks: WebhookConfig = field(default_factory=WebhookConfig) # --------------------------------------------------------------------------- # Loader # --------------------------------------------------------------------------- def _deep_get(data: dict, *keys, default=None): """Safely traverse nested dicts.""" for key in keys: if not isinstance(data, dict): return default data = data.get(key, default) return data def load_config() -> AetheelConfig: """ Load configuration from config.json with ``${VAR}`` env-var substitution. 1. Parse config.json 2. Apply ``env.vars`` block to process env (if not already set) 3. Resolve ``${VAR}`` references throughout the config 4. Process environment variables still override everything """ cfg = AetheelConfig() file_data: dict = {} # 1. Load config.json if it exists if os.path.isfile(CONFIG_PATH): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: file_data = json.load(f) logger.info(f"Config loaded from {CONFIG_PATH}") except (json.JSONDecodeError, OSError) as e: logger.warning(f"Failed to load {CONFIG_PATH}: {e}") # 2. Apply env.vars block to process env (openclaw-style) _apply_env_vars_block(file_data) # 3. Resolve ${VAR} references throughout the config file_data = _substitute_any(file_data, dict(os.environ)) # 4. Apply config.json values cfg.log_level = _deep_get(file_data, "log_level", default=cfg.log_level) # Runtime rt = file_data.get("runtime", {}) cfg.runtime.engine = rt.get("engine", cfg.runtime.engine) cfg.runtime.mode = rt.get("mode", cfg.runtime.mode) cfg.runtime.model = rt.get("model", cfg.runtime.model) cfg.runtime.provider = rt.get("provider", cfg.runtime.provider) cfg.runtime.timeout_seconds = rt.get("timeout_seconds", cfg.runtime.timeout_seconds) cfg.runtime.server_url = rt.get("server_url", cfg.runtime.server_url) cfg.runtime.workspace = rt.get("workspace", cfg.runtime.workspace) cfg.runtime.format = rt.get("format", cfg.runtime.format) cfg.runtime.agent = rt.get("agent", cfg.runtime.agent) cfg.runtime.attach = rt.get("attach", cfg.runtime.attach) # Claude cl = file_data.get("claude", {}) cfg.claude.model = cl.get("model", cfg.claude.model) cfg.claude.timeout_seconds = cl.get("timeout_seconds", cfg.claude.timeout_seconds) cfg.claude.max_turns = cl.get("max_turns", cfg.claude.max_turns) cfg.claude.no_tools = cl.get("no_tools", cfg.claude.no_tools) # Discord dc = file_data.get("discord", {}) cfg.discord.enabled = dc.get("enabled", cfg.discord.enabled) cfg.discord.bot_token = dc.get("bot_token", cfg.discord.bot_token) cfg.discord.listen_channels = dc.get("listen_channels", cfg.discord.listen_channels) cfg.discord.reply_to_mode = dc.get("reply_to_mode", cfg.discord.reply_to_mode) cfg.discord.history_enabled = dc.get("history_enabled", cfg.discord.history_enabled) cfg.discord.history_limit = dc.get("history_limit", cfg.discord.history_limit) cfg.discord.ack_reaction = dc.get("ack_reaction", cfg.discord.ack_reaction) cfg.discord.typing_indicator = dc.get("typing_indicator", cfg.discord.typing_indicator) cfg.discord.reaction_mode = dc.get("reaction_mode", cfg.discord.reaction_mode) cfg.discord.exec_approvals = dc.get("exec_approvals", cfg.discord.exec_approvals) cfg.discord.slash_commands = dc.get("slash_commands", cfg.discord.slash_commands) cfg.discord.components_enabled = dc.get("components_enabled", cfg.discord.components_enabled) dc_exec_tools = dc.get("exec_approval_tools") if dc_exec_tools is not None: cfg.discord.exec_approval_tools = dc_exec_tools # Per-channel overrides dc_overrides = dc.get("channel_overrides", {}) for ch_id, ch_cfg in dc_overrides.items(): cfg.discord.channel_overrides[ch_id] = DiscordChannelOverride( history_limit=ch_cfg.get("history_limit"), history_enabled=ch_cfg.get("history_enabled"), ) # Slack sl = file_data.get("slack", {}) cfg.slack.enabled = sl.get("enabled", cfg.slack.enabled) cfg.slack.bot_token = sl.get("bot_token", cfg.slack.bot_token) cfg.slack.app_token = sl.get("app_token", cfg.slack.app_token) # Telegram tg = file_data.get("telegram", {}) cfg.telegram.enabled = tg.get("enabled", cfg.telegram.enabled) cfg.telegram.bot_token = tg.get("bot_token", cfg.telegram.bot_token) # Memory mem = file_data.get("memory", {}) cfg.memory.workspace = mem.get("workspace", cfg.memory.workspace) cfg.memory.db_path = mem.get("db_path", cfg.memory.db_path) # Scheduler sched = file_data.get("scheduler", {}) cfg.scheduler.db_path = sched.get("db_path", cfg.scheduler.db_path) # Model routing (per-task model overrides) models_data = file_data.get("models", {}) for task_name in ("heartbeat", "subagent", "default"): route_data = models_data.get(task_name) if isinstance(route_data, dict): route = ModelRouteConfig( engine=route_data.get("engine"), model=route_data.get("model"), provider=route_data.get("provider"), timeout_seconds=route_data.get("timeout_seconds"), ) setattr(cfg.models, task_name, route) # Claude allowed_tools cl_allowed = cl.get("allowed_tools") if cl_allowed is not None: cfg.claude.allowed_tools = cl_allowed # Heartbeat hb = file_data.get("heartbeat", {}) cfg.heartbeat.enabled = hb.get("enabled", cfg.heartbeat.enabled) cfg.heartbeat.default_channel = hb.get("default_channel", cfg.heartbeat.default_channel) cfg.heartbeat.default_channel_id = hb.get("default_channel_id", cfg.heartbeat.default_channel_id) cfg.heartbeat.silent = hb.get("silent", cfg.heartbeat.silent) # WebChat wc = file_data.get("webchat", {}) cfg.webchat.enabled = wc.get("enabled", cfg.webchat.enabled) cfg.webchat.port = wc.get("port", cfg.webchat.port) cfg.webchat.host = wc.get("host", cfg.webchat.host) # MCP mcp_data = file_data.get("mcp", {}) servers_data = mcp_data.get("servers", {}) for name, srv in servers_data.items(): cfg.mcp.servers[name] = MCPServerConfig( command=srv.get("command", ""), args=srv.get("args", []), env=srv.get("env", {}), ) # Hooks hk = file_data.get("hooks", {}) cfg.hooks.enabled = hk.get("enabled", cfg.hooks.enabled) # Webhooks wh = file_data.get("webhooks", {}) cfg.webhooks.enabled = wh.get("enabled", cfg.webhooks.enabled) cfg.webhooks.port = wh.get("port", cfg.webhooks.port) cfg.webhooks.host = wh.get("host", cfg.webhooks.host) cfg.webhooks.token = wh.get("token", cfg.webhooks.token) # 5. Environment variables override everything (secrets + overrides) cfg.log_level = os.environ.get("LOG_LEVEL", cfg.log_level) cfg.runtime.engine = os.environ.get("AETHEEL_ENGINE", cfg.runtime.engine) cfg.runtime.mode = os.environ.get("OPENCODE_MODE", cfg.runtime.mode) cfg.runtime.model = os.environ.get("OPENCODE_MODEL") or cfg.runtime.model cfg.runtime.provider = os.environ.get("OPENCODE_PROVIDER") or cfg.runtime.provider cfg.runtime.timeout_seconds = int( os.environ.get("OPENCODE_TIMEOUT", str(cfg.runtime.timeout_seconds)) ) cfg.runtime.server_url = os.environ.get("OPENCODE_SERVER_URL", cfg.runtime.server_url) cfg.runtime.workspace = ( os.environ.get("OPENCODE_WORKSPACE") or os.environ.get("AETHEEL_WORKSPACE") or cfg.runtime.workspace ) cfg.claude.model = os.environ.get("CLAUDE_MODEL") or cfg.claude.model cfg.claude.timeout_seconds = int( os.environ.get("CLAUDE_TIMEOUT", str(cfg.claude.timeout_seconds)) ) cfg.claude.max_turns = int( os.environ.get("CLAUDE_MAX_TURNS", str(cfg.claude.max_turns)) ) cfg.claude.no_tools = os.environ.get( "CLAUDE_NO_TOOLS", str(cfg.claude.no_tools) ).lower() == "true" # Secrets — resolved from config.json ${VAR} refs or env.vars block. # Process env still overrides if set. cfg.slack.bot_token = os.environ.get("SLACK_BOT_TOKEN") or cfg.slack.bot_token cfg.slack.app_token = os.environ.get("SLACK_APP_TOKEN") or cfg.slack.app_token cfg.telegram.bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") or cfg.telegram.bot_token cfg.discord.bot_token = os.environ.get("DISCORD_BOT_TOKEN") or cfg.discord.bot_token # Discord listen channels: env overrides config env_channels = os.environ.get("DISCORD_LISTEN_CHANNELS", "").strip() if env_channels: cfg.discord.listen_channels = [ch.strip() for ch in env_channels.split(",") if ch.strip()] cfg.memory.workspace = os.environ.get("AETHEEL_WORKSPACE", cfg.memory.workspace) cfg.memory.db_path = os.environ.get("AETHEEL_MEMORY_DB", cfg.memory.db_path) return cfg def save_default_config() -> str: """ Write a default config.json if one doesn't exist. Returns the path to the config file. """ os.makedirs(CONFIG_DIR, exist_ok=True) if os.path.isfile(CONFIG_PATH): migrate_config() return CONFIG_PATH default = { "$schema": "Aetheel configuration — secrets go in env.vars, referenced via ${VAR}", "env": { "vars": { "SLACK_BOT_TOKEN": "", "SLACK_APP_TOKEN": "", "TELEGRAM_BOT_TOKEN": "", "DISCORD_BOT_TOKEN": "", "ANTHROPIC_API_KEY": "", "OPENCODE_SERVER_PASSWORD": "", } }, "log_level": "INFO", "runtime": { "engine": "opencode", "mode": "cli", "model": None, "timeout_seconds": 120, "server_url": "http://localhost:4096", "format": "json", "agent": None, "attach": None, }, "claude": { "model": None, "timeout_seconds": 120, "max_turns": 3, "no_tools": False, "allowed_tools": [ "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch", "Task", "TaskOutput", "TaskStop", "Skill", "TeamCreate", "TeamDelete", "SendMessage", ], }, "models": { "heartbeat": None, "subagent": None, "default": None, }, "slack": { "enabled": True, "bot_token": "${SLACK_BOT_TOKEN}", "app_token": "${SLACK_APP_TOKEN}", }, "telegram": { "enabled": False, "bot_token": "${TELEGRAM_BOT_TOKEN}", }, "discord": { "enabled": False, "bot_token": "${DISCORD_BOT_TOKEN}", "listen_channels": [], "reply_to_mode": "first", "history_enabled": True, "history_limit": 20, "channel_overrides": {}, "ack_reaction": "👀", "typing_indicator": True, "reaction_mode": "own", "exec_approvals": False, "exec_approval_tools": ["Bash", "Write", "Edit"], "slash_commands": True, "components_enabled": True, }, "memory": { "workspace": "~/.aetheel/workspace", "db_path": "~/.aetheel/memory.db", }, "scheduler": { "db_path": "~/.aetheel/scheduler.db", }, "heartbeat": { "enabled": True, "default_channel": "slack", "default_channel_id": "", "silent": False, }, "webchat": { "enabled": False, "port": 8080, "host": "127.0.0.1", }, "mcp": { "servers": {}, }, "hooks": { "enabled": True, }, "webhooks": { "enabled": False, "port": 8090, "host": "127.0.0.1", "token": "", }, } with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(default, f, indent=2) logger.info(f"Default config written to {CONFIG_PATH}") return CONFIG_PATH def migrate_config() -> bool: """Patch an existing config.json with new sections added in later versions. Called on every startup (via ``save_default_config``). Only touches the file when keys are actually missing — existing values are never overwritten. Returns ``True`` if the file was modified. """ if not os.path.isfile(CONFIG_PATH): return False try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = json.load(f) except (json.JSONDecodeError, OSError): return False changed = False # --- env.vars block (added when secrets moved from .env) --------------- if "env" not in data: data["env"] = { "vars": { "SLACK_BOT_TOKEN": "", "SLACK_APP_TOKEN": "", "TELEGRAM_BOT_TOKEN": "", "DISCORD_BOT_TOKEN": "", "ANTHROPIC_API_KEY": "", "OPENCODE_SERVER_PASSWORD": "", } } changed = True elif "vars" not in data.get("env", {}): data["env"]["vars"] = {} changed = True # --- bot_token fields in adapter sections ------------------------------ for section, token_key, env_ref in [ ("slack", "bot_token", "${SLACK_BOT_TOKEN}"), ("slack", "app_token", "${SLACK_APP_TOKEN}"), ("telegram", "bot_token", "${TELEGRAM_BOT_TOKEN}"), ("discord", "bot_token", "${DISCORD_BOT_TOKEN}"), ]: if section in data and token_key not in data[section]: data[section][token_key] = env_ref changed = True # --- models section (per-task model routing) --------------------------- if "models" not in data: data["models"] = {"heartbeat": None, "subagent": None, "default": None} changed = True if changed: with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) logger.info(f"Config migrated with new sections: {CONFIG_PATH}") return changed def write_mcp_config(mcp_config: MCPConfig, workspace_dir: str, use_claude: bool) -> None: """Write MCP server config to the appropriate file for the runtime. Produces ``.mcp.json`` (Claude Code) or ``opencode.json`` (OpenCode) in *workspace_dir*. Skips writing when no servers are configured. """ if not mcp_config.servers: return config_data = { "mcpServers" if use_claude else "mcp": { name: {"command": s.command, "args": s.args, "env": s.env} for name, s in mcp_config.servers.items() } } filename = ".mcp.json" if use_claude else "opencode.json" path = os.path.join(os.path.expanduser(workspace_dir), filename) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(config_data, f, indent=2) logger.info(f"MCP config written to {path}")