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.
- {ha_mcp_dev-7.1.0.dev289/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.1.0.dev290}/PKG-INFO +1 -1
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/pyproject.toml +1 -1
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/config.py +18 -1
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/server.py +200 -3
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_search.py +30 -4
- ha_mcp_dev-7.1.0.dev290/src/ha_mcp/transforms/__init__.py +9 -0
- ha_mcp_dev-7.1.0.dev290/src/ha_mcp/transforms/categorized_search.py +393 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/LICENSE +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/README.md +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/setup.cfg +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/errors.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/card_types.json +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/dashboard_guide.md +0 -0
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {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
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_info.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/utils/usage_logger.py +0 -0
- {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
- {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
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/tests/test_env_manager.py +0 -0
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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,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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/AGENTS.md
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/CLAUDE.md
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/LICENSE
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/resources/skills-vendor/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/best_practice_checker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_automations.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_dashboards.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_entry_flow.py
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_config_scripts.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp/tools/tools_voice_assistant.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ha_mcp_dev-7.1.0.dev289 → ha_mcp_dev-7.1.0.dev290}/src/ha_mcp_dev.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|