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

1"""Ensure hermes has its optional ``mcp`` extra (HTTP MCP) before lilbee wires in over it.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import subprocess 

7import sys 

8from collections.abc import Callable 

9from pathlib import Path 

10 

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) 

31 

32 

33# Suffixes pip uses for Windows console-script wrappers; neither carries a shebang. 

34_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe") 

35 

36 

37def hermes_interpreter(binary: str) -> str | None: 

38 """The Python that runs hermes, read from its console-script shebang (or None). 

39 

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 

63 

64 

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 

78 

79 

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

95 

96 

97def _install_failure_reason(proc: subprocess.CompletedProcess[str]) -> str: 

98 """A one-line reason a pip install did not make MCP importable, or ``""``. 

99 

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

108 

109 

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. 

114 

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