Files
hermes-agent/tools/mcp_liveness.py
Siddharth Balyan eb5d735e9a Application-backed MCP servers: live endpoint per attempt, tools follow app liveness, errors and the tool catalog say why (NS-943) (#119050)
* feat: expose interactive and live endpoint facts

* feat: hydrate application-backed MCP liveness

* feat: export interactive session fact

* fix: classify unsupported declared hosts

* test(mcp): a runtime file without a token connects without an Authorization header

* fix(mcp): server_json liveness fields default to http/token/pid; an invalid declaration degrades to static instead of failing the server task

* fix(mcp): a connected server is offerable regardless of the interactive-session rule; the rule only shapes the not-connected sentence

* test(mcp): follow main's ToolSearchConfig fields and patch _bump_server_error at its origin module
2026-09-22 17:54:42 +05:30

179 lines
6.9 KiB
Python

"""Application-backed MCP liveness parsing and user-facing status descriptions."""
from __future__ import annotations
import logging
from dataclasses import dataclass, replace
from typing import Any, Literal
from hermes_platform import declaration
from hermes_platform.host import facts
from hermes_platform.resolver.app import AppDef, AppResolver
from hermes_platform.resolver.availability import Availability, availability
from hermes_platform.resolver.base import Effort
from hermes_platform.resolver.core import CheckState
logger = logging.getLogger(__name__)
LivenessKind = Literal["static", "server_json", "interactive_session"]
LivenessState = Literal[
"app_not_running",
"endpoint_unavailable",
"no_interactive_session",
"version_too_old",
"missing_app",
]
Retry = Literal["after_user_action", "never_here"]
@dataclass(frozen=True)
class Liveness:
kind: LivenessKind
path: str = ""
url_field: str = "http"
token_field: str = "token"
pid_field: str = "pid"
def app_definition(self, definition: AppDef) -> AppDef:
if self.kind != "server_json":
return definition
return replace(
definition,
liveness_kind="server_json",
liveness_path=self.path,
liveness_pid_key=self.pid_field,
liveness_url_key=self.url_field,
liveness_token_key=self.token_field,
)
@dataclass(frozen=True)
class Status:
state: LivenessState
availability: Availability
liveness: Liveness
user_action: str
retry: Retry
def parse_liveness(raw: Any) -> Liveness:
"""Parse one portable-plugin liveness declaration."""
if not isinstance(raw, dict):
raise ValueError("liveness must be an object")
kind = raw.get("kind")
if kind == "static":
if set(raw) != {"kind"}:
raise ValueError("static liveness only accepts 'kind'")
return Liveness("static")
if kind == "interactive_session":
if set(raw) != {"kind"}:
raise ValueError("interactive_session liveness only accepts 'kind'")
return Liveness("interactive_session")
if kind != "server_json":
raise ValueError(f"unknown liveness kind: {kind!r}")
if set(raw) - {"kind", "path", "fields"}:
raise ValueError("server_json liveness has unknown fields")
path = raw.get("path")
fields = {"url": "http", "token": "token", "pid": "pid", **(raw.get("fields") or {})}
if not isinstance(path, str) or not path.strip():
raise ValueError("server_json liveness requires a non-empty path")
if set(fields) != {"url", "token", "pid"}:
raise ValueError("server_json liveness fields may only override url, token, and pid")
if any(not isinstance(value, str) or not value.strip() for value in fields.values()):
raise ValueError("server_json liveness field names must be non-empty strings")
return Liveness(
"server_json",
path=path.strip(),
url_field=fields["url"].strip(),
token_field=fields["token"].strip(),
pid_field=fields["pid"].strip(),
)
def liveness_for(server_name: str) -> Liveness:
"""Return a server's registered liveness declaration, defaulting to static."""
try:
from hermes_cli.agent_plugins import liveness_for as registered_liveness
except ImportError:
return Liveness("static")
raw = registered_liveness(server_name)
if raw is None:
return Liveness("static")
try:
return parse_liveness(raw)
except ValueError as exc:
logger.warning("MCP server '%s' has an invalid liveness declaration (%s); treating it as static", server_name, exc)
return Liveness("static")
def _action(state: LivenessState, app_name: str) -> tuple[str, Retry]:
actions: dict[LivenessState, tuple[str, Retry]] = {
"app_not_running": (f"Start {app_name}, then try again.", "after_user_action"),
"endpoint_unavailable": (f"Open {app_name} and enable its local connection, then try again.", "after_user_action"),
"no_interactive_session": (f"Open an interactive desktop session and start {app_name}, then try again.", "never_here"),
"version_too_old": (f"Update {app_name}, then try again.", "after_user_action"),
"missing_app": (f"Install {app_name}, then try again.", "after_user_action"),
}
return actions[state]
def status(server_name: str) -> Status | None:
"""Return the current unavailable state for a registered declaration."""
decl = declaration.lookup(server_name)
if decl is None:
return None
available = availability(decl)
live = liveness_for(server_name)
if available.state in {"missing_app", "unsupported_os"}:
state: LivenessState = "missing_app"
elif available.state == "version_too_old":
state = "version_too_old"
elif live.kind == "interactive_session" and not facts.interactive_session():
state = "no_interactive_session"
elif live.kind == "server_json":
definition = decl.app_for(facts.os_family())
if definition is None:
state = "missing_app"
else:
probe = AppResolver(live.app_definition(definition)).probe(
AppResolver(live.app_definition(definition)).locate(), effort=Effort.LOCAL
)
if probe.running.value is not True:
state = "app_not_running"
elif probe.endpoint.state is not CheckState.PRESENT:
state = "endpoint_unavailable"
else:
state = "app_not_running"
else:
state = "app_not_running"
action, retry = _action(state, decl.name)
return Status(state, available, live, action, retry)
def describe(decl: declaration.Declaration, available: Availability, liveness_state: LivenessState) -> str:
"""Compose one unavailable-state sentence with one user action."""
app_name = decl.name
action, _retry = _action(liveness_state, app_name)
if liveness_state == "missing_app":
reason = f"{app_name} is not installed."
elif liveness_state == "version_too_old":
found = f" version {available.version}" if available.version else ""
minimum = f"; version {available.min_version} or newer is required" if available.min_version else ""
reason = f"{app_name}{found} is too old{minimum}."
elif liveness_state == "no_interactive_session":
reason = f"{app_name} needs an interactive desktop session."
elif liveness_state == "endpoint_unavailable":
reason = f"{app_name}'s local endpoint is unavailable."
else:
reason = f"{app_name} is not running."
return f"{reason} {action}"
def unavailable_details(server_name: str) -> tuple[declaration.Declaration, Status, str] | None:
"""Return declaration, structured state, and its composed sentence."""
decl = declaration.lookup(server_name)
current = status(server_name)
if decl is None or current is None:
return None
return decl, current, describe(decl, current.availability, current.state)