mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Carry the remote tool metadata in mcp (#182712)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
4550565c7a
commit
9ec49e85db
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user