Skip to content

Commit f654a50

Browse files
nachill-databricksAchille Negrier
andauthored
Improve configure mcp UX: search wizard, faster/robust discovery & apply (#225)
* Improve `configure mcp` UX: search wizard, faster/robust discovery & apply Rework the interactive `ucode configure mcp` flow and fix several scaling/robustness problems surfaced on large workspaces. Discovery: - Fix a crash where a socket read timeout (bare TimeoutError, an OSError, not a URLError) escaped `_http_get_json` and killed the whole command; it is now surfaced as a reason and skipped best-effort. - Add a live progress counter to every walk-based source (Vector Search, UC functions, workspace-wide MCP services) so slow discovery shows "N/M ... K found" instead of an opaque spinner. - Add `list_all_mcp_services`: a workspace-wide MCP-services walk (catalogs -> schemas -> mcp-services) under a wall-clock budget. Interactive flow: - Two-step wizard: a "Search for:" screen chooses which sources to search (External, Apps, MCP services, Genie pre-checked; Vector Search and UC functions off by default since they walk the workspace), then the server picker. Pressing Left (<-) in the picker returns to the search screen without restarting. - Add a Ctrl-A select-all/none hotkey to the picker. - Prefix MCP services with "MCP:" and external connections with "Connection:" to match the existing App:/Genie:/Vector Search: labels. - Print a post-save summary ("Added N, removed M ... across <clients>") instead of a bare "Saved". Apply: - Parallelize `apply_mcp_server_changes` across clients (serial within a client, since each mutates one shared config) with a live "Configuring N/M" progress spinner, so large diffs no longer look frozen. (Per-server CLI spawns remain; see code comments.) Co-authored-by: Isaac * Offer MCP setup at the end of interactive `ucode configure` After the workspace and agents are configured in the fully-interactive flow (no --agent/--agents/--workspaces flags), prompt "Configure MCP servers now?" and run the MCP wizard if accepted. This makes MCP setup discoverable as the natural next step instead of requiring users to know `ucode configure mcp` exists. Flag-driven and scripted runs are unaffected: the prompt only fires on the fully-interactive path, and never in --dry-run. Co-authored-by: Isaac * Fix large vertical gap in MCP picker on tall terminals The picker rendered the prompt from a PromptSession container, which expands to fill the terminal height. In a tall window that pushed the choices list to the very bottom, leaving a big blank gap between the "Search for:"/"MCP:" prompt and the options. Render the prompt as a fixed 1-row window instead, so the choices follow immediately regardless of terminal height. Co-authored-by: Isaac * Increase MCP-services walk budget to 30s Most MCP services live outside `system.ai`, so the workspace-wide walk needs more time to enumerate them; too tight a budget silently truncates the picker list on larger workspaces. Bump the deadline from 8s to 30s. Co-authored-by: Isaac --------- Co-authored-by: Achille Negrier <achille.negrier+data@databricks.com>
1 parent eb6dbf3 commit f654a50

7 files changed

Lines changed: 946 additions & 79 deletions

File tree

src/ucode/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
prompt_for_selection,
8686
prompt_for_tools,
8787
prompt_for_workspace,
88+
prompt_yes_no,
8889
prompt_yes_no_default,
8990
set_verbosity,
9091
spinner,
@@ -1358,6 +1359,9 @@ def configure(
13581359
agent = "claude"
13591360
if enable_databricks_ai_tools is not None:
13601361
skip_kwargs["databricks_ai_tools_enabled"] = enable_databricks_ai_tools
1362+
# Set True only in the fully-interactive branch below; gates the optional
1363+
# MCP setup prompt so flag-driven / scripted runs are never interrupted.
1364+
fully_interactive = False
13611365
if agent is not None:
13621366
tool = normalize_tool(agent)
13631367
install_tool_binary(
@@ -1438,6 +1442,10 @@ def configure(
14381442
prompt_optional_updates=prompt_optional_updates,
14391443
**skip_kwargs,
14401444
)
1445+
# Only the no-agent, no-workspace path is truly interactive (the user
1446+
# picked agents/workspace via prompts); that's where we offer the MCP
1447+
# step below. Flag-driven runs stay scriptable.
1448+
fully_interactive = workspace_entries is None
14411449
if tracing:
14421450
# The workspaces were just configured, so enable tracing for them
14431451
# directly instead of re-prompting. Fall back to the workspace that
@@ -1468,6 +1476,12 @@ def configure(
14681476
"interactive picker."
14691477
)
14701478
configure_mcp_command(services=services)
1479+
# Offer MCP setup as the natural next step of interactive configuration,
1480+
# so users discover it without needing to know `configure mcp` exists.
1481+
# Skipped in dry-run and non-interactive/flag-driven runs (which stay
1482+
# scriptable), and when --dry-run is set.
1483+
if fully_interactive and not dry_run and prompt_yes_no("Configure MCP servers now?"):
1484+
configure_mcp_command()
14711485
except RuntimeError as exc:
14721486
print_err(str(exc))
14731487
raise typer.Exit(1) from None

src/ucode/databricks.py

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import shutil
1616
import subprocess
1717
import time
18+
from collections.abc import Callable
1819
from concurrent.futures import (
1920
ThreadPoolExecutor,
2021
as_completed,
@@ -243,6 +244,12 @@ def _http_get_json(
243244
except urllib_error.URLError as exc:
244245
_debug(f"GET {url}", f"URLError: {exc.reason}")
245246
return None, f"network error: {exc.reason}"
247+
except OSError as exc:
248+
# A socket read timeout raises a bare TimeoutError (an OSError), not a
249+
# URLError, so it must be caught explicitly or it escapes the whole
250+
# discovery flow. Surface it as a reason like every other failure.
251+
_debug(f"GET {url}", f"OSError: {exc}")
252+
return None, f"network error: {exc}"
246253

247254

248255
def _http_post_json(
@@ -288,6 +295,11 @@ def _http_post_json(
288295
except urllib_error.URLError as exc:
289296
_debug(f"POST {url}", f"URLError: {exc.reason}")
290297
return None, f"network error: {exc.reason}"
298+
except OSError as exc:
299+
# See `_http_get_json`: a bare socket timeout is an OSError, not a
300+
# URLError, and would otherwise escape the caller's error handling.
301+
_debug(f"POST {url}", f"OSError: {exc}")
302+
return None, f"network error: {exc}"
291303

292304

293305
def _http_get_bytes(url: str, token: str, *, timeout: int = 10) -> tuple[bytes | None, str | None]:
@@ -1649,6 +1661,10 @@ def map_bedrock_claude_models(targets: list[str]) -> dict[str, str]:
16491661
_UC_FUNCTION_PROBE_TIMEOUT = 5
16501662
_VECTOR_SEARCH_DEADLINE_SECONDS = 15.0
16511663
_UC_FUNCTIONS_DEADLINE_SECONDS = 20.0
1664+
# Most MCP services live outside `system.ai`, so this workspace-wide walk needs
1665+
# enough time to enumerate them; a slow workspace still degrades to partial
1666+
# results once the budget is exceeded instead of hanging indefinitely.
1667+
_MCP_SERVICES_WALK_DEADLINE_SECONDS = 30.0
16521668
# Skip UC catalogs whose schemas almost never carry user-callable functions
16531669
# you'd want to expose as agent tools.
16541670
_UC_FUNCTIONS_SKIP_CATALOGS = frozenset(
@@ -1737,11 +1753,16 @@ def list_vector_search_catalog_schemas(
17371753
token: str,
17381754
*,
17391755
deadline_seconds: float = _VECTOR_SEARCH_DEADLINE_SECONDS,
1756+
on_progress: Callable[[int, int, int], None] | None = None,
17401757
) -> tuple[list[tuple[str, str]], str | None]:
17411758
"""Return sorted unique `(catalog, schema)` pairs that contain at least
17421759
one Databricks Vector Search index. Walks the per-endpoint index listings
17431760
in parallel under a wall-clock budget; returns partial results once
1744-
`deadline_seconds` is exceeded."""
1761+
`deadline_seconds` is exceeded.
1762+
1763+
`on_progress`, if given, is called as each endpoint's listing completes with
1764+
`(endpoints_done, endpoints_total, pairs_found)` for live count reporting.
1765+
It is invoked serially from the draining thread (not the workers)."""
17451766
hostname = workspace_hostname(workspace)
17461767
deadline = time.monotonic() + deadline_seconds
17471768
endpoints, reason = _paginated_json_items(
@@ -1758,7 +1779,9 @@ def list_vector_search_catalog_schemas(
17581779
return [], "no vector search endpoints with names"
17591780

17601781
pairs: set[tuple[str, str]] = set()
1761-
workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(endpoint_names)))
1782+
endpoints_total = len(endpoint_names)
1783+
endpoints_done = 0
1784+
workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, endpoints_total))
17621785
with ThreadPoolExecutor(max_workers=workers) as pool:
17631786
futures = {
17641787
pool.submit(
@@ -1773,11 +1796,15 @@ def list_vector_search_catalog_schemas(
17731796
}
17741797

17751798
def collect(result, _endpoint):
1799+
nonlocal endpoints_done
17761800
indexes, _ = result
17771801
for index in indexes:
17781802
pair = _vector_index_catalog_schema(index)
17791803
if pair:
17801804
pairs.add(pair)
1805+
endpoints_done += 1
1806+
if on_progress is not None:
1807+
on_progress(endpoints_done, endpoints_total, len(pairs))
17811808

17821809
_drain_with_deadline(futures, deadline, collect)
17831810
pool.shutdown(wait=False, cancel_futures=True)
@@ -1805,9 +1832,14 @@ def list_uc_functions_catalog_schemas(
18051832
token: str,
18061833
*,
18071834
deadline_seconds: float = _UC_FUNCTIONS_DEADLINE_SECONDS,
1835+
on_progress: Callable[[int, int, int], None] | None = None,
18081836
) -> tuple[list[tuple[str, str]], str | None]:
18091837
"""Return sorted unique `(catalog, schema)` pairs containing at least one
1810-
user-defined UC function."""
1838+
user-defined UC function.
1839+
1840+
`on_progress`, if given, is called during the function-probe phase with
1841+
`(schemas_done, schemas_total, pairs_found)` for live count reporting. It is
1842+
invoked serially from the draining thread (not the workers)."""
18111843
hostname = workspace_hostname(workspace)
18121844
deadline = time.monotonic() + deadline_seconds
18131845

@@ -1871,15 +1903,21 @@ def collect_schemas(result, catalog):
18711903

18721904
# Parallel function-existence probes.
18731905
pairs: set[tuple[str, str]] = set()
1906+
schemas_total = len(candidate_pairs)
1907+
schemas_done = 0
18741908
with ThreadPoolExecutor(max_workers=_UC_FUNCTION_PROBE_WORKERS) as pool:
18751909
probe_futures = {
18761910
pool.submit(_schema_has_user_function, hostname, token, cat, schema): (cat, schema)
18771911
for cat, schema in candidate_pairs
18781912
}
18791913

18801914
def collect_pair(has_fn, pair):
1915+
nonlocal schemas_done
18811916
if has_fn:
18821917
pairs.add(pair)
1918+
schemas_done += 1
1919+
if on_progress is not None:
1920+
on_progress(schemas_done, schemas_total, len(pairs))
18831921

18841922
_drain_with_deadline(probe_futures, deadline, collect_pair)
18851923
pool.shutdown(wait=False, cancel_futures=True)
@@ -1891,6 +1929,111 @@ def collect_pair(has_fn, pair):
18911929
return sorted(pairs), None
18921930

18931931

1932+
def list_all_mcp_services(
1933+
workspace: str,
1934+
token: str,
1935+
*,
1936+
deadline_seconds: float = _MCP_SERVICES_WALK_DEADLINE_SECONDS,
1937+
on_progress: Callable[[int, int, int], None] | None = None,
1938+
) -> tuple[list[str], str | None]:
1939+
"""Return sorted unique MCP-service full names across every `<catalog>.<schema>`
1940+
in the workspace. The mcp-services API is one-schema-per-call, so this walks
1941+
catalogs -> schemas -> mcp-services in parallel under a wall-clock budget,
1942+
returning partial results once `deadline_seconds` is exceeded.
1943+
1944+
`on_progress`, if given, is called as each schema's listing completes with
1945+
`(schemas_done, schemas_total, services_found)` so callers can render a live
1946+
count. It is invoked serially from the draining thread (not the workers).
1947+
1948+
This walk is the slow, workspace-wide counterpart to `list_mcp_services`
1949+
(single schema)."""
1950+
hostname = workspace_hostname(workspace)
1951+
deadline = time.monotonic() + deadline_seconds
1952+
1953+
catalogs, catalogs_reason = _paginated_json_items(
1954+
f"https://{hostname}/api/2.1/unity-catalog/catalogs",
1955+
token,
1956+
items_key="catalogs",
1957+
timeout=_UC_LIST_HTTP_TIMEOUT,
1958+
)
1959+
if not catalogs:
1960+
return [], catalogs_reason or "no UC catalogs found"
1961+
1962+
catalog_names = [
1963+
c["name"]
1964+
for c in catalogs
1965+
if isinstance(c.get("name"), str)
1966+
and c["name"]
1967+
and c["name"] not in _UC_FUNCTIONS_SKIP_CATALOGS
1968+
]
1969+
if not catalog_names:
1970+
return [], "no user UC catalogs found"
1971+
if time.monotonic() > deadline:
1972+
return [], "deadline exceeded while listing UC catalogs"
1973+
1974+
# Parallel per-catalog schema listing.
1975+
schema_refs: list[str] = []
1976+
schema_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(catalog_names)))
1977+
with ThreadPoolExecutor(max_workers=schema_workers) as pool:
1978+
schema_futures = {
1979+
pool.submit(
1980+
_paginated_json_items,
1981+
f"https://{hostname}/api/2.1/unity-catalog/schemas",
1982+
token,
1983+
items_key="schemas",
1984+
extra_params={"catalog_name": cat},
1985+
timeout=_UC_LIST_HTTP_TIMEOUT,
1986+
): cat
1987+
for cat in catalog_names
1988+
}
1989+
1990+
def collect_schemas(result, catalog):
1991+
schemas, _ = result
1992+
for schema in schemas:
1993+
schema_name = schema.get("name")
1994+
if (
1995+
isinstance(schema_name, str)
1996+
and schema_name
1997+
and schema_name != "information_schema"
1998+
):
1999+
schema_refs.append(f"{catalog}.{schema_name}")
2000+
2001+
_drain_with_deadline(schema_futures, deadline, collect_schemas)
2002+
pool.shutdown(wait=False, cancel_futures=True)
2003+
2004+
if not schema_refs:
2005+
if time.monotonic() > deadline:
2006+
return [], "deadline exceeded while listing UC schemas"
2007+
return [], "no UC schemas found"
2008+
2009+
# Parallel per-schema mcp-services listing.
2010+
names: set[str] = set()
2011+
schemas_total = len(schema_refs)
2012+
schemas_done = 0
2013+
probe_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, schemas_total))
2014+
with ThreadPoolExecutor(max_workers=probe_workers) as pool:
2015+
service_futures = {
2016+
pool.submit(list_mcp_services, workspace, token, ref): ref for ref in schema_refs
2017+
}
2018+
2019+
def collect_services(result, _ref):
2020+
nonlocal schemas_done
2021+
found, _ = result
2022+
names.update(found)
2023+
schemas_done += 1
2024+
if on_progress is not None:
2025+
on_progress(schemas_done, schemas_total, len(names))
2026+
2027+
_drain_with_deadline(service_futures, deadline, collect_services)
2028+
pool.shutdown(wait=False, cancel_futures=True)
2029+
2030+
if not names:
2031+
if time.monotonic() > deadline:
2032+
return [], "deadline exceeded while listing MCP services"
2033+
return [], "no MCP services found"
2034+
return sorted(names), None
2035+
2036+
18942037
def discover_claude_models(workspace: str, token: str) -> tuple[dict[str, str], str | None]:
18952038
"""Discover Claude families on this workspace's AI Gateway.
18962039

0 commit comments

Comments
 (0)