diff --git a/README.md b/README.md index e18ce60..38732ae 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,13 @@ cd mcp-sync - `mcp-sync diff` - Show config differences ### Config Location Management -- `mcp-sync add-location [--name ]` - Register custom config file +- `mcp-sync add-location [--name ] [--mcp-key ]` - Register custom config file - `mcp-sync remove-location ` - Unregister config location - `mcp-sync list-locations` - Show all registered config paths +Use `--mcp-key` when the target config stores MCP servers under a key other than `mcpServers`. +If omitted, `mcpServers` is used. + ### Sync Operations - `mcp-sync sync` - Sync all registered configs - `mcp-sync sync --dry-run` - Preview changes without applying @@ -202,11 +205,18 @@ mcp-sync status "env": { "API_KEY": "your-api-key" } + }, + "http-server": { + "type": "http", + "url": "https://example.com/mcp" } } } ``` +For transport-aware configs, `type` can be set to `http` and `command` becomes optional. +`url`/`serverUrl` are supported for HTTP/SSE-style servers, and `mcp_key` defaults to `mcpServers` unless a client definition overrides it. + ## Development ### Requirements diff --git a/mcp_sync/client_definitions.json b/mcp_sync/client_definitions.json index 4afa360..916fda2 100644 --- a/mcp_sync/client_definitions.json +++ b/mcp_sync/client_definitions.json @@ -60,6 +60,17 @@ "config_format": "json", "mcp_key": "mcpServers" }, + "vscode-mcp": { + "name": "VSCode Copilot", + "description": "Vscode Copilot MCP settings", + "paths": { + "darwin": "~/Library/Application Support/Code/User/mcp.json", + "windows": "%APPDATA%/Code/User/mcp.json", + "linux": "~/.config/Code/User/mcp.json" + }, + "config_format": "json", + "mcp_key": "servers" + }, "cursor": { "name": "Cursor", "description": "Cursor AI code editor", diff --git a/mcp_sync/clients/repository.py b/mcp_sync/clients/repository.py index e9a18e4..ade9568 100644 --- a/mcp_sync/clients/repository.py +++ b/mcp_sync/clients/repository.py @@ -48,6 +48,7 @@ def _get_client_location( "config_type": "cli", "client_name": client_config.name, "description": client_config.description, + "mcp_key": client_config.mcp_key, } else: platform_name = self._get_platform_name() @@ -69,6 +70,7 @@ def _get_client_location( "config_type": "file", "client_name": client_config.name, "description": client_config.description, + "mcp_key": client_config.mcp_key, } return None diff --git a/mcp_sync/config/models.py b/mcp_sync/config/models.py index 8109946..b6c8391 100644 --- a/mcp_sync/config/models.py +++ b/mcp_sync/config/models.py @@ -1,21 +1,24 @@ """Pydantic models for configuration validation.""" -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class MCPServerConfig(BaseModel): """Configuration for an MCP server.""" - command: str = Field(..., description="Command to run the server") - args: list[str] = Field(default_factory=list, description="Additional arguments") - env: dict[str, str] = Field(default_factory=dict, description="Environment variables") - - @field_validator("command") - @classmethod - def validate_command(cls, v): - if not v or not v.strip(): + command: str | None = Field(default=None, description="Command to run the server") + args: list[str] | None = Field(default=None, description="Additional arguments") + env: dict[str, str] | None = Field(default=None, description="Environment variables") + type: str | None = Field(default=None, description="Transport type (e.g., http)") + url: str | None = Field(default=None, description="URL for HTTP/SSE transport") + serverUrl: str | None = Field(default=None, description="Antigravity-specific URL field") + + @model_validator(mode="after") + def validate_command(self): + # command is only required if transport type is not HTTP + if (self.type or "").strip().lower() != "http" and (not self.command or not self.command.strip()): raise ValueError("Command cannot be empty") - return v + return self class MCPClientConfig(BaseModel): @@ -29,6 +32,7 @@ class MCPClientConfig(BaseModel): cli_commands: dict[str, str] | None = Field( default=None, description="CLI commands for management" ) + mcp_key: str = Field(default="mcpServers", description="Key for MCP servers in config (e.g., mcpServers or servers)") @field_validator("config_type") @classmethod @@ -47,6 +51,7 @@ class LocationConfig(BaseModel): config_type: str = Field(default="file", description="Type of configuration") client_name: str | None = Field(default=None, description="Name of the client") description: str | None = Field(default=None, description="Description of the location") + mcp_key: str = Field(default="mcpServers", description="Key for MCP servers in config") class GlobalConfig(BaseModel): diff --git a/mcp_sync/config/settings.py b/mcp_sync/config/settings.py index 9560080..846bbe2 100644 --- a/mcp_sync/config/settings.py +++ b/mcp_sync/config/settings.py @@ -76,7 +76,7 @@ def get_locations_config(self) -> LocationsConfig: def _save_locations_config(self, config: LocationsConfig) -> None: """Save locations configuration.""" with open(self.locations_file, "w") as f: - json.dump(config.model_dump(), f, indent=2) + json.dump(config.model_dump(exclude_none=True), f, indent=2) def get_global_config(self) -> GlobalConfig: """Get global configuration.""" @@ -107,7 +107,7 @@ def get_global_config(self) -> GlobalConfig: def _save_global_config(self, config: GlobalConfig) -> None: """Save global configuration.""" with open(self.global_config_file, "w") as f: - json.dump(config.model_dump(), f, indent=2) + json.dump(config.model_dump(exclude_none=True), f, indent=2) def _migrate_server_config(self, config: dict) -> dict: """Migrate old server config format to new format.""" @@ -168,9 +168,9 @@ def get_client_definitions(self) -> ClientDefinitions: def _save_user_client_definitions(self, definitions: ClientDefinitions) -> None: """Save user client definitions.""" with open(self.user_client_definitions_file, "w") as f: - json.dump(definitions.model_dump(), f, indent=2) + json.dump(definitions.model_dump(exclude_none=True), f, indent=2) - def add_location(self, path: str, name: str | None = None) -> bool: + def add_location(self, path: str, name: str | None = None, mcp_key: str | None = None) -> bool: """Add a new location.""" config = self.get_locations_config() @@ -181,7 +181,12 @@ def add_location(self, path: str, name: str | None = None) -> bool: # Add new location location_name = name or Path(path).stem - new_location = LocationConfig(path=path, name=location_name, type="manual") + new_location = LocationConfig( + path=path, + name=location_name, + type="manual", + mcp_key=mcp_key or "mcpServers" + ) config.locations.append(new_location) self._save_locations_config(config) return True diff --git a/mcp_sync/main.py b/mcp_sync/main.py index db35073..bd7a9ed 100644 --- a/mcp_sync/main.py +++ b/mcp_sync/main.py @@ -41,6 +41,7 @@ def create_parser(): ) add_location_parser.add_argument("path", help="Path to config file") add_location_parser.add_argument("--name", help="Friendly name for the location") + add_location_parser.add_argument("--mcp-key", help="Key for MCP servers in config (e.g., servers)") remove_location_parser = subparsers.add_parser( "remove-location", help="Unregister config location" @@ -137,7 +138,7 @@ def main(): case "diff": handle_diff(sync_engine) case "add-location": - handle_add_location(settings, args.path, args.name) + handle_add_location(settings, args.path, args.name, args.mcp_key) case "remove-location": handle_remove_location(settings, args.path) case "list-locations": @@ -224,7 +225,8 @@ def handle_scan(repository): print(f" Status: {status}") if config_info["config"] and status == "found": - mcp_servers = config_info["config"].get("mcpServers", {}) + mcp_key = location.get("mcp_key", "mcpServers") + mcp_servers = config_info["config"].get(mcp_key, {}) if mcp_servers: print(f" Servers: {', '.join(mcp_servers.keys())}") else: @@ -287,11 +289,13 @@ def handle_diff(sync_engine): print(f" Master ({conflict['source']}): {conflict['master']}") -def handle_add_location(settings, path, name): - if settings.add_location(path, name): +def handle_add_location(settings, path, name, mcp_key): + if settings.add_location(path, name, mcp_key): print(f"Added location: {path}") if name: print(f" Name: {name}") + if mcp_key: + print(f" MCP Key: {mcp_key}") else: print(f"Location already exists: {path}") diff --git a/mcp_sync/sync.py b/mcp_sync/sync.py index 3259c25..76cf0df 100644 --- a/mcp_sync/sync.py +++ b/mcp_sync/sync.py @@ -88,7 +88,7 @@ def _build_master_server_list(self, global_only: bool, project_only: bool) -> di global_config = self.settings.get_global_config() global_servers = global_config.mcpServers for name, config in global_servers.items(): - master_servers[name] = {**config.model_dump(), "_source": "global"} + master_servers[name] = {**config.model_dump(exclude_none=True), "_source": "global"} # Add project servers (override global) if not global_only: @@ -151,7 +151,8 @@ def _sync_location( return # Extract current MCP servers - current_servers = current_config.get("mcpServers", {}) + mcp_key = location.get("mcp_key", "mcpServers") + current_servers = current_config.get(mcp_key, {}) # Build new server list new_servers = {} @@ -184,10 +185,10 @@ def _sync_location( # Update config new_config = current_config.copy() - new_config["mcpServers"] = new_servers + new_config[mcp_key] = new_servers # Check if changes are needed - if current_config.get("mcpServers", {}) != new_servers: + if current_config.get(mcp_key, {}) != new_servers: if not result.dry_run: # Ensure directory exists location_path.parent.mkdir(parents=True, exist_ok=True) @@ -327,15 +328,6 @@ def _sync_cli_location( for name, config in new_servers.items(): if name not in current_servers or current_servers[name] != config: # Check if this is a URL-based server (SSE/HTTP) - url = config.get("url") - if url: - # This is a URL-based server - skip for now - self.logger.info( - f"Skipping URL-based server {name} (URL: {url}) - " - "CLI client URL support not fully implemented" - ) - continue - command = config.get("command", []) args = config.get("args", []) or [] env_vars = config.get("env", {}) @@ -377,7 +369,7 @@ def get_server_status(self) -> dict[str, Any]: # Global servers global_config = self.settings.get_global_config() status["global_servers"] = { - name: config.model_dump() for name, config in global_config.mcpServers.items() + name: config.model_dump(exclude_none=True) for name, config in global_config.mcpServers.items() } # Project servers @@ -411,7 +403,8 @@ def get_server_status(self) -> dict[str, Any]: location_path = Path(location["path"]) config = self._read_json_config(location_path) if config is not None: - status["location_servers"][location["name"]] = config.get("mcpServers", {}) + mcp_key = location.get("mcp_key", "mcpServers") + status["location_servers"][location["name"]] = config.get(mcp_key, {}) else: status["location_servers"][location["name"]] = "error" @@ -611,15 +604,8 @@ def vacuum_configs( result.skipped_servers.append(server_name) continue - # Skip URL-based servers as they're not supported by MCPServerConfig + # Support URL-based servers config_data = server_info["config"].copy() - if "url" in config_data and "command" not in config_data: - self.logger.info( - f"Skipping URL-based server {server_name} - " - "not supported by current config model" - ) - result.skipped_servers.append(server_name) - continue # Normalize command format for MCPServerConfig validation # Command should be a string, args should be an array @@ -630,11 +616,11 @@ def vacuum_configs( config_data["command"] = command_list[0] config_data["args"] = command_list[1:] + config_data.get("args", []) - # Ensure args and env are properly formatted - if "args" not in config_data: - config_data["args"] = [] - if "env" not in config_data: - config_data["env"] = {} + # Ensure args and env are only present if they have values + if not config_data.get("args"): + config_data.pop("args", None) + if not config_data.get("env"): + config_data.pop("env", None) try: server_config = MCPServerConfig(**config_data) diff --git a/tests/test_config_models.py b/tests/test_config_models.py index d69e347..3911611 100644 --- a/tests/test_config_models.py +++ b/tests/test_config_models.py @@ -64,8 +64,8 @@ def test_minimal_valid_configuration(self): """Test creating MCPServerConfig with minimal required fields.""" config = MCPServerConfig(command="echo") assert config.command == "echo" - assert config.args == [] - assert config.env == {} + assert config.args is None + assert config.env is None def test_command_field_validation_non_empty(self): """Test that command field cannot be empty.""" @@ -85,10 +85,31 @@ def test_command_field_validation_whitespace_only(self): assert error["type"] == "value_error" assert "Command cannot be empty" in str(exc_info.value) + def test_http_type_allows_missing_command(self): + """Test that HTTP transport does not require a command.""" + config = MCPServerConfig(type="http", url="https://example.com/mcp") + assert config.type == "http" + assert config.command is None + + def test_http_type_allows_whitespace_command(self): + """Test that HTTP transport ignores empty command values.""" + config = MCPServerConfig(type="http", command=" ", url="https://example.com/mcp") + assert config.type == "http" + assert config.command == " " + + def test_non_http_type_requires_command(self): + """Test that non-HTTP transport still requires a non-empty command.""" + with pytest.raises(ValidationError) as exc_info: + MCPServerConfig(type="stdio", command=" ") + + error = exc_info.value.errors()[0] + assert error["type"] == "value_error" + assert "Command cannot be empty" in str(exc_info.value) + def test_optional_args_field(self): """Test optional args field handling.""" config = MCPServerConfig(command="test") - assert config.args == [] + assert config.args is None config_with_args = MCPServerConfig(command="test", args=["--verbose"]) assert config_with_args.args == ["--verbose"] @@ -96,7 +117,7 @@ def test_optional_args_field(self): def test_optional_env_field(self): """Test optional env field handling.""" config = MCPServerConfig(command="test") - assert config.env == {} + assert config.env is None config_with_env = MCPServerConfig(command="test", env={"KEY": "value"}) assert config_with_env.env == {"KEY": "value"}