mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-27 06:53:11 +08:00
Adds Windows-only red tests and analysis for native Windows failures found during a Windows red-test campaign. This change contains no production fixes. - windows_non_ascii_repo_path_preserves_definitions (integration): byte-identical TypeScript fixtures indexed under non-ASCII parent paths (Latin-1 accents, Cyrillic, CJK, Greek) extract zero definitions and only File/Folder nodes (5 nodes / 4 edges) versus the ASCII baseline (12 nodes / 20 edges / 5 definitions). The pipeline source readers open files with fopen() on a UTF-8 path, which the Windows CRT interprets in the active ANSI code page; directory discovery already uses the wide API, so files are listed but never parsed. - windows_cli_non_ascii_repo_path_is_honored (integration): the documented `cli index_repository` entrypoint rejects a non-ASCII repo_path because main() does not read the wide command line, so argv arrives in the ANSI code page. Both reproduce at the product surface (real MCP process, real stdio, real SQLite DB), are deterministic, and pass on Linux/macOS. A PowerShell runner builds the binary and runs the suite; standard-library Python only. See tests/windows/RED_TEST_ANALYSIS.md for environment, commands, ruled-out seed areas, and suspected fix locations. Signed-off-by: Flipper <jacobphilipp@ymail.com>
147 lines
4.7 KiB
Python
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
|