Files
hermes-agent/tools/mcp_schema_cache.py
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
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.
2026-09-03 17:13:22 -07:00

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 ----