Thin JSON-RPC-over-stdio client. Responses are routed back to the awaiting caller by id, so a late response from a previously-timed-out request cannot be misread as the response to a later request.
| 60 | |
| 61 | |
| 62 | class McpClient: |
| 63 | """Thin JSON-RPC-over-stdio client. Responses are routed back to the |
| 64 | awaiting caller by id, so a late response from a previously-timed-out |
| 65 | request cannot be misread as the response to a later request.""" |
| 66 | |
| 67 | def __init__(self, repo_path: str, repo_name: str = "unknown"): |
| 68 | # Capture stderr per-repo. Earlier revisions piped to DEVNULL, which |
| 69 | # made server crashes during init look like a BrokenPipeError from |
| 70 | # the probe driver — totally unactionable. Real causes (missing |
| 71 | # .tracedecay/tracedecay.db, unreadable DB, OOM on large repos like |
| 72 | # chromium) showed up on stderr but were silently discarded. |
| 73 | STDERR_DIR.mkdir(parents=True, exist_ok=True) |
| 74 | self.stderr_path = STDERR_DIR / f"{repo_name}.stderr" |
| 75 | self.stderr_file = open(self.stderr_path, "wb") |
| 76 | self.proc = subprocess.Popen( |
| 77 | [str(BIN), "serve"], |
| 78 | cwd=repo_path, |
| 79 | stdin=subprocess.PIPE, |
| 80 | stdout=subprocess.PIPE, |
| 81 | stderr=self.stderr_file, |
| 82 | bufsize=0, |
| 83 | ) |
| 84 | self._id = 0 |
| 85 | self._buf = b"" |
| 86 | self._stale: set[int] = set() # ids whose original caller already gave up |
| 87 | |
| 88 | # Initialize handshake — fail loud, fail early. Earlier the code |
| 89 | # ignored a missing/error response and tried to push the |
| 90 | # "initialized" notification regardless; if the server had already |
| 91 | # exited, that second write blew up the whole probe with a bare |
| 92 | # BrokenPipeError instead of skipping the repo. |
| 93 | try: |
| 94 | self._send({ |
| 95 | "jsonrpc": "2.0", |
| 96 | "method": "initialize", |
| 97 | "params": { |
| 98 | "protocolVersion": "2024-11-05", |
| 99 | "capabilities": {}, |
| 100 | "clientInfo": {"name": "tracedecay-probe", "version": "1"}, |
| 101 | }, |
| 102 | "id": self._next(), |
| 103 | }) |
| 104 | except (BrokenPipeError, OSError) as exc: |
| 105 | raise McpInitError(repo_name, self.stderr_path, |
| 106 | f"server died before initialize ({exc})") from exc |
| 107 | |
| 108 | resp = self._recv_id(self._id, timeout=15) |
| 109 | if resp is None: |
| 110 | raise McpInitError(repo_name, self.stderr_path, |
| 111 | "server closed stdout during initialize") |
| 112 | if isinstance(resp, dict) and resp.get("_timeout"): |
| 113 | raise McpInitError(repo_name, self.stderr_path, |
| 114 | "initialize timed out after 15s") |
| 115 | if isinstance(resp, dict) and "error" in resp: |
| 116 | err = resp["error"] |
| 117 | msg = err.get("message", str(err)) if isinstance(err, dict) else str(err) |
| 118 | raise McpInitError(repo_name, self.stderr_path, |
| 119 | f"initialize returned error: {msg[:200]}") |
no outgoing calls
no test coverage detected