"""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 is the delicate one, in two ways. MCP registrations are rebuilt from
what the servers answer *right now*, so a server removed from the config (or a
tool it stopped exposing) has no other way to leave the registry — which is why
they are replaced wholesale, and not added to. And because that rebuild talks to
every server, it is done in two steps: collect what each server offers, then
commit the whole set in one synchronous swap. A rebuild that fails or runs out of
budget on the way therefore leaves the previous registrations exactly as they
were, instead of an empty registry with the model holding names that resolve to
nothing.
"""
import asyncio
from dataclasses import dataclass, field
import structlog
from navi.config import settings
log = structlog.get_logger()
# How long the whole MCP half may take: reconnect every server, list every
# server's tools. A healthy fleet answers in seconds; the budget exists so a
# fleet with a server that has stopped answering still produces a report — with
# the previous registrations left untouched — instead of hanging the turn that
# called reload_tools. It is the backstop, not the first line of defence: every
# individual MCP operation carries its own bound (see navi/mcp/client.py), so a
# single silent server costs its own bound here, not the whole budget.
MCP_RELOAD_BUDGET_S = 120.0
@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} — the MCP tools registered "
"before it were left untouched."
)
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.
Budgeted as a whole, and committed in one step: whatever happens — a server
that never answers, a teardown that will not finish — the registry either
receives the complete new set or keeps the one it had. 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.mcp.tools import build_mcp_tools
async with asyncio.timeout(MCP_RELOAD_BUDGET_S):
await mcp_manager.reload_all()
tools = await build_mcp_tools(mcp_manager)
# No await from here to the swap: the registry is never seen half-built.
registry.replace_external_tools(tools)
return sorted(t.name for t in tools), 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 TimeoutError:
# The phase ran out of budget and was cancelled: nothing was
# committed, so the registry still holds the tools it had.
log.warning("reload.mcp_timeout", budget_s=MCP_RELOAD_BUDGET_S)
report.mcp_error = f"did not finish within {MCP_RELOAD_BUDGET_S:.0f}s"
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