Files
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

147 lines
4.7 KiB
Python

"""Minimal MCP stdio client for the Windows red-test suite.
Drives a real codebase-memory-mcp(.exe) over a line-delimited JSON-RPC stdio
pipe. The pipe carries UTF-8 bytes, so a non-ASCII repo_path reaches the server
without passing through the Windows ANSI command-line code page (which mangles
argv for a binary whose main() is not wmain/GetCommandLineW). This isolates the
server's real path handling from CLI-argv encoding artifacts.
No third-party dependencies — standard library only.
"""
import json
import os
import subprocess
import threading
import time
class McpError(Exception):
pass
class McpServer:
def __init__(self, binary, cache_dir=None, extra_env=None, cwd=None):
self.binary = binary
self._id = 0
self.proc = None
self._stderr = []
env = dict(os.environ)
if cache_dir:
env["CBM_CACHE_DIR"] = cache_dir # isolate the graph DB location
if extra_env:
env.update(extra_env)
self.env = env
self.cwd = cwd
def __enter__(self):
self.start()
return self
def __exit__(self, *a):
self.close()
def start(self):
self.proc = subprocess.Popen(
[self.binary],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env=self.env, cwd=self.cwd, bufsize=0)
threading.Thread(target=self._drain_stderr, daemon=True).start()
def _drain_stderr(self):
try:
for line in self.proc.stderr:
self._stderr.append(line.decode("utf-8", "replace"))
except Exception:
pass
def stderr_text(self):
return "".join(self._stderr)
def _send(self, obj):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.proc.stdin.write(data + b"\n")
self.proc.stdin.flush()
def _read_message(self, timeout=60):
result = {}
def reader():
try:
result["line"] = self.proc.stdout.readline()
except Exception as ex:
result["exc"] = ex
th = threading.Thread(target=reader, daemon=True)
th.start()
th.join(timeout)
if th.is_alive():
raise McpError("timeout after %ss (hang)" % timeout)
if "exc" in result:
raise McpError("read error: %r" % result["exc"])
line = result.get("line", b"")
if not line:
raise McpError("EOF / server closed stdout")
# strict: an invalid-UTF-8 JSON-RPC response is itself a failure.
return json.loads(line.decode("utf-8", "strict"))
def request(self, method, params=None, timeout=60):
self._id += 1
rid = self._id
self._send({"jsonrpc": "2.0", "id": rid, "method": method,
"params": params or {}})
deadline = time.time() + timeout
while True:
msg = self._read_message(timeout=max(1, deadline - time.time()))
if msg.get("id") == rid:
return msg
if time.time() > deadline:
raise McpError("timeout waiting for id=%d" % rid)
def notify(self, method, params=None):
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
def initialize(self, timeout=60):
resp = self.request("initialize", {
"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "windows-red-test", "version": "1.0"}}, timeout)
if "error" in resp:
raise McpError("initialize error: %r" % resp["error"])
try:
self.notify("notifications/initialized")
except Exception:
pass
return resp
def tools_list(self, timeout=60):
resp = self.request("tools/list", {}, timeout=timeout)
if "error" in resp:
raise McpError("tools/list error: %r" % resp["error"])
return resp["result"]["tools"]
def call_tool(self, name, arguments, timeout=180):
return self.request("tools/call",
{"name": name, "arguments": arguments}, timeout=timeout)
@staticmethod
def tool_text(resp):
if "error" in resp:
return None, resp["error"]
parts = [c.get("text", "") for c in resp.get("result", {}).get("content", [])
if c.get("type") == "text"]
return "".join(parts), None
def close(self):
if not self.proc:
return
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.wait(timeout=10)
except Exception:
try:
self.proc.kill()
except Exception:
pass