Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ cd mcp-sync
- `mcp-sync remove-server <name> --scope <global|project>` - Remove server with inline scope
- `mcp-sync list-servers` - Show all managed servers

### Migration
- `mcp-sync vacuum` - Import MCP servers from discovered configs
- `--auto-resolve <first|last>` choose conflict resolution automatically
- `--skip-existing` avoid overwriting servers already in global config

**Adding Servers**: When adding a server, you need to provide:
- **Command**: The executable to run (e.g., `python`, `npx`, `node`)
- **Arguments**: Command-line arguments (comma-separated, optional)
Expand Down Expand Up @@ -223,6 +228,17 @@ uv run ruff format . # Formatting
uv run pytest # Tests (when available)
```

### Running Tests
Tests require the package to be on `PYTHONPATH`. Either install it in editable mode:
```bash
uv pip install -e .
uv run pytest
```
or set `PYTHONPATH` manually when invoking pytest:
```bash
PYTHONPATH=$PWD uv run pytest
```

## License

[License details here]
Expand Down
16 changes: 13 additions & 3 deletions mcp_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def create_parser():
vacuum_parser.add_argument(
"--auto-resolve", choices=["first", "last"], help="Auto-resolve conflicts without prompts"
)
vacuum_parser.add_argument(
"--skip-existing",
action="store_true",
help="Do not overwrite servers already in global config",
)

# Project management
subparsers.add_parser("init", help="Create project .mcp.json")
Expand Down Expand Up @@ -143,7 +148,7 @@ def main():
case "list-servers":
handle_list_servers(sync_engine)
case "vacuum":
handle_vacuum(sync_engine)
handle_vacuum(sync_engine, args.auto_resolve, args.skip_existing)
case "init":
handle_init()
case "template":
Expand Down Expand Up @@ -516,10 +521,10 @@ def handle_init():
print("Created .mcp.json in current directory")


def handle_vacuum(sync_engine):
def handle_vacuum(sync_engine, auto_resolve: str | None, skip_existing: bool):
"""Import existing MCP configs from all discovered locations"""
try:
result = sync_engine.vacuum_configs()
result = sync_engine.vacuum_configs(auto_resolve=auto_resolve, skip_existing=skip_existing)

if not result.imported_servers and not result.conflicts:
print("No MCP servers found in any discovered locations.")
Expand All @@ -537,6 +542,11 @@ def handle_vacuum(sync_engine):
for conflict in result.conflicts:
print(f" {conflict['server']} - kept version from {conflict['chosen_source']}")

if result.skipped_servers:
print(f"\nSkipped {len(result.skipped_servers)} existing servers:")
for name in result.skipped_servers:
print(f" {name}")

print("\nVacuum complete! Run 'mcp-sync sync' to standardize all configs.")

except (KeyboardInterrupt, EOFError):
Expand Down
48 changes: 32 additions & 16 deletions mcp_sync/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class VacuumResult:
imported_servers: dict[str, str] # server_name -> source_location
conflicts: list[dict[str, Any]] # resolved conflicts
errors: list[dict[str, str]]
skipped_servers: list[str]


class SyncEngine:
Expand Down Expand Up @@ -346,9 +347,11 @@ def _write_json_config(self, path: Path, config: dict[str, Any]):
with open(path, "w") as f:
json.dump(config, f, indent=2)

def vacuum_configs(self) -> "VacuumResult":
def vacuum_configs(
self, auto_resolve: str | None = None, skip_existing: bool = False
) -> "VacuumResult":
"""Import existing MCP configs from all discovered locations"""
result = VacuumResult(imported_servers={}, conflicts=[], errors=[])
result = VacuumResult(imported_servers={}, conflicts=[], errors=[], skipped_servers=[])

# Get all locations (excluding project .mcp.json files)
locations = self.config_manager.get_locations()
Expand All @@ -366,13 +369,18 @@ def vacuum_configs(self) -> "VacuumResult":
if server_name in discovered_servers:
# Conflict found - need to resolve
existing = discovered_servers[server_name]
choice = self._resolve_conflict(
server_name,
existing["config"],
existing["source"],
server_config,
location["name"],
)
if auto_resolve == "first":
choice = "existing"
elif auto_resolve == "last":
choice = "new"
else:
choice = self._resolve_conflict(
server_name,
existing["config"],
existing["source"],
server_config,
location["name"],
)

if choice == "new":
discovered_servers[server_name] = {
Expand Down Expand Up @@ -415,13 +423,18 @@ def vacuum_configs(self) -> "VacuumResult":
if server_name in discovered_servers:
# Conflict found - need to resolve
existing = discovered_servers[server_name]
choice = self._resolve_conflict(
server_name,
existing["config"],
existing["source"],
server_config,
location["name"],
)
if auto_resolve == "first":
choice = "existing"
elif auto_resolve == "last":
choice = "new"
else:
choice = self._resolve_conflict(
server_name,
existing["config"],
existing["source"],
server_config,
location["name"],
)

if choice == "new":
discovered_servers[server_name] = {
Expand Down Expand Up @@ -454,6 +467,9 @@ def vacuum_configs(self) -> "VacuumResult":
global_config = self.config_manager.get_global_config()

for server_name, server_info in discovered_servers.items():
if skip_existing and server_name in global_config.get("mcpServers", {}):
result.skipped_servers.append(server_name)
continue
global_config["mcpServers"][server_name] = server_info["config"]
result.imported_servers[server_name] = server_info["source"]

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ target-version = "py312"
select = ["E", "F", "W", "I", "N", "UP", "S", "B", "A", "C4", "PT"]
ignore = ["S101"] # Allow assert statements

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S108"] # Allow insecure temp file usage in tests

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
34 changes: 34 additions & 0 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,37 @@ def test_vacuum_saves_to_global_config():
global_config = config.get_global_config()
assert "test-server" in global_config["mcpServers"]
assert global_config["mcpServers"]["test-server"]["command"] == ["echo", "test"]


def test_vacuum_auto_resolve_first():
"""Conflicts should be resolved automatically keeping first seen version"""
cli_loc = {"path": "cli:cli", "name": "cli", "config_type": "cli"}
file_loc = {"path": "/tmp/f.json", "name": "file", "config_type": "file"}

config = DummyCLIConfig([cli_loc, file_loc])
config.set_cli_servers("cli", {"srv": {"command": ["echo", "cli"]}})

engine = SyncEngine(config)
with patch.object(engine, "_read_json_config") as mock_read:
mock_read.return_value = {"mcpServers": {"srv": {"command": ["echo", "file"]}}}
with patch.object(engine, "_resolve_conflict") as mock_resolve:
result = engine.vacuum_configs(auto_resolve="first")
mock_resolve.assert_not_called()
assert result.imported_servers["srv"] == "cli"
assert result.conflicts[0]["chosen_source"] == "cli"
assert result.conflicts[0]["rejected_source"] == "file"


def test_vacuum_skip_existing():
"""Existing global servers are not overwritten when skip_existing is True"""
cli_loc = {"path": "cli:code", "name": "code", "config_type": "cli"}
config = DummyCLIConfig([cli_loc])
config.set_cli_servers("code", {"existing": {"command": ["echo", "new"]}})
config.set_global_config({"mcpServers": {"existing": {"command": ["echo", "old"]}}})

engine = SyncEngine(config)
result = engine.vacuum_configs(skip_existing=True)

assert "existing" in result.skipped_servers
assert "existing" not in result.imported_servers
assert config.get_global_config()["mcpServers"]["existing"]["command"] == ["echo", "old"]