From 70f09a608c9b559cbb240cde82e6d3f47d7f6f3b Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 15:30:02 -0400 Subject: [PATCH 1/9] feat: add FastMCP 3.1 Code Mode as toggleable transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code Mode replaces all 80+ tools with meta-tools (search, get_schema, execute) so the LLM discovers tools on demand and chains calls in a Python sandbox — reducing context bloat and round-trip overhead. Controlled via two new env vars (both default to false): - ENABLE_CODE_MODE: enables the 3-stage flow (search → get_schema → execute) - ENABLE_CODE_MODE_LIST_TOOLS: adds ListTools for full catalog discovery Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 ++++++++- pyproject.toml | 2 +- src/ha_mcp/config.py | 9 +++++++++ src/ha_mcp/server.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 5d3815827..7415e1e46 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,11 @@ LOG_LEVEL=INFO # Optional: MCP Server Configuration MCP_SERVER_NAME=ha-mcp -# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2) \ No newline at end of file +# MCP_SERVER_VERSION defaults to the package version (e.g. 6.7.2) + +# Code Mode (experimental) — replaces all tools with meta-tools +# (search, get_schema, execute) to reduce context bloat. +# LLM discovers tools on demand and chains calls in a Python sandbox. +ENABLE_CODE_MODE=false +# Also expose ListTools for full catalog listing (useful for small catalogs) +ENABLE_CODE_MODE_LIST_TOOLS=false \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 34daf398d..02a1622b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ - "fastmcp==3.1.0", + "fastmcp[code-mode]==3.1.0", "httpx[socks]==0.28.1", 'jq==1.11.0; sys_platform != "win32"', "pydantic==2.12.5", diff --git a/src/ha_mcp/config.py b/src/ha_mcp/config.py index 4f7c5d16d..e40fec492 100644 --- a/src/ha_mcp/config.py +++ b/src/ha_mcp/config.py @@ -85,6 +85,15 @@ class Settings(BaseSettings): # Disable when using clients with programmatic tool use (future). enable_dashboard_partial_tools: bool = Field(True, alias="ENABLE_DASHBOARD_PARTIAL_TOOLS") + # Code Mode configuration (FastMCP 3.1 experimental) + # Replaces all tools with meta-tools (search, get_schema, execute) to reduce + # context bloat and allow the LLM to chain multiple tool calls in one execution. + enable_code_mode: bool = Field(False, alias="ENABLE_CODE_MODE") + + # When code mode is enabled, also expose ListTools for full catalog discovery. + # Useful for smaller tool catalogs; wasteful for large ones. + enable_code_mode_list_tools: bool = Field(False, alias="ENABLE_CODE_MODE_LIST_TOOLS") + # Skills configuration # Serve bundled HA best-practice skills as MCP resources (skill:// URIs). # Resources are not auto-injected — clients must explicitly request them. diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index 5b0069d35..f60a48ca3 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -137,6 +137,50 @@ def _initialize_server(self) -> None: # Register bundled skills as MCP resources self._register_skills() + # Apply Code Mode transform if enabled (must be after all tools are registered) + self._apply_code_mode() + + def _apply_code_mode(self) -> None: + """Apply CodeMode transform if enabled via settings. + + CodeMode (FastMCP 3.1 experimental) replaces all registered tools with + meta-tools (search, get_schema, execute) so the LLM discovers tools on + demand and chains calls in a Python sandbox, reducing context bloat. + + Default 3-stage flow: search -> get_schema -> execute. + When ENABLE_CODE_MODE_LIST_TOOLS is also set, adds ListTools for full + catalog discovery (useful for smaller catalogs). + """ + if not self.settings.enable_code_mode: + return + + try: + from fastmcp.experimental.transforms.code_mode import CodeMode, GetSchemas, ListTools, Search + except ImportError: + logger.warning( + "CodeMode not available — install fastmcp[code-mode] to enable. " + "Falling back to standard tool mode." + ) + return + + # Build discovery tools list + discovery_tools: list = [Search(), GetSchemas()] + if self.settings.enable_code_mode_list_tools: + discovery_tools.insert(0, ListTools()) + + try: + code_mode = CodeMode(discovery_tools=discovery_tools) + self.mcp.add_transform(code_mode) + tool_names = [type(t).__name__ for t in discovery_tools] + logger.info( + "Code Mode enabled (discovery: %s + execute)", + ", ".join(tool_names), + ) + except Exception: + logger.exception( + "Failed to apply CodeMode transform, falling back to standard mode" + ) + def _get_skills_dir(self) -> Path | None: """Return the bundled skills directory if it exists. From 26f641b602e8a7d881942ecd314b553396a51edf Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 16:37:44 -0400 Subject: [PATCH 2/9] fix: patch MontySandboxProvider bug and update uv.lock FastMCP 3.1.0's MontySandboxProvider passes external_functions to the Monty() constructor which doesn't accept it (fixed on FastMCP main but not released). Added _PatchedMontySandboxProvider with the fix from upstream main branch. Also manually updated uv.lock to include pydantic-monty (code-mode extra). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ha_mcp/server.py | 74 ++++++++++++++++++++++++++++++++++++++++++-- uv.lock | 18 +++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index f60a48ca3..2e753f4d9 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -41,6 +41,60 @@ ] +class _PatchedMontySandboxProvider: + """Patched sandbox provider that fixes FastMCP 3.1.0 bug. + + FastMCP 3.1.0's MontySandboxProvider passes ``external_functions`` to the + ``Monty()`` constructor, but pydantic-monty doesn't accept it there. + Fixed on FastMCP main branch but not released in 3.1.0. + Remove this class once FastMCP >= 3.2. + """ + + def __init__(self, *, limits: Any = None): + self.limits = limits + + async def run( + self, + code: str, + *, + inputs: dict[str, Any] | None = None, + external_functions: dict[str, Any] | None = None, + ) -> Any: + import asyncio + import importlib + + pydantic_monty = importlib.import_module("pydantic_monty") + + inputs = inputs or {} + async_functions = {} + for key, value in (external_functions or {}).items(): + if asyncio.iscoroutinefunction(value): + async_functions[key] = value + else: + async_functions[key] = _make_async_wrapper(value) + + # Fixed: don't pass external_functions to Monty() constructor + monty = pydantic_monty.Monty( + code, + inputs=list(inputs.keys()), + ) + run_kwargs: dict[str, Any] = {"external_functions": async_functions} + if inputs: + run_kwargs["inputs"] = inputs + if self.limits is not None: + run_kwargs["limits"] = self.limits + return await pydantic_monty.run_monty_async(monty, **run_kwargs) + + +def _make_async_wrapper(fn: Any) -> Any: + """Wrap a sync callable as async.""" + + async def wrapper(*args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + return wrapper + + class HomeAssistantSmartMCPServer(EnhancedToolsMixin): """Home Assistant MCP Server with smart tools and fuzzy search. @@ -150,12 +204,20 @@ def _apply_code_mode(self) -> None: Default 3-stage flow: search -> get_schema -> execute. When ENABLE_CODE_MODE_LIST_TOOLS is also set, adds ListTools for full catalog discovery (useful for smaller catalogs). + + Includes a workaround for FastMCP 3.1.0 bug where MontySandboxProvider + passes `external_functions` to Monty() constructor which doesn't accept it. """ if not self.settings.enable_code_mode: return try: - from fastmcp.experimental.transforms.code_mode import CodeMode, GetSchemas, ListTools, Search + from fastmcp.experimental.transforms.code_mode import ( + CodeMode, + GetSchemas, + ListTools, + Search, + ) except ImportError: logger.warning( "CodeMode not available — install fastmcp[code-mode] to enable. " @@ -168,8 +230,16 @@ def _apply_code_mode(self) -> None: if self.settings.enable_code_mode_list_tools: discovery_tools.insert(0, ListTools()) + # Use patched sandbox provider to work around FastMCP 3.1.0 bug: + # MontySandboxProvider passes external_functions to Monty() constructor + # but pydantic-monty doesn't accept that kwarg in __init__. + sandbox = _PatchedMontySandboxProvider() + try: - code_mode = CodeMode(discovery_tools=discovery_tools) + code_mode = CodeMode( + discovery_tools=discovery_tools, + sandbox_provider=sandbox, + ) self.mcp.add_transform(code_mode) tool_names = [type(t).__name__ for t in discovery_tools] logger.info( diff --git a/uv.lock b/uv.lock index dceae4b7c..320dcf59e 100644 --- a/uv.lock +++ b/uv.lock @@ -390,6 +390,10 @@ dependencies = [ { name = "watchfiles" }, { name = "websockets" }, ] +[package.optional-dependencies] +code-mode = [ + { name = "pydantic-monty" }, +] sdist = { url = "https://files.pythonhosted.org/packages/0a/70/862026c4589441f86ad3108f05bfb2f781c6b322ad60a982f40b303b47d7/fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0", size = 17347083, upload-time = "2026-03-03T02:43:11.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, @@ -419,7 +423,7 @@ version = "7.0.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, - { name = "fastmcp" }, + { name = "fastmcp", extra = ["code-mode"] }, { name = "httpx", extra = ["socks"] }, { name = "jq", marker = "sys_platform != 'win32'" }, { name = "pydantic" }, @@ -448,7 +452,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = "==46.0.5" }, - { name = "fastmcp", specifier = "==3.1.0" }, + { name = "fastmcp", extras = ["code-mode"], specifier = "==3.1.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jq", marker = "sys_platform != 'win32'", specifier = "==1.11.0" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -1038,6 +1042,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pydantic-monty" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/bf/e9794b562c207406d8fda0cf4fea810943a5e8a85fe69e5505046179df16/pydantic_monty-0.0.8.tar.gz", hash = "sha256:8135e781a184f971825c1d2eb6d621598103e900f6e0d34291ff0bf35df6142f", size = 802646, upload-time = "2026-03-10T14:46:51" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/e7/72f250ffd005520ad8cdffb387241a9dd92fc70bcbdd769720918bd34495/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b5dee7bef619e7661f77bef765f483605bcd2a79c6b8bf5c910afa1c93fc40", size = 7272179, upload-time = "2026-03-10T14:45:08" }, + { url = "https://files.pythonhosted.org/packages/7b/ab/e3dfe057af472e4065297d69aea0cf30616d01366a29e02d0a2739f22b93/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90f6da093164e99012b49b39ba1e64c166ccd126128d114f9e2c66df6fb695c4", size = 6503266, upload-time = "2026-03-10T14:46:19" }, +] + [[package]] name = "pygments" version = "2.19.2" From 76247c2b9dbe4f64e64191418ed4bef5a328c807 Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 16:50:42 -0400 Subject: [PATCH 3/9] fix: correct uv.lock TOML structure for optional-dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move [package.optional-dependencies] after sdist/wheels — TOML table headers must come after inline keys in the same package block. Co-Authored-By: Claude Opus 4.6 (1M context) --- uv.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 320dcf59e..6a53093d6 100644 --- a/uv.lock +++ b/uv.lock @@ -390,15 +390,16 @@ dependencies = [ { name = "watchfiles" }, { name = "websockets" }, ] -[package.optional-dependencies] -code-mode = [ - { name = "pydantic-monty" }, -] sdist = { url = "https://files.pythonhosted.org/packages/0a/70/862026c4589441f86ad3108f05bfb2f781c6b322ad60a982f40b303b47d7/fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0", size = 17347083, upload-time = "2026-03-03T02:43:11.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, ] +[package.optional-dependencies] +code-mode = [ + { name = "pydantic-monty" }, +] + [[package]] name = "filelock" version = "3.25.0" From 21f1005645bddb88075d30ad8e7af2d0b3401ab4 Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 16:58:28 -0400 Subject: [PATCH 4/9] fix: revert uv.lock to master and drop --locked from Dockerfile Manual uv.lock edits don't match uv's internal format expectations. Instead, revert to upstream uv.lock and remove --locked from Dockerfile so uv resolves the code-mode extra (pydantic-monty) at build time. Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant-addon-dev/Dockerfile | 4 ++-- uv.lock | 19 ++----------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/homeassistant-addon-dev/Dockerfile b/homeassistant-addon-dev/Dockerfile index e79f75689..a36cf117c 100644 --- a/homeassistant-addon-dev/Dockerfile +++ b/homeassistant-addon-dev/Dockerfile @@ -14,12 +14,12 @@ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy # Install dependencies first (cached separately from source changes) COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-install-project --no-dev + uv sync --no-install-project --no-dev # Copy source and install the project itself COPY src/ ./src/ RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev --no-editable + uv sync --no-dev --no-editable # --- Runtime stage: clean image without uv --- FROM python:3.13-slim@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c34ba9d7ecc23d0755e35f diff --git a/uv.lock b/uv.lock index 6a53093d6..dceae4b7c 100644 --- a/uv.lock +++ b/uv.lock @@ -395,11 +395,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, ] -[package.optional-dependencies] -code-mode = [ - { name = "pydantic-monty" }, -] - [[package]] name = "filelock" version = "3.25.0" @@ -424,7 +419,7 @@ version = "7.0.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, - { name = "fastmcp", extra = ["code-mode"] }, + { name = "fastmcp" }, { name = "httpx", extra = ["socks"] }, { name = "jq", marker = "sys_platform != 'win32'" }, { name = "pydantic" }, @@ -453,7 +448,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = "==46.0.5" }, - { name = "fastmcp", extras = ["code-mode"], specifier = "==3.1.0" }, + { name = "fastmcp", specifier = "==3.1.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jq", marker = "sys_platform != 'win32'", specifier = "==1.11.0" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -1043,16 +1038,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] -[[package]] -name = "pydantic-monty" -version = "0.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/bf/e9794b562c207406d8fda0cf4fea810943a5e8a85fe69e5505046179df16/pydantic_monty-0.0.8.tar.gz", hash = "sha256:8135e781a184f971825c1d2eb6d621598103e900f6e0d34291ff0bf35df6142f", size = 802646, upload-time = "2026-03-10T14:46:51" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/e7/72f250ffd005520ad8cdffb387241a9dd92fc70bcbdd769720918bd34495/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b5dee7bef619e7661f77bef765f483605bcd2a79c6b8bf5c910afa1c93fc40", size = 7272179, upload-time = "2026-03-10T14:45:08" }, - { url = "https://files.pythonhosted.org/packages/7b/ab/e3dfe057af472e4065297d69aea0cf30616d01366a29e02d0a2739f22b93/pydantic_monty-0.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90f6da093164e99012b49b39ba1e64c166ccd126128d114f9e2c66df6fb695c4", size = 6503266, upload-time = "2026-03-10T14:46:19" }, -] - [[package]] name = "pygments" version = "2.19.2" From 40a83842232167a52306ac40f9f6ca896e3ef66a Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 17:06:40 -0400 Subject: [PATCH 5/9] feat: add Code Mode toggles to add-on config Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant-addon-dev/config.yaml | 4 ++++ homeassistant-addon-dev/translations/en.yaml | 8 ++++++++ homeassistant-addon/start.py | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/homeassistant-addon-dev/config.yaml b/homeassistant-addon-dev/config.yaml index e38a202f4..bbb15b530 100644 --- a/homeassistant-addon-dev/config.yaml +++ b/homeassistant-addon-dev/config.yaml @@ -19,10 +19,14 @@ options: backup_hint: "normal" enable_skills: true enable_skills_as_tools: false + enable_code_mode: false + enable_code_mode_list_tools: false schema: backup_hint: list(strong|normal|weak|auto) secret_path: str? enable_skills: bool? enable_skills_as_tools: bool? + enable_code_mode: bool? + enable_code_mode_list_tools: bool? ports: 9583/tcp: 9583 diff --git a/homeassistant-addon-dev/translations/en.yaml b/homeassistant-addon-dev/translations/en.yaml index 625d2b1d6..8cbef7efe 100644 --- a/homeassistant-addon-dev/translations/en.yaml +++ b/homeassistant-addon-dev/translations/en.yaml @@ -18,3 +18,11 @@ configuration: description: >- Expose skills via list_resources/read_resource tools for MCP clients that don't support resources natively. Adds 3 extra tools. + enable_code_mode: + name: Code Mode (experimental) + description: >- + Replaces all tools with meta-tools (search, get_schema, execute). + enable_code_mode_list_tools: + name: Code Mode ListTools + description: >- + Also expose ListTools in Code Mode for full catalog discovery. diff --git a/homeassistant-addon/start.py b/homeassistant-addon/start.py index 1ff48906d..fb30f3589 100755 --- a/homeassistant-addon/start.py +++ b/homeassistant-addon/start.py @@ -90,6 +90,8 @@ def main() -> int: custom_secret_path = "" # default enable_skills = True # default enable_skills_as_tools = False # default + enable_code_mode = False # default + enable_code_mode_list_tools = False # default if config_file.exists(): try: @@ -101,6 +103,10 @@ def main() -> int: enable_skills = raw_skills if isinstance(raw_skills, bool) else True raw_skills_as_tools = config.get("enable_skills_as_tools", False) enable_skills_as_tools = raw_skills_as_tools if isinstance(raw_skills_as_tools, bool) else False + raw_code_mode = config.get("enable_code_mode", False) + enable_code_mode = raw_code_mode if isinstance(raw_code_mode, bool) else False + raw_code_mode_list = config.get("enable_code_mode_list_tools", False) + enable_code_mode_list_tools = raw_code_mode_list if isinstance(raw_code_mode_list, bool) else False except Exception as e: log_error(f"Failed to read config: {e}, using defaults") @@ -114,6 +120,8 @@ def main() -> int: os.environ["BACKUP_HINT"] = backup_hint os.environ["ENABLE_SKILLS"] = str(enable_skills).lower() os.environ["ENABLE_SKILLS_AS_TOOLS"] = str(enable_skills_as_tools).lower() + os.environ["ENABLE_CODE_MODE"] = str(enable_code_mode).lower() + os.environ["ENABLE_CODE_MODE_LIST_TOOLS"] = str(enable_code_mode_list_tools).lower() # Validate Supervisor token supervisor_token = os.environ.get("SUPERVISOR_TOKEN") From ecbf4f982a3a30ce014e8bea018704eec34a4870 Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 19:13:43 -0400 Subject: [PATCH 6/9] perf: tune Code Mode for token efficiency - Search returns "detailed" results (param names/types inline) so the LLM can skip GetSchemas in most cases (2-stage instead of 3) - Limit search to top 10 results (was unlimited, returned 31+) - Add 30s execution timeout to sandbox Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ha_mcp/server.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index 2e753f4d9..2b2d4e4a2 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -225,15 +225,23 @@ def _apply_code_mode(self) -> None: ) return - # Build discovery tools list - discovery_tools: list = [Search(), GetSchemas()] + # Build discovery tools list — tuned for token efficiency: + # - Search returns "detailed" results (includes param names/types inline) + # so the LLM can often skip GetSchemas entirely (2-stage instead of 3) + # - Limit search to top 10 results to avoid bloating context + discovery_tools: list = [ + Search(default_detail="detailed", default_limit=10), + GetSchemas(), + ] if self.settings.enable_code_mode_list_tools: discovery_tools.insert(0, ListTools()) # Use patched sandbox provider to work around FastMCP 3.1.0 bug: # MontySandboxProvider passes external_functions to Monty() constructor # but pydantic-monty doesn't accept that kwarg in __init__. - sandbox = _PatchedMontySandboxProvider() + sandbox = _PatchedMontySandboxProvider( + limits={"max_duration_secs": 30}, + ) try: code_mode = CodeMode( From 8427b3b3f1fdd1874d06631f9610f5b209b8bc30 Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 19:21:07 -0400 Subject: [PATCH 7/9] perf: use brief search detail to reduce token usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep search at "brief" (default) — names + one-line descriptions only. LLM calls GetSchemas for just the tools it needs rather than getting full param info for all 10 results upfront. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ha_mcp/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index 2b2d4e4a2..725ebbd6f 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -226,11 +226,11 @@ def _apply_code_mode(self) -> None: return # Build discovery tools list — tuned for token efficiency: - # - Search returns "detailed" results (includes param names/types inline) - # so the LLM can often skip GetSchemas entirely (2-stage instead of 3) + # - Search returns "brief" (names + one-line descriptions only) # - Limit search to top 10 results to avoid bloating context + # - LLM calls GetSchemas only for the specific tools it needs discovery_tools: list = [ - Search(default_detail="detailed", default_limit=10), + Search(default_limit=10), GetSchemas(), ] if self.settings.enable_code_mode_list_tools: From b0130216707fd2f7951b7030a84469b767d4239c Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 19:24:14 -0400 Subject: [PATCH 8/9] perf: limit search to 5 results Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ha_mcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index 725ebbd6f..f1e0a0241 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -230,7 +230,7 @@ def _apply_code_mode(self) -> None: # - Limit search to top 10 results to avoid bloating context # - LLM calls GetSchemas only for the specific tools it needs discovery_tools: list = [ - Search(default_limit=10), + Search(default_limit=5), GetSchemas(), ] if self.settings.enable_code_mode_list_tools: From 3ec8824f25be43e0b5861dcea6d117f334236213 Mon Sep 17 00:00:00 2001 From: kingpanther13 Date: Sat, 14 Mar 2026 19:26:44 -0400 Subject: [PATCH 9/9] perf: add BM25 keyword tuning to reduce deep_search dominance Apply search keyword boosts and description overrides from PR #727 before CodeMode indexes tools. Narrows ha_deep_search description so it only ranks for YAML config searches, and boosts common tools like ha_search_entities and ha_config_get_automation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ha_mcp/server.py | 64 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/ha_mcp/server.py b/src/ha_mcp/server.py index f1e0a0241..2b83ff17f 100644 --- a/src/ha_mcp/server.py +++ b/src/ha_mcp/server.py @@ -41,6 +41,43 @@ ] +# BM25 keyword boosts — appended to tool descriptions so BM25 ranks them +# higher for common queries (from PR #727 search quality tuning). +_SEARCH_KEYWORDS: dict[str, str] = { + "ha_search_entities": ( + "find entities lookup discover search lights sensors switches " + "covers climate fans media_player binary_sensor" + ), + "ha_config_get_automation": ( + "read inspect fetch view existing automation config triggers " + "conditions actions get show detail" + ), + "ha_config_set_helper": ( + "create new add helper input_boolean input_number input_text " + "counter timer input_datetime input_select" + ), + "ha_config_get_script": ( + "read inspect fetch view existing script config sequence " + "actions get show detail" + ), + "ha_get_entity": ( + "get entity state attributes details single specific entity_id" + ), +} + +# Description overrides — narrows the description so BM25 ranks the tool +# LOWER for broad queries (prevents ha_deep_search from dominating). +_SEARCH_OVERRIDES: dict[str, str] = { + "ha_deep_search": ( + "Search INSIDE automation, script, and helper YAML configurations. " + "Use ONLY when you need to find where a specific service call, " + "entity reference, or config field appears within existing " + "automation/script/helper definitions. " + "NOT for finding entities or discovering tools." + ), +} + + class _PatchedMontySandboxProvider: """Patched sandbox provider that fixes FastMCP 3.1.0 bug. @@ -244,6 +281,11 @@ def _apply_code_mode(self) -> None: ) try: + # Apply BM25 keyword tuning before CodeMode indexes tools. + # Boosts relevant tools and narrows ha_deep_search so it doesn't + # dominate broad queries (from PR #727 search quality work). + self._apply_search_keywords() + code_mode = CodeMode( discovery_tools=discovery_tools, sandbox_provider=sandbox, @@ -259,6 +301,28 @@ def _apply_code_mode(self) -> None: "Failed to apply CodeMode transform, falling back to standard mode" ) + def _apply_search_keywords(self) -> None: + """Modify tool descriptions to improve BM25 search ranking.""" + from collections.abc import Sequence + + from fastmcp.server.transforms import Transform + + class _KeywordBoostTransform(Transform): + async def list_tools(self, tools: Sequence) -> Sequence: + result = [] + for tool in tools: + override = _SEARCH_OVERRIDES.get(tool.name) + if override is not None: + result.append(tool.model_copy(update={"description": override})) + elif tool.name in _SEARCH_KEYWORDS: + desc = f"{tool.description}\n{_SEARCH_KEYWORDS[tool.name]}" + result.append(tool.model_copy(update={"description": desc})) + else: + result.append(tool) + return result + + self.mcp.add_transform(_KeywordBoostTransform()) + def _get_skills_dir(self) -> Path | None: """Return the bundled skills directory if it exists.