ha-mcp-dev 7.1.0.dev289__tar.gz → 7.1.0.dev290__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {ha_mcp_dev-7.1.0.dev289/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.1.0.dev290}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/config.py +18 -1
  4. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/server.py +200 -3
  5. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_search.py +30 -4
  6. ha_mcp_dev-7.1.0.dev290/src/ha_mcp/transforms/__init__.py +9 -0
  7. ha_mcp_dev-7.1.0.dev290/src/ha_mcp/transforms/categorized_search.py +393 -0
  8. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  9. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -0
  10. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/LICENSE +0 -0
  11. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/MANIFEST.in +0 -0
  12. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/README.md +0 -0
  13. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/setup.cfg +0 -0
  14. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/__init__.py +0 -0
  15. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/__main__.py +0 -0
  16. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/_pypi_marker +0 -0
  17. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/__init__.py +0 -0
  18. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/consent_form.py +0 -0
  19. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/provider.py +0 -0
  20. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/__init__.py +0 -0
  21. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/rest_client.py +0 -0
  22. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/websocket_client.py +0 -0
  23. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/websocket_listener.py +0 -0
  24. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/errors.py +0 -0
  25. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/py.typed +0 -0
  26. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/card_types.json +0 -0
  27. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/dashboard_guide.md +0 -0
  28. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  29. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  30. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  31. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  32. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  33. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  34. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  35. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  36. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  37. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  38. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  39. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  40. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  41. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  42. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  43. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  44. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/smoke_test.py +0 -0
  45. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/__init__.py +0 -0
  46. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/backup.py +0 -0
  47. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  48. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/device_control.py +0 -0
  49. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/enhanced.py +0 -0
  50. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/helpers.py +0 -0
  51. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/registry.py +0 -0
  52. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/smart_search.py +0 -0
  53. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_addons.py +0 -0
  54. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_areas.py +0 -0
  55. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  56. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  57. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_calendar.py +0 -0
  58. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_camera.py +0 -0
  59. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  60. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  61. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  62. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  63. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_info.py +0 -0
  64. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  65. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_entities.py +0 -0
  66. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  67. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_groups.py +0 -0
  68. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_hacs.py +0 -0
  69. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_history.py +0 -0
  70. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_integrations.py +0 -0
  71. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_labels.py +0 -0
  72. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  73. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_registry.py +0 -0
  74. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_resources.py +0 -0
  75. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_service.py +0 -0
  76. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_services.py +0 -0
  77. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_system.py +0 -0
  78. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_todo.py +0 -0
  79. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_traces.py +0 -0
  80. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_updates.py +0 -0
  81. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_utility.py +0 -0
  82. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  83. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_zones.py +0 -0
  84. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/util_helpers.py +0 -0
  85. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/__init__.py +0 -0
  86. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/domain_handlers.py +0 -0
  87. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  88. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/operation_manager.py +0 -0
  89. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/python_sandbox.py +0 -0
  90. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/usage_logger.py +0 -0
  91. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  92. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  93. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  94. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  95. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/__init__.py +0 -0
  96. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/test_constants.py +0 -0
  97. {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.1.0.dev289
3
+ Version: 7.1.0.dev290
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.1.0.dev289"
7
+ version = "7.1.0.dev290"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -15,7 +15,7 @@ except importlib.metadata.PackageNotFoundError:
15
15
  _PACKAGE_VERSION = "unknown"
16
16
 
17
17
  from dotenv import load_dotenv
18
- from pydantic import Field, field_validator
18
+ from pydantic import Field, field_validator, model_validator
19
19
  from pydantic_settings import BaseSettings, SettingsConfigDict
20
20
 
21
21
  project_root = Path(__file__).parent.parent.parent
@@ -94,6 +94,23 @@ class Settings(BaseSettings):
94
94
  # that don't support MCP resources natively.
95
95
  enable_skills_as_tools: bool = Field(False, alias="ENABLE_SKILLS_AS_TOOLS")
96
96
 
97
+ # Tool search transform — replaces the full tool catalog with a unified
98
+ # BM25 search tool and categorized call proxies (read/write/delete).
99
+ # Dramatically reduces idle context token usage for LLMs.
100
+ enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")
101
+
102
+ @model_validator(mode="after")
103
+ def _skills_dependency(self) -> "Settings":
104
+ """Auto-enable skills (resources) when skills-as-tools is on.
105
+
106
+ skills_as_tools wraps ResourcesAsTools which requires skills to be
107
+ registered as MCP resources first. Without this, enabling
108
+ skills_as_tools alone would produce empty list_resources results.
109
+ """
110
+ if self.enable_skills_as_tools and not self.enable_skills:
111
+ self.enable_skills = True
112
+ return self
113
+
97
114
  @property
98
115
  def env_file_name(self) -> str:
99
116
  """Get the current environment file name."""
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  import logging
13
13
  from collections.abc import Callable, Coroutine
14
14
  from pathlib import Path
15
- from typing import TYPE_CHECKING, Any, cast
15
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
16
16
 
17
17
  import yaml # type: ignore[import-untyped]
18
18
  from fastmcp import FastMCP
@@ -20,6 +20,7 @@ from mcp.types import Icon
20
20
 
21
21
  from .config import _PACKAGE_VERSION, get_global_settings
22
22
  from .tools.enhanced import EnhancedToolsMixin
23
+ from .transforms import DEFAULT_PINNED_TOOLS
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from .client.rest_client import HomeAssistantClient
@@ -138,6 +139,10 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
138
139
  # Register bundled skills as MCP resources
139
140
  self._register_skills()
140
141
 
142
+ # Apply tool search transform (must come after all tools and
143
+ # ResourcesAsTools are registered so it can wrap everything)
144
+ self._apply_tool_search()
145
+
141
146
  def _get_skills_dir(self) -> Path | None:
142
147
  """Return the bundled skills directory if it exists.
143
148
 
@@ -188,7 +193,13 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
188
193
  # Build the access method instruction based on config
189
194
  if self.settings.enable_skills_as_tools:
190
195
  access_method = (
191
- "Use the read_resource tool with the skill's URI to load it."
196
+ "Read the skill via MCP resources (resources/read with the "
197
+ "skill:// URI) — if you can read these instructions, you "
198
+ "should be able to access resources as well. If for any "
199
+ "reason you cannot access MCP resources, use the "
200
+ "list_resources and read_resource tools as a fallback. "
201
+ "If you can access resources normally, do not waste "
202
+ "time or tokens on those tools."
192
203
  )
193
204
  else:
194
205
  access_method = (
@@ -208,7 +219,36 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
208
219
  f"How to access: {access_method}\n"
209
220
  )
210
221
 
211
- return header + "\n".join(skill_blocks)
222
+ instructions = header + "\n".join(skill_blocks)
223
+
224
+ # Append tool search instructions when enabled
225
+ if self.settings.enable_tool_search:
226
+ instructions += (
227
+ "\n\n## Tool Discovery\n"
228
+ "This server uses search-based tool discovery. Most tools "
229
+ "are NOT listed directly \u2014 use ha_search_tools to find them.\n\n"
230
+ "WORKFLOW:\n"
231
+ "1. Call ha_search_tools(query=\"...\") to find relevant tools\n"
232
+ "2. Results include name, description, parameters, and "
233
+ "annotations (readOnlyHint/destructiveHint)\n"
234
+ "3. Execute the discovered tool \u2014 two options:\n"
235
+ " a) DIRECT CALL (preferred): Call the tool directly by "
236
+ "name. All discovered tools are callable without a proxy.\n"
237
+ " b) VIA PROXY: For permission-gated execution, use the "
238
+ "matching proxy:\n"
239
+ " - ha_call_read_tool \u2014 safe, read-only operations\n"
240
+ " - ha_call_write_tool \u2014 creates or modifies data\n"
241
+ " - ha_call_delete_tool \u2014 removes data permanently\n\n"
242
+ "Once you know a tool\u2019s name, you do NOT need to search "
243
+ "again \u2014 call it directly.\n\n"
244
+ f"A few critical tools are listed directly "
245
+ f"({', '.join(DEFAULT_PINNED_TOOLS)}). Everything else must "
246
+ f"be discovered via search.\n\n"
247
+ "DO NOT assume a capability is unavailable because you "
248
+ "don't see a direct tool for it. ALWAYS search first."
249
+ )
250
+
251
+ return instructions
212
252
 
213
253
  @staticmethod
214
254
  def _parse_skill_frontmatter(main_file: Path) -> dict | None:
@@ -264,7 +304,164 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
264
304
 
265
305
  return f"\n### Skill: {skill_name} ({uri})\n{description.strip()}"
266
306
 
307
+ # Tools pinned outside the search transform for individual permission gating.
308
+ # These are always visible in list_tools() regardless of search transform.
309
+ _PINNED_TOOLS: ClassVar[list[str]] = list(DEFAULT_PINNED_TOOLS)
310
+
311
+ # Description for the unified search tool
312
+ _SEARCH_TOOL_DESCRIPTION = (
313
+ "Search ALL Home Assistant tools by keyword. Returns matching tools "
314
+ "with descriptions, parameters, and annotations (read/write/delete). "
315
+ "Categories: entities, states, automations, scripts, dashboards, "
316
+ "helpers, HACS, calendar, zones, labels, groups, areas, floors, "
317
+ "history, statistics, devices, integrations, services, backups, "
318
+ "todo, camera, blueprints, system, and more.\n\n"
319
+ "WORKFLOW:\n"
320
+ "1. ha_search_tools(query='...') \u2014 find tools (this tool)\n"
321
+ "2. Execute: call the tool DIRECTLY by name (preferred), or use "
322
+ "a proxy for permission gating:\n"
323
+ " - ha_call_read_tool \u2014 readOnlyHint tools (safe, no side effects)\n"
324
+ " - ha_call_write_tool \u2014 destructiveHint tools that create/update\n"
325
+ " - ha_call_delete_tool \u2014 destructiveHint tools that remove/delete\n"
326
+ "Once you know a tool name, call it directly \u2014 no need to search "
327
+ "again.\n\n"
328
+ "If using proxies, call with TWO top-level params:\n"
329
+ ' ha_call_read_tool(name="ha_search_entities", arguments={"query": "..."})\n'
330
+ " Do NOT nest name/arguments inside the arguments param.\n"
331
+ " Call proxy tools SEQUENTIALLY, not in parallel.\n\n"
332
+ "ALWAYS search before assuming a capability is unavailable. "
333
+ "Most tools are discoverable only through this search."
334
+ )
335
+
336
+ # Extra keywords appended to tool descriptions for BM25 ranking.
337
+ # Only active behind enable_tool_search — the original docstrings
338
+ # are unchanged; these keywords are appended by SearchKeywordsTransform.
339
+ _SEARCH_KEYWORDS: ClassVar[dict[str, str]] = {
340
+ # s02: "find entities" → ha_search_entities should outrank ha_deep_search
341
+ "ha_search_entities": (
342
+ "find entities lookup discover search lights sensors switches "
343
+ "covers climate fans media_player binary_sensor device_tracker "
344
+ "person weather automation script helper input_boolean input_number"
345
+ ),
346
+ # s07: "get/read automation" → ha_config_get_automation should outrank set
347
+ "ha_config_get_automation": (
348
+ "read inspect fetch view existing automation config triggers "
349
+ "conditions actions get show detail"
350
+ ),
351
+ # s09: "create helper" → ha_config_set_helper should outrank remove_helper
352
+ "ha_config_set_helper": (
353
+ "create new add helper input_boolean input_number input_text "
354
+ "counter timer input_datetime input_select input_button "
355
+ "schedule zone group min_max"
356
+ ),
357
+ # Boost tools that compete with ha_deep_search for common queries
358
+ "ha_config_get_script": (
359
+ "read inspect fetch view existing script config sequence "
360
+ "actions get show detail"
361
+ ),
362
+ "ha_config_list_helpers": (
363
+ "list all helpers input_boolean input_number input_text "
364
+ "counter timer input_datetime input_select"
365
+ ),
366
+ "ha_get_entity": (
367
+ "get entity state attributes details single specific entity_id"
368
+ ),
369
+ "ha_get_state": (
370
+ "get current state value single entity check status"
371
+ ),
372
+ "ha_get_states": (
373
+ "get all states entities bulk overview list"
374
+ ),
375
+ "ha_config_set_automation": (
376
+ "create update modify edit automation triggers conditions actions "
377
+ "new automation write save"
378
+ ),
379
+ "ha_config_set_script": (
380
+ "create update modify edit script sequence actions "
381
+ "new script write save"
382
+ ),
383
+ }
384
+
385
+ # Description overrides that REPLACE the original description for BM25.
386
+ # Used to narrow overly broad tools so they stop matching generic queries.
387
+ # Only active behind enable_tool_search via SearchKeywordsTransform.
388
+ _SEARCH_DESCRIPTION_OVERRIDES: ClassVar[dict[str, str]] = {
389
+ "ha_deep_search": (
390
+ "Search INSIDE automation, script, and helper YAML configurations. "
391
+ "Use ONLY when you need to find where a specific service call, "
392
+ "entity reference, or config field appears within existing "
393
+ "automation/script/helper definitions. "
394
+ "NOT for finding entities or discovering tools."
395
+ ),
396
+ }
397
+
398
+ def _apply_tool_search(self) -> None:
399
+ """Apply the CategorizedSearchTransform if enabled.
400
+
401
+ Replaces the full tool catalog with a unified BM25 search tool and
402
+ three categorized call proxies (read/write/delete). Pinned tools
403
+ remain directly visible in list_tools() for individual permission
404
+ gating. ResourcesAsTools (list_resources/read_resource) are also
405
+ pinned when enabled.
406
+ """
407
+ if not self.settings.enable_tool_search:
408
+ return
409
+
410
+ try:
411
+ from .transforms import CategorizedSearchTransform
412
+ except ImportError:
413
+ logger.error(
414
+ "CategorizedSearchTransform not available but ENABLE_TOOL_SEARCH=true — "
415
+ "full tool catalog will be exposed. Install fastmcp>=3.1 to fix."
416
+ )
417
+ return
418
+
419
+ # Build the always_visible list
420
+ pinned = list(self._PINNED_TOOLS)
267
421
 
422
+ # Pin ResourcesAsTools and skill guidance tools if skills-as-tools is enabled
423
+ if self.settings.enable_skills_as_tools:
424
+ pinned.extend(["list_resources", "read_resource"])
425
+ # Forward-compatible: pin skill guidance tools registered by #732
426
+ pinned.extend(getattr(self, "_skill_tool_names", []))
427
+
428
+ # When skills-as-tools is enabled, the client likely doesn't support
429
+ # resources or server instructions — add skills hint to the search
430
+ # tool description (the one place the LLM is guaranteed to see).
431
+ description = self._SEARCH_TOOL_DESCRIPTION
432
+ if self.settings.enable_skills_as_tools:
433
+ description += (
434
+ "\n\nThis server also provides best-practice skills via "
435
+ "skill:// resources. If your client supports MCP resources, "
436
+ "prefer reading them directly. Otherwise, call "
437
+ "list_resources and read_resource (directly, no proxy "
438
+ "needed) to access the relevant SKILL.md before creating "
439
+ "automations or configuring devices."
440
+ )
441
+
442
+ try:
443
+ # Enrich tool descriptions for BM25 ranking (innermost transform).
444
+ # Added first so the search transform indexes enriched descriptions.
445
+ # Original tool docstrings are unchanged.
446
+ from .transforms import SearchKeywordsTransform
447
+
448
+ self.mcp.add_transform(SearchKeywordsTransform(
449
+ keywords=self._SEARCH_KEYWORDS,
450
+ overrides=self._SEARCH_DESCRIPTION_OVERRIDES,
451
+ ))
452
+
453
+ self.mcp.add_transform(
454
+ CategorizedSearchTransform(
455
+ max_results=5,
456
+ always_visible=pinned,
457
+ search_tool_description=description,
458
+ )
459
+ )
460
+ logger.info(
461
+ "Tool search transform applied (%d pinned tools)", len(pinned)
462
+ )
463
+ except Exception:
464
+ logger.exception("Failed to apply tool search transform")
268
465
 
269
466
  def _register_skills(self) -> None:
270
467
  """Register bundled HA best-practice skills as MCP resources.
@@ -10,7 +10,9 @@ from typing import Annotated, Any, Literal, cast
10
10
 
11
11
  from pydantic import Field
12
12
 
13
+ from ..config import get_global_settings
13
14
  from ..errors import create_validation_error
15
+ from ..transforms.categorized_search import DEFAULT_PINNED_TOOLS
14
16
  from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
15
17
  from .util_helpers import (
16
18
  add_timezone_metadata,
@@ -507,7 +509,7 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
507
509
  Returns comprehensive system information at the requested detail level,
508
510
  including Home Assistant base_url, version, location, timezone, entity overview,
509
511
  and active persistent notifications (if any).
510
- Use 'standard' (default) for most queries. Optionally customize entity fields and limits.
512
+ Default is 'minimal' use this unless you specifically need all entities.
511
513
  """
512
514
  # Coerce boolean parameters that may come as strings from XML-style calls
513
515
  include_state_bool = coerce_bool_param(
@@ -578,7 +580,31 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
578
580
  for n in notifications
579
581
  ]
580
582
  except Exception as e:
581
- logger.debug(f"Failed to fetch notifications for overview: {e}")
583
+ logger.warning(f"Failed to fetch notifications for overview: {e}")
584
+
585
+ # Include tool discovery hint when search transform is active
586
+ settings = get_global_settings()
587
+ if settings.enable_tool_search:
588
+ result["tool_discovery"] = {
589
+ "hint": (
590
+ "This server uses search-based tool discovery. "
591
+ "Use ha_search_tools(query='...') to find tools, then "
592
+ "execute the discovered tool directly by name (preferred), "
593
+ "or via a proxy for permission gating: "
594
+ "ha_call_read_tool, ha_call_write_tool, or "
595
+ "ha_call_delete_tool. Each proxy takes name and arguments "
596
+ "as separate top-level params. Call proxy tools SEQUENTIALLY "
597
+ "(not in parallel) to avoid cascading cancellations. "
598
+ "Do NOT assume a capability is unavailable without searching first."
599
+ ),
600
+ "pinned_tools": sorted([
601
+ *DEFAULT_PINNED_TOOLS,
602
+ "ha_search_tools",
603
+ "ha_call_read_tool",
604
+ "ha_call_write_tool",
605
+ "ha_call_delete_tool",
606
+ ]),
607
+ }
582
608
 
583
609
  return result
584
610
 
@@ -629,7 +655,7 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
629
655
  Args:
630
656
  query: Search query (can be partial, with typos)
631
657
  search_types: Types to search (list of strings, default: ["automation", "script", "helper"])
632
- limit: Maximum total results to return (default: 20)
658
+ limit: Maximum total results to return (default: 5)
633
659
 
634
660
  Examples:
635
661
  - Find automations using a service: ha_deep_search("light.turn_on")
@@ -683,7 +709,7 @@ def register_search_tools(mcp: Any, client: Any, **kwargs: Any) -> None:
683
709
  )
684
710
  @log_tool_usage
685
711
  async def ha_get_state(entity_id: str) -> dict[str, Any]:
686
- """Get detailed state information for a Home Assistant entity with timezone metadata."""
712
+ """Get current status, state, and attributes of any entity (lights, switches, sensors, climate, covers, locks, fans, etc.)."""
687
713
  try:
688
714
  result = await client.get_entity_state(entity_id)
689
715
  return await add_timezone_metadata(client, result)
@@ -0,0 +1,9 @@
1
+ """Custom FastMCP transforms for ha-mcp."""
2
+
3
+ from .categorized_search import (
4
+ DEFAULT_PINNED_TOOLS,
5
+ CategorizedSearchTransform,
6
+ SearchKeywordsTransform,
7
+ )
8
+
9
+ __all__ = ["CategorizedSearchTransform", "DEFAULT_PINNED_TOOLS", "SearchKeywordsTransform"]
@@ -0,0 +1,393 @@
1
+ """Categorized search transform for ha-mcp.
2
+
3
+ Extends FastMCP's BM25SearchTransform to provide a unified search tool
4
+ with separate call proxies for read, write, and delete operations.
5
+ Each proxy carries its own MCP annotations so clients can apply
6
+ appropriate permission policies (e.g., auto-approve reads, gate writes).
7
+
8
+ Tools are categorized by their existing MCP annotations:
9
+ - readOnlyHint=True → "read" category
10
+ - destructiveHint=True with remove/delete in name → "delete" category
11
+ - destructiveHint=True (other) → "write" category
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ from collections.abc import Sequence
21
+ from typing import TYPE_CHECKING, Annotated, Any, Literal
22
+
23
+ from fastmcp.exceptions import ToolError
24
+ from fastmcp.server.context import Context
25
+ from fastmcp.server.transforms import Transform
26
+ from fastmcp.server.transforms.search.bm25 import BM25SearchTransform
27
+ from fastmcp.tools import Tool
28
+ from mcp.types import ToolAnnotations
29
+
30
+ from ..errors import ErrorCode, create_error_response
31
+
32
+ if TYPE_CHECKING:
33
+ from fastmcp.server.transforms import GetToolNext
34
+ from fastmcp.utilities.versions import VersionSpec
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Default HA tools to pin (always visible, bypass search transform)
39
+ DEFAULT_PINNED_TOOLS: tuple[str, ...] = (
40
+ "ha_restart",
41
+ "ha_reload_core",
42
+ "ha_backup_create",
43
+ "ha_backup_restore",
44
+ "ha_get_overview",
45
+ "ha_report_issue",
46
+ "ha_search_entities",
47
+ "ha_config_get_automation",
48
+ "ha_config_set_automation",
49
+ )
50
+
51
+ # Tool name patterns that indicate delete/remove operations
52
+ _DELETE_PATTERNS = ("_remove_", "_delete_")
53
+
54
+
55
+ class SearchKeywordsTransform(Transform):
56
+ """Adjust BM25 search keywords in tool descriptions.
57
+
58
+ Supports two modes per tool:
59
+ - **keywords** (append): Extra keywords appended after the original
60
+ description so BM25 ranks the tool higher for common queries.
61
+ - **overrides** (replace): Completely replaces the description with
62
+ a narrower one so BM25 ranks the tool *lower* for broad queries.
63
+
64
+ The original description is preserved unless an override is applied.
65
+ Only active when added to the transform pipeline (i.e., behind
66
+ the ``enable_tool_search`` toggle).
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ keywords: dict[str, str] | None = None,
72
+ overrides: dict[str, str] | None = None,
73
+ ) -> None:
74
+ """Initialize with optional keyword boosts and description overrides."""
75
+ self._keywords = keywords or {}
76
+ self._overrides = overrides or {}
77
+
78
+ def _enrich(self, tool: Tool) -> Tool:
79
+ # Overrides take priority — replace the entire description
80
+ override = self._overrides.get(tool.name)
81
+ if override is not None:
82
+ return tool.model_copy(update={"description": override})
83
+ # Otherwise append keywords if present
84
+ keywords = self._keywords.get(tool.name)
85
+ if not keywords:
86
+ return tool
87
+ enriched = f"{tool.description}\n\n{keywords}" if tool.description else keywords
88
+ return tool.model_copy(update={"description": enriched})
89
+
90
+ async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
91
+ return [self._enrich(t) for t in tools]
92
+
93
+ async def get_tool(
94
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
95
+ ) -> Tool | None:
96
+ tool = await call_next(name, version=version)
97
+ return self._enrich(tool) if tool else None
98
+
99
+ # Proxy description suffix (shared across all proxies)
100
+ _PROXY_PARAMS_SUFFIX = (
101
+ "Params: name (str) = tool name, arguments (dict) = tool parameters. "
102
+ "These are separate top-level params, not nested.\n"
103
+ "IMPORTANT: Call this tool SEQUENTIALLY, not in parallel with other proxy calls."
104
+ )
105
+
106
+
107
+ def _build_proxy_descriptions(search_tool_name: str) -> dict[str, str]:
108
+ """Build proxy descriptions that reference the configured search tool name."""
109
+ return {
110
+ "read": (
111
+ f"Execute a read-only tool discovered via {search_tool_name}. "
112
+ f"Safe — does not modify any data or state.\n"
113
+ f"{_PROXY_PARAMS_SUFFIX}"
114
+ ),
115
+ "write": (
116
+ f"Execute a write tool discovered via {search_tool_name}. "
117
+ f"Creates or updates data. Use for any tool that modifies "
118
+ f"state but does not delete/remove resources.\n"
119
+ f"{_PROXY_PARAMS_SUFFIX}"
120
+ ),
121
+ "delete": (
122
+ f"Execute a delete/remove tool discovered via {search_tool_name}. "
123
+ f"Permanently removes data. Use for tools that delete or "
124
+ f"remove resources (areas, automations, devices, etc.).\n"
125
+ f"{_PROXY_PARAMS_SUFFIX}"
126
+ ),
127
+ }
128
+
129
+
130
+ def _categorize_tool(tool: Tool) -> str:
131
+ """Categorize a tool as read, write, or delete based on annotations and name."""
132
+ annotations = tool.annotations
133
+ if annotations and annotations.readOnlyHint:
134
+ return "read"
135
+ # A tool is 'delete' only if it's destructive AND its name suggests deletion
136
+ if annotations and annotations.destructiveHint and any(
137
+ pattern in tool.name for pattern in _DELETE_PATTERNS
138
+ ):
139
+ return "delete"
140
+ return "write"
141
+
142
+
143
+ class CategorizedSearchTransform(BM25SearchTransform):
144
+ """BM25 search with categorized call proxies.
145
+
146
+ Replaces the single ``call_tool`` proxy from BaseSearchTransform with
147
+ three category-specific proxies, each carrying appropriate MCP
148
+ annotations for client-side permission handling.
149
+
150
+ The unified ``ha_search_tools`` is inherited from BM25SearchTransform and
151
+ searches across ALL tools regardless of category. Search results include
152
+ each tool's full annotations so the LLM can determine which proxy to use.
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ max_results: int = 5,
159
+ always_visible: list[str] | None = None,
160
+ search_tool_name: str = "ha_search_tools",
161
+ search_tool_description: str | None = None,
162
+ call_read_name: str = "ha_call_read_tool",
163
+ call_write_name: str = "ha_call_write_tool",
164
+ call_delete_name: str = "ha_call_delete_tool",
165
+ **kwargs: Any,
166
+ ) -> None:
167
+ super().__init__(
168
+ max_results=max_results,
169
+ always_visible=always_visible,
170
+ search_tool_name=search_tool_name,
171
+ # Placeholder call_tool_name — we override transform_tools with
172
+ # categorized proxies so the base class's single call proxy is
173
+ # never surfaced to clients.
174
+ call_tool_name="_base_call_proxy",
175
+ **kwargs,
176
+ )
177
+ self._call_read_name = call_read_name
178
+ self._call_write_name = call_write_name
179
+ self._call_delete_name = call_delete_name
180
+ self._search_tool_description = search_tool_description
181
+ self._proxy_descs = _build_proxy_descriptions(search_tool_name)
182
+
183
+ # Category caches rebuilt when the catalog hash changes,
184
+ # matching BM25SearchTransform's staleness detection pattern.
185
+ self._read_tools: set[str] = set()
186
+ self._write_tools: set[str] = set()
187
+ self._delete_tools: set[str] = set()
188
+ self._last_catalog_hash: str = ""
189
+ self._cache_lock = asyncio.Lock()
190
+
191
+ @staticmethod
192
+ def _catalog_hash(tools: Sequence[Tool]) -> str:
193
+ """Hash tool names + categories for staleness detection."""
194
+ key = "|".join(
195
+ sorted(f"{t.name}:{_categorize_tool(t)}" for t in tools)
196
+ )
197
+ return hashlib.sha256(key.encode()).hexdigest()
198
+
199
+ async def _rebuild_category_cache(self, ctx: Any) -> None:
200
+ """Rebuild the read/write/delete category sets if catalog changed."""
201
+ catalog = await self.get_tool_catalog(ctx)
202
+ current_hash = self._catalog_hash(catalog)
203
+ if current_hash == self._last_catalog_hash:
204
+ return
205
+ async with self._cache_lock:
206
+ # Double-check after acquiring lock
207
+ if current_hash == self._last_catalog_hash:
208
+ return
209
+ read: set[str] = set()
210
+ write: set[str] = set()
211
+ delete: set[str] = set()
212
+ for tool in catalog:
213
+ cat = _categorize_tool(tool)
214
+ if cat == "read":
215
+ read.add(tool.name)
216
+ elif cat == "delete":
217
+ delete.add(tool.name)
218
+ else:
219
+ write.add(tool.name)
220
+ self._read_tools = read
221
+ self._write_tools = write
222
+ self._delete_tools = delete
223
+ self._last_catalog_hash = current_hash
224
+
225
+ async def _render_results(self, tools: Sequence[Tool]) -> list[dict[str, Any]]:
226
+ """Serialize search results with ``execute_via`` hints."""
227
+ proxy_map = {
228
+ "read": self._call_read_name,
229
+ "write": self._call_write_name,
230
+ "delete": self._call_delete_name,
231
+ }
232
+ results = []
233
+ for tool in tools:
234
+ data = tool.to_mcp_tool().model_dump(mode="json", exclude_none=True)
235
+ proxy = proxy_map[_categorize_tool(tool)]
236
+ data["execute_via"] = (
237
+ f'client.{proxy}(name="{tool.name}", arguments={{...}}) '
238
+ f'or {proxy}(name="{tool.name}", arguments={{...}})'
239
+ )
240
+ results.append(data)
241
+ return results
242
+
243
+ def _make_categorized_proxy(
244
+ self,
245
+ proxy_name: str,
246
+ category: Literal["read", "write", "delete"],
247
+ annotations: ToolAnnotations,
248
+ description: str,
249
+ ) -> Tool:
250
+ """Create a call proxy that validates tool category before execution."""
251
+ transform = self
252
+
253
+ async def categorized_call(
254
+ name: Annotated[str, "The name of the tool to call"],
255
+ arguments: Annotated[
256
+ dict[str, Any] | None, "Arguments to pass to the tool"
257
+ ] = None,
258
+ ctx: Context = None, # type: ignore[assignment]
259
+ ) -> Any:
260
+ # Rebuild category cache if catalog has changed
261
+ await transform._rebuild_category_cache(ctx)
262
+
263
+ # Determine which category set to check
264
+ if category == "read":
265
+ allowed = transform._read_tools
266
+ elif category == "delete":
267
+ allowed = transform._delete_tools
268
+ else:
269
+ allowed = transform._write_tools
270
+
271
+ # Detect and unwrap double-wrapped arguments where the LLM
272
+ # accidentally nested name/arguments inside the arguments param
273
+ # e.g. ha_call_read_tool(name="ha_call_read_tool",
274
+ # arguments={"name": "actual_tool", "arguments": {...}})
275
+ all_known = (
276
+ transform._read_tools | transform._write_tools | transform._delete_tools
277
+ )
278
+ if (
279
+ arguments
280
+ and isinstance(arguments.get("name"), str)
281
+ and "arguments" in arguments
282
+ and name in (
283
+ transform._call_read_name,
284
+ transform._call_write_name,
285
+ transform._call_delete_name,
286
+ )
287
+ and arguments["name"] in all_known
288
+ ):
289
+ logger.warning(
290
+ "Detected double-wrapped proxy call for '%s' via %s — unwrapping",
291
+ arguments["name"],
292
+ name,
293
+ )
294
+ name = arguments["name"]
295
+ arguments = arguments.get("arguments") or {}
296
+
297
+ if name not in allowed:
298
+ # Provide a helpful error with the correct proxy name
299
+ actual_category = "unknown"
300
+ correct_proxy = ""
301
+ if name in transform._read_tools:
302
+ actual_category = "read"
303
+ correct_proxy = transform._call_read_name
304
+ elif name in transform._write_tools:
305
+ actual_category = "write"
306
+ correct_proxy = transform._call_write_name
307
+ elif name in transform._delete_tools:
308
+ actual_category = "delete"
309
+ correct_proxy = transform._call_delete_name
310
+ else:
311
+ raise ToolError(json.dumps(create_error_response(
312
+ code=ErrorCode.RESOURCE_NOT_FOUND,
313
+ message=f"Tool '{name}' not found. Use ha_search_tools to discover available tools.",
314
+ context={"tool_name": name},
315
+ )))
316
+ raise ToolError(json.dumps(create_error_response(
317
+ code=ErrorCode.VALIDATION_INVALID_PARAMETER,
318
+ message=f"Tool '{name}' is a {actual_category} tool. Use {correct_proxy} instead of {proxy_name}.",
319
+ suggestions=[f"Use '{correct_proxy}' for {actual_category} operations."],
320
+ context={"tool_name": name, "proxy_used": proxy_name, "correct_proxy": correct_proxy},
321
+ )))
322
+
323
+ return await ctx.fastmcp.call_tool(name, arguments)
324
+
325
+ return Tool.from_function(
326
+ fn=categorized_call,
327
+ name=proxy_name,
328
+ description=description,
329
+ annotations=annotations,
330
+ )
331
+
332
+ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]:
333
+ """Replace tool listing with search + categorized call proxies."""
334
+ pinned = [t for t in tools if t.name in (self._always_visible or [])]
335
+
336
+ search_tool = self._make_search_tool()
337
+ # Always set readOnlyHint and override description if provided
338
+ search_tool = search_tool.model_copy(update={
339
+ "description": self._search_tool_description or search_tool.description,
340
+ "annotations": ToolAnnotations(readOnlyHint=True),
341
+ })
342
+
343
+ call_read = self._make_categorized_proxy(
344
+ proxy_name=self._call_read_name,
345
+ category="read",
346
+ annotations=ToolAnnotations(readOnlyHint=True),
347
+ description=self._proxy_descs["read"],
348
+ )
349
+
350
+ call_write = self._make_categorized_proxy(
351
+ proxy_name=self._call_write_name,
352
+ category="write",
353
+ annotations=ToolAnnotations(destructiveHint=True),
354
+ description=self._proxy_descs["write"],
355
+ )
356
+
357
+ call_delete = self._make_categorized_proxy(
358
+ proxy_name=self._call_delete_name,
359
+ category="delete",
360
+ annotations=ToolAnnotations(destructiveHint=True),
361
+ description=self._proxy_descs["delete"],
362
+ )
363
+
364
+ return [*pinned, search_tool, call_read, call_write, call_delete]
365
+
366
+ async def get_tool(
367
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
368
+ ) -> Tool | None:
369
+ """Resolve tool by name, including categorized proxy tools.
370
+
371
+ The parent only handles _search_tool_name and _call_tool_name (unused).
372
+ We must also intercept our three categorized proxy names so they can
373
+ be found when the LLM calls them.
374
+ """
375
+ if name == self._call_read_name:
376
+ return self._make_categorized_proxy(
377
+ self._call_read_name, "read",
378
+ ToolAnnotations(readOnlyHint=True),
379
+ self._proxy_descs["read"],
380
+ )
381
+ if name == self._call_write_name:
382
+ return self._make_categorized_proxy(
383
+ self._call_write_name, "write",
384
+ ToolAnnotations(destructiveHint=True),
385
+ self._proxy_descs["write"],
386
+ )
387
+ if name == self._call_delete_name:
388
+ return self._make_categorized_proxy(
389
+ self._call_delete_name, "delete",
390
+ ToolAnnotations(destructiveHint=True),
391
+ self._proxy_descs["delete"],
392
+ )
393
+ return await super().get_tool(name, call_next, version=version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.1.0.dev289
3
+ Version: 7.1.0.dev290
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -76,6 +76,8 @@ src/ha_mcp/tools/tools_utility.py
76
76
  src/ha_mcp/tools/tools_voice_assistant.py
77
77
  src/ha_mcp/tools/tools_zones.py
78
78
  src/ha_mcp/tools/util_helpers.py
79
+ src/ha_mcp/transforms/__init__.py
80
+ src/ha_mcp/transforms/categorized_search.py
79
81
  src/ha_mcp/utils/__init__.py
80
82
  src/ha_mcp/utils/domain_handlers.py
81
83
  src/ha_mcp/utils/fuzzy_search.py