Skip to content

Commit 9c6e8de

Browse files
kingpanther13claude
andcommitted
fix: address Gemini review feedback
- Use create_error_response/ErrorCode for all REST endpoint errors (settings save, restart, validation) - Validate states dict from client (string keys, allowed state values) before persisting to disk - Type hint settings parameter as Settings (TYPE_CHECKING import) - Initialize _user_pinned_tools in __init__ instead of getattr fallback - Add clearer docstring explaining why _list_tools() (private API) is used: public list_tools() filters disabled tools, and the settings UI specifically needs the unfiltered list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d8cc976 commit 9c6e8de

2 files changed

Lines changed: 53 additions & 19 deletions

File tree

src/ha_mcp/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(
7070
self._device_tools: Any = None
7171
self._tools_registry: ToolsRegistry | None = None
7272
self._skill_tool_names: list[str] = []
73+
# Populated by _apply_settings_ui from tool_config.json on startup
74+
self._user_pinned_tools: list[str] = []
7375

7476
# Get server name/version from settings if no client provided
7577
if not self._client_provided:
@@ -442,7 +444,7 @@ def _apply_tool_search(self) -> None:
442444

443445
# Build the always_visible list: defaults + user-configured pins
444446
pinned = list(self._PINNED_TOOLS)
445-
pinned.extend(getattr(self, "_user_pinned_tools", []))
447+
pinned.extend(self._user_pinned_tools)
446448

447449
# Pin ResourcesAsTools and skill guidance tools if skills-as-tools is enabled
448450
if self.settings.enable_skills_as_tools:

src/ha_mcp/settings_ui.py

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@
1919
from starlette.requests import Request
2020
from starlette.responses import HTMLResponse, JSONResponse
2121

22+
from .errors import ErrorCode, create_error_response
2223
from .transforms import DEFAULT_PINNED_TOOLS
2324

2425
if TYPE_CHECKING:
2526
from fastmcp import FastMCP
2627

28+
from .config import Settings
2729
from .server import HomeAssistantSmartMCPServer
2830

31+
_VALID_STATES = frozenset({"enabled", "disabled", "pinned"})
32+
2933
logger = logging.getLogger(__name__)
3034

3135
MANDATORY_TOOLS: set[str] = {
@@ -88,7 +92,7 @@ def _get_config_path() -> Path:
8892
return home_dir / "tool_config.json"
8993

9094

91-
def load_tool_config(settings: Any = None) -> dict[str, Any]:
95+
def load_tool_config(settings: "Settings | None" = None) -> dict[str, Any]:
9296
"""Load persisted tool config, seeding from env vars if no file exists."""
9397
path = _get_config_path()
9498
if path.exists():
@@ -134,12 +138,15 @@ def save_tool_config(config: dict[str, Any]) -> None:
134138
logger.exception("Failed to save tool config to %s", path)
135139

136140

137-
async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[str, Any]]:
141+
async def _get_tool_metadata(server: "HomeAssistantSmartMCPServer") -> list[dict[str, Any]]:
138142
"""Extract metadata for all registered tools from the server.
139143
140-
Reads from the local provider's unfiltered tool list so that disabled
141-
tools are still shown in the settings UI (users need to be able to
142-
re-enable them).
144+
Uses FastMCP's internal ``local_provider._list_tools()`` because the
145+
public ``mcp.list_tools()`` filters out tools marked as disabled via
146+
``mcp.disable()``. The settings UI specifically needs the UNFILTERED
147+
list so that users can see and re-enable tools they previously
148+
disabled. There is no public FastMCP API that returns the unfiltered
149+
list as of v3.2.0.
143150
"""
144151
tools: list[dict[str, Any]] = []
145152
# Groups not considered "primary" when choosing a tool's canonical group —
@@ -195,9 +202,9 @@ async def _get_tool_metadata(server: HomeAssistantSmartMCPServer) -> list[dict[s
195202

196203

197204
def apply_tool_visibility(
198-
mcp: FastMCP,
205+
mcp: "FastMCP",
199206
config: dict[str, Any],
200-
settings: Any,
207+
settings: "Settings",
201208
) -> set[str]:
202209
"""Apply tool visibility from config, respecting safety toggles.
203210
@@ -648,11 +655,32 @@ async def _save_tools(request: Request) -> JSONResponse:
648655
body = await request.json()
649656
except (ValueError, TypeError):
650657
return JSONResponse(
651-
{"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Invalid JSON body"}},
658+
create_error_response(
659+
ErrorCode.VALIDATION_INVALID_JSON,
660+
"Invalid JSON body",
661+
suggestions=["Ensure the request body is valid JSON"],
662+
),
663+
status_code=400,
664+
)
665+
666+
raw_states = body.get("states", {})
667+
if not isinstance(raw_states, dict):
668+
return JSONResponse(
669+
create_error_response(
670+
ErrorCode.VALIDATION_INVALID_PARAMETER,
671+
"'states' must be an object mapping tool names to state values",
672+
),
652673
status_code=400,
653674
)
675+
# Validate: keys must be strings, values must be one of the valid states
676+
states: dict[str, str] = {}
677+
for name, state in raw_states.items():
678+
if not isinstance(name, str) or not isinstance(state, str):
679+
continue
680+
if state not in _VALID_STATES:
681+
continue
682+
states[name] = state
654683

655-
states = body.get("states", {})
656684
config = load_tool_config()
657685
config["tools"] = states
658686
save_tool_config(config)
@@ -676,7 +704,11 @@ async def _restart_addon(_: Request) -> JSONResponse:
676704
token = os.environ.get("SUPERVISOR_TOKEN")
677705
if not token:
678706
return JSONResponse(
679-
{"success": False, "error": {"code": "NOT_IN_ADDON", "message": "Restart only available in add-on mode"}},
707+
create_error_response(
708+
ErrorCode.CONFIG_VALIDATION_FAILED,
709+
"Restart only available when running as an add-on",
710+
details="SUPERVISOR_TOKEN environment variable is not set",
711+
),
680712
status_code=400,
681713
)
682714
# Short timeout — the supervisor kills our process during restart so
@@ -694,21 +726,21 @@ async def _restart_addon(_: Request) -> JSONResponse:
694726
except httpx.HTTPError as e:
695727
logger.exception("Failed to reach Supervisor for restart")
696728
return JSONResponse(
697-
{"success": False, "error": {"code": "SUPERVISOR_UNREACHABLE", "message": str(e)}},
729+
create_error_response(
730+
ErrorCode.CONNECTION_FAILED,
731+
f"Failed to reach Supervisor: {e}",
732+
),
698733
status_code=502,
699734
)
700735

701736
if resp.status_code >= 400:
702737
body = resp.text
703738
logger.error("Supervisor restart failed: %d %s", resp.status_code, body)
704739
return JSONResponse(
705-
{
706-
"success": False,
707-
"error": {
708-
"code": "SUPERVISOR_ERROR",
709-
"message": f"Supervisor returned {resp.status_code}: {body[:500]}",
710-
},
711-
},
740+
create_error_response(
741+
ErrorCode.INTERNAL_ERROR,
742+
f"Supervisor returned {resp.status_code}: {body[:500]}",
743+
),
712744
status_code=502,
713745
)
714746
return JSONResponse({"success": True, "message": "Restart initiated"})

0 commit comments

Comments
 (0)