Skip to content

Commit f0bd2df

Browse files
add --services subset flag to configure mcp (#175)
`configure mcp` could only configure a whole `--location <catalog>.<schema>`. Add `--services` to configure exactly a comma-separated subset, adding and removing registered servers to match (so deselecting a service removes it). Full names like `system.ai.github` work standalone (schema derived); bare short names like `github` resolve against `--location`. An empty string removes all; names not found at the location are warned about and skipped. Co-authored-by: Isaac Co-authored-by: Kecheng Cao <kecheng.cao@databricks.com>
1 parent 526ed6b commit f0bd2df

3 files changed

Lines changed: 272 additions & 4 deletions

File tree

src/ucode/cli.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -899,10 +899,24 @@ def configure_mcp(
899899
"this location are removed.",
900900
),
901901
] = None,
902+
services: Annotated[
903+
str | None,
904+
typer.Option(
905+
"--services",
906+
help="Configure exactly this comma-separated subset of MCP services (adding and "
907+
"removing to match) instead of a whole schema. Full names like `system.ai.github` "
908+
"work on their own; bare short names like `github` need --location to locate them. "
909+
"Omit --services to configure the whole --location schema; pass an empty string "
910+
"(with --location) to remove all.",
911+
),
912+
] = None,
902913
) -> None:
903914
"""Add Databricks MCP servers to installed coding tools."""
915+
# `--services` absent -> None (whole schema); present (even empty) -> the
916+
# explicit subset, so `--services ""` deselects everything.
917+
selected = None if services is None else {s.strip() for s in services.split(",") if s.strip()}
904918
try:
905-
configure_mcp_command(location=location)
919+
configure_mcp_command(location=location, services=selected)
906920
except RuntimeError as exc:
907921
print_err(str(exc))
908922
raise typer.Exit(1) from None

src/ucode/mcp.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,13 +1109,22 @@ def _resolve_location_mcp_servers(
11091109
clients: list[str],
11101110
location: str,
11111111
original_servers: list[dict],
1112+
services: set[str] | None = None,
11121113
) -> list[dict]:
11131114
"""Build the desired MCP server list for ``--location <cat>.<schema>``.
11141115
11151116
Strict replacement: the returned list is exactly the mcp-services
11161117
discovered at ``location``. Any previously-registered MCP entries outside
11171118
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+
location (HTTP 404 from the listing API) or any other listing failure.
1120+
1121+
When ``services`` is given, the discovered set is narrowed to exactly that
1122+
subset (matched by full name like ``system.ai.github`` or bare short name
1123+
like ``github``); names not found at ``location`` are skipped with a
1124+
warning rather than failing, so a saved selection that references a
1125+
since-removed service still configures the rest. An empty set selects
1126+
nothing (every previously-registered service in the location is removed).
1127+
``None`` keeps the whole schema."""
11191128
if location.count(".") != 1 or not all(part.strip() for part in location.split(".")):
11201129
raise RuntimeError(f"--location must be `<catalog>.<schema>`, got `{location}`.")
11211130

@@ -1133,6 +1142,21 @@ def _resolve_location_mcp_servers(
11331142
if not names:
11341143
print_note(f"No MCP services exist at `{location}`.")
11351144

1145+
if services is not None:
1146+
discovered_full = set(names)
1147+
discovered_short = {full_name.split(".")[-1] for full_name in names}
1148+
unknown = services - discovered_full - discovered_short
1149+
if unknown:
1150+
print_warning(
1151+
f"Ignoring requested MCP services not found in `{location}`: "
1152+
f"{', '.join(sorted(unknown))}."
1153+
)
1154+
names = [
1155+
full_name
1156+
for full_name in names
1157+
if full_name in services or full_name.split(".")[-1] in services
1158+
]
1159+
11361160
original_by_name = _servers_by_name(original_servers)
11371161
working_servers: list[dict] = []
11381162
for full_name in names:
@@ -1153,7 +1177,24 @@ def _resolve_location_mcp_servers(
11531177
return working_servers
11541178

11551179

1156-
def configure_mcp_command(location: str | None = None) -> int:
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))
11571198
state = load_state()
11581199
workspace = state.get("workspace")
11591200
if not workspace:
@@ -1194,7 +1235,7 @@ def configure_mcp_command(location: str | None = None) -> int:
11941235
original_mcp_servers_for_location: list[dict] = list(state.get("mcp_servers") or [])
11951236
if location is not None:
11961237
working_mcp_servers = _resolve_location_mcp_servers(
1197-
workspace, profile, clients, location, original_mcp_servers_for_location
1238+
workspace, profile, clients, location, original_mcp_servers_for_location, services
11981239
)
11991240
changed = apply_mcp_server_changes(
12001241
original_mcp_servers_for_location, working_mcp_servers, clients

tests/test_mcp.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,219 @@ def test_existing_entry_gets_reconfigured_for_newly_added_clients(self, monkeypa
14291429
]
14301430

14311431

1432+
class TestConfigureMcpServicesSubset:
1433+
"""`--location <schema> --services a,b,...` configures exactly the named subset."""
1434+
1435+
def test_configures_only_the_requested_subset(self, monkeypatch):
1436+
configured: list[tuple[str, str, str, dict]] = []
1437+
saved_states: list[dict] = []
1438+
_stub_location_base(monkeypatch, {**CLAUDE_STATE})
1439+
monkeypatch.setattr(
1440+
mcp,
1441+
"list_mcp_services",
1442+
lambda workspace, token, parent: (
1443+
["system.ai.github", "system.ai.slack", "system.ai.gmail"],
1444+
None,
1445+
),
1446+
)
1447+
monkeypatch.setattr(
1448+
mcp,
1449+
"configure_client_mcp_server",
1450+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1451+
)
1452+
monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state.copy()))
1453+
1454+
assert (
1455+
mcp.configure_mcp_command(
1456+
location="system.ai", services={"system.ai.github", "system.ai.gmail"}
1457+
)
1458+
== 0
1459+
)
1460+
1461+
# slack is dropped; only the two requested services are configured.
1462+
assert sorted(c[1] for c in configured) == ["system-ai-github", "system-ai-gmail"]
1463+
assert sorted(s["name"] for s in saved_states[-1]["mcp_servers"]) == [
1464+
"system-ai-github",
1465+
"system-ai-gmail",
1466+
]
1467+
1468+
def test_matches_bare_short_names(self, monkeypatch):
1469+
configured: list[tuple[str, str, str, dict]] = []
1470+
_stub_location_base(monkeypatch, {**CLAUDE_STATE})
1471+
monkeypatch.setattr(
1472+
mcp,
1473+
"list_mcp_services",
1474+
lambda workspace, token, parent: (["system.ai.github", "system.ai.slack"], None),
1475+
)
1476+
monkeypatch.setattr(
1477+
mcp,
1478+
"configure_client_mcp_server",
1479+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1480+
)
1481+
monkeypatch.setattr(mcp, "save_state", lambda state: None)
1482+
1483+
assert mcp.configure_mcp_command(location="system.ai", services={"github"}) == 0
1484+
1485+
assert [c[1] for c in configured] == ["system-ai-github"]
1486+
1487+
def test_unknown_requested_service_warns_and_skips(self, monkeypatch):
1488+
configured: list[tuple[str, str, str, dict]] = []
1489+
warnings: list[str] = []
1490+
_stub_location_base(monkeypatch, {**CLAUDE_STATE})
1491+
monkeypatch.setattr(
1492+
mcp,
1493+
"list_mcp_services",
1494+
lambda workspace, token, parent: (["system.ai.github"], None),
1495+
)
1496+
monkeypatch.setattr(
1497+
mcp,
1498+
"configure_client_mcp_server",
1499+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1500+
)
1501+
monkeypatch.setattr(mcp, "save_state", lambda state: None)
1502+
monkeypatch.setattr(mcp, "print_warning", lambda msg: warnings.append(msg))
1503+
1504+
assert (
1505+
mcp.configure_mcp_command(
1506+
location="system.ai", services={"system.ai.github", "system.ai.ghost"}
1507+
)
1508+
== 0
1509+
)
1510+
1511+
# The known service is still configured; the unknown one is reported, not fatal.
1512+
assert [c[1] for c in configured] == ["system-ai-github"]
1513+
assert any("system.ai.ghost" in w for w in warnings)
1514+
1515+
def test_empty_services_removes_everything(self, monkeypatch):
1516+
existing = {
1517+
"name": "system-ai-github",
1518+
"url": f"{WS}/ai-gateway/mcp-services/system.ai.github",
1519+
"auth": "env:OAUTH_TOKEN",
1520+
"clients": ["claude"],
1521+
}
1522+
configured: list[tuple[str, str, str, dict]] = []
1523+
removed: list[tuple[str, str]] = []
1524+
saved_states: list[dict] = []
1525+
_stub_location_base(monkeypatch, {**CLAUDE_STATE, "mcp_servers": [existing]})
1526+
monkeypatch.setattr(
1527+
mcp,
1528+
"list_mcp_services",
1529+
lambda workspace, token, parent: (["system.ai.github"], None),
1530+
)
1531+
monkeypatch.setattr(
1532+
mcp,
1533+
"configure_client_mcp_server",
1534+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1535+
)
1536+
monkeypatch.setattr(
1537+
mcp,
1538+
"remove_client_mcp_server",
1539+
lambda client, name: removed.append((client, name)) or [],
1540+
)
1541+
monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state.copy()))
1542+
1543+
assert mcp.configure_mcp_command(location="system.ai", services=set()) == 0
1544+
1545+
assert configured == []
1546+
assert removed == [("claude", "system-ai-github")]
1547+
assert saved_states[-1]["mcp_servers"] == []
1548+
1549+
def test_adds_and_removes_to_match_new_selection(self, monkeypatch):
1550+
# The live case teammates want mid-session: started with github+slack,
1551+
# then the user deselects slack and selects gmail.
1552+
github = {
1553+
"name": "system-ai-github",
1554+
"url": f"{WS}/ai-gateway/mcp-services/system.ai.github",
1555+
"auth": "env:OAUTH_TOKEN",
1556+
"clients": ["claude"],
1557+
}
1558+
slack = {
1559+
"name": "system-ai-slack",
1560+
"url": f"{WS}/ai-gateway/mcp-services/system.ai.slack",
1561+
"auth": "env:OAUTH_TOKEN",
1562+
"clients": ["claude"],
1563+
}
1564+
configured: list[tuple[str, str, str, dict]] = []
1565+
removed: list[tuple[str, str]] = []
1566+
saved_states: list[dict] = []
1567+
_stub_location_base(monkeypatch, {**CLAUDE_STATE, "mcp_servers": [github, slack]})
1568+
monkeypatch.setattr(
1569+
mcp,
1570+
"list_mcp_services",
1571+
lambda workspace, token, parent: (
1572+
["system.ai.github", "system.ai.slack", "system.ai.gmail"],
1573+
None,
1574+
),
1575+
)
1576+
monkeypatch.setattr(
1577+
mcp,
1578+
"configure_client_mcp_server",
1579+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1580+
)
1581+
monkeypatch.setattr(
1582+
mcp,
1583+
"remove_client_mcp_server",
1584+
lambda client, name: removed.append((client, name)) or [],
1585+
)
1586+
monkeypatch.setattr(mcp, "save_state", lambda state: saved_states.append(state.copy()))
1587+
1588+
assert (
1589+
mcp.configure_mcp_command(
1590+
location="system.ai", services={"system.ai.github", "system.ai.gmail"}
1591+
)
1592+
== 0
1593+
)
1594+
1595+
# slack removed, gmail added, github untouched (entry unchanged).
1596+
assert removed == [("claude", "system-ai-slack")]
1597+
assert [c[1] for c in configured] == ["system-ai-gmail"]
1598+
assert sorted(s["name"] for s in saved_states[-1]["mcp_servers"]) == [
1599+
"system-ai-github",
1600+
"system-ai-gmail",
1601+
]
1602+
1603+
def test_full_names_without_location_derive_the_schema(self, monkeypatch):
1604+
configured: list[tuple[str, str, str, dict]] = []
1605+
seen: dict[str, str] = {}
1606+
_stub_location_base(monkeypatch, {**CLAUDE_STATE})
1607+
1608+
def fake_list(workspace, token, parent):
1609+
seen["parent"] = parent
1610+
return ["system.ai.github", "system.ai.slack"], None
1611+
1612+
monkeypatch.setattr(mcp, "list_mcp_services", fake_list)
1613+
monkeypatch.setattr(
1614+
mcp,
1615+
"configure_client_mcp_server",
1616+
lambda client, name, url, entry: configured.append((client, name, url, entry)) or [],
1617+
)
1618+
monkeypatch.setattr(mcp, "save_state", lambda state: None)
1619+
1620+
# No --location: the `<catalog>.<schema>` is derived from the full names.
1621+
assert mcp.configure_mcp_command(services={"system.ai.github", "system.ai.slack"}) == 0
1622+
1623+
assert seen == {"parent": "system.ai"}
1624+
assert sorted(c[1] for c in configured) == ["system-ai-github", "system-ai-slack"]
1625+
1626+
def test_short_name_without_location_raises(self):
1627+
try:
1628+
mcp.configure_mcp_command(services={"github"})
1629+
except RuntimeError as exc:
1630+
assert "--location" in str(exc)
1631+
else:
1632+
raise AssertionError("expected RuntimeError for a bare short name without --location")
1633+
1634+
def test_full_names_spanning_multiple_schemas_without_location_raises(self):
1635+
try:
1636+
mcp.configure_mcp_command(services={"system.ai.github", "other.cat.thing"})
1637+
except RuntimeError as exc:
1638+
assert "--location" in str(exc)
1639+
else:
1640+
raise AssertionError(
1641+
"expected RuntimeError for multi-schema services without --location"
1642+
)
1643+
1644+
14321645
class TestRevertMcpConfigs:
14331646
def test_removes_cli_registered_servers_and_restores_copilot_config(self, monkeypatch):
14341647
removed: list[tuple[str, str]] = []

0 commit comments

Comments
 (0)