From c641628c5e4dfc9531069fac6d19fa60b6a1c73d Mon Sep 17 00:00:00 2001 From: Ztripez Date: Thu, 19 Jun 2025 20:07:56 +0200 Subject: [PATCH 1/2] feat: improve vacuum and docs --- README.md | 16 ++++++++++++++++ mcp_sync/main.py | 16 +++++++++++++--- mcp_sync/sync.py | 48 ++++++++++++++++++++++++++++++---------------- tests/test_sync.py | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f45f965..e18ce60 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ cd mcp-sync - `mcp-sync remove-server --scope ` - 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 ` 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) @@ -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] diff --git a/mcp_sync/main.py b/mcp_sync/main.py index a99f544..eafc0df 100644 --- a/mcp_sync/main.py +++ b/mcp_sync/main.py @@ -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") @@ -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": @@ -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.") @@ -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): diff --git a/mcp_sync/sync.py b/mcp_sync/sync.py index 6787228..fbf35f4 100644 --- a/mcp_sync/sync.py +++ b/mcp_sync/sync.py @@ -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: @@ -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() @@ -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] = { @@ -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] = { @@ -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"] diff --git a/tests/test_sync.py b/tests/test_sync.py index cfaf194..1033d26 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -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"] From c30f5c354560eabf0e147a7cdace0dc6b9316692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ztripez=20von=20Mat=C3=A9rn?= Date: Thu, 19 Jun 2025 21:06:55 +0200 Subject: [PATCH 2/2] fix: Disable S108 ruff check for test files --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c775f6c..a4a687e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"