1919from starlette .requests import Request
2020from starlette .responses import HTMLResponse , JSONResponse
2121
22+ from .errors import ErrorCode , create_error_response
2223from .transforms import DEFAULT_PINNED_TOOLS
2324
2425if 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+
2933logger = logging .getLogger (__name__ )
3034
3135MANDATORY_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
197204def 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