Files
hermes-agent/tools/mcp_schema_cache.py
kshitij ebf967ff2c polish(mcp): simplify-pass folds on the lazy-startup salvage
Five review findings folded:
- schema cache writes via utils.atomic_json_write (fsync; was bare
  tmp+replace), file moved to cache/mcp_schema_cache.json with 0o600
  (sibling precedent: registry discovery cache)
- phantom-tool reconciliation: after a lazy server's first-use connect,
  cached tools the live server no longer offers are deregistered (were
  permanent registry ghosts burning circuit-breaker strikes on every
  'Unknown tool' round-trip); stale fingerprint logged
- cache-load path now runs _scan_mcp_description like the eager path
  (cache file is user-writable JSON; defense-in-depth)
- write-through skips the disk rewrite when the entry is unchanged
  (a flapping stdio server was rewriting byte-identical JSON per
  revival)
- _lazy_server_fingerprints no longer write-only dead state (consumed
  by the reconciliation logging)

444 mcp tests green (440 pre-fold + 4 new guards); phantom-dereg and
write-skip mutation-checked.
2026-08-03 14:24:37 +05:30

122 lines
3.8 KiB
Python

"""Persistent MCP tool-schema cache for lazy server startup.
Stores per-server tool manifests on disk so Hermes can register MCP tools
into the agent snapshot without spawning the stdio child process at idle
dashboard startup. Cache 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
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
# Cache dir + 0o600: sibling precedent in tools/registry.py
# _save_discovery_cache; the cache file is trusted input on the lazy
# registration path, so 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, else None."""
with _cache_lock:
entry = _load_all().get(server_name)
if not isinstance(entry, dict):
return None
if entry.get("fingerprint") != fingerprint:
return None
return entry
def has_cached_entry(server_name: str, fingerprint: str) -> bool:
return get_cached_entry(server_name, fingerprint) is not None
def write_cache_entry(
server_name: str,
fingerprint: str,
*,
tools: List[dict],
utility_tools: Optional[List[dict]] = None,
) -> None:
"""Persist tool schemas after a successful live connect."""
entry = {
"fingerprint": fingerprint,
"tools": tools,
"utility_tools": utility_tools or [],
}
with _cache_lock:
data = _load_all()
# Write-through fires on every registration (reconnects,
# list_changed refreshes); skip the load-all+rewrite churn when the
# entry is byte-identical to what is already on disk.
if data.get(server_name) == entry:
return
data[server_name] = entry
_save_all(data)
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 tools_from_cache_entry(entry: dict) -> List[dict]:
"""Return cached MCP tool dicts (name, description, inputSchema)."""
tools = entry.get("tools")
return list(tools) if isinstance(tools, list) else []
def utility_tools_from_cache_entry(entry: dict) -> List[dict]:
util = entry.get("utility_tools")
return list(util) if isinstance(util, list) else []