From 044b33f85f08cc5923d556e839c9032811d4409c Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Tue, 18 Aug 2026 00:43:25 +0000 Subject: [PATCH 1/2] mcp: add `ucode mcp remove` to unregister configured MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the `ucode mcp ` set alongside `ucode mcp add`. `ucode mcp remove` shows the MCP servers you currently have configured — each with the coding tools it's registered on — and unregisters the ones you select from those tools. It's interactive only (no flags), never touches skills connections (owned by `configure skills`), and needs no Databricks login: unregistering a server is purely local, so it works even when the workspace token has expired (setup_mcp_clients grows a `require_auth` toggle for this). --- README.md | 13 ++++++ src/ucode/cli.py | 19 ++++++++ src/ucode/mcp.py | 90 +++++++++++++++++++++++++++++++++++--- tests/test_mcp.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 17a4589..5a802b6 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,18 @@ the only difference is that it never removes servers outside the selection. In t picker, servers you already have configured are shown as `(already configured)` and can't be toggled off — you only pick new ones to add. +#### Remove configured servers + +To unregister servers you've already configured, use `ucode mcp remove`: + +```bash +ucode mcp remove +``` + +It shows the servers you currently have configured — each with the coding tools it's registered +on — and removes the ones you select from those tools. Skills connections (managed by +`ucode configure skills`) aren't shown, and it needs no Databricks login. + ### Skills (optional) Configure Unity Catalog Skills for your coding tools with `ucode configure skills`: @@ -267,6 +279,7 @@ pick the new config up on their next ucode run. | `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 mcp remove` | Interactively unregister configured MCP servers from your coding tools | | `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 2d2f49c..684ffa5 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -91,6 +91,7 @@ configure_mcp_command, configure_skills_mcp_command, purge_cross_workspace_mcp_residue, + remove_mcp_command, revert_mcp_configs, ) from ucode.skills_download import ( @@ -1097,6 +1098,24 @@ def mcp_add( raise typer.Exit(130) from None +@mcp_app.command("remove") +def mcp_remove() -> None: + """Remove configured Databricks MCP servers from your coding tools. + + Interactive: shows the servers you currently have configured and unregisters the + ones you select. Skills connections (managed by `ucode configure skills`) are not + shown. Needs no Databricks login. + """ + try: + remove_mcp_command() + 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 24ace6c..83ca6f4 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -1712,11 +1712,21 @@ def prompt_for_mcp_search_sources(exclude_sources: set[str] | None = None) -> se return {str(value) for value in selection} -def setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[str]]: +def setup_mcp_clients( + state: dict, + section: str, + *, + require_auth: bool = True, + action_note: str = "Configuring for", +) -> tuple[str, str | None, list[str]]: """Validate the workspace, resolve configured MCP clients, and prepare auth. Returns ``(workspace, profile, clients)`` and prints the section header, the - "Configuring for" note, and a warning per configured-but-uninstalled client. + ``action_note`` line, and a warning per configured-but-uninstalled client. + + ``require_auth`` forces a Databricks login (needed to register a server); the + removal path passes ``False`` since unregistering a server is purely local and + should work even when the workspace token has expired. """ workspace = state.get("workspace") if not workspace: @@ -1742,12 +1752,13 @@ def setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[ ] profile = state.get("profile") - apply_pat_environment(state) - ensure_databricks_auth(workspace, profile) + if require_auth: + apply_pat_environment(state) + ensure_databricks_auth(workspace, profile) print_section(section) client_names = ", ".join(str(MCP_CLIENTS[client]["display"]) for client in clients) - print_note(f"Configuring for: {client_names}") + print_note(f"{action_note}: {client_names}") for client in missing_clients: print_warning( f"{MCP_CLIENTS[client]['display']} is configured in ucode but not installed; " @@ -1966,6 +1977,75 @@ def _mcp_change_summary(added: list[str], removed: list[str], clients: list[str] return f"{summary} {noun} across {client_names}" if client_names else f"{summary} {noun}" +def _prompt_for_mcp_removal(servers: list[dict]) -> list[str] | None: + """Checklist of already-configured MCP servers to remove. Each item shows the + registered name and the tools it's currently on. Returns the selected server + names, ``None`` if cancelled (Ctrl-C), or ``[]`` if nothing was checked.""" + choices: list[questionary.Choice | questionary.Separator] = [] + for server in servers: + name = _server_name(server) + if not name: + continue + on_clients = [str(MCP_CLIENTS[c]["display"]) for c in _mcp_server_clients(server)] + title = f"{name} ({', '.join(on_clients)})" if on_clients else name + choices.append(questionary.Choice(title=title, value=name, checked=False)) + if not choices: + return [] + selection = _scrolling_checkbox( + "Remove MCP:", + choices=choices, + style=_picker_style(), + instruction="(space to toggle, ctrl-a all, enter to remove, type to filter)", + ).ask() + if selection is None: + return None + return [str(value) for value in selection] + + +def remove_mcp_command() -> int: + """`ucode mcp remove`: interactively unregister configured MCP servers. + + Shows the servers currently configured (skills connections excluded — they're + owned by `configure skills`) and removes the ones you select from every coding + tool they're registered on. It never adds or reconfigures anything, and needs no + Databricks auth.""" + state = load_state() + workspace, profile, clients = setup_mcp_clients( + state, "Remove MCP Servers", require_auth=False, action_note="Removing from" + ) + + original_mcp_servers = list(state.get("mcp_servers") or []) + removable = [s for s in original_mcp_servers if s.get("kind") != SKILLS_MCP_KIND] + if not removable: + print_note("No MCP servers are configured to remove.") + return 0 + + selection = _prompt_for_mcp_removal(removable) + if selection is None: + return 0 + if not selection: + print_note("No MCP servers selected.") + return 0 + remove_names = set(selection) + + working_mcp_servers = [ + s for s in original_mcp_servers if (_server_name(s) or "") not in remove_names + ] + changed = apply_mcp_server_changes( + original_mcp_servers, + working_mcp_servers, + clients, + workspace, + profile, + use_pat=bool(state.get("use_pat")), + ) + if changed or original_mcp_servers != working_mcp_servers: + state["mcp_servers"] = working_mcp_servers + save_state(state) + print_success(_mcp_change_summary([], sorted(remove_names), clients)) + return 0 + + def _merge_clients(prior: list[str] | None, new: list[str]) -> list[str]: """Order-preserving union of a prior client list with newly-configured ones.""" prior = list(prior or []) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f243a63..18f4e3e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -386,6 +386,33 @@ def test_additive_picker_shows_configured_servers_as_disabled(self): # A not-yet-configured server stays an addable, toggleable choice. assert choices_by_title["Databricks SQL"].disabled is None + def test_removal_picker_lists_configured_servers_with_their_clients(self, monkeypatch): + checkbox_calls: list[dict] = [] + + class FakePrompt: + def ask(self): + return ["system-ai-github"] + + def fake_checkbox(*args, **kwargs): + checkbox_calls.append(kwargs) + return FakePrompt() + + monkeypatch.setattr(mcp, "_scrolling_checkbox", fake_checkbox) + + result = mcp._prompt_for_mcp_removal( + [ + {"name": "system-ai-github", "url": f"{WS}/x", "clients": ["claude", "codex"]}, + {"name": "", "url": f"{WS}/y", "clients": ["claude"]}, # unnamed: skipped + ] + ) + + assert result == ["system-ai-github"] + choices = checkbox_calls[0]["choices"] + # The unnamed server is skipped; the named one shows the tools it's on. + assert [c.title for c in choices] == ["system-ai-github (Claude Code, Codex)"] + assert [c.value for c in choices] == ["system-ai-github"] + assert all(c.checked is False for c in choices) + def test_picker_keeps_databricks_sql_when_nothing_discovered(self): choices = mcp.build_mcp_picker_choices([], [], [], []) assert [choice.title for choice in choices] == ["Databricks SQL"] @@ -1920,6 +1947,87 @@ def test_empty_services_is_a_noop(self, monkeypatch): assert called == [] +class TestRemoveMcpCommand: + """`ucode mcp remove` interactively unregisters configured MCP servers.""" + + GITHUB = { + "name": "system-ai-github", + "url": f"{WS}/ai-gateway/mcp-services/system.ai.github", + "auth": "proxy", + "clients": ["claude"], + } + SQL = { + "name": "databricks-sql", + "url": f"{WS}/api/2.0/mcp/sql", + "auth": "proxy", + "clients": ["claude"], + } + + def test_removes_only_selected_servers(self, monkeypatch): + saved_states: list[dict] = [] + removed: list[tuple[str, str]] = [] + _stub_location_base( + monkeypatch, + {**CLAUDE_STATE, "mcp_servers": [self.GITHUB, self.SQL]}, + ) + monkeypatch.setattr(mcp, "_prompt_for_mcp_removal", lambda servers: ["system-ai-github"]) + 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.remove_mcp_command() == 0 + + assert removed == [("claude", "system-ai-github")] + assert [s["name"] for s in saved_states[-1]["mcp_servers"]] == ["databricks-sql"] + + def test_cancel_makes_no_changes(self, monkeypatch): + saved_states: list[dict] = [] + _stub_location_base(monkeypatch, {**CLAUDE_STATE, "mcp_servers": [self.SQL]}) + monkeypatch.setattr(mcp, "_prompt_for_mcp_removal", lambda servers: None) + monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state)) + + assert mcp.remove_mcp_command() == 0 + assert saved_states == [] + + def test_skills_connection_is_not_offered(self, monkeypatch): + offered: dict[str, list[str]] = {} + skills = { + "name": mcp.SKILLS_MCP_SERVER_NAME, + "kind": mcp.SKILLS_MCP_KIND, + "url": f"{WS}/ai-gateway/skills/?schema=main.default", + "auth": "env:OAUTH_TOKEN", + "clients": ["claude"], + } + _stub_location_base( + monkeypatch, + {**CLAUDE_STATE, "mcp_servers": [skills, self.GITHUB]}, + ) + + def fake_prompt(servers): + offered["names"] = [s["name"] for s in servers] + return [] + + monkeypatch.setattr(mcp, "_prompt_for_mcp_removal", fake_prompt) + + assert mcp.remove_mcp_command() == 0 + # The skills connection is owned by `configure skills`, so it's never a + # removal candidate; only the real MCP server is offered. + assert offered["names"] == ["system-ai-github"] + + def test_no_configured_servers_skips_the_picker(self, monkeypatch): + prompted: list[bool] = [] + _stub_location_base(monkeypatch, {**CLAUDE_STATE, "mcp_servers": []}) + monkeypatch.setattr( + mcp, "_prompt_for_mcp_removal", lambda servers: prompted.append(True) or [] + ) + + assert mcp.remove_mcp_command() == 0 + assert prompted == [] + + class TestConfigureMcpServicesSubset: """`--location --services a,b,...` configures exactly the named subset.""" From b4a7915a74403da5c6d6aadbc6929a44e9d25cd1 Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Tue, 18 Aug 2026 00:59:19 +0000 Subject: [PATCH 2/2] mcp remove: drop skills mention from user-facing docs Skills get a separate docs update later; don't reference the skills relationship in the README or CLI --help here. --- README.md | 3 +-- src/ucode/cli.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5a802b6..bc13a8b 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,7 @@ ucode mcp remove ``` It shows the servers you currently have configured — each with the coding tools it's registered -on — and removes the ones you select from those tools. Skills connections (managed by -`ucode configure skills`) aren't shown, and it needs no Databricks login. +on — and removes the ones you select from those tools. It needs no Databricks login. ### Skills (optional) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 684ffa5..5926526 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1103,8 +1103,7 @@ def mcp_remove() -> None: """Remove configured Databricks MCP servers from your coding tools. Interactive: shows the servers you currently have configured and unregisters the - ones you select. Skills connections (managed by `ucode configure skills`) are not - shown. Needs no Databricks login. + ones you select. Needs no Databricks login. """ try: remove_mcp_command()