The onboarding install card installs its rows concurrently, and each install runs load_and_go_live: a forced plugin rediscovery, then a read of the plugin's MCP server configs and skills, then an MCP connect. A forced rediscovery unloads every plugin first (PluginManager.unload clears _portable_mcp_servers, _plugin_skills and each server's liveness declaration) and only then loads them again. When NVIDIA App and NVIDIA Broadcast finished installing together, the Broadcast pass unloaded the manager while the NVIDIA App go-live was reading it: NVIDIA App went live with no MCP servers and no skill (the card row said "Installed" with no tool count, and the build chat had no NVIDIA App tools until the app restarted). load_and_go_live now runs one at a time in the process (_GO_LIVE_LOCK), so a second install's rediscovery cannot run while the first reads or connects. The connect is inside that lock because it reads the server's liveness declaration (tools/mcp_tool_transport.py::_live_endpoint), which a forced pass also clears. The reads share the manager's discovery lock with the pass that produced them, for forced passes that do not go through load_and_go_live, and connect_plugin_mcp takes the server configs read there instead of reading the manager again. The MCP connect stays outside the discovery lock, so chats that call discover_plugins() are not held for the length of a connect.
132 lines
6.2 KiB
Python
132 lines
6.2 KiB
Python
"""Make an installed plugin's MCP servers and skills usable in THIS process now (#87770 follow-up).
|
|
|
|
:func:`hermes_cli.plugins_activation.activate_plugin_now` loads the plugin; skills are live from that
|
|
point (``skill_view`` resolves ``<plugin>:<skill>``). MCP servers are not: nothing connects them until
|
|
``reload.mcp``. :func:`connect_plugin_mcp` connects the plugin's servers one by one (the connector
|
|
flow's ``register_mcp_servers({name: config})``), and :func:`live_notice` renders what became usable
|
|
as the note an open chat receives on its next turn.
|
|
|
|
MCP tools are deferred behind ``tool_search`` / ``tool_describe`` / ``tool_call``, so adding them to a
|
|
live session leaves the model-facing tool array (and the cached prompt prefix) byte-identical.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def connect_plugin_mcp(activation: Dict[str, Any], portable: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Connect every portable MCP server ``activation`` defers, under the caller's profile scope.
|
|
``portable`` is the manager's portable server configs, read together with ``activation``.
|
|
|
|
Returns ``[{name, connected, tools, error?}]``, one row per server; a server that fails to
|
|
connect is reported, never retried. Never raises."""
|
|
names = list((activation.get("deferred") or {}).get("mcp_servers") or ())
|
|
if not names:
|
|
return []
|
|
try:
|
|
from tools.mcp_tool_config import _filter_suspicious_mcp_servers, _load_mcp_config
|
|
from tools.mcp_tool_discovery import register_mcp_servers
|
|
configured = _load_mcp_config()
|
|
except Exception as exc:
|
|
logger.warning("plugin MCP activation could not read server config: %s", exc)
|
|
return [{"name": n, "connected": False, "tools": [], "error": str(exc)} for n in names]
|
|
rows = []
|
|
for name in names:
|
|
config = configured.get(name) or _filter_suspicious_mcp_servers(
|
|
{name: portable[name]} if name in portable else {}).get(name)
|
|
if not isinstance(config, dict):
|
|
rows.append({"name": name, "connected": False, "tools": [], "error": "no MCP configuration"})
|
|
continue
|
|
try:
|
|
register_mcp_servers({name: config})
|
|
from tools.connectors.mcp import _registered_tool_names
|
|
tools = [t for t in _registered_tool_names(name) if not t.endswith(_utility_suffixes())]
|
|
except Exception as exc:
|
|
rows.append({"name": name, "connected": False, "tools": [], "error": str(exc)})
|
|
continue
|
|
row: Dict[str, Any] = {"name": name, "connected": bool(tools), "tools": tools}
|
|
if not tools:
|
|
row["error"] = _server_error(name) or "the server did not connect"
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def _utility_suffixes() -> tuple:
|
|
"""The resource/prompt wrappers every MCP server gets (``mcp__x__list_resources``, ...): callable,
|
|
but they say nothing about what the plugin does, so they are left out of counts and listings."""
|
|
from tools.mcp_tool_registration import _UTILITY_HANDLER_FACTORIES
|
|
return tuple(f"_{kind}" for kind in _UTILITY_HANDLER_FACTORIES)
|
|
|
|
|
|
def _server_error(name: str) -> Optional[str]:
|
|
try:
|
|
from tools.mcp_tool_discovery import get_mcp_status
|
|
for entry in get_mcp_status():
|
|
if entry.get("name") == name:
|
|
return entry.get("error")
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def plugin_skills(plugin_key: str) -> List[Dict[str, str]]:
|
|
"""``[{name, description}]`` for the skills ``plugin_key`` registered (qualified ``<ns>:<skill>``)."""
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
skills = get_plugin_manager()._plugin_skills
|
|
except Exception:
|
|
return []
|
|
return [{"name": qualified, "description": str(entry.get("description") or "")}
|
|
for qualified, entry in sorted(skills.items()) if entry.get("plugin_key") == plugin_key]
|
|
|
|
|
|
def live_notice(activation: Dict[str, Any]) -> str:
|
|
"""The note an open chat gets on its next turn: connected MCP servers with their tools, skills,
|
|
and what waits for the next session. Empty when nothing became usable."""
|
|
live = activation.get("live_now") or {}
|
|
servers, skills = live.get("mcp_servers") or [], live.get("skills") or []
|
|
deferred = activation.get("deferred") or {}
|
|
if not servers and not skills:
|
|
return ""
|
|
lines = [f"[plugin installed: {activation.get('name') or activation.get('key')}]"]
|
|
connected = [s for s in servers if s.get("connected")]
|
|
if connected:
|
|
lines.append("MCP servers now connected; call their tools with tool_describe / tool_call:")
|
|
for server in connected:
|
|
lines.append(f"- {server['name']}")
|
|
listing = _tool_listing(server["tools"])
|
|
if listing:
|
|
lines.extend(f" {line}" for line in listing.splitlines())
|
|
for server in (s for s in servers if not s.get("connected")):
|
|
lines.append(f"- {server['name']}: not connected ({server.get('error') or 'unknown error'})")
|
|
if skills:
|
|
lines.append("Skills now available; load one with skill_view:")
|
|
lines.extend(f"- {s['name']}: {s['description']}" if s.get("description") else f"- {s['name']}"
|
|
for s in skills)
|
|
later = [f"{len(deferred['tools'])} Python tools" if deferred.get("tools") else "",
|
|
f"{len(deferred['prompt'])} prompt sections" if deferred.get("prompt") else ""]
|
|
if any(later):
|
|
lines.append("Next session: " + ", ".join(p for p in later if p) + ".")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _tool_listing(names: List[str]) -> str:
|
|
"""``name: first sentence`` per tool."""
|
|
try:
|
|
from tools.registry import registry
|
|
from tools.tool_search_catalog import _short_desc
|
|
definitions = registry.get_definitions(set(names), quiet=True)
|
|
except Exception:
|
|
return ""
|
|
rows = []
|
|
for definition in definitions:
|
|
fn = definition.get("function") or definition
|
|
name = str(fn.get("name") or "")
|
|
if name:
|
|
desc = _short_desc(fn.get("description") or "")
|
|
rows.append(f"{name}: {desc}" if desc else name)
|
|
return "\n".join(sorted(rows))
|