diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index f0f0086805e6..903a42d0ed6f 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -33,6 +33,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from http import HTTPStatus import logging +from typing import get_args from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest, HTTPNotFound @@ -61,6 +62,12 @@ _LOGGER = logging.getLogger(__name__) STREAMABLE_API = "/api/mcp" TIMEOUT = 60 # Seconds +KNOWN_MCP_METHODS: frozenset[str] = frozenset( + req_cls.model_fields["method"].default + for req_cls in get_args(types.ClientRequestType) + if "method" in req_cls.model_fields +) + # Legacy SSE endpoint SSE_API = f"/{DOMAIN}/sse" MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}" @@ -286,6 +293,20 @@ async def _async_handle_streamable_message( _LOGGER.debug("Notification or response received, returning 202") return web.Response(status=HTTPStatus.ACCEPTED) + if message.root.method not in KNOWN_MCP_METHODS: + error_response = types.JSONRPCError( + jsonrpc="2.0", + id=message.root.id, + error=types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="Method not found", + data=message.root.method, + ), + ) + return web.json_response( + data=error_response.model_dump(by_alias=True, exclude_none=True), + ) + # The MCP server runs as a background task for the duration of the # request. We open a buffered stream pair to communicate with it. The # request is sent to the MCP server and we wait for a single response diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 8755c8db4a9b..3fa02a2778ea 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -1217,3 +1217,47 @@ async def test_require_admin_allows_admin( headers={"accept": CONTENT_TYPE_JSON}, ) assert response.status == HTTPStatus.OK + + +@pytest.mark.parametrize( + "method", + [ + pytest.param("server/discover", id="server_discover"), + pytest.param("no/such-method", id="unknown_method"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_unsupported_method_returns_method_not_found( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + method: str, +) -> None: + """Test that unsupported methods return JSON-RPC METHOD_NOT_FOUND without log warnings.""" + client = await hass_client() + + response = await client.post( + STREAMABLE_API, + json={ + "jsonrpc": "2.0", + "id": "request-123", + "method": method, + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + } + }, + }, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == { + "jsonrpc": "2.0", + "id": "request-123", + "error": { + "code": -32601, + "message": "Method not found", + "data": method, + }, + } + assert "Failed to validate request" not in caplog.text