1029 lines
41 KiB
Python
1029 lines
41 KiB
Python
"""MCP Server Registry: local state management for installed MCP servers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import tomllib
|
|
from datetime import UTC, datetime
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
import httpx
|
|
|
|
from framework.loader.mcp_client import MCPClient, MCPServerConfig
|
|
from framework.loader.mcp_connection_manager import MCPConnectionManager
|
|
from framework.loader.mcp_errors import (
|
|
MCPError,
|
|
MCPErrorCode,
|
|
MCPInstallError,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/aden-hive/hive-mcp-registry/main/registry_index.json"
|
|
DEFAULT_REFRESH_INTERVAL_HOURS = 24
|
|
_LAST_FETCHED_FILENAME = "last_fetched"
|
|
_LEGACY_LAST_FETCHED_FILENAME = "last_fetched.json"
|
|
|
|
_DEFAULT_CONFIG = {
|
|
"index_url": DEFAULT_INDEX_URL,
|
|
"refresh_interval_hours": DEFAULT_REFRESH_INTERVAL_HOURS,
|
|
}
|
|
|
|
# Default local MCP servers that ship with Hive. Seeded on first startup so
|
|
# fresh users get working file I/O, browser automation, and the hive tool
|
|
# suite without having to run `hive mcp add` manually. ``cwd`` is filled in
|
|
# at registration time with the absolute path to the ``tools/`` directory.
|
|
_DEFAULT_LOCAL_SERVERS: dict[str, dict[str, Any]] = {
|
|
"hive_tools": {
|
|
"description": "Hive tools: web search, email, CRM, calendar, and 100+ integrations",
|
|
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
|
},
|
|
"gcu-tools": {
|
|
"description": "Browser automation: click, type, navigate, screenshot, snapshot",
|
|
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
|
},
|
|
"files-tools": {
|
|
"description": "File I/O: read, write, edit, search, list, run commands",
|
|
"args": ["run", "python", "files_server.py", "--stdio"],
|
|
},
|
|
"terminal-tools": {
|
|
"description": "Terminal capabilities: process exec, background jobs, PTY sessions, fs search. Bash-only on POSIX.",
|
|
"args": ["run", "python", "terminal_tools_server.py", "--stdio"],
|
|
},
|
|
}
|
|
|
|
# Aliases that earlier versions of ensure_defaults wrote under the wrong name.
|
|
# When we see one of these stale entries, drop it before seeding the canonical
|
|
# name so the active agents (queen, credential_tester) can find their tools.
|
|
_STALE_DEFAULT_ALIASES: dict[str, str] = {
|
|
"hive_tools": "hive-tools",
|
|
# 2026-04-30: shell-tools renamed to terminal-tools. Drop the stale name
|
|
# on next ensure_defaults() so the queen's allowlist (which now includes
|
|
# @server:terminal-tools) actually finds a server with the new name.
|
|
"terminal-tools": "shell-tools",
|
|
}
|
|
|
|
|
|
class MCPRegistry:
|
|
"""Manages local MCP server state in $HIVE_HOME/mcp_registry/."""
|
|
|
|
def __init__(self, base_path: Path | None = None):
|
|
if base_path is None:
|
|
from framework.config import HIVE_HOME
|
|
|
|
base_path = HIVE_HOME / "mcp_registry"
|
|
self._base = base_path
|
|
self._installed_path = self._base / "installed.json"
|
|
self._config_path = self._base / "config.json"
|
|
self._cache_dir = self._base / "cache"
|
|
|
|
# ── Initialization ──────────────────────────────────────────────
|
|
|
|
def initialize(self) -> None:
|
|
"""Create directory structure, default files, and seed bundled servers.
|
|
|
|
Every read path (queen orchestrator, pipeline stage, CLI, routes)
|
|
calls this — keeping the seeding here means a fresh ``HIVE_HOME``
|
|
(e.g. the desktop's per-user dir under ``~/.config/Hive/users/<hash>/``
|
|
or ``~/Library/Application Support/Hive/users/<hash>/``) is always
|
|
populated with ``hive_tools`` / ``gcu-tools`` / ``files-tools`` /
|
|
``shell-tools`` before any agent code reads ``installed.json``.
|
|
Without this, ``load_agent_selection()`` resolves an empty registry
|
|
and emits "Server X requested but not installed" warnings even
|
|
though the server is bundled.
|
|
|
|
Idempotent — already-installed entries are left untouched.
|
|
"""
|
|
self._bootstrap_io()
|
|
self._seed_defaults()
|
|
|
|
def _bootstrap_io(self) -> None:
|
|
"""Create the registry directory + empty config/installed files.
|
|
|
|
Split out from ``initialize()`` so ``_seed_defaults()`` can call it
|
|
without re-entering the seeding logic (which would recurse via
|
|
``_read_installed()`` → ``initialize()``).
|
|
"""
|
|
self._base.mkdir(parents=True, exist_ok=True)
|
|
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not self._config_path.exists():
|
|
self._write_json(self._config_path, _DEFAULT_CONFIG)
|
|
|
|
if not self._installed_path.exists():
|
|
self._write_json(self._installed_path, {"servers": {}})
|
|
|
|
def ensure_defaults(self) -> list[str]:
|
|
"""Public alias kept for the ``hive mcp init`` CLI command.
|
|
|
|
Returns the list of newly-registered server names so the CLI can
|
|
print them. Same idempotent seeding logic as ``initialize()``.
|
|
"""
|
|
self._bootstrap_io()
|
|
return self._seed_defaults()
|
|
|
|
def _seed_defaults(self) -> list[str]:
|
|
"""Idempotently register the bundled default local servers.
|
|
|
|
Skips entirely when the source-tree ``tools/`` directory cannot
|
|
be located (e.g. wheel installs). Returns the list of names that
|
|
were newly registered.
|
|
"""
|
|
# parents: [0]=loader, [1]=framework, [2]=core, [3]=repo root
|
|
tools_dir = Path(__file__).resolve().parents[3] / "tools"
|
|
if not tools_dir.is_dir():
|
|
logger.debug(
|
|
"MCPRegistry._seed_defaults: tools dir %s missing; skipping default seed",
|
|
tools_dir,
|
|
)
|
|
return []
|
|
|
|
cwd = str(tools_dir)
|
|
data = self._read_installed()
|
|
existing = data.get("servers", {})
|
|
added: list[str] = []
|
|
|
|
# Drop stale aliases (from earlier versions that wrote the wrong name).
|
|
# Only remove the alias when the canonical name isn't already installed,
|
|
# so we never clobber a hand-edited entry the user cares about.
|
|
mutated = False
|
|
for canonical, stale in _STALE_DEFAULT_ALIASES.items():
|
|
if stale in existing and canonical not in existing:
|
|
logger.info(
|
|
"MCPRegistry._seed_defaults: removing stale alias '%s' (canonical: '%s')",
|
|
stale,
|
|
canonical,
|
|
)
|
|
del existing[stale]
|
|
mutated = True
|
|
if mutated:
|
|
self._write_installed(data)
|
|
|
|
for name, spec in _DEFAULT_LOCAL_SERVERS.items():
|
|
if name in existing:
|
|
continue
|
|
try:
|
|
self.add_local(
|
|
name=name,
|
|
transport="stdio",
|
|
command="uv",
|
|
args=list(spec["args"]),
|
|
cwd=cwd,
|
|
description=spec["description"],
|
|
)
|
|
added.append(name)
|
|
except MCPError as exc:
|
|
logger.warning("MCPRegistry._seed_defaults: failed to seed '%s': %s", name, exc)
|
|
|
|
if added:
|
|
logger.info("MCPRegistry: seeded default local servers: %s", added)
|
|
return added
|
|
|
|
# ── Internal I/O ────────────────────────────────────────────────
|
|
|
|
def _read_installed(self) -> dict:
|
|
"""Read installed.json, initializing if needed."""
|
|
if not self._installed_path.exists():
|
|
self.initialize()
|
|
return json.loads(self._installed_path.read_text(encoding="utf-8"))
|
|
|
|
def _write_installed(self, data: dict) -> None:
|
|
"""Write installed.json."""
|
|
self._write_json(self._installed_path, data)
|
|
|
|
def _read_config(self) -> dict:
|
|
"""Read config.json."""
|
|
if not self._config_path.exists():
|
|
self.initialize()
|
|
return json.loads(self._config_path.read_text(encoding="utf-8"))
|
|
|
|
def _read_cached_index(self) -> dict:
|
|
"""Read cached registry_index.json."""
|
|
index_path = self._cache_dir / "registry_index.json"
|
|
if not index_path.exists():
|
|
return {"servers": {}}
|
|
return json.loads(index_path.read_text(encoding="utf-8"))
|
|
|
|
def _get_effective_manifest(
|
|
self,
|
|
name: str,
|
|
entry: dict,
|
|
cached_index: dict | None = None,
|
|
) -> dict:
|
|
"""Return the manifest currently in effect for an installed entry."""
|
|
manifest = entry.get("manifest", {})
|
|
if entry.get("source") != "registry":
|
|
return manifest
|
|
|
|
index = cached_index or self._read_cached_index()
|
|
cached_manifest = index.get("servers", {}).get(name)
|
|
if cached_manifest is not None:
|
|
return cached_manifest
|
|
|
|
# Fall back to persisted manifest data when the cache is unavailable.
|
|
if isinstance(manifest, dict) and manifest:
|
|
return manifest
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _write_json(path: Path, data: dict) -> None:
|
|
"""Write JSON to file atomically (write to temp, fsync, rename)."""
|
|
content = json.dumps(data, indent=2) + "\n"
|
|
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
# ── add_local ───────────────────────────────────────────────────
|
|
|
|
def add_local(
|
|
self,
|
|
name: str,
|
|
transport: str | None = None,
|
|
manifest: dict | None = None,
|
|
url: str | None = None,
|
|
command: str | None = None,
|
|
args: list[str] | None = None,
|
|
env: dict[str, str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
cwd: str | None = None,
|
|
socket_path: str | None = None,
|
|
description: str = "",
|
|
) -> dict:
|
|
"""Register a local/running MCP server.
|
|
|
|
Can be called with an inline manifest dict, or with individual
|
|
transport/url/command params that build a manifest automatically.
|
|
"""
|
|
data = self._read_installed()
|
|
if name in data["servers"]:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Server '{name}' already exists",
|
|
why="A server with this name is already registered locally.",
|
|
fix=f"Run: hive mcp remove {name} — then add it again.",
|
|
)
|
|
|
|
if manifest is not None:
|
|
# Inline manifest provided directly
|
|
manifest = {**manifest, "name": name}
|
|
transport_config = manifest.get("transport", {})
|
|
transport = transport or transport_config.get("default", "stdio")
|
|
if "transport" not in manifest:
|
|
manifest["transport"] = {"supported": [transport], "default": transport}
|
|
else:
|
|
# Build manifest from individual params
|
|
if not transport:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}'",
|
|
why="transport is required when manifest is not provided.",
|
|
fix="Pass --transport stdio|http|unix|sse when using hive mcp add.",
|
|
)
|
|
manifest = {
|
|
"name": name,
|
|
"description": description,
|
|
"transport": {"supported": [transport], "default": transport},
|
|
}
|
|
match transport:
|
|
case "http":
|
|
if not url:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}' with http transport",
|
|
why="url is required for http transport.",
|
|
fix="Pass --url https://your-server to hive mcp add.",
|
|
)
|
|
manifest["http"] = {"url": url, "headers": headers or {}}
|
|
case "stdio":
|
|
if not command:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}' with stdio transport",
|
|
why="command is required for stdio transport.",
|
|
fix="Pass --command <executable> to hive mcp add.",
|
|
)
|
|
manifest["stdio"] = {
|
|
"command": command,
|
|
"args": args or [],
|
|
"env": env or {},
|
|
"cwd": cwd,
|
|
}
|
|
case "unix":
|
|
if not socket_path:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}' with unix transport",
|
|
why="socket_path is required for unix transport.",
|
|
fix="Pass --socket-path /path/to/socket to hive mcp add.",
|
|
)
|
|
manifest["unix"] = {"socket_path": socket_path}
|
|
manifest["http"] = {"url": url or "http://localhost"}
|
|
case "sse":
|
|
if not url:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}' with sse transport",
|
|
why="url is required for sse transport.",
|
|
fix="Pass --url https://your-server to hive mcp add.",
|
|
)
|
|
manifest["sse"] = {"url": url}
|
|
case _:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot register server '{name}'",
|
|
why=f"Unsupported transport: '{transport}'.",
|
|
fix="Use one of: stdio, http, unix, sse.",
|
|
)
|
|
|
|
entry = self._make_entry(
|
|
source="local",
|
|
manifest=manifest,
|
|
transport=transport,
|
|
installed_by="hive mcp add",
|
|
)
|
|
|
|
data["servers"][name] = entry
|
|
self._write_installed(data)
|
|
logger.info("Registered local MCP server '%s' (%s)", name, transport)
|
|
return entry
|
|
|
|
# ── install ─────────────────────────────────────────────────────
|
|
|
|
def install(self, name: str, transport: str | None = None, version: str | None = None) -> dict:
|
|
"""Install a server from the cached remote registry index."""
|
|
data = self._read_installed()
|
|
if name in data["servers"]:
|
|
raise MCPInstallError(
|
|
server=name,
|
|
why=f"Server '{name}' already exists in the registry.",
|
|
fix=f"Run: hive mcp remove {name} — then install again.",
|
|
)
|
|
|
|
index = self._read_cached_index()
|
|
manifest = index.get("servers", {}).get(name)
|
|
if manifest is None:
|
|
raise MCPInstallError(
|
|
server=name,
|
|
why=f"Server '{name}' not found in registry index.",
|
|
fix="Run: hive mcp update — then try again.",
|
|
)
|
|
|
|
# Validate version if specified
|
|
if version is not None:
|
|
index_version = manifest.get("version")
|
|
if index_version is None:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_VERSION_CONFLICT,
|
|
what=f"Cannot pin version for '{name}'",
|
|
why="The registry manifest has no version field.",
|
|
fix="Run: hive mcp update — then omit --version to use latest.",
|
|
)
|
|
if index_version != version:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_VERSION_CONFLICT,
|
|
what=f"Version mismatch for '{name}'",
|
|
why=f"Requested {version} but index has {index_version}.",
|
|
fix="Run: hive mcp update — or omit --version to use latest.",
|
|
)
|
|
|
|
transport_config = manifest.get("transport", {})
|
|
supported = transport_config.get("supported", [])
|
|
if transport is not None:
|
|
if supported and transport not in supported:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Transport '{transport}' not supported by '{name}'",
|
|
why=f"Server supports: {supported}.",
|
|
fix=f"Use one of the supported transports: {supported}.",
|
|
)
|
|
resolved_transport = transport
|
|
else:
|
|
resolved_transport = transport_config.get("default", "stdio")
|
|
|
|
entry = self._make_entry(
|
|
source="registry",
|
|
manifest=self._make_registry_manifest_snapshot(name, manifest),
|
|
transport=resolved_transport,
|
|
installed_by="hive mcp install",
|
|
pinned=version is not None,
|
|
auto_update=version is None,
|
|
resolved_package_version=manifest.get("version"),
|
|
)
|
|
|
|
data["servers"][name] = entry
|
|
self._write_installed(data)
|
|
logger.info(
|
|
"Installed MCP server '%s' v%s from registry",
|
|
name,
|
|
entry["manifest_version"],
|
|
)
|
|
return entry
|
|
|
|
# ── remove / enable / disable ───────────────────────────────────
|
|
|
|
def remove(self, name: str) -> None:
|
|
"""Remove a server from the registry."""
|
|
data = self._read_installed()
|
|
if name not in data["servers"]:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot remove server '{name}'",
|
|
why="Server is not installed.",
|
|
fix="Run: hive mcp list — to see installed servers.",
|
|
)
|
|
del data["servers"][name]
|
|
self._write_installed(data)
|
|
logger.info("Removed MCP server '%s'", name)
|
|
|
|
def enable(self, name: str) -> None:
|
|
"""Enable a disabled server."""
|
|
self._set_enabled(name, enabled=True)
|
|
|
|
def disable(self, name: str) -> None:
|
|
"""Disable a server without removing it."""
|
|
self._set_enabled(name, enabled=False)
|
|
|
|
def _set_enabled(self, name: str, *, enabled: bool) -> None:
|
|
data = self._read_installed()
|
|
if name not in data["servers"]:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot {'enable' if enabled else 'disable'} server '{name}'",
|
|
why="Server is not installed.",
|
|
fix="Run: hive mcp list — to see installed servers.",
|
|
)
|
|
data["servers"][name]["enabled"] = enabled
|
|
self._write_installed(data)
|
|
logger.info("%s MCP server '%s'", "Enabled" if enabled else "Disabled", name)
|
|
|
|
# ── list / get ──────────────────────────────────────────────────
|
|
|
|
def list_installed(self) -> list[dict]:
|
|
"""Return all installed servers as a list of dicts with name included."""
|
|
data = self._read_installed()
|
|
return [{"name": name, **entry} for name, entry in data["servers"].items()]
|
|
|
|
def get_server(self, name: str) -> dict | None:
|
|
"""Get a single installed server entry by name, or None if not found."""
|
|
data = self._read_installed()
|
|
entry = data["servers"].get(name)
|
|
if entry is None:
|
|
return None
|
|
return {"name": name, **entry}
|
|
|
|
def list_available(self) -> list[dict]:
|
|
"""List all servers from cached remote index."""
|
|
index = self._read_cached_index()
|
|
return [{"name": name, **m} for name, m in index.get("servers", {}).items()]
|
|
|
|
# ── set_override ────────────────────────────────────────────────
|
|
|
|
def set_override(
|
|
self,
|
|
name: str,
|
|
key: str,
|
|
value: str,
|
|
override_type: Literal["env", "headers"] = "env",
|
|
) -> None:
|
|
"""Set an env or header override for a server."""
|
|
data = self._read_installed()
|
|
if name not in data["servers"]:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Cannot set override for server '{name}'",
|
|
why="Server is not installed.",
|
|
fix="Run: hive mcp list — to see installed servers.",
|
|
)
|
|
if override_type not in ("env", "headers"):
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_INSTALL_FAILED,
|
|
what=f"Invalid override type '{override_type}' for server '{name}'",
|
|
why="Override type must be 'env' or 'headers'.",
|
|
fix="Use --type env or --type headers.",
|
|
)
|
|
data["servers"][name]["overrides"][override_type][key] = value
|
|
self._write_installed(data)
|
|
logger.info("Set %s override %s for MCP server '%s'", override_type, key, name)
|
|
|
|
# ── search ──────────────────────────────────────────────────────
|
|
|
|
def search(self, query: str) -> list[dict]:
|
|
"""Search registry index by name, tag, description, or tool name."""
|
|
query_lower = query.lower()
|
|
index = self._read_cached_index()
|
|
matches = []
|
|
|
|
for name, manifest in index.get("servers", {}).items():
|
|
if self._matches_query(name, manifest, query_lower):
|
|
matches.append({"name": name, **manifest})
|
|
|
|
return matches
|
|
|
|
@staticmethod
|
|
def _matches_query(name: str, manifest: dict, query: str) -> bool:
|
|
"""Check if a manifest matches a search query."""
|
|
if query in name.lower():
|
|
return True
|
|
|
|
description = manifest.get("description", "")
|
|
if query in description.lower():
|
|
return True
|
|
|
|
for tag in manifest.get("tags", []):
|
|
if query in tag.lower():
|
|
return True
|
|
|
|
for tool in manifest.get("tools", []):
|
|
tool_name = tool.get("name", "") if isinstance(tool, dict) else str(tool)
|
|
if query in tool_name.lower():
|
|
return True
|
|
|
|
return False
|
|
|
|
# ── update_index ────────────────────────────────────────────────
|
|
|
|
def is_index_stale(self) -> bool:
|
|
"""Check if the cached registry index needs refreshing."""
|
|
last_fetched_path = self._cache_dir / _LAST_FETCHED_FILENAME
|
|
legacy_path = self._cache_dir / _LEGACY_LAST_FETCHED_FILENAME
|
|
if not last_fetched_path.exists() and not legacy_path.exists():
|
|
return True
|
|
|
|
try:
|
|
path = last_fetched_path if last_fetched_path.exists() else legacy_path
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
last_fetched = datetime.fromisoformat(data["timestamp"])
|
|
config = self._read_config()
|
|
interval_hours = config.get("refresh_interval_hours", DEFAULT_REFRESH_INTERVAL_HOURS)
|
|
age_hours = (datetime.now(UTC) - last_fetched).total_seconds() / 3600
|
|
return age_hours >= interval_hours
|
|
except (KeyError, ValueError, OSError):
|
|
return True
|
|
|
|
def update_index(self) -> int:
|
|
"""Fetch the latest registry index from remote and cache it.
|
|
|
|
Returns the number of servers in the index.
|
|
"""
|
|
config = self._read_config()
|
|
url = config.get("index_url", DEFAULT_INDEX_URL)
|
|
|
|
response = httpx.get(url, timeout=10.0)
|
|
response.raise_for_status()
|
|
index = response.json()
|
|
|
|
self._write_json(self._cache_dir / "registry_index.json", index)
|
|
# Write last_fetched atomically too
|
|
self._write_json(
|
|
self._cache_dir / _LAST_FETCHED_FILENAME,
|
|
{"timestamp": datetime.now(UTC).isoformat()},
|
|
)
|
|
|
|
server_count = len(index.get("servers", {}))
|
|
logger.info("Updated registry index: %d servers available", server_count)
|
|
return server_count
|
|
|
|
# ── load_agent_selection ────────────────────────────────────────
|
|
|
|
def load_agent_selection(self, agent_path: Path) -> tuple[list[dict[str, Any]], int | None]:
|
|
"""Load mcp_registry.json from an agent directory and resolve servers.
|
|
|
|
Returns:
|
|
(server_config_dicts, max_tools) for :meth:`ToolRegistry.load_registry_servers`.
|
|
``max_tools`` is ``None`` when omitted or invalid in JSON.
|
|
"""
|
|
registry_json_path = agent_path / "mcp_registry.json"
|
|
if not registry_json_path.exists():
|
|
return [], None
|
|
|
|
selection = json.loads(registry_json_path.read_text(encoding="utf-8"))
|
|
|
|
# Validate types at the JSON boundary. Bad fields are dropped with a
|
|
# warning so the agent still starts (graceful degradation).
|
|
expected_types: dict[str, type] = {
|
|
"include": list,
|
|
"tags": list,
|
|
"exclude": list,
|
|
"profile": str,
|
|
"max_tools": int,
|
|
"versions": dict,
|
|
}
|
|
validated: dict[str, Any] = {}
|
|
for field, expected in expected_types.items():
|
|
value = selection.get(field)
|
|
if value is None:
|
|
continue
|
|
if not isinstance(value, expected):
|
|
logger.warning(
|
|
"mcp_registry.json: '%s' must be %s, got %s; ignoring",
|
|
field,
|
|
expected.__name__,
|
|
type(value).__name__,
|
|
)
|
|
continue
|
|
validated[field] = value
|
|
|
|
max_tools = validated.get("max_tools")
|
|
configs = self.resolve_for_agent(
|
|
include=validated.get("include"),
|
|
tags=validated.get("tags"),
|
|
exclude=validated.get("exclude"),
|
|
profile=validated.get("profile"),
|
|
max_tools=max_tools,
|
|
versions=validated.get("versions"),
|
|
)
|
|
return [self._server_config_to_dict(c) for c in configs], max_tools
|
|
|
|
# ── resolve_for_agent ───────────────────────────────────────────
|
|
|
|
def resolve_for_agent(
|
|
self,
|
|
include: list[str] | None = None,
|
|
tags: list[str] | None = None,
|
|
exclude: list[str] | None = None,
|
|
profile: str | None = None,
|
|
max_tools: int | None = None,
|
|
versions: dict[str, str] | None = None,
|
|
) -> list[MCPServerConfig]:
|
|
"""Resolve installed servers matching agent selection criteria.
|
|
|
|
Selection precedence per PRD section 7.2:
|
|
1. profile expands to server names (union with include + tags)
|
|
2. include adds explicit servers
|
|
3. tags adds servers whose tags overlap
|
|
4. exclude removes (always wins)
|
|
5. Load order: include-order first, then alphabetical for tag/profile matches
|
|
|
|
Returns list of MCPServerConfig objects ready for ToolRegistry.
|
|
"""
|
|
data = self._read_installed()
|
|
servers = data.get("servers", {})
|
|
cached_index = self._read_cached_index()
|
|
exclude_set = set(exclude or [])
|
|
|
|
# Phase 1: collect profile-matched servers (alphabetical)
|
|
profile_matched: list[str] = []
|
|
if profile:
|
|
for name, entry in sorted(servers.items()):
|
|
if name in exclude_set:
|
|
continue
|
|
if profile == "all":
|
|
profile_matched.append(name)
|
|
else:
|
|
manifest = self._get_effective_manifest(name, entry, cached_index)
|
|
profiles = manifest.get("hive", {}).get("profiles", [])
|
|
if profile in profiles:
|
|
profile_matched.append(name)
|
|
|
|
# Phase 2: collect tag-matched servers (alphabetical)
|
|
tag_matched: list[str] = []
|
|
if tags:
|
|
tag_set = set(tags)
|
|
for name, entry in sorted(servers.items()):
|
|
if name in exclude_set:
|
|
continue
|
|
manifest = self._get_effective_manifest(name, entry, cached_index)
|
|
server_tags = set(manifest.get("tags", []))
|
|
if tag_set & server_tags:
|
|
tag_matched.append(name)
|
|
|
|
# Phase 3: build final ordered list
|
|
# include-order first, then alphabetical for profile/tag matches
|
|
selected: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
for name in include or []:
|
|
if name not in seen and name not in exclude_set:
|
|
selected.append(name)
|
|
seen.add(name)
|
|
|
|
for name in profile_matched:
|
|
if name not in seen:
|
|
selected.append(name)
|
|
seen.add(name)
|
|
|
|
for name in tag_matched:
|
|
if name not in seen:
|
|
selected.append(name)
|
|
seen.add(name)
|
|
|
|
# Build configs, tracking aggregate tool count for max_tools cap (FR-56)
|
|
configs: list[MCPServerConfig] = []
|
|
total_tools = 0
|
|
for name in selected:
|
|
entry = servers.get(name)
|
|
if entry is None:
|
|
logger.warning(
|
|
"Server '%s' requested but not installed. Run: hive mcp install %s",
|
|
name,
|
|
name,
|
|
)
|
|
continue
|
|
if not entry.get("enabled", True):
|
|
continue
|
|
|
|
manifest = self._get_effective_manifest(name, entry, cached_index)
|
|
|
|
# Check version pin (VC-6)
|
|
if versions and name in versions:
|
|
installed_version = entry.get("manifest_version", "0.0.0")
|
|
pinned_version = versions[name]
|
|
if installed_version != pinned_version:
|
|
logger.warning(
|
|
"Server '%s' version mismatch: installed=%s, pinned=%s. Run: hive mcp update %s",
|
|
name,
|
|
installed_version,
|
|
pinned_version,
|
|
name,
|
|
)
|
|
continue
|
|
|
|
# Check tool count cap before adding (FR-56), using manifest tool list when present.
|
|
# When ``tools`` is empty (e.g. ``add_local``), counts are unknown here—callers should
|
|
# pass the same ``max_tools`` to ToolRegistry.load_registry_servers to cap registration.
|
|
manifest_tools = manifest.get("tools", [])
|
|
server_tool_count = len(manifest_tools)
|
|
if max_tools is not None and server_tool_count == 0:
|
|
logger.debug(
|
|
"Server '%s' has no tools list in manifest; max_tools enforced at registration",
|
|
name,
|
|
)
|
|
elif max_tools is not None and total_tools + server_tool_count > max_tools:
|
|
logger.info(
|
|
"Skipping server '%s' (%d tools): would exceed max_tools=%d",
|
|
name,
|
|
server_tool_count,
|
|
max_tools,
|
|
)
|
|
continue
|
|
|
|
config = self._manifest_to_server_config(
|
|
name,
|
|
manifest,
|
|
entry.get("overrides", {}),
|
|
transport_override=entry.get("transport"),
|
|
)
|
|
if config is not None:
|
|
configs.append(config)
|
|
total_tools += server_tool_count
|
|
|
|
return configs
|
|
|
|
def _manifest_to_server_config(
|
|
self,
|
|
name: str,
|
|
manifest: dict,
|
|
overrides: dict | None = None,
|
|
transport_override: str | None = None,
|
|
) -> MCPServerConfig | None:
|
|
"""Convert a manifest and overrides to MCPServerConfig."""
|
|
overrides = overrides or {}
|
|
transport_config = manifest.get("transport", {})
|
|
transport = transport_override or transport_config.get("default", "stdio")
|
|
description = manifest.get("description", "")
|
|
|
|
match transport:
|
|
case "stdio":
|
|
stdio_config = manifest.get("stdio", {})
|
|
merged_env = {
|
|
**stdio_config.get("env", {}),
|
|
**overrides.get("env", {}),
|
|
}
|
|
return MCPServerConfig(
|
|
name=name,
|
|
transport="stdio",
|
|
command=stdio_config.get("command"),
|
|
args=stdio_config.get("args", []),
|
|
env=merged_env,
|
|
cwd=stdio_config.get("cwd"),
|
|
description=description,
|
|
)
|
|
case "http":
|
|
http_config = manifest.get("http", {})
|
|
url = http_config.get("url", "")
|
|
merged_headers = {
|
|
**http_config.get("headers", {}),
|
|
**overrides.get("headers", {}),
|
|
}
|
|
return MCPServerConfig(
|
|
name=name,
|
|
transport="http",
|
|
url=url,
|
|
headers=merged_headers,
|
|
description=description,
|
|
)
|
|
case "unix":
|
|
unix_config = manifest.get("unix", {})
|
|
http_config = manifest.get("http", {})
|
|
merged_headers = {
|
|
**http_config.get("headers", {}),
|
|
**overrides.get("headers", {}),
|
|
}
|
|
return MCPServerConfig(
|
|
name=name,
|
|
transport="unix",
|
|
socket_path=unix_config.get("socket_path"),
|
|
url=http_config.get("url") or "http://localhost",
|
|
headers=merged_headers,
|
|
description=description,
|
|
)
|
|
case "sse":
|
|
sse_config = manifest.get("sse", {})
|
|
merged_headers = {
|
|
**sse_config.get("headers", {}),
|
|
**overrides.get("headers", {}),
|
|
}
|
|
return MCPServerConfig(
|
|
name=name,
|
|
transport="sse",
|
|
url=sse_config.get("url", ""),
|
|
headers=merged_headers,
|
|
description=description,
|
|
)
|
|
case _:
|
|
logger.warning(
|
|
"Unsupported transport '%s' for server '%s'",
|
|
transport,
|
|
name,
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _server_config_to_dict(config: MCPServerConfig) -> dict[str, Any]:
|
|
"""Convert MCPServerConfig to plain dict for ToolRegistry.register_mcp_server()."""
|
|
return {
|
|
"name": config.name,
|
|
"transport": config.transport,
|
|
"command": config.command,
|
|
"args": config.args,
|
|
"env": config.env,
|
|
"cwd": config.cwd,
|
|
"url": config.url,
|
|
"headers": config.headers,
|
|
"socket_path": config.socket_path,
|
|
"description": config.description,
|
|
}
|
|
|
|
# ── run_health_check ────────────────────────────────────────────
|
|
|
|
def health_check(self, name: str | None = None) -> dict | dict[str, dict]:
|
|
"""Check health of installed server(s). Updates telemetry fields.
|
|
|
|
If name is None, checks all installed servers and returns
|
|
a dict mapping server names to their health results.
|
|
|
|
"""
|
|
if name is None:
|
|
results = {}
|
|
for server in self.list_installed():
|
|
results[server["name"]] = self.health_check(server["name"])
|
|
return results
|
|
|
|
data = self._read_installed()
|
|
if name not in data["servers"]:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_HEALTH_FAILED,
|
|
what=f"Cannot health-check server '{name}'",
|
|
why="Server is not installed.",
|
|
fix="Run: hive mcp list — to see installed servers.",
|
|
)
|
|
|
|
entry = data["servers"][name]
|
|
manifest = self._get_effective_manifest(name, entry)
|
|
config = self._manifest_to_server_config(
|
|
name,
|
|
manifest,
|
|
entry.get("overrides", {}),
|
|
transport_override=entry.get("transport"),
|
|
)
|
|
now = datetime.now(UTC).isoformat()
|
|
|
|
result: dict[str, Any] = {
|
|
"name": name,
|
|
"status": "unknown",
|
|
"tools": 0,
|
|
"error": None,
|
|
}
|
|
|
|
if config is None:
|
|
transport = entry.get("transport", "unknown")
|
|
result["status"] = "unhealthy"
|
|
result["error"] = f"Unsupported transport '{transport}'"
|
|
entry["last_health_status"] = "unhealthy"
|
|
entry["last_error"] = result["error"]
|
|
entry["last_health_check_at"] = now
|
|
self._write_installed(data)
|
|
return result
|
|
|
|
manager = MCPConnectionManager.get_instance()
|
|
|
|
try:
|
|
if manager.has_connection(name):
|
|
is_healthy = manager.health_check(name)
|
|
if not is_healthy:
|
|
raise MCPError(
|
|
code=MCPErrorCode.MCP_HEALTH_FAILED,
|
|
what=f"Health check failed for server '{name}'",
|
|
why="Shared MCP connection reported unhealthy.",
|
|
fix=f"Run: hive mcp doctor {name} — for diagnostics.",
|
|
)
|
|
pooled_client = manager.acquire(config)
|
|
try:
|
|
tools = pooled_client.list_tools()
|
|
finally:
|
|
manager.release(name)
|
|
else:
|
|
with MCPClient(config) as client:
|
|
tools = client.list_tools()
|
|
|
|
result["status"] = "healthy"
|
|
result["tools"] = len(tools)
|
|
entry["last_health_status"] = "healthy"
|
|
entry["last_error"] = None
|
|
entry["last_validated_with_hive_version"] = self._get_hive_version()
|
|
except Exception as exc:
|
|
result["status"] = "unhealthy"
|
|
result["error"] = str(exc)
|
|
entry["last_health_status"] = "unhealthy"
|
|
entry["last_error"] = str(exc)
|
|
|
|
entry["last_health_check_at"] = now
|
|
self._write_installed(data)
|
|
return result
|
|
|
|
def run_health_check(self, name: str | None = None) -> dict | dict[str, dict]:
|
|
"""Backward-compatible wrapper for the public health_check API."""
|
|
return self.health_check(name)
|
|
|
|
@staticmethod
|
|
def _get_hive_version() -> str:
|
|
"""Get the current Hive version."""
|
|
try:
|
|
return version("framework")
|
|
except PackageNotFoundError:
|
|
project_toml = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
if not project_toml.exists():
|
|
return "unknown"
|
|
try:
|
|
with project_toml.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
return data.get("project", {}).get("version", "unknown")
|
|
except (tomllib.TOMLDecodeError, OSError):
|
|
return "unknown"
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _make_entry(
|
|
*,
|
|
source: str,
|
|
manifest: dict,
|
|
transport: str,
|
|
installed_by: str,
|
|
pinned: bool = False,
|
|
auto_update: bool = False,
|
|
resolved_package_version: str | None = None,
|
|
) -> dict:
|
|
"""Build a standard installed server entry."""
|
|
now = datetime.now(UTC).isoformat()
|
|
return {
|
|
"source": source,
|
|
"manifest_version": manifest.get("version", "0.0.0"),
|
|
"manifest": manifest,
|
|
"installed_at": now,
|
|
"installed_by": installed_by,
|
|
"transport": transport,
|
|
"enabled": True,
|
|
"pinned": pinned,
|
|
"auto_update": auto_update,
|
|
"resolved_package_version": resolved_package_version,
|
|
"overrides": {"env": {}, "headers": {}},
|
|
"last_health_check_at": None,
|
|
"last_health_status": None,
|
|
"last_error": None,
|
|
"last_used_at": None,
|
|
"last_validated_with_hive_version": None,
|
|
}
|
|
|
|
@staticmethod
|
|
def _make_registry_manifest_snapshot(name: str, manifest: dict) -> dict[str, Any]:
|
|
"""Persist a full manifest snapshot for registry-installed servers."""
|
|
manifest_snapshot = dict(manifest)
|
|
manifest_snapshot["name"] = name
|
|
return manifest_snapshot
|