aria / scripts /mcp_validate.py
varshithreddy1807's picture
fix: resolve Pydantic compatibility for MCP SDK (#13)
a5d33a6
Raw History Blame Contribute Delete
20.8 kB
#!/usr/bin/env python
"""Protocol conformance validator for the Repo Intelligence stdio MCP server.
Launches `python -m backend.cli mcp` as a subprocess and drives it over raw
stdio JSON-RPC. Validates the handshake, tool discovery, tool execution, and
the negative paths that an SDK-based client (e.g. MCP Inspector) cannot emit.
Read-only: never mutates the repository or production code.
Usage:
python scripts/mcp_validate.py # full run
python scripts/mcp_validate.py --skip-slow # skip LLM/network-bound tools
python scripts/mcp_validate.py --json report.json
Exit code: 0 = no FAILs, 1 = at least one FAIL.
"""
from __future__ import annotations
import argparse
import json
import os
import queue
import subprocess
import sys
import threading
import time
from typing import Any
PROTOCOL_VERSION = "2024-11-05"
SLOW_TOOLS = {
"get_dead_code", # builds dependency + call graphs
"query_codebase", # LLM round trip
"get_workspace", # aggregates every workspace panel (FastMCP only)
"generate_report", # composes every analysis (FastMCP only)
"export_report", # composes then renders (FastMCP only)
"analyze_repository", # clones over the network (FastMCP only)
"get_impact_analysis", # LLM-assisted prediction (FastMCP only)
"semantic_search", # loads the embedding model (FastMCP only)
}
TRACEBACK_MARKER = "Traceback (most recent call last)"
RESULTS: list[dict[str, Any]] = []
# Every client-visible failure payload (JSON-RPC error objects and isError
# results), collected so the hygiene phase can sweep them for leaked internals.
FAILURE_PAYLOADS: list[str] = []
def record(
phase: str, name: str, status: str, note: str = "", ms: float | None = None
) -> None:
RESULTS.append(
{"phase": phase, "check": name, "status": status, "note": note, "ms": ms}
)
icon = {"PASS": "[PASS]", "FAIL": "[FAIL]", "WARN": "[WARN]", "INFO": "[INFO]"}[
status
]
timing = f" {ms:7.1f}ms" if ms is not None else " " * 10
print(
f"{icon}{timing} {phase:<12} {name}{f' - {note}' if note else ''}", flush=True
)
class StdioClient:
"""Minimal framed JSON-RPC client over a subprocess' stdin/stdout."""
def __init__(self, cmd: list[str], cwd: str) -> None:
env = dict(os.environ, PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
self.stdout_q: queue.Queue[str | None] = queue.Queue()
self.stderr_lines: list[str] = []
self.raw_stdout: list[str] = []
self.stale_frames = 0
self._id = 0
t0 = time.perf_counter()
self.proc = subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
bufsize=1,
)
self.spawn_ms = (time.perf_counter() - t0) * 1000
threading.Thread(target=self._pump_stdout, daemon=True).start()
threading.Thread(target=self._pump_stderr, daemon=True).start()
def _pump_stdout(self) -> None:
for line in self.proc.stdout: # type: ignore[union-attr]
if line.strip():
self.raw_stdout.append(line.rstrip("\n"))
self.stdout_q.put(line.rstrip("\n"))
self.stdout_q.put(None)
def _pump_stderr(self) -> None:
for line in self.proc.stderr: # type: ignore[union-attr]
self.stderr_lines.append(line.rstrip("\n"))
def write_raw(self, payload: str) -> None:
self.proc.stdin.write(payload + "\n") # type: ignore[union-attr]
self.proc.stdin.flush() # type: ignore[union-attr]
def read_frame(self, timeout: float) -> tuple[Any, str | None]:
"""Return (parsed_frame, error_reason). None frame means timeout/EOF."""
try:
line = self.stdout_q.get(timeout=timeout)
except queue.Empty:
return None, f"no response within {timeout}s"
if line is None:
return None, "stdout closed (server exited)"
try:
return json.loads(line), None
except json.JSONDecodeError as exc:
return None, f"non-JSON on stdout ({exc}): {line[:120]!r}"
def call(
self, method: str, params: Any = None, timeout: float = 30.0
) -> tuple[Any, str | None, float]:
self._id += 1
req: dict[str, Any] = {"jsonrpc": "2.0", "id": self._id, "method": method}
if params is not None:
req["params"] = params
t0 = time.perf_counter()
self.write_raw(json.dumps(req))
# Match on id and discard stale frames. A response that arrives after a
# previous call timed out would otherwise desynchronise every later
# check, reporting one slow tool as a dozen protocol failures.
while True:
frame, err = self.read_frame(timeout)
elapsed = (time.perf_counter() - t0) * 1000
if frame is None or frame.get("id") == self._id:
return frame, err, elapsed
if isinstance(frame.get("id"), int) and frame["id"] < self._id:
self.stale_frames += 1
continue
return (
frame,
f"id mismatch: sent {self._id}, got {frame.get('id')!r}",
elapsed,
)
def notify(self, method: str, params: Any = None) -> None:
req: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
req["params"] = params
self.write_raw(json.dumps(req))
def shutdown(self, timeout: float = 10.0) -> tuple[int | None, str]:
try:
self.proc.stdin.close() # type: ignore[union-attr]
except Exception:
pass
try:
return self.proc.wait(timeout=timeout), "exited on stdin EOF"
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=5)
return None, f"did not exit within {timeout}s of EOF; killed"
def check_frame(
phase: str,
name: str,
frame: Any,
err: str | None,
ms: float,
expect_error_code: int | None = None,
) -> bool:
"""Validate envelope shape. Returns True if a usable frame came back."""
if err or frame is None:
record(phase, name, "FAIL", err or "no frame", ms)
return False
problems = []
if frame.get("jsonrpc") != "2.0":
problems.append(f"jsonrpc={frame.get('jsonrpc')!r}")
if ("result" in frame) == ("error" in frame):
problems.append("must carry exactly one of result/error")
if "id" not in frame:
problems.append("missing 'id' member")
payload = "error" if "error" in frame else "result"
detail = "result ok"
if payload == "error":
e = frame["error"]
FAILURE_PAYLOADS.append(json.dumps(e))
if not isinstance(e.get("code"), int) or not isinstance(e.get("message"), str):
problems.append("malformed error object")
if TRACEBACK_MARKER in json.dumps(e):
problems.append("LEAKS TRACEBACK in error payload")
if expect_error_code is not None and e.get("code") != expect_error_code:
problems.append(f"expected code {expect_error_code}, got {e.get('code')}")
detail = f"error {e.get('code')}: {str(e.get('message'))[:70]}"
else:
r = frame["result"]
# MCP tool failures arrive as a successful result carrying isError.
if isinstance(r, dict) and r.get("isError"):
text = " ".join(
c.get("text", "") for c in r.get("content", []) if isinstance(c, dict)
)
FAILURE_PAYLOADS.append(text)
if TRACEBACK_MARKER in text:
problems.append("LEAKS TRACEBACK in isError content")
detail = f"isError: {text.splitlines()[0][:70] if text else '(no message)'}"
elif expect_error_code is not None:
problems.append(
f"expected JSON-RPC error {expect_error_code}, got a result"
)
if problems:
record(phase, name, "FAIL", "; ".join(problems), ms)
return True
record(phase, name, "PASS", detail, ms)
return True
def happy_args(
tool: str, owner: str, repo: str, file_path: str, symbol: str
) -> dict[str, Any]:
args: dict[str, Any] = {}
props = {
"owner": owner,
"repo": repo,
"file_path": file_path,
"symbol_name": symbol,
"query": "What does this project do?",
}
for key, val in props.items():
if key in TOOL_SCHEMAS.get(tool, {}).get("properties", {}):
args[key] = val
return args
TOOL_SCHEMAS: dict[str, dict[str, Any]] = {}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--python", default=sys.executable, help="interpreter that runs the server"
)
ap.add_argument("--cwd", default=os.getcwd(), help="project root")
ap.add_argument(
"--launch-module",
default=None,
help="run 'python -m <module>' instead of 'python -m backend.cli mcp'",
)
ap.add_argument(
"--launch-code",
default=None,
help="run 'python -c <code>'; used to drive the FastMCP server, which has "
"no module entry point of its own",
)
ap.add_argument(
"--expect-tools",
type=int,
default=8,
help="expected tool count (8 for legacy, 17 for FastMCP)",
)
ap.add_argument("--owner", default=None)
ap.add_argument("--repo", default=None)
ap.add_argument("--file-path", default="backend/mcp_server.py")
ap.add_argument("--symbol", default="run_mcp_server")
ap.add_argument("--timeout", type=float, default=30.0)
ap.add_argument("--slow-timeout", type=float, default=180.0)
ap.add_argument("--skip-slow", action="store_true")
ap.add_argument("--json", dest="json_out", default=None)
a = ap.parse_args()
if a.launch_code:
cmd = [a.python, "-c", a.launch_code]
elif a.launch_module:
cmd = [a.python, "-m", a.launch_module]
else:
cmd = [a.python, "-m", "backend.cli", "mcp"]
print(f"Server : {' '.join(cmd)}\nCwd : {a.cwd}\n" + "-" * 88, flush=True)
c = StdioClient(cmd, a.cwd)
record("startup", "process spawned", "INFO", f"pid {c.proc.pid}", c.spawn_ms)
# ---- Phase 1: handshake -------------------------------------------------
frame, err, ms = c.call(
"initialize",
{
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "mcp-validate", "version": "1.0.0"},
},
a.timeout,
)
if not check_frame("handshake", "initialize", frame, err, ms):
record(
"handshake",
"server reachable",
"FAIL",
"aborting; stderr tail: " + " | ".join(c.stderr_lines[-3:]),
)
return finish(c, a.json_out)
res = (frame or {}).get("result", {})
record(
"handshake",
"protocolVersion",
"PASS" if res.get("protocolVersion") else "FAIL",
str(res.get("protocolVersion")),
)
caps = res.get("capabilities", {})
record(
"handshake",
"capabilities.tools",
"PASS" if "tools" in caps else "FAIL",
json.dumps(caps),
)
info = res.get("serverInfo", {})
record(
"handshake",
"serverInfo",
"PASS" if info.get("name") and info.get("version") else "FAIL",
f"{info.get('name')} v{info.get('version')}",
)
# initialized notification must NOT produce a response
c.notify("notifications/initialized")
frame, err, ms = c.call("tools/list", {}, a.timeout)
if frame is not None and frame.get("id") == 2:
record(
"handshake",
"initialized notification silent",
"PASS",
"no reply to notification",
)
else:
record(
"handshake",
"initialized notification silent",
"FAIL",
"server replied to a notification",
)
# ---- Phase 2: discovery -------------------------------------------------
check_frame("discovery", "tools/list", frame, err, ms)
tools = ((frame or {}).get("result") or {}).get("tools") or []
record(
"discovery",
"tool count",
"PASS" if len(tools) == a.expect_tools else "WARN",
f"{len(tools)} tools (expected {a.expect_tools})",
)
for t in tools:
name = t.get("name", "<unnamed>")
schema = t.get("inputSchema") or {}
TOOL_SCHEMAS[name] = schema
issues = []
if not t.get("description"):
issues.append("no description")
if schema.get("type") != "object":
issues.append(f"inputSchema.type={schema.get('type')!r}")
if not isinstance(schema.get("properties"), dict):
issues.append("properties not an object")
for req in schema.get("required", []):
if req not in schema.get("properties", {}):
issues.append(f"required '{req}' absent from properties")
req_list = ",".join(schema.get("required", [])) or "none"
record(
"discovery",
f"schema {name}",
"FAIL" if issues else "PASS",
"; ".join(issues) or f"required: {req_list}",
)
# ---- Phase 3: resolve a live repository --------------------------------
owner, repo = a.owner, a.repo
if not (owner and repo):
frame, err, ms = c.call(
"tools/call", {"name": "list_repositories", "arguments": {}}, a.timeout
)
check_frame("fixture", "list_repositories", frame, err, ms)
try:
listed = json.loads((frame or {})["result"]["content"][0]["text"])
except Exception:
listed = []
if listed:
owner, _, repo = str(listed[0]).partition("/")
record(
"fixture",
"repo under test",
"INFO",
f"{owner}/{repo} (of {len(listed)} indexed)",
)
else:
owner, repo = "octocat", "Spoon-Knife"
record(
"fixture",
"repo under test",
"WARN",
"store empty; happy paths will exercise error paths",
)
# ---- Phase 4: per-tool matrix ------------------------------------------
for name in [t.get("name") for t in tools]:
if not name:
continue
tmo = a.slow_timeout if name in SLOW_TOOLS else a.timeout
if a.skip_slow and name in SLOW_TOOLS:
record("tools", f"{name}", "WARN", "skipped via --skip-slow")
continue
variants: list[tuple[str, Any]] = [
("A happy", happy_args(name, owner, repo, a.file_path, a.symbol)),
(
"B bad repo",
happy_args(
name, "zz-no-such-owner", "zz-no-such-repo", a.file_path, a.symbol
),
),
("C missing req", {}),
(
"D bad types",
{
k: 12345
for k in happy_args(name, owner, repo, a.file_path, a.symbol)
},
),
(
"E odd values",
{
**happy_args(name, "", "", "", ""),
"__unexpected__": [None, {"x": 1}],
},
),
]
for label, args in variants:
frame, err, ms = c.call(
"tools/call", {"name": name, "arguments": args}, tmo
)
check_frame("tools", f"{name} :: {label}", frame, err, ms)
if c.proc.poll() is not None:
record(
"tools",
"server alive",
"FAIL",
f"process died during {name} {label}",
)
return finish(c, a.json_out)
# ---- Phase 5: protocol abuse -------------------------------------------
frame, err, ms = c.call(
"tools/call", {"name": "no_such_tool_xyz", "arguments": {}}, a.timeout
)
check_frame("abuse", "unknown tool name", frame, err, ms)
frame, err, ms = c.call("definitely/not/a/method", {}, a.timeout)
check_frame(
"abuse", "unknown method -> -32601", frame, err, ms, expect_error_code=-32601
)
t0 = time.perf_counter()
c.write_raw('{"jsonrpc":"2.0","id":999,"method":"tools/list"') # truncated JSON
frame, err = c.read_frame(a.timeout)
ms = (time.perf_counter() - t0) * 1000
if frame is None:
record("abuse", "malformed JSON -> parse error", "FAIL", err or "no reply", ms)
else:
code = (frame.get("error") or {}).get("code")
note = f"code {code}" + (
"" if "id" in frame else "; MISSING 'id' member (JSON-RPC requires id:null)"
)
record(
"abuse",
"malformed JSON -> parse error",
"PASS" if code == -32700 else "FAIL",
note,
ms,
)
frame, err, ms = c.call(
"tools/call",
{"name": "list_repositories", "arguments": "not-an-object"},
a.timeout,
)
check_frame("abuse", "arguments not an object", frame, err, ms)
frame, err, ms = c.call("tools/list", {}, a.timeout)
record(
"abuse",
"recovered after abuse",
"PASS" if frame and "result" in frame else "FAIL",
"still serving requests",
ms,
)
return finish(c, a.json_out)
def finish(c: StdioClient, json_out: str | None) -> int:
code, note = c.shutdown()
record(
"shutdown",
"graceful exit on EOF",
"PASS" if code == 0 else "FAIL",
f"rc={code}; {note}",
)
bad_stdout = [ln for ln in c.raw_stdout if not ln.lstrip().startswith("{")]
record(
"hygiene",
"stdout carries only JSON",
"PASS" if not bad_stdout else "FAIL",
"clean"
if not bad_stdout
else f"{len(bad_stdout)} stray line(s): {bad_stdout[0][:80]!r}",
)
leaks = sum(1 for r in RESULTS if "LEAKS TRACEBACK" in r["note"])
record(
"hygiene",
"no traceback in responses",
"PASS" if not leaks else "FAIL",
"clean" if not leaks else f"{leaks} response(s) leaked a stack trace",
)
# Sweep every failure payload for internals. Scoped to failures on purpose:
# successful results legitimately contain repository file paths (e.g.
# analysis.metadata.local_path), so scanning them would false-positive.
blob = "\n".join(FAILURE_PAYLOADS)
internals = {
"traceback header": TRACEBACK_MARKER,
"source line": 'File "',
"drive-letter path": ":\\",
"site-packages path": "site-packages",
"interpreter frame": ", line ",
}
found = [label for label, needle in internals.items() if needle in blob]
record(
"hygiene",
"no internals in failure payloads",
"PASS" if not found else "FAIL",
f"{len(FAILURE_PAYLOADS)} failure payload(s) clean"
if not found
else "leaked: " + ", ".join(found),
)
record(
"hygiene",
"response ordering",
"PASS" if not c.stale_frames else "WARN",
"in order"
if not c.stale_frames
else f"{c.stale_frames} late frame(s) discarded after a timeout",
)
tb_stderr = sum(1 for ln in c.stderr_lines if TRACEBACK_MARKER in ln)
record(
"hygiene",
"stderr tracebacks",
"INFO" if tb_stderr else "PASS",
f"{tb_stderr} on stderr (not client-visible)",
)
print("-" * 88)
timed = [r for r in RESULTS if r["ms"]]
tally = {
s: sum(1 for r in RESULTS if r["status"] == s)
for s in ("PASS", "FAIL", "WARN", "INFO")
}
print(
f"PASS {tally['PASS']} FAIL {tally['FAIL']} WARN {tally['WARN']} INFO {tally['INFO']}"
)
if timed:
slowest = max(timed, key=lambda r: r["ms"])
print(
f"latency: max {slowest['ms']:.0f}ms ({slowest['check']}), "
f"median {sorted(r['ms'] for r in timed)[len(timed) // 2]:.0f}ms"
)
for r in RESULTS:
if r["status"] == "FAIL":
print(f" FAIL {r['phase']}/{r['check']}: {r['note']}")
if json_out:
with open(json_out, "w", encoding="utf-8") as fh:
json.dump({"results": RESULTS, "stderr": c.stderr_lines}, fh, indent=2)
print(f"report -> {json_out}")
return 1 if tally["FAIL"] else 0
if __name__ == "__main__":
sys.exit(main())