Skip to content

Commit d6be6b7

Browse files
authored
fix: enable e2e filesystem tests and fix ha_mcp_tools integration (#868)
Three root causes prevented filesystem e2e tests from running: 1. ha_mcp_tools component was never installed in the Docker test container. Unified the existing _install_mcp_proxy_component into a generic _install_custom_component helper and used it to install ha_mcp_tools alongside mcp_proxy. 2. async_add_executor_job does not pass kwargs — mkdir calls with parents=True, exist_ok=True were silently called without arguments, causing 500 errors. Fixed with lambda wrappers (3 occurrences). 3. HA's call_service(return_response=True) wraps results in {"changed_states": [], "service_response": {...}} but parse_mcp_result returned this as-is. Tests couldn't find the success/error fields. Added _unwrap_service_response to extract the inner dict. Also: - Removed add_timezone_metadata from filesystem/yaml_config tools (timezone info is irrelevant for file operations) - Fixed tests that used call_tool_success for expected-failure cases (should use safe_call_tool which handles ToolError) - Removed dead data.get("data", {}).get("success") branch and unsafe eval() fallback in assert_mcp_success / parse_mcp_result - Added tests/AGENTS.md with e2e test infrastructure notes Results: 22 file_operations + 18 yaml_config e2e tests now pass (previously all skipped). 901 unit tests pass. Full e2e suite: 525 passed, 3 failed (pre-existing HACS), 11 skipped.
1 parent 596a673 commit d6be6b7

11 files changed

Lines changed: 413 additions & 397 deletions

File tree

custom_components/ha_mcp_tools/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ async def handle_write_file(call: ServiceCall) -> ServiceResponse:
402402
# Create parent directories if needed
403403
if create_dirs:
404404
await hass.async_add_executor_job(
405-
target_file.parent.mkdir, parents=True, exist_ok=True
405+
lambda: target_file.parent.mkdir(parents=True, exist_ok=True)
406406
)
407407

408408
# Check parent directory exists
@@ -597,7 +597,7 @@ async def handle_edit_yaml_config(call: ServiceCall) -> ServiceResponse:
597597
if do_backup and raw_content:
598598
backup_dir = config_dir / "www" / "yaml_backups"
599599
await hass.async_add_executor_job(
600-
backup_dir.mkdir, parents=True, exist_ok=True
600+
lambda: backup_dir.mkdir(parents=True, exist_ok=True)
601601
)
602602
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
603603
safe_name = normalized.replace(os.sep, "_")
@@ -661,7 +661,7 @@ async def handle_edit_yaml_config(call: ServiceCall) -> ServiceResponse:
661661
# Create parent directories if needed (for new package files)
662662
if not target_file.parent.exists():
663663
await hass.async_add_executor_job(
664-
target_file.parent.mkdir, parents=True, exist_ok=True
664+
lambda: target_file.parent.mkdir(parents=True, exist_ok=True)
665665
)
666666

667667
# Atomic write: write to temp file, then rename into place

src/ha_mcp/tools/tools_filesystem.py

Lines changed: 42 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
Feature Flag: Set HAMCP_ENABLE_FILESYSTEM_TOOLS=true to enable these tools.
1414
"""
1515

16+
import json
1617
import logging
1718
import os
1819
from typing import Annotated, Any
@@ -22,7 +23,7 @@
2223

2324
from ..errors import ErrorCode, create_error_response
2425
from .helpers import exception_to_structured_error, log_tool_usage, raise_tool_error
25-
from .util_helpers import add_timezone_metadata, coerce_bool_param, coerce_int_param
26+
from .util_helpers import coerce_bool_param, coerce_int_param, unwrap_service_response
2627

2728
logger = logging.getLogger(__name__)
2829

@@ -174,18 +175,14 @@ async def ha_list_files(
174175

175176
# The service returns the response directly
176177
if isinstance(result, dict):
177-
return await add_timezone_metadata(client, result)
178-
179-
return await add_timezone_metadata(
180-
client,
181-
{
182-
"success": True,
183-
"path": path,
184-
"pattern": pattern,
185-
"files": [],
186-
"count": 0,
187-
"note": "Unexpected response format from service",
188-
},
178+
return unwrap_service_response(result)
179+
180+
raise_tool_error(
181+
create_error_response(
182+
ErrorCode.SERVICE_CALL_FAILED,
183+
"Unexpected response format from list_files service",
184+
context={"path": path},
185+
)
189186
)
190187

191188
except ToolError:
@@ -289,15 +286,14 @@ async def ha_read_file(
289286
)
290287

291288
if isinstance(result, dict):
292-
return await add_timezone_metadata(client, result)
293-
294-
return await add_timezone_metadata(
295-
client,
296-
{
297-
"success": False,
298-
"error": "Unexpected response format from service",
299-
"path": path,
300-
},
289+
return unwrap_service_response(result)
290+
291+
raise_tool_error(
292+
create_error_response(
293+
ErrorCode.SERVICE_CALL_FAILED,
294+
"Unexpected response format from read_file service",
295+
context={"path": path},
296+
)
301297
)
302298

303299
except ToolError:
@@ -419,15 +415,14 @@ async def ha_write_file(
419415
)
420416

421417
if isinstance(result, dict):
422-
return await add_timezone_metadata(client, result)
423-
424-
return await add_timezone_metadata(
425-
client,
426-
{
427-
"success": False,
428-
"error": "Unexpected response format from service",
429-
"path": path,
430-
},
418+
return unwrap_service_response(result)
419+
420+
raise_tool_error(
421+
create_error_response(
422+
ErrorCode.SERVICE_CALL_FAILED,
423+
"Unexpected response format from write_file service",
424+
context={"path": path},
425+
)
431426
)
432427

433428
except ToolError:
@@ -503,20 +498,15 @@ async def ha_delete_file(
503498
confirm_bool = coerce_bool_param(confirm, "confirm", default=False)
504499

505500
if not confirm_bool:
506-
return await add_timezone_metadata(
507-
client,
508-
{
509-
"success": False,
510-
"error": "Deletion not confirmed",
511-
"message": (
512-
"You must set confirm=True to delete a file. "
513-
"This is a safety measure to prevent accidental deletions."
514-
),
515-
"path": path,
516-
"suggestions": [
517-
f"Call ha_delete_file(path='{path}', confirm=True) to proceed",
501+
raise_tool_error(
502+
create_error_response(
503+
ErrorCode.VALIDATION_INVALID_PARAMETER,
504+
"Deletion not confirmed. Set confirm=True to delete a file.",
505+
suggestions=[
506+
f"Call ha_delete_file(path={json.dumps(path)}, confirm=True) to proceed",
518507
],
519-
},
508+
context={"path": path},
509+
)
520510
)
521511

522512
# Check if custom component is available
@@ -534,15 +524,14 @@ async def ha_delete_file(
534524
)
535525

536526
if isinstance(result, dict):
537-
return await add_timezone_metadata(client, result)
538-
539-
return await add_timezone_metadata(
540-
client,
541-
{
542-
"success": False,
543-
"error": "Unexpected response format from service",
544-
"path": path,
545-
},
527+
return unwrap_service_response(result)
528+
529+
raise_tool_error(
530+
create_error_response(
531+
ErrorCode.SERVICE_CALL_FAILED,
532+
"Unexpected response format from delete_file service",
533+
context={"path": path},
534+
)
546535
)
547536

548537
except ToolError:

src/ha_mcp/tools/tools_yaml_config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
MCP_TOOLS_DOMAIN,
2525
_assert_mcp_tools_available,
2626
)
27-
from .util_helpers import add_timezone_metadata, coerce_bool_param
27+
from .util_helpers import coerce_bool_param, unwrap_service_response
2828

2929
logger = logging.getLogger(__name__)
3030

@@ -179,9 +179,10 @@ async def ha_config_set_yaml(
179179
)
180180

181181
if isinstance(result, dict):
182+
result = unwrap_service_response(result)
182183
if not result.get("success", True):
183184
raise_tool_error(result)
184-
return await add_timezone_metadata(client, result)
185+
return result
185186

186187
raise_tool_error(
187188
create_error_response(

src/ha_mcp/tools/util_helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ def parse_string_list_param(
232232
raise ValueError(f"{param_name} must be string, list, or None")
233233

234234

235+
def unwrap_service_response(result: dict[str, Any]) -> dict[str, Any]:
236+
"""Extract service_response from HA call_service result.
237+
238+
HA's call_service with return_response wraps results in
239+
{"changed_states": [...], "service_response": {...}}.
240+
Returns service_response if present and is a dict, otherwise the original result.
241+
"""
242+
sr = result.get("service_response")
243+
return sr if isinstance(sr, dict) else result
244+
245+
235246
async def add_timezone_metadata(client: Any, data: dict[str, Any]) -> dict[str, Any]:
236247
"""Add Home Assistant timezone to tool responses for local time context."""
237248
try:

tests/AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# E2E Test Infrastructure
2+
3+
## Custom Component (ha_mcp_tools)
4+
5+
- Component is installed into the Docker container by `_install_custom_component` in `src/e2e/conftest.py`
6+
- HA's `call_service(return_response=True)` wraps results in `{"changed_states": [], "service_response": {...}}` — tools unwrap this with `result.get("service_response", result)` before returning
7+
- `hass.async_add_executor_job` only passes positional args — use `lambda:` wrappers for calls needing kwargs (e.g., `mkdir(parents=True, exist_ok=True)`)
8+
- HA Docker image uses `annotatedyaml` (PyYAML wrapper), NOT `ruamel.yaml` — custom components needing ruamel must declare it in `manifest.json` requirements
9+
- Feature flags (`ENABLE_YAML_CONFIG_EDITING`, `HAMCP_ENABLE_FILESYSTEM_TOOLS`) are set in `ha_container_with_fresh_config` fixture
10+
11+
## Test Patterns
12+
13+
- Tests expecting tool **success**: use `mcp.call_tool_success()` inside `MCPAssertions` context
14+
- Tests expecting tool **failure**: use `safe_call_tool()` directly (catches `ToolError`, returns parsed dict)
15+
- Service availability checks should use `safe_call_tool` to probe, not `call_tool_success`

tests/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

tests/src/e2e/conftest.py

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -125,63 +125,55 @@ def _ensure_hacs_frontend(initial_state_path: Path) -> None:
125125
logger.warning("HACS tests may be skipped without the frontend")
126126

127127

128-
def _install_mcp_proxy_component(config_path: Path) -> None:
129-
"""Dynamically install mcp_proxy from the webhook proxy addon source.
130-
131-
Copies the integration source, writes the test config file, and injects
132-
a config entry into HA storage. This avoids duplicating source files in
133-
initial_test_state and survives test environment rebuilds.
128+
def _install_custom_component(
129+
config_path: Path,
130+
component_src: Path,
131+
domain: str,
132+
title: str,
133+
) -> bool:
134+
"""Install a custom component into the test HA config.
135+
136+
Copies component source into custom_components/<domain> and injects a
137+
config entry so HA loads it on startup. Returns True if installed.
134138
"""
135-
import json
136-
137-
repo_root = Path(__file__).parent.parent.parent.parent
138-
addon_mcp_proxy = repo_root / "homeassistant-addon-webhook-proxy" / "mcp_proxy"
139-
140-
if not addon_mcp_proxy.exists():
141-
logger.info("mcp_proxy addon source not found — skipping installation")
142-
return
139+
if not component_src.exists():
140+
logger.info("%s source not found — skipping installation", domain)
141+
return False
143142

144-
# Copy component source
145-
dest = config_path / "custom_components" / "mcp_proxy"
143+
dest = config_path / "custom_components" / domain
146144
dest.mkdir(parents=True, exist_ok=True)
147-
shutil.copytree(addon_mcp_proxy, dest, dirs_exist_ok=True)
148-
149-
# Write config file (target_url points at HA's own API for testing)
150-
proxy_config = {
151-
"target_url": "http://localhost:8123/api/",
152-
"webhook_id": "mcp_e2e_test_webhook_proxy",
153-
}
154-
(config_path / ".mcp_proxy_config.json").write_text(json.dumps(proxy_config))
145+
shutil.copytree(component_src, dest, dirs_exist_ok=True)
155146

156147
# Inject config entry if not already present
157148
storage_file = config_path / ".storage" / "core.config_entries"
158149
if storage_file.exists():
159150
data = json.loads(storage_file.read_text())
160151
entries = data.get("data", {}).get("entries", [])
161-
if not any(e.get("domain") == "mcp_proxy" for e in entries):
152+
if not any(e.get("domain") == domain for e in entries):
162153
entries.append(
163154
{
164155
"created_at": "2025-09-07T23:56:28.040744+00:00",
165156
"data": {},
166157
"disabled_by": None,
167158
"discovery_keys": {},
168-
"domain": "mcp_proxy",
169-
"entry_id": "e2e_test_mcp_proxy_entry",
159+
"domain": domain,
160+
"entry_id": f"e2e_test_{domain}_entry",
170161
"minor_version": 1,
171162
"modified_at": "2025-09-07T23:56:28.040747+00:00",
172163
"options": {},
173164
"pref_disable_new_entities": False,
174165
"pref_disable_polling": False,
175166
"source": "import",
176167
"subentries": [],
177-
"title": "MCP Webhook Proxy",
178-
"unique_id": "mcp_proxy",
168+
"title": title,
169+
"unique_id": domain,
179170
"version": 1,
180171
}
181172
)
182173
storage_file.write_text(json.dumps(data, indent=2))
183174

184-
logger.info("Installed mcp_proxy component from addon source")
175+
logger.info("Installed %s component", domain)
176+
return True
185177

186178

187179
@pytest.fixture(scope="session")
@@ -247,8 +239,28 @@ def ha_container_with_fresh_config():
247239
break
248240
storage_file.write_text(json.dumps(ce_data, indent=2))
249241

250-
# Install mcp_proxy from addon source (avoids duplicating files in test state)
251-
_install_mcp_proxy_component(config_path)
242+
# Install custom components from repo source
243+
repo_root = Path(__file__).parent.parent.parent.parent
244+
if _install_custom_component(
245+
config_path,
246+
repo_root / "homeassistant-addon-webhook-proxy" / "mcp_proxy",
247+
"mcp_proxy",
248+
"MCP Webhook Proxy",
249+
):
250+
# mcp_proxy needs a config file pointing at HA's own API
251+
proxy_config = {
252+
"target_url": "http://localhost:8123/api/",
253+
"webhook_id": "mcp_e2e_test_webhook_proxy",
254+
}
255+
(config_path / ".mcp_proxy_config.json").write_text(
256+
json.dumps(proxy_config)
257+
)
258+
_install_custom_component(
259+
config_path,
260+
repo_root / "custom_components" / "ha_mcp_tools",
261+
"ha_mcp_tools",
262+
"HA MCP Tools",
263+
)
252264

253265
# Ensure proper permissions for Home Assistant
254266
_setup_config_permissions(config_path)
@@ -295,6 +307,9 @@ def ha_container_with_fresh_config():
295307
# Set environment variables for the dynamic URL so WebSocket client uses correct port
296308
os.environ["HOMEASSISTANT_URL"] = base_url
297309
os.environ["HOMEASSISTANT_TOKEN"] = TEST_TOKEN
310+
# Enable feature flags for e2e tests
311+
os.environ["ENABLE_YAML_CONFIG_EDITING"] = "true"
312+
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = "true"
298313

299314
# Reset cached settings so WebSocket client picks up the dynamic URL
300315
import ha_mcp.config

tests/src/e2e/utilities/assertions.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,10 @@ def parse_mcp_result(result) -> dict[str, Any]:
4747
if hasattr(result.content[0], "text"):
4848
response_text = result.content[0].text
4949
try:
50-
return json.loads(response_text)
50+
parsed = json.loads(response_text)
51+
return parsed
5152
except json.JSONDecodeError:
52-
try:
53-
return eval(response_text)
54-
except Exception:
55-
return {"raw_response": response_text}
53+
return {"raw_response": response_text}
5654
return {"content": str(result.content[0])}
5755
return {"error": "No content in result"}
5856

@@ -112,10 +110,8 @@ def assert_mcp_success(result, operation_name: str = "operation"):
112110
data = parse_mcp_result(result)
113111

114112
# Handle different success indicators
115-
# Some tools return success in the top level, others in data.success
116113
success_indicators = [
117114
data.get("success") is True,
118-
data.get("data", {}).get("success") is True,
119115
# If no explicit success field but has data and no error, consider success
120116
("data" in data and data.get("error") is None and data.get("success") is None),
121117
# Bulk operations success: has operational data without explicit success field
@@ -135,8 +131,7 @@ def assert_mcp_success(result, operation_name: str = "operation"):
135131
]
136132

137133
if not any(success_indicators):
138-
error_msg = data.get("error") or data.get("data", {}).get(
139-
"error", "Unknown error"
134+
error_msg = data.get("error", "Unknown error"
140135
)
141136
suggestions = data.get("suggestions", [])
142137

0 commit comments

Comments
 (0)