ha-mcp-dev 7.5.0.dev575__tar.gz → 7.5.0.dev576__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {ha_mcp_dev-7.5.0.dev575/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev576}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/__main__.py +87 -1
  4. ha_mcp_dev-7.5.0.dev576/src/ha_mcp/config.py +530 -0
  5. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/settings_ui.py +836 -64
  6. ha_mcp_dev-7.5.0.dev576/src/ha_mcp/stdio_settings_sidecar.py +773 -0
  7. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_filesystem.py +21 -15
  8. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_mcp_component.py +69 -53
  9. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_search.py +62 -18
  10. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  11. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/SOURCES.txt +1 -0
  12. ha_mcp_dev-7.5.0.dev575/src/ha_mcp/config.py +0 -263
  13. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/LICENSE +0 -0
  14. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/MANIFEST.in +0 -0
  15. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/README.md +0 -0
  16. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/setup.cfg +0 -0
  17. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/__init__.py +0 -0
  18. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/_pypi_marker +0 -0
  19. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/_version.py +0 -0
  20. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/__init__.py +0 -0
  21. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/consent_form.py +0 -0
  22. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/auth/provider.py +0 -0
  23. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/__init__.py +0 -0
  24. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/rest_client.py +0 -0
  25. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/supervisor_client.py +0 -0
  26. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/websocket_client.py +0 -0
  27. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/client/websocket_listener.py +0 -0
  28. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/errors.py +0 -0
  29. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/py.typed +0 -0
  30. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  31. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  32. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  33. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  34. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  35. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  36. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  37. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  38. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  39. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  40. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  41. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  42. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  43. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  46. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  47. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  48. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  49. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  50. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  51. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  52. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/server.py +0 -0
  53. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/smoke_test.py +0 -0
  54. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/__init__.py +0 -0
  55. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/backup.py +0 -0
  56. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  57. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/device_control.py +0 -0
  58. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/enhanced.py +0 -0
  59. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/helpers.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/reference_validator.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/registry.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/smart_search.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_addons.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_areas.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_calendar.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_camera.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_categories.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_code.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_energy.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_entities.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_groups.py +0 -0
  80. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_hacs.py +0 -0
  81. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_history.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_integrations.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_labels.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_registry.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_resources.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_service.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_services.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_system.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_todo.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_traces.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_updates.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_utility.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/tools_zones.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/tools/util_helpers.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/__init__.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/categorized_search.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/__init__.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/config_hash.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/data_paths.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/domain_handlers.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/operation_manager.py +0 -0
  107. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/python_sandbox.py +0 -0
  108. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp/utils/usage_logger.py +0 -0
  109. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  110. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  111. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  112. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  113. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/tests/__init__.py +0 -0
  114. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/tests/test_constants.py +0 -0
  115. {ha_mcp_dev-7.5.0.dev575 → ha_mcp_dev-7.5.0.dev576}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.5.0.dev575
3
+ Version: 7.5.0.dev576
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.5.0.dev575"
7
+ version = "7.5.0.dev576"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -83,7 +83,9 @@ class OAuthProxyClient:
83
83
  logger.error(
84
84
  f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}"
85
85
  )
86
- raise HomeAssistantAuthError("No Home Assistant credentials in OAuth token claims")
86
+ raise HomeAssistantAuthError(
87
+ "No Home Assistant credentials in OAuth token claims"
88
+ )
87
89
 
88
90
  ha_token = claims["ha_token"]
89
91
 
@@ -627,9 +629,92 @@ def main() -> None:
627
629
  _setup_logging(settings.log_level)
628
630
  _log_startup_version()
629
631
 
632
+ # Spawn the persistent settings UI sidecar (issue #863). The sidecar
633
+ # is a detached subprocess so the settings page stays reachable even
634
+ # when this stdio process is SIGTERM'd or idle-killed by the client.
635
+ # Best-effort: failure logs a warning but doesn't block MCP startup.
636
+ _maybe_spawn_settings_sidecar()
637
+
630
638
  _run_entrypoint(_run_with_graceful_shutdown(), "Server")
631
639
 
632
640
 
641
+ def _maybe_spawn_settings_sidecar() -> None:
642
+ """Dump tool metadata cache + spawn the stdio settings UI sidecar.
643
+
644
+ Split out of ``main()`` to keep the entrypoint readable. The cache
645
+ dump uses a one-off ``asyncio.run`` because ``_get_tool_metadata``
646
+ is async; this happens before the main stdio loop so there's no
647
+ nested-loop conflict with ``_run_entrypoint``'s own ``asyncio.run``.
648
+
649
+ Performance: the dump constructs the full FastMCP server, which is
650
+ heavy. Skip it (and the server build) when there's nothing to spawn
651
+ for — sidecar disabled or already alive. Warm restarts that already
652
+ have a sidecar pay zero cold-start tax from this path.
653
+ """
654
+ from ha_mcp.settings_ui import (
655
+ _get_tool_metadata,
656
+ dump_tool_metadata_cache,
657
+ )
658
+ from ha_mcp.stdio_settings_sidecar import (
659
+ _existing_sidecar_alive,
660
+ _is_disabled,
661
+ maybe_spawn,
662
+ )
663
+
664
+ # Cheap gates first; skip the heavy metadata dump when the sidecar
665
+ # would be a no-op anyway. Any condition that makes maybe_spawn()
666
+ # short-circuit also makes the dump pointless (the running sidecar
667
+ # already has a cache from a prior parent startup; a disabled
668
+ # sidecar never reads one).
669
+ if _is_disabled() or _existing_sidecar_alive():
670
+ try:
671
+ maybe_spawn()
672
+ except Exception as e:
673
+ logger.warning(
674
+ "Failed to invoke maybe_spawn no-op path (%s)",
675
+ type(e).__name__,
676
+ exc_info=True,
677
+ )
678
+ return
679
+
680
+ try:
681
+ metadata = asyncio.run(_get_tool_metadata(_get_server()))
682
+ dumped = dump_tool_metadata_cache(metadata)
683
+ # Log a deliberate one-liner so users debugging an empty
684
+ # settings page can see whether the parent's dump succeeded
685
+ # by grepping the stdio process output (which Claude Desktop
686
+ # surfaces in its MCP server log panel).
687
+ logger.info(
688
+ "Tool metadata cache: %d tools dumped, write %s",
689
+ len(metadata),
690
+ "succeeded" if dumped else "FAILED",
691
+ )
692
+ except Exception as e:
693
+ # Cache dump is best-effort — the sidecar falls back to an empty
694
+ # tools list rather than blocking stdio startup. Include the
695
+ # exception class in the warning so ops can distinguish
696
+ # server-init failures (Pydantic ValidationError) from cache I/O
697
+ # (OSError) from event-loop issues (RuntimeError).
698
+ logger.warning(
699
+ "Failed to dump tool metadata cache (%s)",
700
+ type(e).__name__,
701
+ exc_info=True,
702
+ )
703
+
704
+ try:
705
+ maybe_spawn()
706
+ except Exception as e:
707
+ # Spawn failures already log inside maybe_spawn(); the bare
708
+ # except here is a defense-in-depth guard for any unexpected
709
+ # path (e.g. import error in the sidecar module). Settings UI
710
+ # is advisory — never let it block MCP startup.
711
+ logger.warning(
712
+ "Failed to spawn settings UI sidecar (%s)",
713
+ type(e).__name__,
714
+ exc_info=True,
715
+ )
716
+
717
+
633
718
  def main_dev() -> None:
634
719
  """Run server with DEBUG logging enabled (for ha-mcp-dev package)."""
635
720
  import os
@@ -875,6 +960,7 @@ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) ->
875
960
  register_browser_landing(mcp, path)
876
961
 
877
962
  from ha_mcp.settings_ui import register_settings_routes
963
+
878
964
  register_settings_routes(mcp, _server, secret_path=path)
879
965
 
880
966
  tools = await mcp.list_tools()
@@ -0,0 +1,530 @@
1
+ """
2
+ Configuration management for Home Assistant MCP Server.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+
8
+ # Load environment variables from .env file with HAMCP_ENV_FILE support
9
+ # Use absolute path to ensure .env is found regardless of cwd
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv
13
+ from pydantic import Field, field_validator
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+
16
+ from ha_mcp._version import get_version
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _PACKAGE_VERSION = get_version()
21
+
22
+ project_root = Path(__file__).parent.parent.parent
23
+
24
+ # Demo environment token - use HOMEASSISTANT_TOKEN="demo" to connect to the public demo
25
+ # Demo server: https://ha-mcp-demo-server.qc-h.net (login: mcp/mcp, resets weekly)
26
+ DEMO_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxOTE5ZTZlMTVkYjI0Mzk2YTQ4YjFiZTI1MDM1YmU2YSIsImlhdCI6MTc1NzI4OTc5NiwiZXhwIjoyMDcyNjQ5Nzk2fQ.Yp9SSAjm2gvl9Xcu96FFxS8SapHxWAVzaI0E3cD9xac"
27
+
28
+ # OAuth mode sentinel values — when these are present, HA credentials come from OAuth tokens
29
+ OAUTH_MODE_URL = "http://oauth-mode"
30
+ OAUTH_MODE_TOKEN = "oauth-mode-token"
31
+
32
+ # Support for different environment files via HAMCP_ENV_FILE
33
+ env_file = os.getenv("HAMCP_ENV_FILE", ".env")
34
+ env_path = project_root / env_file
35
+
36
+ # Load the specified environment file (silently, since env vars may come from other sources)
37
+ if env_path.exists():
38
+ load_dotenv(env_path)
39
+ else:
40
+ # Fallback to default .env
41
+ default_env_path = project_root / ".env"
42
+ if default_env_path.exists():
43
+ load_dotenv(default_env_path)
44
+
45
+
46
+ class Settings(BaseSettings):
47
+ """Application settings loaded from environment variables."""
48
+
49
+ # Home Assistant connection
50
+ # In OAuth mode, these are optional and provided per-request
51
+ homeassistant_url: str = Field(default=OAUTH_MODE_URL, alias="HOMEASSISTANT_URL")
52
+ homeassistant_token: str = Field(
53
+ default=OAUTH_MODE_TOKEN, alias="HOMEASSISTANT_TOKEN"
54
+ )
55
+
56
+ # Server configuration
57
+ timeout: int = Field(30, alias="HA_TIMEOUT")
58
+ max_retries: int = Field(3, alias="HA_MAX_RETRIES")
59
+
60
+ # False = skip TLS verification (self-signed / hostname mismatch). Trusted networks only.
61
+ verify_ssl: bool = Field(True, alias="HA_VERIFY_SSL")
62
+
63
+ # Tool configuration
64
+ fuzzy_threshold: int = Field(60, alias="FUZZY_THRESHOLD")
65
+ entity_search_limit: int = Field(20, alias="ENTITY_SEARCH_LIMIT")
66
+
67
+ # Backup tool configuration
68
+ backup_hint: str = Field("normal", alias="BACKUP_HINT")
69
+
70
+ # WebSocket configuration (essential for async operations)
71
+ enable_websocket: bool = Field(True, alias="ENABLE_WEBSOCKET")
72
+
73
+ # Development/Debug configuration
74
+ debug: bool = Field(False, alias="DEBUG")
75
+ log_level: str = Field("INFO", alias="LOG_LEVEL")
76
+
77
+ # MCP Server configuration
78
+ mcp_server_name: str = Field("ha-mcp", alias="MCP_SERVER_NAME")
79
+ mcp_server_version: str = Field(
80
+ default=_PACKAGE_VERSION, alias="MCP_SERVER_VERSION"
81
+ )
82
+
83
+ # Environment configuration
84
+ environment: str = Field("development", alias="ENVIRONMENT")
85
+
86
+ # Tool filtering - comma-separated list of module names to enable
87
+ # Special values: "all" (default), "automation" (automation-related tools only)
88
+ # Examples: "tools_config_automations,tools_config_scripts,tools_traces"
89
+ enabled_tool_modules: str = Field("all", alias="ENABLED_TOOL_MODULES")
90
+
91
+ # Dashboard partial update tools (python_transform, find_card)
92
+ # These are token-efficient alternatives to full config replacement.
93
+ # Disable when using clients with programmatic tool use (future).
94
+ enable_dashboard_partial_tools: bool = Field(
95
+ True, alias="ENABLE_DASHBOARD_PARTIAL_TOOLS"
96
+ )
97
+
98
+ # Tool search transform — replaces the full tool catalog with a unified
99
+ # BM25 search tool and categorized call proxies (read/write/delete).
100
+ # Dramatically reduces idle context token usage for LLMs.
101
+ enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")
102
+
103
+ # Managed YAML config editing — allows ha_config_set_yaml to add,
104
+ # replace, or remove top-level keys in configuration.yaml and package
105
+ # files. Disabled by default; only for YAML-only features with no UI/API path.
106
+ enable_yaml_config_editing: bool = Field(False, alias="ENABLE_YAML_CONFIG_EDITING")
107
+
108
+ # Seed values for tool visibility (comma-separated tool names).
109
+ # Used as initial config when no tool_config.json exists.
110
+ # The web settings UI (/settings) is the primary interface for managing these.
111
+ disabled_tools: str = Field("", alias="DISABLED_TOOLS")
112
+ pinned_tools: str = Field("", alias="PINNED_TOOLS")
113
+
114
+ # Max results returned by ha_search_tools. Pydantic enforces the
115
+ # 2-10 range; the addon-dev schema also uses ``int(2,10)?`` so the
116
+ # supervisor UI rejects out-of-range values before they reach env vars.
117
+ tool_search_max_results: int = Field(
118
+ 5, ge=2, le=10, alias="TOOL_SEARCH_MAX_RESULTS"
119
+ )
120
+
121
+ # Lite docstrings — replace selected heavy tool descriptions with
122
+ # shorter variants that defer detailed guidance to the
123
+ # ``ha_get_skill_guide`` skill tool/resource.
124
+ # Reduces idle catalog token usage at the cost of relying on the LLM
125
+ # to actually consult the skill when it needs detail. Beta feature
126
+ # (issue #1062); a startup WARNING is emitted when enabled so
127
+ # env-var users see the trade-off in their logs.
128
+ enable_lite_docstrings: bool = Field(False, alias="ENABLE_LITE_DOCSTRINGS")
129
+
130
+ # Filesystem tools — read/write/delete/list under the HA config dir.
131
+ # Previously gated by a direct ``os.getenv`` call in
132
+ # ``tools/tools_filesystem.py`` so callers (and the settings UI)
133
+ # couldn't see it through ``Settings``. Promoted to a first-class
134
+ # Settings field so the same precedence path applies as for every
135
+ # other gated capability.
136
+ enable_filesystem_tools: bool = Field(False, alias="HAMCP_ENABLE_FILESYSTEM_TOOLS")
137
+
138
+ # Custom-component installer (``ha_install_mcp_tools``) — pulls the
139
+ # ``ha_mcp_tools`` integration into HACS. Same env-var-direct
140
+ # background as ``enable_filesystem_tools``; promoted for the same
141
+ # reason.
142
+ enable_custom_component_integration: bool = Field(
143
+ False, alias="HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"
144
+ )
145
+
146
+ # Code Mode — sandboxed Python execution via pydantic-monty.
147
+ # Provides an "escape hatch" tool (ha_manage_custom_tool) that lets LLMs write
148
+ # custom one-off Python code when no existing tool covers the request.
149
+ # Disabled by default due to the inherent risk of LLM-generated code.
150
+ # Range bounds reject zero/negative values that would silently break the
151
+ # tool and clamp upper bounds at sane safety margins (5 min wall-clock,
152
+ # 256 MB memory, 10k recursion, 10k API/tool calls per execution).
153
+ enable_code_mode: bool = Field(False, alias="ENABLE_CODE_MODE")
154
+ code_mode_max_duration: float = Field(
155
+ 30.0, ge=1.0, le=300.0, alias="CODE_MODE_MAX_DURATION"
156
+ )
157
+ code_mode_max_memory: int = Field(
158
+ 10_485_760, ge=1_048_576, le=268_435_456, alias="CODE_MODE_MAX_MEMORY"
159
+ ) # 10 MB default; 1 MB floor, 256 MB ceiling
160
+ code_mode_max_recursion: int = Field(
161
+ 100, ge=1, le=10_000, alias="CODE_MODE_MAX_RECURSION"
162
+ )
163
+ code_mode_max_invocations: int = Field(
164
+ 100, ge=1, le=10_000, alias="CODE_MODE_MAX_INVOCATIONS"
165
+ )
166
+ # Path to a JSON file for persisting saved custom tools across restarts.
167
+ # Empty string disables persistence (saved tools live in process memory
168
+ # and are lost on restart). The addon sets this to /data/saved_tools.json
169
+ # by default so saved tools survive addon restarts (the /data directory
170
+ # is mapped per-addon by Supervisor and is preserved across addon
171
+ # updates).
172
+ code_mode_saved_tools_path: str = Field("", alias="CODE_MODE_SAVED_TOOLS_PATH")
173
+
174
+ # Mirror the legacy ``os.getenv("FLAG", "").lower() in ("true", ...)``
175
+ # semantics for the two ex-direct-getenv flags: an empty env var
176
+ # value MUST be treated as False rather than raising
177
+ # ``ValidationError``. Pydantic v2's bool parser raises on ``""``
178
+ # which broke ``test_tools_filesystem.py::TestFeatureFlag::
179
+ # test_disabled_with_empty_string`` after the migration; this
180
+ # validator restores the contract callers rely on.
181
+ @field_validator(
182
+ "enable_filesystem_tools",
183
+ "enable_custom_component_integration",
184
+ mode="before",
185
+ )
186
+ @classmethod
187
+ def _empty_string_means_false(cls, v: object) -> object:
188
+ if isinstance(v, str) and not v.strip():
189
+ return False
190
+ return v
191
+
192
+ @property
193
+ def env_file_name(self) -> str:
194
+ """Get the current environment file name."""
195
+ return os.getenv("HAMCP_ENV_FILE", ".env")
196
+
197
+ @field_validator("homeassistant_url")
198
+ @classmethod
199
+ def validate_homeassistant_url(cls, v: str) -> str:
200
+ """Ensure URL is properly formatted."""
201
+ # Allow OAuth mode placeholder
202
+ if v == OAUTH_MODE_URL:
203
+ return v
204
+ if not v.startswith(("http://", "https://")):
205
+ raise ValueError("Home Assistant URL must start with http:// or https://")
206
+ return v.rstrip("/") # Remove trailing slash
207
+
208
+ @field_validator("homeassistant_token")
209
+ @classmethod
210
+ def validate_homeassistant_token(cls, v: str) -> str:
211
+ """Ensure token is not empty. Use 'demo' for public demo environment."""
212
+ # Allow OAuth mode placeholder
213
+ if v == OAUTH_MODE_TOKEN:
214
+ return v
215
+ if not v or v == "your_long_lived_access_token_here":
216
+ raise ValueError("Home Assistant token must be provided")
217
+ # Replace "demo" with actual demo token for easy onboarding
218
+ if v.lower() == "demo":
219
+ return DEMO_TOKEN
220
+ return v
221
+
222
+ @field_validator("fuzzy_threshold")
223
+ @classmethod
224
+ def validate_fuzzy_threshold(cls, v: int) -> int:
225
+ """Ensure fuzzy threshold is reasonable."""
226
+ if not 0 <= v <= 100:
227
+ raise ValueError("Fuzzy threshold must be between 0 and 100")
228
+ return v
229
+
230
+ @field_validator("log_level")
231
+ @classmethod
232
+ def validate_log_level(cls, v: str) -> str:
233
+ """Ensure log level is valid."""
234
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
235
+ if v.upper() not in valid_levels:
236
+ raise ValueError(f"Log level must be one of {valid_levels}")
237
+ return v.upper()
238
+
239
+ @field_validator("backup_hint")
240
+ @classmethod
241
+ def validate_backup_hint(cls, v: str) -> str:
242
+ """Ensure backup hint is valid."""
243
+ valid_hints = ["strong", "normal", "weak", "auto"]
244
+ if v.lower() not in valid_hints:
245
+ raise ValueError(f"Backup hint must be one of {valid_hints}")
246
+ return v.lower()
247
+
248
+ model_config = SettingsConfigDict(
249
+ env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="allow"
250
+ )
251
+
252
+
253
+ def get_settings() -> Settings:
254
+ """Get application settings."""
255
+ return Settings() # type: ignore[call-arg]
256
+
257
+
258
+ def validate_settings() -> tuple[bool, str | None]:
259
+ """
260
+ Validate settings and return (is_valid, error_message).
261
+
262
+ Returns:
263
+ tuple: (True, None) if valid, (False, error_message) if invalid
264
+ """
265
+ try:
266
+ settings = get_settings()
267
+
268
+ # Additional validation
269
+ if not settings.homeassistant_url:
270
+ return False, "Home Assistant URL is required"
271
+
272
+ if not settings.homeassistant_token:
273
+ return False, "Home Assistant token is required"
274
+
275
+ return True, None
276
+ except Exception as e:
277
+ return False, str(e)
278
+
279
+
280
+ # Runtime-editable feature flags surfaced in the /settings web UI
281
+ # (issue #863). Each entry is (field_name, env_var_name, python_type).
282
+ # The web UI's /api/settings/features GET/POST endpoints iterate this
283
+ # tuple to advertise per-field origin (env / addon / file / default)
284
+ # and to validate incoming writes. Precedence: explicit env var beats
285
+ # the override file, addon mode (SUPERVISOR_TOKEN set) ignores the
286
+ # file entirely (start.py owns env vars from config.yaml in that
287
+ # mode), and the pydantic field default is the fallback.
288
+ FEATURE_FLAG_FIELDS: tuple[tuple[str, str, type], ...] = (
289
+ ("enable_tool_search", "ENABLE_TOOL_SEARCH", bool),
290
+ ("tool_search_max_results", "TOOL_SEARCH_MAX_RESULTS", int),
291
+ ("enable_yaml_config_editing", "ENABLE_YAML_CONFIG_EDITING", bool),
292
+ ("enable_lite_docstrings", "ENABLE_LITE_DOCSTRINGS", bool),
293
+ ("enable_filesystem_tools", "HAMCP_ENABLE_FILESYSTEM_TOOLS", bool),
294
+ (
295
+ "enable_custom_component_integration",
296
+ "HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION",
297
+ bool,
298
+ ),
299
+ )
300
+
301
+ # Override-file location is the same data dir that holds tool_config.json
302
+ # (resolved via ``utils.data_paths.get_data_dir`` — addon ``/data``,
303
+ # ``HA_MCP_CONFIG_DIR``, ``XDG_DATA_HOME``, or a tmpdir fallback).
304
+ # Imported lazily inside helpers to avoid a circular import at module
305
+ # load.
306
+ _FEATURE_FLAG_OVERRIDE_FILENAME = "feature_flags.json"
307
+
308
+ # Per-field validation bounds for non-bool fields. Only fields with
309
+ # range constraints need entries here; bools are handled by the
310
+ # coercion in ``_apply_feature_flag_overrides``. Mirrors the pydantic
311
+ # Field bounds on the same fields so a corrupt override file can't
312
+ # push values out of range.
313
+ _FEATURE_FLAG_INT_BOUNDS: dict[str, tuple[int, int]] = {
314
+ "tool_search_max_results": (2, 10),
315
+ }
316
+
317
+
318
+ def get_feature_flag_origin(env_name: str) -> str:
319
+ """Return where the live value for ``env_name`` is sourced from.
320
+
321
+ Used by the web UI to label each feature-flag field with its
322
+ source and decide whether the field is editable from the web UI:
323
+
324
+ - ``"addon"``: running inside the HA add-on. ``start.py`` always
325
+ writes these env vars from ``config.yaml`` on every addon
326
+ start; the override file is ignored. Web UI edits are routed
327
+ through Supervisor ``/addons/self/options`` so ``config.yaml``
328
+ stays authoritative. (The current PR exposes flags read-only
329
+ in addon mode; routing addon edits through Supervisor is
330
+ tracked separately.)
331
+ - ``"env"``: env var explicitly set in the process environment
332
+ (includes values loaded from ``.env`` via ``load_dotenv`` at
333
+ module import — those land in ``os.environ`` and are
334
+ indistinguishable from ``docker -e`` / shell-set values,
335
+ which is intentional). Web UI shows the field read-only;
336
+ user must unset the env var to edit.
337
+ - ``"file"``: standalone deployment with a value persisted in
338
+ ``<data_dir>/feature_flags.json``. Web UI edits update the
339
+ file in place.
340
+ - ``"default"``: no env var and no override file entry; the
341
+ pydantic field default applies. Web UI edits create the file.
342
+ """
343
+ if os.environ.get("SUPERVISOR_TOKEN"):
344
+ return "addon"
345
+ if os.environ.get(env_name) is not None:
346
+ return "env"
347
+ field_name = next(
348
+ (fname for fname, ename, _ in FEATURE_FLAG_FIELDS if ename == env_name),
349
+ None,
350
+ )
351
+ if field_name is None:
352
+ return "default"
353
+ overrides = _read_feature_flag_override_file()
354
+ if field_name in overrides:
355
+ return "file"
356
+ return "default"
357
+
358
+
359
+ def _read_feature_flag_override_file() -> dict[str, object]:
360
+ """Return the contents of the feature-flag override file, or ``{}``.
361
+
362
+ Best-effort: a corrupt file MUST NOT break Settings loading. But
363
+ the failure modes split into two categories that need different
364
+ treatment:
365
+
366
+ * **Silent**: file does not exist. The override layer is opt-in;
367
+ a missing file is the normal "user has never edited" state and
368
+ should not log.
369
+ * **Loud (WARNING)**: file exists but is unreadable
370
+ (``PermissionError``, broken filesystem) or unparseable
371
+ (``JSONDecodeError``). The user toggled something, the UI said
372
+ "Saved", and the value is silently being ignored. Without a log
373
+ line they have no diagnostic; with one, the sidecar/server log
374
+ tells them exactly what to fix.
375
+
376
+ Data-dir resolution itself can raise (``RuntimeError`` when
377
+ ``Path.home()`` cannot determine a home directory — typical of
378
+ pytest's ``patch.dict(os.environ, {}, clear=True)``), so the
379
+ ``get_data_dir()`` call is inside the try/except too. That branch
380
+ is treated as silent: the user could not have created an override
381
+ file in a directory we cannot resolve.
382
+ """
383
+ import json
384
+ from pathlib import Path
385
+
386
+ try:
387
+ from .utils.data_paths import get_data_dir
388
+
389
+ path: Path = get_data_dir() / _FEATURE_FLAG_OVERRIDE_FILENAME
390
+ except (RuntimeError, OSError):
391
+ # Couldn't resolve the data dir at all — user has no override
392
+ # file by definition. Silent.
393
+ return {}
394
+ try:
395
+ raw = path.read_text()
396
+ except FileNotFoundError:
397
+ return {}
398
+ except OSError:
399
+ logger.warning(
400
+ "Feature-flag override file at %s exists but is unreadable; "
401
+ "falling back to defaults. Check filesystem permissions.",
402
+ path,
403
+ exc_info=True,
404
+ )
405
+ return {}
406
+ try:
407
+ data = json.loads(raw)
408
+ except ValueError:
409
+ logger.warning(
410
+ "Feature-flag override file at %s is not valid JSON; "
411
+ "falling back to defaults. Delete or fix the file to "
412
+ "re-enable persisted toggles.",
413
+ path,
414
+ )
415
+ return {}
416
+ if not isinstance(data, dict):
417
+ logger.warning(
418
+ "Feature-flag override file at %s is not a JSON object "
419
+ "(got %s); falling back to defaults.",
420
+ path,
421
+ type(data).__name__,
422
+ )
423
+ return {}
424
+ return data
425
+
426
+
427
+ def _apply_feature_flag_overrides(settings: "Settings") -> None:
428
+ """Patch ``settings`` with values from the override file, in place.
429
+
430
+ Honors the "env var wins" contract: a field whose env var is set
431
+ in the process environment is never overwritten. Addon mode
432
+ short-circuits — ``start.py`` already wrote env vars from
433
+ ``config.yaml`` and the override file is ignored. Range / type
434
+ coercion mirrors the pydantic Field bounds so a corrupt file
435
+ can't push values out of range; out-of-range or untypable
436
+ entries are logged at WARNING and skipped rather than crashing
437
+ every consumer of ``get_global_settings()``.
438
+ """
439
+ if os.environ.get("SUPERVISOR_TOKEN"):
440
+ return
441
+ overrides = _read_feature_flag_override_file()
442
+ if not overrides:
443
+ return
444
+ for field_name, env_name, ftype in FEATURE_FLAG_FIELDS:
445
+ if os.environ.get(env_name) is not None:
446
+ continue
447
+ if field_name not in overrides:
448
+ continue
449
+ raw = overrides[field_name]
450
+ coerced: bool | int
451
+ if ftype is bool:
452
+ if not isinstance(raw, bool | int):
453
+ logger.warning(
454
+ "Override for %r is %s; expected bool — ignoring.",
455
+ field_name,
456
+ type(raw).__name__,
457
+ )
458
+ continue
459
+ coerced = bool(raw)
460
+ elif ftype is int:
461
+ if isinstance(raw, bool) or not isinstance(raw, int):
462
+ # Reject bool-typed ints (since ``bool`` subclasses
463
+ # ``int``, a stray ``true`` would otherwise coerce to
464
+ # ``1``).
465
+ logger.warning(
466
+ "Override for %r is %s; expected int — ignoring.",
467
+ field_name,
468
+ type(raw).__name__,
469
+ )
470
+ continue
471
+ coerced = int(raw)
472
+ bounds = _FEATURE_FLAG_INT_BOUNDS.get(field_name)
473
+ if bounds is not None and not (bounds[0] <= coerced <= bounds[1]):
474
+ logger.warning(
475
+ "Override for %r is %d, outside %d-%d — ignoring.",
476
+ field_name,
477
+ coerced,
478
+ bounds[0],
479
+ bounds[1],
480
+ )
481
+ continue
482
+ else:
483
+ continue
484
+ try:
485
+ setattr(settings, field_name, coerced)
486
+ except Exception:
487
+ # Pydantic field validators may raise types other than the
488
+ # ones we expect (ValidationError is a ValueError subclass
489
+ # but mode='before' validators can route weird shapes
490
+ # through arbitrary code). Log at WARNING and skip rather
491
+ # than letting a malformed override crash every consumer of
492
+ # ``get_global_settings()``.
493
+ logger.warning(
494
+ "Override for %r could not be applied to Settings; ignoring.",
495
+ field_name,
496
+ exc_info=True,
497
+ )
498
+
499
+
500
+ # Global settings instance
501
+ _settings: Settings | None = None
502
+
503
+
504
+ def get_global_settings() -> Settings:
505
+ """Get global settings instance (singleton pattern).
506
+
507
+ Applies feature-flag overrides from
508
+ ``<data_dir>/feature_flags.json`` after pydantic construction so
509
+ web-UI edits take effect on the next ``get_global_settings()``
510
+ call after ``_reset_global_settings()`` is called by the POST
511
+ handler.
512
+ """
513
+ global _settings
514
+ if _settings is None:
515
+ _settings = get_settings()
516
+ _apply_feature_flag_overrides(_settings)
517
+ return _settings
518
+
519
+
520
+ def _reset_global_settings() -> None:
521
+ """Drop the cached settings singleton.
522
+
523
+ Test seam so suites that mutate env vars can force a re-read
524
+ without reaching into module-private state. Also used by the
525
+ feature-flag settings POST handler to publish a freshly edited
526
+ override file value to runtime consumers (``get_global_settings``
527
+ is the only documented read path).
528
+ """
529
+ global _settings
530
+ _settings = None