Files
Open_Ultra_SAST/tests/test_mcp.py
T
Marius CiepluchandClaude Opus 4.8 9c917dd6aa feat(mcp): Phase 12 — narrow MCP server + ousast mcp
A zero-dependency MCP server over stdio (newline-delimited JSON-RPC 2.0) for
OpenCode/IDE integration, exposing only stable project-level operations.

- mcp.py: McpServer (pure handle() dispatch) + serve() stdio loop, stdlib-only.
  Ten tools: openultrasast.scan/status/findings/get_finding/evidence/artifacts/
  benchmark/explain/propose_patch/export_report. scan/benchmark run the bounded
  analysis pipeline; NO tool accepts a free-form command — no shell, Docker, or
  internal hunter tools are exposed. propose_patch degrades visibly (patch oracle
  is a later phase). initialize/tools/list/tools/call/ping handled; notifications
  get no response; errors surface as JSON-RPC errors instead of crashing the loop.
- cli: `ousast mcp` subcommand launches the stdio server (lazy import → keeps the
  mcp↔cli cycle one-directional).
- tests/test_mcp.py: tool listing is exactly the narrow surface, surface-safety
  (no shell/exec/command tool), scan→status→findings→explain→evidence flow,
  export_report, propose_patch degradation, and the stdio loop round-trip.
- README "MCP server" section; openrouter-sast-harness Phase 12 checked off.

Verified end-to-end over real stdio (initialize → tools/list → scan). 179 passed,
4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-22 19:42:01 +02:00

132 lines
5.2 KiB
Python

"""Narrow MCP server: tool listing, scan→status flow, and surface safety (Phase 12)."""
import io
import json
from pathlib import Path
from openultrasast.mcp import McpServer, serve
EXPECTED_TOOLS = {
"openultrasast.scan",
"openultrasast.status",
"openultrasast.findings",
"openultrasast.get_finding",
"openultrasast.evidence",
"openultrasast.artifacts",
"openultrasast.benchmark",
"openultrasast.explain",
"openultrasast.propose_patch",
"openultrasast.export_report",
}
def _call(server: McpServer, name: str, arguments: dict) -> dict:
response = server.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}})
assert response is not None
result = response["result"]
payload = json.loads(result["content"][0]["text"]) if not result["isError"] else {"error": result["content"][0]["text"]}
return {"isError": result["isError"], **payload}
# ---- protocol + tool listing ------------------------------------------------
def test_initialize_returns_server_info() -> None:
server = McpServer()
response = server.handle({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})
assert response is not None
assert response["result"]["serverInfo"]["name"] == "openultrasast"
assert "protocolVersion" in response["result"]
def test_tools_list_is_exactly_the_narrow_surface() -> None:
server = McpServer()
response = server.handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
assert response is not None
names = {tool["name"] for tool in response["result"]["tools"]}
assert names == EXPECTED_TOOLS
def test_surface_exposes_no_shell_or_exec_tool() -> None:
# The narrow surface must never expose arbitrary shell/exec/docker tools.
server = McpServer()
forbidden = ("shell", "exec", "command", "docker", "run_command", "bash")
for name in server.tool_names:
assert not any(token in name.lower() for token in forbidden), name
for tool in McpServer()._tools.values(): # no tool accepts a free-form command argument
assert "command" not in tool.input_schema.get("properties", {})
def test_notifications_get_no_response() -> None:
server = McpServer()
assert server.handle({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
def test_unknown_method_returns_jsonrpc_error() -> None:
server = McpServer()
response = server.handle({"jsonrpc": "2.0", "id": 9, "method": "does/not/exist"})
assert response is not None and response["error"]["code"] == -32602
# ---- scan -> status -> findings -> explain flow -----------------------------
def _repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text("@app.route('/admin')\ndef admin():\n return eval(request.data)\n")
return repo
def test_scan_then_status_and_findings(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", str(tmp_path / "runs"))
server = McpServer()
scan = _call(server, "openultrasast.scan", {"path": str(_repo(tmp_path)), "mode": "quick"})
assert not scan["isError"] and scan["scan_id"]
run_dir = scan["run_dir"]
status = _call(server, "openultrasast.status", {"run_dir": run_dir})
assert status["finding_count"] == scan["finding_count"]
findings = _call(server, "openultrasast.findings", {"run_dir": run_dir})
assert findings["count"] == scan["finding_count"]
finding_id = findings["findings"][0]["finding_id"]
explain = _call(server, "openultrasast.explain", {"run_dir": run_dir, "finding_id": finding_id})
assert explain["finding_id"] == finding_id and explain["severity"]
evidence = _call(server, "openultrasast.evidence", {"run_dir": run_dir, "finding_id": finding_id})
assert "reachability_status" in evidence
def test_propose_patch_degrades_visibly(tmp_path: Path) -> None:
server = McpServer()
result = _call(server, "openultrasast.propose_patch", {"run_dir": str(tmp_path), "finding_id": "x"})
assert result["status"] == "not_implemented"
def test_missing_required_argument_is_a_tool_error() -> None:
server = McpServer()
result = _call(server, "openultrasast.status", {})
assert result["isError"] and "run_dir" in result["error"]
def test_export_report_returns_markdown(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", str(tmp_path / "runs"))
server = McpServer()
scan = _call(server, "openultrasast.scan", {"path": str(_repo(tmp_path)), "mode": "quick"})
report = _call(server, "openultrasast.export_report", {"run_dir": scan["run_dir"], "format": "markdown"})
assert report["format"] == "markdown" and report["content"]
# ---- stdio loop -------------------------------------------------------------
def test_serve_stdio_loop_handles_tools_list() -> None:
stdin = io.StringIO(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + "\n")
stdout = io.StringIO()
assert serve(stdin, stdout) == 0
response = json.loads(stdout.getvalue().strip())
assert {tool["name"] for tool in response["result"]["tools"]} == EXPECTED_TOOLS