Add tool metadata to the LLM API (#182614)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-19 13:56:02 -04:00
committed by GitHub
co-authored by Claude
parent 40fcd7dc6b
commit 7fb69f2e6c
4 changed files with 151 additions and 0 deletions
@@ -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,
),
)
+28
View File
@@ -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.
+48
View File
@@ -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,
+68
View File
@@ -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