The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in the focused modules that define them. This commit is the ONLY thing keeping the old paths alive, so external plugins have time to update. It is deliberately a single, unsquashed commit: git revert <this sha> removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does. What it adds (see COMPAT_MANIFEST.md, compat_manifest.json): - 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file - 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import, so no import cycles; facades that already had `__getattr__` get a chained one - 592 third-party/stdlib names the old modules used to expose, with their original import statements - 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them) - 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/ relay_runtime, tools/environments/modal_utils) - private names (`_x`) get no pointer: they were never API (3,792 skipped) Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves; the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
"""Persistent MCP tool-schema cache for lazy server startup: per-server tool manifests on
|
|
disk so Hermes can register MCP tools into the agent snapshot without spawning the stdio
|
|
child at idle dashboard startup. Entries are keyed by server name + a fingerprint of the
|
|
connection config (command/args/url/tools filters)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CACHE_FILENAME = "mcp_schema_cache.json"
|
|
_cache_lock = threading.Lock()
|
|
|
|
|
|
def _cache_path() -> Path:
|
|
from hermes_constants import get_hermes_home
|
|
return get_hermes_home() / "cache" / _CACHE_FILENAME
|
|
|
|
|
|
def config_fingerprint(config: dict) -> str:
|
|
"""Stable hash of the connection-defining parts of an MCP server config."""
|
|
tools_filter = config.get("tools") or {}
|
|
payload = {
|
|
"command": config.get("command"),
|
|
"args": config.get("args") or [],
|
|
"url": config.get("url"),
|
|
"transport": config.get("transport"),
|
|
"tools_include": sorted(tools_filter.get("include") or []),
|
|
"tools_exclude": sorted(tools_filter.get("exclude") or [])}
|
|
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _load_all() -> Dict[str, Any]:
|
|
path = _cache_path()
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception as exc:
|
|
logger.debug("Could not read MCP schema cache %s: %s", path, exc)
|
|
return {}
|
|
|
|
|
|
def _save_all(data: Dict[str, Any]) -> None:
|
|
from utils import atomic_json_write
|
|
# 0o600: the cache file is trusted input on the lazy registration path, keep it user-only.
|
|
atomic_json_write(_cache_path(), data, mode=0o600)
|
|
|
|
|
|
def get_cached_entry(server_name: str, fingerprint: str) -> Optional[dict]:
|
|
"""Return cached entry when fingerprint matches (and TTL holds), else None. ``tools/list``
|
|
results may carry ``ttlMs`` (SEP-2549); an entry older than a recorded TTL is a miss so the
|
|
next startup re-probes instead of serving a stale manifest forever. Entries without a TTL
|
|
never expire. ``cacheScope`` is irrelevant: this cache is per-user local disk."""
|
|
with _cache_lock:
|
|
entry = _load_all().get(server_name)
|
|
if not isinstance(entry, dict) or entry.get("fingerprint") != fingerprint:
|
|
return None
|
|
ttl_ms = entry.get("ttl_ms")
|
|
written_at = entry.get("written_at")
|
|
expired = (isinstance(ttl_ms, (int, float)) and isinstance(written_at, (int, float))
|
|
and (time.time() - written_at) * 1000.0 >= float(ttl_ms))
|
|
return None if expired else entry
|
|
|
|
|
|
def write_cache_entry(server_name: str, fingerprint: str, *, tools: List[dict],
|
|
utility_tools: Optional[List[dict]] = None, ttl_ms: Optional[float] = None,
|
|
cache_scope: Optional[str] = None) -> None:
|
|
"""Persist tool schemas after a successful live connect. ``ttl_ms`` / ``cache_scope`` are
|
|
the server's ``tools/list`` SEP-2549 hints; ``written_at`` anchors TTL expiry."""
|
|
entry = {"fingerprint": fingerprint, "tools": tools, "utility_tools": utility_tools or []}
|
|
if isinstance(ttl_ms, (int, float)):
|
|
entry["ttl_ms"] = ttl_ms
|
|
entry["written_at"] = time.time()
|
|
if cache_scope:
|
|
entry["cache_scope"] = cache_scope
|
|
with _cache_lock:
|
|
data = _load_all()
|
|
# Write-through fires on every registration (reconnects, list_changed); skip the
|
|
# rewrite when the entry is byte-identical on disk. TTL'd entries always rewrite:
|
|
# written_at must advance or the entry would expire at its ORIGINAL write time.
|
|
if "written_at" not in entry and data.get(server_name) == entry:
|
|
return
|
|
data[server_name] = entry
|
|
_save_all(data)
|
|
|
|
|
|
def _list_field(entry: dict, key: str) -> List[dict]:
|
|
value = entry.get(key)
|
|
return list(value) if isinstance(value, list) else []
|
|
|
|
|
|
def tools_from_cache_entry(entry: dict) -> List[dict]:
|
|
"""Return cached MCP tool dicts (name, description, inputSchema)."""
|
|
return _list_field(entry, "tools")
|
|
|
|
|
|
def utility_tools_from_cache_entry(entry: dict) -> List[dict]:
|
|
"""Return cached ``{schema, handler_key}`` utility rows."""
|
|
return _list_field(entry, "utility_tools")
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
|
|
def clear_cache_entry(server_name: str) -> None:
|
|
with _cache_lock:
|
|
data = _load_all()
|
|
if server_name in data:
|
|
del data[server_name]
|
|
_save_all(data)
|
|
|
|
def has_cached_entry(server_name: str, fingerprint: str) -> bool:
|
|
return get_cached_entry(server_name, fingerprint) is not None
|
|
# ---- END PLUGIN-COMPAT ----
|