"""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 ``:``). 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 ``:``).""" 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))