mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* ci: E2E workflow, web typecheck job, pre-commit hook, test suite CI: - ci.yml consolidated to reference ci-tests.yml - ci-quality.yml: add typecheck-web job for gitnexus-web/ - ci-e2e.yml: E2E workflow with dorny/paths-filter (web changes only) - ci-report.yml: remove dead integration-reports references - CI gate allows skipped E2E status - .gitignore: playwright artifacts, eval test artifacts Pre-commit hook: - .githooks/pre-commit: typecheck + unit tests for both packages - Activated via git config core.hooksPath in prepare script Test infrastructure: - Vitest + React Testing Library: 58 unit tests (graph, server-connection, mermaid, settings, constants, utils, paths) - Playwright E2E: 5 tests + manual recording harness - vitest.config from vitest/config, engines.node >= 20 - Playwright artifacts retain-on-failure - wait-on in devDependencies - vitest/coverage-v8 aligned with vitest 4.x Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update gitnexus-web package-lock.json Reflects devDependency additions (vitest, playwright, wait-on, @testing-library, etc.) from package.json changes in this PR. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): add missing process-list-loaded testid, increase CI timeouts - Add data-testid="process-list-loaded" to ProcessesPanel (E2E tests were waiting for an element that didn't exist) - Increase server connect timeouts from 5s to 10s for slower CI Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): run gitnexus-web unit tests in CI, remove unused variable - Add gitnexus-web npm ci + vitest run to ci-tests.yml so web unit tests are gated by the CI status check (were only running locally) - Remove unused IS_PLAYWRIGHT_AUTOMATION variable from E2E spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): add process-row testid, wait for networkidle on page load - Add data-testid="process-row" to ProcessItem component (E2E tests referenced it but it didn't exist in the source) - Use waitUntil: 'networkidle' on page.goto to ensure Vite dev server is fully ready before interacting (fixes first-test timeout in CI) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): add process-view-button and process-highlight-button testids E2E tests referenced these data-testid attributes but they didn't exist in ProcessItem. All 6 E2E testids now have matching source elements: status-ready, process-list-loaded, process-row, process-view-button, process-highlight-button, server-url-input. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): remove networkidle — Vite HMR WebSocket prevents it from resolving networkidle waits for zero network activity for 500ms, but Vite's HMR WebSocket stays open permanently, causing page.goto to timeout at 60s on all tests after the first. The explicit toBeVisible waits on UI elements are sufficient and deterministic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): wait for Server button visibility, add CI retry, all 5 tests pass locally Root cause: test 1 clicked the Server button before React hydrated, so the tab content never rendered and the input wasn't found. Fixes: - Wait for Server button toBeVisible before clicking - Increase input wait to 15s - Remove networkidle (Vite HMR WebSocket prevents it from resolving) - Add retries: 1 in CI for transient cold-start flakiness Verified locally: all 5 E2E tests pass, 198 unit tests pass, typecheck clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): tolerate LadybugDB native crash during analyze step gitnexus analyze can crash with "double free or corruption" (known issue #273) during the LadybugDB native addon shutdown. The index is usually written successfully before the crash. The workflow now: 1. Allows analyze to exit non-zero with a warning 2. Verifies .gitnexus index was actually created 3. Only fails if no index exists (real failure) All tests verified locally: 198 unit, 5 E2E pass, typecheck clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): fix shell quoting in analyze step, simplify to || true The previous echo string had special characters that broke bash quoting in GitHub Actions. Simplified to: analyze || true, then check if .gitnexus exists. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add agent development framework, GitHub templates, eval refactor Agent framework (layered docs for AI-assisted contributions): - AGENTS.md: canonical instructions, impact analysis, MCP tools - CLAUDE.md: Claude Code-specific deltas and hooks - GUARDRAILS.md: safety boundaries, non-negotiables, escalation - ARCHITECTURE.md: monorepo layout, data flow map - TESTING.md: test structure, commands, categories - RUNBOOK.md: copy-paste operations for dev/CI/MCP - llms.txt: minimal LLM context pointer Editor integration: - .cursor/index.mdc + rules/100-monorepo.mdc GitHub templates: - PR template with areas-touched checkboxes - Bug report + feature request issue forms Eval harness: - Refactored mcp_bridge, tool_registry, constants - Error sanitization utilities - Property-based tests via Hypothesis Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(eval): use format_exception instead of format_exc in sanitize_exception format_exc() returns the currently handled exception traceback, which may be unrelated if called outside an active except block. Using format_exception(type(exc), exc, exc.__traceback__) reliably captures the passed exception's traceback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update CONTRIBUTING.md and TESTING.md for current CI/hook setup - CONTRIBUTING.md: add gitnexus-web typecheck command, pre-commit hook checklist item - TESTING.md: add gitnexus-web typecheck command, pre-commit hook section (husky), update CI integration to list actual workflow files (ci-quality, ci-tests, ci-e2e) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update testing docs to reflect CI/E2E changes from PR #486 - AGENTS.md: update test counts (CLI ~2000 unit, ~1850 integration), add gitnexus-web testing section (198 unit, 5 E2E with commands) - RUNBOOK.md: fix Node requirement to >=20, fix E2E local repro command - TESTING.md: E2E uses data-testid selectors + real servers, not mocks - .cursor/rules/100-monorepo.mdc: add web test/E2E commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: address context engineering review — deduplicate tokens, expand Cursor rules - Remove ~100-line gitnexus:start block from CLAUDE.md (was duplicated from AGENTS.md) - Fix gitnexus:start block inlined inside AGENTS.md Reference Docs bullet (doubled) - Replace CLAUDE.md scope table with pointer to AGENTS.md (single source of truth) - Expand .cursor/index.mdc with 5 non-negotiable safety rules for always-on context - Add .cursor/rules/200-eval.mdc with Python/eval commands (glob-scoped to eval/**) - Improve llms.txt with priority annotations and descriptions - Bump version headers to 1.2.0, last-reviewed to 2026-03-24 Saves ~1,400 tokens/session with zero information loss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
378 lines
12 KiB
Python
378 lines
12 KiB
Python
"""
|
|
MCP Bridge for GitNexus
|
|
|
|
Starts the GitNexus MCP server as a subprocess and provides a Python interface
|
|
to call MCP tools. Used by the bash wrapper scripts and the augmentation layer..
|
|
|
|
The bridge communicates with the MCP server via stdio using the JSON-RPC protocol.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from constants import (
|
|
MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
|
|
MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
|
MCP_READ_TIMEOUT_SECONDS,
|
|
MCP_STOP_WAIT_SECONDS,
|
|
)
|
|
from utils.errors import is_debug_enabled, log_safe_exception
|
|
|
|
logger = logging.getLogger("mcp_bridge")
|
|
|
|
|
|
class MCPBridge:
|
|
"""
|
|
Manages a GitNexus MCP server subprocess and proxies tool calls to it.
|
|
|
|
Usage:
|
|
bridge = MCPBridge(repo_path="/path/to/repo")
|
|
bridge.start()
|
|
result = bridge.call_tool("query", {"query": "authentication"})
|
|
bridge.stop()
|
|
"""
|
|
|
|
def __init__(self, repo_path: str | None = None):
|
|
self.repo_path = repo_path or os.getcwd()
|
|
self.process: subprocess.Popen | None = None
|
|
self._request_id = 0
|
|
self._lock = threading.Lock()
|
|
self._started = False
|
|
|
|
def start(self) -> bool:
|
|
"""Start the GitNexus MCP server subprocess."""
|
|
if self._started:
|
|
return True
|
|
|
|
try:
|
|
# Find gitnexus binary
|
|
gitnexus_bin = self._find_gitnexus()
|
|
if not gitnexus_bin:
|
|
logger.error("GitNexus not found. Install with: npm install -g gitnexus")
|
|
return False
|
|
|
|
self.process = subprocess.Popen(
|
|
[gitnexus_bin, "mcp"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
cwd=self.repo_path,
|
|
text=False,
|
|
)
|
|
|
|
# Send initialize request
|
|
init_result = self._send_request("initialize", {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "gitnexus-eval", "version": "0.1.0"},
|
|
})
|
|
|
|
if init_result is None:
|
|
logger.error("MCP server failed to initialize")
|
|
self.stop()
|
|
return False
|
|
|
|
# Send initialized notification
|
|
self._send_notification("notifications/initialized", {})
|
|
self._started = True
|
|
logger.info("MCP bridge started successfully")
|
|
return True
|
|
|
|
except Exception as e:
|
|
log_safe_exception(logger, "Failed to start MCP bridge", e, include_debug=is_debug_enabled())
|
|
self.stop()
|
|
return False
|
|
|
|
def stop(self):
|
|
"""Stop the MCP server subprocess."""
|
|
if self.process:
|
|
try:
|
|
if self.process.stdin:
|
|
self.process.stdin.close()
|
|
if self.process.stdout:
|
|
self.process.stdout.close()
|
|
if self.process.stderr:
|
|
self.process.stderr.close()
|
|
self.process.terminate()
|
|
self.process.wait(timeout=MCP_STOP_WAIT_SECONDS)
|
|
except Exception:
|
|
try:
|
|
self.process.kill()
|
|
except Exception:
|
|
pass
|
|
self.process = None
|
|
self._started = False
|
|
|
|
def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
"""
|
|
Call a GitNexus MCP tool and return the result.
|
|
|
|
Returns the tool result content or None on error.
|
|
"""
|
|
if not self._started:
|
|
logger.error("MCP bridge not started")
|
|
return None
|
|
|
|
result = self._send_request("tools/call", {
|
|
"name": tool_name,
|
|
"arguments": arguments or {},
|
|
})
|
|
|
|
if result is None:
|
|
return None
|
|
|
|
# Extract text content from MCP response
|
|
content = result.get("content", [])
|
|
if content and isinstance(content, list):
|
|
texts = [item.get("text", "") for item in content if item.get("type") == "text"]
|
|
return {"text": "\n".join(texts), "raw": content}
|
|
|
|
return {"text": "", "raw": content}
|
|
|
|
def list_tools(self) -> list[dict]:
|
|
"""List available MCP tools."""
|
|
result = self._send_request("tools/list", {})
|
|
if result:
|
|
return result.get("tools", [])
|
|
return []
|
|
|
|
def read_resource(self, uri: str) -> str | None:
|
|
"""Read an MCP resource by URI."""
|
|
result = self._send_request("resources/read", {"uri": uri})
|
|
if result:
|
|
contents = result.get("contents", [])
|
|
if contents:
|
|
return contents[0].get("text", "")
|
|
return None
|
|
|
|
def _find_gitnexus(self) -> str | None:
|
|
"""Find the gitnexus CLI binary."""
|
|
# Check if npx is available (preferred - uses local install)
|
|
for cmd in ["npx"]:
|
|
try:
|
|
result = subprocess.run(
|
|
[cmd, "gitnexus", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS,
|
|
cwd=self.repo_path,
|
|
)
|
|
if result.returncode == 0:
|
|
return cmd # Will use "npx gitnexus mcp"
|
|
except Exception:
|
|
continue
|
|
|
|
# Check for global install
|
|
try:
|
|
result = subprocess.run(
|
|
["gitnexus", "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS,
|
|
)
|
|
if result.returncode == 0:
|
|
return "gitnexus"
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
def _next_id(self) -> int:
|
|
with self._lock:
|
|
self._request_id += 1
|
|
return self._request_id
|
|
|
|
def _send_request(self, method: str, params: dict) -> dict | None:
|
|
"""Send a JSON-RPC request and wait for response."""
|
|
if not self.process or not self.process.stdin or not self.process.stdout:
|
|
return None
|
|
|
|
request_id = self._next_id()
|
|
request = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
try:
|
|
message = json.dumps(request)
|
|
# MCP uses Content-Length header framing
|
|
header = f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n"
|
|
self.process.stdin.write(header.encode("utf-8"))
|
|
self.process.stdin.write(message.encode("utf-8"))
|
|
self.process.stdin.flush()
|
|
|
|
# Read response
|
|
response = self._read_response(timeout=MCP_READ_TIMEOUT_SECONDS)
|
|
if response and response.get("id") == request_id:
|
|
if "error" in response:
|
|
logger.error(f"MCP error: {response['error']}")
|
|
return None
|
|
return response.get("result")
|
|
return None
|
|
|
|
except Exception as e:
|
|
log_safe_exception(logger, "MCP request failed", e, include_debug=is_debug_enabled())
|
|
return None
|
|
|
|
def _send_notification(self, method: str, params: dict):
|
|
"""Send a JSON-RPC notification (no response expected)."""
|
|
if not self.process or not self.process.stdin:
|
|
return
|
|
|
|
notification = {
|
|
"jsonrpc": "2.0",
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
try:
|
|
message = json.dumps(notification)
|
|
header = f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n"
|
|
self.process.stdin.write(header.encode("utf-8"))
|
|
self.process.stdin.write(message.encode("utf-8"))
|
|
self.process.stdin.flush()
|
|
except Exception as e:
|
|
log_safe_exception(logger, "MCP notification failed", e, include_debug=is_debug_enabled())
|
|
|
|
def _read_content_length(self, deadline: float) -> int | None:
|
|
"""Read Content-Length header, returning the byte length or None."""
|
|
if not self.process or not self.process.stdout:
|
|
return None
|
|
|
|
header_line = b""
|
|
while time.time() < deadline:
|
|
byte = self.process.stdout.read(1)
|
|
if not byte:
|
|
return None
|
|
header_line += byte
|
|
if header_line.endswith(b"\r\n\r\n") or header_line.endswith(b"\n\n"):
|
|
break
|
|
|
|
if not header_line:
|
|
return None
|
|
|
|
header_str = header_line.decode("utf-8").strip()
|
|
for line in header_str.split("\r\n"):
|
|
if line.lower().startswith("content-length:"):
|
|
try:
|
|
return int(line.split(":", 1)[1].strip())
|
|
except (ValueError, IndexError):
|
|
return None
|
|
return None
|
|
|
|
def _read_body(self, content_length: int, deadline: float) -> bytes | None:
|
|
"""Read a response body of the expected length before deadline."""
|
|
if not self.process or not self.process.stdout:
|
|
return None
|
|
|
|
remaining = content_length
|
|
chunks: list[bytes] = []
|
|
|
|
while remaining > 0 and time.time() < deadline:
|
|
chunk = self.process.stdout.read(remaining)
|
|
if not chunk:
|
|
return None
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
|
|
if remaining > 0:
|
|
return None
|
|
|
|
return b"".join(chunks)
|
|
|
|
def _read_response(self, timeout: float = MCP_READ_TIMEOUT_SECONDS) -> dict | None:
|
|
"""Read a JSON-RPC response from the MCP server."""
|
|
if not self.process or not self.process.stdout:
|
|
return None
|
|
|
|
try:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
content_length = self._read_content_length(deadline)
|
|
if content_length is None:
|
|
continue
|
|
|
|
body = self._read_body(content_length, deadline)
|
|
if not body:
|
|
return None
|
|
|
|
message = json.loads(body.decode("utf-8"))
|
|
|
|
# Skip notifications (no id), return responses
|
|
if "id" in message:
|
|
return message
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
log_safe_exception(logger, "Error reading MCP response", e, include_debug=is_debug_enabled())
|
|
return None
|
|
|
|
|
|
class MCPToolCLI:
|
|
"""
|
|
CLI wrapper that exposes MCP tools as simple command-line calls.
|
|
Used by the bash wrapper scripts inside Docker containers.
|
|
|
|
Usage from bash:
|
|
python -m bridge.mcp_bridge query '{"query": "authentication"}'
|
|
python -m bridge.mcp_bridge context '{"name": "validateUser"}'
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.bridge = MCPBridge()
|
|
|
|
def run(self, tool_name: str, args_json: str = "{}") -> int:
|
|
"""Run a single tool call and print the result."""
|
|
try:
|
|
args = json.loads(args_json)
|
|
except json.JSONDecodeError:
|
|
# Try to parse as simple key=value pairs
|
|
args = self._parse_simple_args(args_json)
|
|
|
|
if not self.bridge.start():
|
|
print("ERROR: Failed to start GitNexus MCP bridge", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
result = self.bridge.call_tool(tool_name, args)
|
|
if result:
|
|
print(result.get("text", ""))
|
|
return 0
|
|
else:
|
|
print("No results", file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
self.bridge.stop()
|
|
|
|
@staticmethod
|
|
def _parse_simple_args(args_str: str) -> dict:
|
|
"""Parse 'key=value key2=value2' style arguments."""
|
|
args = {}
|
|
for part in args_str.split():
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
args[key] = value
|
|
return args
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python -m bridge.mcp_bridge <tool_name> [args_json]", file=sys.stderr)
|
|
print("Tools: query, context, impact, cypher, list_repos, detect_changes, rename", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
tool = sys.argv[1]
|
|
args_json = sys.argv[2] if len(sys.argv) > 2 else "{}"
|
|
|
|
cli = MCPToolCLI()
|
|
sys.exit(cli.run(tool, args_json))
|