Skip to content

Commit fb0b561

Browse files
authored
feat: set openWorldHint explicitly on every MCP tool (#1879)
* feat: set openWorldHint explicitly on every MCP tool openWorldHint defaults to true in the MCP spec, so a tool that omits it is silently treated as open-world by clients. Only ha_manage_updates set it explicitly, leaving the other 86 tools relying on the implicit default. Set it explicitly on all 87 tools: true for the 8 whose output crosses a trust boundary into an externally-authored world (HACS store search/info, HACS download/add, the add-on store, blueprint import-from-URL, core update checks, the in-process server self-update, and the MCP-tools component install), false for the 79 whose domain is the local Home Assistant instance. A tool is open-world when its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf. Clients that honor the hint can then skip untrusted-content scrutiny on local reads and keep flagging the genuinely external tools. Add a test asserting every tool sets openWorldHint explicitly so new tools can't drift back to the implicit default, document the annotation in AGENTS.md and the styleguide alongside the other safety hints, and regenerate tools.json. Closes #1871 * fix(annotations): mark update-embedding and external-content reads open-world Review adoption for PR #1879: - ha_get_overview / ha_get_system_health embed ha_mcp_update, resolved via a network check (update_check.get_update_info), so their output can carry externally-fetched release metadata -> openWorldHint: true. - ha_get_blueprint returns imported blueprint content (author, source_url, inputs) that may originate from an external URL -> true. - ha_config_list_dashboard_resources returns third-party card URLs and, with include_content, decoded inline JS/CSS -> true. - ha_get_skill_guide is registered in server.py via self.mcp.tool(...) and was missed by the tools/ scanner: annotate it openWorldHint: false (bundled local docs) and extend the test to assert server-registered tools carry the hint. - Make the presence-check regex quote-agnostic (single or double quotes). * fix(annotations): set openWorldHint on the runtime tool-search proxies Review adoption for PR #1879. CategorizedSearchTransform builds ha_search_tools and the ha_call_read/write/delete proxies at runtime via ToolAnnotations(...), which the static tools/ scan never sees, so they inherited the implicit open-world default when ENABLE_TOOL_SEARCH=true. - ha_search_tools -> false (searches the local tool catalog). - ha_call_read_tool / ha_call_write_tool -> true (they dispatch read/write tools that include the open-world ones: HACS, add-on store, blueprint import, updates, server self-update). - ha_call_delete_tool -> false (delete-category tools are all local registry removals; none reach an external system). - Add a guard test asserting every ToolAnnotations(...) construction in categorized_search.py sets openWorldHint. * docs: state the content-provenance clause in the AGENTS.md openWorldHint row The row said only "Set to False when the tool's domain is the local Home Assistant instance". Applied literally that classifies ha_get_overview, ha_get_system_health, ha_get_blueprint and ha_config_list_dashboard_resources as false -- contradicting the true values this PR ships. .gemini/styleguide.md already carries the load-bearing clause: a tool is open-world if its output carries externally-authored content back to the client, even when a local integration makes the actual network call. Append it here with the four examples so both docs state one rule. * test(annotations): fail loudly when the tool scan drops a tool The closure-form pattern @mcp.tool\(([^)]*)\) cannot span a ')', so a future decorator whose args contain one (e.g. a title like "Get Logs (verbose)") falls out of get_all_tools() and escapes every annotation assertion -- silently inheriting the MCP default openWorldHint=true. Verified by injecting a paren into a closure-form title: the scan dropped to 86 tools while test_all_tools_have_open_world_hint still passed, i.e. the tool skipped every check unnoticed. Every tool sets openWorldHint exactly once, so assert the scanned count equals the occurrences across the tool files; the injected drop now fails with 86 != 87. No tool triggers this today. Also resolve module-level string constants when naming server-registered tools: ha_get_skill_guide registers as name=SKILL_TOOL_NAME, so the Constant-only lookup reported "<unknown>" in the failure message. Drop two stale scanner claims while in the file: ha_set_entity and ha_get_system_health are both class-form, so the class pattern matches them -- neither is dropped by the closure pattern's nested-paren limit.
1 parent 9f37ee9 commit fb0b561

44 files changed

Lines changed: 350 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gemini/styleguide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Verify that safety annotations match actual tool behavior:
7070
- Tool with `readOnlyHint: True` must NOT modify state (no writes, no service calls)
7171
- Tool with `destructiveHint: True` must actually delete data
7272
- State-changing operations should have `idempotentHint: True` only if safe to retry
73+
- Tool with `openWorldHint: True` must reach an external, third-party-authored world (HACS store, add-on repositories, GitHub release feeds, arbitrary import URLs); a tool whose domain is the local Home Assistant instance should use `False`. It is open-world if its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf
7374

7475
Flag HIGH severity if annotation contradicts actual behavior in the implementation.
7576

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ Every tool needs `tags={"Category Name"}` (native FastMCP parameter). Drives the
553553
| `readOnlyHint: True` | `False` | Tool does not modify its environment |
554554
| `destructiveHint: True` | `True` | Tool may perform destructive updates (only meaningful when `readOnlyHint` is false). Set to `False` for non-destructive writes (e.g., creating a record) |
555555
| `idempotentHint: True` | `False` | Repeated calls with same args have no additional effect (only meaningful when `readOnlyHint` is false) |
556+
| `openWorldHint: True` | `True` | Tool reaches an external, third-party-authored world (HACS store, add-on repositories, GitHub release feeds, arbitrary import URLs). Set to `False` when the tool's domain is the local Home Assistant instance. A tool is also open-world if its output carries externally-authored content back to the client, even when a local integration (HACS, Supervisor, HA Core) makes the actual network call on its behalf — `ha_get_overview` and `ha_get_system_health` embed the update-check field that reaches PyPI / the Supervisor store, while `ha_get_blueprint` and `ha_config_list_dashboard_resources` return externally-authored content from purely local reads. Required on every tool — the default is `true`, so an omitted value silently marks a local tool as open-world |
556557

557558
### Error Handling
558559

src/ha_mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,7 @@ async def ha_get_skill_guide(
13491349
name=SKILL_TOOL_NAME,
13501350
description=tool_description,
13511351
annotations={
1352+
"openWorldHint": False,
13521353
"readOnlyHint": True,
13531354
"idempotentHint": True,
13541355
"title": "Get Home Assistant Best Practices Skill Guide",

src/ha_mcp/tools/backup.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,11 @@ def register_backup_tools(
10541054
@mcp.tool(
10551055
description=manage_backup_description,
10561056
tags={"System"},
1057-
annotations={"destructiveHint": True, "title": "Manage Backups"},
1057+
annotations={
1058+
"openWorldHint": False,
1059+
"destructiveHint": True,
1060+
"title": "Manage Backups",
1061+
},
10581062
)
10591063
@log_tool_usage
10601064
async def ha_manage_backup(

src/ha_mcp/tools/tools_addons.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,6 +2572,7 @@ def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -
25722572
@mcp.tool(
25732573
tags={"Add-ons"},
25742574
annotations={
2575+
"openWorldHint": True,
25752576
"idempotentHint": True,
25762577
"readOnlyHint": True,
25772578
"title": "Get Add-ons",
@@ -2660,6 +2661,7 @@ async def ha_get_addon(
26602661
@mcp.tool(
26612662
tags={"Add-ons"},
26622663
annotations={
2664+
"openWorldHint": True,
26632665
"destructiveHint": True,
26642666
"idempotentHint": False,
26652667
"readOnlyHint": False,

src/ha_mcp/tools/tools_areas.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def _build_floor_create_message(
139139
name="ha_list_floors_areas",
140140
tags={"Areas & Floors"},
141141
annotations={
142+
"openWorldHint": False,
142143
"idempotentHint": True,
143144
"readOnlyHint": True,
144145
"title": "List Floors and Areas",
@@ -390,6 +391,7 @@ def _floor_sort_key(floor: dict[str, Any]) -> int:
390391
name="ha_set_area_or_floor",
391392
tags={"Areas & Floors"},
392393
annotations={
394+
"openWorldHint": False,
393395
"destructiveHint": True,
394396
"title": "Create or Update Area or Floor",
395397
},
@@ -640,6 +642,7 @@ async def ha_set_area_or_floor(
640642
name="ha_remove_area_or_floor",
641643
tags={"Areas & Floors"},
642644
annotations={
645+
"openWorldHint": False,
643646
"destructiveHint": True,
644647
"idempotentHint": True,
645648
"title": "Remove Area or Floor",

src/ha_mcp/tools/tools_blueprints.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _format_blueprint_list(
7676
name="ha_get_blueprint",
7777
tags={"Blueprints"},
7878
annotations={
79+
"openWorldHint": True,
7980
"idempotentHint": True,
8081
"readOnlyHint": True,
8182
"title": "Get Blueprint",
@@ -224,7 +225,11 @@ async def ha_get_blueprint(
224225
@tool(
225226
name="ha_import_blueprint",
226227
tags={"Blueprints"},
227-
annotations={"destructiveHint": True, "title": "Import Blueprint"},
228+
annotations={
229+
"openWorldHint": True,
230+
"destructiveHint": True,
231+
"title": "Import Blueprint",
232+
},
228233
)
229234
@log_tool_usage
230235
async def ha_import_blueprint(

src/ha_mcp/tools/tools_bug_report.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ async def _detect_component_version(self) -> str | None:
646646
name="ha_report_issue",
647647
tags={"Utilities"},
648648
annotations={
649+
"openWorldHint": False,
649650
"idempotentHint": True,
650651
"readOnlyHint": True,
651652
"title": "Report Issue or Feedback",

src/ha_mcp/tools/tools_calendar.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(self, client: Any) -> None:
4343
name="ha_config_get_calendar_events",
4444
tags={"Calendar"},
4545
annotations={
46+
"openWorldHint": False,
4647
"idempotentHint": True,
4748
"readOnlyHint": True,
4849
"title": "Get Calendar Events",
@@ -282,6 +283,7 @@ def _build_set_calendar_event_error_suggestions(
282283
name="ha_config_set_calendar_event",
283284
tags={"Calendar"},
284285
annotations={
286+
"openWorldHint": False,
285287
"destructiveHint": True,
286288
"title": "Create or Update Calendar Event",
287289
},
@@ -426,6 +428,7 @@ async def ha_config_set_calendar_event(
426428
name="ha_config_remove_calendar_event",
427429
tags={"Calendar"},
428430
annotations={
431+
"openWorldHint": False,
429432
"destructiveHint": True,
430433
"idempotentHint": True,
431434
"title": "Remove Calendar Event",

src/ha_mcp/tools/tools_camera.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def _check_response(response: Any, entity_id: str) -> None:
6262
name="ha_get_camera_image",
6363
tags={"Camera"},
6464
annotations={
65+
"openWorldHint": False,
6566
"idempotentHint": True,
6667
"readOnlyHint": True,
6768
"title": "Get Camera Image",

0 commit comments

Comments
 (0)