Coverage for src/lilbee/cli/launchers/hermes_mcp.py: 100%
67 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Ensure hermes has its optional ``mcp`` extra (HTTP MCP) before lilbee wires in over it."""
3from __future__ import annotations
5import json
6import subprocess
7import sys
8from collections.abc import Callable
9from pathlib import Path
11# The module hermes imports to enable Streamable-HTTP MCP; its absence is what
12# produces "MCP Servers (0) connected".
13_HTTP_MCP_PROBE = "import mcp.client.streamable_http"
14# hermes's own documented command (tools/mcp_tool.py); pins the mcp + starlette
15# versions hermes expects. Shown when we don't (or can't) auto-install.
16MCP_EXTRA_HINT = (
17 "Enable lilbee's search in hermes by installing hermes's MCP extra:\n"
18 " pip install 'hermes-agent[mcp]'\n"
19 "(or `uv tool install 'hermes-agent[mcp]'` / `pipx inject hermes-agent mcp`)."
20)
21# Reads hermes's pinned `[mcp]` extra requirements from its own metadata, so we
22# install exactly what hermes expects rather than an unpinned `mcp`.
23_EXTRA_REQS_SNIPPET = (
24 "import importlib.metadata as m, json\n"
25 "try: reqs = m.requires('hermes-agent') or []\n"
26 "except Exception: reqs = []\n"
27 "out = [r.split(';')[0].strip() for r in reqs\n"
28 ' if len(r.split(";")) > 1 and "extra" in r.split(";")[1] and "mcp" in r.split(";")[1]]\n'
29 "print(json.dumps(out))"
30)
33# Suffixes pip uses for Windows console-script wrappers; neither carries a shebang.
34_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe")
37def hermes_interpreter(binary: str) -> str | None:
38 """The Python that runs hermes, read from its console-script shebang (or None).
40 On Windows, ``.cmd``/``.exe`` wrappers have no ``#!python`` line. We parse
41 the embedded Python path from the ``.cmd`` (pip writes it as a comment), and
42 fall back to ``sys.executable`` (shared-venv assumption) when that fails.
43 """
44 try:
45 first_line = Path(binary).read_text(encoding="utf-8", errors="replace").splitlines()[0]
46 except (OSError, IndexError):
47 return None
48 if first_line.startswith("#!") and "python" in first_line:
49 return first_line[2:].strip().split()[0] or None
50 # Windows .cmd/.exe wrappers carry no shebang; detect by suffix + platform.
51 if sys.platform == "win32" and Path(binary).suffix.lower() in _WINDOWS_WRAPPER_SUFFIXES:
52 # pip's generated .cmd embeds the interpreter on the first line as
53 # `@"<path>\python.exe" ...`; try to parse that before falling back.
54 if first_line.startswith('@"') or first_line.startswith("@'"):
55 candidate = first_line[2:].split('"')[0].split("'")[0]
56 if "python" in candidate.lower():
57 return candidate or None
58 # Shared-venv fallback: the lilbee process and hermes share an environment
59 # (e.g. both installed via `pip install` into the same venv), so the
60 # running interpreter is the right one to use for hermes's packages too.
61 return sys.executable
62 return None
65def has_http_mcp(interpreter: str) -> bool:
66 """Whether ``interpreter`` can import hermes's Streamable-HTTP MCP client."""
67 try:
68 return (
69 subprocess.run( # noqa: S603 - hermes's own resolved interpreter, fixed argv
70 [interpreter, "-c", _HTTP_MCP_PROBE],
71 capture_output=True,
72 check=False,
73 ).returncode
74 == 0
75 )
76 except OSError:
77 return False
80def _mcp_extra_requirements(interpreter: str) -> list[str]:
81 """hermes's pinned ``[mcp]`` extra requirements, or ``["mcp"]`` if unreadable."""
82 try:
83 result = subprocess.run( # noqa: S603 - hermes's own resolved interpreter, fixed argv
84 [interpreter, "-c", _EXTRA_REQS_SNIPPET],
85 capture_output=True,
86 text=True,
87 encoding="utf-8",
88 errors="replace",
89 check=False,
90 )
91 reqs = json.loads(result.stdout or "[]")
92 except (OSError, json.JSONDecodeError):
93 reqs = []
94 return reqs or ["mcp"]
97def _install_failure_reason(proc: subprocess.CompletedProcess[str]) -> str:
98 """A one-line reason a pip install did not make MCP importable, or ``""``.
100 The usual causes (a pip-less tool env, a PEP 668 externally-managed env) land
101 on pip's last output line, which is what we surface.
102 """
103 text = (proc.stderr or proc.stdout or "").strip()
104 if not text:
105 return ""
106 last = text.splitlines()[-1].strip()
107 return f"hermes's pip could not install the mcp extra (exit {proc.returncode}): {last}"
110def ensure_hermes_http_mcp(
111 binary: str, *, allow_lazy_installs: bool, echo: Callable[[str], None]
112) -> bool:
113 """Make sure hermes can speak HTTP MCP, returning whether it ends up supported.
115 When support is missing: auto-installs hermes's pinned ``[mcp]`` extra into
116 hermes's own environment (only if ``allow_lazy_installs``), otherwise echoes
117 hermes's documented install command; when an install does not take, surfaces
118 why. Idempotent and cheap when already present."""
119 interpreter = hermes_interpreter(binary)
120 if interpreter is None:
121 echo(MCP_EXTRA_HINT)
122 return False
123 if has_http_mcp(interpreter):
124 return True
125 if not allow_lazy_installs:
126 # Respect the user's hermes security setting; don't pip-install behind it.
127 echo(MCP_EXTRA_HINT)
128 return False
129 echo("Setting up hermes MCP support (installing hermes's mcp extra)...")
130 try:
131 proc = subprocess.run( # noqa: S603 - hermes's own resolved interpreter, fixed argv
132 [interpreter, "-m", "pip", "install", *_mcp_extra_requirements(interpreter)],
133 capture_output=True,
134 text=True,
135 encoding="utf-8",
136 errors="replace",
137 check=False,
138 )
139 except OSError as exc:
140 echo(f"Could not run hermes's pip to install the mcp extra: {exc}")
141 echo(MCP_EXTRA_HINT)
142 return False
143 if has_http_mcp(interpreter):
144 echo("hermes MCP support ready.")
145 return True
146 reason = _install_failure_reason(proc)
147 if reason:
148 echo(reason)
149 echo(MCP_EXTRA_HINT)
150 return False