diff --git a/README.md b/README.md index d0a457c..caed922 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,26 @@ ucode configure --agents claude --mcp system.ai.slack `--mcp` also works without `--agents` for MCP-only clients (it configures just the workspace, then registers the servers); pass a comma-separated list to register several at once. +#### Add servers without replacing existing ones + +`ucode configure mcp` **replaces** the registered MCP servers with your selection — anything +outside a `--location`/`--services` scope (or left unchecked in the picker) is removed. To +**add** servers while leaving everything already configured in place, use `ucode mcp add`: + +```bash +# Register a whole schema's services, keeping any servers already configured. +ucode mcp add --location system.ai + +# Register just a subset (same name rules as `configure mcp --services`). +ucode mcp add --services system.ai.slack,system.ai.github + +# No arguments launches the same interactive picker, but never removes servers. +ucode mcp add +``` + +`ucode mcp add` takes the same `--location` and `--services` options as `ucode configure mcp`; +the only difference is that it never removes servers outside the selection. + ### Skills (optional) Configure Unity Catalog Skills for your coding tools with `ucode configure skills`: @@ -243,6 +263,8 @@ pick the new config up on their next ucode run. | `ucode configure --skip-validate` | Write configs without sending a test message through each agent | | `ucode configure --agents claude,codex,pi --skip-unavailable` | Configure the requested agents that are available; skip the rest with a warning | | `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command | +| `ucode mcp add --location system.ai` | Register a schema's MCP servers, keeping any already configured (additive; never removes) | +| `ucode mcp add --services system.ai.slack` | Register specific MCP server(s) without removing existing ones | | `ucode configure skills` | Register the skills MCP connection (utility tools only); no skills download | | `ucode configure skills --location main.default [--path ]` | Download a schema's skills to disk (under ``, or your home dir) and register a schema-less skills MCP connection | | `ucode configure skills --location main.default --skill my-skill` | Download only the named skill(s) from a schema (comma-separated for several) | diff --git a/src/ucode/cli.py b/src/ucode/cli.py index c2e38f4..3ddb881 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -84,6 +84,7 @@ from ucode.mcp import ( MCP_CLIENTS, SKILLS_MCP_KIND, + add_mcp_command, apply_managed_mcp_servers, apply_managed_skills, configure_mcp_command, @@ -1056,6 +1057,43 @@ def _version_callback(value: bool) -> None: raise typer.Exit() +@mcp_app.command("add") +def mcp_add( + location: Annotated[ + str | None, + typer.Option( + "--location", + help="Non-interactive: register the MCP services in the given Unity Catalog " + "`.` (e.g. `system.ai`) and exit without showing the picker. " + "Servers already configured outside this location are kept.", + ), + ] = None, + services: Annotated[ + str | None, + typer.Option( + "--services", + help="Register exactly this comma-separated subset of MCP services. Full names like " + "`system.ai.github` work on their own; bare short names like `github` need --location " + "to locate them. Omit --services to register the whole --location schema.", + ), + ] = None, +) -> None: + """Add Databricks MCP servers to installed coding tools. + + Like `ucode configure mcp`, but purely additive: it never removes MCP servers + that are already configured, only registers new ones. + """ + selected = None if services is None else {s.strip() for s in services.split(",") if s.strip()} + try: + add_mcp_command(location=location, services=selected) + except RuntimeError as exc: + print_err(str(exc)) + raise typer.Exit(1) from None + except KeyboardInterrupt: + print_err("Interrupted.") + raise typer.Exit(130) from None + + @mcp_app.command("web-search") def mcp_web_search_cmd() -> None: """Run the web_search MCP server over stdio. Invoked as a subprocess by Claude Code.""" diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index a3db790..5602d4d 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -1739,15 +1739,43 @@ def setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[ return workspace, profile, clients +def _union_missing(base: list[dict], selected: list[dict]) -> list[dict]: + """Return ``selected`` followed by every ``base`` server whose name isn't + already in it. Used by ``ucode mcp add`` so registering new servers never + removes ones that are already configured (append semantics).""" + have = _servers_by_name(selected) + extra = [s for s in base if (_server_name(s) or "") not in have] + return [*selected, *extra] + + +def add_mcp_command( + location: str | None = None, + services: set[str] | None = None, +) -> int: + """`ucode mcp add`: register Databricks MCP servers WITHOUT removing any that + are already configured. + + Uses the same discovery and options as `configure mcp` — the interactive + picker, or the non-interactive `--location`/`--services` paths — but is purely + additive: unlike `configure mcp`, it never removes servers outside the + selection.""" + return configure_mcp_command(location=location, services=services, append=True) + + def configure_mcp_command( location: str | None = None, services: set[str] | None = None, *, exclude_sources: set[str] | None = None, + append: bool = False, ) -> int: """Interactive MCP picker. ``exclude_sources`` hides search sources the caller can't use — `ucode setup` passes ``{"apps"}`` because a managed config can't carry an app's off-workspace - host, so an app picked here would be silently dropped from the published config.""" + host, so an app picked here would be silently dropped from the published config. + + ``append`` (used by `ucode mcp add`) makes the command purely additive: the + final server list is unioned with the already-configured servers, so nothing + outside the current selection is removed.""" if services is not None and location is None: # `--services` works standalone with full names (`system.ai.github`): the # `.` to configure is derived from them. Bare short names @@ -1766,13 +1794,19 @@ def configure_mcp_command( ) location = next(iter(schemas)) state = load_state() - workspace, profile, clients = setup_mcp_clients(state, "MCP Servers") + workspace, profile, clients = setup_mcp_clients( + state, "Add MCP Servers" if append else "MCP Servers" + ) original_mcp_servers_for_location: list[dict] = list(state.get("mcp_servers") or []) if location is not None: working_mcp_servers = _resolve_location_mcp_servers( workspace, profile, clients, location, original_mcp_servers_for_location, services ) + if append: + working_mcp_servers = _union_missing( + original_mcp_servers_for_location, working_mcp_servers + ) changed = apply_mcp_server_changes( original_mcp_servers_for_location, working_mcp_servers, @@ -1866,6 +1900,9 @@ def configure_mcp_command( ) working_names.add(entry_name) + if append: + working_mcp_servers = _union_missing(original_mcp_servers, working_mcp_servers) + changed = apply_mcp_server_changes( original_mcp_servers, working_mcp_servers, @@ -1878,7 +1915,8 @@ def configure_mcp_command( state["mcp_servers"] = working_mcp_servers save_state(state) added = sorted(working_names - set(original_by_name)) - removed = sorted(set(original_by_name) - working_names) + # `add` never removes; the union above re-keeps unselected servers. + removed = [] if append else sorted(set(original_by_name) - working_names) print_success(_mcp_change_summary(added, removed, clients)) elif not selections and not original_mcp_servers: # User submitted the picker without toggling anything --> make it clear nothing was selected diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2852f47..002a0f8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1806,6 +1806,92 @@ def test_existing_entry_gets_reconfigured_for_newly_added_clients(self, monkeypa ] +class TestAddMcpCommand: + """`ucode mcp add` (append) registers new servers without removing existing ones.""" + + def test_keeps_servers_outside_location(self, monkeypatch): + """Unlike `configure mcp --location`, `mcp add --location` preserves any + server outside the location instead of removing it.""" + saved_states: list[dict] = [] + configured: list[tuple[str, str, str]] = [] + removed: list[tuple[str, str]] = [] + outside_entry = { + "name": "databricks-sql", + "url": f"{WS}/api/2.0/mcp/sql", + "auth": "proxy", + "clients": ["claude"], + } + _stub_location_base( + monkeypatch, + {**CLAUDE_STATE, "mcp_servers": [outside_entry]}, + ) + monkeypatch.setattr( + mcp, + "list_mcp_services", + lambda workspace, token, parent: (["system.ai.github"], None), + ) + monkeypatch.setattr( + mcp, + "configure_client_mcp_server", + lambda client, name, url, *a, **kw: configured.append((client, name, url)) or [], + ) + monkeypatch.setattr( + mcp, + "remove_client_mcp_server", + lambda client, name: removed.append((client, name)) or [], + ) + monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state.copy())) + + assert mcp.add_mcp_command(location="system.ai") == 0 + + # Nothing is removed; the new service is added and the outside one kept. + assert removed == [] + assert [c[1] for c in configured] == ["system-ai-github"] + assert saved_states[-1]["mcp_servers"] == [ + { + "name": "system-ai-github", + "url": f"{WS}/ai-gateway/mcp-services/system.ai.github", + "auth": "proxy", + "clients": ["claude"], + }, + outside_entry, + ] + + def test_services_subset_keeps_others_in_location(self, monkeypatch): + """`mcp add --services` registers the named subset while leaving other + already-registered services in the same schema untouched.""" + saved_states: list[dict] = [] + removed: list[tuple[str, str]] = [] + existing = { + "name": "system-ai-slack", + "url": f"{WS}/ai-gateway/mcp-services/system.ai.slack", + "auth": "proxy", + "clients": ["claude"], + } + _stub_location_base( + monkeypatch, + {**CLAUDE_STATE, "mcp_servers": [existing]}, + ) + monkeypatch.setattr( + mcp, + "list_mcp_services", + lambda workspace, token, parent: (["system.ai.github", "system.ai.slack"], None), + ) + monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: []) + monkeypatch.setattr( + mcp, + "remove_client_mcp_server", + lambda client, name: removed.append((client, name)) or [], + ) + monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state.copy())) + + assert mcp.add_mcp_command(location="system.ai", services={"github"}) == 0 + + assert removed == [] + names = [s["name"] for s in saved_states[-1]["mcp_servers"]] + assert names == ["system-ai-github", "system-ai-slack"] + + class TestConfigureMcpServicesSubset: """`--location --services a,b,...` configures exactly the named subset."""