Files
Aetheel/config.py
tanmay11k 82c2640481 feat: openclaw-style secrets (env.vars + \) and per-task model routing
- Replace python-dotenv with config.json env.vars block + \ substitution
- Add models section for per-task model routing (heartbeat, subagent, default)
- Heartbeat/subagent tasks can use different models/providers than main chat
- Remove python-dotenv from dependencies
- Update all docs to reflect new config approach
- Reorganize docs into project/ and research/ subdirectories
2026-02-20 23:49:05 -05:00

717 lines
25 KiB
Python

"""
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):
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 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}")