From 1289a68c2e3a66263a0bb87d00eb1b28f330ec42 Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Wed, 16 Sep 2026 16:37:56 +0000 Subject: [PATCH 1/5] Add `ug mcp` to list configured MCP servers and their connection status `ug mcp` with no subcommand now reports the Databricks MCP servers ug has configured, each annotated per coding agent with live status read from that agent's own `mcp list` (connected/failed; Codex reports enabled/disabled since its listing does not health-check). Workspace-managed servers and the skills connection are shown separately, and servers an agent lists that ug did not configure are summarized. Read-only; needs no Databricks login. The add/remove/web-search subcommands are unchanged. Co-authored-by: Isaac --- README.md | 20 +++ src/ucode/cli.py | 40 +++++- src/ucode/mcp.py | 319 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 26 ++++ tests/test_mcp.py | 170 ++++++++++++++++++++++++ 5 files changed, 574 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 606e2097..ad151ca5 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,24 @@ 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, run `ug mcp` with no subcommand: + +```bash +ug mcp + +# Limit the report to specific agents. +ug mcp --agents claude,codex +``` + +For every configured server it shows, per agent, whether that agent's own `mcp list` reports it +connected (Codex reports `enabled`/`disabled`, since its listing does not health-check). +Workspace-managed servers and the skills connection appear in their own sections, and any servers +an agent lists that `ug` didn't configure are summarized separately. 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`: @@ -413,6 +431,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 configured MCP servers and their live per-agent connection status | +| `ug mcp --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 8dc024e8..601a88a9 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -100,6 +100,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, @@ -1042,7 +1043,7 @@ 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) +mcp_app = typer.Typer(add_completion=False, no_args_is_help=False) app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ug.") skill_app = typer.Typer(add_completion=False, no_args_is_help=True) app.add_typer(skill_app, name="skill", help="Databricks Skills for your coding tools.") @@ -1180,6 +1181,43 @@ def mcp_remove( raise typer.Exit(130) from None +@mcp_app.callback(invoke_without_command=True) +def mcp_default( + ctx: typer.Context, + 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. + + With no subcommand, `ug mcp` 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. + """ + # A subcommand (add/remove/web-search) was given, so this callback only ran to parse group + # options; let the subcommand handle it. + if ctx.invoked_subcommand is not None: + return + 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 9771b583..d13b7fca 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 @@ -46,6 +47,7 @@ _Back, console, picker_style, + print_heading, print_kv, print_note, print_section, @@ -1714,6 +1716,323 @@ 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.""" + low = rest.lower() + if ( + any(marker in rest for marker in _HEALTH_FAIL_MARKERS) + or "fail" in low + or "error" in low + or "disconnect" in low + ): + return LIVE_FAILED + if any(marker in rest for marker in _HEALTH_OK_MARKERS) or "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_type_label(server: dict) -> str: + """Short human label for a configured MCP server, derived from its Databricks URL shape.""" + url = str(server.get("url") or "") + if AIGW_MCP_SERVICES_PATH in url: + return url.split(AIGW_MCP_SERVICES_PATH, 1)[1] or "MCP service" + if "/api/2.0/mcp/external/" in url: + return f"connection: {url.rstrip('/').rsplit('/', 1)[-1]}" + if "/api/2.0/mcp/genie/" in url: + return "Genie space" + if "/api/2.0/mcp/vector-search/" in url: + return "Vector Search" + if "/api/2.0/mcp/functions/" in url: + return "UC Functions" + if url.rstrip("/").endswith("/api/2.0/mcp/sql"): + return "Databricks SQL" + if _is_app_mcp_server(server): + return "Databricks app" + return url or "unknown" + + +def _live_status_markup(state: str | None) -> str: + """Rich markup for a per-agent live status; ``None`` means the agent isn't installed.""" + if state is None: + return "[dim]agent not installed[/dim]" + return { + LIVE_CONNECTED: "[green]✔ connected[/green]", + LIVE_FAILED: "[red]✘ failed[/red]", + LIVE_ENABLED: "[green]• enabled[/green]", + LIVE_DISABLED: "[yellow]• disabled[/yellow]", + LIVE_UNKNOWN: "[yellow]? status unknown[/yellow]", + LIVE_NOT_REGISTERED: "[dim]not registered[/dim]", + }.get(state, "[dim]not registered[/dim]") + + +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) + + +def _print_server_live_status( + name: str, + clients: list[str], + installed: list[str], + live: dict[str, dict[str, str]], +) -> None: + """Print one configured server's per-agent live status rows.""" + for client in clients: + display = str(MCP_CLIENTS[client]["display"]) + markup = _live_status_markup(_resolve_live_status(client, name, installed, live)) + console.print(f" {display}: {markup}") + + +def list_mcp_command(agents: set[str] | None = None) -> int: + """`ug mcp` (no subcommand): show the Databricks MCP servers ug has configured and their live + connection status in each coding agent. + + Read-only: it reads ug's saved state and each installed agent's own `mcp list`, and needs no + Databricks login. For each configured server it reports, per agent, whether that agent currently + connects to it (Codex reports enabled/disabled, since its listing does not health-check). + Workspace-managed servers and the skills connection are shown in their own sections. ``agents`` + (from ``--agents``) scopes the whole 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 + # (skills connections are reported separately). ``--agents`` drops agents outside the scope, + # and a server left with no in-scope agent is omitted. + 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: + print_heading("Configured MCP servers") + for name in sorted(configured): + entry = configured[name] + label = _mcp_server_type_label(entry["server"]) + managed_tag = " [magenta](workspace-managed)[/magenta]" if entry["managed"] else "" + console.print(f" [bold]{name}[/bold] [dim]{label}[/dim]{managed_tag}") + _print_server_live_status(name, entry["clients"], installed, live) + else: + print_heading("Configured MCP servers") + print_note(f"No MCP servers are configured by ug{scope_note}.") + + skills_entry = _skills_entry(list(state.get("mcp_servers") or [])) + skills_clients = ( + [ + client + for client in _mcp_server_clients(skills_entry) + if agents is None or client in agents + ] + if skills_entry + else [] + ) + if skills_clients: + print_heading("Skills MCP connection") + console.print(f" [bold]{SKILLS_MCP_SERVER_NAME}[/bold]") + _print_server_live_status(SKILLS_MCP_SERVER_NAME, skills_clients, installed, live) + + # Anything an agent lists that ug didn't configure: surface a count + names so the developer + # sees connections ug isn't tracking (e.g. hand-added servers) without conflating them with ug's. + ug_names = set(configured) + if skills_clients: + ug_names.add(SKILLS_MCP_SERVER_NAME) + other_by_client = { + client: sorted(name for name in live.get(client, {}) if name not in ug_names) + for client in probe_clients + } + if any(other_by_client.values()): + print_heading("Other MCP servers (not configured by ug)") + for client in probe_clients: + names = other_by_client[client] + if names: + print_kv(str(MCP_CLIENTS[client]["display"]), f"{len(names)}: {', '.join(names)}") + + console.print() + print_note( + "Live status comes from each agent's own `mcp list`; Codex reports enabled/disabled " + "(it does not health-check)." + ) + 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 cbb5afd2..245ee959 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_lists_configured_servers(self, monkeypatch): + # `ug mcp` with no subcommand runs the listing (not the group help). + calls: list[set[str] | None] = [] + monkeypatch.setattr(cli_mod, "list_mcp_command", lambda agents=None: calls.append(agents)) + result = runner.invoke(app, ["mcp"]) + assert result.exit_code == 0, result.output + assert calls == [None] + + def test_bare_mcp_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", "--agents", "claude,codex"]) + assert result.exit_code == 0, result.output + assert calls == [{"claude", "codex"}] + + def test_mcp_subcommand_still_routes_without_listing(self, monkeypatch): + # A subcommand must reach its own handler, not the bare-mcp lister. + monkeypatch.setattr( + cli_mod, + "list_mcp_command", + lambda agents=None: pytest.fail("listing ran for subcommand"), + ) + result = runner.invoke(app, ["mcp", "remove", "--help"]) + assert result.exit_code == 0 + assert "Usage:" in result.output + class TestAuthTokenCommand: """`ucode auth-token` is the cross-platform apiKeyHelper (#116).""" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c163e930..af2cc62e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3144,3 +3144,173 @@ 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} + + +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_with_live_status(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) + assert "system-ai-github" in out + assert "Claude Code: ✔ connected" in out + assert "Codex: • enabled" in out + # The workspace-managed server is tagged and shows its live status on claude. + assert "databricks-genie-abc" in out + assert "(workspace-managed)" in out + # The skills connection is reported in its own section, not among plain servers. + assert "Skills MCP connection" in out + assert "databricks-skill-registry" in out + + def test_marks_server_not_registered_when_absent_from_agent_listing(self, monkeypatch, capsys): + # claude lists nothing, so the server it's configured on shows "not registered". + self._patch(monkeypatch, live={"claude": {}, "codex": {}}) + assert mcp.list_mcp_command() == 0 + assert "not registered" in _unwrap(capsys.readouterr().out) + + def test_agent_not_installed_is_flagged(self, monkeypatch, capsys): + # Only claude installed; codex-configured rows report "agent not installed". + self._patch(monkeypatch, installed=("claude",), live={"claude": {}}) + assert mcp.list_mcp_command() == 0 + assert "agent not installed" in _unwrap(capsys.readouterr().out) + + def test_lists_other_servers_not_configured_by_ug(self, monkeypatch, capsys): + 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 "some-other-mcp" 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) + assert "Claude Code:" in out + assert "Codex:" not in 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 From ea4795bea845785d16b9c6d7a1de9bfe4b552d89 Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Wed, 16 Sep 2026 21:48:27 +0000 Subject: [PATCH 2/5] Show help on bare `ug mcp`, list under `ug mcp list`, and slim `ug status` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per e2e review feedback: `ug mcp` with no subcommand now shows the group help (its command list) following the Claude CLI convention, and the connection listing moved to the `ug mcp list` subcommand. `ug status` is kept a high-level overview — it now shows a per-agent MCP server count and points to `ug mcp list` for the per-server detail and live connection status, instead of printing each agent's raw `mcp list` command and server names. Co-authored-by: Isaac --- README.md | 10 +++---- src/ucode/cli.py | 36 ++++++++++++------------ src/ucode/mcp.py | 2 +- tests/test_cli.py | 70 ++++++++++++++++++++++------------------------- 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ad151ca5..8c668cdf 100644 --- a/README.md +++ b/README.md @@ -257,13 +257,13 @@ on — and removes the ones you select from those tools. It needs no Databricks #### 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, run `ug mcp` with no subcommand: +connected to them, use `ug mcp list`: ```bash -ug mcp +ug mcp list # Limit the report to specific agents. -ug mcp --agents claude,codex +ug mcp list --agents claude,codex ``` For every configured server it shows, per agent, whether that agent's own `mcp list` reports it @@ -431,8 +431,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 configured MCP servers and their live per-agent connection status | -| `ug mcp --agents claude` | Show the connection-status report for 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 601a88a9..917492a5 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -928,18 +928,16 @@ 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. + mcp_count = sum( + 1 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(mcp_count)) 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( @@ -984,6 +982,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." ) @@ -1043,8 +1042,12 @@ 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=False) -app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ug.") +mcp_app = typer.Typer(add_completion=False, no_args_is_help=True) +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="skill", help="Databricks Skills for your coding tools.") @@ -1181,9 +1184,8 @@ def mcp_remove( raise typer.Exit(130) from None -@mcp_app.callback(invoke_without_command=True) -def mcp_default( - ctx: typer.Context, +@mcp_app.command("list") +def mcp_list( agents: Annotated[ str | None, typer.Option( @@ -1195,14 +1197,10 @@ def mcp_default( ) -> None: """List the Databricks MCP servers ug has configured and their live connection status. - With no subcommand, `ug mcp` 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. + 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. """ - # A subcommand (add/remove/web-search) was given, so this callback only ran to parse group - # options; let the subcommand handle it. - if ctx.invoked_subcommand is not None: - return requested_agents = ( None if agents is None diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index d13b7fca..b413436d 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -1928,7 +1928,7 @@ def _print_server_live_status( def list_mcp_command(agents: set[str] | None = None) -> int: - """`ug mcp` (no subcommand): show the Databricks MCP servers ug has configured and their live + """`ug mcp list`: show the Databricks MCP servers ug has configured and their live connection status in each coding agent. Read-only: it reads ug's saved state and each installed agent's own `mcp list`, and needs no diff --git a/tests/test_cli.py b/tests/test_cli.py index 245ee959..8f38630a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1188,32 +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_lists_configured_servers(self, monkeypatch): - # `ug mcp` with no subcommand runs the listing (not the group help). + 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"]) + result = runner.invoke(app, ["mcp", "list"]) assert result.exit_code == 0, result.output assert calls == [None] - def test_bare_mcp_forwards_agents_option(self, monkeypatch): + 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", "--agents", "claude,codex"]) + result = runner.invoke(app, ["mcp", "list", "--agents", "claude,codex"]) assert result.exit_code == 0, result.output assert calls == [{"claude", "codex"}] - def test_mcp_subcommand_still_routes_without_listing(self, monkeypatch): - # A subcommand must reach its own handler, not the bare-mcp lister. - monkeypatch.setattr( - cli_mod, - "list_mcp_command", - lambda agents=None: pytest.fail("listing ran for subcommand"), - ) - result = runner.invoke(app, ["mcp", "remove", "--help"]) - assert result.exit_code == 0 - assert "Usage:" in result.output - class TestAuthTokenCommand: """`ucode auth-token` is the cross-platform apiKeyHelper (#116).""" @@ -1356,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": [ @@ -1391,13 +1392,11 @@ 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_status_treats_available_tools_as_configured_agents(self): state = { @@ -1420,11 +1419,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 From 97b6e8c0860aa1fdda821e08f9f3687f902103ad Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Wed, 16 Sep 2026 22:06:46 +0000 Subject: [PATCH 3/5] Render `ug mcp list` as a compact one-row-per-server table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the multi-line per-agent output with a single NAME/LOCATION/AGENTS/ STATUS table (one row per server), addressing review feedback that the listing was too long. STATUS aggregates the agents a server is on — treating a Codex `enabled` and a Claude `connected` as one healthy state so the common case collapses to a single token, and splitting into `agent:state` only when agents diverge (e.g. a failure on one). The skills connection is a `skills` row in the same table, and servers ug didn't configure are summarized as a per-agent count instead of a name dump. Co-authored-by: Isaac --- README.md | 11 +-- src/ucode/mcp.py | 196 ++++++++++++++++++++++++++++------------------ tests/test_mcp.py | 42 ++++++---- 3 files changed, 151 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 8c668cdf..99f0bcfe 100644 --- a/README.md +++ b/README.md @@ -266,11 +266,12 @@ ug mcp list ug mcp list --agents claude,codex ``` -For every configured server it shows, per agent, whether that agent's own `mcp list` reports it -connected (Codex reports `enabled`/`disabled`, since its listing does not health-check). -Workspace-managed servers and the skills connection appear in their own sections, and any servers -an agent lists that `ug` didn't configure are summarized separately. It reads local state plus each -installed agent's `mcp list`, so it needs no Databricks login. +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, the skills connection is shown as a `skills` row, and any +servers an agent lists that `ug` didn't configure are summarized as a per-agent count. It reads +local state plus each installed agent's `mcp list`, so it needs no Databricks login. ### Skills (optional) diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index b413436d..6f293207 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -13,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 @@ -47,7 +48,6 @@ _Back, console, picker_style, - print_heading, print_kv, print_note, print_section, @@ -1870,40 +1870,29 @@ def _query_live_statuses(clients: list[str]) -> dict[str, dict[str, str]]: return results -def _mcp_server_type_label(server: dict) -> str: - """Short human label for a configured MCP server, derived from its Databricks URL shape.""" +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" + return url.split(AIGW_MCP_SERVICES_PATH, 1)[1] or "mcp-service" if "/api/2.0/mcp/external/" in url: - return f"connection: {url.rstrip('/').rsplit('/', 1)[-1]}" + return f"connection:{stripped.rsplit('/', 1)[-1]}" if "/api/2.0/mcp/genie/" in url: - return "Genie space" + return f"genie:{stripped.rsplit('/', 1)[-1]}" if "/api/2.0/mcp/vector-search/" in url: - return "Vector Search" + return f"vector-search:{'.'.join(stripped.split('/')[-2:])}" if "/api/2.0/mcp/functions/" in url: - return "UC Functions" - if url.rstrip("/").endswith("/api/2.0/mcp/sql"): - return "Databricks SQL" + return f"uc-functions:{'.'.join(stripped.split('/')[-2:])}" + if stripped.endswith("/api/2.0/mcp/sql"): + return "databricks-sql" if _is_app_mcp_server(server): - return "Databricks app" + return "app" return url or "unknown" -def _live_status_markup(state: str | None) -> str: - """Rich markup for a per-agent live status; ``None`` means the agent isn't installed.""" - if state is None: - return "[dim]agent not installed[/dim]" - return { - LIVE_CONNECTED: "[green]✔ connected[/green]", - LIVE_FAILED: "[red]✘ failed[/red]", - LIVE_ENABLED: "[green]• enabled[/green]", - LIVE_DISABLED: "[yellow]• disabled[/yellow]", - LIVE_UNKNOWN: "[yellow]? status unknown[/yellow]", - LIVE_NOT_REGISTERED: "[dim]not registered[/dim]", - }.get(state, "[dim]not registered[/dim]") - - def _resolve_live_status( client: str, name: str, installed: list[str], live: dict[str, dict[str, str]] ) -> str | None: @@ -1914,28 +1903,78 @@ def _resolve_live_status( return live.get(client, {}).get(name, LIVE_NOT_REGISTERED) -def _print_server_live_status( - name: str, - clients: list[str], - installed: list[str], - live: dict[str, dict[str, str]], -) -> None: - """Print one configured server's per-agent live status rows.""" - for client in clients: - display = str(MCP_CLIENTS[client]["display"]) - markup = _live_status_markup(_resolve_live_status(client, name, installed, live)) - console.print(f" {display}: {markup}") +# 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. + 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. For each configured server it reports, per agent, whether that agent currently - connects to it (Codex reports enabled/disabled, since its listing does not health-check). - Workspace-managed servers and the skills connection are shown in their own sections. ``agents`` - (from ``--agents``) scopes the whole report to that subset of agents. + 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) @@ -1958,9 +1997,9 @@ def list_mcp_command(agents: set[str] | None = None) -> int: live = _query_live_statuses(probe_clients) - # Merge developer- and workspace-managed servers by registered name, unioning their agents - # (skills connections are reported separately). ``--agents`` drops agents outside the scope, - # and a server left with no in-scope agent is omitted. + # 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 appended as a final row so everything is one table. configured: dict[str, dict[str, Any]] = {} def _collect(server: dict, *, managed: bool) -> None: @@ -1981,18 +2020,6 @@ def _collect(server: dict, *, managed: bool) -> None: for server in state.get("managed_mcp_servers") or []: _collect(server, managed=True) - if configured: - print_heading("Configured MCP servers") - for name in sorted(configured): - entry = configured[name] - label = _mcp_server_type_label(entry["server"]) - managed_tag = " [magenta](workspace-managed)[/magenta]" if entry["managed"] else "" - console.print(f" [bold]{name}[/bold] [dim]{label}[/dim]{managed_tag}") - _print_server_live_status(name, entry["clients"], installed, live) - else: - print_heading("Configured MCP servers") - print_note(f"No MCP servers are configured by ug{scope_note}.") - skills_entry = _skills_entry(list(state.get("mcp_servers") or [])) skills_clients = ( [ @@ -2003,32 +2030,49 @@ def _collect(server: dict, *, managed: bool) -> None: if skills_entry else [] ) - if skills_clients: - print_heading("Skills MCP connection") - console.print(f" [bold]{SKILLS_MCP_SERVER_NAME}[/bold]") - _print_server_live_status(SKILLS_MCP_SERVER_NAME, skills_clients, installed, live) - # Anything an agent lists that ug didn't configure: surface a count + names so the developer - # sees connections ug isn't tracking (e.g. hand-added servers) without conflating them with ug's. + if configured or skills_clients: + 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), + ) + if skills_clients: + table.add_row( + SKILLS_MCP_SERVER_NAME, + "skills", + ", ".join(skills_clients), + _row_status(skills_clients, SKILLS_MCP_SERVER_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. ug_names = set(configured) if skills_clients: ug_names.add(SKILLS_MCP_SERVER_NAME) - other_by_client = { - client: sorted(name for name in live.get(client, {}) if name not in ug_names) + other_counts = [ + (client, sum(1 for name in live.get(client, {}) if name not in ug_names)) for client in probe_clients - } - if any(other_by_client.values()): - print_heading("Other MCP servers (not configured by ug)") - for client in probe_clients: - names = other_by_client[client] - if names: - print_kv(str(MCP_CLIENTS[client]["display"]), f"{len(names)}: {', '.join(names)}") + ] + 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}.") - console.print() - print_note( - "Live status comes from each agent's own `mcp list`; Codex reports enabled/disabled " - "(it does not health-check)." - ) + print_note("Live status is from each agent's `mcp list`; Codex reports enabled/disabled.") print_note("Use `ug mcp add` / `ug mcp remove` to change the servers ug configures.") return 0 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index af2cc62e..615851f2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3240,7 +3240,7 @@ def _patch(self, monkeypatch, *, installed=("claude", "codex"), live=None): 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_with_live_status(self, monkeypatch, capsys): + def test_reports_configured_servers_as_a_table(self, monkeypatch, capsys): self._patch( monkeypatch, live={ @@ -3256,37 +3256,44 @@ def test_reports_configured_servers_with_live_status(self, monkeypatch, capsys): 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 Code: ✔ connected" in out - assert "Codex: • enabled" in out - # The workspace-managed server is tagged and shows its live status on claude. + 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 "(workspace-managed)" in out - # The skills connection is reported in its own section, not among plain servers. - assert "Skills MCP connection" in out + assert "(managed)" in out + assert "failed" in out + # The skills connection is a row in the same table (LOCATION "skills"). assert "databricks-skill-registry" in out + assert "skills" in out - def test_marks_server_not_registered_when_absent_from_agent_listing(self, monkeypatch, capsys): - # claude lists nothing, so the server it's configured on shows "not registered". + 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 "not registered" in _unwrap(capsys.readouterr().out) + assert "missing" in _unwrap(capsys.readouterr().out) def test_agent_not_installed_is_flagged(self, monkeypatch, capsys): - # Only claude installed; codex-configured rows report "agent not installed". + # 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 "agent not installed" in _unwrap(capsys.readouterr().out) + assert "not installed" in _unwrap(capsys.readouterr().out) - def test_lists_other_servers_not_configured_by_ug(self, monkeypatch, capsys): + 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 "some-other-mcp" in 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( @@ -3298,8 +3305,9 @@ def test_agents_scope_limits_report(self, monkeypatch, capsys): ) assert mcp.list_mcp_command(agents={"claude"}) == 0 out = _unwrap(capsys.readouterr().out) - assert "Claude Code:" in out - assert "Codex:" not in out + # Only claude appears in the AGENTS column and the other-servers count. + assert "claude" in out + assert "codex" not in out def test_unknown_agent_raises(self, monkeypatch): self._patch(monkeypatch) From 7e361a71e17df7909e2c54bf067862be6a2530a2 Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Wed, 16 Sep 2026 22:25:23 +0000 Subject: [PATCH 4/5] Exclude the skills MCP connection from `ug mcp list` The skills registry connection is surfaced by the skill commands (`ug skill` / `ug configure skills`), so it no longer appears as a row in `ug mcp list`. It is still treated as ug-managed, so it's never miscounted in the "other servers not configured by ug" summary. Co-authored-by: Isaac --- README.md | 7 ++++--- src/ucode/mcp.py | 29 +++++------------------------ tests/test_mcp.py | 5 ++--- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 99f0bcfe..96ca903a 100644 --- a/README.md +++ b/README.md @@ -269,9 +269,10 @@ 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, the skills connection is shown as a `skills` row, and any -servers an agent lists that `ug` didn't configure are summarized as a per-agent count. It reads -local state plus each installed agent's `mcp list`, so it needs no Databricks login. +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) diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 6f293207..17cca717 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -1999,7 +1999,7 @@ def list_mcp_command(agents: set[str] | None = None) -> int: # 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 appended as a final row so everything is one table. + # 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: @@ -2020,18 +2020,7 @@ def _collect(server: dict, *, managed: bool) -> None: for server in state.get("managed_mcp_servers") or []: _collect(server, managed=True) - skills_entry = _skills_entry(list(state.get("mcp_servers") or [])) - skills_clients = ( - [ - client - for client in _mcp_server_clients(skills_entry) - if agents is None or client in agents - ] - if skills_entry - else [] - ) - - if configured or skills_clients: + if configured: table = Table(box=None, pad_edge=False, header_style="bold") table.add_column("NAME", no_wrap=True) table.add_column("LOCATION") @@ -2048,22 +2037,14 @@ def _collect(server: dict, *, managed: bool) -> None: ", ".join(entry["clients"]), _row_status(entry["clients"], name, installed, live), ) - if skills_clients: - table.add_row( - SKILLS_MCP_SERVER_NAME, - "skills", - ", ".join(skills_clients), - _row_status(skills_clients, SKILLS_MCP_SERVER_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. - ug_names = set(configured) - if skills_clients: - ug_names.add(SKILLS_MCP_SERVER_NAME) + # 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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 615851f2..883b8ae2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3267,9 +3267,8 @@ def test_reports_configured_servers_as_a_table(self, monkeypatch, capsys): assert "databricks-genie-abc" in out assert "(managed)" in out assert "failed" in out - # The skills connection is a row in the same table (LOCATION "skills"). - assert "databricks-skill-registry" in out - assert "skills" 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". From 7c9e60a610f7ca63cb27eaafdd76ecf8c32eab83 Mon Sep 17 00:00:00 2001 From: Sunish Sheth Date: Thu, 17 Sep 2026 15:48:02 +0000 Subject: [PATCH 5/5] Address review comments on `ug mcp list` / `ug status` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Classify live status by health glyph first, only falling back to keyword text when no ✔/✘ is present, so a healthy server whose name/URL contains "fail"/"error" isn't misread as failed. - `ug status` MCP count now folds in workspace-managed servers and dedupes by name, so it agrees with `ug mcp list`. - The `ug mcp list` note mentions Codex's enabled/disabled caveat only when Codex is in the reported set (e.g. omitted under `--agents claude`). - Pull the shared `/api/2.0/mcp/...` path fragments into module constants reused by `_is_app_mcp_server` and `_mcp_server_location`. Co-authored-by: Isaac --- src/ucode/cli.py | 14 ++++++------ src/ucode/mcp.py | 55 ++++++++++++++++++++++++++++++----------------- tests/test_cli.py | 37 +++++++++++++++++++++++++++++++ tests/test_mcp.py | 23 ++++++++++++++++++++ 4 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 917492a5..d3386c4e 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -893,7 +893,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")) @@ -929,15 +930,16 @@ def status() -> int: print_kv("Base URL", base_url) if configured and tool in MCP_CLIENTS: # 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. - mcp_count = sum( - 1 + # 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 servers", str(mcp_count)) + } + 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( diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 17cca717..72825096 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -61,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 @@ -730,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") @@ -1743,16 +1751,19 @@ def remove_mcp_command(agents: set[str] | None = None) -> int: def _classify_health_line(rest: str) -> str: - """Map the text trailing a server name in an agent's `mcp list` to a live state.""" + """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 ( - any(marker in rest for marker in _HEALTH_FAIL_MARKERS) - or "fail" in low - or "error" in low - or "disconnect" in low - ): + if "fail" in low or "error" in low or "disconnect" in low: return LIVE_FAILED - if any(marker in rest for marker in _HEALTH_OK_MARKERS) or "connected" in low or "ready" in low: + if "connected" in low or "ready" in low: return LIVE_CONNECTED return LIVE_UNKNOWN @@ -1878,15 +1889,15 @@ def _mcp_server_location(server: dict) -> str: stripped = url.rstrip("/") if AIGW_MCP_SERVICES_PATH in url: return url.split(AIGW_MCP_SERVICES_PATH, 1)[1] or "mcp-service" - if "/api/2.0/mcp/external/" in url: + if MCP_EXTERNAL_PATH in url: return f"connection:{stripped.rsplit('/', 1)[-1]}" - if "/api/2.0/mcp/genie/" in url: + if MCP_GENIE_PATH in url: return f"genie:{stripped.rsplit('/', 1)[-1]}" - if "/api/2.0/mcp/vector-search/" in url: + if MCP_VECTOR_SEARCH_PATH in url: return f"vector-search:{'.'.join(stripped.split('/')[-2:])}" - if "/api/2.0/mcp/functions/" in url: + if MCP_FUNCTIONS_PATH in url: return f"uc-functions:{'.'.join(stripped.split('/')[-2:])}" - if stripped.endswith("/api/2.0/mcp/sql"): + if stripped.endswith(MCP_SQL_PATH): return "databricks-sql" if _is_app_mcp_server(server): return "app" @@ -2053,7 +2064,11 @@ def _collect(server: dict, *, managed: bool) -> None: if other_summary: print_note(f"Other MCP servers not configured by ug — {other_summary}.") - print_note("Live status is from each agent's `mcp list`; Codex reports enabled/disabled.") + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f38630a..95229169 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1398,6 +1398,43 @@ def test_shows_mcp_server_counts_configured_by_ucode(self): 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 = { **MINIMAL_STATE, diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 883b8ae2..e10dd432 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3201,6 +3201,17 @@ class _Result: 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): @@ -3308,6 +3319,18 @@ def test_agents_scope_limits_report(self, monkeypatch, capsys): 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"):