From aebd4314d2fd03ce60fa3dad57f78f40ec1a727f Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:05:35 -0500 Subject: [PATCH 01/10] docs: add design spec for native MCP server (2026-07-28 Streamable HTTP) --- .../specs/2026-10-25-mcp-server-design.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-25-mcp-server-design.md diff --git a/docs/superpowers/specs/2026-10-25-mcp-server-design.md b/docs/superpowers/specs/2026-10-25-mcp-server-design.md new file mode 100644 index 0000000..6dce0ac --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-mcp-server-design.md @@ -0,0 +1,138 @@ +# Google Assistant Entity Console - Model Context Protocol (MCP) Server Design + +**Date**: 2026-10-25 +**Component**: `google_assistant_entity_console` +**Specification Version**: Model Context Protocol (MCP) `2026-07-28` (Streamable HTTP Transport) + +--- + +## 1. Overview & Objectives + +The **Google Assistant Entity Console** provides a native `.storage`-backed console for managing Home Assistant entity exposure, voice aliases/nicknames, regex blocklists, and zero-reboot `devices:requestSync` to Google HomeGraph. + +This feature introduces a native **Model Context Protocol (MCP) Server** directly into the integration, allowing AI agents (such as Antigravity, Claude Desktop, Cursor, and Model Context Gateway / MCG) to inspect, configure, and synchronize Google Assistant devices via standard MCP tools. + +### Key Objectives +* **Zero External Python Dependencies**: Built entirely upon Home Assistant's native HTTP engine (`aiohttp` via `HomeAssistantView`) without introducing third-party packages or risking dependency conflicts in HA Docker / Core. +* **Modern MCP Compliance (`2026-07-28`)**: Uses the stateless **Streamable HTTP** transport with a single unified POST endpoint, eliminating sticky sessions, protocol-level handshakes, and persistent SSE connection bottlenecks. +* **Seamless State Integration**: Connects directly to existing async runtime services (`runtime.py` and `store.py`) to reflect changes live in Home Assistant memory and Google HomeGraph without requiring restarts. +* **Secure by Default**: Protected by Home Assistant's authenticated HTTP pipeline (`requires_auth = True`), validating Long-Lived Access Tokens. + +--- + +## 2. Architecture & Transport Specification + +### 2.1 Endpoint +* **Path**: `/api/google_assistant_entity_console/mcp` +* **Method**: `POST` +* **Authentication**: Requires standard Home Assistant Long-Lived Access Token via `Authorization: Bearer `. +* **View Name**: `api:google_assistant_entity_console:mcp` + +### 2.2 Transport Protocol (Streamable HTTP - 2026-07-28) +In compliance with the 2026-07-28 revision: +* **Statelessness**: No protocol-level session initialization handshake or sticky connection tracking. Each HTTP POST request is self-contained. +* **Header Handling**: + * Response header `Mcp-Protocol-Version: 2026-07-28` is included on all responses. + * Respects `Mcp-Method` routing headers if provided by upstream proxies/gateways. +* **Response Framing**: + * **Direct JSON (`application/json`)**: For standard, rapid requests (`tools/list`, `tools/call`, `ping`, `initialize`), the server responds directly with an `application/json` JSON-RPC 2.0 response object. + * **Request-Scoped Streaming (`text/event-stream`)**: When requested via `Accept: text/event-stream` or for long-running operations, the server streams progress events and emits the final JSON-RPC response before terminating the HTTP stream. +* **Tool Caching**: Tool listing results advertise a `ttlMs: 300000` (5-minute cache hint), enabling clients and gateways to cache tool definitions safely. + +--- + +## 3. Tool Registry & Schemas + +The MCP server exposes 7 core tools covering inspection, entity configuration, blocklists, and cloud sync: + +### 3.1 `list_entities` +Queries entities registered in Home Assistant along with their Google Assistant status. +* **Parameters**: + * `domain` *(string, optional)*: Filter by domain (e.g. `light`, `switch`, `climate`). + * `area` *(string, optional)*: Filter by area/room name or area ID. + * `floor` *(string, optional)*: Filter by floor name or floor ID. + * `exposure_status` *(string, optional, enum: `["all", "exposed", "not_exposed", "blocked"]`, default: `"all"`)*. + * `search` *(string, optional)*: Text search across entity IDs, friendly names, and voice aliases. +* **Returns**: Array of entities with `entity_id`, `friendly_name`, `area`, `floor`, `domain`, `exposed`, `blocklisted`, `blocklist_rule`, and `nicknames`. + +### 3.2 `get_entity` +Retrieves detailed Google Assistant configuration for a single entity. +* **Parameters**: + * `entity_id` *(string, required)*: Target entity ID. +* **Returns**: Entity object with full exposure state, custom voice name, nicknames array, area/floor placement, and matched blocklist rule (if applicable). + +### 3.3 `get_google_assistant_status` +Checks cloud project status, credentials state, and exposure counts. +* **Parameters**: None. +* **Returns**: `project_id`, `service_account_configured` (bool), `report_state` (bool), `exposed_count` (int), `blocklisted_count` (int), and `last_sync_timestamp`. + +### 3.4 `set_entity_exposure` +Exposes or unexposes one or more entities to Google Assistant in `.storage` and memory. +* **Parameters**: + * `entity_ids` *(array of strings, required)*: Entity ID(s) to modify. + * `exposed` *(boolean, required)*: `true` to expose, `false` to exclude. +* **Returns**: Confirmation object with the list of modified entity IDs. + +### 3.5 `set_entity_nicknames` +Modifies voice aliases used by Google Assistant for voice matching. +* **Parameters**: + * `entity_id` *(string, required)*: Target entity ID. + * `nicknames` *(array of strings, required)*: List of voice nickname strings. + * `mode` *(string, optional, enum: `["replace", "append", "remove"]`, default: `"replace"`)*: Mutation mode. +* **Returns**: Updated active nickname array for the entity. + +### 3.6 `manage_blocklist_rule` +Adds or removes a regular expression blocklist filter. +* **Parameters**: + * `action` *(string, required, enum: `["add", "remove"]`)*: Action to perform. + * `pattern` *(string, required)*: Regex pattern string (e.g. `.*_battery_level$`). +* **Returns**: Updated blocklist rules array and count of entities affected. + +### 3.7 `sync_to_google` +Triggers real-time zero-reboot `devices:requestSync` to Google HomeGraph. +* **Parameters**: None. +* **Returns**: Sync result (`status`: `"success"` or `"error"`), HomeGraph HTTP status code, and timestamp. + +--- + +## 4. Module & Integration Design + +### 4.1 New File: `custom_components/google_assistant_entity_console/mcp.py` +Contains: +* `MCP_PROTOCOL_VERSION = "2026-07-28"` +* `TOOL_DEFINITIONS`: List of tool definitions conforming to JSON Schema draft-07 and MCP 2026-07-28 `ttlMs`. +* `async def handle_mcp_request(hass: HomeAssistant, payload: dict) -> dict`: Main JSON-RPC router. +* Tool implementation dispatchers invoking `async_get_store(hass)` and `async_get_runtime(hass)`. + +### 4.2 Updates to `views.py` +Add `McpStreamableView(HomeAssistantView)`: +* Handles `POST` requests to `/api/google_assistant_entity_console/mcp`. +* Enforces `requires_auth = True`. +* Sets `Mcp-Protocol-Version: 2026-07-28` header. +* Dispatches to `handle_mcp_request`. + +### 4.3 Updates to `__init__.py` +Register `hass.http.register_view(McpStreamableView())` within `async_setup_entry`. + +--- + +## 5. Error Handling & Edge Cases + +* **JSON-RPC Compliance**: + * Code `-32700`: Parse error (invalid JSON). + * Code `-32600`: Invalid Request. + * Code `-32601`: Method not found. + * Code `-32602`: Invalid params. + * Code `-32603`: Internal error. +* **Tool-Level Errors**: Handled without crashing HTTP response by returning `{ "content": [{"type": "text", "text": "error description"}], "isError": true }`. +* **Invalid Regex Patterns**: Validated before adding to store using `re.compile()`; returns validation error if syntax is malformed. + +--- + +## 6. Testing & Verification + +* Create test harness in `tests/test_mcp.py`: + * Verify `tools/list` returns the 7 tools with schemas and `ttlMs: 300000`. + * Verify `tools/call` for each tool (`list_entities`, `get_entity`, `set_entity_exposure`, `set_entity_nicknames`, `manage_blocklist_rule`, `sync_to_google`, `get_google_assistant_status`). + * Verify JSON-RPC error formats for malformed payloads and missing tool arguments. + * Verify execution against mock Home Assistant instance with pytest. From 5818e75dbda8653d7b93b134d550106619a9d85f Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:09:31 -0500 Subject: [PATCH 02/10] docs: add implementation plan for native MCP server --- .../2026-10-25-mcp-server-implementation.md | 398 ++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-25-mcp-server-implementation.md diff --git a/docs/superpowers/plans/2026-10-25-mcp-server-implementation.md b/docs/superpowers/plans/2026-10-25-mcp-server-implementation.md new file mode 100644 index 0000000..6a07c6a --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-mcp-server-implementation.md @@ -0,0 +1,398 @@ +# Google Assistant Entity Console - MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a native Model Context Protocol (MCP) Server in Google Assistant Entity Console adhering to the 2026-07-28 Streamable HTTP specification to allow AI agents and Model Context Gateway (MCG) to inspect, configure, and synchronize Google Assistant devices. + +**Architecture:** A lightweight, zero-dependency async JSON-RPC 2.0 dispatcher in `mcp.py` backed by `HomeAssistantView` in `views.py` mounted at `/api/google_assistant_entity_console/mcp`. Tool calls directly invoke existing async services in `runtime.py` and `store.py` for live zero-reboot updates and HomeGraph sync. + +**Tech Stack:** Python 3.12+, Home Assistant Core / `aiohttp.web`, JSON-RPC 2.0, MCP Spec `2026-07-28`. + +## Global Constraints + +- Specification Version: Model Context Protocol (MCP) `2026-07-28` (Streamable HTTP Transport). +- Zero external dependencies: `requirements: []` in `manifest.json`. +- Authentication: Standard Home Assistant Long-Lived Access Token via `Authorization: Bearer ` (`requires_auth = True`). +- Endpoint: `POST /api/google_assistant_entity_console/mcp`. +- Protocol Version Header: `Mcp-Protocol-Version: 2026-07-28`. +- Tool Cache Hint: `ttlMs: 300000` (5 minutes) in `tools/list`. + +--- + +### Task 1: Protocol Dispatcher, Schemas & Lifecycle in `mcp.py` + +**Files:** +- Create: `custom_components/google_assistant_entity_console/mcp.py` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: Standard Python `json`, `typing`, and Home Assistant `HomeAssistant` type. +- Produces: + - `MCP_PROTOCOL_VERSION = "2026-07-28"` + - `TOOL_DEFINITIONS: list[dict]` + - `async def async_handle_mcp_payload(hass: HomeAssistant, payload: dict) -> dict` + +- [ ] **Step 1: Write failing tests for JSON-RPC dispatcher, initialize, ping, and tools/list** + +```python +# In tests/test_mcp.py +import pytest +from custom_components.google_assistant_entity_console.mcp import ( + MCP_PROTOCOL_VERSION, + TOOL_DEFINITIONS, + async_handle_mcp_payload, +) + +@pytest.mark.asyncio +async def test_mcp_protocol_version(): + assert MCP_PROTOCOL_VERSION == "2026-07-28" + +@pytest.mark.asyncio +async def test_mcp_ping(mock_hass): + req = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + res = await async_handle_mcp_payload(mock_hass, req) + assert res == {"jsonrpc": "2.0", "id": 1, "result": {}} + +@pytest.mark.asyncio +async def test_mcp_initialize(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 2 + assert res["result"]["protocolVersion"] == "2026-07-28" + assert res["result"]["serverInfo"]["name"] == "google-assistant-entity-console" + +@pytest.mark.asyncio +async def test_mcp_tools_list(mock_hass): + req = {"jsonrpc": "2.0", "id": 3, "method": "tools/list"} + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 3 + tools = res["result"]["tools"] + assert len(tools) == 7 + tool_names = [t["name"] for t in tools] + assert "list_entities" in tool_names + assert "get_entity" in tool_names + assert "get_google_assistant_status" in tool_names + assert "set_entity_exposure" in tool_names + assert "set_entity_nicknames" in tool_names + assert "manage_blocklist_rule" in tool_names + assert "sync_to_google" in tool_names + assert res["result"]["ttlMs"] == 300000 + +@pytest.mark.asyncio +async def test_mcp_invalid_jsonrpc(mock_hass): + req = {"id": 4, "method": "unknown_method"} + res = await async_handle_mcp_payload(mock_hass, req) + assert "error" in res + assert res["error"]["code"] == -32600 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_mcp.py -v` +Expected: FAIL with ModuleNotFoundError or import error for `mcp.py`. + +- [ ] **Step 3: Implement `mcp.py` core dispatcher, tool schemas, and error handling** + +Create `custom_components/google_assistant_entity_console/mcp.py` implementing: +- `MCP_PROTOCOL_VERSION = "2026-07-28"` +- `TOOL_DEFINITIONS` listing all 7 tool schemas with inputSchema and parameters. +- `async_handle_mcp_payload(hass: HomeAssistant, payload: dict) -> dict` handling `ping`, `initialize`, `notifications/initialized`, `tools/list`, and JSON-RPC errors (`-32600`, `-32601`, `-32602`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_mcp.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/google_assistant_entity_console/mcp.py tests/test_mcp.py +git commit -m "feat(mcp): add 2026-07-28 protocol dispatcher and tool definitions" +``` + +--- + +### Task 2: Inspection Tools (`list_entities`, `get_entity`, `get_google_assistant_status`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/mcp.py` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: + - `views.async_fetch_entities_data(hass)` + - `store.async_get_store(hass)` +- Produces: + - `tools/call` execution for `list_entities`, `get_entity`, and `get_google_assistant_status`. + +- [ ] **Step 1: Write failing tests for inspection tools** + +```python +# In tests/test_mcp.py +@pytest.mark.asyncio +async def test_mcp_call_list_entities(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "list_entities", + "arguments": {"domain": "light", "exposure_status": "all"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 10 + assert not res["result"].get("isError", False) + content = res["result"]["content"] + assert len(content) > 0 + assert content[0]["type"] == "text" + +@pytest.mark.asyncio +async def test_mcp_call_get_entity(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "get_entity", + "arguments": {"entity_id": "light.living_room_lights"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 11 + +@pytest.mark.asyncio +async def test_mcp_call_get_google_assistant_status(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": {"name": "get_google_assistant_status", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_mcp.py -k "test_mcp_call" -v` +Expected: FAIL (tool dispatcher not yet implemented for these tools). + +- [ ] **Step 3: Implement inspection tool handlers in `mcp.py`** + +Connect `list_entities`, `get_entity`, and `get_google_assistant_status` to `async_fetch_entities_data(hass)` and `async_get_store(hass)`. Add filtering logic for domain, area, floor, exposure_status, and search text. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_mcp.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/google_assistant_entity_console/mcp.py tests/test_mcp.py +git commit -m "feat(mcp): implement list_entities, get_entity, and get_status tools" +``` + +--- + +### Task 3: Mutation & Control Tools (`set_entity_exposure`, `set_entity_nicknames`, `manage_blocklist_rule`, `sync_to_google`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/mcp.py` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: + - `store.async_get_store(hass)` + - `runtime.async_get_runtime(hass)` + - `views.async_build_entity_config_map(hass)` +- Produces: + - `tools/call` execution for `set_entity_exposure`, `set_entity_nicknames`, `manage_blocklist_rule`, and `sync_to_google`. + +- [ ] **Step 1: Write failing tests for mutation tools** + +```python +# In tests/test_mcp.py +@pytest.mark.asyncio +async def test_mcp_call_set_entity_exposure(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 20, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": ["light.living_room_lights"], "exposed": True}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_nicknames(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": { + "entity_id": "light.living_room_lights", + "nicknames": ["Chandelier", "Main Lights"], + "mode": "replace", + }, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + +@pytest.mark.asyncio +async def test_mcp_call_manage_blocklist_rule(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": { + "name": "manage_blocklist_rule", + "arguments": {"action": "add", "pattern": ".*_battery_level$"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + +@pytest.mark.asyncio +async def test_mcp_call_sync_to_google(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 23, + "method": "tools/call", + "params": {"name": "sync_to_google", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_mcp.py -k "test_mcp_call_set or test_mcp_call_manage or test_mcp_call_sync" -v` +Expected: FAIL. + +- [ ] **Step 3: Implement mutation tools in `mcp.py`** + +Wire tool handlers to: +- Update store entity settings & call `runtime.async_update_entity_config` without reboot. +- Manage regex patterns with regex validation (`re.compile`) and update store blocklist. +- Trigger `runtime.async_request_sync()`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_mcp.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/google_assistant_entity_console/mcp.py tests/test_mcp.py +git commit -m "feat(mcp): implement exposure, nicknames, blocklist, and sync tools" +``` + +--- + +### Task 4: Home Assistant HTTP View Integration (`McpStreamableView`) + +**Files:** +- Modify: `custom_components/google_assistant_entity_console/views.py` +- Modify: `custom_components/google_assistant_entity_console/__init__.py` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `mcp.async_handle_mcp_payload` +- Produces: + - `McpStreamableView(HomeAssistantView)` at `/api/google_assistant_entity_console/mcp` + - Integration registration in `async_setup_entry` + +- [ ] **Step 1: Write failing tests for `McpStreamableView` endpoint** + +```python +# In tests/test_mcp.py +from custom_components.google_assistant_entity_console.views import McpStreamableView + +@pytest.mark.asyncio +async def test_mcp_view_post_json(mock_hass): + view = McpStreamableView() + # Mock aiohttp request with JSON payload + # Verify response status 200, header Mcp-Protocol-Version: 2026-07-28, and JSON-RPC body +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_mcp.py -k "test_mcp_view" -v` +Expected: FAIL with ImportError (McpStreamableView does not exist). + +- [ ] **Step 3: Implement `McpStreamableView` in `views.py` and register in `__init__.py`** + +- In `views.py`: + - Create `McpStreamableView(HomeAssistantView)` with `url = "/api/google_assistant_entity_console/mcp"`, `name = "api:google_assistant_entity_console:mcp"`, `requires_auth = True`. + - Check `Accept` header for `text/event-stream` vs `application/json`. + - Set `Mcp-Protocol-Version: 2026-07-28`. +- In `__init__.py`: + - Import `McpStreamableView` and call `hass.http.register_view(McpStreamableView())`. + +- [ ] **Step 4: Run tests to verify view passes** + +Run: `pytest tests/test_mcp.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/google_assistant_entity_console/views.py custom_components/google_assistant_entity_console/__init__.py tests/test_mcp.py +git commit -m "feat(mcp): expose McpStreamableView at /api/google_assistant_entity_console/mcp" +``` + +--- + +### Task 5: Documentation & End-to-End Test Suite Verification + +**Files:** +- Modify: `README.md` +- Modify: `docs/ARCHITECTURE.md` +- Test: Full test suite (`pytest tests/`) + +- [ ] **Step 1: Update `README.md` and `docs/ARCHITECTURE.md`** + +Add an **MCP Server (Model Context Protocol)** section explaining: +- Endpoint: `POST /api/google_assistant_entity_console/mcp` +- Authentication: Bearer token +- Spec version: `2026-07-28` (Streamable HTTP) +- List of tools and example client configuration (e.g. Model Context Gateway or Claude Desktop / Antigravity). + +- [ ] **Step 2: Run entire test suite to guarantee 0 regressions** + +Run: `pytest tests/` +Expected: All tests pass (including existing 361 tests and new test suite). + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/ARCHITECTURE.md +git commit -m "docs(mcp): document native MCP server endpoint and configuration" +``` From ebd05ecc47d995f10846ebe7fa8c7decf6bbb894 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:12:35 -0500 Subject: [PATCH 03/10] feat(mcp): add 2026-07-28 protocol dispatcher and tool definitions --- .../google_assistant_entity_console/mcp.py | 232 ++++++++++++++++++ tests/test_mcp.py | 119 +++++++++ 2 files changed, 351 insertions(+) create mode 100644 custom_components/google_assistant_entity_console/mcp.py create mode 100644 tests/test_mcp.py diff --git a/custom_components/google_assistant_entity_console/mcp.py b/custom_components/google_assistant_entity_console/mcp.py new file mode 100644 index 0000000..06daa35 --- /dev/null +++ b/custom_components/google_assistant_entity_console/mcp.py @@ -0,0 +1,232 @@ +"""Model Context Protocol (MCP) server implementation for Google Assistant Entity Console. + +Adheres to MCP specification revision 2026-07-28 (Streamable HTTP Transport). +Provides stateless JSON-RPC 2.0 dispatching for inspecting and configuring entities, +voice aliases, blocklists, and Google HomeGraph synchronization. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +MCP_PROTOCOL_VERSION = "2026-07-28" +SERVER_NAME = "google-assistant-entity-console" +SERVER_VERSION = "3.0.2" +TOOL_CACHE_TTL_MS = 300000 + +TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "name": "list_entities", + "description": "Queries entities registered in Home Assistant along with their Google Assistant status.", + "inputSchema": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Filter by domain (e.g. light, switch, climate).", + }, + "area": { + "type": "string", + "description": "Filter by area/room name or area ID.", + }, + "floor": { + "type": "string", + "description": "Filter by floor name or floor ID.", + }, + "exposure_status": { + "type": "string", + "enum": ["all", "exposed", "not_exposed", "blocked"], + "default": "all", + "description": "Filter by exposure status.", + }, + "search": { + "type": "string", + "description": "Text search across entity IDs, friendly names, and voice aliases.", + }, + }, + }, + }, + { + "name": "get_entity", + "description": "Retrieves detailed Google Assistant configuration for a single entity.", + "inputSchema": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "Target entity ID.", + }, + }, + "required": ["entity_id"], + }, + }, + { + "name": "get_google_assistant_status", + "description": "Checks cloud project status, credentials state, and exposure counts.", + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, + { + "name": "set_entity_exposure", + "description": "Exposes or unexposes one or more entities to Google Assistant in .storage and memory.", + "inputSchema": { + "type": "object", + "properties": { + "entity_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Entity ID(s) to modify.", + }, + "exposed": { + "type": "boolean", + "description": "true to expose, false to exclude.", + }, + }, + "required": ["entity_ids", "exposed"], + }, + }, + { + "name": "set_entity_nicknames", + "description": "Modifies voice aliases used by Google Assistant for voice matching.", + "inputSchema": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "description": "Target entity ID.", + }, + "nicknames": { + "type": "array", + "items": {"type": "string"}, + "description": "List of voice nickname strings.", + }, + "mode": { + "type": "string", + "enum": ["replace", "append", "remove"], + "default": "replace", + "description": "Mutation mode: replace, append, or remove.", + }, + }, + "required": ["entity_id", "nicknames"], + }, + }, + { + "name": "manage_blocklist_rule", + "description": "Adds or removes a regular expression blocklist filter.", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "remove"], + "description": "Action to perform: add or remove.", + }, + "pattern": { + "type": "string", + "description": "Regex pattern string (e.g. .*_battery_level$).", + }, + }, + "required": ["action", "pattern"], + }, + }, + { + "name": "sync_to_google", + "description": "Triggers real-time zero-reboot devices:requestSync to Google HomeGraph.", + "inputSchema": { + "type": "object", + "properties": {}, + }, + }, +] + + +def _jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> dict[str, Any]: + """Format a JSON-RPC 2.0 error response.""" + error: dict[str, Any] = {"code": code, "message": message} + if data is not None: + error["data"] = data + return { + "jsonrpc": "2.0", + "id": req_id, + "error": error, + } + + +def _jsonrpc_result(result: Any, req_id: Any) -> dict[str, Any]: + """Format a JSON-RPC 2.0 success response.""" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": result, + } + + +async def async_handle_mcp_payload(hass: HomeAssistant, payload: Any) -> dict[str, Any] | None: + """Route and handle an incoming MCP JSON-RPC payload. + + Args: + hass: Home Assistant core instance. + payload: Decoded JSON payload. + + Returns: + JSON-RPC 2.0 response dictionary, or None for notifications. + """ + if not isinstance(payload, dict): + return _jsonrpc_error(-32600, "Invalid Request: payload must be a JSON object", None) + + req_id = payload.get("id") + is_notification = "id" not in payload + + if payload.get("jsonrpc") != "2.0": + return _jsonrpc_error(-32600, "Invalid Request: missing or invalid jsonrpc 2.0 version", req_id) + + method = payload.get("method") + if not isinstance(method, str): + return _jsonrpc_error(-32600, "Invalid Request: method must be a string", req_id) + + params = payload.get("params") + if params is not None and not isinstance(params, (dict, list)): + return _jsonrpc_error(-32602, "Invalid params: expected object or array", req_id) + + # Lifecycle & core handlers + if method == "ping": + return _jsonrpc_result({}, req_id) + + if method == "initialize": + result = { + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": { + "listChanged": False, + }, + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + }, + } + return _jsonrpc_result(result, req_id) + + if method == "notifications/initialized": + # Client notification confirming initialization receipt + return None + + if method == "tools/list": + result = { + "tools": TOOL_DEFINITIONS, + "ttlMs": TOOL_CACHE_TTL_MS, + } + return _jsonrpc_result(result, req_id) + + # Notifications without id do not receive error responses + if is_notification: + return None + + return _jsonrpc_error(-32601, f"Method not found: {method}", req_id) diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..b304cf2 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,119 @@ +"""Tests for MCP server protocol dispatcher, schemas, and lifecycle.""" + +from __future__ import annotations + +import pytest + +from custom_components.google_assistant_entity_console.mcp import ( + MCP_PROTOCOL_VERSION, + TOOL_DEFINITIONS, + async_handle_mcp_payload, +) + + +@pytest.mark.asyncio +async def test_mcp_protocol_version(): + """Test protocol version constant matches 2026-07-28.""" + assert MCP_PROTOCOL_VERSION == "2026-07-28" + + +def test_tool_definitions_structure(): + """Test TOOL_DEFINITIONS structure and tool count.""" + assert len(TOOL_DEFINITIONS) == 7 + for tool in TOOL_DEFINITIONS: + assert "name" in tool + assert "description" in tool + assert "inputSchema" in tool + assert tool["inputSchema"]["type"] == "object" + + +@pytest.mark.asyncio +async def test_mcp_ping(mock_hass): + """Test ping method returns empty result.""" + req = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + res = await async_handle_mcp_payload(mock_hass, req) + assert res == {"jsonrpc": "2.0", "id": 1, "result": {}} + + +@pytest.mark.asyncio +async def test_mcp_initialize(mock_hass): + """Test initialize method returns server capabilities and protocol version.""" + req = { + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 2 + assert res["result"]["protocolVersion"] == "2026-07-28" + assert res["result"]["serverInfo"]["name"] == "google-assistant-entity-console" + + +@pytest.mark.asyncio +async def test_mcp_notifications_initialized(mock_hass): + """Test notifications/initialized notification payload (no id).""" + req = {"jsonrpc": "2.0", "method": "notifications/initialized"} + res = await async_handle_mcp_payload(mock_hass, req) + # Notifications without id do not require a JSON-RPC response + assert res is None or res == {} + + +@pytest.mark.asyncio +async def test_mcp_tools_list(mock_hass): + """Test tools/list returns all 7 tools and 5-minute cache hint.""" + req = {"jsonrpc": "2.0", "id": 3, "method": "tools/list"} + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 3 + tools = res["result"]["tools"] + assert len(tools) == 7 + tool_names = [t["name"] for t in tools] + assert "list_entities" in tool_names + assert "get_entity" in tool_names + assert "get_google_assistant_status" in tool_names + assert "set_entity_exposure" in tool_names + assert "set_entity_nicknames" in tool_names + assert "manage_blocklist_rule" in tool_names + assert "sync_to_google" in tool_names + assert res["result"]["ttlMs"] == 300000 + + +@pytest.mark.asyncio +async def test_mcp_invalid_jsonrpc(mock_hass): + """Test invalid JSON-RPC payload returns code -32600.""" + req = {"id": 4, "method": "unknown_method"} + res = await async_handle_mcp_payload(mock_hass, req) + assert "error" in res + assert res["error"]["code"] == -32600 + + +@pytest.mark.asyncio +async def test_mcp_non_dict_payload(mock_hass): + """Test non-dict payload returns code -32600.""" + res = await async_handle_mcp_payload(mock_hass, "not-a-dict") + assert "error" in res + assert res["error"]["code"] == -32600 + + +@pytest.mark.asyncio +async def test_mcp_invalid_params(mock_hass): + """Test invalid params type returns code -32602.""" + req = {"jsonrpc": "2.0", "id": 6, "method": "initialize", "params": "string_not_dict"} + res = await async_handle_mcp_payload(mock_hass, req) + assert "error" in res + assert res["error"]["code"] == -32602 + + +@pytest.mark.asyncio +async def test_mcp_method_not_found(mock_hass): + """Test unknown method returns code -32601.""" + req = {"jsonrpc": "2.0", "id": 5, "method": "non_existent_method"} + res = await async_handle_mcp_payload(mock_hass, req) + assert "error" in res + assert res["error"]["code"] == -32601 From dddd183492ac29ef2307f2cb14988a1d8e7668cf Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:17:34 -0500 Subject: [PATCH 04/10] feat(mcp): implement list_entities, get_entity, and get_status tools --- .../google_assistant_entity_console/mcp.py | 371 +++++++++++++++ tests/test_mcp.py | 442 ++++++++++++++++++ 2 files changed, 813 insertions(+) diff --git a/custom_components/google_assistant_entity_console/mcp.py b/custom_components/google_assistant_entity_console/mcp.py index 06daa35..b63ec3f 100644 --- a/custom_components/google_assistant_entity_console/mcp.py +++ b/custom_components/google_assistant_entity_console/mcp.py @@ -7,10 +7,23 @@ from __future__ import annotations +import contextlib +import json import logging +import re from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry, + device_registry, + entity_registry, + floor_registry, +) + +from .const import DOMAIN +from .store import async_get_store +from .views import async_fetch_entities_data, async_load_blocklist _LOGGER = logging.getLogger(__name__) @@ -168,6 +181,331 @@ def _jsonrpc_result(result: Any, req_id: Any) -> dict[str, Any]: } +def _format_entity_response(entity_data: dict[str, Any]) -> dict[str, Any]: + """Format entity dictionary into standard MCP tool output.""" + entity_id = entity_data["entity_id"] + display_name = ( + entity_data.get("display_name") + or entity_data.get("friendly_name") + or entity_data.get("name") + or entity_id + ) + aliases = list(entity_data.get("aliases") or entity_data.get("nicknames") or []) + is_exposed = bool( + entity_data.get("should_expose") + or entity_data.get("yaml_exposed") + or entity_data.get("exposed") + ) + + return { + "entity_id": entity_id, + "name": entity_data.get("name"), + "original_name": entity_data.get("original_name"), + "display_name": display_name, + "friendly_name": display_name, + "device_class": entity_data.get("device_class") or "", + "platform": entity_data.get("platform") or "", + "should_expose": bool(entity_data.get("should_expose", False)), + "yaml_exposed": bool(entity_data.get("yaml_exposed", False)), + "exposed": is_exposed, + "area": entity_data.get("area") or "TBA", + "floor": entity_data.get("floor") or "TBA", + "domain": entity_data.get("domain") or entity_id.split(".")[0], + "aliases": aliases, + "nicknames": aliases, + "in_group": bool(entity_data.get("in_group", False)), + "unavailable": bool(entity_data.get("unavailable", False)), + "blocklisted": bool(entity_data.get("blocklisted", False)), + "blocklist_rule": entity_data.get("blocklist_rule"), + } + + +def _get_blocked_entities( + hass: HomeAssistant, compiled_blocklist: list[tuple[str, re.Pattern]] +) -> list[dict[str, Any]]: + """Find entities in registry that are blocked by current blocklist rules.""" + if not compiled_blocklist: + return [] + + ent_reg = entity_registry.async_get(hass) + dev_reg = device_registry.async_get(hass) + area_reg = area_registry.async_get(hass) + floor_reg = floor_registry.async_get(hass) + + raw_entities = getattr(ent_reg, "entities", {}) + if not isinstance(raw_entities, dict): + return [] + + area_map = { + a.id: a.name + for a in getattr(area_reg, "async_list_areas", lambda: [])() + } + area_floor_map = { + a.id: getattr(a, "floor_id", None) + for a in getattr(area_reg, "async_list_areas", lambda: [])() + } + floor_map = { + getattr(f, "floor_id", getattr(f, "id", None)): f.name + for f in getattr(floor_reg, "async_list_floors", lambda: [])() + if getattr(f, "floor_id", getattr(f, "id", None)) + } + + devices_dict = getattr(dev_reg, "devices", {}) + device_vals = devices_dict.values() if isinstance(devices_dict, dict) else [] + device_area_map = {d.id: d.area_id for d in device_vals if hasattr(d, "id")} + device_name_map = { + d.id: getattr(d, "name_by_user", None) or getattr(d, "name", "") + for d in device_vals + if hasattr(d, "id") + } + + blocked: list[dict[str, Any]] = [] + for entry in raw_entities.values(): + if getattr(entry, "disabled_by", None) or getattr(entry, "hidden_by", None): + continue + entity_id = getattr(entry, "entity_id", "") + matched_rule = None + for pattern_str, rx in compiled_blocklist: + if rx.search(entity_id): + matched_rule = pattern_str + break + if not matched_rule: + continue + + device_id = getattr(entry, "device_id", None) + domain = entity_id.split(".")[0] + area_id = getattr(entry, "area_id", None) or device_area_map.get(device_id) + area_name = area_map.get(area_id, "TBA") if area_id else "TBA" + floor_id = area_floor_map.get(area_id) if area_id else None + floor_name = floor_map.get(floor_id, "TBA") if floor_id else "TBA" + + name = getattr(entry, "name", None) + if not isinstance(name, str): + name = None + orig_name = getattr(entry, "original_name", None) + if not isinstance(orig_name, str): + orig_name = None + + display_name = name + if not display_name and device_id and isinstance(device_id, str): + display_name = device_name_map.get(device_id) + if not display_name: + display_name = orig_name + if not display_name: + display_name = entity_id.split(".")[-1].replace("_", " ").title() + + device_class = getattr(entry, "device_class", None) or getattr(entry, "original_device_class", None) or "" + if not isinstance(device_class, str): + device_class = "" + + platform = getattr(entry, "platform", None) or "" + if not isinstance(platform, str): + platform = "" + + raw_aliases = getattr(entry, "aliases", []) or [] + clean_aliases = [str(a) for a in raw_aliases if isinstance(a, (str, int, float)) and str(a) != "0" and a] + + blocked.append({ + "entity_id": entity_id, + "name": name, + "original_name": orig_name, + "display_name": str(display_name), + "friendly_name": str(display_name), + "device_class": device_class, + "platform": platform, + "should_expose": False, + "yaml_exposed": False, + "exposed": False, + "area": area_name, + "floor": floor_name, + "domain": domain, + "aliases": clean_aliases, + "nicknames": clean_aliases, + "in_group": False, + "unavailable": False, + "blocklisted": True, + "blocklist_rule": matched_rule, + }) + return blocked + + +async def _handle_list_entities(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute list_entities tool with filtering.""" + exposure_status = args.get("exposure_status", "all") + domain_filter = args.get("domain") + area_filter = args.get("area") + floor_filter = args.get("floor") + search_filter = args.get("search") + + raw_blocklist = await async_load_blocklist(hass) + compiled_blocklist: list[tuple[str, re.Pattern]] = [] + for pattern in raw_blocklist: + with contextlib.suppress(re.error): + compiled_blocklist.append((pattern, re.compile(pattern))) + + if exposure_status == "blocked": + candidates = _get_blocked_entities(hass, compiled_blocklist) + else: + active = await async_fetch_entities_data(hass) + formatted_active = [_format_entity_response(e) for e in active] + if exposure_status == "exposed": + candidates = [e for e in formatted_active if e["exposed"]] + elif exposure_status == "not_exposed": + candidates = [e for e in formatted_active if not e["exposed"]] + else: + candidates = formatted_active + + # Resolve area filter mappings + matched_area_names: set[str] = set() + if area_filter: + area_filter_lower = area_filter.lower().strip() + matched_area_names.add(area_filter_lower) + area_reg = area_registry.async_get(hass) + if hasattr(area_reg, "async_list_areas"): + for a in area_reg.async_list_areas(): + if a.id.lower() == area_filter_lower or a.name.lower() == area_filter_lower: + matched_area_names.add(a.name.lower()) + + # Resolve floor filter mappings + matched_floor_names: set[str] = set() + if floor_filter: + floor_filter_lower = floor_filter.lower().strip() + matched_floor_names.add(floor_filter_lower) + floor_reg = floor_registry.async_get(hass) + if hasattr(floor_reg, "async_list_floors"): + for f in floor_reg.async_list_floors(): + fid = getattr(f, "floor_id", getattr(f, "id", None)) + if (fid and fid.lower() == floor_filter_lower) or (f.name and f.name.lower() == floor_filter_lower): + matched_floor_names.add(f.name.lower()) + + results: list[dict[str, Any]] = [] + for ent in candidates: + if domain_filter and ent.get("domain", "").lower() != domain_filter.lower().strip(): + continue + + if area_filter and ent.get("area", "").lower() not in matched_area_names: + continue + + if floor_filter and ent.get("floor", "").lower() not in matched_floor_names: + continue + + if search_filter: + term = search_filter.lower().strip() + eid = ent.get("entity_id", "").lower() + fname = ( + ent.get("friendly_name") + or ent.get("display_name") + or ent.get("name") + or "" + ).lower() + aliases = [str(a).lower() for a in ent.get("nicknames", [])] + if term not in eid and term not in fname and not any(term in a for a in aliases): + continue + + results.append(ent) + + return { + "content": [{"type": "text", "text": json.dumps(results, indent=2)}], + } + + +async def _handle_get_entity(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute get_entity tool.""" + entity_id = args.get("entity_id") + if not entity_id or not isinstance(entity_id, str): + return { + "content": [{"type": "text", "text": "Missing required argument: entity_id"}], + "isError": True, + } + + entities = await async_fetch_entities_data(hass) + match = next((e for e in entities if e.get("entity_id") == entity_id), None) + if match: + formatted = _format_entity_response(match) + return { + "content": [{"type": "text", "text": json.dumps(formatted, indent=2)}], + } + + # Check if entity is in blocklist + raw_blocklist = await async_load_blocklist(hass) + compiled_blocklist: list[tuple[str, re.Pattern]] = [] + for pattern in raw_blocklist: + with contextlib.suppress(re.error): + compiled_blocklist.append((pattern, re.compile(pattern))) + + blocked_entities = _get_blocked_entities(hass, compiled_blocklist) + blocked_match = next((e for e in blocked_entities if e.get("entity_id") == entity_id), None) + if blocked_match: + return { + "content": [{"type": "text", "text": json.dumps(blocked_match, indent=2)}], + } + + return { + "content": [{"type": "text", "text": f"Entity '{entity_id}' not found"}], + "isError": True, + } + + +async def _handle_get_google_assistant_status( + hass: HomeAssistant, args: dict[str, Any] +) -> dict[str, Any]: + """Execute get_google_assistant_status tool.""" + store = await async_get_store(hass) + gs = store.google_settings + project_id = gs.get("project_id", "") + sa = gs.get("service_account") + sa_configured = bool( + isinstance(sa, dict) + and sa.get("client_email") + and sa.get("private_key") + ) + report_state = bool(gs.get("report_state", True)) + + entities = await async_fetch_entities_data(hass) + exposed_count = sum( + 1 for e in entities if e.get("should_expose") or e.get("yaml_exposed") + ) + if not entities and store.synced_entities: + exposed_count = len(store.synced_entities) + + raw_blocklist = await async_load_blocklist(hass) + compiled_blocklist: list[re.Pattern] = [] + for pattern in raw_blocklist: + with contextlib.suppress(re.error): + compiled_blocklist.append(re.compile(pattern)) + + ent_reg = entity_registry.async_get(hass) + raw_entities = getattr(ent_reg, "entities", {}) + ent_values = list(raw_entities.values()) if isinstance(raw_entities, dict) else [] + blocklisted_count = 0 + if compiled_blocklist: + for entry in ent_values: + eid = getattr(entry, "entity_id", "") + if any(rx.search(eid) for rx in compiled_blocklist): + blocklisted_count += 1 + + domain_data = ( + hass.data.get(DOMAIN, {}) + if isinstance(getattr(hass, "data", None), dict) + else {} + ) + last_sync = domain_data.get("last_sync_timestamp") or gs.get("last_sync_timestamp") + + status = { + "project_id": project_id, + "service_account_configured": sa_configured, + "report_state": report_state, + "exposed_count": exposed_count, + "blocklisted_count": blocklisted_count, + "last_sync_timestamp": last_sync, + } + + return { + "content": [{"type": "text", "text": json.dumps(status, indent=2)}], + } + + + async def async_handle_mcp_payload(hass: HomeAssistant, payload: Any) -> dict[str, Any] | None: """Route and handle an incoming MCP JSON-RPC payload. @@ -225,6 +563,39 @@ async def async_handle_mcp_payload(hass: HomeAssistant, payload: Any) -> dict[st } return _jsonrpc_result(result, req_id) + if method == "tools/call": + if not isinstance(params, dict): + return _jsonrpc_error(-32602, "Invalid params: expected object", req_id) + name = params.get("name") + if not name or not isinstance(name, str): + return _jsonrpc_error(-32602, "Missing or invalid tool name", req_id) + arguments = params.get("arguments") or {} + if not isinstance(arguments, dict): + return _jsonrpc_error(-32602, "Invalid arguments: expected object", req_id) + + if name == "list_entities": + res = await _handle_list_entities(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "get_entity": + res = await _handle_get_entity(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "get_google_assistant_status": + res = await _handle_get_google_assistant_status(hass, arguments) + return _jsonrpc_result(res, req_id) + + return _jsonrpc_result( + { + "content": [ + { + "type": "text", + "text": f"Tool '{name}' is not implemented or unknown", + } + ], + "isError": True, + }, + req_id, + ) + # Notifications without id do not receive error responses if is_notification: return None diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b304cf2..a033225 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -117,3 +117,445 @@ async def test_mcp_method_not_found(mock_hass): res = await async_handle_mcp_payload(mock_hass, req) assert "error" in res assert res["error"]["code"] == -32601 + + +@pytest.mark.asyncio +async def test_mcp_call_list_entities(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "list_entities", + "arguments": {"domain": "light", "exposure_status": "all"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 10 + assert not res["result"].get("isError", False) + content = res["result"]["content"] + assert len(content) > 0 + assert content[0]["type"] == "text" + + +@pytest.mark.asyncio +async def test_mcp_call_get_entity(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": { + "name": "get_entity", + "arguments": {"entity_id": "light.living_room_lights"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 11 + + +@pytest.mark.asyncio +async def test_mcp_call_get_google_assistant_status(mock_hass): + req = { + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": {"name": "get_google_assistant_status", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + + +@pytest.mark.asyncio +async def test_mcp_call_list_entities_filtering(mock_hass): + """Test filtering by domain, area, floor, exposure status, and search.""" + import json + from unittest.mock import patch + + sample_entities = [ + { + "entity_id": "light.living_room_ceiling", + "name": "Ceiling Light", + "original_name": "Hue Lamp", + "display_name": "Living Room Ceiling Light", + "device_class": "", + "platform": "hue", + "should_expose": True, + "yaml_exposed": True, + "area": "Living Room", + "floor": "First Floor", + "domain": "light", + "aliases": ["overhead light", "main light"], + "in_group": False, + "unavailable": False, + }, + { + "entity_id": "switch.kitchen_fan", + "name": "Exhaust Fan", + "original_name": "Fan", + "display_name": "Kitchen Fan", + "device_class": "fan", + "platform": "esphome", + "should_expose": False, + "yaml_exposed": False, + "area": "Kitchen", + "floor": "First Floor", + "domain": "switch", + "aliases": ["cooker fan"], + "in_group": False, + "unavailable": False, + }, + { + "entity_id": "climate.upstairs_thermostat", + "name": "Upstairs Thermostat", + "original_name": "Ecobee", + "display_name": "Upstairs Thermostat", + "device_class": "", + "platform": "ecobee", + "should_expose": True, + "yaml_exposed": False, + "area": "Master Bedroom", + "floor": "Second Floor", + "domain": "climate", + "aliases": ["bedroom ac"], + "in_group": False, + "unavailable": False, + }, + ] + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_fetch_entities_data", + return_value=sample_entities, + ): + # 1. Filter domain = 'switch' + req1 = { + "jsonrpc": "2.0", + "id": 20, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"domain": "switch"}}, + } + res1 = await async_handle_mcp_payload(mock_hass, req1) + data1 = json.loads(res1["result"]["content"][0]["text"]) + assert len(data1) == 1 + assert data1[0]["entity_id"] == "switch.kitchen_fan" + + # 2. Filter area = 'Living Room' + req2 = { + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"area": "Living Room"}}, + } + res2 = await async_handle_mcp_payload(mock_hass, req2) + data2 = json.loads(res2["result"]["content"][0]["text"]) + assert len(data2) == 1 + assert data2[0]["entity_id"] == "light.living_room_ceiling" + + # 3. Filter floor = 'Second Floor' + req3 = { + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"floor": "Second Floor"}}, + } + res3 = await async_handle_mcp_payload(mock_hass, req3) + data3 = json.loads(res3["result"]["content"][0]["text"]) + assert len(data3) == 1 + assert data3[0]["entity_id"] == "climate.upstairs_thermostat" + + # 4. Filter exposure_status = 'exposed' + req4 = { + "jsonrpc": "2.0", + "id": 23, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"exposure_status": "exposed"}}, + } + res4 = await async_handle_mcp_payload(mock_hass, req4) + data4 = json.loads(res4["result"]["content"][0]["text"]) + assert len(data4) == 2 + assert {d["entity_id"] for d in data4} == { + "light.living_room_ceiling", + "climate.upstairs_thermostat", + } + + # 5. Filter exposure_status = 'not_exposed' + req5 = { + "jsonrpc": "2.0", + "id": 24, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"exposure_status": "not_exposed"}}, + } + res5 = await async_handle_mcp_payload(mock_hass, req5) + data5 = json.loads(res5["result"]["content"][0]["text"]) + assert len(data5) == 1 + assert data5[0]["entity_id"] == "switch.kitchen_fan" + + # 6. Filter search by alias + req6 = { + "jsonrpc": "2.0", + "id": 25, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"search": "bedroom ac"}}, + } + res6 = await async_handle_mcp_payload(mock_hass, req6) + data6 = json.loads(res6["result"]["content"][0]["text"]) + assert len(data6) == 1 + assert data6[0]["entity_id"] == "climate.upstairs_thermostat" + + +@pytest.mark.asyncio +async def test_mcp_call_get_entity_found_and_not_found(mock_hass): + """Test get_entity tool when entity is present vs missing.""" + import json + from unittest.mock import patch + + sample_entities = [ + { + "entity_id": "light.living_room_lights", + "name": "Living Room Lights", + "original_name": "Hue Group", + "display_name": "Living Room Lights", + "device_class": "", + "platform": "hue", + "should_expose": True, + "yaml_exposed": True, + "area": "Living Room", + "floor": "Ground Floor", + "domain": "light", + "aliases": ["overhead"], + "in_group": True, + "unavailable": False, + } + ] + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_fetch_entities_data", + return_value=sample_entities, + ): + # Found + req_found = { + "jsonrpc": "2.0", + "id": 30, + "method": "tools/call", + "params": { + "name": "get_entity", + "arguments": {"entity_id": "light.living_room_lights"}, + }, + } + res_found = await async_handle_mcp_payload(mock_hass, req_found) + assert res_found["jsonrpc"] == "2.0" + assert res_found["id"] == 30 + assert not res_found["result"].get("isError", False) + ent = json.loads(res_found["result"]["content"][0]["text"]) + assert ent["entity_id"] == "light.living_room_lights" + + # Not found + req_not_found = { + "jsonrpc": "2.0", + "id": 31, + "method": "tools/call", + "params": { + "name": "get_entity", + "arguments": {"entity_id": "light.non_existent"}, + }, + } + res_not_found = await async_handle_mcp_payload(mock_hass, req_not_found) + assert res_not_found["jsonrpc"] == "2.0" + assert res_not_found["id"] == 31 + assert res_not_found["result"].get("isError") is True + assert "not found" in res_not_found["result"]["content"][0]["text"].lower() + + +@pytest.mark.asyncio +async def test_mcp_call_get_google_assistant_status_fields(mock_hass): + """Test get_google_assistant_status returns expected fields.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + mock_store = MagicMock() + mock_store.google_settings = { + "project_id": "my-google-project", + "service_account": {"client_email": "test@example.com", "private_key": "-----BEGIN KEY-----"}, + "report_state": True, + } + mock_store.synced_entities = {"light.one": {}, "light.two": {}} + mock_store.blocklist = ["^camera\\."] + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_get_store", + AsyncMock(return_value=mock_store), + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_fetch_entities_data", + AsyncMock(return_value=[{"entity_id": "light.one", "should_expose": True, "yaml_exposed": True}]), + ): + req = { + "jsonrpc": "2.0", + "id": 40, + "method": "tools/call", + "params": {"name": "get_google_assistant_status", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + status = json.loads(res["result"]["content"][0]["text"]) + assert status["project_id"] == "my-google-project" + assert status["service_account_configured"] is True + assert status["report_state"] is True + assert status["exposed_count"] == 1 + assert "blocklisted_count" in status + assert "last_sync_timestamp" in status + + +@pytest.mark.asyncio +async def test_mcp_call_list_entities_blocked_exposure_status(mock_hass): + """Test list_entities returns blocked entities when exposure_status='blocked'.""" + import json + from unittest.mock import MagicMock, patch + + mock_entry = MagicMock() + mock_entry.entity_id = "sensor.blocked_battery" + mock_entry.name = "Blocked Battery" + mock_entry.original_name = "Battery" + mock_entry.disabled_by = None + mock_entry.hidden_by = None + mock_entry.device_id = None + mock_entry.area_id = None + mock_entry.aliases = [] + + mock_ent_reg = MagicMock() + mock_ent_reg.entities = {"sensor.blocked_battery": mock_entry} + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_load_blocklist", + return_value=[r".*_battery$"], + ), patch( + "custom_components.google_assistant_entity_console.mcp.entity_registry.async_get", + return_value=mock_ent_reg, + ), patch( + "custom_components.google_assistant_entity_console.mcp.device_registry.async_get", + return_value=MagicMock(devices={}), + ), patch( + "custom_components.google_assistant_entity_console.mcp.area_registry.async_get", + return_value=MagicMock(async_list_areas=MagicMock(return_value=[])), + ), patch( + "custom_components.google_assistant_entity_console.mcp.floor_registry.async_get", + return_value=MagicMock(async_list_floors=MagicMock(return_value=[])), + ): + req = { + "jsonrpc": "2.0", + "id": 50, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": {"exposure_status": "blocked"}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + data = json.loads(res["result"]["content"][0]["text"]) + assert len(data) == 1 + assert data[0]["entity_id"] == "sensor.blocked_battery" + assert data[0]["blocklisted"] is True + assert data[0]["blocklist_rule"] == r".*_battery$" + + +@pytest.mark.asyncio +async def test_mcp_call_get_entity_blocked(mock_hass): + """Test get_entity returns entity details even if blocked by blocklist.""" + import json + from unittest.mock import MagicMock, patch + + mock_entry = MagicMock() + mock_entry.entity_id = "sensor.blocked_battery" + mock_entry.name = "Blocked Battery" + mock_entry.original_name = "Battery" + mock_entry.disabled_by = None + mock_entry.hidden_by = None + mock_entry.device_id = None + mock_entry.area_id = None + mock_entry.aliases = [] + + mock_ent_reg = MagicMock() + mock_ent_reg.entities = {"sensor.blocked_battery": mock_entry} + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_fetch_entities_data", + return_value=[], + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_load_blocklist", + return_value=[r".*_battery$"], + ), patch( + "custom_components.google_assistant_entity_console.mcp.entity_registry.async_get", + return_value=mock_ent_reg, + ), patch( + "custom_components.google_assistant_entity_console.mcp.device_registry.async_get", + return_value=MagicMock(devices={}), + ), patch( + "custom_components.google_assistant_entity_console.mcp.area_registry.async_get", + return_value=MagicMock(async_list_areas=MagicMock(return_value=[])), + ), patch( + "custom_components.google_assistant_entity_console.mcp.floor_registry.async_get", + return_value=MagicMock(async_list_floors=MagicMock(return_value=[])), + ): + req = { + "jsonrpc": "2.0", + "id": 51, + "method": "tools/call", + "params": { + "name": "get_entity", + "arguments": {"entity_id": "sensor.blocked_battery"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + ent = json.loads(res["result"]["content"][0]["text"]) + assert ent["entity_id"] == "sensor.blocked_battery" + assert ent["blocklisted"] is True + + +@pytest.mark.asyncio +async def test_mcp_call_unknown_tool(mock_hass): + """Test calling an unknown tool returns isError=True.""" + req = { + "jsonrpc": "2.0", + "id": 52, + "method": "tools/call", + "params": {"name": "unsupported_tool", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 52 + assert res["result"].get("isError") is True + assert "not implemented or unknown" in res["result"]["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_mcp_call_invalid_arguments_type(mock_hass): + """Test tools/call with invalid arguments type returns error -32602.""" + req = { + "jsonrpc": "2.0", + "id": 53, + "method": "tools/call", + "params": {"name": "list_entities", "arguments": "not-a-dict"}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert "error" in res + assert res["error"]["code"] == -32602 + + +@pytest.mark.asyncio +async def test_mcp_call_get_entity_missing_id(mock_hass): + """Test get_entity with missing entity_id returns tool error.""" + req = { + "jsonrpc": "2.0", + "id": 54, + "method": "tools/call", + "params": {"name": "get_entity", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["result"].get("isError") is True + assert "Missing required argument" in res["result"]["content"][0]["text"] + + + From 555aeffa86526bbd83772e100e6e1bbd6ba0d000 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:24:32 -0500 Subject: [PATCH 05/10] feat(mcp): implement exposure, nicknames, blocklist, and sync tools --- .../google_assistant_entity_console/mcp.py | 360 ++++++++++++++++-- tests/test_mcp.py | 338 ++++++++++++++++ 2 files changed, 675 insertions(+), 23 deletions(-) diff --git a/custom_components/google_assistant_entity_console/mcp.py b/custom_components/google_assistant_entity_console/mcp.py index b63ec3f..b273ae0 100644 --- a/custom_components/google_assistant_entity_console/mcp.py +++ b/custom_components/google_assistant_entity_console/mcp.py @@ -8,6 +8,7 @@ from __future__ import annotations import contextlib +from datetime import datetime, timezone import json import logging import re @@ -22,8 +23,14 @@ ) from .const import DOMAIN +from .runtime import async_get_runtime from .store import async_get_store -from .views import async_fetch_entities_data, async_load_blocklist +from .views import ( + async_build_entity_config_map, + async_fetch_entities_data, + async_load_blocklist, + async_save_blocklist, +) _LOGGER = logging.getLogger(__name__) @@ -505,6 +512,291 @@ async def _handle_get_google_assistant_status( } +async def _handle_set_entity_exposure(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute set_entity_exposure tool.""" + raw_entity_ids = args.get("entity_ids") + if isinstance(raw_entity_ids, str): + entity_ids = [raw_entity_ids] + elif isinstance(raw_entity_ids, list) and all(isinstance(x, str) for x in raw_entity_ids): + entity_ids = list(raw_entity_ids) + else: + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: entity_ids"}], + "isError": True, + } + + if "exposed" not in args or not isinstance(args.get("exposed"), bool): + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: exposed (boolean)"}], + "isError": True, + } + exposed = bool(args["exposed"]) + + store = await async_get_store(hass) + ent_reg = entity_registry.async_get(hass) + + for entity_id in entity_ids: + entry = ent_reg.async_get(entity_id) if hasattr(ent_reg, "async_get") else None + if not entry: + _LOGGER.warning("Entity '%s' not found in entity registry during set_entity_exposure", entity_id) + + # Update store if dedicated method exists or update synced_entities directly + if hasattr(store, "async_update_entity"): + await store.async_update_entity(entity_id, should_expose=exposed) + else: + if exposed: + aliases = list(getattr(entry, "aliases", []) or []) if entry else [] + name = getattr(entry, "name", None) or getattr(entry, "original_name", None) if entry else None + store.synced_entities[entity_id] = { + "name": name, + "aliases": aliases, + "room": None, + } + else: + store.synced_entities.pop(entity_id, None) + await store.async_save() + + # Update entity registry options if available + if hasattr(ent_reg, "async_update_entity_options"): + ent_reg.async_update_entity_options( + entity_id, + "conversation", + {"should_expose": exposed}, + ) + + runtime = await async_get_runtime(hass) + for entity_id in entity_ids: + aliases = None + entry = ent_reg.async_get(entity_id) if hasattr(ent_reg, "async_get") else None + if entry and getattr(entry, "aliases", None): + aliases = list(entry.aliases) + if hasattr(runtime, "async_update_entity_config"): + await runtime.async_update_entity_config(entity_id, should_expose=exposed, aliases=aliases) + + if hasattr(runtime, "async_refresh_memory_config"): + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config(entity_config, schedule_auto_sync=True) + + return { + "content": [ + { + "type": "text", + "text": json.dumps({"status": "success", "updated": entity_ids}, indent=2), + } + ], + } + + +async def _handle_set_entity_nicknames(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute set_entity_nicknames tool.""" + entity_id = args.get("entity_id") + if not entity_id or not isinstance(entity_id, str): + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: entity_id"}], + "isError": True, + } + + nicknames = args.get("nicknames") + if nicknames is None or not isinstance(nicknames, list) or not all(isinstance(x, str) for x in nicknames): + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: nicknames (must be list of strings)"}], + "isError": True, + } + + mode = args.get("mode", "replace") + if mode not in ("replace", "append", "remove"): + return { + "content": [{"type": "text", "text": f"Invalid mode: '{mode}'. Expected 'replace', 'append', or 'remove'"}], + "isError": True, + } + + ent_reg = entity_registry.async_get(hass) + entry = ent_reg.async_get(entity_id) if hasattr(ent_reg, "async_get") else None + existing: list[str] = [] + if entry and getattr(entry, "aliases", None): + existing = [str(a) for a in entry.aliases if a and str(a) != "0"] + else: + store = await async_get_store(hass) + if entity_id in store.synced_entities: + existing = [str(a) for a in store.synced_entities[entity_id].get("aliases", []) if a and str(a) != "0"] + + clean_input = [n.strip() for n in nicknames if n and n.strip() and n.strip() != "0"] + + if mode == "replace": + final_nicknames = list(dict.fromkeys(clean_input)) + elif mode == "append": + combined = list(existing) + for n in clean_input: + if n not in combined: + combined.append(n) + final_nicknames = combined + elif mode == "remove": + to_remove = set(clean_input) + final_nicknames = [n for n in existing if n not in to_remove] + else: + final_nicknames = list(dict.fromkeys(clean_input)) + + store = await async_get_store(hass) + if hasattr(store, "async_update_entity"): + await store.async_update_entity(entity_id, aliases=final_nicknames) + else: + if entity_id in store.synced_entities: + store.synced_entities[entity_id]["aliases"] = final_nicknames + await store.async_save() + + if entry and hasattr(ent_reg, "async_update_entity"): + ent_reg.async_update_entity(entity_id, aliases=set(final_nicknames)) + + runtime = await async_get_runtime(hass) + if hasattr(runtime, "async_update_entity_config"): + await runtime.async_update_entity_config(entity_id, aliases=final_nicknames) + + if hasattr(runtime, "async_refresh_memory_config"): + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config(entity_config, schedule_auto_sync=True) + + return { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "status": "success", + "entity_id": entity_id, + "nicknames": final_nicknames, + }, + indent=2, + ), + } + ], + } + + +async def _handle_manage_blocklist_rule(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute manage_blocklist_rule tool.""" + action = args.get("action") + if action not in ("add", "remove"): + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: action ('add' or 'remove')"}], + "isError": True, + } + + pattern = args.get("pattern") + if not pattern or not isinstance(pattern, str): + return { + "content": [{"type": "text", "text": "Missing or invalid required argument: pattern (regex string)"}], + "isError": True, + } + + try: + re.compile(pattern) + except (re.error, TypeError) as err: + return { + "content": [{"type": "text", "text": f"Invalid regular expression pattern '{pattern}': {err}"}], + "isError": True, + } + + store = await async_get_store(hass) + if action == "add": + if hasattr(store, "async_add_blocklist_pattern"): + await store.async_add_blocklist_pattern(pattern) + else: + blocklist = await async_load_blocklist(hass) + if pattern not in blocklist: + blocklist.append(pattern) + await async_save_blocklist(hass, blocklist) + elif action == "remove": + if hasattr(store, "async_remove_blocklist_pattern"): + await store.async_remove_blocklist_pattern(pattern) + else: + blocklist = await async_load_blocklist(hass) + if pattern in blocklist: + blocklist.remove(pattern) + await async_save_blocklist(hass, blocklist) + + current_blocklist = await async_load_blocklist(hass) + if not current_blocklist and hasattr(store, "blocklist"): + current_blocklist = list(store.blocklist) + + compiled_rules: list[re.Pattern] = [] + for p in current_blocklist: + with contextlib.suppress(re.error): + compiled_rules.append(re.compile(p)) + + ent_reg = entity_registry.async_get(hass) + raw_entities = getattr(ent_reg, "entities", {}) + affected_count = 0 + if isinstance(raw_entities, dict) and compiled_rules: + for entry in raw_entities.values(): + if getattr(entry, "disabled_by", None) or getattr(entry, "hidden_by", None): + continue + eid = getattr(entry, "entity_id", "") + if any(rx.search(eid) for rx in compiled_rules): + affected_count += 1 + + runtime = await async_get_runtime(hass) + if hasattr(runtime, "async_refresh_memory_config"): + entity_config = await async_build_entity_config_map(hass) + await runtime.async_refresh_memory_config(entity_config, schedule_auto_sync=True) + + return { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "status": "success", + "action": action, + "pattern": pattern, + "blocklist": current_blocklist, + "affected_entities_count": affected_count, + }, + indent=2, + ), + } + ], + } + + +async def _handle_sync_to_google(hass: HomeAssistant, args: dict[str, Any]) -> dict[str, Any]: + """Execute sync_to_google tool.""" + runtime = await async_get_runtime(hass) + if hasattr(runtime, "async_request_sync"): + result = await runtime.async_request_sync() + else: + entity_config = await async_build_entity_config_map(hass) + result = await runtime.async_sync_to_google(entity_config) + + success = result.get("success", False) if isinstance(result, dict) else bool(result) + status_code = ( + result.get("status_code", 200 if success else 500) + if isinstance(result, dict) + else (200 if success else 500) + ) + timestamp = datetime.now(timezone.utc).isoformat() + + resp_payload = { + "status": "success" if success else "error", + "status_code": status_code, + "timestamp": timestamp, + } + if isinstance(result, dict): + for k, v in result.items(): + if k not in ("success", "status_code"): + resp_payload[k] = v + + response: dict[str, Any] = { + "content": [ + { + "type": "text", + "text": json.dumps(resp_payload, indent=2), + } + ], + } + if not success: + response["isError"] = True + return response + async def async_handle_mcp_payload(hass: HomeAssistant, payload: Any) -> dict[str, Any] | None: """Route and handle an incoming MCP JSON-RPC payload. @@ -573,28 +865,50 @@ async def async_handle_mcp_payload(hass: HomeAssistant, payload: Any) -> dict[st if not isinstance(arguments, dict): return _jsonrpc_error(-32602, "Invalid arguments: expected object", req_id) - if name == "list_entities": - res = await _handle_list_entities(hass, arguments) - return _jsonrpc_result(res, req_id) - if name == "get_entity": - res = await _handle_get_entity(hass, arguments) - return _jsonrpc_result(res, req_id) - if name == "get_google_assistant_status": - res = await _handle_get_google_assistant_status(hass, arguments) - return _jsonrpc_result(res, req_id) - - return _jsonrpc_result( - { - "content": [ - { - "type": "text", - "text": f"Tool '{name}' is not implemented or unknown", - } - ], - "isError": True, - }, - req_id, - ) + try: + if name == "list_entities": + res = await _handle_list_entities(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "get_entity": + res = await _handle_get_entity(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "get_google_assistant_status": + res = await _handle_get_google_assistant_status(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "set_entity_exposure": + res = await _handle_set_entity_exposure(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "set_entity_nicknames": + res = await _handle_set_entity_nicknames(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "manage_blocklist_rule": + res = await _handle_manage_blocklist_rule(hass, arguments) + return _jsonrpc_result(res, req_id) + if name == "sync_to_google": + res = await _handle_sync_to_google(hass, arguments) + return _jsonrpc_result(res, req_id) + + return _jsonrpc_result( + { + "content": [ + { + "type": "text", + "text": f"Tool '{name}' is not implemented or unknown", + } + ], + "isError": True, + }, + req_id, + ) + except Exception as err: + _LOGGER.exception("Tool execution error in '%s': %s", name, err) + return _jsonrpc_result( + { + "content": [{"type": "text", "text": str(err)}], + "isError": True, + }, + req_id, + ) # Notifications without id do not receive error responses if is_notification: diff --git a/tests/test_mcp.py b/tests/test_mcp.py index a033225..1363136 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -558,4 +558,342 @@ async def test_mcp_call_get_entity_missing_id(mock_hass): assert "Missing required argument" in res["result"]["content"][0]["text"] +# =========================================================================== +# Task 3: Mutation & Action Tool Tests +# =========================================================================== + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_exposure(mock_hass): + """Test set_entity_exposure successfully updates entity exposure.""" + import json + + req = { + "jsonrpc": "2.0", + "id": 20, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": ["light.living_room_lights"], "exposed": True}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + content = json.loads(res["result"]["content"][0]["text"]) + assert content["status"] == "success" + assert content["updated"] == ["light.living_room_lights"] + + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_exposure_string_coercion(mock_hass): + """Test set_entity_exposure coerces single string entity_ids to list.""" + import json + + req = { + "jsonrpc": "2.0", + "id": 201, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": "light.single_light", "exposed": False}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + content = json.loads(res["result"]["content"][0]["text"]) + assert content["status"] == "success" + assert content["updated"] == ["light.single_light"] + + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_exposure_invalid_args(mock_hass): + """Test set_entity_exposure errors on missing or invalid arguments.""" + # Missing entity_ids + req1 = { + "jsonrpc": "2.0", + "id": 202, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"exposed": True}, + }, + } + res1 = await async_handle_mcp_payload(mock_hass, req1) + assert res1["result"].get("isError") is True + + # Missing exposed boolean + req2 = { + "jsonrpc": "2.0", + "id": 203, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": ["light.test"]}, + }, + } + res2 = await async_handle_mcp_payload(mock_hass, req2) + assert res2["result"].get("isError") is True + + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_nicknames(mock_hass): + """Test set_entity_nicknames with replace, append, and remove modes.""" + import json + from unittest.mock import MagicMock, patch + + mock_entry = MagicMock() + mock_entry.entity_id = "light.living_room_lights" + mock_entry.aliases = ["Old Name"] + mock_ent_reg = MagicMock() + mock_ent_reg.async_get.return_value = mock_entry + + with patch( + "custom_components.google_assistant_entity_console.mcp.entity_registry.async_get", + return_value=mock_ent_reg, + ): + # 1. Replace mode + req = { + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": { + "entity_id": "light.living_room_lights", + "nicknames": ["Chandelier", "Main Lights"], + "mode": "replace", + }, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + content = json.loads(res["result"]["content"][0]["text"]) + assert content["nicknames"] == ["Chandelier", "Main Lights"] + + # 2. Append mode + mock_entry.aliases = ["Chandelier", "Main Lights"] + req_append = { + "jsonrpc": "2.0", + "id": 211, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": { + "entity_id": "light.living_room_lights", + "nicknames": ["Ceiling Fan Light", "Chandelier"], # deduplicate + "mode": "append", + }, + }, + } + res_append = await async_handle_mcp_payload(mock_hass, req_append) + assert not res_append["result"].get("isError", False) + content_append = json.loads(res_append["result"]["content"][0]["text"]) + assert content_append["nicknames"] == ["Chandelier", "Main Lights", "Ceiling Fan Light"] + + # 3. Remove mode + mock_entry.aliases = ["Chandelier", "Main Lights", "Ceiling Fan Light"] + req_remove = { + "jsonrpc": "2.0", + "id": 212, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": { + "entity_id": "light.living_room_lights", + "nicknames": ["Chandelier"], + "mode": "remove", + }, + }, + } + res_remove = await async_handle_mcp_payload(mock_hass, req_remove) + assert not res_remove["result"].get("isError", False) + content_remove = json.loads(res_remove["result"]["content"][0]["text"]) + assert content_remove["nicknames"] == ["Main Lights", "Ceiling Fan Light"] + + +@pytest.mark.asyncio +async def test_mcp_call_set_entity_nicknames_errors(mock_hass): + """Test set_entity_nicknames validation errors.""" + # Missing entity_id + req1 = { + "jsonrpc": "2.0", + "id": 213, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": {"nicknames": ["Test"]}, + }, + } + res1 = await async_handle_mcp_payload(mock_hass, req1) + assert res1["result"].get("isError") is True + + # Missing nicknames + req2 = { + "jsonrpc": "2.0", + "id": 214, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": {"entity_id": "light.test"}, + }, + } + res2 = await async_handle_mcp_payload(mock_hass, req2) + assert res2["result"].get("isError") is True + + # Invalid mode + req3 = { + "jsonrpc": "2.0", + "id": 215, + "method": "tools/call", + "params": { + "name": "set_entity_nicknames", + "arguments": { + "entity_id": "light.test", + "nicknames": ["Test"], + "mode": "invalid_mode", + }, + }, + } + res3 = await async_handle_mcp_payload(mock_hass, req3) + assert res3["result"].get("isError") is True + + +@pytest.mark.asyncio +async def test_mcp_call_manage_blocklist_rule(mock_hass): + """Test manage_blocklist_rule adds and removes patterns.""" + import json + from unittest.mock import patch + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_load_blocklist", + return_value=[], + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_save_blocklist", + ) as mock_save: + # Add rule + req_add = { + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": { + "name": "manage_blocklist_rule", + "arguments": {"action": "add", "pattern": ".*_battery_level$"}, + }, + } + res_add = await async_handle_mcp_payload(mock_hass, req_add) + assert res_add["jsonrpc"] == "2.0" + assert not res_add["result"].get("isError", False) + content_add = json.loads(res_add["result"]["content"][0]["text"]) + assert content_add["status"] == "success" + assert content_add["action"] == "add" + assert ".*_battery_level$" in content_add["blocklist"] + assert "affected_entities_count" in content_add + mock_save.assert_called_once() + + # Remove rule + with patch( + "custom_components.google_assistant_entity_console.mcp.async_load_blocklist", + return_value=[".*_battery_level$"], + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_save_blocklist", + ) as mock_save_remove: + req_rem = { + "jsonrpc": "2.0", + "id": 221, + "method": "tools/call", + "params": { + "name": "manage_blocklist_rule", + "arguments": {"action": "remove", "pattern": ".*_battery_level$"}, + }, + } + res_rem = await async_handle_mcp_payload(mock_hass, req_rem) + assert not res_rem["result"].get("isError", False) + content_rem = json.loads(res_rem["result"]["content"][0]["text"]) + assert content_rem["action"] == "remove" + assert ".*_battery_level$" not in content_rem["blocklist"] + mock_save_remove.assert_called_once() + + +@pytest.mark.asyncio +async def test_mcp_call_manage_blocklist_rule_invalid_regex(mock_hass): + """Test manage_blocklist_rule rejects invalid regex pattern.""" + req = { + "jsonrpc": "2.0", + "id": 222, + "method": "tools/call", + "params": { + "name": "manage_blocklist_rule", + "arguments": {"action": "add", "pattern": "[invalid(regex"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["result"].get("isError") is True + assert "Invalid regular expression pattern" in res["result"]["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_mcp_call_manage_blocklist_rule_invalid_action(mock_hass): + """Test manage_blocklist_rule rejects invalid action.""" + req = { + "jsonrpc": "2.0", + "id": 223, + "method": "tools/call", + "params": { + "name": "manage_blocklist_rule", + "arguments": {"action": "destroy", "pattern": ".*_battery$"}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["result"].get("isError") is True + + +@pytest.mark.asyncio +async def test_mcp_call_sync_to_google(mock_hass): + """Test sync_to_google triggers runtime sync.""" + import json + + req = { + "jsonrpc": "2.0", + "id": 23, + "method": "tools/call", + "params": {"name": "sync_to_google", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert not res["result"].get("isError", False) + content = json.loads(res["result"]["content"][0]["text"]) + assert content["status"] in ("success", "error") + assert "status_code" in content + assert "timestamp" in content + + +@pytest.mark.asyncio +async def test_mcp_call_top_level_exception_handling(mock_hass): + """Test top-level try...except catches unhandled exceptions and returns isError.""" + from unittest.mock import patch + + with patch( + "custom_components.google_assistant_entity_console.mcp._handle_set_entity_exposure", + side_effect=RuntimeError("Simulated catastrophic crash"), + ): + req = { + "jsonrpc": "2.0", + "id": 99, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": ["light.test"], "exposed": True}, + }, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + assert res["id"] == 99 + assert res["result"].get("isError") is True + assert "Simulated catastrophic crash" in res["result"]["content"][0]["text"] + + + From db3c59e2d7ab9275932ede4dbb08daf63c8aa0ea Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:29:44 -0500 Subject: [PATCH 06/10] feat(mcp): expose McpStreamableView at /api/google_assistant_entity_console/mcp --- .../__init__.py | 2 + .../google_assistant_entity_console/views.py | 55 ++++++ tests/test_config_flow.py | 6 +- tests/test_mcp.py | 181 ++++++++++++++++++ 4 files changed, 242 insertions(+), 2 deletions(-) diff --git a/custom_components/google_assistant_entity_console/__init__.py b/custom_components/google_assistant_entity_console/__init__.py index 43a1bf9..2bcecfb 100644 --- a/custom_components/google_assistant_entity_console/__init__.py +++ b/custom_components/google_assistant_entity_console/__init__.py @@ -22,6 +22,7 @@ BlocklistView, EntitiesView, GoogleSettingsView, + McpStreamableView, MigrateYamlView, RebuildView, RestartView, @@ -91,6 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.http.register_view(AIGenerateNicknamesView()) hass.http.register_view(AISuggestExposureView()) hass.http.register_view(AIGenerateSingleEntityNicknameView()) + hass.http.register_view(McpStreamableView()) # 4. Register Sidebar Panel async_remove_panel(hass, DOMAIN, warn_if_unknown=False) diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py index 78b0f9d..b662291 100644 --- a/custom_components/google_assistant_entity_console/views.py +++ b/custom_components/google_assistant_entity_console/views.py @@ -1234,3 +1234,58 @@ async def post(self, request: web.Request) -> web.Response: except Exception as err: _LOGGER.exception("Failed to generate single nickname") return self.json({"error": str(err)}, status_code=500) + + +class McpStreamableView(HomeAssistantView): + """MCP Streamable HTTP view endpoint adhering to MCP 2026-07-28.""" + + url = "/api/google_assistant_entity_console/mcp" + name = "api:google_assistant_entity_console:mcp" + requires_auth = True + + async def post(self, request: web.Request) -> web.StreamResponse: + """Handle incoming MCP JSON-RPC 2.0 requests over Streamable HTTP transport.""" + from .mcp import MCP_PROTOCOL_VERSION, async_handle_mcp_payload + + try: + payload = await request.json() + except Exception: + return web.json_response( + { + "jsonrpc": "2.0", + "error": {"code": -32700, "message": "Parse error"}, + "id": None, + }, + status=400, + headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, + ) + + res = await async_handle_mcp_payload(request.app["hass"], payload) + + if res is None: + return web.Response( + status=204, + headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, + ) + + accept_header = request.headers.get("Accept", "") + if "text/event-stream" in accept_header: + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Mcp-Protocol-Version": MCP_PROTOCOL_VERSION, + }, + ) + await response.prepare(request) + sse_payload = f"event: message\ndata: {json.dumps(res)}\n\n".encode("utf-8") + await response.write(sse_payload) + await response.write_eof() + return response + + return web.json_response( + res, + headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, + ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 32fd4e1..fbd5f39 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -25,6 +25,7 @@ BlocklistView, EntitiesView, GoogleSettingsView, + McpStreamableView, MigrateYamlView, RebuildView, RestartView, @@ -157,8 +158,8 @@ async def test_async_setup_entry_success(lifecycle_hass): ) assert static_path_config.cache_headers is True - # 2. Verify registration of all 15 API views in exact order - assert lifecycle_hass.http.register_view.call_count == 15 + # 2. Verify registration of all 16 API views in exact order + assert lifecycle_hass.http.register_view.call_count == 16 registered_classes = [ call.args[0].__class__ for call in lifecycle_hass.http.register_view.call_args_list @@ -179,6 +180,7 @@ async def test_async_setup_entry_success(lifecycle_hass): AIGenerateNicknamesView, AISuggestExposureView, AIGenerateSingleEntityNicknameView, + McpStreamableView, ] assert registered_classes == expected_classes diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1363136..95f2013 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -9,6 +9,7 @@ TOOL_DEFINITIONS, async_handle_mcp_payload, ) +from custom_components.google_assistant_entity_console.views import McpStreamableView @pytest.mark.asyncio @@ -895,5 +896,185 @@ async def test_mcp_call_top_level_exception_handling(mock_hass): assert "Simulated catastrophic crash" in res["result"]["content"][0]["text"] +# =========================================================================== +# Task 4: Home Assistant HTTP View Integration (McpStreamableView) Tests +# =========================================================================== + +@pytest.mark.asyncio +async def test_mcp_view_attributes(): + """Test McpStreamableView URL, name, and auth requirement attributes.""" + view = McpStreamableView() + assert view.url == "/api/google_assistant_entity_console/mcp" + assert view.name == "api:google_assistant_entity_console:mcp" + assert view.requires_auth is True + + +@pytest.mark.asyncio +async def test_mcp_view_post_json(mock_hass): + """Test valid JSON-RPC POST returns 200 with Mcp-Protocol-Version header and JSON body.""" + import json + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Accept": "application/json", "Content-Type": "application/json"}, + app=app, + ) + req._read_bytes = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "ping"}).encode("utf-8") + + view = McpStreamableView() + resp = await view.post(req) + + assert resp.status == 200 + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + assert resp.content_type == "application/json" + body = json.loads(resp.body.decode("utf-8")) + assert body == {"jsonrpc": "2.0", "id": 1, "result": {}} + + +@pytest.mark.asyncio +async def test_mcp_view_post_invalid_json(mock_hass): + """Test invalid JSON POST returns HTTP 400 with -32700 parse error.""" + import json + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Content-Type": "application/json"}, + app=app, + ) + req._read_bytes = b"invalid json content {" + + view = McpStreamableView() + resp = await view.post(req) + + assert resp.status == 400 + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + body = json.loads(resp.body.decode("utf-8")) + assert body.get("jsonrpc") == "2.0" + assert body.get("error", {}).get("code") == -32700 + assert "Parse error" in body.get("error", {}).get("message", "") + + +@pytest.mark.asyncio +async def test_mcp_view_post_sse_stream(mock_hass): + """Test Accept: text/event-stream returns 200 with SSE formatted event.""" + import json + from unittest.mock import patch + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Accept": "text/event-stream", "Content-Type": "application/json"}, + app=app, + ) + req._read_bytes = json.dumps( + { + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": {"protocolVersion": "2026-07-28"}, + } + ).encode("utf-8") + + written_chunks = [] + orig_write = web.StreamResponse.write + + async def mock_write(self, data): + written_chunks.append(data) + return await orig_write(self, data) + + view = McpStreamableView() + with patch.object(web.StreamResponse, "write", mock_write): + resp = await view.post(req) + + assert resp.status == 200 + assert resp.headers.get("Content-Type") == "text/event-stream" + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + assert resp.headers.get("Cache-Control") == "no-cache" + assert resp.headers.get("Connection") == "keep-alive" + + assert len(written_chunks) == 1 + chunk_str = written_chunks[0].decode("utf-8") + assert chunk_str.startswith("event: message\ndata: ") + assert chunk_str.endswith("\n\n") + + event_payload = json.loads(chunk_str[len("event: message\ndata: ") : -2]) + assert event_payload["jsonrpc"] == "2.0" + assert event_payload["id"] == 2 + assert event_payload["result"]["protocolVersion"] == "2026-07-28" + + +@pytest.mark.asyncio +async def test_mcp_view_post_notification_204(mock_hass): + """Test JSON-RPC notification returns HTTP 204 No Content.""" + import json + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Content-Type": "application/json"}, + app=app, + ) + req._read_bytes = json.dumps( + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ).encode("utf-8") + + view = McpStreamableView() + resp = await view.post(req) + + assert resp.status == 204 + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + + +@pytest.mark.asyncio +async def test_mcp_view_registration_in_setup_entry(mock_hass): + """Test McpStreamableView is registered during async_setup_entry.""" + from unittest.mock import AsyncMock, MagicMock, patch + from custom_components.google_assistant_entity_console import async_setup_entry + + mock_entry = MagicMock() + mock_hass.http.register_view = MagicMock() + mock_hass.http.async_register_static_paths = AsyncMock() + + with patch( + "custom_components.google_assistant_entity_console.async_remove_panel" + ), patch( + "custom_components.google_assistant_entity_console.async_register_built_in_panel" + ), patch( + "custom_components.google_assistant_entity_console.async_get_store", + new_callable=AsyncMock, + ), patch( + "custom_components.google_assistant_entity_console.async_get_runtime", + new_callable=AsyncMock, + ), patch( + "custom_components.google_assistant_entity_console.async_build_entity_config_map", + new_callable=AsyncMock, + ): + result = await async_setup_entry(mock_hass, mock_entry) + assert result is True + + registered_views = [ + call.args[0] for call in mock_hass.http.register_view.call_args_list + ] + assert any(isinstance(v, McpStreamableView) for v in registered_views) + + From fc9b5dbf070a459cba937285958b7b81a4645de6 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:32:26 -0500 Subject: [PATCH 07/10] docs(mcp): document native MCP server endpoint and configuration --- README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 41 +++++++++++++++++--- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2a921b0..43af322 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Key Features • Installation • Configuration • + MCP Server • AI Features • Documentation • FAQ @@ -42,6 +43,7 @@ Self-hosted Google Assistant in Home Assistant traditionally requires manual YAM - **Inline Renaming**: Edit entity display names directly on the row. - **Voice Nickname Chips**: View, add, or delete voice aliases (nicknames) with chip management. - **Regex Blocklist Manager**: Filter out entities or domain patterns (e.g. `.*_battery_level$`) using regular expression rules with live validation. +- **Native Model Context Protocol (MCP) Server**: Exposes an MCP 2026-07-28 Streamable HTTP endpoint for AI coding agents and LLM gateways (Antigravity, Claude, MCG) to query and manage entities, nicknames, blocklists, and sync live to Google HomeGraph. - **AI Assistant Integration**: - Suggest entity exposure based on natural-language requests (e.g., *"Expose all living room lights and climate controls"*). - Bulk and room-aware voice nickname generation using local LLMs (Ollama, LocalAI, vLLM), cloud endpoints (OpenAI, OpenRouter, Groq), or Home Assistant Assist conversation agents. @@ -98,6 +100,94 @@ Once installed and Home Assistant has restarted: --- +## MCP Server (Model Context Protocol) + +Google Assistant Entity Console includes a native **Model Context Protocol (MCP)** server adhering to the MCP specification revision **`2026-07-28` (Streamable HTTP Transport)**. + +This allows external AI coding assistants (such as Google Antigravity, Claude Desktop, Cursor) and orchestrators such as Model Context Gateway (MCG) to inspect, manage, and synchronize your Google Assistant entities programmatically without custom wrappers. + +### Endpoint & Authentication + +- **Endpoint**: `POST /api/google_assistant_entity_console/mcp` +- **Transport**: Streamable HTTP (`stateless`, JSON-RPC 2.0; supports standard JSON responses and `Accept: text/event-stream` SSE streaming) +- **Protocol Version**: `2026-07-28` (returned in the `Mcp-Protocol-Version` HTTP header) +- **Authentication**: Standard Home Assistant Long-Lived Access Token via HTTP header: + ```http + Authorization: Bearer + ``` + +### Available Tools (7 Native Tools) + +| Tool | Description | Key Parameters | +| :--- | :--- | :--- | +| `list_entities` | Queries entities registered in Home Assistant along with their Google Assistant exposure status, area, floor, and aliases. | `domain`, `area`, `floor`, `exposure_status` (`all`, `exposed`, `not_exposed`, `blocked`), `search` | +| `get_entity` | Retrieves detailed Google Assistant configuration, names, aliases, and registry metadata for a single entity. | `entity_id` (required) | +| `get_google_assistant_status` | Checks cloud project status, credentials state, auto-sync setting, and exposure/synced counts. | *None* | +| `set_entity_exposure` | Exposes or unexposes one or more entities to Google Assistant in `.storage` and live memory. | `entity_ids` (required, array), `exposed` (required, boolean) | +| `set_entity_nicknames` | Modifies voice aliases used by Google Assistant for voice matching. | `entity_id` (required), `nicknames` (required, array), `mode` (`replace`, `append`, `remove`) | +| `manage_blocklist_rule` | Adds or removes regular expression blocklist filters. | `action` (`add`, `remove`), `pattern` (e.g. `.*_battery_level$`) | +| `sync_to_google` | Triggers a real-time zero-reboot `devices:requestSync` call to Google HomeGraph. | *None* | + +### Client Configuration Examples + +#### 1. Model Context Gateway (MCG) / Antigravity (`agy`) / HTTP Clients +In your MCP configuration file (e.g. `mcg.config.json` or Antigravity MCP settings): + +```json +{ + "mcpServers": { + "google-assistant-entity-console": { + "url": "http://:8123/api/google_assistant_entity_console/mcp", + "transport": "http", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +#### 2. Claude Desktop / Local CLI Bridge (via `mcp-remote`) +If using an MCP proxy runner such as `mcp-remote`: + +```json +{ + "mcpServers": { + "google-assistant-entity-console": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://:8123/api/google_assistant_entity_console/mcp", + "--header", + "Authorization: Bearer " + ] + } + } +} +``` + +#### 3. Direct JSON-RPC 2.0 Request (cURL) +```bash +curl -X POST "http://:8123/api/google_assistant_entity_console/mcp" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "list_entities", + "arguments": { + "domain": "light", + "exposure_status": "exposed" + } + }, + "id": 1 + }' +``` + +--- + ## AI Assistant Integration The console supports optional AI-assisted workflows using local or remote endpoints: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e487b66..b052397 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Google Assistant Entity Console Architecture (v3.0.0) +# Google Assistant Entity Console Architecture (v3.0.2) This document provides a technical overview of the **Google Assistant Entity Console** Home Assistant custom component architecture. @@ -6,18 +6,24 @@ This document provides a technical overview of the **Google Assistant Entity Con ## 1. System Overview -Google Assistant Entity Console is a Home Assistant custom component that provides a sidebar management dashboard to configure entities exposed to Google Assistant, manage voice aliases, and maintain regex blocklists with **zero-reboot `.storage`-native live syncing**. +Google Assistant Entity Console is a Home Assistant custom component that provides a sidebar management dashboard to configure entities exposed to Google Assistant, manage voice aliases, and maintain regex blocklists with **zero-reboot `.storage`-native live syncing** and a **native Model Context Protocol (MCP) server**. -It operates inside the Home Assistant process, combining an `aiohttp` HTTP API backend with a single-page web application served inside an iframe panel. +It operates inside the Home Assistant process, combining an `aiohttp` HTTP API backend, an MCP 2026-07-28 Streamable HTTP endpoint, and a single-page web application served inside an iframe panel. ```mermaid graph TD + subgraph ExternalClients ["External Clients & Agents"] + Agent["AI Agents (Antigravity, Claude Desktop, MCG)"] + BrowserUser["User Browser (HA Sidebar)"] + end + subgraph HomeAssistantBackend ["Home Assistant Backend"] HARegistries["Entity, Device, Area & Floor Registries"] HACore["Home Assistant Core (GoogleConfig, RequestSync)"] Store["ConsoleStore (.storage/google_assistant_entity_console)"] Runtime["GoogleAssistantRuntimeManager (In-Memory GoogleConfig)"] ViewsPy["views.py (aiohttp HomeAssistantView Endpoints)"] + MCP["mcp.py + McpStreamableView (MCP 2026-07-28 Streamable HTTP)"] end subgraph FrontendSidebar ["Frontend (Sidebar Panel)"] @@ -27,6 +33,11 @@ graph TD StyleCSS["style.css (Material Design 3 + HA CSS Variables)"] end + BrowserUser --> PanelJS + Agent -- JSON-RPC 2.0 / POST /api/.../mcp --> MCP + MCP <--> Store + MCP <--> Runtime + MCP --> HARegistries HARegistries --> ViewsPy HACore --> Runtime Runtime <--> Store @@ -46,11 +57,11 @@ The integration entrypoint is defined in [`custom_components/google_assistant_en ### Setup Sequence: 1. **Async Setup Entry** (`async_setup_entry`): - - Reads integration manifest version (`3.0.0`). + - Reads integration manifest version (`3.0.2`). - Initializes `ConsoleStore` via `async_get_store(hass)` to load or migrate credentials, blocklists, and sync state from `.storage/google_assistant_entity_console`. - Initializes `GoogleAssistantRuntimeManager` via `async_get_runtime(hass)` and hooks in-memory `GoogleConfig._config["entity_config"]`. - Registers static HTTP paths (`/google_assistant_entity_console/static`) pointing to the component's `static/` directory. - - Registers all 15 backend API HTTP views on `hass.http`. + - Registers all 16 backend API HTTP views on `hass.http` (including REST views and `McpStreamableView`). - Registers the custom sidebar panel (`async_register_panel`) pointing to `panel.js`. 2. **Unload Entry** (`async_unload_entry`): - Removes the sidebar panel (`async_remove_panel`). @@ -76,6 +87,25 @@ Starting in v3.0.0, persistence is managed entirely by [`ConsoleStore`](file:/// - **Zero-Reboot Sync**: `runtime.async_sync_to_google()` calls `google_config.async_sync_entities_all()`, issuing Google Cloud's `devices:requestSync` API to update Google HomeGraph live. - **Debounced Auto-Sync**: An optional 15-second debounce automatically syncs entity updates in the background. +### 3.3 Native Model Context Protocol (MCP) Subsystem (`mcp.py` + `McpStreamableView`) +Starting in v3.0.2, Google Assistant Entity Console provides a native MCP server implementing the **2026-07-28 Streamable HTTP Transport**: +- **Transport & Lifecycle**: + - [`McpStreamableView`](file:///home/steve/Repos/GoogleAssistantEntityConsole/custom_components/google_assistant_entity_console/views.py) listens on `POST /api/google_assistant_entity_console/mcp` requiring standard Home Assistant Bearer token authentication (`requires_auth = True`). + - Supports stateless JSON-RPC 2.0 request/response cycles as well as `Accept: text/event-stream` SSE streams. + - Implements the MCP initialization handshake (`initialize` returning server capabilities, protocol version `2026-07-28`, and server metadata `google-assistant-entity-console` v3.0.2; `notifications/initialized`). + - Implements JSON-RPC ping (`ping`) and notification handling (`notifications/*` returning HTTP 204 No Content). + - Validates protocol headers and includes `Mcp-Protocol-Version: 2026-07-28` in all HTTP responses. +- **Tool Registry (`tools/list` & `tools/call`)**: + - Exposes 7 tools with strict JSON Schema definitions and cache TTL metadata (`TOOL_CACHE_TTL_MS = 300000`). + - Directly wires into `ConsoleStore` ([`store.py`](file:///home/steve/Repos/GoogleAssistantEntityConsole/custom_components/google_assistant_entity_console/store.py)) and `GoogleAssistantRuntimeManager` ([`runtime.py`](file:///home/steve/Repos/GoogleAssistantEntityConsole/custom_components/google_assistant_entity_console/runtime.py)): + - `list_entities`: Queries Home Assistant's `entity_registry`, `device_registry`, `area_registry`, `floor_registry`, blocklist rules, and active runtime config with filtering by domain, area, floor, exposure status (`all`, `exposed`, `not_exposed`, `blocked`), and text search. + - `get_entity`: Retrieves full registry metadata, active exposure flags, voice aliases, and blocklist match for an individual entity. + - `get_google_assistant_status`: Inspects `.storage` cloud credentials state, auto-sync setting, and counts for exposed vs synced entities. + - `set_entity_exposure`: Bulk exposes or excludes entities in `.storage` and immediately refreshes in-memory runtime via `runtime.async_refresh_memory_config()`. + - `set_entity_nicknames`: Mutates entity voice aliases (`replace`, `append`, `remove`) in `.storage` and refreshes runtime memory. + - `manage_blocklist_rule`: Adds or removes regular expression patterns via `async_save_blocklist()`. + - `sync_to_google`: Calls `runtime.async_sync_to_google()` to push `devices:requestSync` live to Google HomeGraph. + --- ## 4. Frontend Architecture @@ -116,3 +146,4 @@ static/ | `/api/google_assistant_entity_console/ai/generate_nicknames` | `POST` | Bulk nickname generation for entity array | | `/api/google_assistant_entity_console/ai/generate_single_nickname` | `POST` | Context-aware single entity nickname generation | | `/api/google_assistant_entity_console/ai/suggest_exposure` | `POST` | Intent-driven entity selection | +| `/api/google_assistant_entity_console/mcp` | `POST` | Native Model Context Protocol (MCP 2026-07-28) Streamable HTTP JSON-RPC endpoint | From f9e2383c9d9fc99fa5228307af5d0596be12ff59 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:39:21 -0500 Subject: [PATCH 08/10] fix(mcp): resolve lint sorting, exception boundary, and blocklist parity --- .../google_assistant_entity_console/mcp.py | 10 +- .../google_assistant_entity_console/views.py | 14 ++- tests/test_mcp.py | 112 ++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/custom_components/google_assistant_entity_console/mcp.py b/custom_components/google_assistant_entity_console/mcp.py index b273ae0..12ab5f2 100644 --- a/custom_components/google_assistant_entity_console/mcp.py +++ b/custom_components/google_assistant_entity_console/mcp.py @@ -8,10 +8,10 @@ from __future__ import annotations import contextlib -from datetime import datetime, timezone import json import logging import re +from datetime import datetime, timezone from typing import Any from homeassistant.core import HomeAssistant @@ -487,6 +487,8 @@ async def _handle_get_google_assistant_status( blocklisted_count = 0 if compiled_blocklist: for entry in ent_values: + if getattr(entry, "disabled_by", None) or getattr(entry, "hidden_by", None): + continue eid = getattr(entry, "entity_id", "") if any(rx.search(eid) for rx in compiled_blocklist): blocklisted_count += 1 @@ -525,6 +527,12 @@ async def _handle_set_entity_exposure(hass: HomeAssistant, args: dict[str, Any]) "isError": True, } + if not entity_ids: + return { + "content": [{"type": "text", "text": "Argument 'entity_ids' must contain at least one entity ID"}], + "isError": True, + } + if "exposed" not in args or not isinstance(args.get("exposed"), bool): return { "content": [{"type": "text", "text": "Missing or invalid required argument: exposed (boolean)"}], diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py index b662291..cd18723 100644 --- a/custom_components/google_assistant_entity_console/views.py +++ b/custom_components/google_assistant_entity_console/views.py @@ -1260,7 +1260,19 @@ async def post(self, request: web.Request) -> web.StreamResponse: headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, ) - res = await async_handle_mcp_payload(request.app["hass"], payload) + try: + res = await async_handle_mcp_payload(request.app["hass"], payload) + except Exception as err: + _LOGGER.exception("Unexpected error in MCP endpoint: %s", err) + return web.json_response( + { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": f"Internal error: {err}"}, + "id": payload.get("id") if isinstance(payload, dict) else None, + }, + status=500, + headers={"Mcp-Protocol-Version": "2026-07-28"}, + ) if res is None: return web.Response( diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 95f2013..11f7e29 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -409,6 +409,64 @@ async def test_mcp_call_get_google_assistant_status_fields(mock_hass): assert "last_sync_timestamp" in status +@pytest.mark.asyncio +async def test_mcp_call_get_google_assistant_status_blocklist_parity(mock_hass): + """Test get_google_assistant_status blocklisted_count filters out disabled and hidden entities.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + entry_active = MagicMock() + entry_active.entity_id = "sensor.active_battery" + entry_active.disabled_by = None + entry_active.hidden_by = None + + entry_disabled = MagicMock() + entry_disabled.entity_id = "sensor.disabled_battery" + entry_disabled.disabled_by = "user" + entry_disabled.hidden_by = None + + entry_hidden = MagicMock() + entry_hidden.entity_id = "sensor.hidden_battery" + entry_hidden.disabled_by = None + entry_hidden.hidden_by = "integration" + + mock_ent_reg = MagicMock() + mock_ent_reg.entities = { + "sensor.active_battery": entry_active, + "sensor.disabled_battery": entry_disabled, + "sensor.hidden_battery": entry_hidden, + } + + mock_store = MagicMock() + mock_store.google_settings = {} + mock_store.synced_entities = {} + + with patch( + "custom_components.google_assistant_entity_console.mcp.async_get_store", + AsyncMock(return_value=mock_store), + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_fetch_entities_data", + AsyncMock(return_value=[]), + ), patch( + "custom_components.google_assistant_entity_console.mcp.async_load_blocklist", + return_value=[r".*_battery$"], + ), patch( + "custom_components.google_assistant_entity_console.mcp.entity_registry.async_get", + return_value=mock_ent_reg, + ): + req = { + "jsonrpc": "2.0", + "id": 41, + "method": "tools/call", + "params": {"name": "get_google_assistant_status", "arguments": {}}, + } + res = await async_handle_mcp_payload(mock_hass, req) + assert res["jsonrpc"] == "2.0" + status = json.loads(res["result"]["content"][0]["text"]) + # Only active_battery should be counted (1), not disabled or hidden (total 3 matching regex) + assert status["blocklisted_count"] == 1 + + @pytest.mark.asyncio async def test_mcp_call_list_entities_blocked_exposure_status(mock_hass): """Test list_entities returns blocked entities when exposure_status='blocked'.""" @@ -636,6 +694,20 @@ async def test_mcp_call_set_entity_exposure_invalid_args(mock_hass): res2 = await async_handle_mcp_payload(mock_hass, req2) assert res2["result"].get("isError") is True + # Empty entity_ids list + req3 = { + "jsonrpc": "2.0", + "id": 204, + "method": "tools/call", + "params": { + "name": "set_entity_exposure", + "arguments": {"entity_ids": [], "exposed": True}, + }, + } + res3 = await async_handle_mcp_payload(mock_hass, req3) + assert res3["result"].get("isError") is True + assert "Argument 'entity_ids' must contain at least one entity ID" in res3["result"]["content"][0]["text"] + @pytest.mark.asyncio async def test_mcp_call_set_entity_nicknames(mock_hass): @@ -913,6 +985,7 @@ async def test_mcp_view_attributes(): async def test_mcp_view_post_json(mock_hass): """Test valid JSON-RPC POST returns 200 with Mcp-Protocol-Version header and JSON body.""" import json + from aiohttp import web from aiohttp.test_utils import make_mocked_request @@ -940,6 +1013,7 @@ async def test_mcp_view_post_json(mock_hass): async def test_mcp_view_post_invalid_json(mock_hass): """Test invalid JSON POST returns HTTP 400 with -32700 parse error.""" import json + from aiohttp import web from aiohttp.test_utils import make_mocked_request @@ -969,6 +1043,7 @@ async def test_mcp_view_post_sse_stream(mock_hass): """Test Accept: text/event-stream returns 200 with SSE formatted event.""" import json from unittest.mock import patch + from aiohttp import web from aiohttp.test_utils import make_mocked_request @@ -1021,6 +1096,7 @@ async def mock_write(self, data): async def test_mcp_view_post_notification_204(mock_hass): """Test JSON-RPC notification returns HTTP 204 No Content.""" import json + from aiohttp import web from aiohttp.test_utils import make_mocked_request @@ -1043,10 +1119,46 @@ async def test_mcp_view_post_notification_204(mock_hass): assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" +@pytest.mark.asyncio +async def test_mcp_view_post_internal_error(mock_hass): + """Test unexpected exception in async_handle_mcp_payload returns HTTP 500 with -32603 internal error.""" + import json + from unittest.mock import patch + + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Content-Type": "application/json"}, + app=app, + ) + req._read_bytes = json.dumps({"jsonrpc": "2.0", "id": 999, "method": "ping"}).encode("utf-8") + + view = McpStreamableView() + with patch( + "custom_components.google_assistant_entity_console.mcp.async_handle_mcp_payload", + side_effect=RuntimeError("Database failure"), + ): + resp = await view.post(req) + + assert resp.status == 500 + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + body = json.loads(resp.body.decode("utf-8")) + assert body.get("jsonrpc") == "2.0" + assert body.get("id") == 999 + assert body.get("error", {}).get("code") == -32603 + assert "Internal error: Database failure" in body.get("error", {}).get("message", "") + + @pytest.mark.asyncio async def test_mcp_view_registration_in_setup_entry(mock_hass): """Test McpStreamableView is registered during async_setup_entry.""" from unittest.mock import AsyncMock, MagicMock, patch + from custom_components.google_assistant_entity_console import async_setup_entry mock_entry = MagicMock() From f5d76b01a7ca75c003618c512e907b85bba57e99 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:47:14 -0500 Subject: [PATCH 09/10] fix(mcp): optimize ping GC bypass, handle disconnects, and add GET health check --- .../google_assistant_entity_console/views.py | 44 ++++++++-- tests/test_mcp.py | 86 +++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/custom_components/google_assistant_entity_console/views.py b/custom_components/google_assistant_entity_console/views.py index cd18723..a08d47f 100644 --- a/custom_components/google_assistant_entity_console/views.py +++ b/custom_components/google_assistant_entity_console/views.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -1243,8 +1244,26 @@ class McpStreamableView(HomeAssistantView): name = "api:google_assistant_entity_console:mcp" requires_auth = True + async def get(self, request: web.Request) -> web.Response: + """Health check and server discovery for gateways (like MCG) and monitoring.""" + from .mcp import MCP_PROTOCOL_VERSION + + return web.json_response( + { + "status": "healthy", + "protocolVersion": MCP_PROTOCOL_VERSION, + "server": "google-assistant-entity-console", + }, + headers={ + "Mcp-Protocol-Version": MCP_PROTOCOL_VERSION, + "Cache-Control": "no-cache, no-transform", + }, + ) + async def post(self, request: web.Request) -> web.StreamResponse: """Handle incoming MCP JSON-RPC 2.0 requests over Streamable HTTP transport.""" + import contextlib + from .mcp import MCP_PROTOCOL_VERSION, async_handle_mcp_payload try: @@ -1271,7 +1290,7 @@ async def post(self, request: web.Request) -> web.StreamResponse: "id": payload.get("id") if isinstance(payload, dict) else None, }, status=500, - headers={"Mcp-Protocol-Version": "2026-07-28"}, + headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, ) if res is None: @@ -1280,8 +1299,11 @@ async def post(self, request: web.Request) -> web.StreamResponse: headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, ) + # Health/keepalive pings return immediate JSON without allocating SSE stream buffers + is_ping = isinstance(payload, dict) and payload.get("method") == "ping" + accept_header = request.headers.get("Accept", "") - if "text/event-stream" in accept_header: + if "text/event-stream" in accept_header and not is_ping: response = web.StreamResponse( status=200, headers={ @@ -1291,13 +1313,21 @@ async def post(self, request: web.Request) -> web.StreamResponse: "Mcp-Protocol-Version": MCP_PROTOCOL_VERSION, }, ) - await response.prepare(request) - sse_payload = f"event: message\ndata: {json.dumps(res)}\n\n".encode("utf-8") - await response.write(sse_payload) - await response.write_eof() + try: + await response.prepare(request) + sse_payload = f"event: message\ndata: {json.dumps(res)}\n\n".encode("utf-8") + await response.write(sse_payload) + except (ConnectionResetError, asyncio.CancelledError, Exception) as write_err: + _LOGGER.debug("Client closed stream during SSE write: %s", write_err) + finally: + with contextlib.suppress(Exception): + await response.write_eof() return response return web.json_response( res, - headers={"Mcp-Protocol-Version": MCP_PROTOCOL_VERSION}, + headers={ + "Mcp-Protocol-Version": MCP_PROTOCOL_VERSION, + "Cache-Control": "no-cache", + }, ) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 11f7e29..8640ae7 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1188,5 +1188,91 @@ async def test_mcp_view_registration_in_setup_entry(mock_hass): assert any(isinstance(v, McpStreamableView) for v in registered_views) +@pytest.mark.asyncio +async def test_mcp_view_get_health_check(mock_hass): + """Test GET /api/google_assistant_entity_console/mcp returns healthy status for gateway polling.""" + import json + + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "GET", + "/api/google_assistant_entity_console/mcp", + app=app, + ) + view = McpStreamableView() + resp = await view.get(req) + + assert resp.status == 200 + assert resp.headers.get("Mcp-Protocol-Version") == "2026-07-28" + assert "no-cache" in resp.headers.get("Cache-Control", "") + body = json.loads(resp.body.decode("utf-8")) + assert body.get("status") == "healthy" + assert body.get("protocolVersion") == "2026-07-28" + assert body.get("server") == "google-assistant-entity-console" + + +@pytest.mark.asyncio +async def test_mcp_view_post_ping_fastpath_no_stream(mock_hass): + """Test ping requests with Accept: text/event-stream return immediate JSON without allocating SSE stream.""" + import json + + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Content-Type": "application/json", "Accept": "text/event-stream"}, + app=app, + ) + req._read_bytes = json.dumps({"jsonrpc": "2.0", "id": 42, "method": "ping"}).encode("utf-8") + + view = McpStreamableView() + resp = await view.post(req) + + # Must be standard Response (json_response), not a chunked StreamResponse + assert not isinstance(resp, web.StreamResponse) or isinstance(resp, web.Response) + assert resp.status == 200 + assert resp.content_type == "application/json" + body = json.loads(resp.body.decode("utf-8")) + assert body.get("result") == {} + assert body.get("id") == 42 + + +@pytest.mark.asyncio +async def test_mcp_view_post_sse_stream_client_disconnect(mock_hass): + """Test SSE streaming handles client disconnection during write cleanly and calls write_eof.""" + import json + from unittest.mock import AsyncMock, patch + + from aiohttp import web + from aiohttp.test_utils import make_mocked_request + + app = web.Application() + app["hass"] = mock_hass + req = make_mocked_request( + "POST", + "/api/google_assistant_entity_console/mcp", + headers={"Content-Type": "application/json", "Accept": "text/event-stream"}, + app=app, + ) + req._read_bytes = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}).encode("utf-8") + + view = McpStreamableView() + with patch("aiohttp.web.StreamResponse.prepare", new_callable=AsyncMock), patch( + "aiohttp.web.StreamResponse.write", side_effect=ConnectionResetError("Connection reset by peer") + ), patch("aiohttp.web.StreamResponse.write_eof", new_callable=AsyncMock) as mock_write_eof: + resp = await view.post(req) + assert resp is not None + mock_write_eof.assert_awaited_once() + + + From bb8b72ba442cc7a6ed5f28e1cd06485ab2e87821 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 25 Oct 2026 16:50:07 -0500 Subject: [PATCH 10/10] chore(release): bump version to 3.1.0 --- .../google_assistant_entity_console/manifest.json | 2 +- custom_components/google_assistant_entity_console/mcp.py | 2 +- docs/ARCHITECTURE.md | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/google_assistant_entity_console/manifest.json b/custom_components/google_assistant_entity_console/manifest.json index e488770..7ae1a31 100644 --- a/custom_components/google_assistant_entity_console/manifest.json +++ b/custom_components/google_assistant_entity_console/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "config_flow": true, "requirements": [], - "version": "3.0.2" + "version": "3.1.0" } diff --git a/custom_components/google_assistant_entity_console/mcp.py b/custom_components/google_assistant_entity_console/mcp.py index 12ab5f2..862a175 100644 --- a/custom_components/google_assistant_entity_console/mcp.py +++ b/custom_components/google_assistant_entity_console/mcp.py @@ -36,7 +36,7 @@ MCP_PROTOCOL_VERSION = "2026-07-28" SERVER_NAME = "google-assistant-entity-console" -SERVER_VERSION = "3.0.2" +SERVER_VERSION = "3.1.0" TOOL_CACHE_TTL_MS = 300000 TOOL_DEFINITIONS: list[dict[str, Any]] = [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b052397..aa47027 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Google Assistant Entity Console Architecture (v3.0.2) +# Google Assistant Entity Console Architecture (v3.1.0) This document provides a technical overview of the **Google Assistant Entity Console** Home Assistant custom component architecture. @@ -57,7 +57,7 @@ The integration entrypoint is defined in [`custom_components/google_assistant_en ### Setup Sequence: 1. **Async Setup Entry** (`async_setup_entry`): - - Reads integration manifest version (`3.0.2`). + - Reads integration manifest version (`3.1.0`). - Initializes `ConsoleStore` via `async_get_store(hass)` to load or migrate credentials, blocklists, and sync state from `.storage/google_assistant_entity_console`. - Initializes `GoogleAssistantRuntimeManager` via `async_get_runtime(hass)` and hooks in-memory `GoogleConfig._config["entity_config"]`. - Registers static HTTP paths (`/google_assistant_entity_console/static`) pointing to the component's `static/` directory. @@ -88,11 +88,11 @@ Starting in v3.0.0, persistence is managed entirely by [`ConsoleStore`](file:/// - **Debounced Auto-Sync**: An optional 15-second debounce automatically syncs entity updates in the background. ### 3.3 Native Model Context Protocol (MCP) Subsystem (`mcp.py` + `McpStreamableView`) -Starting in v3.0.2, Google Assistant Entity Console provides a native MCP server implementing the **2026-07-28 Streamable HTTP Transport**: +Starting in v3.1.0, Google Assistant Entity Console provides a native MCP server implementing the **2026-07-28 Streamable HTTP Transport**: - **Transport & Lifecycle**: - [`McpStreamableView`](file:///home/steve/Repos/GoogleAssistantEntityConsole/custom_components/google_assistant_entity_console/views.py) listens on `POST /api/google_assistant_entity_console/mcp` requiring standard Home Assistant Bearer token authentication (`requires_auth = True`). - Supports stateless JSON-RPC 2.0 request/response cycles as well as `Accept: text/event-stream` SSE streams. - - Implements the MCP initialization handshake (`initialize` returning server capabilities, protocol version `2026-07-28`, and server metadata `google-assistant-entity-console` v3.0.2; `notifications/initialized`). + - Implements the MCP initialization handshake (`initialize` returning server capabilities, protocol version `2026-07-28`, and server metadata `google-assistant-entity-console` v3.1.0; `notifications/initialized`). - Implements JSON-RPC ping (`ping`) and notification handling (`notifications/*` returning HTTP 204 No Content). - Validates protocol headers and includes `Mcp-Protocol-Version: 2026-07-28` in all HTTP responses. - **Tool Registry (`tools/list` & `tools/call`)**: