Skip to main content
Glama
config.py26.5 kB
# # MCP Foxxy Bridge - Configuration Management Commands # # Copyright (C) 2024 Billy Bryant # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. # """Configuration management CLI commands.""" import argparse import json import logging import shutil from pathlib import Path from typing import Any, cast import yaml from rich.console import Console from rich.prompt import Confirm from mcp_foxxy_bridge.cli.formatters import ConfigFormatter from mcp_foxxy_bridge.config.config_loader import load_bridge_config_from_file from mcp_foxxy_bridge.utils.config_migration import get_config_dir from mcp_foxxy_bridge.utils.path_security import validate_config_path from mcp_foxxy_bridge.utils.server_names import find_server_key async def handle_config_command( args: argparse.Namespace, config_path: Path, config_dir: Path, console: Console, logger: logging.Logger, ) -> None: """Handle configuration management commands.""" # Check if no subcommand was provided if not hasattr(args, "config_command") or args.config_command is None: console.print("[yellow]Usage: foxxy-bridge config <command>[/yellow]") console.print("Available commands: add, remove, list, show, validate, init, get, set") return if args.config_command == "add": await _config_add(args, config_path, console, logger) elif args.config_command == "remove": await _config_remove(args, config_path, console, logger) elif args.config_command == "list": await _config_list(args, config_path, console, logger) elif args.config_command == "show": await _config_show(args, config_path, console, logger) elif args.config_command == "validate": await _config_validate(args, config_path, console, logger) elif args.config_command == "init": await _config_init(args, config_path, console, logger) elif args.config_command == "get": await _config_get(args, config_path, console, logger) elif args.config_command == "set": await _config_set(args, config_path, console, logger) else: console.print(f"[red]Unknown config command: {args.config_command}[/red]") async def _config_add( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Add a new MCP server to configuration.""" try: # Load existing configuration config = _load_config_safe(config_path, logger) # Check if server already exists servers = config.get("mcpServers", {}) if args.name in servers: try: if not Confirm.ask(f"Server '{args.name}' already exists. Overwrite?"): console.print("[yellow]Operation cancelled[/yellow]") return except EOFError: console.print( f"[red]Server '{args.name}' already exists. Use --force to overwrite in non-interactive mode.[/red]" ) return # Build server configuration server_config = { "transport": "stdio", "command": args.server_command, } if args.server_args: server_config["args"] = args.server_args if args.env: server_config["env"] = dict(args.env) if args.cwd: server_config["cwd"] = args.cwd if args.tags: server_config["tags"] = args.tags # OAuth configuration if args.oauth: oauth_config = {"enabled": True} if args.oauth_issuer: oauth_config["issuer"] = args.oauth_issuer server_config["oauth_config"] = oauth_config # Add server to configuration servers[args.name] = server_config config["mcpServers"] = servers # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Added server '[cyan]{args.name}[/cyan]'") logger.info(f"Added server '{args.name}' with command '{args.server_command}'") except Exception as e: console.print(f"[red]Error adding server: {e}[/red]") logger.exception("Failed to add server configuration") async def _config_remove( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Remove an MCP server from configuration.""" try: # Load existing configuration config = _load_config_safe(config_path, logger) servers = config.get("mcpServers", {}) if args.name not in servers: console.print(f"[red]Server '{args.name}' not found[/red]") return # Confirm removal if not args.force: try: if not Confirm.ask(f"Remove server '[cyan]{args.name}[/cyan]'?"): console.print("[yellow]Operation cancelled[/yellow]") return except EOFError: console.print("[red]Use --force to remove in non-interactive mode[/red]") return # Remove server del servers[args.name] config["mcpServers"] = servers # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Removed server '[cyan]{args.name}[/cyan]'") logger.info(f"Removed server '{args.name}' from configuration") except Exception as e: console.print(f"[red]Error removing server: {e}[/red]") logger.exception("Failed to remove server configuration") async def _config_list( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """List configured servers.""" try: config = _load_config_safe(config_path, logger) servers = config.get("mcpServers", {}) if args.format == "json": console.print(json.dumps(servers, indent=2)) elif args.format == "yaml": console.print(yaml.dump(servers, default_flow_style=False)) # type: ignore[no-untyped-call] else: ConfigFormatter.format_servers_table(servers, console) except Exception as e: console.print(f"[red]Error listing servers: {e}[/red]") logger.exception("Failed to list server configurations") async def _config_show( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Show configuration details.""" try: config = _load_config_safe(config_path, logger) if args.name: # Show specific server servers = config.get("mcpServers", {}) if args.name not in servers: console.print(f"[red]Server '{args.name}' not found[/red]") return server_config = {args.name: servers[args.name]} else: # Show entire configuration server_config = config if args.format == "json": ConfigFormatter.format_config_json(server_config, console) else: ConfigFormatter.format_config_yaml(server_config, console) except Exception as e: console.print(f"[red]Error showing configuration: {e}[/red]") logger.exception("Failed to show configuration") async def _config_validate( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Validate configuration file.""" try: # Try to load configuration bridge_config = load_bridge_config_from_file(str(config_path), {}) console.print("[green]✓[/green] Configuration is valid") # Show summary servers = bridge_config.servers console.print(f"Found {len(servers)} server(s) configured") for name, server_config in servers.items(): status_icon = ( "🔐" if hasattr(server_config, "oauth_config") and server_config.oauth_config and getattr(server_config.oauth_config, "enabled", False) else "🔓" ) transport_type = getattr(server_config, "transport_type", "stdio") console.print(f" {status_icon} {name} ({transport_type})") except Exception as e: console.print(f"[red]✗[/red] Configuration validation failed: {e}") if args.fix: console.print("[yellow]Attempting to fix configuration...[/yellow]") # TODO: Implement basic fixes like adding missing required fields async def _config_init( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Initialize configuration with defaults.""" try: if config_path.exists() and not args.force: try: if not Confirm.ask("Configuration already exists. Overwrite?"): console.print("[yellow]Operation cancelled[/yellow]") return except EOFError: console.print( "[red]Configuration already exists. Use --force to overwrite in non-interactive mode[/red]" ) return # Create default configuration with absolute schema path config_dir = get_config_dir() schema_path = config_dir / "bridge_config_schema.json" default_config = { "$schema": str(schema_path), "mcpServers": { "filesystem": { "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], "tags": ["local", "development"], } }, "bridge": { "conflictResolution": "namespace", "defaultNamespace": True, "aggregation": {"tools": True, "resources": True, "prompts": True}, "host": "127.0.0.1", "port": 9000, }, } # Ensure config directory exists config_path.parent.mkdir(parents=True, exist_ok=True) # Save configuration _save_config(default_config, config_path, console, logger) console.print(f"[green]✓[/green] Initialized configuration at [cyan]{config_path}[/cyan]") console.print("Edit the configuration file to add your MCP servers.") except Exception as e: console.print(f"[red]Error initializing configuration: {e}[/red]") logger.exception("Failed to initialize configuration") def _load_config_safe(config_path: Path, logger: logging.Logger) -> dict[str, Any]: """Load configuration file with error handling.""" if not config_path.exists(): logger.info("Configuration file does not exist, creating empty config") return {"mcpServers": {}} try: with config_path.open("r", encoding="utf-8") as f: return cast("dict[str, Any]", json.load(f)) except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in configuration file: {e}") from e except Exception as e: raise ValueError(f"Failed to read configuration file: {e}") from e def _save_config( config: dict[str, Any], config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Save configuration file with backup.""" # Create backup if file exists if config_path.exists(): backup_path = config_path.with_suffix(".json.backup") try: shutil.copy2(config_path, backup_path) logger.debug("Created configuration backup") except Exception as e: logger.warning(f"Failed to create backup: {e}") # Ensure directory exists config_path.parent.mkdir(parents=True, exist_ok=True) # Write configuration try: validated_path = validate_config_path(config_path) with validated_path.open("w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) # Set secure permissions validated_path.chmod(0o600) except Exception as e: raise ValueError(f"Failed to save configuration: {e}") from e async def _config_get( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Get a configuration value by key path.""" try: config = _load_config_safe(config_path, logger) value = _get_config_value(config, args.key) if value is None: console.print(f"[yellow]Configuration key '{args.key}' not found[/yellow]") elif isinstance(value, (dict, list)): console.print(json.dumps(value, indent=2)) else: console.print(str(value)) except Exception as e: console.print(f"[red]Error getting config value: {e}[/red]") logger.exception("Failed to get config value") async def _config_set( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Set a configuration value by key path (assumes bridge prefix).""" try: config = _load_config_safe(config_path, logger) # Get key and value from CLI args key = args.key value = args.value # Assume bridge prefix unless key starts with "mcpServers" or other root-level keys bridge_key = _normalize_bridge_config_key(key) old_value = _get_config_value(config, bridge_key) parsed_value = _parse_config_value(value) # Set the value _set_config_value(config, bridge_key, parsed_value) # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Set [bold]{bridge_key}[/bold] = [cyan]{parsed_value}[/cyan]") if old_value is not None and old_value != parsed_value: console.print(f"[dim]Previous value: {old_value}[/dim]") except Exception as e: console.print(f"[red]Error setting config value: {e}[/red]") logger.exception("Failed to set config value") async def _config_unset( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Unset a configuration value by key path (assumes bridge prefix).""" try: config = _load_config_safe(config_path, logger) # Get key from CLI args key = args.key # Assume bridge prefix unless key starts with "mcpServers" or other root-level keys bridge_key = _normalize_bridge_config_key(key) old_value = _get_config_value(config, bridge_key) if old_value is None: console.print(f"[yellow]Key [bold]{bridge_key}[/bold] is not set[/yellow]") return # Unset the value _unset_config_value(config, bridge_key) # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Unset [bold]{bridge_key}[/bold]") console.print(f"[dim]Previous value: {old_value}[/dim]") except Exception as e: console.print(f"[red]Error unsetting config value: {e}[/red]") logger.exception("Failed to unset config value") def _normalize_bridge_config_key(key: str) -> str: """Normalize a configuration key by adding 'bridge.' prefix for non-root keys. Args: key: The configuration key to normalize Returns: The normalized key with 'bridge.' prefix if needed Example: >>> _normalize_bridge_config_key('port') 'bridge.port' >>> _normalize_bridge_config_key('mcpServers.test') 'mcpServers.test' """ # Root-level keys that should not be prefixed with "bridge." root_keys = {"mcpServers"} # If key starts with a root-level key, don't add bridge prefix first_part = key.split(".")[0] if first_part in root_keys: return key # If key already starts with "bridge.", don't add prefix if key.startswith("bridge."): return key # Otherwise, assume bridge prefix return f"bridge.{key}" def _get_config_value(config: dict[str, Any], key: str) -> Any: """Get a configuration value using dot-notation key path. Args: config: The configuration dictionary key: Dot-notation key path (e.g. 'bridge.read_only_mode') Returns: The value at the specified key path, or None if not found Example: >>> config = {'bridge': {'port': 9000}} >>> _get_config_value(config, 'bridge.port') 9000 """ keys = key.split(".") current = config for k in keys: if isinstance(current, dict) and k in current: current = current[k] else: return None return current def _set_config_value(config: dict[str, Any], key: str, value: Any) -> None: """Set a configuration value using dot-notation key path. Args: config: The configuration dictionary to modify key: Dot-notation key path (e.g. 'bridge.port') value: The value to set Raises: ValueError: If trying to set nested value where parent is not a dict Example: >>> config = {} >>> _set_config_value(config, 'bridge.port', 9000) >>> config['bridge']['port'] 9000 """ keys = key.split(".") current = config # Navigate to the parent of the target key for k in keys[:-1]: if k not in current: current[k] = {} elif not isinstance(current[k], dict): raise ValueError(f"Cannot set nested value: '{k}' is not an object") current = current[k] # Set the final value current[keys[-1]] = value def _unset_config_value(config: dict[str, Any], key: str) -> None: """Remove a configuration value using dot-notation key path. Args: config: The configuration dictionary to modify key: Dot-notation key path (e.g. 'bridge.port') Example: >>> config = {'bridge': {'port': 9000}} >>> _unset_config_value(config, 'bridge.port') >>> 'port' in config['bridge'] False """ keys = key.split(".") current = config # Navigate to the parent of the target key for k in keys[:-1]: if k not in current or not isinstance(current[k], dict): # Key doesn't exist, nothing to unset return current = current[k] # Remove the final key if it exists if keys[-1] in current: del current[keys[-1]] def _parse_config_value(value: str) -> Any: """Parse a string value into the appropriate Python type. Handles automatic type conversion for: - Booleans: 'true'/'false' - None values: 'null'/'none' - Numbers: integers and floats - Arrays: JSON arrays or comma-separated values - Objects: JSON objects - Strings: fallback for unrecognized formats Args: value: The string value to parse Returns: The parsed value in the appropriate Python type Example: >>> _parse_config_value('true') True >>> _parse_config_value('9000') 9000 >>> _parse_config_value('a,b,c') ['a', 'b', 'c'] """ # Handle boolean values if value.lower() in ("true", "false"): return value.lower() == "true" # Handle null/none if value.lower() in ("null", "none"): return None # Handle numbers try: if "." in value: return float(value) return int(value) except ValueError: pass # Handle arrays (comma-separated or JSON format) if value.startswith("[") and value.endswith("]"): try: return json.loads(value) except json.JSONDecodeError: pass elif "," in value: return [item.strip() for item in value.split(",")] # Handle objects (JSON format) if value.startswith("{") and value.endswith("}"): try: return json.loads(value) except json.JSONDecodeError: pass # Return as string return value # MCP Server Configuration Functions async def _mcp_config_set( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Set a configuration value for a specific MCP server. Args: args: Command line arguments containing server_name, key, and value config_path: Path to the configuration file console: Rich console for output logger: Logger for error reporting Example: CLI usage: foxxy-bridge mcp config set filesystem timeout 120 """ try: config = _load_config_safe(config_path, logger) # Get server name, key, and value from CLI args input_server_name = args.server_name key = args.key value = args.value # Find actual server key using case-insensitive matching servers = config.get("mcpServers", {}) actual_server_key = find_server_key(servers, input_server_name) if actual_server_key is None: console.print(f"[red]MCP server '{input_server_name}' not found[/red]") console.print("[dim]Use 'foxxy-bridge mcp list' to see available servers[/dim]") return # Build the full key path for mcpServers using actual server key full_key = f"mcpServers.{actual_server_key}.{key}" old_value = _get_config_value(config, full_key) parsed_value = _parse_config_value(value) # Set the value _set_config_value(config, full_key, parsed_value) # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Set [bold]{actual_server_key}.{key}[/bold] = [cyan]{parsed_value}[/cyan]") if old_value is not None and old_value != parsed_value: console.print(f"[dim]Previous value: {old_value}[/dim]") except Exception as e: console.print(f"[red]Error setting MCP server config value: {e}[/red]") logger.exception("Failed to set MCP server config value") async def _mcp_config_get( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Get a configuration value from a specific MCP server. Args: args: Command line arguments containing server_name and key config_path: Path to the configuration file console: Rich console for output logger: Logger for error reporting Example: CLI usage: foxxy-bridge mcp config get filesystem timeout """ try: config = _load_config_safe(config_path, logger) # Get server name and key from CLI args input_server_name = args.server_name key = args.key # Find actual server key using case-insensitive matching servers = config.get("mcpServers", {}) actual_server_key = find_server_key(servers, input_server_name) if actual_server_key is None: console.print(f"[red]MCP server '{input_server_name}' not found[/red]") console.print("[dim]Use 'foxxy-bridge mcp list' to see available servers[/dim]") return # Build the full key path for mcpServers using actual server key full_key = f"mcpServers.{actual_server_key}.{key}" value = _get_config_value(config, full_key) if value is None: console.print(f"[yellow]Key [bold]{actual_server_key}.{key}[/bold] is not set[/yellow]") else: console.print(f"[bold]{actual_server_key}.{key}[/bold] = [cyan]{value}[/cyan]") except Exception as e: console.print(f"[red]Error getting MCP server config value: {e}[/red]") logger.exception("Failed to get MCP server config value") async def _mcp_config_unset( args: argparse.Namespace, config_path: Path, console: Console, logger: logging.Logger, ) -> None: """Remove a configuration value from a specific MCP server. Args: args: Command line arguments containing server_name and key config_path: Path to the configuration file console: Rich console for output logger: Logger for error reporting Example: CLI usage: foxxy-bridge mcp config unset filesystem timeout """ try: config = _load_config_safe(config_path, logger) # Get server name and key from CLI args input_server_name = args.server_name key = args.key # Find actual server key using case-insensitive matching servers = config.get("mcpServers", {}) actual_server_key = find_server_key(servers, input_server_name) if actual_server_key is None: console.print(f"[red]MCP server '{input_server_name}' not found[/red]") console.print("[dim]Use 'foxxy-bridge mcp list' to see available servers[/dim]") return # Build the full key path for mcpServers using actual server key full_key = f"mcpServers.{actual_server_key}.{key}" old_value = _get_config_value(config, full_key) if old_value is None: console.print(f"[yellow]Key [bold]{actual_server_key}.{key}[/bold] is not set[/yellow]") return # Unset the value _unset_config_value(config, full_key) # Save configuration _save_config(config, config_path, console, logger) console.print(f"[green]✓[/green] Unset [bold]{actual_server_key}.{key}[/bold]") console.print(f"[dim]Previous value: {old_value}[/dim]") except Exception as e: console.print(f"[red]Error unsetting MCP server config value: {e}[/red]") logger.exception("Failed to unset MCP server config value")

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/billyjbryant/mcp-foxxy-bridge'

If you have feedback or need assistance with the MCP directory API, please join our Discord server