import json from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional class ClaudeConfigError(ValueError): pass Transport = Literal["stdio", "sse", "http", "streamable_http"] @dataclass(frozen=True) class ClaudeMcpServer: name: str transport: Transport command: str = "" args: List[str] = field(default_factory=list) env: Dict[str, str] = field(default_factory=dict) url: str = "" headers: Dict[str, str] = field(default_factory=dict) enabled: bool = True def _normalize_transport(value: Optional[str]) -> Transport: if not value: return "streamable_http" v = value.strip().lower().replace("-", "_") if v in ("streamable_http", "streamablehttp", "streamable"): return "streamable_http" if v in ("http",): return "http" if v in ("sse",): return "sse" if v in ("stdio",): return "stdio" raise ClaudeConfigError(f"unsupported transport: {value}") def _coerce_str_list(value: Any, field_name: str) -> List[str]: if value is None: return [] if isinstance(value, list): return [str(v) for v in value] raise ClaudeConfigError(f"{field_name} must be a list") def _coerce_str_dict(value: Any, field_name: str) -> Dict[str, str]: if value is None: return {} if isinstance(value, dict): return {str(k): str(v) for k, v in value.items()} raise ClaudeConfigError(f"{field_name} must be an object") def parse_claude_mcp_config(config_json: str) -> List[ClaudeMcpServer]: """Parse Claude Desktop style MCP config JSON. Supported: - Full object: {"mcpServers": {...}} - Direct mapping: {...} treated as mcpServers """ text = (config_json or "").strip() if not text: return [] try: data = json.loads(text) except json.JSONDecodeError as e: raise ClaudeConfigError(f"invalid JSON: {e}") from e if not isinstance(data, dict): raise ClaudeConfigError("config must be a JSON object") servers_obj = data.get("mcpServers", data) if not isinstance(servers_obj, dict): raise ClaudeConfigError("mcpServers must be an object") servers: List[ClaudeMcpServer] = [] for name, raw in servers_obj.items(): if not isinstance(name, str) or not name.strip(): raise ClaudeConfigError("server name must be a non-empty string") if not isinstance(raw, dict): raise ClaudeConfigError(f"server '{name}' must be an object") enabled = bool(raw.get("enabled", True)) command = str(raw.get("command", "") or "") url = str(raw.get("url", "") or "") args = _coerce_str_list(raw.get("args"), "args") env = _coerce_str_dict(raw.get("env"), "env") headers = _coerce_str_dict(raw.get("headers"), "headers") transport_hint = raw.get("transport", raw.get("type")) if command: transport: Transport = "stdio" elif url: try: transport = _normalize_transport(str(transport_hint) if transport_hint is not None else None) except ClaudeConfigError: transport = "streamable_http" else: raise ClaudeConfigError(f"server '{name}' must have either 'command' or 'url'") servers.append( ClaudeMcpServer( name=name, transport=transport, command=command, args=args, env=env, url=url, headers=headers, enabled=enabled, ) ) return servers def legacy_servers_list_to_claude_config(servers_list_json: str) -> str: """Convert legacy v1.x servers list (JSON array) to Claude mcpServers JSON. Legacy item schema: {"name","enabled","transport","url","headers","command","args","env"} """ text = (servers_list_json or "").strip() if not text: return "" try: data = json.loads(text) except json.JSONDecodeError: return "" if isinstance(data, dict): data = [data] if not isinstance(data, list): return "" mcp_servers: Dict[str, Any] = {} for item in data: if not isinstance(item, dict): continue name = str(item.get("name", "") or "").strip() if not name: continue enabled = bool(item.get("enabled", True)) transport = str(item.get("transport", "") or "").strip().lower().replace("-", "_") if transport == "stdio" or item.get("command"): entry: Dict[str, Any] = { "enabled": enabled, "command": item.get("command", "") or "", "args": item.get("args", []) or [], } if item.get("env"): entry["env"] = item.get("env") mcp_servers[name] = entry continue entry = {"enabled": enabled, "url": item.get("url", "") or ""} if item.get("headers"): entry["headers"] = item.get("headers") if transport: entry["transport"] = transport mcp_servers[name] = entry if not mcp_servers: return "" return json.dumps({"mcpServers": mcp_servers}, ensure_ascii=False, indent=2)