Skip to content

Commit aefbd11

Browse files
authored
clear stale MCP entries when switching workspaces (#111)
* clear stale MCP entries when switching workspaces * Retrigger CI
1 parent 58c62d5 commit aefbd11

4 files changed

Lines changed: 352 additions & 2 deletions

File tree

src/ucode/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@
4141
normalize_workspace_url,
4242
run_databricks_login,
4343
)
44-
from ucode.mcp import MCP_CLIENTS, configure_mcp_command, revert_mcp_configs
44+
from ucode.mcp import (
45+
MCP_CLIENTS,
46+
configure_mcp_command,
47+
purge_cross_workspace_mcp_residue,
48+
revert_mcp_configs,
49+
)
4550
from ucode.state import STATE_PATH, clear_state, load_state, save_state
4651
from ucode.ui import (
4752
console,
@@ -152,6 +157,7 @@ def configure_shared_state(
152157
don't error out. If ``None``, we resolve it from the host after login.
153158
"""
154159
workspace = normalize_workspace_url(workspace)
160+
previous_workspace = load_state().get("workspace")
155161
fetch_all = tools is None
156162
if force_login:
157163
run_databricks_login(workspace, profile)
@@ -211,6 +217,10 @@ def configure_shared_state(
211217
if fetch_all or "opencode" in tools:
212218
state["opencode_models"] = opencode_models
213219
save_state(state)
220+
# Scrub MCP entries that ucode wrote for the previous workspace so the new
221+
# workspace's agent configs aren't stale.
222+
if previous_workspace and previous_workspace != workspace:
223+
purge_cross_workspace_mcp_residue(state, workspace)
214224
# Diagnostic reasons are transient — attach after save_state so they don't
215225
# land on disk but are available to the caller for this run.
216226
state["_discovery_reasons"] = {

src/ucode/mcp.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
from collections.abc import Callable
1010
from typing import Any
11+
from urllib.parse import urlparse
1112

1213
import questionary
1314
from prompt_toolkit.application import Application
@@ -29,8 +30,9 @@
2930
list_databricks_apps,
3031
list_databricks_connections,
3132
list_genie_spaces,
33+
workspace_hostname,
3234
)
33-
from ucode.state import load_state, save_state
35+
from ucode.state import load_full_state, load_state, save_state
3436
from ucode.ui import (
3537
print_note,
3638
print_section,
@@ -432,6 +434,61 @@ def _servers_by_name(mcp_servers: list[dict]) -> dict[str, dict]:
432434
return servers
433435

434436

437+
def _mcp_entry_url_host(entry: dict) -> str | None:
438+
"""Return the host of an MCP entry's URL, or ``None`` if missing/malformed."""
439+
url = entry.get("url")
440+
if not isinstance(url, str) or not url:
441+
return None
442+
try:
443+
return urlparse(url).hostname
444+
except ValueError:
445+
return None
446+
447+
448+
def _partition_mcp_entries_by_workspace(
449+
entries: list[dict], workspace: str
450+
) -> tuple[list[dict], list[dict]]:
451+
"""Split MCP entries into ones that belong to ``workspace`` and ones that don't."""
452+
workspace_host = workspace_hostname(workspace)
453+
current: list[dict] = []
454+
foreign: list[dict] = []
455+
for entry in entries:
456+
if _mcp_entry_url_host(entry) == workspace_host:
457+
current.append(entry)
458+
else:
459+
foreign.append(entry)
460+
return current, foreign
461+
462+
463+
def _mcp_entries_only_in_other_workspaces(current_workspace: str) -> dict[str, set[str]]:
464+
"""Return ``{name: {client, ...}}`` for MCPs ucode tracks only in workspaces other than ``current_workspace``."""
465+
full_state = load_full_state()
466+
workspaces = full_state.get("workspaces")
467+
if not isinstance(workspaces, dict):
468+
return {}
469+
470+
current_names: set[str] = set()
471+
current_bucket = workspaces.get(current_workspace)
472+
if isinstance(current_bucket, dict):
473+
for entry in current_bucket.get("mcp_servers") or []:
474+
name = _server_name(entry)
475+
if name:
476+
current_names.add(name)
477+
478+
external_entries: dict[str, set[str]] = {}
479+
for ws, bucket in workspaces.items():
480+
if ws == current_workspace or not isinstance(bucket, dict):
481+
continue
482+
for entry in bucket.get("mcp_servers") or []:
483+
name = _server_name(entry)
484+
if not name or name in current_names:
485+
continue
486+
client_set = external_entries.setdefault(name, set())
487+
for client in entry.get("clients") or []:
488+
client_set.add(client)
489+
return external_entries
490+
491+
435492
def _server_choice(name: str, checked: bool, title: str | None = None) -> questionary.Choice:
436493
return questionary.Choice(
437494
title=title or name,
@@ -743,12 +800,72 @@ def apply_mcp_server_changes(
743800
return changed
744801

745802

803+
def purge_cross_workspace_mcp_residue(state: dict, workspace: str) -> None:
804+
installed = set(available_mcp_clients())
805+
806+
raw_mcp_servers = list(state.get("mcp_servers") or [])
807+
current_mcp_servers, foreign_mcp_servers = _partition_mcp_entries_by_workspace(
808+
raw_mcp_servers, workspace
809+
)
810+
if foreign_mcp_servers:
811+
foreign_names = ", ".join(
812+
(_server_name(server) or "(unnamed)") for server in foreign_mcp_servers
813+
)
814+
noun = "entry" if len(foreign_mcp_servers) == 1 else "entries"
815+
print_warning(
816+
f"Dropping {len(foreign_mcp_servers)} stale MCP {noun} "
817+
f"not bound to this workspace: {foreign_names}."
818+
)
819+
for server in foreign_mcp_servers:
820+
name = _server_name(server)
821+
if not name:
822+
continue
823+
for client in server.get("clients") or []:
824+
if client not in installed or client not in MCP_CLIENTS:
825+
continue
826+
try:
827+
remove_client_mcp_server(client, name)
828+
except RuntimeError as exc:
829+
print_warning(
830+
f"Failed to remove `{name}` from {MCP_CLIENTS[client]['display']}: {exc}"
831+
)
832+
state["mcp_servers"] = current_mcp_servers
833+
save_state(state)
834+
835+
other_ws_mcps = _mcp_entries_only_in_other_workspaces(workspace)
836+
actually_removed: list[str] = []
837+
for name in sorted(other_ws_mcps):
838+
any_removed = False
839+
for client in other_ws_mcps[name]:
840+
if client not in installed or client not in MCP_CLIENTS:
841+
continue
842+
try:
843+
removed_scopes = remove_client_mcp_server(client, name)
844+
except RuntimeError as exc:
845+
print_warning(
846+
f"Failed to remove `{name}` from {MCP_CLIENTS[client]['display']}: {exc}"
847+
)
848+
continue
849+
if removed_scopes:
850+
any_removed = True
851+
if any_removed:
852+
actually_removed.append(name)
853+
if actually_removed:
854+
noun = "entry" if len(actually_removed) == 1 else "entries"
855+
print_warning(
856+
f"Removed {len(actually_removed)} MCP {noun} left over from "
857+
f"previously-configured workspaces: {', '.join(actually_removed)}."
858+
)
859+
860+
746861
def configure_mcp_command() -> int:
747862
state = load_state()
748863
workspace = state.get("workspace")
749864
if not workspace:
750865
raise RuntimeError("Workspace is not configured. Run `ucode configure` first.")
751866

867+
purge_cross_workspace_mcp_residue(state, workspace)
868+
752869
installed_clients = available_mcp_clients()
753870
if not installed_clients:
754871
raise RuntimeError(

tests/test_cli.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,61 @@ def fake_configure_shared_state(workspace, profile=None, tools=None, force_login
646646
]
647647
assert saved == ["https://first.com"]
648648
assert configured_tools == [("https://first.com", ["codex"])]
649+
650+
651+
class TestConfigureSharedStateMcpCleanup:
652+
"""A workspace switch should scrub the previous workspace's MCP entries from
653+
installed client configs. Switching to the same workspace must not."""
654+
655+
@staticmethod
656+
def _stub_external_deps(monkeypatch):
657+
import ucode.cli as cli_mod
658+
659+
monkeypatch.setattr(cli_mod, "normalize_workspace_url", lambda w: w)
660+
monkeypatch.setattr(cli_mod, "run_databricks_login", lambda w, p: None)
661+
monkeypatch.setattr(cli_mod, "ensure_databricks_auth", lambda w, p=None: None)
662+
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
663+
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
664+
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
665+
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
666+
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
667+
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))
668+
monkeypatch.setattr(cli_mod, "build_shared_base_urls", lambda w: {})
669+
670+
def test_purges_residue_when_workspace_changes(self, monkeypatch):
671+
import ucode.cli as cli_mod
672+
673+
self._stub_external_deps(monkeypatch)
674+
monkeypatch.setattr(
675+
cli_mod, "load_state", lambda: {"workspace": "https://old.databricks.com"}
676+
)
677+
purge_calls: list[tuple[dict, str]] = []
678+
monkeypatch.setattr(
679+
cli_mod,
680+
"purge_cross_workspace_mcp_residue",
681+
lambda state, workspace: purge_calls.append((state, workspace)),
682+
)
683+
684+
cli_mod.configure_shared_state("https://new.databricks.com")
685+
686+
assert len(purge_calls) == 1
687+
_, called_workspace = purge_calls[0]
688+
assert called_workspace == "https://new.databricks.com"
689+
690+
def test_skips_purge_when_workspace_unchanged(self, monkeypatch):
691+
import ucode.cli as cli_mod
692+
693+
self._stub_external_deps(monkeypatch)
694+
monkeypatch.setattr(
695+
cli_mod, "load_state", lambda: {"workspace": "https://same.databricks.com"}
696+
)
697+
purge_calls: list = []
698+
monkeypatch.setattr(
699+
cli_mod,
700+
"purge_cross_workspace_mcp_residue",
701+
lambda state, workspace: purge_calls.append((state, workspace)),
702+
)
703+
704+
cli_mod.configure_shared_state("https://same.databricks.com")
705+
706+
assert purge_calls == []

0 commit comments

Comments
 (0)