From 9ec49e85db1a1e759e6efb131a1f8fa2f8bec513 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 20 Sep 2026 11:29:01 -0400 Subject: [PATCH] Carry the remote tool metadata in mcp (#182712) Co-authored-by: Claude --- homeassistant/components/mcp/coordinator.py | 30 ++++++++++- tests/components/mcp/test_init.py | 60 ++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index fb099a800530..87f251f20777 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -12,7 +12,7 @@ from mcp import McpError from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client -from mcp.types import InitializeResult +from mcp.types import InitializeResult, ToolAnnotations import probatio # Imported by name because the tests patch it on this module. @@ -121,22 +121,48 @@ async def mcp_client( raise main_error from streamable_err +def _tool_annotations(remote: ToolAnnotations | None) -> llm.ToolAnnotations: + """Return the annotations the remote server declares for a tool. + + A hint the server leaves out keeps the conservative default. + """ + if remote is None: + return llm.ToolAnnotations() + declared = { + field: value + for field, value in ( + ("read_only", remote.readOnlyHint), + ("destructive", remote.destructiveHint), + ("idempotent", remote.idempotentHint), + ("open_world", remote.openWorldHint), + ) + if value is not None + } + return llm.ToolAnnotations(**declared) + + class ModelContextProtocolTool(llm.Tool): """A Tool exposed over the Model Context Protocol.""" + integration = DOMAIN + def __init__( self, name: str, + title: str | None, description: str | None, parameters: probatio.Schema, server_url: str, config_entry: ConfigEntry, token_manager: TokenManager | None = None, + annotations: llm.ToolAnnotations = llm.ToolAnnotations(), ) -> None: """Initialize the tool.""" self.name = name + self.title = title self.description = description self.parameters = parameters + self.annotations = annotations self.server_url = server_url self.config_entry = config_entry self.token_manager = token_manager @@ -262,11 +288,13 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tools.append( ModelContextProtocolTool( tool.name, + tool.title, tool.description, parameters, self.config_entry.data[CONF_URL], self.config_entry, self.token_manager, + _tool_annotations(tool.annotations), ) ) return tools diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 7e903e5066d2..6988e10574b6 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -6,7 +6,14 @@ from unittest.mock import AsyncMock, Mock, patch import httpx from mcp import McpError -from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, Tool +from mcp.types import ( + CallToolResult, + ErrorData, + ListToolsResult, + TextContent, + Tool, + ToolAnnotations, +) import probatio import pytest @@ -317,6 +324,57 @@ async def test_llm_get_api_tools( } +@pytest.mark.parametrize( + ("remote_annotations", "expected_annotations"), + [ + pytest.param(None, llm.ToolAnnotations(), id="unannotated"), + pytest.param( + ToolAnnotations(readOnlyHint=True, openWorldHint=False), + llm.ToolAnnotations(read_only=True, open_world=False), + id="partly-annotated", + ), + pytest.param( + ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + llm.ToolAnnotations(destructive=False, idempotent=True), + id="fully-annotated", + ), + ], +) +async def test_llm_tool_annotations( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, + remote_annotations: ToolAnnotations | None, + expected_annotations: llm.ToolAnnotations, +) -> None: + """Test the annotations the remote server declares are carried over.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[ + SEARCH_MEMORY_TOOL.model_copy( + update={"title": "Search memory", "annotations": remote_annotations} + ) + ] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + api = next( + iter(api for api in llm.async_get_apis(hass) if api.name == TEST_API_NAME) + ) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + assert tool.integration == "mcp" + assert tool.title == "Search memory" + assert tool.annotations == expected_annotations + + @pytest.mark.parametrize( ("call_tool_result", "expected_result"), [