Coverage for astrocyte/harness/server.py: 91%

128 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""Locating and probing the ``astrocyte-mcp`` server setup registers.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import queue 

8import re 

9import shutil 

10import subprocess 

11import sys 

12import threading 

13import time 

14from dataclasses import dataclass 

15from pathlib import Path 

16 

17from .hosts import ServerSpec 

18 

19 

20@dataclass(frozen=True) 

21class ServerLocation: 

22 command: str 

23 ephemeral: bool # inside a cache uv/pipx may garbage-collect 

24 

25 

26def locate_script(name: str) -> ServerLocation | None: 

27 """A console script from *this* installation, as an absolute path. 

28 

29 The one beside the running interpreter wins over PATH: setup must register 

30 commands from the same environment it is running in, not whichever 

31 install happens to come first on PATH. 

32 """ 

33 exe = f"{name}.exe" if os.name == "nt" else name 

34 beside = Path(sys.executable).parent / exe 

35 found = str(beside) if beside.exists() else shutil.which(name) 

36 if not found: 

37 return None 

38 resolved = str(Path(found).absolute()) 

39 # `uvx astrocyte setup` runs from uv's archive cache, which uv prunes; a 

40 # path registered from there breaks later with no obvious cause. 

41 markers = (f"{os.sep}uv{os.sep}archive-", f"{os.sep}.cache{os.sep}uv{os.sep}", f"{os.sep}pipx{os.sep}.cache") 

42 return ServerLocation(command=resolved, ephemeral=any(m in resolved for m in markers)) 

43 

44 

45def locate_mcp_server() -> ServerLocation | None: 

46 """The interpreter of *this* installation, which runs the server. 

47 

48 Registrations name the interpreter rather than the ``astrocyte-mcp`` 

49 console script so they can pass ``-I`` (isolated mode): agents launch 

50 servers and hooks with their own environment, and an inherited 

51 ``PYTHONPATH`` made the installed scripts import a different checkout's 

52 code (observed on first real install). ``-I`` also keeps the cwd off 

53 ``sys.path``, so working inside an Astrocyte source tree can't shadow it. 

54 The interpreter path is kept as-is, not resolved: in a venv it is a 

55 symlink, and resolving it would escape the venv's site-packages. 

56 """ 

57 if locate_script("astrocyte-mcp") is None: 

58 return None # not installed in this environment 

59 exe = str(Path(sys.executable).absolute()) 

60 markers = (f"{os.sep}uv{os.sep}archive-", f"{os.sep}.cache{os.sep}uv{os.sep}", f"{os.sep}pipx{os.sep}.cache") 

61 return ServerLocation(command=exe, ephemeral=any(m in exe for m in markers)) 

62 

63 

64ISOLATED = "-I" 

65 

66 

67def server_spec(python: str, config: Path) -> ServerSpec: 

68 return ServerSpec(command=python, args=(ISOLATED, "-m", "astrocyte.mcp", "--config", str(config))) 

69 

70 

71class HookPathError(ValueError): 

72 """The interpreter path can't be written as a shell-neutral hook command.""" 

73 

74 

75# Characters every shell an agent may use on Windows (cmd, PowerShell, Git 

76# Bash) reads literally in an unquoted word. 

77_SHELL_NEUTRAL = re.compile(r"^[A-Za-z0-9_.~:/-]+$") 

78 

79 

80def hook_prefix(python: str, *, windows: bool = os.name == "nt") -> str: 

81 """Shell command prefix for ``astrocyte hook <event>`` registrations. 

82 

83 POSIX: shell-quoted. Windows: an agent runs hooks through cmd, PowerShell 

84 or Git Bash depending on the machine (Claude Code: Git Bash when present, 

85 else PowerShell), and no quoting means the same in all three. So the path 

86 is written unquoted with forward slashes (bash keeps them; CreateProcess 

87 accepts them) and without spaces, using its 8.3 short form if it has any. 

88 """ 

89 if not windows: 

90 import shlex 

91 

92 return f"{shlex.quote(python)} {ISOLATED} -m astrocyte.cli" 

93 path = _short_path(python) if " " in python else python 

94 path = path.replace("\\", "/") 

95 if not _SHELL_NEUTRAL.match(path): 

96 raise HookPathError( 

97 f"{python} can't be written so that cmd, PowerShell and Git Bash all run it (it has spaces or shell " 

98 "characters, and no 8.3 short name). Reinstall under a plain path, e.g. " 

99 "uv tool install 'astrocyte[local]' with UV_TOOL_DIR=C:/astrocyte-tools." 

100 ) 

101 return f"{path} {ISOLATED} -m astrocyte.cli" 

102 

103 

104def _short_path(path: str) -> str: 

105 """Windows' 8.3 alias of ``path`` (no spaces), or ``path`` where there is none.""" 

106 try: 

107 import ctypes 

108 from ctypes import wintypes 

109 

110 get = ctypes.windll.kernel32.GetShortPathNameW # type: ignore[attr-defined] 

111 get.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] 

112 get.restype = wintypes.DWORD 

113 size = get(path, None, 0) 

114 if not size: 

115 return path 

116 buf = ctypes.create_unicode_buffer(size) 

117 return buf.value if get(path, buf, size) else path 

118 except (AttributeError, OSError): # not Windows, or no kernel32 

119 return path 

120 

121 

122@dataclass(frozen=True) 

123class HandshakeResult: 

124 ok: bool 

125 detail: str 

126 tools: tuple[str, ...] = () 

127 seconds: float = 0.0 

128 

129 

130_EXCEPTION_LINE = re.compile(r"^\s*[A-Za-z_][\w.]*(?:Error|Exception|Exit|Interrupt)\b.*:") 

131 

132 

133def _explain(stderr: str | None) -> str: 

134 """The line of a crash's stderr that says what went wrong. 

135 

136 A traceback's last lines are often frames or source, and a chained 

137 exception repeats; the final ``SomethingError: message`` line is the one 

138 a user can act on. Without one, the last few lines as they are. 

139 """ 

140 lines = [ln.rstrip() for ln in (stderr or "").splitlines() if ln.strip()] 

141 errors = [ln.strip() for ln in lines if _EXCEPTION_LINE.match(ln)] 

142 if errors: 

143 return errors[-1] 

144 return " / ".join(ln.strip() for ln in lines[-3:]) 

145 

146 

147def handshake(spec: ServerSpec, *, timeout: float = 90.0) -> HandshakeResult: 

148 """Launch the server exactly as a harness would and complete an MCP 

149 ``initialize`` + ``tools/list``. Catches what static checks cannot: a 

150 missing dependency, a config the server rejects, a broken interpreter. 

151 

152 Each reply is awaited before the next request is sent and stdin stays open 

153 until the end: a stdio server may exit on EOF before answering requests 

154 that are still queued. 

155 """ 

156 started = time.perf_counter() 

157 deadline = started + timeout 

158 try: 

159 proc = subprocess.Popen( 

160 [spec.command, *spec.args], 

161 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, 

162 ) 

163 except OSError as e: 

164 return HandshakeResult(False, f"could not start {spec.command}: {e}") 

165 

166 lines: queue.Queue[str | None] = queue.Queue() 

167 

168 def pump() -> None: 

169 assert proc.stdout is not None 

170 for line in proc.stdout: 

171 lines.put(line) 

172 lines.put(None) # EOF 

173 

174 threading.Thread(target=pump, daemon=True).start() 

175 

176 def send(msg: dict) -> None: 

177 assert proc.stdin is not None 

178 proc.stdin.write(json.dumps(msg) + "\n") 

179 proc.stdin.flush() 

180 

181 def await_reply(want: int) -> dict | None: 

182 while (remaining := deadline - time.perf_counter()) > 0: 

183 try: 

184 line = lines.get(timeout=remaining) 

185 except queue.Empty: 

186 return None 

187 if line is None: 

188 return None 

189 try: 

190 msg = json.loads(line) 

191 except json.JSONDecodeError: 

192 continue 

193 if isinstance(msg, dict) and msg.get("id") == want: 

194 return msg 

195 return None 

196 

197 def failure(default: str) -> HandshakeResult: 

198 proc.kill() 

199 _, stderr = proc.communicate(timeout=10) 

200 return HandshakeResult(False, _explain(stderr) or default, seconds=time.perf_counter() - started) 

201 

202 try: 

203 send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { 

204 "protocolVersion": "2025-06-18", "capabilities": {}, 

205 "clientInfo": {"name": "astrocyte-doctor", "version": "1"}}}) 

206 if await_reply(1) is None: 

207 return failure(f"no initialize reply within {timeout:.0f}s") 

208 send({"jsonrpc": "2.0", "method": "notifications/initialized"}) 

209 send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) 

210 listed = await_reply(2) 

211 if listed is None or "result" not in listed: 

212 return failure("server did not answer tools/list") 

213 except (BrokenPipeError, OSError): 

214 return failure("server exited during the handshake") 

215 

216 proc.kill() 

217 proc.communicate(timeout=10) 

218 tools = tuple(t.get("name", "") for t in listed["result"].get("tools", [])) 

219 elapsed = time.perf_counter() - started 

220 if "memory_retain" not in tools: 

221 return HandshakeResult(False, "server answered but exposes no memory_retain tool", tools, elapsed) 

222 return HandshakeResult(True, f"{len(tools)} tools", tools, elapsed)