Carry the remote tool metadata in mcp (#182712)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-20 17:29:01 +02:00
committed by GitHub
co-authored by Claude
parent 4550565c7a
commit 9ec49e85db
2 changed files with 88 additions and 2 deletions
+29 -1
View File
@@ -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
+59 -1
View File
@@ -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"),
[