Skip to content

Commit c183cc7

Browse files
committed
feat(policy): schema-driven condition builder for write/destructive tools (#966)
The previous "Add predicate" UX required users to type both the dotted arg path (e.g. `args.domain`) and the value as JSON. Two problems: 1. They need to know what fields each tool takes. 2. They need to know what values are legal (which HA domains exist, which entities, etc.). Replace the free-text path input with a dropdown sourced from the tool's JSON schema, and replace the free-text value input with a (multi-)select sourced from HA when the path has a known value source (domain, service, entity_id today; trivially extensible). Free-text is still available via an "(other — type a path)" escape hatch and as the automatic fallback for ops that don't pair with a registry (regex / contains / gt / lt). Server: - New `/api/policy/tool-schema?name=...` returns `{paths: [...], value_sources: {path: source_key}}`. Read-only tools return empty paths so the UI falls back to free-text (gating those is low-value but still permitted manually). - New `/api/policy/value-source?source=...` resolves a source key to a live list of choices. In-process 30s TTL cache avoids hammering HA when the user explores paths. - value_sources.py registry maps (tool_name, arg_path) → source_key for the common write/destructive surface (call_service, set_entity, set_integration_enabled, get_history, etc.). New mappings are one dict entry plus, if a new source key, one fetcher. - Both endpoints mount in addon + secret-prefix routes. Sidecar serves 503 stubs (no FastMCP registry / HA client in that process). UI: rename user-facing "predicate" → "condition" (CS jargon → SQL/JIRA terminology users actually recognise; internal Pydantic class stays `Predicate` so the wire format is unchanged). Form fetches the schema lazily on first open, caches it on the card, refetches value choices when path/op changes. Includes test_schema_handlers.py covering: missing-name 400, sidecar 503, unknown-tool 404, read-only empty-paths, write-tool paths + registry, JSON-schema enum passthrough, value-source 400 paths, both HA-services payload shapes, domain filtering for entities/services, and upstream-fetch 502 mapping.
1 parent b3e8107 commit c183cc7

5 files changed

Lines changed: 746 additions & 31 deletions

File tree

src/ha_mcp/policy/handlers.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,58 @@
1313
from .approval_queue import ApprovalQueue
1414
from .model import Policy
1515
from .persistence import load_policy, save_policy
16+
from .value_sources import (
17+
all_value_sources_for,
18+
fetch_value_source,
19+
)
20+
21+
22+
def _is_write_or_destructive(tool: Any) -> bool:
23+
"""True iff the tool can mutate state (not read-only) — the only
24+
surface gating-UX polish is worth investing in. Read-only tools
25+
still gate correctly if a user adds a rule, they just get the
26+
free-text predicate fallback in the UI."""
27+
ann = getattr(tool, "annotations", None)
28+
# No annotations = treat as potentially write (safe default for the
29+
# UI, matches the runtime gate which doesn't skip read-only).
30+
return ann is None or getattr(ann, "readOnlyHint", None) is not True
31+
32+
33+
def _extract_arg_paths(parameters: dict[str, Any]) -> list[dict[str, Any]]:
34+
"""Turn a tool's JSON-schema ``parameters`` into a list of
35+
``{path, type, enum, description, required}`` entries the UI can
36+
render as a dropdown.
37+
38+
Only top-level properties are surfaced — nested objects exist in a
39+
few tools but the predicate language already supports dotted paths,
40+
so users can fall back to free-text for those if needed.
41+
"""
42+
if not isinstance(parameters, dict):
43+
return []
44+
props = parameters.get("properties") or {}
45+
required = set(parameters.get("required") or [])
46+
out: list[dict[str, Any]] = []
47+
for name, schema in props.items():
48+
if not isinstance(schema, dict):
49+
continue
50+
out.append(
51+
{
52+
"path": f"args.{name}",
53+
"label": name,
54+
"type": schema.get("type"),
55+
"enum": schema.get("enum"),
56+
"description": (schema.get("description") or "")[:200],
57+
"required": name in required,
58+
}
59+
)
60+
return out
1661

1762

1863
def build_policy_handlers(
1964
*,
2065
data_dir: Path,
2166
queue: ApprovalQueue,
67+
server: Any | None = None,
2268
) -> dict[str, Callable[[Request], Any]]:
2369

2470
async def get_config(_: Request) -> JSONResponse:
@@ -121,10 +167,79 @@ async def post_deny(request: Request) -> JSONResponse:
121167
)
122168
return JSONResponse({"denied": True})
123169

170+
async def get_tool_schema(request: Request) -> JSONResponse:
171+
"""Return the predicate-builder hints for one tool.
172+
173+
Powers the schema-driven path/value pickers in the Tool Security
174+
Policies tab. Returns ``paths: []`` for read-only tools so the
175+
UI knows to hide the pickers (free-text fallback still works).
176+
Returns 503 when the sidecar/stub backend is in use — the
177+
sidecar has no FastMCP registry to introspect.
178+
"""
179+
name = request.query_params.get("name") or ""
180+
if not name:
181+
return JSONResponse({"error": "missing 'name' query param"}, 400)
182+
if server is None:
183+
return JSONResponse(
184+
{"error": "tool schema introspection unavailable in this mode"},
185+
503,
186+
)
187+
try:
188+
tools = await server.mcp.local_provider._list_tools()
189+
except Exception as e:
190+
return JSONResponse({"error": f"tool list failed: {e}"}, 500)
191+
tool = next((t for t in tools if getattr(t, "name", None) == name), None)
192+
if tool is None:
193+
return JSONResponse({"error": f"tool not found: {name}"}, 404)
194+
if not _is_write_or_destructive(tool):
195+
return JSONResponse(
196+
{
197+
"tool_name": name,
198+
"is_write_or_destructive": False,
199+
"paths": [],
200+
"value_sources": {},
201+
}
202+
)
203+
return JSONResponse(
204+
{
205+
"tool_name": name,
206+
"is_write_or_destructive": True,
207+
"paths": _extract_arg_paths(getattr(tool, "parameters", {}) or {}),
208+
"value_sources": all_value_sources_for(name),
209+
}
210+
)
211+
212+
async def get_value_source(request: Request) -> JSONResponse:
213+
"""Return live legal values for a named value source.
214+
215+
Sources are defined in ``policy/value_sources.py``. Extra query
216+
params (e.g. ``domain=light``) are passed through to the
217+
fetcher to support cascading selects.
218+
"""
219+
source = request.query_params.get("source") or ""
220+
if not source:
221+
return JSONResponse({"error": "missing 'source' query param"}, 400)
222+
if server is None:
223+
return JSONResponse(
224+
{"error": "value-source fetch unavailable in this mode"}, 503
225+
)
226+
params = {k: v for k, v in request.query_params.items() if k != "source"}
227+
try:
228+
values = await fetch_value_source(
229+
source, client=server.client, params=params
230+
)
231+
except ValueError as e:
232+
return JSONResponse({"error": str(e)}, 400)
233+
except Exception as e:
234+
return JSONResponse({"error": f"value-source fetch failed: {e}"}, 502)
235+
return JSONResponse({"source": source, "values": values})
236+
124237
return {
125238
"policy_get_config": get_config,
126239
"policy_put_config": put_config,
127240
"policy_get_pending": get_pending,
128241
"policy_post_approve": post_approve,
129242
"policy_post_deny": post_deny,
243+
"policy_get_tool_schema": get_tool_schema,
244+
"policy_get_value_source": get_value_source,
130245
}

src/ha_mcp/policy/value_sources.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Value-source registry for the predicate builder UI (#966).
2+
3+
When a user picks a tool + arg-path in the Tool Security Policies tab,
4+
the UI needs to know whether to render a free-text input or a dropdown
5+
of legal values. This module maps `(tool_name, arg_path)` pairs to a
6+
named value source, plus implements the fetchers that read live values
7+
out of Home Assistant.
8+
9+
Read-only tools are explicitly out of scope for gating UX polish, so the
10+
registry covers write/destructive tools only. Anything not in the
11+
registry falls back to free-text JSON entry in the UI.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import time
17+
from collections.abc import Awaitable, Callable
18+
from typing import Any
19+
20+
# (tool_name, arg_path) → value_source key. arg_path is the same dotted
21+
# string the user picks in the predicate "path" dropdown — i.e. with the
22+
# "args." prefix the evaluator uses.
23+
VALUE_SOURCE_REGISTRY: dict[tuple[str, str], str] = {
24+
# Service-call gating — by far the most common case the user wants
25+
# to author (gate ha_call_service when domain in [lock, alarm_*]).
26+
("ha_call_service", "args.domain"): "ha_domains",
27+
("ha_call_service", "args.service"): "ha_services",
28+
("ha_call_service", "args.entity_id"): "ha_entities",
29+
# Bulk-control mirrors call_service's arg shape per item, but the
30+
# outer wrapper takes a list — the predicate language can't reach
31+
# into list items today, so no registry entries here. Free-text
32+
# fallback still works.
33+
("ha_set_entity", "args.entity_id"): "ha_entities",
34+
("ha_get_state", "args.entity_id"): "ha_entities",
35+
("ha_get_entity", "args.entity_id"): "ha_entities",
36+
("ha_get_history", "args.entity_ids"): "ha_entities",
37+
("ha_remove_entity", "args.entity_id"): "ha_entities",
38+
("ha_update_device", "args.entity_id"): "ha_entities",
39+
("ha_get_entity_exposure", "args.entity_id"): "ha_entities",
40+
("ha_set_integration_enabled", "args.entity_id"): "ha_entities",
41+
}
42+
43+
# Fetched-value TTL cache so the UI can click through path options
44+
# without hammering HA. 30s is long enough to cover normal exploration
45+
# but short enough that newly-added domains/entities appear quickly.
46+
_CACHE_TTL_SECONDS = 30.0
47+
_cache: dict[str, tuple[float, list[str]]] = {}
48+
49+
50+
def value_source_for(tool_name: str, arg_path: str) -> str | None:
51+
return VALUE_SOURCE_REGISTRY.get((tool_name, arg_path))
52+
53+
54+
def all_value_sources_for(tool_name: str) -> dict[str, str]:
55+
"""Return {arg_path: value_source} for one tool — used by the UI to
56+
decide which paths render as dropdowns vs free-text."""
57+
return {
58+
arg_path: source
59+
for (tn, arg_path), source in VALUE_SOURCE_REGISTRY.items()
60+
if tn == tool_name
61+
}
62+
63+
64+
def _cache_get(key: str) -> list[str] | None:
65+
entry = _cache.get(key)
66+
if entry is None:
67+
return None
68+
ts, value = entry
69+
if time.monotonic() - ts > _CACHE_TTL_SECONDS:
70+
return None
71+
return value
72+
73+
74+
def _cache_set(key: str, value: list[str]) -> None:
75+
_cache[key] = (time.monotonic(), value)
76+
77+
78+
async def fetch_value_source(
79+
source: str,
80+
*,
81+
client: Any,
82+
params: dict[str, str] | None = None,
83+
) -> list[str]:
84+
"""Fetch live choices for a known value source.
85+
86+
Raises ValueError if ``source`` is unknown so the handler can return
87+
a 400 instead of an empty list (which would look like "no choices
88+
available" to the user).
89+
"""
90+
params = params or {}
91+
fetcher = _FETCHERS.get(source)
92+
if fetcher is None:
93+
raise ValueError(f"Unknown value source: {source!r}")
94+
cache_key = source + "|" + "&".join(f"{k}={v}" for k, v in sorted(params.items()))
95+
cached = _cache_get(cache_key)
96+
if cached is not None:
97+
return cached
98+
values = sorted(await fetcher(client, params))
99+
_cache_set(cache_key, values)
100+
return values
101+
102+
103+
async def _fetch_ha_domains(client: Any, _params: dict[str, str]) -> list[str]:
104+
services = await client.get_services()
105+
# /services returns either {domain: {service: ...}} or
106+
# [{domain, services}, ...] depending on HA version; handle both.
107+
if isinstance(services, dict):
108+
return list(services.keys())
109+
if isinstance(services, list):
110+
return [s["domain"] for s in services if isinstance(s, dict) and "domain" in s]
111+
return []
112+
113+
114+
async def _fetch_ha_services(client: Any, params: dict[str, str]) -> list[str]:
115+
services = await client.get_services()
116+
domain_filter = params.get("domain")
117+
out: set[str] = set()
118+
if isinstance(services, dict):
119+
if domain_filter:
120+
out.update((services.get(domain_filter) or {}).keys())
121+
else:
122+
for svcs in services.values():
123+
if isinstance(svcs, dict):
124+
out.update(svcs.keys())
125+
return list(out)
126+
if isinstance(services, list):
127+
for entry in services:
128+
if not isinstance(entry, dict):
129+
continue
130+
if domain_filter and entry.get("domain") != domain_filter:
131+
continue
132+
svcs = entry.get("services") or {}
133+
if isinstance(svcs, dict):
134+
out.update(svcs.keys())
135+
return list(out)
136+
return []
137+
138+
139+
async def _fetch_ha_entities(client: Any, params: dict[str, str]) -> list[str]:
140+
states = await client.get_states()
141+
domain_filter = params.get("domain")
142+
out: list[str] = []
143+
for s in states:
144+
if not isinstance(s, dict):
145+
continue
146+
eid = s.get("entity_id")
147+
if not isinstance(eid, str):
148+
continue
149+
if domain_filter and not eid.startswith(domain_filter + "."):
150+
continue
151+
out.append(eid)
152+
return out
153+
154+
155+
_FETCHERS: dict[str, Callable[[Any, dict[str, str]], Awaitable[list[str]]]] = {
156+
"ha_domains": _fetch_ha_domains,
157+
"ha_services": _fetch_ha_services,
158+
"ha_entities": _fetch_ha_entities,
159+
}

0 commit comments

Comments
 (0)