Skip to content

Commit eb66760

Browse files
authored
fix(types): add mypy type checking and fix 47 type errors (#716)
* fix(types): add mypy type checking and fix 47 type errors - Add mypy to pre-commit hook (runs on src/ and pyproject.toml changes) - Fix python_version 3.11 → 3.13 in mypy config to match requires-python - Add mypy_path = "src" for correct src-layout module resolution - Add jq to mypy ignore_missing_imports (Windows ARM64 conditional dep) Type fixes across 12 files: - auth/provider.py: fix OAuth error literals (invalid_client→invalid_request, server_error→invalid_client); preserve None for optional PKCE code_challenge - client/rest_client.py: type payload dict, add fallback return after retry loop - tools/backup.py: assert ws_client not None after error checks - tools/tools_areas.py: remove duplicate type annotations in if/else branches - tools/tools_config_dashboards.py: fix Traversable variable conflict, use cast instead of assert for jq_result narrowing - tools/tools_config_automations.py: cast Any return to dict[str, Any] - tools/tools_traces.py: str() cast on return, type sort_key function - tools/tools_service.py, tools_addons.py, tools_mcp_component.py, backup.py: add missing **kwargs: Any annotations - server.py: cast Any-returning methods, fix list|dict return type - __main__.py: replace Any with specific types (HomeAssistantOAuthProvider, HomeAssistantClient, Settings, Coroutine) via TYPE_CHECKING imports * Update src/ha_mcp/tools/tools_mcp_component.py
1 parent f6e25b4 commit eb66760

15 files changed

Lines changed: 73 additions & 53 deletions

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ repos:
1111
- id: uv-lock
1212
- repo: local
1313
hooks:
14+
- id: mypy
15+
name: mypy type check
16+
entry: uv run mypy src/
17+
language: system
18+
pass_filenames: false
19+
files: ^(src/|pyproject\.toml)
1420
- id: unit-tests
1521
name: unit tests
1622
entry: uv run pytest tests/src/unit/ -n auto -m "not slow" --tb=short -q

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ packages = { find = { where = ["src", "."], include = ["ha_mcp*", "tests"] } }
5555
ha_mcp = ["py.typed", "_pypi_marker", "resources/*.md", "resources/*.json", "resources/skills-vendor/**/*"]
5656

5757
[tool.mypy]
58-
python_version = "3.11"
58+
python_version = "3.13"
59+
mypy_path = "src"
5960
warn_return_any = true
6061
warn_unused_configs = true
6162
disallow_untyped_defs = true
@@ -71,6 +72,7 @@ explicit_package_bases = true
7172
[[tool.mypy.overrides]]
7273
module = [
7374
"fastmcp.*",
75+
"jq",
7476
]
7577
ignore_missing_imports = true
7678

src/ha_mcp/__main__.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
import stat # noqa: E402
1414
import sys # noqa: E402
1515
import threading # noqa: E402
16-
from typing import Any # noqa: E402
16+
from collections.abc import Coroutine # noqa: E402
17+
from typing import TYPE_CHECKING, Any # noqa: E402
18+
19+
if TYPE_CHECKING:
20+
from fastmcp import FastMCP
21+
22+
from ha_mcp.auth.provider import HomeAssistantOAuthProvider
23+
from ha_mcp.client.rest_client import HomeAssistantClient
24+
from ha_mcp.config import Settings
25+
from ha_mcp.server import HomeAssistantSmartMCPServer
1726

1827
logger = logging.getLogger(__name__)
1928

@@ -25,12 +34,12 @@ class OAuthProxyClient:
2534
The proxy allows us to inject different credentials per-request based on OAuth token claims.
2635
"""
2736

28-
def __init__(self, auth_provider):
37+
def __init__(self, auth_provider: "HomeAssistantOAuthProvider") -> None:
2938
self._auth_provider = auth_provider
30-
self._oauth_clients = {}
39+
self._oauth_clients: dict[str, HomeAssistantClient] = {}
3140
self._lock = threading.Lock()
3241

33-
def _get_oauth_client(self):
42+
def _get_oauth_client(self) -> "HomeAssistantClient":
3443
"""Get the OAuth client for the current request context."""
3544
from fastmcp.server.dependencies import get_access_token
3645

@@ -74,7 +83,7 @@ async def close(self) -> None:
7483
for client in clients:
7584
await client.close()
7685

77-
def __getattr__(self, name):
86+
def __getattr__(self, name: str) -> Any:
7887
"""Forward all attribute access to the OAuth client."""
7988
client = self._get_oauth_client()
8089
return getattr(client, name)
@@ -217,7 +226,7 @@ def _handle_config_error(error: Exception) -> None:
217226
sys.exit(1)
218227

219228

220-
def _validate_standard_credentials(settings) -> None:
229+
def _validate_standard_credentials(settings: "Settings") -> None:
221230
"""Exit with error if HA credentials are OAuth sentinels in standard (non-OAuth) mode."""
222231
from ha_mcp.config import OAUTH_MODE_TOKEN, OAUTH_MODE_URL
223232

@@ -264,14 +273,12 @@ def _http_run_kwargs(transport: str, port: int, path: str) -> dict:
264273
}
265274

266275

267-
def _create_server():
276+
def _create_server() -> "HomeAssistantSmartMCPServer":
268277
"""Create server instance (deferred to avoid import during smoke test)."""
269278
from pydantic import ValidationError
270279

271280
try:
272-
from ha_mcp.server import (
273-
HomeAssistantSmartMCPServer, # type: ignore[import-not-found]
274-
)
281+
from ha_mcp.server import HomeAssistantSmartMCPServer
275282

276283
return HomeAssistantSmartMCPServer()
277284
except ValidationError as e:
@@ -280,18 +287,18 @@ def _create_server():
280287

281288

282289
# Lazy server creation - only create when needed
283-
_server = None
290+
_server: "HomeAssistantSmartMCPServer | None" = None
284291

285292

286-
def _get_mcp():
293+
def _get_mcp() -> "FastMCP":
287294
"""Get the MCP instance, creating server if needed."""
288295
global _server
289296
if _server is None:
290297
_server = _create_server()
291298
return _server.mcp
292299

293300

294-
def _get_server():
301+
def _get_server() -> "HomeAssistantSmartMCPServer":
295302
"""Get the server instance, creating if needed."""
296303
global _server
297304
if _server is None:
@@ -390,7 +397,7 @@ async def _cancel_tasks(*tasks: asyncio.Task) -> None:
390397
pass
391398

392399

393-
async def _run_with_shutdown(server_coro) -> None:
400+
async def _run_with_shutdown(server_coro: Coroutine[Any, Any, Any]) -> None:
394401
"""Run a server coroutine with graceful shutdown support.
395402
396403
Handles signal-based shutdown, resource cleanup, and task cancellation.
@@ -431,7 +438,7 @@ async def _run_with_shutdown(server_coro) -> None:
431438
await _cancel_tasks(server_task, shutdown_task)
432439

433440

434-
def _run_entrypoint(coro, label: str) -> None:
441+
def _run_entrypoint(coro: Coroutine[Any, Any, Any], label: str) -> None:
435442
"""Run an async entrypoint with standard exception handling."""
436443
_setup_signal_handlers()
437444

@@ -659,7 +666,9 @@ async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
659666
proxy_client = OAuthProxyClient(auth_provider)
660667

661668
global _server
662-
_server = HomeAssistantSmartMCPServer(client=proxy_client)
669+
_server = HomeAssistantSmartMCPServer(
670+
client=proxy_client, # type: ignore[arg-type] # OAuthProxyClient forwards all HomeAssistantClient attrs via __getattr__
671+
)
663672
mcp = _server.mcp
664673
mcp.auth = auth_provider
665674

src/ha_mcp/auth/provider.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ async def authorize(
338338
"""
339339
if client.client_id is None:
340340
raise AuthorizeError(
341-
error="invalid_client",
341+
error="invalid_request",
342342
error_description="Client ID is required",
343343
)
344344

@@ -507,7 +507,7 @@ async def _consent_post(self, request: Request) -> Response:
507507
),
508508
scopes=scopes_list,
509509
expires_at=expires_at,
510-
code_challenge=pending.get("code_challenge"),
510+
code_challenge=pending.get("code_challenge"), # type: ignore[arg-type] # None is valid per PKCE spec (RFC 7636 §4.3); empty string would break validation
511511
)
512512
self.auth_codes[auth_code_value] = auth_code
513513

@@ -617,7 +617,7 @@ async def exchange_authorization_code(
617617
ha_credentials = self.ha_credentials.get(client.client_id)
618618
if not ha_credentials:
619619
raise TokenError(
620-
"server_error",
620+
"invalid_client",
621621
f"No Home Assistant credentials found for client {client.client_id}",
622622
)
623623

src/ha_mcp/client/rest_client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ async def start_config_flow(
618618
Raises:
619619
HomeAssistantAPIError: If flow start fails
620620
"""
621-
payload = {"handler": handler}
621+
payload: dict[str, Any] = {"handler": handler}
622622
if context:
623623
payload["context"] = context
624624

@@ -740,6 +740,8 @@ async def send_websocket_message(self, message: dict[str, Any]) -> dict[str, Any
740740
logger.error(f"WebSocket message failed: {e}")
741741
return {"success": False, "error": str(e)}
742742

743+
return {"success": False, "error": "WebSocket request failed"}
744+
743745
async def _handle_render_template(
744746
self, ws_client: Any, message: dict[str, Any]
745747
) -> dict[str, Any]:

src/ha_mcp/client/websocket_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ async def get_client(
688688

689689
# Evict least-recently-used connection if over limit
690690
if len(self._clients) > MAX_POOL_SIZE:
691-
oldest_key = min(self._last_used, key=self._last_used.get)
691+
oldest_key = min(self._last_used, key=lambda k: self._last_used[k])
692692
stale = self._clients.pop(oldest_key, None)
693693
self._last_used.pop(oldest_key, None)
694694
if stale:

src/ha_mcp/server.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111

1212
import logging
1313
from pathlib import Path
14-
from typing import TYPE_CHECKING, Any
14+
from typing import TYPE_CHECKING, Any, cast
1515

16-
import yaml
16+
import yaml # type: ignore[import-untyped]
1717
from fastmcp import FastMCP
1818
from mcp.types import Icon
1919

@@ -314,9 +314,9 @@ async def smart_entity_search(
314314
self, query: str, domain_filter: str | None = None, limit: int = 10
315315
) -> dict[str, Any]:
316316
"""Bridge method to existing smart search implementation."""
317-
return await self.smart_tools.smart_entity_search(
317+
return cast(dict[str, Any], await self.smart_tools.smart_entity_search(
318318
query=query, limit=limit, include_attributes=False
319-
)
319+
))
320320

321321
async def get_entity_state(self, entity_id: str) -> dict[str, Any]:
322322
"""Bridge method to existing entity state implementation."""
@@ -328,7 +328,7 @@ async def call_service(
328328
service: str,
329329
entity_id: str | None = None,
330330
data: dict | None = None,
331-
) -> list[dict[str, Any]]:
331+
) -> list[dict[str, Any]] | dict[str, Any]:
332332
"""Bridge method to existing service call implementation."""
333333
service_data = data or {}
334334
if entity_id:
@@ -337,9 +337,9 @@ async def call_service(
337337

338338
async def get_entities_by_area(self, area_name: str) -> dict[str, Any]:
339339
"""Bridge method to existing area functionality."""
340-
return await self.smart_tools.get_entities_by_area(
340+
return cast(dict[str, Any], await self.smart_tools.get_entities_by_area(
341341
area_query=area_name, group_by_domain=True
342-
)
342+
))
343343

344344
async def start(self) -> None:
345345
"""Start the Smart MCP server with async compatibility."""

src/ha_mcp/tools/backup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import asyncio
88
import logging
99
from datetime import datetime
10-
from typing import TYPE_CHECKING, Annotated, Any
10+
from typing import TYPE_CHECKING, Annotated, Any, cast
1111

1212
from fastmcp.exceptions import ToolError
1313
from pydantic import Field
@@ -105,6 +105,7 @@ async def create_backup(
105105
ErrorCode.CONNECTION_FAILED,
106106
"Failed to connect to Home Assistant WebSocket for backup",
107107
))
108+
ws_client = cast(HomeAssistantWebSocketClient, ws_client)
108109

109110
# Get backup password
110111
password, error = await _get_backup_password(ws_client)
@@ -256,6 +257,7 @@ async def restore_backup(
256257
ErrorCode.CONNECTION_FAILED,
257258
"Failed to connect to Home Assistant WebSocket for restore",
258259
))
260+
ws_client = cast(HomeAssistantWebSocketClient, ws_client)
259261

260262
# Verify backup exists
261263
backup_info = await ws_client.send_command("backup/info")
@@ -354,7 +356,7 @@ async def restore_backup(
354356
pass # Ignore errors during cleanup
355357

356358

357-
def register_backup_tools(mcp: "FastMCP", client: HomeAssistantClient, **kwargs) -> None:
359+
def register_backup_tools(mcp: "FastMCP", client: HomeAssistantClient, **kwargs: Any) -> None:
358360
"""
359361
Register backup and restore tools with the MCP server.
360362

src/ha_mcp/tools/tools_addons.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ async def list_available_addons(
257257
pass
258258

259259

260-
def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs) -> None:
260+
def register_addon_tools(mcp: Any, client: HomeAssistantClient, **kwargs: Any) -> None:
261261
"""
262262
Register add-on management tools with the MCP server.
263263

src/ha_mcp/tools/tools_areas.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ async def ha_config_set_area(
156156
suggestions=["Provide a name for the new area"],
157157
))
158158

159-
message: dict[str, Any] = {
159+
message = {
160160
"type": "config/area_registry/create",
161161
"name": name,
162162
}
@@ -380,7 +380,7 @@ async def ha_config_set_floor(
380380
suggestions=["Provide a name for the new floor"],
381381
))
382382

383-
message: dict[str, Any] = {
383+
message = {
384384
"type": "config/floor_registry/create",
385385
"name": name,
386386
}

0 commit comments

Comments
 (0)