Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,13 @@ cd mcp-sync
- `mcp-sync diff` - Show config differences

### Config Location Management
- `mcp-sync add-location <path> [--name <alias>]` - Register custom config file
- `mcp-sync add-location <path> [--name <alias>] [--mcp-key <key>]` - Register custom config file
- `mcp-sync remove-location <path>` - 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
Expand Down Expand Up @@ -202,11 +205,18 @@ mcp-sync status
"env": {
"API_KEY": "your-api-key"
}
},
"http-server": {
"type": "http",
"url": "https://example.com/mcp"
Comment on lines +208 to +211
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON example here is not valid: the custom-server object is missing a comma after the env block, braces/indentation are mismatched, and the http-server object isn’t properly aligned/closed. This will confuse users copying the example; please fix the snippet to be valid JSON.

Suggested change
},
"http-server": {
"type": "http",
"url": "https://example.com/mcp"
},
"http-server": {
"type": "http",
"url": "https://example.com/mcp"

Copilot uses AI. Check for mistakes.
}
}
}
```

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
Expand Down
11 changes: 11 additions & 0 deletions mcp_sync/client_definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@
"config_format": "json",
"mcp_key": "mcpServers"
},
"vscode-mcp": {
"name": "VSCode Copilot",
"description": "Vscode Copilot MCP settings",
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor capitalization inconsistency in the description: “Vscode Copilot MCP settings” should use consistent product casing (e.g., “VS Code” / “VSCode”).

Suggested change
"description": "Vscode Copilot MCP settings",
"description": "VS Code Copilot MCP settings",

Copilot uses AI. Check for mistakes.
"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",
Expand Down
2 changes: 2 additions & 0 deletions mcp_sync/clients/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
25 changes: 15 additions & 10 deletions mcp_sync/config/models.py
Original file line number Diff line number Diff line change
@@ -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()):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow URL-only servers to bypass command validation

The new vacuum path now attempts to import URL-based servers, but this validator still rejects any entry without command unless type is explicitly "http". In practice, this codebase already identifies remote transports by URL presence (for example, the CLI sync path checks url), so configs like { "url": "..." } will now fail with Command cannot be empty and never import, which breaks the advertised direct HTTP support. Treat url/serverUrl as remote transport indicators (or normalize type) before enforcing a command.

Useful? React with 👍 / 👎.

raise ValueError("Command cannot be empty")
Comment on lines +18 to 20
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For type="http", command is optional, but there is no validation that an HTTP endpoint is actually provided (url or serverUrl). As-is, MCPServerConfig(type="http") will validate successfully but is not usable downstream. Consider validating that at least one of url/serverUrl is set when type is HTTP (and possibly normalizing/validating those fields).

Suggested change
# 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")
transport_type = (self.type or "").strip().lower()
command = (self.command or "").strip()
url = (self.url or "").strip()
server_url = (self.serverUrl or "").strip()
# command is only required if transport type is not HTTP
if transport_type != "http" and not command:
raise ValueError("Command cannot be empty")
# HTTP transport requires at least one endpoint to be configured
if transport_type == "http" and not url and not server_url:
raise ValueError("HTTP transport requires either 'url' or 'serverUrl'")

Copilot uses AI. Check for mistakes.
return v
return self


class MCPClientConfig(BaseModel):
Expand All @@ -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
Expand All @@ -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):
Expand Down
15 changes: 10 additions & 5 deletions mcp_sync/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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()

Expand All @@ -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"
Comment on lines +185 to +188
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocationConfig already defaults mcp_key to "mcpServers", so forcing mcp_key=mcp_key or "mcpServers" makes the value always persisted even when the user didn’t specify it (and defeats the intent of exclude_none=True to keep configs minimal). Consider passing mcp_key=mcp_key and letting the model default apply when it’s omitted.

Suggested change
path=path,
name=location_name,
type="manual",
mcp_key=mcp_key or "mcpServers"
path=path,
name=location_name,
type="manual",
mcp_key=mcp_key

Copilot uses AI. Check for mistakes.
)
config.locations.append(new_location)
self._save_locations_config(config)
return True
Expand Down
12 changes: 8 additions & 4 deletions mcp_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")

Expand Down
42 changes: 14 additions & 28 deletions mcp_sync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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", {})
Comment on lines 330 to 333
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude serverUrl transports from CLI command sync

This add/update block now processes all changed servers as command-based entries, but newly supported serverUrl configs can reach it without any command. That yields an empty command, gets skipped, and still marks the location as updated, so repeated syncs keep reporting changes without applying them. Add a guard for serverUrl (or type == "http") before command assembly so remote transports are consistently excluded from CLI command sync.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Comment on lines +607 to 610
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vacuum_configs now claims to “Support URL-based servers”, but it still discovers servers only under the hard-coded mcpServers key earlier in this method and it registers discovered client locations without propagating their mcp_key. This means configs that store servers under a custom key (e.g. servers) won’t be vacuumed/imported correctly. Consider using each location’s mcp_key when reading servers and passing mcp_key into settings.add_location(...) when registering discovered clients.

Copilot uses AI. Check for mistakes.
# Command should be a string, args should be an array
Expand All @@ -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)
Expand Down
29 changes: 25 additions & 4 deletions tests/test_config_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -85,18 +85,39 @@ 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 == " "
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name/docstring says HTTP transport “ignores empty command values”, but the assertion checks that whitespace is preserved (" "). If the intended behavior is to ignore empty/whitespace commands for HTTP, the model validator should normalize whitespace-only commands to None (or the test/docstring should be updated to reflect that whitespace is allowed and retained).

Suggested change
assert config.command == " "
assert config.command is None

Copilot uses AI. Check for mistakes.

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"]

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"}
Expand Down
Loading