Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,10 @@ subscription token and Databricks gateway token on macOS, Windows, and Linux.

## MCP Servers

Register Databricks MCP servers for configured MCP-capable agents. Cursor Agent
is MCP-only and is included when `cursor-agent` is installed:
Use `ug mcp add` to register Databricks MCP servers for coding agents. Cursor
Agent supports MCP only and requires `cursor-agent` on PATH.

Run `ug mcp add` without selectors to browse a table of services by name, UC location,
Run `ug mcp add` without options to browse services by name, UC location,
and status. ✓ means configured (or managed by the workspace); ✗ means not configured.
Configured and managed services stay visible as read-only rows. `[ ]`/`[x]` indicate
what you select for this operation; `[-]` marks a read-only row.
Expand All @@ -224,11 +224,15 @@ ug mcp add --agents cursor --names system.ai.github --workspace https://first.da
ug mcp add --agents codex --names system.ai.github --profile DEFAULT
```

With `--agents`, installed agents can be registered for MCP without configuring their
models. `--workspace` or `--profile` selects the workspace on a fresh setup. If another
workspace is already active, run `ug configure --workspace <url>` to switch first;
the switch replaces the agent's active MCP registrations with those saved for the
selected workspace. Switching back restores the earlier workspace's saved servers.
`--agents` adds MCP servers without changing an agent's models. Claude Code and
Codex work even if their CLI is not on PATH: UG writes their user MCP config
directly. If that file already exists, UG detects the client without its CLI.
This works on macOS, Windows, and Linux; other agents need their CLI on PATH.
Without a CLI, status shows the saved config but cannot check the connection.

`--workspace` or `--profile` selects the workspace on a fresh setup. To switch
an existing setup, run `ug configure --workspace <url>` first. The switch uses
that workspace's saved MCP servers; switching back restores the previous ones.

Remove configured servers:

Expand Down
11 changes: 8 additions & 3 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,8 +1059,8 @@ def write_user_mcp_servers(add: dict[str, dict], remove: set[str]) -> set[str]:
developer's own servers and every other key in the file are preserved. Returns the subset of
``remove`` names that were actually present (so callers can report only real removals).

If the file exists but can't be parsed as a JSON object, we must not clobber it, so we defer to
the per-server ``claude`` CLI (which edits the file in place) for exactly the changed entries."""
If the file exists but can't be parsed as a JSON object, we must not clobber it. Defer to the
CLI when available; otherwise report the invalid config."""
removed: set[str] = set()

def mutate(config: dict) -> bool:
Expand All @@ -1074,7 +1074,12 @@ def mutate(config: dict) -> bool:
config["mcpServers"] = servers
return True

if not _update_claude_config(claude_code_config_path(), mutate):
path = claude_code_config_path()
if not _update_claude_config(path, mutate):
if shutil.which("claude") is None:
raise RuntimeError(
f"Claude Code config at {path} is invalid. Fix it or install the CLI."
)
for name in remove:
# Clean every scope (not short-circuited), recording the name if any scope had it.
if [scope for scope in MCP_CLEANUP_SCOPES if remove_claude_mcp_server(name, scope)]:
Expand Down
3 changes: 3 additions & 0 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import plistlib
import re
import shutil
import signal
import socket
import sys
Expand Down Expand Up @@ -1035,6 +1036,8 @@ def write_user_mcp_servers(add: dict[str, dict], remove: set[str]) -> set[str]:
path = user_mcp_config_path()
doc = _read_user_config_for_rewrite(path)
if doc is None:
if shutil.which("codex") is None:
raise RuntimeError(f"Codex config at {path} is invalid. Fix it or install the CLI.")
if any("url" in entry for entry in add.values()):
raise RuntimeError(
f"Cannot register native OAuth MCP server: Codex config is invalid TOML at {path}."
Expand Down
13 changes: 8 additions & 5 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
resolve_state,
)
from ucode.mcp import (
CONFIG_WRITABLE_MCP_CLIENTS,
MCP_CLIENTS,
McpServiceListingRateLimited,
add_mcp_command,
Expand Down Expand Up @@ -1965,7 +1966,8 @@ def _configure_agents_for_mcp(
"""Select MCP clients for one workspace without changing agent model settings."""
scope = {a if a == "cursor" else normalize_tool(a) for a in requested}
installed = available_mcp_clients()
not_installed = sorted(scope - set(installed))
# A named client whose user MCP config UG writes directly needs no CLI on PATH.
not_installed = sorted(scope - set(installed) - CONFIG_WRITABLE_MCP_CLIENTS.keys())
if not_installed:
raise RuntimeError(_not_installed_message(not_installed))
previous_workspace = current_workspace_key(load_full_state())
Expand Down Expand Up @@ -2072,7 +2074,8 @@ def mcp_add(
typer.Option(
"--agents",
help="Comma-separated coding agents to register the server(s) for (e.g. "
"claude,codex,cursor). MCP registration is independent of model setup. Without --agents, "
"claude,codex,cursor). Claude/Codex user config can be written even without a CLI on "
"PATH. MCP registration is independent of model setup. Without --agents, "
"the server is registered for every already-configured agent.",
),
] = None,
Expand All @@ -2095,11 +2098,11 @@ def mcp_add(
),
] = None,
) -> None:
"""Add Databricks MCP servers to installed coding tools.
"""Add Databricks MCP servers to supported coding tools.

Purely additive: it never removes MCP servers that are already configured, only registers new
ones (use `ug mcp remove` to remove). Pass --agents to target (and, if needed, set up) specific
agents. Explicit MCP setup never configures agent models or machine-wide settings.
ones (use `ug mcp remove` to remove). Pass --agents to target specific clients. Explicit MCP
setup never configures agent models or machine-wide settings.
"""
selected = None if names is None else {s.strip() for s in names.split(",") if s.strip()}
requested_agents = (
Expand Down
92 changes: 84 additions & 8 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import threading
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -124,6 +125,12 @@
"list_command": "agy mcp list",
},
}
# These clients read their user MCP files on every supported OS. UG can write those files
# directly, including when a desktop app exists without a CLI on PATH.
CONFIG_WRITABLE_MCP_CLIENTS: dict[str, Callable[[], Path]] = {
"claude": lambda: claude.claude_code_config_path(),
"codex": lambda: codex.user_mcp_config_path(),
}
SKILLS_MCP_KIND = "skills"
SKILLS_MCP_SERVER_NAME = "databricks-skill-registry"
SKILL_LOCATIONS_BY_CLIENT_KEY = "skill_locations_by_client"
Expand Down Expand Up @@ -255,7 +262,21 @@ def remove_gemini_mcp_server(name: str) -> bool:


def available_mcp_clients() -> list[str]:
return [client for client, spec in MCP_CLIENTS.items() if shutil.which(str(spec["binary"]))]
return [
client
for client in MCP_CLIENTS
if _mcp_cli_available(client) or _config_only_mcp_client(client)
]


def _mcp_cli_available(client: str) -> bool:
return shutil.which(str(MCP_CLIENTS[client]["binary"])) is not None


def _config_only_mcp_client(client: str) -> bool:
"""A client with a readable user config and no CLI; presence is not live health."""
path = CONFIG_WRITABLE_MCP_CLIENTS.get(client)
return path is not None and path().is_file()


def configured_mcp_clients(state: dict, installed_clients: list[str]) -> list[str]:
Expand Down Expand Up @@ -363,6 +384,20 @@ def configure_client_mcp_server(
use_pat: bool = False,
always_load: bool = False,
) -> list[str]:
# The desktop apps read these files on every OS. An explicitly selected client can be
# configured before its CLI is on PATH; the writer creates the user config if needed.
if client in CONFIG_WRITABLE_MCP_CLIENTS and not _mcp_cli_available(client):
entry = _managed_mcp_entry(
client,
url,
workspace,
profile,
use_pat=use_pat,
always_load=always_load,
http_client=_oauth_http_client(client, workspace, use_pat=use_pat),
)
removed = _MCP_CLIENT_MODULES[client].write_user_mcp_servers({name: entry}, {name})
Comment thread
sunishsheth2009 marked this conversation as resolved.
Resolved
return [MCP_USER_SCOPE] if removed else []
# Connection-backed services use native HTTP OAuth for CIMD-capable agents.
# Other cases keep the stdio proxy: no-login services, unrelated URLs, PAT,
# unsupported agents, and workspaces without CIMD support.
Expand Down Expand Up @@ -429,6 +464,9 @@ def configure_client_mcp_server(


def remove_client_mcp_server(client: str, name: str) -> list[str]:
if client in CONFIG_WRITABLE_MCP_CLIENTS and not _mcp_cli_available(client):
removed = _MCP_CLIENT_MODULES[client].write_user_mcp_servers({}, {name})
return [MCP_USER_SCOPE] if removed else []
if client == "claude":
return [
scope for scope in MCP_CLEANUP_SCOPES if claude.remove_claude_mcp_server(name, scope)
Expand Down Expand Up @@ -1944,12 +1982,18 @@ def setup_mcp_clients(
purge_cross_workspace_mcp_residue(state, workspace)

installed_clients = available_mcp_clients()
if not installed_clients:
explicit_direct = (
set(state.get("mcp_clients") or []) & (agents or set()) & CONFIG_WRITABLE_MCP_CLIENTS.keys()
)
if not installed_clients and not explicit_direct:
raise RuntimeError(
"No supported MCP clients are installed. Install Claude, Codex, Gemini, OpenCode, "
"or GitHub Copilot CLI."
"No supported MCP clients are available. Install a client CLI or select "
"Claude Code or Codex with `ug mcp add --agents`."
)
clients = configured_mcp_clients(state, installed_clients)
clients.extend(
client for client in MCP_CLIENTS if client in explicit_direct and client not in clients
)
if agents is not None:
missing = sorted(a for a in agents if a not in clients)
if missing:
Expand Down Expand Up @@ -2607,9 +2651,31 @@ def _run_mcp_list(client: str) -> str | None:


def query_live_mcp_status(client: str) -> dict[str, str]:
"""MCP ``{server_name: state}`` for one installed agent, including ug's Copilot config."""
output = _run_mcp_list(client)
"""MCP ``{server_name: state}`` for one client, including config-only status."""
config_only = _config_only_mcp_client(client) and not _mcp_cli_available(client)
output = None if config_only else _run_mcp_list(client)
statuses = parse_mcp_list_output(client, output) if output is not None else {}
if config_only:
# A config-only client has no CLI to probe. Report config presence, not connection
# health. Codex exposes a local enabled bit; Claude's connection remains unknown.
if client == "claude":
servers = read_json_safe(claude.claude_code_config_path()).get("mcpServers")
managed = claude.read_managed_mcp_urls()
else:
servers = read_toml_safe(codex.user_mcp_config_path()).get("mcp_servers")
managed = codex.read_managed_mcp_urls()
if isinstance(servers, Mapping):
for name, entry in servers.items():
state = LIVE_UNKNOWN
if client == "codex":
state = (
LIVE_DISABLED
if isinstance(entry, Mapping) and entry.get("enabled") is False
else LIVE_ENABLED
)
statuses.setdefault(name, state)
for name in managed:
statuses.setdefault(name, LIVE_UNKNOWN)
if client == "copilot":
# Copilot reads ug's additional MCP config only on an agent launch. Its bare `mcp list`
# omits these servers, so report their configured state without claiming a live probe.
Expand Down Expand Up @@ -2841,8 +2907,18 @@ def list_mcp_command(agents: set[str] | None = None) -> int:
if other_summary:
print_note(f"Other MCP servers not configured by ug — {other_summary}.")

live_note = "Status is from each agent's `mcp list`"
if "codex" in probe_clients:
config_only = {
client
for client in probe_clients
if _config_only_mcp_client(client) and not _mcp_cli_available(client)
}
live_note = (
"Status uses each CLI's `mcp list` when available; config-only status is from config, "
"not live health"
if config_only
else "Status is from each agent's `mcp list`"
)
if "codex" in probe_clients and "codex" not in config_only:
live_note += "; Codex reports enabled/disabled"
if "copilot" in probe_clients:
live_note += "; ug-added Copilot servers report configured/enabled, not live health"
Expand Down
5 changes: 5 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ native installer, `agy mcp list` parsing, `ug antigravity`/`ug agy`, and
eight journeys in `integration/` (blocking Linux CI lane, Databricks Hosted and the Gemini
Enterprise MPS; see `integration/README.md`).

`test_mcp.py` and `test_cli.py` cover Claude/Codex MCP setup without their CLIs:
finding an existing config, creating one with `--agents`, and reporting status
from the file. They also check malformed config errors and stale client choices.
They use temporary files; real desktop apps remain untested.

`test_integration_contract.py` checks that isolated test sessions set Antigravity's
`AGY_CLI_DISABLE_AUTO_UPDATE` to the required literal `true`, overriding an inherited `1`.
This component check does not run the native updater; the integration runner retains its
Expand Down
1 change: 1 addition & 0 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -4495,6 +4495,7 @@ def test_unparseable_file_falls_back_to_cli_and_does_not_clobber(self, tmp_path,
path = tmp_path / ".claude.json"
path.write_text("{ this is not valid json")
monkeypatch.setattr(claude, "CLAUDE_CODE_CONFIG_PATH", path)
monkeypatch.setattr(claude.shutil, "which", lambda binary: "/fake/claude")
added: list[tuple[str, object]] = []
removed: list[tuple[str, str]] = []
monkeypatch.setattr(claude, "add_claude_mcp_server", lambda n, e, s: added.append((n, e)))
Expand Down
2 changes: 2 additions & 0 deletions tests/test_agent_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -2699,6 +2699,7 @@ def test_unparseable_file_falls_back_to_cli_and_does_not_clobber(self, tmp_path,
path = tmp_path / "config.toml"
path.write_text("this is = = not valid toml [[[")
monkeypatch.setattr(codex, "LEGACY_CODEX_CONFIG_PATH", path)
monkeypatch.setattr(codex.shutil, "which", lambda binary: "/fake/codex")
added: list[tuple[str, list]] = []
removed: list[str] = []
import ucode.mcp as mcp_mod
Expand All @@ -2722,6 +2723,7 @@ def test_unparseable_file_does_not_start_native_oauth_login(self, tmp_path, monk
path = tmp_path / "config.toml"
path.write_text("this is = = not valid toml [[[", encoding="utf-8")
monkeypatch.setattr(codex, "LEGACY_CODEX_CONFIG_PATH", path)
monkeypatch.setattr(codex.shutil, "which", lambda binary: "/fake/codex")
with pytest.raises(RuntimeError, match="invalid TOML"):
codex.write_user_mcp_servers(
{"github": codex.managed_mcp_http_entry("https://ws/mcp", "https://client/id")},
Expand Down
43 changes: 39 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3274,14 +3274,16 @@ def test_missing_mcp_only_cli_errors_instead_of_prompting(self):

mock_prompt.assert_not_called()

def test_agent_still_unready_after_setup_errors(self):
def test_other_clients_without_cli_still_fail_before_prompt(self):
with (
patch("ucode.cli.load_state", return_value={}),
patch("ucode.cli.available_mcp_clients", return_value=[]),
patch("ucode.cli.configure_workspace_command"),
pytest.raises(RuntimeError, match="Not installed or not set up: Codex"),
patch("ucode.cli._prompt_for_configuration") as prompt,
pytest.raises(RuntimeError, match="Not installed or not set up: Gemini CLI"),
):
cli_mod._configure_agents_for_mcp(["codex"])
cli_mod._configure_agents_for_mcp(["gemini"])

prompt.assert_not_called()


class TestMcpAddWorkspace:
Expand Down Expand Up @@ -3360,6 +3362,39 @@ def _real_state_writes(self):
def _save_workspace(workspace: str, **extra) -> None:
real_save_state({"workspace": workspace, "available_tools": ["codex"], **extra})

@pytest.mark.parametrize("agent", ["claude", "codex"])
def test_explicit_config_writable_client_needs_no_cli(self, agent):
from ucode.mcp import setup_mcp_clients
from ucode.state import load_state

selected = []

def register(**kwargs):
selected.append(
setup_mcp_clients(
load_state(),
"MCP servers",
agents=kwargs["agents"],
require_auth=False,
purge_foreign=False,
)[2]
)

with (
patch("ucode.cli.available_mcp_clients", return_value=[]),
patch("ucode.mcp.available_mcp_clients", return_value=[]),
patch("ucode.cli.add_mcp_command", side_effect=register),
patch("ucode.cli._sync_claude_cowork_plugin"),
):
result = runner.invoke(
app,
["mcp", "add", "--agents", agent, "--names", "a.b.c", "--workspace", self.WS_B],
)

assert result.exit_code == 0, result.output
assert selected == [[agent]]
assert load_state()["mcp_clients"] == [agent]

def test_first_mcp_add_selects_its_workspace(self):
from ucode.state import load_full_state

Expand Down
Loading
Loading