Skip to content

Commit 0fed5fd

Browse files
authored
ucode configure skills: MCP-connection mode (add/remove/replace) + status (#236)
* Add `ucode configure skills` MCP-connection mode + status section Introduces `ucode configure skills` for managing a single Unity Catalog Skills MCP connection (`databricks-skill-registry`) whose scope is a set of `catalog.schema` locations, rendered as a multi-schema `?schema=` URL (aligns with the backend route in universe #2256555). - `build_skills_mcp_url(workspace, locations)`: repeated `?schema=`; empty list -> bare `/ai-gateway/skills/` (utility tools only). - Single `kind:"skills"` entry with `skill_locations` as the source of truth; the URL is derived and never parsed back. Every run rebuilds exactly one skills entry, preserving non-skills entries. - `--location a.b,c.d --mcp [--remove|--replace]` adds/removes/replaces the location set; `--remove`/`--replace` are mutually exclusive and require `--mcp`; a soft warning fires above 10 schemas. The bare form (no `--mcp`) registers the connection for the given schemas; download-only affordances (`--path`, `--yes`, a 3-part `.skill` location) without `--mcp` raise a clear "not available yet" error. - `alwaysLoad` is set on the skills entry for the Claude client only. - `ucode status` gains a top-level Skills section (Connection / Mode / Locations / Configured); the skills entry is excluded from each agent's per-client MCP-servers line. Also extracts `_setup_mcp_clients` from `configure_mcp_command` (shared by the mcp and skills commands) and adds an `always_load` keyword to `build_mcp_http_entry` (default off) as behavior-preserving prep. Co-authored-by: Isaac * Preserve the skills connection across `configure mcp` `configure mcp --location` (and the interactive picker) rebuilt the MCP server list from only the mcp-services they manage, so `apply_mcp_server_changes` treated the `kind:"skills"` entry as a removal and unregistered it from every agent. Both paths now carry an existing skills connection through untouched (the picker also hides it, since it is managed by `configure skills`). Co-authored-by: Isaac * status: trim Skills section to locations + configured agents Drop the Connection line (fixed name, no signal) and the Mode line (inferred; deferred until download mode lands), and rename the locations line to `Skill MCP Locations` to distinguish it from download locations a later PR may track separately. Co-authored-by: Isaac * Address review: scope skills PR to MCP mode only Defer all download scaffolding to the later download-mode PR and apply review cleanups: - CLI: drop --path/--yes and 3-part `.skill` parsing; --location takes only `<catalog>.<schema>` refs. Non---mcp invocations return a clear "not available yet" error until download mode lands. - Remove the bare `configure_skills_command` entry point and the `SkillLocation` NamedTuple. - Drop the client-side schema soft-cap; let the backend enforce limits. - Rename `_apply_skills_connection` -> `_update_skills_mcp`; group the skills helpers just above `configure_skills_mcp_command`. - `resolve_skill_location_set` dedupes via `dict.fromkeys`. - status: rename locals to `skill_mcp_entry` / `configured_agents`. - Tighten `build_skills_mcp_url` docstring and a couple of comments. Co-authored-by: Isaac * Address review: drop redundant dedup, group skills helpers - `resolve_skill_location_set` no longer de-dupes the `replace` set; the CLI parser (`_parse_skill_locations`) already drops duplicates, so it just returns `requested`. - Move `_merge_clients`, `_build_skills_entry`, and `_resolve_skills_mcp_servers` down to sit with the rest of the skills helpers just above `configure_skills_mcp_command`. Co-authored-by: Isaac * skills: make --location override-only, drop --remove/--replace Per review, keep the MCP-mode surface minimal: --location now sets the skills connection's schema set to exactly the listed locations, replacing any prior set, instead of add/remove/replace mutations. Removes the --remove/--replace flags and mode dispatch, and the now-dead resolve_skill_location_set / _current_skill_locations helpers. Co-authored-by: Isaac * status: add configure-skills hint to footer Surface `ucode configure skills --location <catalog>.<schema> --mcp` alongside the existing configure-mcp/tracing hints so users discover the Skills connection command from `ucode status`. Co-authored-by: Isaac
1 parent 0a5225a commit 0fed5fd

6 files changed

Lines changed: 581 additions & 31 deletions

File tree

src/ucode/cli.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@
5353
)
5454
from ucode.mcp import (
5555
MCP_CLIENTS,
56+
SKILLS_MCP_KIND,
5657
configure_mcp_command,
58+
configure_skills_mcp_command,
5759
purge_cross_workspace_mcp_residue,
5860
revert_mcp_configs,
5961
)
@@ -143,6 +145,27 @@ def _parse_agents_option(agents: str) -> list[str]:
143145
return tools
144146

145147

148+
def _parse_skill_locations(location: str) -> list[str]:
149+
"""Parse a comma-separated `--location` into `<catalog>.<schema>` refs,
150+
dropping duplicates while preserving order."""
151+
locations: list[str] = []
152+
for raw in location.split(","):
153+
raw = raw.strip()
154+
if not raw:
155+
continue
156+
parts = raw.split(".")
157+
if len(parts) != 2 or not all(part.strip() for part in parts):
158+
raise RuntimeError(f"--location entries must be `<catalog>.<schema>`, got `{raw}`.")
159+
if raw not in locations:
160+
locations.append(raw)
161+
if not locations:
162+
raise RuntimeError(
163+
"No schemas provided for --location. Use `<catalog>.<schema>`, "
164+
"comma-separated for multiple."
165+
)
166+
return locations
167+
168+
146169
def _parse_workspaces_option(workspaces: str) -> list[tuple[str, str | None]]:
147170
"""Parse `--workspaces` into [(url, profile_name | None), ...].
148171
@@ -723,7 +746,9 @@ def status() -> int:
723746
tool_mcp_servers = [
724747
str(server.get("name"))
725748
for server in mcp_servers
726-
if tool in (server.get("clients") or []) and server.get("name")
749+
if tool in (server.get("clients") or [])
750+
and server.get("name")
751+
and server.get("kind") != SKILLS_MCP_KIND
727752
]
728753
print_kv("MCP list command", str(MCP_CLIENTS[tool]["list_command"]))
729754
print_kv(
@@ -733,6 +758,23 @@ def status() -> int:
733758
print_kv("Config file", str(config_path) if config_path.exists() else "missing")
734759
console.print()
735760

761+
print_heading("Skills")
762+
skill_mcp_entry = next((s for s in mcp_servers if s.get("kind") == SKILLS_MCP_KIND), None)
763+
if not skill_mcp_entry:
764+
print_kv("Skills", "not configured")
765+
else:
766+
locations = skill_mcp_entry.get("skill_locations") or []
767+
print_kv(
768+
"Skill MCP Locations",
769+
", ".join(locations) if locations else "none — utility tools only",
770+
)
771+
configured_agents = [
772+
str(MCP_CLIENTS[client]["display"])
773+
for client in (skill_mcp_entry.get("clients") or [])
774+
if client in MCP_CLIENTS
775+
]
776+
print_kv("Configured", ", ".join(configured_agents) if configured_agents else "none")
777+
736778
print_heading("Tracing")
737779
tracing = state.get("tracing") or {}
738780
if tracing.get("enabled"):
@@ -757,6 +799,10 @@ def status() -> int:
757799
print_note(
758800
"Use `ucode configure mcp` to add Databricks MCP servers to configured coding tools."
759801
)
802+
print_note(
803+
"Use `ucode configure skills --location <catalog>.<schema> --mcp` to connect Unity "
804+
"Catalog Skills."
805+
)
760806
print_note("Use `ucode configure tracing` to log coding sessions to an MLflow experiment.")
761807
print_note("Use `ucode revert` to clear managed configs and restore prior files.")
762808
return 0
@@ -1349,6 +1395,36 @@ def configure_mcp(
13491395
raise typer.Exit(130) from None
13501396

13511397

1398+
@configure_app.command("skills")
1399+
def configure_skills(
1400+
location: Annotated[
1401+
str,
1402+
typer.Option("--location", help="Comma-separated `<catalog>.<schema>` skill scopes."),
1403+
],
1404+
mcp: Annotated[
1405+
bool,
1406+
typer.Option(
1407+
"--mcp", help="Manage the skills MCP connection (required until download mode lands)."
1408+
),
1409+
] = False,
1410+
) -> None:
1411+
"""Configure Databricks Skills for your coding tools.
1412+
1413+
``--location`` sets the skills MCP connection's scope to exactly the listed
1414+
schemas, replacing any previous set.
1415+
"""
1416+
try:
1417+
if not mcp:
1418+
raise RuntimeError("Download mode is not available yet; pass --mcp for now.")
1419+
configure_skills_mcp_command(_parse_skill_locations(location))
1420+
except RuntimeError as exc:
1421+
print_err(str(exc))
1422+
raise typer.Exit(1) from None
1423+
except KeyboardInterrupt:
1424+
print_err("Interrupted.")
1425+
raise typer.Exit(130) from None
1426+
1427+
13521428
@configure_app.command("tracing")
13531429
def configure_tracing(
13541430
disable: Annotated[

src/ucode/databricks.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,19 @@ def build_mcp_service_url(workspace: str, full_name: str) -> str:
13651365
return f"{workspace}/ai-gateway/mcp-services/{full_name}"
13661366

13671367

1368+
def build_skills_mcp_url(workspace: str, locations: list[str]) -> str:
1369+
"""Skills route with one ``?schema=`` scope per location. The trailing slash
1370+
is required by the Envoy prefix even with no query params.
1371+
1372+
[] -> ``.../ai-gateway/skills/``
1373+
["main.default", "ml.a"] -> ``.../ai-gateway/skills/?schema=main.default&schema=ml.a``
1374+
"""
1375+
base = f"{workspace}/ai-gateway/skills/"
1376+
if not locations:
1377+
return base
1378+
return base + "?" + urlencode([("schema", loc) for loc in locations])
1379+
1380+
13681381
# Maps the gateway routing dialect a coding tool speaks to the Model Provider
13691382
# Service `provider_type`s it can be backed by. claude speaks Anthropic's API,
13701383
# which both the `anthropic` and `amazon_bedrock` provider types serve (Bedrock

src/ucode/mcp.py

Lines changed: 122 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ucode.databricks import (
3030
apply_pat_environment,
3131
build_mcp_service_url,
32+
build_skills_mcp_url,
3233
ensure_databricks_auth,
3334
get_databricks_token,
3435
list_databricks_apps,
@@ -79,6 +80,8 @@
7980
"list_command": "copilot mcp list",
8081
},
8182
}
83+
SKILLS_MCP_KIND = "skills"
84+
SKILLS_MCP_SERVER_NAME = "databricks-skill-registry"
8285
EXTERNAL_MCP_SELECTION_PREFIX = "external:"
8386
SQL_MCP_VALUE = "managed:sql"
8487
GENIE_SPACE_SELECTION_PREFIX = "genie-space:"
@@ -96,14 +99,17 @@
9699
)
97100

98101

99-
def build_mcp_http_entry(url: str) -> dict:
100-
return {
102+
def build_mcp_http_entry(url: str, *, always_load: bool = False) -> dict:
103+
entry: dict[str, Any] = {
101104
"type": "http",
102105
"url": url,
103106
"headers": {
104107
"Authorization": f"Bearer ${{{MCP_AUTH_TOKEN_ENV_VAR}}}",
105108
},
106109
}
110+
if always_load:
111+
entry["alwaysLoad"] = True
112+
return entry
107113

108114

109115
def add_claude_mcp_server(name: str, entry: dict, scope: str = MCP_USER_SCOPE) -> None:
@@ -1037,7 +1043,9 @@ def apply_mcp_server_changes(
10371043
url = server.get("url")
10381044
if not isinstance(url, str) or not url:
10391045
continue
1040-
entry = build_mcp_http_entry(url)
1046+
# alwaysLoad (Claude-only) keeps the skills registry's utility tools
1047+
# discoverable without an explicit mention; other clients ignore it.
1048+
entry = build_mcp_http_entry(url, always_load=server.get("kind") == SKILLS_MCP_KIND)
10411049
for client in clients:
10421050
configure_client_mcp_server(client, name, url, entry)
10431051
changed = True
@@ -1103,6 +1111,10 @@ def purge_cross_workspace_mcp_residue(state: dict, workspace: str) -> None:
11031111
)
11041112

11051113

1114+
def _skills_entries(servers: list[dict]) -> list[dict]:
1115+
return [s for s in servers if s.get("kind") == SKILLS_MCP_KIND]
1116+
1117+
11061118
def _resolve_location_mcp_servers(
11071119
workspace: str,
11081120
profile: str | None,
@@ -1113,9 +1125,10 @@ def _resolve_location_mcp_servers(
11131125
) -> list[dict]:
11141126
"""Build the desired MCP server list for ``--location <cat>.<schema>``.
11151127
1116-
Strict replacement: the returned list is exactly the mcp-services
1117-
discovered at ``location``. Any previously-registered MCP entries outside
1118-
that location are removed by ``apply_mcp_server_changes``. Raises ``RuntimeError`` for an invalid
1128+
Strict replacement for mcp-services: the returned list is exactly the ones
1129+
discovered at ``location`` (any previously-registered mcp-service outside it
1130+
is removed by ``apply_mcp_server_changes``), plus any existing skills
1131+
connection, preserved untouched. Raises ``RuntimeError`` for an invalid
11191132
location (HTTP 404 from the listing API) or any other listing failure.
11201133
11211134
When ``services`` is given, the discovered set is narrowed to exactly that
@@ -1174,28 +1187,15 @@ def _resolve_location_mcp_servers(
11741187
working_servers.append(original.copy())
11751188
else:
11761189
working_servers.append(candidate)
1177-
return working_servers
1190+
return [*working_servers, *_skills_entries(original_servers)]
11781191

11791192

1180-
def configure_mcp_command(location: str | None = None, services: set[str] | None = None) -> int:
1181-
if services is not None and location is None:
1182-
# `--services` works standalone with full names (`system.ai.github`): the
1183-
# `<catalog>.<schema>` to configure is derived from them. Bare short names
1184-
# (`github`) can't be located without `--location`.
1185-
schemas = {".".join(s.split(".")[:2]) for s in services if s.count(".") >= 2}
1186-
bare = sorted(s for s in services if s.count(".") < 2)
1187-
if bare:
1188-
raise RuntimeError(
1189-
"--services short names need --location (or pass full names like "
1190-
f"`system.ai.<name>`): {', '.join(bare)}"
1191-
)
1192-
if len(schemas) != 1:
1193-
raise RuntimeError(
1194-
"--services without --location must all share one `<catalog>.<schema>` "
1195-
f"(got: {', '.join(sorted(schemas)) or 'none'}); pass --location instead."
1196-
)
1197-
location = next(iter(schemas))
1198-
state = load_state()
1193+
def _setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[str]]:
1194+
"""Validate the workspace, resolve configured MCP clients, and prepare auth.
1195+
1196+
Returns ``(workspace, profile, clients)`` and prints the section header, the
1197+
"Configuring for" note, and a warning per configured-but-uninstalled client.
1198+
"""
11991199
workspace = state.get("workspace")
12001200
if not workspace:
12011201
raise RuntimeError("Workspace is not configured. Run `ucode configure` first.")
@@ -1223,14 +1223,37 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
12231223
apply_pat_environment(state)
12241224
ensure_databricks_auth(workspace, profile)
12251225

1226-
print_section("MCP Servers")
1226+
print_section(section)
12271227
client_names = ", ".join(str(MCP_CLIENTS[client]["display"]) for client in clients)
12281228
print_note(f"Configuring for: {client_names}")
12291229
for client in missing_clients:
12301230
print_warning(
12311231
f"{MCP_CLIENTS[client]['display']} is configured in ucode but not installed; "
12321232
"skipping MCP config."
12331233
)
1234+
return workspace, profile, clients
1235+
1236+
1237+
def configure_mcp_command(location: str | None = None, services: set[str] | None = None) -> int:
1238+
if services is not None and location is None:
1239+
# `--services` works standalone with full names (`system.ai.github`): the
1240+
# `<catalog>.<schema>` to configure is derived from them. Bare short names
1241+
# (`github`) can't be located without `--location`.
1242+
schemas = {".".join(s.split(".")[:2]) for s in services if s.count(".") >= 2}
1243+
bare = sorted(s for s in services if s.count(".") < 2)
1244+
if bare:
1245+
raise RuntimeError(
1246+
"--services short names need --location (or pass full names like "
1247+
f"`system.ai.<name>`): {', '.join(bare)}"
1248+
)
1249+
if len(schemas) != 1:
1250+
raise RuntimeError(
1251+
"--services without --location must all share one `<catalog>.<schema>` "
1252+
f"(got: {', '.join(sorted(schemas)) or 'none'}); pass --location instead."
1253+
)
1254+
location = next(iter(schemas))
1255+
state = load_state()
1256+
workspace, profile, clients = _setup_mcp_clients(state, "MCP Servers")
12341257

12351258
original_mcp_servers_for_location: list[dict] = list(state.get("mcp_servers") or [])
12361259
if location is not None:
@@ -1276,20 +1299,24 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
12761299
)
12771300

12781301
original_mcp_servers: list[dict] = list(state.get("mcp_servers") or [])
1279-
original_by_name = _servers_by_name(original_mcp_servers)
1302+
# Skills connections are managed by `configure skills`, so keep them out of
1303+
# the picker and carry them through untouched.
1304+
skills_servers = _skills_entries(original_mcp_servers)
1305+
picker_servers = [s for s in original_mcp_servers if s.get("kind") != SKILLS_MCP_KIND]
1306+
original_by_name = _servers_by_name(picker_servers)
12801307
selections = prompt_for_mcp_server_choices(
12811308
available_external_mcp_names,
12821309
available_genie_mcp_servers,
12831310
available_app_mcp_servers,
1284-
original_mcp_servers,
1311+
picker_servers,
12851312
available_mcp_service_names,
12861313
available_vector_search_servers,
12871314
available_uc_functions_servers,
12881315
)
12891316
if selections is None:
12901317
return 0
12911318

1292-
working_mcp_servers: list[dict] = []
1319+
working_mcp_servers: list[dict] = list(skills_servers)
12931320
working_names: set[str] = set()
12941321
add_selections: list[str] = []
12951322
for selection in selections:
@@ -1335,3 +1362,68 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
13351362
# User submitted the picker without toggling anything --> make it clear nothing was selected
13361363
print_note("No MCP servers selected. Press space to toggle an item, then enter to save.")
13371364
return 0
1365+
1366+
1367+
def _merge_clients(prior: list[str] | None, new: list[str]) -> list[str]:
1368+
"""Order-preserving union of a prior client list with newly-configured ones."""
1369+
prior = list(prior or [])
1370+
return prior + [c for c in new if c not in prior]
1371+
1372+
1373+
def _build_skills_entry(workspace: str, locations: list[str], clients: list[str]) -> dict:
1374+
"""Canonical single skills-registry entry. ``skill_locations`` is the source
1375+
of truth; the URL is always derived from it, never parsed back."""
1376+
return {
1377+
"name": SKILLS_MCP_SERVER_NAME,
1378+
"kind": SKILLS_MCP_KIND,
1379+
"skill_locations": list(locations),
1380+
"url": build_skills_mcp_url(workspace, locations),
1381+
"auth": f"env:{MCP_AUTH_TOKEN_ENV_VAR}",
1382+
"clients": clients,
1383+
}
1384+
1385+
1386+
def _resolve_skills_mcp_servers(
1387+
workspace: str,
1388+
clients: list[str],
1389+
locations: list[str],
1390+
original_servers: list[dict],
1391+
) -> list[dict]:
1392+
"""Rebuild the MCP server list around exactly one skills entry.
1393+
1394+
Drops every prior ``kind=="skills"`` entry and any entry named
1395+
``SKILLS_MCP_SERVER_NAME`` (single-connection invariant; also sweeps up a
1396+
stray old-named entry via ``apply_mcp_server_changes``), keeps everything
1397+
else, and appends one rebuilt entry whose clients merge the prior skills
1398+
entry's clients with ``clients``.
1399+
"""
1400+
prior = next((s for s in original_servers if s.get("kind") == SKILLS_MCP_KIND), None)
1401+
merged = _merge_clients((prior or {}).get("clients"), clients)
1402+
kept = [
1403+
s
1404+
for s in original_servers
1405+
if s.get("kind") != SKILLS_MCP_KIND and _server_name(s) != SKILLS_MCP_SERVER_NAME
1406+
]
1407+
return [*kept, _build_skills_entry(workspace, locations, merged)]
1408+
1409+
1410+
def _update_skills_mcp(
1411+
state: dict, workspace: str, clients: list[str], locations: list[str]
1412+
) -> None:
1413+
"""Rebuild the single skills connection for ``locations`` and persist it."""
1414+
original = list(state.get("mcp_servers") or [])
1415+
working = _resolve_skills_mcp_servers(workspace, clients, locations, original)
1416+
changed = apply_mcp_server_changes(original, working, clients)
1417+
if changed or original != working:
1418+
state["mcp_servers"] = working
1419+
save_state(state)
1420+
print_success("Saved")
1421+
1422+
1423+
def configure_skills_mcp_command(locations: list[str]) -> int:
1424+
"""Set the skills MCP connection's ``skill_locations`` to exactly ``locations``,
1425+
replacing any previous set."""
1426+
state = load_state()
1427+
workspace, _profile, clients = _setup_mcp_clients(state, "Skills MCP")
1428+
_update_skills_mcp(state, workspace, clients, locations)
1429+
return 0

0 commit comments

Comments
 (0)