Skip to content

Commit 4ce5ae9

Browse files
authored
configure mcp surface vector search and UC functions (#163)
1 parent 18c9a06 commit 4ce5ae9

3 files changed

Lines changed: 619 additions & 4 deletions

File tree

src/ucode/databricks.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
import shlex
1515
import shutil
1616
import subprocess
17+
import time
18+
from concurrent.futures import (
19+
ThreadPoolExecutor,
20+
as_completed,
21+
)
22+
from concurrent.futures import (
23+
TimeoutError as FutureTimeoutError,
24+
)
1725
from pathlib import Path
1826
from typing import Literal, cast, overload
1927
from urllib import error as urllib_error
@@ -1251,6 +1259,259 @@ def build_mcp_service_url(workspace: str, full_name: str) -> str:
12511259
return f"{workspace}/ai-gateway/mcp-services/{full_name}"
12521260

12531261

1262+
# `list_vector_search_catalog_schemas` walks Vector Search endpoints+indexes.
1263+
# `list_uc_functions_catalog_schemas` walks UC catalogs+schemas in parallel and
1264+
# keeps only schemas with at least one user function.
1265+
1266+
_UC_LIST_PAGE_SIZE = 200
1267+
_UC_LIST_MAX_PAGES = 50
1268+
_UC_FUNCTION_PROBE_WORKERS = 16
1269+
_UC_LIST_HTTP_TIMEOUT = 10
1270+
_UC_FUNCTION_PROBE_TIMEOUT = 5
1271+
_VECTOR_SEARCH_DEADLINE_SECONDS = 15.0
1272+
_UC_FUNCTIONS_DEADLINE_SECONDS = 20.0
1273+
# Skip UC catalogs whose schemas almost never carry user-callable functions
1274+
# you'd want to expose as agent tools.
1275+
_UC_FUNCTIONS_SKIP_CATALOGS = frozenset(
1276+
{"__databricks_internal", "hive_metastore", "samples", "system"}
1277+
)
1278+
1279+
1280+
def _drain_with_deadline(futures: dict, deadline: float, on_result) -> None:
1281+
"""Iterate `futures` via `as_completed`, calling `on_result(value, key)` per
1282+
completed future, until either all are done or `deadline` passes. Per-task
1283+
exceptions are swallowed so one failure doesn't stop the rest."""
1284+
remaining = max(0.0, deadline - time.monotonic())
1285+
try:
1286+
for future in as_completed(futures, timeout=remaining):
1287+
try:
1288+
value = future.result()
1289+
except Exception: # noqa: BLE001
1290+
continue
1291+
on_result(value, futures[future])
1292+
if time.monotonic() > deadline:
1293+
break
1294+
except FutureTimeoutError:
1295+
pass
1296+
1297+
1298+
def _paginated_json_items(
1299+
base_url: str,
1300+
token: str,
1301+
*,
1302+
items_key: str,
1303+
extra_params: dict[str, str] | None = None,
1304+
page_size: int = _UC_LIST_PAGE_SIZE,
1305+
max_pages: int = _UC_LIST_MAX_PAGES,
1306+
timeout: int = 30,
1307+
) -> tuple[list[dict], str | None]:
1308+
"""Walk a Databricks `next_page_token` listing and return all items.
1309+
1310+
Returns (items, reason). Items are dicts; reason is None on success or a
1311+
short description of why the walk stopped early.
1312+
"""
1313+
items: list[dict] = []
1314+
page_token: str | None = None
1315+
seen_tokens: set[str] = set()
1316+
last_reason: str | None = None
1317+
for _ in range(max_pages):
1318+
params: dict[str, str] = {"max_results": str(page_size)}
1319+
if extra_params:
1320+
params.update(extra_params)
1321+
if page_token:
1322+
params["page_token"] = page_token
1323+
url = f"{base_url}?{urlencode(params)}"
1324+
payload, reason = _http_get_json(url, token, timeout=timeout)
1325+
if payload is None:
1326+
last_reason = reason
1327+
break
1328+
data = cast(dict, payload) if isinstance(payload, dict) else {}
1329+
raw = data.get(items_key) or []
1330+
if isinstance(raw, list):
1331+
for item in raw:
1332+
if isinstance(item, dict):
1333+
items.append(item)
1334+
page_token = data.get("next_page_token") or None
1335+
if not page_token or page_token in seen_tokens:
1336+
break
1337+
seen_tokens.add(page_token)
1338+
return items, last_reason
1339+
1340+
1341+
def _vector_index_catalog_schema(index: dict) -> tuple[str, str] | None:
1342+
"""Pull (catalog, schema) from one vector-search index entry."""
1343+
catalog = index.get("catalog_name")
1344+
schema = index.get("schema_name")
1345+
if isinstance(catalog, str) and isinstance(schema, str) and catalog and schema:
1346+
return catalog, schema
1347+
# Fallback: `name` is the fully-qualified UC name `catalog.schema.index`.
1348+
name = index.get("name")
1349+
if isinstance(name, str):
1350+
parts = name.split(".")
1351+
if len(parts) >= 3 and parts[0] and parts[1]:
1352+
return parts[0], parts[1]
1353+
return None
1354+
1355+
1356+
def list_vector_search_catalog_schemas(
1357+
workspace: str,
1358+
token: str,
1359+
*,
1360+
deadline_seconds: float = _VECTOR_SEARCH_DEADLINE_SECONDS,
1361+
) -> tuple[list[tuple[str, str]], str | None]:
1362+
"""Return sorted unique `(catalog, schema)` pairs that contain at least
1363+
one Databricks Vector Search index. Walks the per-endpoint index listings
1364+
in parallel under a wall-clock budget; returns partial results once
1365+
`deadline_seconds` is exceeded."""
1366+
hostname = workspace_hostname(workspace)
1367+
deadline = time.monotonic() + deadline_seconds
1368+
endpoints, reason = _paginated_json_items(
1369+
f"https://{hostname}/api/2.0/vector-search/endpoints",
1370+
token,
1371+
items_key="endpoints",
1372+
timeout=_UC_LIST_HTTP_TIMEOUT,
1373+
)
1374+
if not endpoints:
1375+
return [], reason or "no vector search endpoints found"
1376+
1377+
endpoint_names = [e["name"] for e in endpoints if isinstance(e.get("name"), str) and e["name"]]
1378+
if not endpoint_names:
1379+
return [], "no vector search endpoints with names"
1380+
1381+
pairs: set[tuple[str, str]] = set()
1382+
workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(endpoint_names)))
1383+
with ThreadPoolExecutor(max_workers=workers) as pool:
1384+
futures = {
1385+
pool.submit(
1386+
_paginated_json_items,
1387+
f"https://{hostname}/api/2.0/vector-search/indexes",
1388+
token,
1389+
items_key="vector_indexes",
1390+
extra_params={"endpoint_name": name},
1391+
timeout=_UC_LIST_HTTP_TIMEOUT,
1392+
): name
1393+
for name in endpoint_names
1394+
}
1395+
1396+
def collect(result, _endpoint):
1397+
indexes, _ = result
1398+
for index in indexes:
1399+
pair = _vector_index_catalog_schema(index)
1400+
if pair:
1401+
pairs.add(pair)
1402+
1403+
_drain_with_deadline(futures, deadline, collect)
1404+
pool.shutdown(wait=False, cancel_futures=True)
1405+
1406+
if not pairs:
1407+
return [], "no vector search indexes found"
1408+
return sorted(pairs), None
1409+
1410+
1411+
def _schema_has_user_function(hostname: str, token: str, catalog: str, schema: str) -> bool:
1412+
"""One-shot probe: does `{catalog}.{schema}` expose any UC function?"""
1413+
url = (
1414+
f"https://{hostname}/api/2.1/unity-catalog/functions"
1415+
f"?{urlencode({'catalog_name': catalog, 'schema_name': schema, 'max_results': '1'})}"
1416+
)
1417+
payload, _reason = _http_get_json(url, token, timeout=_UC_FUNCTION_PROBE_TIMEOUT)
1418+
if not isinstance(payload, dict):
1419+
return False
1420+
functions = payload.get("functions") or []
1421+
return isinstance(functions, list) and any(isinstance(item, dict) for item in functions)
1422+
1423+
1424+
def list_uc_functions_catalog_schemas(
1425+
workspace: str,
1426+
token: str,
1427+
*,
1428+
deadline_seconds: float = _UC_FUNCTIONS_DEADLINE_SECONDS,
1429+
) -> tuple[list[tuple[str, str]], str | None]:
1430+
"""Return sorted unique `(catalog, schema)` pairs containing at least one
1431+
user-defined UC function."""
1432+
hostname = workspace_hostname(workspace)
1433+
deadline = time.monotonic() + deadline_seconds
1434+
1435+
catalogs, catalogs_reason = _paginated_json_items(
1436+
f"https://{hostname}/api/2.1/unity-catalog/catalogs",
1437+
token,
1438+
items_key="catalogs",
1439+
timeout=_UC_LIST_HTTP_TIMEOUT,
1440+
)
1441+
if not catalogs:
1442+
return [], catalogs_reason or "no UC catalogs found"
1443+
1444+
catalog_names = [
1445+
c["name"]
1446+
for c in catalogs
1447+
if isinstance(c.get("name"), str)
1448+
and c["name"]
1449+
and c["name"] not in _UC_FUNCTIONS_SKIP_CATALOGS
1450+
]
1451+
if not catalog_names:
1452+
return [], "no user UC catalogs found"
1453+
if time.monotonic() > deadline:
1454+
return [], "deadline exceeded while listing UC catalogs"
1455+
1456+
# Parallel per-catalog schema listing.
1457+
candidate_pairs: list[tuple[str, str]] = []
1458+
schema_workers = max(1, min(_UC_FUNCTION_PROBE_WORKERS, len(catalog_names)))
1459+
with ThreadPoolExecutor(max_workers=schema_workers) as pool:
1460+
schema_futures = {
1461+
pool.submit(
1462+
_paginated_json_items,
1463+
f"https://{hostname}/api/2.1/unity-catalog/schemas",
1464+
token,
1465+
items_key="schemas",
1466+
extra_params={"catalog_name": cat},
1467+
timeout=_UC_LIST_HTTP_TIMEOUT,
1468+
): cat
1469+
for cat in catalog_names
1470+
}
1471+
1472+
def collect_schemas(result, catalog):
1473+
schemas, _ = result
1474+
for schema in schemas:
1475+
schema_name = schema.get("name")
1476+
# `information_schema` is auto-attached to every catalog and
1477+
# never holds user functions.
1478+
if (
1479+
isinstance(schema_name, str)
1480+
and schema_name
1481+
and schema_name != "information_schema"
1482+
):
1483+
candidate_pairs.append((catalog, schema_name))
1484+
1485+
_drain_with_deadline(schema_futures, deadline, collect_schemas)
1486+
pool.shutdown(wait=False, cancel_futures=True)
1487+
1488+
if not candidate_pairs:
1489+
if time.monotonic() > deadline:
1490+
return [], "deadline exceeded while listing UC schemas"
1491+
return [], "no UC schemas found"
1492+
1493+
# Parallel function-existence probes.
1494+
pairs: set[tuple[str, str]] = set()
1495+
with ThreadPoolExecutor(max_workers=_UC_FUNCTION_PROBE_WORKERS) as pool:
1496+
probe_futures = {
1497+
pool.submit(_schema_has_user_function, hostname, token, cat, schema): (cat, schema)
1498+
for cat, schema in candidate_pairs
1499+
}
1500+
1501+
def collect_pair(has_fn, pair):
1502+
if has_fn:
1503+
pairs.add(pair)
1504+
1505+
_drain_with_deadline(probe_futures, deadline, collect_pair)
1506+
pool.shutdown(wait=False, cancel_futures=True)
1507+
1508+
if not pairs:
1509+
if time.monotonic() > deadline:
1510+
return [], "deadline exceeded probing UC schemas for functions"
1511+
return [], "no UC schemas with user functions found"
1512+
return sorted(pairs), None
1513+
1514+
12541515
def discover_claude_models(workspace: str, token: str) -> tuple[dict[str, str], str | None]:
12551516
"""Discover Claude families on this workspace's AI Gateway.
12561517

0 commit comments

Comments
 (0)