From 7fb69f2e6c082dabe4a1c850fea2007587bdadc2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 19 Sep 2026 13:56:02 -0400 Subject: [PATCH] Add tool metadata to the LLM API (#182614) Co-authored-by: Claude --- homeassistant/components/mcp_server/server.py | 7 ++ homeassistant/helpers/llm.py | 28 ++++++++ tests/components/mcp_server/test_http.py | 48 +++++++++++++ tests/helpers/test_llm.py | 68 +++++++++++++++++++ 4 files changed, 151 insertions(+) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 25dc9a6f89b8..2386ffa153db 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -55,8 +55,15 @@ def _format_tool( mcp_schema["required"] = required return types.Tool( name=tool.name, + title=tool.title, description=tool.description or "", inputSchema=mcp_schema, + annotations=types.ToolAnnotations( + readOnlyHint=tool.annotations.read_only, + destructiveHint=tool.annotations.destructive, + idempotentHint=tool.annotations.idempotent, + openWorldHint=tool.annotations.open_world, + ), ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 77f9405ac74e..128c1eee3fc0 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -163,12 +163,29 @@ class ToolResult: error: bool = False +@dataclass(frozen=True, slots=True, kw_only=True) +class ToolAnnotations: + """Properties describing how a tool behaves. + + The defaults describe the least safe case, so a tool that declares nothing + is taken to write, to be destructive, and to reach outside Home Assistant. + """ + + read_only: bool = False + destructive: bool = True + idempotent: bool = False + open_world: bool = True + + class Tool: """LLM Tool base class.""" name: str + title: str | None = None description: str | None = None parameters: probatio.Schema = probatio.Schema({}) + annotations: ToolAnnotations = ToolAnnotations() + integration: str | None = None @abstractmethod async def async_call( @@ -259,9 +276,16 @@ class IntentTool(Tool): self, name: str, intent_handler: intent.IntentHandler, + *, + title: str | None = None, + integration: str | None = None, + annotations: ToolAnnotations = ToolAnnotations(), ) -> None: """Init the class.""" self.name = name + self.title = title + self.integration = integration + self.annotations = annotations self.intent_type = intent_handler.intent_type self.description = ( intent_handler.description @@ -357,8 +381,11 @@ class NamespacedTool(Tool): """Init the class.""" self.namespace = namespace self.name = f"{namespace}__{tool.name}" + self.title = tool.title self.description = tool.description self.parameters = tool.parameters + self.annotations = tool.annotations + self.integration = tool.integration self.tool = tool @override @@ -664,6 +691,7 @@ class ActionTool(Tool): self._domain = domain self._action = action self.name = f"{domain}__{action}" + self.integration = domain # Note: _get_cached_action_parameters only works for services which # add their description directly to the service description cache. # This is not the case for most services, but it is for scripts. diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 391eedac5b94..ab0aa337b005 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -631,6 +631,13 @@ async def test_mcp_tools_list( assert tool.inputSchema.get("type") == "object" properties = tool.inputSchema.get("properties") assert properties.get("name") == {"type": "string"} + # A tool that declares no annotations is advertised as unsafe. + assert tool.annotations == mcp.types.ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ) @pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) @@ -680,6 +687,47 @@ async def test_mcp_tools_list_required_parameters( assert tool.inputSchema.get("required") == expected_required +@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) +async def test_mcp_tools_list_metadata( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: MCPClientFactory, + hass_supervisor_access_token: str, +) -> None: + """Test the tools list advertises the tool title and annotations.""" + + class _AnnotatedTool(_StubTool): + """Tool that declares it only reads.""" + + title = "Test tool" + annotations = llm.ToolAnnotations( + read_only=True, destructive=False, idempotent=True, open_world=False + ) + + llm.async_register_api( + hass, + MockLLMAPI( + hass=hass, + id=TEST_LLM_API_ID, + name="Test API", + tools=[_AnnotatedTool(probatio.Schema({}))], + ), + ) + + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.list_tools() + + tool = next(iter(tool for tool in result.tools if tool.name == "test_tool")) + assert tool.title == "Test tool" + assert tool.annotations == mcp.types.ToolAnnotations( + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ) + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3539f34efc12..9be07fe4322f 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -230,6 +230,74 @@ async def test_call_tool_deprecated_json_object_custom_integration( assert "returns a JSON object from a tool" in caplog.text +def test_tool_metadata_defaults() -> None: + """Test a tool that declares no metadata is taken to be unsafe.""" + + class MyTool(llm.Tool): + name = "test_tool" + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> llm.ToolResult: + return llm.ToolResult(data={}) + + tool = MyTool() + assert tool.title is None + assert tool.integration is None + assert tool.annotations == llm.ToolAnnotations( + read_only=False, destructive=True, idempotent=False, open_world=True + ) + + +def test_intent_tool_metadata() -> None: + """Test an intent tool takes the metadata of the integration exposing it.""" + + class MyIntentHandler(intent.IntentHandler): + intent_type = "test_intent" + + annotations = llm.ToolAnnotations(read_only=True, open_world=False) + tool = llm.IntentTool( + "test_tool", + MyIntentHandler(), + title="Test tool", + integration="my_integration", + annotations=annotations, + ) + + assert tool.title == "Test tool" + assert tool.integration == "my_integration" + assert tool.annotations == annotations + + # An intent tool that declares nothing keeps the unsafe defaults. + tool = llm.IntentTool("test_tool", MyIntentHandler()) + assert tool.title is None + assert tool.integration is None + assert tool.annotations == llm.ToolAnnotations() + + +def test_namespaced_tool_keeps_metadata() -> None: + """Test a namespaced tool carries the metadata of the tool it wraps.""" + + class MyTool(llm.Tool): + name = "test_tool" + title = "Test tool" + annotations = llm.ToolAnnotations(read_only=True, open_world=False) + integration = "my_integration" + + async def async_call( + self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext + ) -> llm.ToolResult: + return llm.ToolResult(data={}) + + tool = MyTool() + namespaced = llm.NamespacedTool("test_api", tool) + + assert namespaced.name == "test_api__test_tool" + assert namespaced.title == tool.title + assert namespaced.annotations == tool.annotations + assert namespaced.integration == tool.integration + + @pytest.mark.parametrize("namespaced", [False, True]) async def test_intent_tool_omits_blank_arguments( hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool