From fdb882c737a91f6f0f9d0658d76a736b6271f0f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Sep 2026 11:22:56 -0400 Subject: [PATCH] Advertise required tool parameters in MCP Server (#182105) Co-authored-by: Claude --- homeassistant/components/mcp_server/server.py | 12 ++-- tests/components/mcp_server/conftest.py | 8 ++- tests/components/mcp_server/test_http.py | 66 +++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 80d5169b1184..3806efb0242a 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -45,13 +45,17 @@ def _format_tool( ) -> types.Tool: """Format tool specification.""" input_schema = to_openapi(tool.parameters, custom_serializer=custom_serializer) + mcp_schema: dict[str, Any] = { + "type": "object", + "properties": input_schema["properties"], + } + # Omitted by to_openapi when the tool has no required parameters. + if required := input_schema.get("required"): + mcp_schema["required"] = required return types.Tool( name=tool.name, description=tool.description or "", - inputSchema={ - "type": "object", - "properties": input_schema["properties"], - }, + inputSchema=mcp_schema, ) diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index e318713cf7bf..00a8dda93b9b 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Model Context Protocol Server tests.""" from collections.abc import Generator +from dataclasses import dataclass, field from unittest.mock import AsyncMock, patch import pytest @@ -16,8 +17,11 @@ from tests.common import MockConfigEntry TEST_LLM_API_ID = "test-api" +@dataclass(slots=True, kw_only=True) class MockLLMAPI(llm.API): - """Test LLM API that does not expose any tools.""" + """Test LLM API that exposes the tools it is created with.""" + + tools: list[llm.Tool] = field(default_factory=list) async def async_get_api_instance( self, llm_context: llm.LLMContext @@ -27,7 +31,7 @@ class MockLLMAPI(llm.API): api=self, api_prompt="Test prompt", llm_context=llm_context, - tools=[], + tools=self.tools, ) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 9ba896c7971f..f81a7b5fa755 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -15,6 +15,7 @@ import mcp.client.sse import mcp.client.streamable_http from mcp.shared.exceptions import McpError import pytest +import voluptuous as vol from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.components.homeassistant.exposed_entities import async_expose_entity @@ -42,6 +43,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonObjectType from .conftest import TEST_LLM_API_ID, MockLLMAPI @@ -80,6 +82,25 @@ EXPECTED_PROMPT_ENTITY_DEFINITION = """ """ +class _StubTool(llm.Tool): + """Minimal tool with a configurable parameter schema.""" + + name = "test_tool" + + def __init__(self, parameters: vol.Schema) -> None: + """Initialize the stub tool.""" + self.parameters = parameters + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Return an empty result.""" + return {} + + @pytest.fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up the config entry.""" @@ -612,6 +633,51 @@ async def test_mcp_tools_list( assert properties.get("name") == {"type": "string"} +@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID]) +@pytest.mark.parametrize( + ("parameters", "expected_required"), + [ + pytest.param( + vol.Schema({vol.Required("name"): str, vol.Optional("area"): str}), + ["name"], + id="required-and-optional", + ), + pytest.param( + vol.Schema({vol.Optional("area"): str}), + None, + id="optional-only", + ), + ], +) +async def test_mcp_tools_list_required_parameters( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: MCPClientFactory, + hass_supervisor_access_token: str, + parameters: vol.Schema, + expected_required: list[str] | None, +) -> None: + """Test the tools list advertises the required tool parameters.""" + + llm.async_register_api( + hass, + MockLLMAPI( + hass=hass, + id=TEST_LLM_API_ID, + name="Test API", + tools=[_StubTool(parameters)], + ), + ) + + 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.inputSchema.get("type") == "object" + assert tool.inputSchema.get("required") == expected_required + + @pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant,