diff --git a/README.md b/README.md
index fb1a7abc..7234ecaf 100644
--- a/README.md
+++ b/README.md
@@ -254,6 +254,26 @@ ug mcp remove --agents codex
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. It needs no Databricks login.
+#### List configured servers and their connection status
+
+To see the Databricks MCP servers `ug` has configured and whether each coding agent is currently
+connected to them, use `ug mcp list`:
+
+```bash
+ug mcp list
+
+# Limit the report to specific agents.
+ug mcp list --agents claude,codex
+```
+
+It prints one row per configured server — `NAME`, `LOCATION`, `AGENTS`, and a `STATUS` aggregated
+from each agent's own `mcp list` (connected/failed; Codex reports `enabled`/`disabled`, since its
+listing does not health-check). When agents disagree, `STATUS` splits into `agent:state`.
+Workspace-managed servers are tagged, and any servers an agent lists that `ug` didn't configure are
+summarized as a per-agent count. Skills connections are managed separately (via `ug skill` /
+`ug configure skills`) and aren't listed here. It reads local state plus each installed agent's
+`mcp list`, so it needs no Databricks login.
+
### Skills (optional)
Configure Unity Catalog Skills for your coding tools with `ug configure skills`:
@@ -416,6 +436,8 @@ The output looks like:
| `ug mcp add --agents claude --services system.ai.slack` | Set up the agent(s) if needed and register the server for them |
| `ug mcp remove` | Interactively unregister configured MCP servers from your coding tools |
| `ug mcp remove --agents codex` | Unregister selected servers from specific agents only |
+| `ug mcp list` | List configured MCP servers and their live per-agent connection status |
+| `ug mcp list --agents claude` | Show the connection-status report for specific agents only |
| `ug configure skills` | Register the skills MCP connection (utility tools only); no skills download |
| `ug 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 |
| `ug configure skills --skill main.default.my-skill` | Download named skills by fully-qualified name (comma-separated; may span schemas) |
diff --git a/src/ucode/cli.py b/src/ucode/cli.py
index e956a341..3a5d8c40 100644
--- a/src/ucode/cli.py
+++ b/src/ucode/cli.py
@@ -105,6 +105,7 @@
configure_skills_mcp_command,
configure_skills_mcp_picker_command,
configured_mcp_clients,
+ list_mcp_command,
purge_cross_workspace_mcp_residue,
reconcile_managed_mcp_servers,
remove_mcp_command,
@@ -906,7 +907,8 @@ def status() -> int:
state = load_state()
workspace = state.get("workspace")
managed_configs = state.get("managed_configs") or {}
- mcp_servers = state.get("mcp_servers") or []
+ # Both developer- and workspace-managed servers, so the count agrees with `ug mcp list`.
+ mcp_servers = (state.get("mcp_servers") or []) + (state.get("managed_mcp_servers") or [])
configured_tools = set(state.get("available_tools") or managed_configs.keys())
console.print(heading("ug status"))
@@ -941,18 +943,17 @@ def status() -> int:
print_kv("Model Provider Service", provider_service)
print_kv("Base URL", base_url)
if configured and tool in MCP_CLIENTS:
- tool_mcp_servers = [
- str(server.get("name"))
+ # High-level overview: just a count per agent. `ug mcp list` (see the note below) shows
+ # the per-server detail and live connection status, so status stays scannable. Dedupe by
+ # name so a server present in both mcp_servers and managed_mcp_servers isn't double-counted.
+ mcp_names = {
+ server.get("name")
for server in mcp_servers
if tool in (server.get("clients") or [])
and server.get("name")
and server.get("kind") != SKILLS_MCP_KIND
- ]
- print_kv("MCP list command", str(MCP_CLIENTS[tool]["list_command"]))
- print_kv(
- "MCP servers",
- ", ".join(tool_mcp_servers) if tool_mcp_servers else "none saved by ug",
- )
+ }
+ print_kv("MCP servers", str(len(mcp_names)))
print_kv("Config file", str(config_path) if config_path.exists() else "missing")
if tool == "claude":
managed_path, managed_status, backup_status = claude_agent.managed_settings_status(
@@ -997,6 +998,7 @@ def status() -> int:
print_kv("State file", str(STATE_PATH) if STATE_PATH.exists() else "missing")
print_note("Use `ug configure` to update workspace settings or configure new tools.")
print_note("Use `ug configure mcp` to add Databricks MCP servers to configured coding tools.")
+ print_note("Use `ug mcp list` to see configured MCP servers and their connection status.")
print_note(
"Use `ug configure skills` to set up Unity Catalog Skills for configured coding tools."
)
@@ -1057,7 +1059,11 @@ def revert() -> int:
configure_app = typer.Typer(add_completion=False, no_args_is_help=False)
app.add_typer(configure_app, name="configure", help="Configure workspace and tool settings.")
mcp_app = typer.Typer(add_completion=False, no_args_is_help=True)
-app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ug.")
+app.add_typer(
+ mcp_app,
+ name="mcp",
+ help="Inspect and manage the Databricks MCP servers ug configures for your coding agents.",
+)
skill_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(skill_app, name="skills", help="Databricks Skills for your coding tools.")
@@ -1194,6 +1200,38 @@ def mcp_remove(
raise typer.Exit(130) from None
+@mcp_app.command("list")
+def mcp_list(
+ agents: Annotated[
+ str | None,
+ typer.Option(
+ "--agents",
+ help="Comma-separated coding agents to report on (e.g. claude,codex). Without "
+ "--agents, every installed MCP-capable agent is included.",
+ ),
+ ] = None,
+) -> None:
+ """List the Databricks MCP servers ug has configured and their live connection status.
+
+ Reads ug's saved state and each installed agent's own `mcp list` to show, per agent, whether
+ each server is connected. Read-only; needs no Databricks login. Use the `add`/`remove`
+ subcommands to change what's configured.
+ """
+ requested_agents = (
+ None
+ if agents is None
+ else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None)
+ )
+ try:
+ list_mcp_command(agents=requested_agents)
+ 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 15ad291a..5444be3c 100644
--- a/src/ucode/mcp.py
+++ b/src/ucode/mcp.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
+import re
import shutil
import subprocess
import threading
@@ -12,6 +13,7 @@
from urllib.parse import urlparse
import questionary
+from rich.table import Table
from ucode.agents import claude, copilot, cursor, gemini, opencode
from ucode.config_io import restore_file
@@ -59,6 +61,14 @@
# connection-backed services that need a per-user connection login.
AIGW_MCP_SERVICES_PATH = "/ai-gateway/mcp-services/"
+# Workspace-relative path fragments for the V2 AI Gateway MCP endpoints, shared by the URL-shape
+# checks (`_is_app_mcp_server`, `_mcp_server_location`) so the set stays in one place.
+MCP_EXTERNAL_PATH = "/api/2.0/mcp/external/"
+MCP_GENIE_PATH = "/api/2.0/mcp/genie/"
+MCP_VECTOR_SEARCH_PATH = "/api/2.0/mcp/vector-search/"
+MCP_FUNCTIONS_PATH = "/api/2.0/mcp/functions/"
+MCP_SQL_PATH = "/api/2.0/mcp/sql"
+
# Per-agent published OAuth app used for the direct-HTTP MCP connection login.
# These agents can pin a pre-registered OAuth client and drive the `/oidc` login
# themselves (so `/mcp` shows "needs authentication" / Cursor shows a login), which
@@ -728,15 +738,15 @@ def _is_app_mcp_server(server: dict) -> bool:
return False
stripped = url.rstrip("/")
known = (
- "/ai-gateway/mcp-services/",
- "/api/2.0/mcp/external/",
- "/api/2.0/mcp/genie/",
- "/api/2.0/mcp/vector-search/",
- "/api/2.0/mcp/functions/",
+ AIGW_MCP_SERVICES_PATH,
+ MCP_EXTERNAL_PATH,
+ MCP_GENIE_PATH,
+ MCP_VECTOR_SEARCH_PATH,
+ MCP_FUNCTIONS_PATH,
)
if any(fragment in url for fragment in known):
return False
- if stripped.endswith("/api/2.0/mcp/sql"):
+ if stripped.endswith(MCP_SQL_PATH):
return False
return stripped.endswith("/mcp")
@@ -1714,6 +1724,355 @@ def remove_mcp_command(agents: set[str] | None = None) -> int:
return 0
+# ---------------------------------------------------------------------------
+# `ug mcp list`: configured MCP servers + their live per-agent connection status
+# ---------------------------------------------------------------------------
+
+# Per-(server, agent) live states surfaced by `ug mcp list`. Every agent's `mcp list`
+# health-checks its servers and reports connected/failed — except Codex, whose listing
+# reports only whether an entry is enabled/disabled (no probe). NOT_REGISTERED means the
+# agent is installed but doesn't list the server; a server maps to None (rendered
+# "agent not installed") when the agent binary isn't installed at all.
+LIVE_CONNECTED = "connected"
+LIVE_FAILED = "failed"
+LIVE_ENABLED = "enabled"
+LIVE_DISABLED = "disabled"
+LIVE_UNKNOWN = "unknown"
+LIVE_NOT_REGISTERED = "not-registered"
+
+# Some agent CLIs (e.g. cursor-agent) redraw progress with ANSI escapes that otherwise leak into
+# parsed server names; strip them before parsing.
+_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
+
+# Glyphs agent CLIs use for MCP health (claude: ✔/✘; others commonly ✓/✗ or 🟢/🔴).
+_HEALTH_OK_MARKERS = ("✔", "✓", "🟢")
+_HEALTH_FAIL_MARKERS = ("✘", "✗", "🔴")
+_CODEX_STATUS_BY_LABEL = {"enabled": LIVE_ENABLED, "disabled": LIVE_DISABLED}
+
+
+def _classify_health_line(rest: str) -> str:
+ """Map the text trailing a server name in an agent's `mcp list` to a live state.
+
+ Prefer the explicit health glyph (✔/✘); only fall back to keyword text when no glyph is present,
+ so a healthy server whose name or URL happens to contain "fail"/"error" isn't misread as failed.
+ """
+ if any(marker in rest for marker in _HEALTH_FAIL_MARKERS):
+ return LIVE_FAILED
+ if any(marker in rest for marker in _HEALTH_OK_MARKERS):
+ return LIVE_CONNECTED
+ low = rest.lower()
+ if "fail" in low or "error" in low or "disconnect" in low:
+ return LIVE_FAILED
+ if "connected" in low or "ready" in low:
+ return LIVE_CONNECTED
+ return LIVE_UNKNOWN
+
+
+def _parse_health_mcp_list(output: str) -> dict[str, str]:
+ """Parse a health-checking `mcp list` (claude and, best-effort, the others) into
+ ``{server_name: state}``.
+
+ Claude prints ``: - `` per server, so the name is the
+ token before the first colon. Some agents use a colon-less `` - `` shape;
+ that is handled as a fallback. Prose and headers (which have spaces in the leading token) are
+ skipped, so an unrecognized line contributes nothing rather than a bogus entry.
+ """
+ statuses: dict[str, str] = {}
+ for raw in output.splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ if ":" in line:
+ name, _, rest = line.partition(":")
+ name = name.strip()
+ elif line.startswith(_HEALTH_OK_MARKERS + _HEALTH_FAIL_MARKERS):
+ # Colon-less shape, e.g. `🟢 serverName - Ready`: take the token after the leading
+ # glyph as the name (best-effort for agents beyond claude). Requiring the leading
+ # glyph skips prose like claude's "Checking MCP server health…" header.
+ tokens = line.lstrip(
+ "".join(_HEALTH_OK_MARKERS + _HEALTH_FAIL_MARKERS) + "•*- "
+ ).split()
+ if not tokens:
+ continue
+ name, rest = tokens[0], line
+ else:
+ continue
+ # Registered MCP server names are single tokens; a leading token with spaces is prose.
+ if not name or " " in name:
+ continue
+ statuses[name] = _classify_health_line(rest)
+ return statuses
+
+
+def _parse_codex_mcp_list(output: str) -> dict[str, str]:
+ """Parse `codex mcp list`'s columnar table into ``{server_name: state}``.
+
+ Codex reports config state (``enabled``/``disabled``), not a health probe. Columns are
+ separated by runs of two-plus spaces; the name is the first column and the state column holds
+ ``enabled`` or ``disabled``. The header row (first column ``Name``) is skipped.
+ """
+ statuses: dict[str, str] = {}
+ for raw in output.splitlines():
+ line = raw.strip()
+ if not line:
+ continue
+ fields = re.split(r"\s{2,}", line)
+ name = fields[0].strip()
+ if not name or name == "Name":
+ continue
+ state = LIVE_UNKNOWN
+ for field in fields[1:]:
+ mapped = _CODEX_STATUS_BY_LABEL.get(field.strip().lower())
+ if mapped is not None:
+ state = mapped
+ break
+ statuses[name] = state
+ return statuses
+
+
+def parse_mcp_list_output(client: str, output: str) -> dict[str, str]:
+ """Parse an agent's `mcp list` output into ``{server_name: live-state}`` (best-effort)."""
+ if _is_missing_mcp_server_output(output):
+ return {}
+ if client == "codex":
+ return _parse_codex_mcp_list(output)
+ return _parse_health_mcp_list(output)
+
+
+def _run_mcp_list(client: str) -> str | None:
+ """Run an installed agent's `mcp list`, returning combined stdout+stderr, or None on failure.
+
+ Best-effort and read-only: a missing binary, timeout, or non-zero exit yields None so the
+ caller shows configured servers without live status rather than erroring out.
+ """
+ spec = MCP_CLIENTS.get(client)
+ if not spec:
+ return None
+ argv = str(spec["list_command"]).split()
+ # Gemini reads its config from a pinned home dir, matching how ucode registers servers there.
+ env = _gemini_cli_env() if client == "gemini" else None
+ try:
+ result = subprocess.run(
+ argv, check=False, capture_output=True, text=True, timeout=90, env=env
+ )
+ except (subprocess.TimeoutExpired, OSError):
+ return None
+ return _ANSI_ESCAPE_RE.sub("", f"{result.stdout or ''}\n{result.stderr or ''}")
+
+
+def query_live_mcp_status(client: str) -> dict[str, str]:
+ """Live ``{server_name: state}`` for one installed agent (empty if its listing can't be read)."""
+ output = _run_mcp_list(client)
+ if output is None:
+ return {}
+ return parse_mcp_list_output(client, output)
+
+
+def _query_live_statuses(clients: list[str]) -> dict[str, dict[str, str]]:
+ """Query every client's live MCP status concurrently (each `mcp list` is independent)."""
+ if not clients:
+ return {}
+ results: dict[str, dict[str, str]] = {}
+ with spinner("Checking MCP connection status..."):
+ with ThreadPoolExecutor(max_workers=len(clients)) as pool:
+ futures = {pool.submit(query_live_mcp_status, client): client for client in clients}
+ for future in as_completed(futures):
+ results[futures[future]] = future.result()
+ return results
+
+
+def _mcp_server_location(server: dict) -> str:
+ """Concise LOCATION label for a configured MCP server, derived from its Databricks URL shape."""
+ if server.get("kind") == SKILLS_MCP_KIND:
+ return "skills"
+ url = str(server.get("url") or "")
+ stripped = url.rstrip("/")
+ if AIGW_MCP_SERVICES_PATH in url:
+ return url.split(AIGW_MCP_SERVICES_PATH, 1)[1] or "mcp-service"
+ if MCP_EXTERNAL_PATH in url:
+ return f"connection:{stripped.rsplit('/', 1)[-1]}"
+ if MCP_GENIE_PATH in url:
+ return f"genie:{stripped.rsplit('/', 1)[-1]}"
+ if MCP_VECTOR_SEARCH_PATH in url:
+ return f"vector-search:{'.'.join(stripped.split('/')[-2:])}"
+ if MCP_FUNCTIONS_PATH in url:
+ return f"uc-functions:{'.'.join(stripped.split('/')[-2:])}"
+ if stripped.endswith(MCP_SQL_PATH):
+ return "databricks-sql"
+ if _is_app_mcp_server(server):
+ return "app"
+ return url or "unknown"
+
+
+def _resolve_live_status(
+ client: str, name: str, installed: list[str], live: dict[str, dict[str, str]]
+) -> str | None:
+ """The live state of server ``name`` in ``client``: None if the agent isn't installed,
+ NOT_REGISTERED if installed but the server isn't in its listing, else the parsed state."""
+ if client not in installed:
+ return None
+ return live.get(client, {}).get(name, LIVE_NOT_REGISTERED)
+
+
+# Compact one-word label + color per live state for the STATUS column. ``None`` (agent not
+# installed) is handled separately in `_status_token`.
+_LIVE_LABEL = {
+ LIVE_CONNECTED: "connected",
+ LIVE_FAILED: "failed",
+ LIVE_ENABLED: "enabled",
+ LIVE_DISABLED: "disabled",
+ LIVE_UNKNOWN: "unknown",
+ LIVE_NOT_REGISTERED: "missing",
+}
+_LIVE_STYLE = {
+ LIVE_CONNECTED: "green",
+ LIVE_ENABLED: "green",
+ LIVE_FAILED: "red",
+ LIVE_DISABLED: "yellow",
+ LIVE_UNKNOWN: "yellow",
+ LIVE_NOT_REGISTERED: "yellow",
+}
+
+
+# Live states that mean the same thing for the STATUS column, so a server that is `connected` on
+# one agent and `enabled` on Codex (which can't health-check) collapses to a single token instead
+# of splitting the common case. ``None`` (agent not installed) maps to "absent".
+_LIVE_CLASS = {
+ LIVE_CONNECTED: "ok",
+ LIVE_ENABLED: "ok",
+ LIVE_DISABLED: "off",
+ LIVE_FAILED: "bad",
+ LIVE_NOT_REGISTERED: "missing",
+ LIVE_UNKNOWN: "unknown",
+}
+
+
+def _state_class(state: str | None) -> str:
+ return "absent" if state is None else _LIVE_CLASS.get(state, "unknown")
+
+
+def _status_token(state: str | None) -> str:
+ """Colored one-word status token; ``None`` renders as a dim 'not installed'."""
+ if state is None:
+ return "[dim]not installed[/dim]"
+ label = _LIVE_LABEL.get(state, "unknown")
+ style = _LIVE_STYLE.get(state, "yellow")
+ return f"[{style}]{label}[/{style}]"
+
+
+def _row_status(
+ clients: list[str], name: str, installed: list[str], live: dict[str, dict[str, str]]
+) -> str:
+ """STATUS cell for a server: one token when every agent agrees (treating connected/enabled as
+ the same healthy state), else per-agent ``agent:state`` so a divergent failure stands out."""
+ states = [_resolve_live_status(client, name, installed, live) for client in clients]
+ if len({_state_class(state) for state in states}) == 1:
+ # Uniform class. For the healthy class prefer the stronger 'connected' when any agent
+ # actually health-checked it; otherwise any state in the class is representative.
+ if LIVE_CONNECTED in states:
+ return _status_token(LIVE_CONNECTED)
+ return _status_token(states[0])
+ return " ".join(
+ f"{client}:{_status_token(state)}" for client, state in zip(clients, states, strict=True)
+ )
+
+
+def list_mcp_command(agents: set[str] | None = None) -> int:
+ """`ug mcp list`: show the Databricks MCP servers ug has configured and their live
+ connection status in each coding agent, one row per server.
+
+ Read-only: it reads ug's saved state and each installed agent's own `mcp list`, and needs no
+ Databricks login. Each row's STATUS aggregates the agents the server is registered on
+ (connected/failed; Codex reports enabled/disabled since its listing does not health-check),
+ splitting into ``agent:state`` only when they disagree. ``agents`` (from ``--agents``) scopes
+ the report to that subset of agents.
+ """
+ if agents is not None:
+ unknown = sorted(agent for agent in agents if agent not in MCP_CLIENTS)
+ if unknown:
+ raise RuntimeError(
+ f"Unknown agent(s): {', '.join(unknown)}. Known: {', '.join(MCP_CLIENTS)}."
+ )
+
+ state = load_state()
+ installed = available_mcp_clients()
+ probe_clients = [client for client in installed if agents is None or client in agents]
+ scope_note = "" if agents is None else f" for {', '.join(sorted(agents))}"
+
+ print_section("MCP servers")
+ print_kv("Workspace", state.get("workspace") or "not configured")
+ if not installed:
+ print_warning(
+ "No supported MCP clients are installed; showing configured servers without live status."
+ )
+
+ live = _query_live_statuses(probe_clients)
+
+ # Merge developer- and workspace-managed servers by registered name, unioning their agents.
+ # ``--agents`` drops agents outside the scope, and a server left with no in-scope agent is
+ # omitted. The skills connection is intentionally excluded — it's reported by the skill commands.
+ configured: dict[str, dict[str, Any]] = {}
+
+ def _collect(server: dict, *, managed: bool) -> None:
+ name = _server_name(server)
+ if not name or server.get("kind") == SKILLS_MCP_KIND:
+ return
+ clients = [
+ client for client in _mcp_server_clients(server) if agents is None or client in agents
+ ]
+ if not clients:
+ return
+ entry = configured.setdefault(name, {"server": server, "clients": [], "managed": managed})
+ entry["clients"] = _merge_clients(entry["clients"], clients)
+ entry["managed"] = entry["managed"] or managed
+
+ for server in state.get("mcp_servers") or []:
+ _collect(server, managed=False)
+ for server in state.get("managed_mcp_servers") or []:
+ _collect(server, managed=True)
+
+ if configured:
+ table = Table(box=None, pad_edge=False, header_style="bold")
+ table.add_column("NAME", no_wrap=True)
+ table.add_column("LOCATION")
+ table.add_column("AGENTS")
+ table.add_column("STATUS")
+ for name in sorted(configured):
+ entry = configured[name]
+ location = _mcp_server_location(entry["server"])
+ if entry["managed"]:
+ location += " [magenta](managed)[/magenta]"
+ table.add_row(
+ name,
+ location,
+ ", ".join(entry["clients"]),
+ _row_status(entry["clients"], name, installed, live),
+ )
+ console.print(table)
+ else:
+ print_note(f"No MCP servers are configured by ug{scope_note}.")
+
+ # Anything an agent lists that ug didn't configure (e.g. hand-added servers): a one-line count
+ # per agent, so the developer sees them without a long name dump conflated with ug's. The skills
+ # registry is ug-managed (shown by the skill commands), so it's never counted here either.
+ ug_names = set(configured) | {SKILLS_MCP_SERVER_NAME}
+ other_counts = [
+ (client, sum(1 for name in live.get(client, {}) if name not in ug_names))
+ for client in probe_clients
+ ]
+ other_summary = ", ".join(f"{client}: {count}" for client, count in other_counts if count)
+ if other_summary:
+ print_note(f"Other MCP servers not configured by ug — {other_summary}.")
+
+ live_note = "Live status is from each agent's `mcp list`"
+ if "codex" in probe_clients:
+ # Only mention Codex's enabled/disabled caveat when Codex is actually in the reported set.
+ live_note += "; Codex reports enabled/disabled"
+ print_note(f"{live_note}.")
+ print_note("Use `ug mcp add` / `ug mcp remove` to change the servers ug configures.")
+ 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_cli.py b/tests/test_cli.py
index 815afa72..8effbbd4 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1188,6 +1188,32 @@ def test_mcp_group_lists_web_search(self):
assert result.exit_code == 0
assert "web-search" in result.output
+ def test_bare_mcp_shows_group_help(self, monkeypatch):
+ # `ug mcp` with no subcommand shows the group help (commands list), not the listing.
+ monkeypatch.setattr(
+ cli_mod,
+ "list_mcp_command",
+ lambda agents=None: pytest.fail("listing ran for bare mcp"),
+ )
+ result = runner.invoke(app, ["mcp"])
+ assert "Usage:" in result.output
+ assert "list" in result.output
+ assert "add" in result.output
+
+ def test_mcp_list_runs_the_lister(self, monkeypatch):
+ calls: list[set[str] | None] = []
+ monkeypatch.setattr(cli_mod, "list_mcp_command", lambda agents=None: calls.append(agents))
+ result = runner.invoke(app, ["mcp", "list"])
+ assert result.exit_code == 0, result.output
+ assert calls == [None]
+
+ def test_mcp_list_forwards_agents_option(self, monkeypatch):
+ calls: list[set[str] | None] = []
+ monkeypatch.setattr(cli_mod, "list_mcp_command", lambda agents=None: calls.append(agents))
+ result = runner.invoke(app, ["mcp", "list", "--agents", "claude,codex"])
+ assert result.exit_code == 0, result.output
+ assert calls == [{"claude", "codex"}]
+
class TestAuthTokenCommand:
"""`ucode auth-token` is the cross-platform apiKeyHelper (#116)."""
@@ -1330,20 +1356,21 @@ def test_errors_without_workspace(self):
class TestStatus:
- def test_shows_mcp_list_commands(self):
+ def test_points_to_ug_mcp_list_with_counts(self):
+ # status is a high-level overview: it shows a per-agent MCP count and points to the
+ # detail command, rather than surfacing each agent's raw ` mcp list` command.
with patch("ucode.cli.load_state", return_value=MINIMAL_STATE):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0, result.output
assert "Managed by Databricks" not in result.output
- assert "MCP list command:" in result.output
- assert "claude mcp list" in result.output
- assert "codex mcp list" in result.output
- assert "gemini mcp list" in result.output
- assert "opencode mcp list" in result.output
- assert "copilot mcp list" not in result.output
-
- def test_shows_mcp_servers_configured_by_ucode(self):
+ assert "MCP servers: 0" in result.output
+ assert "ug mcp list" in result.output
+ assert "MCP list command:" not in result.output
+ assert "claude mcp list" not in result.output
+ assert "codex mcp list" not in result.output
+
+ def test_shows_mcp_server_counts_configured_by_ucode(self):
state = {
**MINIMAL_STATE,
"mcp_servers": [
@@ -1365,13 +1392,48 @@ def test_shows_mcp_servers_configured_by_ucode(self):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0, result.output
- assert "github-mcp" in result.output
- assert "MCP servers: github-mcp" in result.output
- assert "databricks-sql" in result.output
- assert "MCP servers: databricks-sql" in result.output
- assert "MCP Servers" not in result.output
- assert "MCP Server:" not in result.output
- assert "Configured tools:" not in result.output
+ # Counts, not names: claude, codex, and gemini each carry one server.
+ assert "MCP servers: 1" in result.output
+ assert "github-mcp" not in result.output
+ assert "databricks-sql" not in result.output
+ assert "ug mcp list" in result.output
+
+ def test_mcp_count_includes_managed_servers_and_dedupes(self):
+ # The count folds in workspace-managed servers (matching `ug mcp list`) and dedupes a
+ # server present in both lists by name, so it isn't counted twice.
+ state = {
+ **MINIMAL_STATE,
+ "mcp_servers": [
+ {
+ "name": "dev-mcp",
+ "url": "https://example.databricks.com/api/2.0/mcp/external/dev-mcp",
+ "clients": ["claude"],
+ },
+ {
+ "name": "shared-mcp",
+ "url": "https://example.databricks.com/api/2.0/mcp/external/shared-mcp",
+ "clients": ["claude"],
+ },
+ ],
+ "managed_mcp_servers": [
+ {
+ "name": "managed-mcp",
+ "url": "https://example.databricks.com/ai-gateway/mcp-services/system.ai.x",
+ "clients": ["claude"],
+ },
+ {
+ "name": "shared-mcp",
+ "url": "https://example.databricks.com/api/2.0/mcp/external/shared-mcp",
+ "clients": ["claude"],
+ },
+ ],
+ }
+ with patch("ucode.cli.load_state", return_value=state):
+ result = runner.invoke(app, ["status"])
+
+ assert result.exit_code == 0, result.output
+ # claude: dev-mcp, shared-mcp, managed-mcp = 3 distinct (shared-mcp not double-counted).
+ assert "MCP servers: 3" in result.output
def test_status_treats_available_tools_as_configured_agents(self):
state = {
@@ -1394,11 +1456,8 @@ def test_status_treats_available_tools_as_configured_agents(self):
result = runner.invoke(app, ["status"])
assert result.exit_code == 0, result.output
- assert "copilot mcp list" in result.output
- assert "MCP servers: databricks-sql" in result.output
- assert "codex mcp list" not in result.output
- assert "claude mcp list" not in result.output
- assert "gemini mcp list" not in result.output
+ assert "MCP servers: 1" in result.output
+ assert "databricks-sql" not in result.output
assert "https://example.databricks.com/ai-gateway/anthropic" not in result.output
assert "https://example.databricks.com/ai-gateway/gemini" not in result.output
diff --git a/tests/test_mcp.py b/tests/test_mcp.py
index 033d3bb6..43b49c0c 100644
--- a/tests/test_mcp.py
+++ b/tests/test_mcp.py
@@ -3144,3 +3144,203 @@ def boom():
assert mcp._discover_mcp_source("Genie spaces", boom) == []
out = capsys.readouterr().out
assert "network down" in out
+
+
+# Real-shaped `claude mcp list` output: `: - `, health-probed.
+CLAUDE_MCP_LIST = """Checking MCP server health…
+
+github: dbexec repo run mcp start-single github - ✔ Connected
+databricks: python3.10 /home/u/mcp/databricks_deploy.pex - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed
+approval-demo: https://host.databricksapps.com/mcp (HTTP) - ✘ Failed to connect — ENOTFOUND: getaddrinfo
+web_search: /home/u/.cache/ucode mcp web-search - ✔ Connected
+"""
+
+# Real-shaped `codex mcp list` table: columns separated by 2+ spaces; Status is enabled/disabled.
+CODEX_MCP_LIST = """Name Command Args Env Cwd Status Auth
+accounts-admin python3.10 /home/u/mcp/accounts_deploy.pex - - enabled Unsupported
+chrome-devtools npx https://host/chrome.tgz --headless - - disabled Unsupported
+github dbexec repo run mcp start-single github - - enabled Unsupported
+"""
+
+
+class TestParseMcpListOutput:
+ def test_parses_claude_health_output(self):
+ assert mcp.parse_mcp_list_output("claude", CLAUDE_MCP_LIST) == {
+ "github": mcp.LIVE_CONNECTED,
+ "databricks": mcp.LIVE_FAILED,
+ "approval-demo": mcp.LIVE_FAILED,
+ "web_search": mcp.LIVE_CONNECTED,
+ }
+
+ def test_claude_header_line_is_not_a_server(self):
+ # The "Checking MCP server health…" header must not become a bogus entry.
+ assert "Checking" not in mcp.parse_mcp_list_output("claude", CLAUDE_MCP_LIST)
+
+ def test_parses_codex_enabled_disabled_table(self):
+ assert mcp.parse_mcp_list_output("codex", CODEX_MCP_LIST) == {
+ "accounts-admin": mcp.LIVE_ENABLED,
+ "chrome-devtools": mcp.LIVE_DISABLED,
+ "github": mcp.LIVE_ENABLED,
+ }
+
+ def test_empty_listing_returns_no_servers(self):
+ assert mcp.parse_mcp_list_output("gemini", "No MCP servers configured.") == {}
+
+ def test_colonless_glyph_shape_is_parsed_best_effort(self):
+ parsed = mcp.parse_mcp_list_output(
+ "gemini", "🟢 alpha - Ready (3 tools)\n🔴 beta - Disconnected\n"
+ )
+ assert parsed == {"alpha": mcp.LIVE_CONNECTED, "beta": mcp.LIVE_FAILED}
+
+ def test_ansi_progress_escapes_are_stripped_before_parsing(self, monkeypatch):
+ # cursor-agent redraws progress with ANSI escapes that must not leak into server names.
+ class _Result:
+ stdout = "\x1b[2K\x1b[1A\x1b[Ggithub: cmd - ✔ Connected\n"
+ stderr = ""
+
+ monkeypatch.setattr(mcp.subprocess, "run", lambda *a, **k: _Result())
+ assert mcp.query_live_mcp_status("cursor") == {"github": mcp.LIVE_CONNECTED}
+
+ def test_health_glyph_wins_over_keyword_in_command(self):
+ # A healthy server whose name/command contains "error" must not be misread as failed:
+ # the ✔ glyph is authoritative.
+ out = "error-mcp: /opt/error-runner start - ✔ Connected\n"
+ assert mcp.parse_mcp_list_output("claude", out) == {"error-mcp": mcp.LIVE_CONNECTED}
+
+ def test_keyword_fallback_used_when_no_glyph(self):
+ assert mcp.parse_mcp_list_output("claude", "foo: bar - Failed to connect\n") == {
+ "foo": mcp.LIVE_FAILED
+ }
+
+
+class TestListMcpCommand:
+ def _state(self):
+ # A developer-added AI Gateway service on claude+codex, a workspace-managed one, and a
+ # skills connection (which must be reported in its own section, never as a plain server).
+ return {
+ "workspace": WS,
+ "available_tools": ["claude", "codex"],
+ "mcp_servers": [
+ {
+ "name": "system-ai-github",
+ "url": f"{WS}/ai-gateway/mcp-services/system.ai.github",
+ "auth": "proxy",
+ "clients": ["claude", "codex"],
+ },
+ {
+ "name": mcp.SKILLS_MCP_SERVER_NAME,
+ "kind": mcp.SKILLS_MCP_KIND,
+ "url": f"{WS}/ai-gateway/skills/",
+ "auth": "proxy",
+ "clients": ["claude"],
+ },
+ ],
+ "managed_mcp_servers": [
+ {
+ "name": "databricks-genie-abc",
+ "url": f"{WS}/api/2.0/mcp/genie/abc",
+ "auth": "proxy",
+ "clients": ["claude"],
+ }
+ ],
+ }
+
+ def _patch(self, monkeypatch, *, installed=("claude", "codex"), live=None):
+ live = live or {}
+ monkeypatch.setattr(mcp, "load_state", lambda: self._state())
+ monkeypatch.setattr(mcp, "available_mcp_clients", lambda: list(installed))
+ monkeypatch.setattr(mcp, "query_live_mcp_status", lambda client: live.get(client, {}))
+
+ def test_reports_configured_servers_as_a_table(self, monkeypatch, capsys):
+ self._patch(
+ monkeypatch,
+ live={
+ "claude": {
+ "system-ai-github": mcp.LIVE_CONNECTED,
+ "databricks-genie-abc": mcp.LIVE_FAILED,
+ "databricks-skill-registry": mcp.LIVE_CONNECTED,
+ },
+ "codex": {"system-ai-github": mcp.LIVE_ENABLED},
+ },
+ )
+
+ assert mcp.list_mcp_command() == 0
+
+ out = _unwrap(capsys.readouterr().out)
+ # One table with NAME/LOCATION/AGENTS/STATUS columns.
+ assert "NAME" in out and "LOCATION" in out and "AGENTS" in out and "STATUS" in out
+ assert "system-ai-github" in out
+ assert "claude, codex" in out
+ # connected (claude) + enabled (codex) collapse to a single healthy token, not a split.
+ assert "connected" in out
+ assert "claude:connected" not in out
+ # The workspace-managed server is tagged, and its failed status shows.
+ assert "databricks-genie-abc" in out
+ assert "(managed)" in out
+ assert "failed" in out
+ # The skills connection is NOT listed here — it's reported by the skill commands.
+ assert "databricks-skill-registry" not in out
+
+ def test_marks_server_missing_when_absent_from_agent_listing(self, monkeypatch, capsys):
+ # Agents list nothing, so servers ug configured show STATUS "missing".
+ self._patch(monkeypatch, live={"claude": {}, "codex": {}})
+ assert mcp.list_mcp_command() == 0
+ assert "missing" in _unwrap(capsys.readouterr().out)
+
+ def test_agent_not_installed_is_flagged(self, monkeypatch, capsys):
+ # Only claude installed; codex diverges to "not installed" in the split status cell.
+ self._patch(monkeypatch, installed=("claude",), live={"claude": {}})
+ assert mcp.list_mcp_command() == 0
+ assert "not installed" in _unwrap(capsys.readouterr().out)
+
+ def test_summarizes_other_servers_as_a_count(self, monkeypatch, capsys):
+ # Servers ug didn't configure are counted per agent, not dumped by name.
+ self._patch(
+ monkeypatch,
+ live={"claude": {"some-other-mcp": mcp.LIVE_CONNECTED}, "codex": {}},
+ )
+ assert mcp.list_mcp_command() == 0
+ out = _unwrap(capsys.readouterr().out)
+ assert "Other MCP servers not configured by ug" in out
+ assert "claude: 1" in out
+ assert "some-other-mcp" not in out
+
+ def test_agents_scope_limits_report(self, monkeypatch, capsys):
+ self._patch(
+ monkeypatch,
+ live={
+ "claude": {"system-ai-github": mcp.LIVE_CONNECTED},
+ "codex": {"system-ai-github": mcp.LIVE_ENABLED},
+ },
+ )
+ assert mcp.list_mcp_command(agents={"claude"}) == 0
+ out = _unwrap(capsys.readouterr().out)
+ # Only claude appears in the AGENTS column and the other-servers count.
+ assert "claude" in out
+ assert "codex" not in out
+
+ def test_codex_caveat_shown_only_when_codex_reported(self, monkeypatch, capsys):
+ # With Codex in scope the note carries its enabled/disabled caveat.
+ self._patch(monkeypatch, live={"claude": {}, "codex": {}})
+ assert mcp.list_mcp_command() == 0
+ assert "Codex reports enabled/disabled" in _unwrap(capsys.readouterr().out)
+
+ def test_codex_caveat_omitted_when_scoped_out(self, monkeypatch, capsys):
+ # Scoping Codex out drops the Codex clause from the note.
+ self._patch(monkeypatch, live={"claude": {}, "codex": {}})
+ assert mcp.list_mcp_command(agents={"claude"}) == 0
+ assert "Codex reports enabled/disabled" not in _unwrap(capsys.readouterr().out)
+
+ def test_unknown_agent_raises(self, monkeypatch):
+ self._patch(monkeypatch)
+ with pytest.raises(RuntimeError, match="Unknown agent"):
+ mcp.list_mcp_command(agents={"bogus"})
+
+ def test_no_configured_servers_still_succeeds(self, monkeypatch, capsys):
+ monkeypatch.setattr(mcp, "load_state", lambda: {"workspace": WS, "available_tools": []})
+ monkeypatch.setattr(mcp, "available_mcp_clients", lambda: [])
+ monkeypatch.setattr(mcp, "query_live_mcp_status", lambda client: {})
+ assert mcp.list_mcp_command() == 0
+ out = _unwrap(capsys.readouterr().out)
+ assert "No MCP servers are configured by ug" in out
+ assert "No supported MCP clients are installed" in out