|
8 | 8 | import subprocess |
9 | 9 | from collections.abc import Callable |
10 | 10 | from typing import Any |
| 11 | +from urllib.parse import urlparse |
11 | 12 |
|
12 | 13 | import questionary |
13 | 14 | from prompt_toolkit.application import Application |
|
29 | 30 | list_databricks_apps, |
30 | 31 | list_databricks_connections, |
31 | 32 | list_genie_spaces, |
| 33 | + workspace_hostname, |
32 | 34 | ) |
33 | | -from ucode.state import load_state, save_state |
| 35 | +from ucode.state import load_full_state, load_state, save_state |
34 | 36 | from ucode.ui import ( |
35 | 37 | print_note, |
36 | 38 | print_section, |
@@ -432,6 +434,61 @@ def _servers_by_name(mcp_servers: list[dict]) -> dict[str, dict]: |
432 | 434 | return servers |
433 | 435 |
|
434 | 436 |
|
| 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 | + |
435 | 492 | def _server_choice(name: str, checked: bool, title: str | None = None) -> questionary.Choice: |
436 | 493 | return questionary.Choice( |
437 | 494 | title=title or name, |
@@ -743,12 +800,72 @@ def apply_mcp_server_changes( |
743 | 800 | return changed |
744 | 801 |
|
745 | 802 |
|
| 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 | + |
746 | 861 | def configure_mcp_command() -> int: |
747 | 862 | state = load_state() |
748 | 863 | workspace = state.get("workspace") |
749 | 864 | if not workspace: |
750 | 865 | raise RuntimeError("Workspace is not configured. Run `ucode configure` first.") |
751 | 866 |
|
| 867 | + purge_cross_workspace_mcp_residue(state, workspace) |
| 868 | + |
752 | 869 | installed_clients = available_mcp_clients() |
753 | 870 | if not installed_clients: |
754 | 871 | raise RuntimeError( |
|
0 commit comments