Skip to content

Commit 526ed6b

Browse files
authored
add non-interactive ucode configure mcp --location (#172)
* non-interactive ucode configure mcp --location * e2e tests for configure mcp --location flag
1 parent 6e23cd8 commit 526ed6b

6 files changed

Lines changed: 340 additions & 18 deletions

File tree

src/ucode/cli.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -888,10 +888,21 @@ def configure(
888888

889889

890890
@configure_app.command("mcp")
891-
def configure_mcp() -> None:
891+
def configure_mcp(
892+
location: Annotated[
893+
str | None,
894+
typer.Option(
895+
"--location",
896+
help="Non-interactive: replace registered MCPs with exactly the services "
897+
"in the given Unity Catalog `<catalog>.<schema>` (e.g. `system.ai`) and "
898+
"exit without showing the picker. Any previously-registered MCPs outside "
899+
"this location are removed.",
900+
),
901+
] = None,
902+
) -> None:
892903
"""Add Databricks MCP servers to installed coding tools."""
893904
try:
894-
configure_mcp_command()
905+
configure_mcp_command(location=location)
895906
except RuntimeError as exc:
896907
print_err(str(exc))
897908
raise typer.Exit(1) from None

src/ucode/databricks.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,47 +1212,49 @@ def discover_model_services(
12121212

12131213

12141214
_MCP_SERVICE_NAME_PREFIX = "mcp-services/"
1215-
_MCP_SERVICE_REQUIRED_PREFIX = "system.ai."
12161215

12171216

1218-
def _mcp_service_full_name(service: dict) -> str | None:
1219-
"""Extract `system.ai.<name>` from one mcp-service entry, or None."""
1217+
def _mcp_service_full_name(service: dict, required_prefix: str) -> str | None:
1218+
"""Extract the full UC name from one mcp-service entry, or None if it
1219+
doesn't live under ``required_prefix`` or isn't ACTIVE."""
12201220
name = service.get("name")
12211221
if not isinstance(name, str):
12221222
return None
12231223
name = name.strip().removeprefix(_MCP_SERVICE_NAME_PREFIX)
1224-
if not name.startswith(_MCP_SERVICE_REQUIRED_PREFIX):
1224+
if not name.startswith(required_prefix):
12251225
return None
12261226
status = ((service.get("config") or {}).get("connection") or {}).get("status")
12271227
if status is not None and status != "ACTIVE":
12281228
return None
12291229
return name
12301230

12311231

1232-
def list_mcp_services(workspace: str, token: str) -> tuple[list[str], str | None]:
1233-
"""List Databricks-curated `system.ai.*` MCP services.
1232+
def list_mcp_services(
1233+
workspace: str, token: str, parent: str = "system.ai"
1234+
) -> tuple[list[str], str | None]:
1235+
"""List UC MCP services under ``parent`` (a ``<catalog>.<schema>`` ref).
12341236
1235-
The listing endpoint requires `?parent=schemas/system.ai`; without it the
1236-
request returns 499 with a truncated body (verified against e2-dogfood
1237-
2026-06-11). Returns (full_names, reason).
1237+
A non-None string indicates the listing call itself failed. Callers can inspect
1238+
``error`` for ``HTTP 404`` to distinguish "invalid location" from other failures.
12381239
"""
12391240
hostname = workspace_hostname(workspace)
12401241
url = (
12411242
f"https://{hostname}/api/2.1/unity-catalog/mcp-services"
1242-
f"?{urlencode({'parent': 'schemas/system.ai'})}"
1243+
f"?{urlencode({'parent': f'schemas/{parent}'})}"
12431244
)
12441245
payload, reason = _http_get_json(url, token, timeout=30)
12451246
if payload is None:
12461247
return [], reason
1248+
expected_prefix = parent + "."
12471249
data = cast(dict, payload) if isinstance(payload, dict) else {}
1248-
names = []
1250+
names: list[str] = []
12491251
for service in data.get("mcp_services") or []:
12501252
if not isinstance(service, dict):
12511253
continue
1252-
full_name = _mcp_service_full_name(service)
1254+
full_name = _mcp_service_full_name(service, expected_prefix)
12531255
if full_name:
12541256
names.append(full_name)
1255-
return sorted(set(names)), None if names else (reason or "no `system.ai.*` mcp services found")
1257+
return sorted(set(names)), None
12561258

12571259

12581260
def build_mcp_service_url(workspace: str, full_name: str) -> str:

src/ucode/mcp.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,7 +1103,57 @@ def purge_cross_workspace_mcp_residue(state: dict, workspace: str) -> None:
11031103
)
11041104

11051105

1106-
def configure_mcp_command() -> int:
1106+
def _resolve_location_mcp_servers(
1107+
workspace: str,
1108+
profile: str | None,
1109+
clients: list[str],
1110+
location: str,
1111+
original_servers: list[dict],
1112+
) -> list[dict]:
1113+
"""Build the desired MCP server list for ``--location <cat>.<schema>``.
1114+
1115+
Strict replacement: the returned list is exactly the mcp-services
1116+
discovered at ``location``. Any previously-registered MCP entries outside
1117+
that location are removed by ``apply_mcp_server_changes``. Raises ``RuntimeError`` for an invalid
1118+
location (HTTP 404 from the listing API) or any other listing failure."""
1119+
if location.count(".") != 1 or not all(part.strip() for part in location.split(".")):
1120+
raise RuntimeError(f"--location must be `<catalog>.<schema>`, got `{location}`.")
1121+
1122+
token = get_databricks_token(workspace, profile)
1123+
with spinner(f"Discovering MCP services in {location}..."):
1124+
names, reason = list_mcp_services(workspace, token, parent=location)
1125+
1126+
if reason and reason.startswith("HTTP 404"):
1127+
raise RuntimeError(
1128+
f"Invalid location: `{location}` is not a valid Unity Catalog schema "
1129+
"in this workspace (or you lack USE permission on it)."
1130+
)
1131+
if reason:
1132+
raise RuntimeError(f"Failed to list MCP services at `{location}`: {reason}")
1133+
if not names:
1134+
print_note(f"No MCP services exist at `{location}`.")
1135+
1136+
original_by_name = _servers_by_name(original_servers)
1137+
working_servers: list[dict] = []
1138+
for full_name in names:
1139+
entry_name = full_name.replace(".", "-")
1140+
original = original_by_name.get(entry_name)
1141+
original_clients = list((original or {}).get("clients") or [])
1142+
merged_clients = original_clients + [c for c in clients if c not in original_clients]
1143+
candidate = {
1144+
"name": entry_name,
1145+
"url": build_mcp_service_url(workspace, full_name),
1146+
"auth": f"env:{MCP_AUTH_TOKEN_ENV_VAR}",
1147+
"clients": merged_clients,
1148+
}
1149+
if original is not None and original == candidate:
1150+
working_servers.append(original.copy())
1151+
else:
1152+
working_servers.append(candidate)
1153+
return working_servers
1154+
1155+
1156+
def configure_mcp_command(location: str | None = None) -> int:
11071157
state = load_state()
11081158
workspace = state.get("workspace")
11091159
if not workspace:
@@ -1141,6 +1191,20 @@ def configure_mcp_command() -> int:
11411191
"skipping MCP config."
11421192
)
11431193

1194+
original_mcp_servers_for_location: list[dict] = list(state.get("mcp_servers") or [])
1195+
if location is not None:
1196+
working_mcp_servers = _resolve_location_mcp_servers(
1197+
workspace, profile, clients, location, original_mcp_servers_for_location
1198+
)
1199+
changed = apply_mcp_server_changes(
1200+
original_mcp_servers_for_location, working_mcp_servers, clients
1201+
)
1202+
if changed or original_mcp_servers_for_location != working_mcp_servers:
1203+
state["mcp_servers"] = working_mcp_servers
1204+
save_state(state)
1205+
print_success("Saved")
1206+
return 0
1207+
11441208
available_external_mcp_names = _discover_mcp_source(
11451209
"external connections",
11461210
lambda: discover_external_mcp_connection_names(workspace, profile),

tests/test_databricks.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,57 @@ def test_http_failure_propagates_reason(self, monkeypatch):
367367
assert names == []
368368
assert reason == "HTTP 500 Server Error"
369369

370-
def test_empty_payload_reports_no_results(self, monkeypatch):
370+
def test_empty_payload_is_successful_with_no_reason(self, monkeypatch):
371371
monkeypatch.setattr(
372372
db_mod, "_http_get_json", lambda url, token, timeout=30: ({"mcp_services": []}, None)
373373
)
374374

375375
names, reason = db_mod.list_mcp_services(WS, "token")
376376

377377
assert names == []
378-
assert reason and "no `system.ai.*`" in reason
378+
assert reason is None
379+
380+
def test_custom_parent_passes_through_to_url(self, monkeypatch):
381+
captured: dict[str, str] = {}
382+
383+
def fake_get(url, token, timeout=30):
384+
captured["url"] = url
385+
return {"mcp_services": []}, None
386+
387+
monkeypatch.setattr(db_mod, "_http_get_json", fake_get)
388+
389+
db_mod.list_mcp_services(WS, "token", parent="main.svenwb")
390+
391+
assert "parent=schemas%2Fmain.svenwb" in captured["url"]
392+
393+
def test_custom_parent_filters_to_namespace(self, monkeypatch):
394+
payload = {
395+
"mcp_services": [
396+
{"name": "mcp-services/main.svenwb.github"},
397+
{"name": "mcp-services/main.svenwb.slack"},
398+
{"name": "mcp-services/system.ai.github"},
399+
]
400+
}
401+
monkeypatch.setattr(
402+
db_mod, "_http_get_json", lambda url, token, timeout=30: (payload, None)
403+
)
404+
405+
names, reason = db_mod.list_mcp_services(WS, "token", parent="main.svenwb")
406+
407+
assert reason is None
408+
assert names == ["main.svenwb.github", "main.svenwb.slack"]
409+
410+
def test_http_404_reason_surfaces_for_invalid_parent(self, monkeypatch):
411+
monkeypatch.setattr(
412+
db_mod,
413+
"_http_get_json",
414+
lambda url, token, timeout=30: (None, "HTTP 404 Not Found: NOT_FOUND"),
415+
)
416+
417+
names, reason = db_mod.list_mcp_services(WS, "token", parent="nope.nope")
418+
419+
assert names == []
420+
assert reason and reason.startswith("HTTP 404")
379421

380422

381423
def _foundation_models_payload(names):

tests/test_e2e_uc.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ def test_returns_only_system_ai_mcp_services(self, e2e_workspace, e2e_token):
6565
non_system = sorted({n for n in names if not n.startswith("system.ai.")})
6666
assert not non_system, f"Non-system.ai entries leaked through: {non_system[:5]}"
6767

68+
def test_custom_parent_filters_server_side(self, e2e_workspace, e2e_token):
69+
names, _ = list_mcp_services(e2e_workspace, e2e_token, parent="main.default")
70+
if not names:
71+
pytest.skip("No mcp-services in main.default on this workspace.")
72+
outside = sorted({n for n in names if not n.startswith("main.default.")})
73+
assert not outside, f"Server returned entries outside main.default: {outside[:5]}"
74+
75+
def test_invalid_parent_returns_http_404(self, e2e_workspace, e2e_token):
76+
names, reason = list_mcp_services(
77+
e2e_workspace, e2e_token, parent="nope_catalog.nope_schema"
78+
)
79+
assert names == []
80+
assert reason and reason.startswith("HTTP 404"), (
81+
f"Expected HTTP 404 for bogus location, got: {reason}"
82+
)
83+
6884

6985
# ---------------------------------------------------------------------------
7086
# `configure_shared_state` end-to-end: UC discovery is the default, with a

0 commit comments

Comments
 (0)