"""Hot-reload of user tools, context providers and MCP servers.
One implementation serves both surfaces — the ``reload_tools`` built-in and the
admin endpoint behind the settings button — so the two can never drift apart.
The MCP half especially: ``register_mcp_tools`` only ever *adds* registrations,
so reconnecting without dropping the old ones left ghost tools for every server
removed from the config or every tool the server stopped exposing.
"""
from dataclasses import dataclass, field
import structlog
from navi.config import settings
log = structlog.get_logger()
@dataclass
class ReloadReport:
"""What a reload loaded, and everything that went wrong on the way."""
tools: list[str] = field(default_factory=list)
total_tools: int = 0
tool_errors: dict[str, str] = field(default_factory=dict)
missing_enabled: list[str] = field(default_factory=list)
providers: list[str] = field(default_factory=list)
provider_errors: dict[str, str] = field(default_factory=dict)
mcp_tools: list[str] = field(default_factory=list)
mcp_error: str | None = None
@property
def ok(self) -> bool:
"""True when nothing failed to load. ``missing_enabled`` is a warning:
the name in enabled.json is wrong, but the reload itself worked."""
return not self.tool_errors and not self.provider_errors and self.mcp_error is None
def as_dict(self) -> dict:
return {
"ok": self.ok,
"tools": self.tools,
"total_tools": self.total_tools,
"tool_errors": self.tool_errors,
"missing_enabled": self.missing_enabled,
"providers": self.providers,
"provider_errors": self.provider_errors,
"mcp_tools": self.mcp_tools,
"mcp_error": self.mcp_error,
}
def lines(self) -> list[str]:
"""Human/model-readable report, one line per fact."""
lines = [
f"Tools ({len(self.tools)}): {', '.join(self.tools)}."
if self.tools
else "Tools: none.",
]
if self.tool_errors:
lines.append(f"Tool errors ({len(self.tool_errors)}):")
lines.extend(f" {f}: {e}" for f, e in self.tool_errors.items())
if self.missing_enabled:
lines.append(
f"Warning: enabled.json names {len(self.missing_enabled)} tool(s) that do not "
f"exist and are silently dropped from every profile: "
f"{', '.join(self.missing_enabled)}."
)
lines.append(
f"Context providers ({len(self.providers)}): {', '.join(self.providers)}."
if self.providers
else "Context providers: none."
)
if self.provider_errors:
lines.append(f"Context provider errors ({len(self.provider_errors)}):")
lines.extend(f" {f}: {e}" for f, e in self.provider_errors.items())
if self.mcp_error is not None:
lines.append(f"MCP reload error: {self.mcp_error}")
else:
lines.append(
f"MCP tools ({len(self.mcp_tools)}): {', '.join(self.mcp_tools)}."
if self.mcp_tools
else "MCP tools: none."
)
return lines
def _missing_enabled(registry) -> list[str]:
"""Names in tools/enabled.json that no tool in the registry answers to."""
from navi.core.tool_utils import load_user_enabled_tools
missing = []
for name in load_user_enabled_tools():
try:
registry.get(name)
except Exception:
missing.append(name)
return missing
async def _reload_mcp(registry, mcp_manager) -> tuple[list[str], str | None]:
"""Reconnect every MCP server and rebuild its tool registrations.
Every external registration is dropped first — see the module docstring.
Servers that are down right now lose their tools until the health-check
reconnects them, which re-registers them through the manager's
on_server_connected callback.
"""
from navi.api.deps import register_mcp_tools
from navi.mcp.tools import is_mcp_tool
registry.clear_external_tools()
await mcp_manager.reload_all()
await register_mcp_tools(registry, mcp_manager)
return sorted(t.name for t in registry.all() if is_mcp_tool(t.name)), None
async def reload_all(registry, cp_registry=None, mcp_manager=None) -> ReloadReport:
"""Reload user tools, context providers and MCP servers in place."""
report = ReloadReport()
if registry is not None:
tool_result = registry.reload_user_tools(settings.tools_dir)
report.tools = [t.name for t in tool_result.loaded]
report.tool_errors = dict(tool_result.errors)
report.total_tools = len(registry.all())
report.missing_enabled = _missing_enabled(registry)
if cp_registry is not None:
cp_result = cp_registry.reload_user_providers(settings.context_providers_dir)
report.providers = list(cp_result.loaded)
report.provider_errors = dict(cp_result.errors)
if mcp_manager is not None and registry is not None:
try:
report.mcp_tools, report.mcp_error = await _reload_mcp(registry, mcp_manager)
except Exception as exc: # noqa: BLE001 — a failed MCP pass must not lose the rest
log.warning("reload.mcp_failed", error=str(exc))
report.mcp_error = f"{type(exc).__name__}: {exc}"
log.info(
"reload.done",
tools=len(report.tools),
tool_errors=len(report.tool_errors),
missing_enabled=len(report.missing_enabled),
providers=len(report.providers),
mcp_tools=len(report.mcp_tools),
ok=report.ok,
)
return report