feat(mcp): HTTP+Bearer CRA-MCP-Server für den Repo-Scanner + Finding-Adapter
Register-Flow für compliance-scanner-agent (anderes Team, Rust): deren MCP-Client (McpServerConfig) erwartet Streamable HTTP + Bearer — unser MCP war stdio/ohne Auth. - server.py auf FastMCP umgestellt: Tools cra_assess_findings + cra_list_requirements, Dual-Transport (stdio default; Streamable HTTP wenn MCP_PORT gesetzt), Bearer-Gate via CRA_MCP_TOKEN. - ScannerFinding.from_dict tolerant für ihr Finding-Schema (_id/fingerprint, scan_type→category, cvss_score→cvss, file_path→location, severity info→low). - Eigenständiger docker-compose-Dienst bp-compliance-mcp (Port 8099, pure/kein DB, isoliert von der Haupt-API) + Hetzner-amd64-Override. - Tests: test_cra_scanner_adapter, test_mcp_server (Bearer-Gate + Tool-Registry). Pull-Flow (wir holen ihre Findings über ihren MCP) + öffentliches nginx-Routing folgen separat (brauchen ihren Endpoint/Token). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""The MCP finding adapter must accept the compliance-scanner-agent Finding shape.
|
||||
|
||||
Their Rust/Mongo Finding uses _id, scan_type, cvss_score, file_path and a 5-level
|
||||
severity (incl. "info"). Our mapper consumes id, category, cvss, location and a
|
||||
4-level severity. from_dict bridges that gap.
|
||||
"""
|
||||
from compliance.services.cra_finding_mapper import (
|
||||
ScannerFinding,
|
||||
assess_findings_payload,
|
||||
)
|
||||
|
||||
|
||||
def _scanner_finding(**over):
|
||||
"""A finding shaped like compliance-scanner-agent emits it."""
|
||||
base = {
|
||||
"_id": "507f1f77bcf86cd799439011",
|
||||
"repo_id": "r1",
|
||||
"fingerprint": "fp-123",
|
||||
"scanner": "semgrep",
|
||||
"scan_type": "secret_detection",
|
||||
"title": "Hardcoded credential in source",
|
||||
"description": "A hardcoded password was found.",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-798",
|
||||
"cve": None,
|
||||
"cvss_score": 8.2,
|
||||
"file_path": "src/config.py",
|
||||
"status": "open",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_from_dict_maps_scanner_field_names():
|
||||
f = ScannerFinding.from_dict(_scanner_finding())
|
||||
assert f.id == "507f1f77bcf86cd799439011" # from _id
|
||||
assert f.category == "secret_detection" # from scan_type
|
||||
assert f.cvss == 8.2 # from cvss_score
|
||||
assert f.location == "src/config.py" # from file_path
|
||||
assert f.cwe == "CWE-798"
|
||||
assert f.severity == "high"
|
||||
|
||||
|
||||
def test_from_dict_info_severity_becomes_low():
|
||||
f = ScannerFinding.from_dict(_scanner_finding(severity="info"))
|
||||
assert f.severity == "low"
|
||||
|
||||
|
||||
def test_from_dict_mongo_extended_json_id():
|
||||
f = ScannerFinding.from_dict(_scanner_finding(_id={"$oid": "abc123"}))
|
||||
assert f.id == "abc123"
|
||||
|
||||
|
||||
def test_scanner_finding_assesses_to_cra_requirement():
|
||||
result = assess_findings_payload({"findings": [_scanner_finding()]})
|
||||
mapped = result["mapped"]
|
||||
assert len(mapped) == 1
|
||||
m = mapped[0]
|
||||
assert m["finding_id"] == "507f1f77bcf86cd799439011"
|
||||
# CWE-798 -> CRA-AI-9/-8 (hardcoded credentials / no default passwords)
|
||||
assert m["primary_requirement"].startswith("CRA-AI-")
|
||||
assert m["requirement_ids"]
|
||||
assert m["risk_level"] in {"LOW", "MEDIUM", "HIGH", "CRITICAL"}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The MCP HTTP transport must gate on the Bearer token the scanner sends.
|
||||
|
||||
The scanner registers us with an access_token (McpServerConfig.access_token) and
|
||||
calls over Streamable HTTP. When CRA_MCP_TOKEN is set, requests without the exact
|
||||
``Authorization: Bearer <token>`` must be rejected before reaching the MCP layer.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp") # MCP SDK only present in the container/CI image
|
||||
|
||||
_MCP_HDR = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
||||
_INIT = {
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
||||
"clientInfo": {"name": "t", "version": "1"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("CRA_MCP_TOKEN", "testtok")
|
||||
from starlette.testclient import TestClient
|
||||
import compliance.mcp.server as srv
|
||||
importlib.reload(srv) # rebuild app under the patched env
|
||||
with TestClient(srv._build_http_app()) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_missing_token_rejected(client):
|
||||
r = client.post("/mcp", json=_INIT, headers=_MCP_HDR)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_token_rejected(client):
|
||||
r = client.post("/mcp", json=_INIT, headers={**_MCP_HDR, "Authorization": "Bearer nope"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_correct_token_passes_auth(client):
|
||||
# Reaches the MCP layer (any non-401 status) → the Bearer gate let it through.
|
||||
r = client.post("/mcp", json=_INIT, headers={**_MCP_HDR, "Authorization": "Bearer testtok"})
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_tools_registered():
|
||||
import compliance.mcp.server as srv
|
||||
importlib.reload(srv)
|
||||
names = {t.name for t in srv.mcp._tool_manager.list_tools()}
|
||||
assert {"cra_assess_findings", "cra_list_requirements"} <= names
|
||||
Reference in New Issue
Block a user