diff --git a/.gitignore b/.gitignore index b3c1b49a..00afaa48 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ data/ # Node node_modules/ + +# Repo Reference for Onboarding +REPO_REFERENCE.md \ No newline at end of file diff --git a/README.md b/README.md index 6fb1e802..bb1aa88a 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,11 @@ servers: main: host: localhost port: 8000 + protocol: auto # optional: auto | http | https + tls_verify: true # optional: true | false (HTTPS certificate verification) + ca_bundle_path: null # optional: custom CA bundle path for private/self-signed certs + client_cert_path: null # optional: client cert path for mTLS + client_key_path: null # optional: client key path for mTLS username: admin password: admin default_server: main @@ -176,6 +181,187 @@ chat_defaults: {} audit_log: [] ``` +## HTTPS / SSL Server Configuration + +Condor supports both HTTP and HTTPS when connecting to Hummingbot API servers. + +### Protocol resolution + +For each server in `config.yml`: +- If `host` includes a scheme (`http://` or `https://`), that scheme is used. +- Otherwise, `protocol` is used when provided (`http` or `https`). +- If `protocol` is omitted or set to `auto`: + - port `443` => HTTPS + - any other port => HTTP + +### Recommended patterns + +**Local development (HTTP):** +```yaml +servers: + local: + host: localhost + port: 8000 + protocol: auto + username: admin + password: admin +``` + +**Production (HTTPS on 443):** +```yaml +servers: + production: + host: api.example.com + port: 443 + protocol: auto + tls_verify: true + username: admin + password: strong_password +``` + +**Explicit HTTPS custom port:** +```yaml +servers: + production_custom: + host: api.example.com + port: 8443 + protocol: https + tls_verify: true + username: admin + password: strong_password +``` + +**Host with explicit scheme:** +```yaml +servers: + explicit: + host: https://api.example.com + port: 443 + tls_verify: true + username: admin + password: strong_password +``` + +**HTTPS with private/internal CA:** +```yaml +servers: + private_ca: + host: api.internal.example.com + port: 443 + protocol: auto + tls_verify: true + ca_bundle_path: /etc/ssl/certs/internal-ca.pem + username: admin + password: strong_password +``` + +**mTLS (client certificate authentication):** +```yaml +servers: + mtls: + host: api.example.com + port: 443 + protocol: auto + tls_verify: true + ca_bundle_path: /etc/ssl/certs/ca.pem + client_cert_path: /etc/ssl/certs/condor-client.pem + client_key_path: /etc/ssl/private/condor-client.key + username: admin + password: strong_password +``` + +**Temporary insecure mode (not recommended):** +```yaml +servers: + lab: + host: lab.example.local + port: 8443 + protocol: https + tls_verify: false + username: admin + password: admin +``` + +### Validation behavior + +Condor validates server URL settings and rejects conflicting configurations, for example: +- `host: "https://api.example.com:9000"` with `port: 8000` +- `host: "https://api.example.com"` with `protocol: http` + +When `tls_verify` is `true` and `ca_bundle_path` is provided, Condor uses that CA bundle for certificate validation. +When `tls_verify` is `false`, Condor disables certificate verification for that server. +If `client_cert_path` or `client_key_path` is provided, both must be provided (mTLS pair). + +## End-to-End Certificate Setup (Hummingbot API + Condor) + +The easiest secure local setup is to generate a local CA, issue a server cert for Hummingbot API, then configure Condor to trust that CA. + +### 1) Generate a local CA and server cert (OpenSSL example) + +```bash +# Create a local CA +openssl genrsa -out ca.key 4096 +openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ + -out ca.pem -subj "/CN=Condor Local CA" + +# Create server key + CSR +openssl genrsa -out server.key 2048 +openssl req -new -key server.key -out server.csr -subj "/CN=localhost" + +# Sign server cert with local CA +openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \ + -out server.pem -days 825 -sha256 +``` + +### Alternative: automated generation from `hummingbot-api` + +If you run the sibling `hummingbot-api` repo setup: + +```bash +make generate-certs +``` + +It will generate certs in `hummingbot-api/certs` and print exact paths you can copy into Condor (`ca_bundle_path`, and optional mTLS client cert/key paths). + +### 2) Run Hummingbot API with HTTPS + +```bash +uvicorn main:app --host 0.0.0.0 --port 8443 \ + --ssl-certfile /path/to/server.pem \ + --ssl-keyfile /path/to/server.key +``` + +### 3) Configure Condor to trust that CA + +```yaml +servers: + local_https: + host: localhost + port: 8443 + protocol: auto + tls_verify: true + ca_bundle_path: /path/to/ca.pem + username: admin + password: admin +``` + +### 4) Optional mTLS (if server requires client certs) + +Generate client cert/key signed by the same CA and add: + +```yaml + client_cert_path: /path/to/client.pem + client_key_path: /path/to/client.key +``` + +### Backward compatibility and migration + +Existing configurations continue to work without changes: +- `host: localhost`, `port: 8000` still resolves to `http://localhost:8000` +- configurations already using `http://...` or `https://...` in `host` remain supported + +For migration to HTTPS, update one server at a time and verify status in `/servers` before switching `default_server`. + ## Troubleshooting | Issue | Solution | @@ -185,6 +371,8 @@ audit_log: [] | Commands failing | Verify Hummingbot API is running | | Connection refused | Check server host:port in `/config` | | Auth error | Verify server credentials | +| TLS handshake/certificate errors | Verify API certificate validity and hostname; if using custom CA, ensure Condor environment trusts it | +| Invalid server URL config | Check for conflicts between scheme/host/port/protocol (for example host URL already includes a different port) | | DEX features unavailable | Ensure Gateway is configured and running | ## Docker Deployment diff --git a/condor/trading_agent/prompts.py b/condor/trading_agent/prompts.py index c76092eb..bf3efe5e 100644 --- a/condor/trading_agent/prompts.py +++ b/condor/trading_agent/prompts.py @@ -160,7 +160,7 @@ def build_tick_prompt( "Make your best move now — there will be no follow-up ticks." ) - # Server credentials are injected via env vars into the MCP process, + # Server credentials are injected via CLI args into the MCP process, # so no need to include them in the prompt or call configure_server. # Strategy instructions diff --git a/config_manager.py b/config_manager.py index 0f9caee7..18bba73f 100644 --- a/config_manager.py +++ b/config_manager.py @@ -12,6 +12,8 @@ import yaml from aiohttp import ClientTimeout +from utils.hummingbot_client_factory import create_initialized_client +from utils.url_builder import build_server_url_from_config logger = logging.getLogger(__name__) @@ -94,6 +96,13 @@ def _load_config(self): # Ensure all sections exist self._data.setdefault("servers", {}) + for server in self._data["servers"].values(): + server.setdefault("protocol", "auto") + server.setdefault("tls_verify", True) + server.setdefault("ca_bundle_path", None) + server.setdefault("client_cert_path", None) + server.setdefault("client_key_path", None) + self._data.setdefault("default_server", None) self._data.setdefault("users", {}) self._data.setdefault("server_access", {}) @@ -233,6 +242,11 @@ def add_server( username: str, password: str, owner_id: int = None, + protocol: str = "auto", + tls_verify: bool = True, + ca_bundle_path: str | None = None, + client_cert_path: str | None = None, + client_key_path: str | None = None, ) -> bool: """Add a new server.""" servers = self._data["servers"] @@ -245,6 +259,11 @@ def add_server( "port": port, "username": username, "password": password, + "protocol": protocol, + "tls_verify": tls_verify, + "ca_bundle_path": ca_bundle_path, + "client_cert_path": client_cert_path, + "client_key_path": client_key_path, } # Register ownership @@ -262,6 +281,11 @@ def modify_server( port: int = None, username: str = None, password: str = None, + protocol: str = None, + tls_verify: bool = None, + ca_bundle_path: str = None, + client_cert_path: str = None, + client_key_path: str = None, ) -> bool: """Modify an existing server.""" servers = self._data["servers"] @@ -281,6 +305,16 @@ def modify_server( servers[name]["username"] = username if password is not None: servers[name]["password"] = password + if protocol is not None: + servers[name]["protocol"] = protocol + if tls_verify is not None: + servers[name]["tls_verify"] = tls_verify + if ca_bundle_path is not None: + servers[name]["ca_bundle_path"] = ca_bundle_path + if client_cert_path is not None: + servers[name]["client_cert_path"] = client_cert_path + if client_key_path is not None: + servers[name]["client_key_path"] = client_key_path self._save_config() logger.info(f"Modified server '{name}'") @@ -324,8 +358,6 @@ def set_default_server(self, name: str) -> bool: async def get_client(self, name: str = None): """Get or create API client for a server.""" - from hummingbot_api_client import HummingbotAPIClient - if name is None: name = self.get_default_server() if not name: @@ -349,9 +381,9 @@ async def get_client(self, name: str = None): self._client_locks[name] = asyncio.Lock() async with self._client_locks[name]: - return await self._get_or_create_client(name, HummingbotAPIClient) + return await self._get_or_create_client(name) - async def _get_or_create_client(self, name: str, HummingbotAPIClient): + async def _get_or_create_client(self, name: str): """Inner client acquisition — must be called under _client_locks[name].""" # Re-check under lock (another coroutine may have just created it) if name in self._clients: @@ -385,16 +417,19 @@ async def _get_or_create_client(self, name: str, HummingbotAPIClient): # Create new client server = self._data["servers"][name] - base_url = f"http://{server['host']}:{server['port']}" - client = HummingbotAPIClient( + base_url = build_server_url_from_config(server) + client = await create_initialized_client( base_url=base_url, username=server["username"], password=server["password"], timeout=ClientTimeout(total=60, connect=10), + tls_verify=server.get("tls_verify", True), + ca_bundle_path=server.get("ca_bundle_path"), + client_cert_path=server.get("client_cert_path"), + client_key_path=server.get("client_key_path"), ) try: - await client.init() await client.accounts.list_accounts() self._clients[name] = (client, time.time()) logger.info(f"Connected to server '{name}' at {base_url}") @@ -442,23 +477,27 @@ async def get_client_for_chat( async def check_server_status(self, name: str) -> dict: """Check if a server is online.""" - from hummingbot_api_client import HummingbotAPIClient - if name not in self._data["servers"]: return {"status": "error", "message": "Server not found"} server = self._data["servers"][name] - base_url = f"http://{server['host']}:{server['port']}" + try: + base_url = build_server_url_from_config(server) + except ValueError as e: + return {"status": "error", "message": f"Invalid server URL config: {e}"} - client = HummingbotAPIClient( + client = await create_initialized_client( base_url=base_url, username=server["username"], password=server["password"], timeout=ClientTimeout(total=3, connect=2), + tls_verify=server.get("tls_verify", True), + ca_bundle_path=server.get("ca_bundle_path"), + client_cert_path=server.get("client_cert_path"), + client_key_path=server.get("client_key_path"), ) try: - await client.init() await client.accounts.list_accounts() return {"status": "online", "message": "Connected and authenticated"} except Exception as e: diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index 43e43743..0e90ca8b 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -3,6 +3,7 @@ import logging from pathlib import Path from typing import Any +from utils.url_builder import build_server_url_from_config log = logging.getLogger(__name__) @@ -386,7 +387,7 @@ def build_mcp_servers_for_session( ) return [condor] - api_url = f"http://{server['host']}:{server['port']}" + api_url = build_server_url_from_config(server) mcp_hummingbot = { "name": "mcp-hummingbot", @@ -396,9 +397,16 @@ def build_mcp_servers_for_session( "--url", api_url, "--username", server["username"], "--password", server["password"], + "--tls-verify", str(server.get("tls_verify", True)).lower(), ], "env": [], } + if server.get("ca_bundle_path"): + mcp_hummingbot["args"] += ["--ca-bundle-path", str(server["ca_bundle_path"])] + if server.get("client_cert_path"): + mcp_hummingbot["args"] += ["--client-cert-path", str(server["client_cert_path"])] + if server.get("client_key_path"): + mcp_hummingbot["args"] += ["--client-key-path", str(server["client_key_path"])] return [mcp_hummingbot, condor] @@ -433,7 +441,7 @@ def build_mcp_servers_for_agent( ) return [condor] - api_url = f"http://{server['host']}:{server['port']}" + api_url = build_server_url_from_config(server) mcp_hummingbot = { "name": "mcp-hummingbot", @@ -443,9 +451,16 @@ def build_mcp_servers_for_agent( "--url", api_url, "--username", server["username"], "--password", server["password"], + "--tls-verify", str(server.get("tls_verify", True)).lower(), ], "env": [], } + if server.get("ca_bundle_path"): + mcp_hummingbot["args"] += ["--ca-bundle-path", str(server["ca_bundle_path"])] + if server.get("client_cert_path"): + mcp_hummingbot["args"] += ["--client-cert-path", str(server["client_cert_path"])] + if server.get("client_key_path"): + mcp_hummingbot["args"] += ["--client-key-path", str(server["client_key_path"])] return [mcp_hummingbot, condor] @@ -482,13 +497,32 @@ def build_initial_context(user_id: int, chat_id: int, user_data: dict | None = N f"user={server['username']}, pass={server['password']}" ) - # Build configure_server call for the active server + # Build configure_server call for the active server. + # Include all TLS-relevant args so partial updates can't downgrade the + # session's protocol/cert config when the agent calls configure_server. active_server = cm.get_server(active_name) if active_server: + def _py_repr(value: Any) -> str: + if value is None: + return "None" + if isinstance(value, bool): + return "True" if value else "False" + return repr(str(value)) + + args = [ + f'host="{active_server["host"]}"', + f'port={active_server["port"]}', + f'protocol="{active_server.get("protocol") or "auto"}"', + f'username="{active_server["username"]}"', + f'password="{active_server["password"]}"', + f'tls_verify={_py_repr(active_server.get("tls_verify", True))}', + f'ca_bundle_path={_py_repr(active_server.get("ca_bundle_path"))}', + f'client_cert_path={_py_repr(active_server.get("client_cert_path"))}', + f'client_key_path={_py_repr(active_server.get("client_key_path"))}', + ] configure_hint = ( f'IMPORTANT: Before making ANY tool calls to mcp-hummingbot, you MUST first call ' - f'configure_server(host="{active_server["host"]}", port={active_server["port"]}, ' - f'username="{active_server["username"]}", password="{active_server["password"]}") ' + f'configure_server({", ".join(args)}) ' f'to ensure the MCP is connected to the correct server. ' f'Do this silently without telling the user.' ) diff --git a/handlers/config/__init__.py b/handlers/config/__init__.py index 9ffb438c..bbc13b03 100644 --- a/handlers/config/__init__.py +++ b/handlers/config/__init__.py @@ -177,7 +177,7 @@ async def config_callback_handler( # Route to appropriate sub-module based on callback data prefix if query.data == "config_api_servers" or query.data.startswith( - ("api_server_", "modify_field_", "add_server_") + ("api_server_", "modify_field_", "add_server_", "tlscfg_") ): await handle_servers_callback(update, context) elif query.data == "config_api_keys" or query.data.startswith("api_key_"): @@ -195,7 +195,7 @@ def get_config_callback_handler(): """Get the callback query handler for config menu""" return CallbackQueryHandler( config_callback_handler, - pattern="^config_|^modify_field_|^add_server_|^api_server_|^api_key_|^gateway_|^admin:", + pattern="^config_|^modify_field_|^add_server_|^api_server_|^tlscfg_|^api_key_|^gateway_|^admin:", ) @@ -248,8 +248,10 @@ async def handle_all_text_input(update: Update, context: ContextTypes.DEFAULT_TY return # 4. Check config flows - server modification - if context.user_data.get("awaiting_add_server_input") or context.user_data.get( - "awaiting_modify_input" + if ( + context.user_data.get("awaiting_add_server_input") + or context.user_data.get("awaiting_modify_input") + or context.user_data.get("awaiting_tls_input") ): await handle_server_input(update, context) return diff --git a/handlers/config/servers.py b/handlers/config/servers.py index 20f7814f..d32e65e0 100644 --- a/handlers/config/servers.py +++ b/handlers/config/servers.py @@ -50,6 +50,8 @@ async def handle_servers_callback( await handle_modify_field_selection(query, context) elif query.data.startswith("add_server_"): await handle_add_server_callbacks(query, context) + elif query.data.startswith("tlscfg_"): + await handle_tls_callback(query, context) elif query.data.startswith("api_server_"): await handle_api_server_action(query, context) @@ -224,6 +226,9 @@ async def handle_api_server_action(query, context: ContextTypes.DEFAULT_TYPE) -> await confirm_delete_server(query, context, server_name) elif action_data == "cancel_delete": await show_api_servers(query, context) + elif action_data.startswith("tls_"): + server_name = action_data.replace("tls_", "", 1) + await show_tls_settings(query, context, server_name) # Server sharing actions elif action_data.startswith("share_user_"): # Format: share_user_{uid}_{server_name} @@ -413,6 +418,9 @@ async def _show_server_details( ) keyboard.append( [ + InlineKeyboardButton( + "🔐 TLS", callback_data=f"api_server_tls_{server_name}" + ), InlineKeyboardButton( "📤 Share", callback_data=f"api_server_share_{server_name}" ), @@ -1279,6 +1287,8 @@ async def handle_server_input( await handle_add_server_input(update, context) elif context.user_data.get("awaiting_modify_input"): await handle_modify_value_input(update, context) + elif context.user_data.get("awaiting_tls_input"): + await handle_tls_input(update, context) elif context.user_data.get("awaiting_share_user_id"): await handle_share_user_id_input(update, context) @@ -1794,3 +1804,335 @@ async def revoke_access( await query.answer("Failed to revoke access", show_alert=True) await show_server_sharing(query, context, server_name) + + +# ==================== TLS / HTTPS Settings ==================== + +_TLS_PROTOCOL_CYCLE = ["auto", "http", "https"] +_TLS_PATH_FIELDS = { + "ca": ("ca_bundle_path", "CA bundle"), + "cert": ("client_cert_path", "Client cert"), + "key": ("client_key_path", "Client key"), +} + + +def _tls_render(server: dict) -> str: + """Render the TLS state for a server config.""" + proto = server.get("protocol") or "auto" + verify = server.get("tls_verify", True) + ca = server.get("ca_bundle_path") or "(none)" + cert = server.get("client_cert_path") or "(none)" + key = server.get("client_key_path") or "(none)" + return ( + f"*Protocol:* `{escape_markdown_v2(proto)}`\n" + f"*Verify TLS:* `{escape_markdown_v2('on' if verify else 'off')}`\n" + f"*CA bundle:* `{escape_markdown_v2(str(ca))}`\n" + f"*Client cert:* `{escape_markdown_v2(str(cert))}`\n" + f"*Client key:* `{escape_markdown_v2(str(key))}`" + ) + + +async def show_tls_settings( + query, context: ContextTypes.DEFAULT_TYPE, server_name: str +) -> None: + """Show TLS settings for a server (owner only).""" + from config_manager import ServerPermission, get_config_manager + + user_id = query.from_user.id + cm = get_config_manager() + + if cm.get_server_permission(user_id, server_name) != ServerPermission.OWNER: + await query.answer("Only the owner can edit TLS settings", show_alert=True) + return + + server = cm.get_server(server_name) + if not server: + await query.answer("Server not found", show_alert=True) + return + + # Track context so subsequent callbacks (which don't carry server_name) know + # which server they're acting on. + context.user_data["tls_server"] = server_name + context.user_data["tls_message_id"] = query.message.message_id + context.user_data["tls_chat_id"] = query.message.chat_id + context.user_data.pop("awaiting_tls_input", None) + context.user_data.pop("tls_field", None) + + name_escaped = escape_markdown_v2(server_name) + message_text = ( + f"🔐 *TLS Settings: {name_escaped}*\n\n" + f"{_tls_render(server)}\n\n" + "_Tap a button to change a value\\._" + ) + + has_ca = bool(server.get("ca_bundle_path")) + has_cert = bool(server.get("client_cert_path")) + has_key = bool(server.get("client_key_path")) + + keyboard = [ + [ + InlineKeyboardButton("🌐 Protocol", callback_data="tlscfg_proto"), + InlineKeyboardButton("✅ Verify", callback_data="tlscfg_verify"), + ], + [ + InlineKeyboardButton( + "🗑 CA" if has_ca else "📄 CA", + callback_data="tlscfg_caclr" if has_ca else "tlscfg_ca", + ), + InlineKeyboardButton( + "🗑 Cert" if has_cert else "📄 Cert", + callback_data="tlscfg_certclr" if has_cert else "tlscfg_cert", + ), + InlineKeyboardButton( + "🗑 Key" if has_key else "📄 Key", + callback_data="tlscfg_keyclr" if has_key else "tlscfg_key", + ), + ], + [ + InlineKeyboardButton( + "« Back", callback_data=f"api_server_view_{server_name}" + ) + ], + ] + + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def _refresh_tls_screen( + query, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Re-render the TLS settings screen for the active server.""" + server_name = context.user_data.get("tls_server") + if not server_name: + await query.answer("Session expired", show_alert=True) + await show_api_servers(query, context) + return + await show_tls_settings(query, context, server_name) + + +async def handle_tls_callback( + query, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle tlscfg_* callbacks for TLS settings edits.""" + from config_manager import ServerPermission, get_config_manager + + server_name = context.user_data.get("tls_server") + if not server_name: + await query.answer("Session expired", show_alert=True) + await show_api_servers(query, context) + return + + cm = get_config_manager() + user_id = query.from_user.id + if cm.get_server_permission(user_id, server_name) != ServerPermission.OWNER: + await query.answer("Only the owner can edit TLS settings", show_alert=True) + return + + server = cm.get_server(server_name) + if not server: + await query.answer("Server not found", show_alert=True) + return + + action = query.data.replace("tlscfg_", "", 1) + + if action == "proto": + current = server.get("protocol") or "auto" + try: + idx = _TLS_PROTOCOL_CYCLE.index(current) + except ValueError: + idx = -1 + new_value = _TLS_PROTOCOL_CYCLE[(idx + 1) % len(_TLS_PROTOCOL_CYCLE)] + cm.modify_server(server_name, protocol=new_value) + await query.answer(f"Protocol → {new_value}") + await _refresh_tls_screen(query, context) + return + + if action == "verify": + new_value = not bool(server.get("tls_verify", True)) + cm.modify_server(server_name, tls_verify=new_value) + await query.answer(f"Verify TLS → {'on' if new_value else 'off'}") + await _refresh_tls_screen(query, context) + return + + if action in _TLS_PATH_FIELDS: + field, label = _TLS_PATH_FIELDS[action] + context.user_data["awaiting_tls_input"] = True + context.user_data["tls_field"] = field + context.user_data["tls_message_id"] = query.message.message_id + context.user_data["tls_chat_id"] = query.message.chat_id + name_escaped = escape_markdown_v2(server_name) + label_escaped = escape_markdown_v2(label) + current_value = server.get(field) or "(none)" + current_escaped = escape_markdown_v2(str(current_value)) + message_text = ( + f"📄 *Set {label_escaped}*\n\n" + f"Server: *{name_escaped}*\n" + f"Current: `{current_escaped}`\n\n" + f"Send the new path \\(must exist on the bot host\\)\\.\n" + f"Send `\\-` to clear\\." + ) + keyboard = [ + [InlineKeyboardButton("« Back", callback_data="tlscfg_back")] + ] + await query.message.edit_text( + message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + + if action in {"caclr", "certclr", "keyclr"}: + clr_map = { + "caclr": "ca_bundle_path", + "certclr": "client_cert_path", + "keyclr": "client_key_path", + } + field = clr_map[action] + # modify_server only writes when the value is not None — so set to "" then None. + cm._data["servers"][server_name][field] = None + cm._save_config() + # Drop cached client to force reconnect with new TLS settings + if server_name in cm._clients: + try: + client_obj, _ = cm._clients[server_name] + await client_obj.close() + except Exception: + pass + del cm._clients[server_name] + await query.answer(f"Cleared {field}") + await _refresh_tls_screen(query, context) + return + + if action == "back": + await _refresh_tls_screen(query, context) + return + + await query.answer("Unknown TLS action") + + +async def handle_tls_input( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle text input for TLS path fields.""" + from config_manager import get_config_manager + from pathlib import Path + + if not context.user_data.get("awaiting_tls_input"): + return + + server_name = context.user_data.get("tls_server") + field = context.user_data.get("tls_field") + message_id = context.user_data.get("tls_message_id") + chat_id = context.user_data.get("tls_chat_id") + + try: + await update.message.delete() + except Exception: + pass + + if not server_name or not field: + context.user_data.pop("awaiting_tls_input", None) + return + + new_value = update.message.text.strip() + + cm = get_config_manager() + server = cm.get_server(server_name) + if not server: + context.user_data.pop("awaiting_tls_input", None) + return + + if new_value == "-" or new_value == "": + # Clear the field + cm._data["servers"][server_name][field] = None + cm._save_config() + notice = f"Cleared {field}" + else: + path_obj = Path(new_value).expanduser() + if not path_obj.exists(): + if message_id and chat_id: + name_escaped = escape_markdown_v2(server_name) + path_escaped = escape_markdown_v2(new_value) + error_text = ( + f"❌ *Path not found*\n\n" + f"Server: *{name_escaped}*\n" + f"Path: `{path_escaped}`\n\n" + f"Send a valid path or `\\-` to clear\\." + ) + keyboard = [ + [InlineKeyboardButton("« Back", callback_data="tlscfg_back")] + ] + await context.bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=error_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return + cm.modify_server(server_name, **{field: str(path_obj)}) + notice = f"Set {field}" + + # Drop cached client so next request reconnects with new TLS settings + if server_name in cm._clients: + try: + client_obj, _ = cm._clients[server_name] + await client_obj.close() + except Exception: + pass + del cm._clients[server_name] + + context.user_data.pop("awaiting_tls_input", None) + context.user_data.pop("tls_field", None) + + if message_id and chat_id: + # Re-render TLS screen + server = cm.get_server(server_name) + name_escaped = escape_markdown_v2(server_name) + message_text = ( + f"🔐 *TLS Settings: {name_escaped}*\n\n" + f"{_tls_render(server)}\n\n" + f"_{escape_markdown_v2(notice)}\\._" + ) + has_ca = bool(server.get("ca_bundle_path")) + has_cert = bool(server.get("client_cert_path")) + has_key = bool(server.get("client_key_path")) + keyboard = [ + [ + InlineKeyboardButton("🌐 Protocol", callback_data="tlscfg_proto"), + InlineKeyboardButton("✅ Verify", callback_data="tlscfg_verify"), + ], + [ + InlineKeyboardButton( + "🗑 CA" if has_ca else "📄 CA", + callback_data="tlscfg_caclr" if has_ca else "tlscfg_ca", + ), + InlineKeyboardButton( + "🗑 Cert" if has_cert else "📄 Cert", + callback_data="tlscfg_certclr" if has_cert else "tlscfg_cert", + ), + InlineKeyboardButton( + "🗑 Key" if has_key else "📄 Key", + callback_data="tlscfg_keyclr" if has_key else "tlscfg_key", + ), + ], + [ + InlineKeyboardButton( + "« Back", callback_data=f"api_server_view_{server_name}" + ) + ], + ] + try: + await context.bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=message_text, + parse_mode="MarkdownV2", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + except Exception as e: + logger.error(f"Error refreshing TLS screen: {e}") diff --git a/mcp_servers/hummingbot_api/hummingbot_client.py b/mcp_servers/hummingbot_api/hummingbot_client.py index a371c6d5..8afaa733 100644 --- a/mcp_servers/hummingbot_api/hummingbot_client.py +++ b/mcp_servers/hummingbot_api/hummingbot_client.py @@ -11,6 +11,7 @@ from mcp_servers.hummingbot_api.exceptions import MaxConnectionsAttemptError from mcp_servers.hummingbot_api.settings import settings +from utils.hummingbot_client_factory import create_initialized_client logger = logging.getLogger("hummingbot-mcp") @@ -44,15 +45,16 @@ async def initialize(self, force: bool = False) -> HummingbotAPIClient: last_error = None for attempt in range(settings.max_retries): try: - self._client = HummingbotAPIClient( + self._client = await create_initialized_client( base_url=settings.api_url, username=settings.api_username, password=settings.api_password, timeout=settings.client_timeout, + tls_verify=settings.tls_verify, + ca_bundle_path=settings.ca_bundle_path, + client_cert_path=settings.client_cert_path, + client_key_path=settings.client_key_path, ) - - # Initialize and test connection - await self._client.init() await self._client.accounts.list_accounts() self._initialized = True diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index d1a2e61a..29825955 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -6,6 +6,7 @@ import logging import sys from typing import Any, Literal +from utils.url_builder import build_server_url from mcp.server.fastmcp import FastMCP @@ -110,8 +111,13 @@ async def configure_server( name: str | None = None, host: str | None = None, port: int | None = None, + protocol: Literal["auto", "http", "https"] | None = None, username: str | None = None, password: str | None = None, + tls_verify: bool | None = None, + ca_bundle_path: str | None = None, + client_cert_path: str | None = None, + client_key_path: str | None = None, ) -> str: """Configure the active Hummingbot API server connection. @@ -125,19 +131,39 @@ async def configure_server( name: Server label (e.g., 'macmini', 'production') host: API host (e.g., 'localhost', 'host.docker.internal', '72.212.424.42') port: API port (e.g., 8000) + protocol: URL protocol override ('auto', 'http', 'https'). Default: auto. username: API username password: API password + tls_verify: Enable TLS certificate verification when using HTTPS (default: True) + ca_bundle_path: Optional custom CA bundle path for private/self-signed CAs + client_cert_path: Optional client certificate path for mTLS + client_key_path: Optional client private key path for mTLS """ from mcp_servers.hummingbot_api.settings import ServerConfig, _load_server_config, save_server_config # No params → show active server - if name is None and host is None and port is None and username is None and password is None: + if ( + name is None + and host is None + and port is None + and protocol is None + and username is None + and password is None + and tls_verify is None + and ca_bundle_path is None + and client_cert_path is None + and client_key_path is None + ): current = _load_server_config() return ( f"Active Server:\n\n" f" Name: {current.name}\n" f" URL: {current.url}\n" f" Username: {current.username}\n" + f" TLS Verify: {current.tls_verify}\n" + f" CA Bundle: {current.ca_bundle_path or '(system default)'}\n" + f" Client Cert: {current.client_cert_path or '(none)'}\n" + f" Client Key: {current.client_key_path or '(none)'}\n" ) # Build new config with partial updates @@ -147,18 +173,35 @@ async def configure_server( parsed = urlparse(current.url) current_host = parsed.hostname or "localhost" current_port = parsed.port or 8000 + current_protocol = parsed.scheme or "auto" final_name = name if name is not None else current.name final_host = host if host is not None else current_host final_port = port if port is not None else current_port + final_protocol = protocol if protocol is not None else current_protocol final_username = username if username is not None else current.username final_password = password if password is not None else current.password + final_tls_verify = tls_verify if tls_verify is not None else current.tls_verify + final_ca_bundle = ( + ca_bundle_path if ca_bundle_path is not None else current.ca_bundle_path + ) + final_client_cert = ( + client_cert_path if client_cert_path is not None else current.client_cert_path + ) + final_client_key = ( + client_key_path if client_key_path is not None else current.client_key_path + ) + new_config = ServerConfig( name=final_name, - url=f"http://{final_host}:{final_port}", + url=build_server_url(host=final_host, port=final_port, protocol=final_protocol), username=final_username, password=final_password, + tls_verify=final_tls_verify, + ca_bundle_path=final_ca_bundle, + client_cert_path=final_client_cert, + client_key_path=final_client_key, ) # Persist and apply @@ -172,12 +215,20 @@ async def configure_server( f"Server '{new_config.name}' configured and connected successfully.\n\n" f" URL: {new_config.url}\n" f" Username: {new_config.username}\n" + f" TLS Verify: {new_config.tls_verify}\n" + f" CA Bundle: {new_config.ca_bundle_path or '(system default)'}\n" + f" Client Cert: {new_config.client_cert_path or '(none)'}\n" + f" Client Key: {new_config.client_key_path or '(none)'}\n" ) except Exception as e: return ( f"Server '{new_config.name}' configured but could not connect.\n\n" f" URL: {new_config.url}\n" f" Username: {new_config.username}\n\n" + f" TLS Verify: {new_config.tls_verify}\n" + f" CA Bundle: {new_config.ca_bundle_path or '(system default)'}\n\n" + f" Client Cert: {new_config.client_cert_path or '(none)'}\n" + f" Client Key: {new_config.client_key_path or '(none)'}\n\n" f"Error: {str(e)}\n" ) @@ -978,7 +1029,12 @@ async def manage_backtest_tasks( def _apply_cli_args(): - """Parse CLI args and override settings if provided.""" + """Parse CLI args and override settings if provided. + + When --url is supplied, treat the CLI as the authoritative configuration: + reset cert paths so a stale ~/.hummingbot_mcp/server.yml from a prior session + cannot leak into this one. CLI args then re-populate any paths that apply. + """ import argparse parser = argparse.ArgumentParser(add_help=False) @@ -986,16 +1042,36 @@ def _apply_cli_args(): parser.add_argument("--username") parser.add_argument("--password") parser.add_argument("--server-name") + parser.add_argument("--tls-verify") + parser.add_argument("--ca-bundle-path") + parser.add_argument("--client-cert-path") + parser.add_argument("--client-key-path") args, _ = parser.parse_known_args() if args.url: settings.api_url = args.url + settings.ca_bundle_path = None + settings.client_cert_path = None + settings.client_key_path = None if args.username: settings.api_username = args.username if args.password: settings.api_password = args.password if args.server_name: settings.server_name = args.server_name + if args.tls_verify is not None: + settings.tls_verify = str(args.tls_verify).strip().lower() in { + "1", + "true", + "yes", + "on", + } + if args.ca_bundle_path: + settings.ca_bundle_path = args.ca_bundle_path + if args.client_cert_path: + settings.client_cert_path = args.client_cert_path + if args.client_key_path: + settings.client_key_path = args.client_key_path async def _run(): diff --git a/mcp_servers/hummingbot_api/settings.py b/mcp_servers/hummingbot_api/settings.py index 34546ecf..f0ae0404 100644 --- a/mcp_servers/hummingbot_api/settings.py +++ b/mcp_servers/hummingbot_api/settings.py @@ -22,6 +22,10 @@ class ServerConfig(BaseModel): url: str = Field(default="http://localhost:8000") username: str = Field(default="admin") password: str = Field(default="admin") + tls_verify: bool = Field(default=True) + ca_bundle_path: str | None = Field(default=None) + client_cert_path: str | None = Field(default=None) + client_key_path: str | None = Field(default=None) @field_validator("url", mode="before") def validate_url(cls, v): @@ -45,6 +49,10 @@ def _load_server_config() -> ServerConfig: url=os.getenv("HUMMINGBOT_API_URL", "http://localhost:8000"), username=os.getenv("HUMMINGBOT_USERNAME", "admin"), password=os.getenv("HUMMINGBOT_PASSWORD", "admin"), + tls_verify=os.getenv("HUMMINGBOT_TLS_VERIFY", "true").lower() in {"1", "true", "yes", "on"}, + ca_bundle_path=os.getenv("HUMMINGBOT_CA_BUNDLE_PATH"), + client_cert_path=os.getenv("HUMMINGBOT_CLIENT_CERT_PATH"), + client_key_path=os.getenv("HUMMINGBOT_CLIENT_KEY_PATH"), ) @@ -62,6 +70,10 @@ class Settings(BaseModel): api_url: str = Field(default="http://localhost:8000") api_username: str = Field(default="admin") api_password: str = Field(default="admin") + tls_verify: bool = Field(default=True) + ca_bundle_path: str | None = Field(default=None) + client_cert_path: str | None = Field(default=None) + client_key_path: str | None = Field(default=None) server_name: str = Field(default="default") default_account: str = Field(default="master_account") @@ -96,6 +108,10 @@ def reload_from_server_config(self, config: ServerConfig): self.api_url = config.url self.api_username = config.username self.api_password = config.password + self.tls_verify = config.tls_verify + self.ca_bundle_path = config.ca_bundle_path + self.client_cert_path = config.client_cert_path + self.client_key_path = config.client_key_path self.server_name = config.name @@ -108,6 +124,10 @@ def get_settings() -> Settings: api_url=server_config.url, api_username=server_config.username, api_password=server_config.password, + tls_verify=server_config.tls_verify, + ca_bundle_path=server_config.ca_bundle_path, + client_cert_path=server_config.client_cert_path, + client_key_path=server_config.client_key_path, server_name=server_config.name, connection_timeout=float(os.getenv("HUMMINGBOT_TIMEOUT", "30.0")), max_retries=int(os.getenv("HUMMINGBOT_MAX_RETRIES", "3")), diff --git a/setup-environment.sh b/setup-environment.sh index c16290c7..cc87f1ec 100755 --- a/setup-environment.sh +++ b/setup-environment.sh @@ -477,6 +477,26 @@ else if [[ "${deploy_hb:-}" =~ ^[Nn]$ ]]; then echo "DEPLOY_HUMMINGBOT_API=false" >> "$ENV_FILE" msg_ok "Skipped Hummingbot API deployment" + echo "" + msg_info "Enter the Hummingbot API connection details to pre-configure $CONFIG_FILE." + prompt_visible "API URL" "http://localhost:8000" "hb_api_url_raw" + hb_api_url_raw="${hb_api_url_raw:-http://localhost:8000}" + prompt_visible "API admin username" "admin" "hb_username" + prompt_secret "API admin password" "admin" "hb_password" + + HB_API_PROTOCOL=$(python3 -c "from urllib.parse import urlparse; p=urlparse('${hb_api_url_raw}'); print(p.scheme or 'http')" 2>/dev/null || echo "http") + HB_API_HOST=$(python3 -c "from urllib.parse import urlparse; p=urlparse('${hb_api_url_raw}'); print(p.hostname or 'localhost')" 2>/dev/null || echo "localhost") + _def_port=$([ "$HB_API_PROTOCOL" = "https" ] && echo "8443" || echo "8000") + HB_API_PORT=$(python3 -c "from urllib.parse import urlparse; p=urlparse('${hb_api_url_raw}'); print(p.port or ${_def_port})" 2>/dev/null || echo "$_def_port") + HB_API_TLS_VERIFY="true" + + if [ "$HB_API_PROTOCOL" = "https" ]; then + echo "" + prompt_visible "Verify TLS certificate? Use 'n' for self-signed certs [Y/n]" "Y" "tls_verify_input" + [[ "${tls_verify_input:-Y}" =~ ^[Nn]$ ]] && HB_API_TLS_VERIFY="false" + fi + + hb_api_configured=true else # Check Docker if ! command_exists docker; then @@ -657,6 +677,33 @@ if [ "${hb_api_deployed:-}" = true ]; then fi fi +# If user provided a remote API URL (skipped local deployment), update config.yml +if [ "${hb_api_configured:-false}" = true ] && [ -f "$CONFIG_FILE" ]; then + sed -i.bak "/servers:/,/^[^ ]/ s/host: .*/host: $HB_API_HOST/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak" + sed -i.bak "/servers:/,/^[^ ]/ s/port: .*/port: $HB_API_PORT/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak" + if [ -n "${hb_username:-}" ]; then + sed -i.bak "/servers:/,/^[^ ]/ s/username: .*/username: $hb_username/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak" + sed -i.bak "/servers:/,/^[^ ]/ s/password: .*/password: $hb_password/" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak" + fi + if [ "$HB_API_PROTOCOL" = "https" ]; then + tmp="$(mktemp)" + awk -v verify="$HB_API_TLS_VERIFY" ' + /^ local:/ { in_local=1 } + in_local && /^[^ ]/ { in_local=0 } + in_local && /^ [^ ]/ && !/^ local:/ { in_local=0 } + in_local && /^ *(protocol|tls_verify):/ { next } + in_local && /^ *password:/ { + print + print " protocol: https" + print " tls_verify: " verify + next + } + { print } + ' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE" + fi + msg_ok "Configured $CONFIG_FILE: ${HB_API_PROTOCOL}://${HB_API_HOST}:${HB_API_PORT}" +fi + if [ "$config_updated" = false ] && [ -f "$CONFIG_FILE" ]; then msg_ok "$CONFIG_FILE exists and is configured" fi diff --git a/utils/config.py b/utils/config.py index 14d7631a..073cfd7a 100644 --- a/utils/config.py +++ b/utils/config.py @@ -27,7 +27,12 @@ if _web_url_raw: WEB_URL = _web_url_raw.rstrip("/") _parsed = urlparse(WEB_URL) - WEB_PORT = _parsed.port or (443 if _parsed.scheme == "https" else 80) + # When no port is explicit in WEB_URL, fall back to WEB_PORT env var then 8088. + # We avoid binding to privileged ports (80/443) which require root on Linux. + WEB_PORT = _parsed.port or (int(_web_port_raw) if _web_port_raw else 8088) + # Keep WEB_URL consistent with the port uvicorn will actually bind to. + if not _parsed.port: + WEB_URL = f"{_parsed.scheme}://{_parsed.hostname}:{WEB_PORT}" else: WEB_PORT = int(_web_port_raw) if _web_port_raw else 8088 WEB_URL = f"http://localhost:{WEB_PORT}" diff --git a/utils/hummingbot_client_factory.py b/utils/hummingbot_client_factory.py new file mode 100644 index 00000000..99db2b07 --- /dev/null +++ b/utils/hummingbot_client_factory.py @@ -0,0 +1,119 @@ +"""Factory helpers for HummingbotAPIClient with optional TLS policy.""" + +from __future__ import annotations + +import asyncio +import ssl +from pathlib import Path +from typing import Any + +import aiohttp +from hummingbot_api_client import HummingbotAPIClient + +_INIT_PATCH_LOCK = asyncio.Lock() + + +def _coerce_bool(value: Any, default: bool = True) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return bool(value) + + +def build_ssl_option( + tls_verify: Any = True, + ca_bundle_path: str | None = None, + client_cert_path: str | None = None, + client_key_path: str | None = None, +): + """Build aiohttp ssl option (None, False, or SSLContext).""" + verify = _coerce_bool(tls_verify, default=True) + has_client_cert = bool(client_cert_path or client_key_path) + + if (client_cert_path and not client_key_path) or (client_key_path and not client_cert_path): + raise ValueError("Both client_cert_path and client_key_path must be provided for mTLS.") + + if not verify: + if not has_client_cert: + return False + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + elif ca_bundle_path: + ca_path = Path(ca_bundle_path).expanduser() + if not ca_path.exists(): + raise ValueError(f"CA bundle not found: {ca_path}") + ctx = ssl.create_default_context(cafile=str(ca_path)) + else: + if not has_client_cert: + return None + ctx = ssl.create_default_context() + + if has_client_cert: + cert_path = Path(client_cert_path).expanduser() + key_path = Path(client_key_path).expanduser() + if not cert_path.exists(): + raise ValueError(f"Client cert not found: {cert_path}") + if not key_path.exists(): + raise ValueError(f"Client key not found: {key_path}") + ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + return ctx + + +async def create_initialized_client( + *, + base_url: str, + username: str, + password: str, + timeout: aiohttp.ClientTimeout | None = None, + tls_verify: Any = True, + ca_bundle_path: str | None = None, + client_cert_path: str | None = None, + client_key_path: str | None = None, +) -> HummingbotAPIClient: + """Create and initialize a HummingbotAPIClient with optional TLS policy.""" + client = HummingbotAPIClient( + base_url=base_url, + username=username, + password=password, + timeout=timeout, + ) + + ssl_option = build_ssl_option( + tls_verify=tls_verify, + ca_bundle_path=ca_bundle_path, + client_cert_path=client_cert_path, + client_key_path=client_key_path, + ) + if ssl_option is None: + await client.init() + return client + + # Inject our SSL-aware connector by patching aiohttp.ClientSession during init(). + # This lets upstream init() create its session and routers normally — we only + # override the underlying connector, so any routers added upstream are picked up + # automatically. + connector = aiohttp.TCPConnector(ssl=ssl_option) + original_cs = aiohttp.ClientSession + + def patched_cs(*args, **kwargs): + kwargs.setdefault("connector", connector) + return original_cs(*args, **kwargs) + + async with _INIT_PATCH_LOCK: + aiohttp.ClientSession = patched_cs + try: + await client.init() + except Exception: + await connector.close() + raise + finally: + aiohttp.ClientSession = original_cs + return client diff --git a/utils/url_builder.py b/utils/url_builder.py new file mode 100644 index 00000000..126ac8ef --- /dev/null +++ b/utils/url_builder.py @@ -0,0 +1,100 @@ +"""Utilities for building and validating Hummingbot API server URLs.""" + +from __future__ import annotations + +from typing import Any, Mapping +from urllib.parse import urlparse + + +class ServerUrlError(ValueError): + """Raised when server host/port/protocol configuration is invalid.""" + + +def _normalize_protocol(protocol: str | None) -> str: + value = (protocol or "auto").strip().lower() + if value not in {"auto", "http", "https"}: + raise ServerUrlError( + f"Invalid protocol '{protocol}'. Expected one of: auto, http, https." + ) + return value + + +def _normalize_host_for_url(host: str) -> str: + # Bracket IPv6 literals so URL parsing and formatting remain valid. + if ":" in host and not host.startswith("[") and host.count(":") > 1: + return f"[{host}]" + return host + + +def _format_url(protocol: str, host: str, port: int) -> str: + host = _normalize_host_for_url(host) + if (protocol == "https" and port == 443) or (protocol == "http" and port == 80): + return f"{protocol}://{host}" + return f"{protocol}://{host}:{port}" + + +def build_server_url(host: str, port: int, protocol: str | None = "auto") -> str: + """Build a validated API URL from host/port/protocol settings. + + Rules: + - If host includes scheme, keep that scheme (unless explicit protocol conflicts). + - If host includes a port and it conflicts with `port`, raise ServerUrlError. + - If scheme is omitted and protocol is "auto", infer https for 443 else http. + - Default ports are omitted from final URL (http:80, https:443). + """ + + if host is None: + raise ServerUrlError("Server host is required.") + host = str(host).strip().rstrip("/") + if not host: + raise ServerUrlError("Server host cannot be empty.") + + try: + port = int(port) + except Exception as exc: + raise ServerUrlError(f"Invalid port '{port}'.") from exc + if port <= 0 or port > 65535: + raise ServerUrlError(f"Invalid port '{port}'. Must be between 1 and 65535.") + + protocol = _normalize_protocol(protocol) + + if host.startswith(("http://", "https://")): + parsed = urlparse(host) + parsed_scheme = (parsed.scheme or "").lower() + parsed_host = parsed.hostname + parsed_port = parsed.port + + if parsed_scheme not in {"http", "https"}: + raise ServerUrlError( + f"Unsupported URL scheme '{parsed_scheme}' in host '{host}'." + ) + if not parsed_host: + raise ServerUrlError(f"Invalid host URL '{host}'.") + + if protocol != "auto" and protocol != parsed_scheme: + raise ServerUrlError( + f"Protocol conflict: host uses '{parsed_scheme}' but protocol is '{protocol}'." + ) + if parsed_port is not None and parsed_port != port: + raise ServerUrlError( + f"Port conflict: host URL includes port {parsed_port} but port is set to {port}." + ) + + final_port = parsed_port if parsed_port is not None else port + return _format_url(parsed_scheme, parsed_host, final_port) + + if "://" in host: + raise ServerUrlError( + f"Invalid host '{host}'. Include a full URL (http/https) or plain hostname." + ) + + resolved_protocol = protocol if protocol != "auto" else ("https" if port == 443 else "http") + return _format_url(resolved_protocol, host, port) + + +def build_server_url_from_config(server: Mapping[str, Any]) -> str: + """Build URL from a server config mapping.""" + host = server.get("host", "localhost") + port = server.get("port", 8000) + protocol = server.get("protocol", "auto") + return build_server_url(host=host, port=port, protocol=protocol)