mxready 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mxready/__init__.py +1 -0
- mxready/api/__init__.py +4 -0
- mxready/api/middleware.py +149 -0
- mxready/api/rules.py +10 -0
- mxready/api/scans.py +164 -0
- mxready/api/workspace.py +156 -0
- mxready/app.py +179 -0
- mxready/cli/__init__.py +1 -0
- mxready/cli/build_verification_bundle.py +45 -0
- mxready/cli/generate_public_report.py +140 -0
- mxready/cli/scan_batch.py +368 -0
- mxready/cli/scan_repository.py +212 -0
- mxready/cli/serve.py +55 -0
- mxready/config.py +117 -0
- mxready/errors.py +11 -0
- mxready/logging_setup.py +88 -0
- mxready/mcp/__init__.py +5 -0
- mxready/mcp/server.py +433 -0
- mxready/models.py +382 -0
- mxready/persistence/__init__.py +1 -0
- mxready/persistence/sqlite.py +581 -0
- mxready/py.typed +0 -0
- mxready/reporting/__init__.py +4 -0
- mxready/reporting/badge.py +40 -0
- mxready/reporting/comparison.py +33 -0
- mxready/reporting/markdown.py +157 -0
- mxready/reporting/sarif.py +94 -0
- mxready/reporting/terminal.py +75 -0
- mxready/repository/__init__.py +14 -0
- mxready/repository/git_client.py +275 -0
- mxready/repository/github_archive.py +566 -0
- mxready/repository/identity.py +140 -0
- mxready/resources/__init__.py +19 -0
- mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
- mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
- mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
- mxready/resources/frontend/index.html +17 -0
- mxready/resources/rules/v1/core.yml +634 -0
- mxready/resources/rules/v1/manifest.yml +4 -0
- mxready/resources/schemas/verification-result-v1.json +259 -0
- mxready/scanning/__init__.py +17 -0
- mxready/scanning/analyzer.py +183 -0
- mxready/scanning/facts.py +596 -0
- mxready/scanning/indexer.py +299 -0
- mxready/scanning/path_context.py +174 -0
- mxready/scanning/python_source.py +67 -0
- mxready/scanning/rule_engine.py +595 -0
- mxready/scanning/rule_loader.py +282 -0
- mxready/scanning/toolchain.py +100 -0
- mxready/services/__init__.py +3 -0
- mxready/services/scans.py +239 -0
- mxready/services/worker.py +38 -0
- mxready/verification/__init__.py +11 -0
- mxready/verification/bundle.py +137 -0
- mxready/verification/validation.py +101 -0
- mxready-1.0.0.dist-info/METADATA +526 -0
- mxready-1.0.0.dist-info/RECORD +71 -0
- mxready-1.0.0.dist-info/WHEEL +5 -0
- mxready-1.0.0.dist-info/entry_points.txt +8 -0
- mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
- mxready-1.0.0.dist-info/top_level.txt +2 -0
- mxready_runner/__init__.py +1 -0
- mxready_runner/__main__.py +3 -0
- mxready_runner/cli.py +88 -0
- mxready_runner/contracts.py +26 -0
- mxready_runner/execute.py +185 -0
- mxready_runner/inspect.py +79 -0
- mxready_runner/process.py +103 -0
- mxready_runner/py.typed +0 -0
- mxready_runner/redact.py +36 -0
- mxready_runner/schema.py +326 -0
mxready/mcp/server.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Model Context Protocol front end for the MXReady static readiness pipeline.
|
|
2
|
+
|
|
3
|
+
The HTTP application and this server build the same scan pipeline; this module
|
|
4
|
+
exists so an agent can ask MXReady for migration facts instead of guessing about
|
|
5
|
+
CUDA to MXMACA compatibility.
|
|
6
|
+
|
|
7
|
+
Two constraints shape the implementation:
|
|
8
|
+
|
|
9
|
+
* stdout is the stdio transport channel, so logging must never reach it;
|
|
10
|
+
* the ``mcp`` SDK is an optional dependency, so nothing outside this module may
|
|
11
|
+
import it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import atexit
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sys
|
|
22
|
+
from threading import Lock
|
|
23
|
+
from time import monotonic, sleep
|
|
24
|
+
from typing import Any
|
|
25
|
+
from uuid import UUID
|
|
26
|
+
|
|
27
|
+
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
|
|
29
|
+
from mxready import __version__
|
|
30
|
+
from mxready.config import Settings
|
|
31
|
+
from mxready.errors import MxReadyError
|
|
32
|
+
from mxready.models import Finding, ScanJob, ScanReport, ScanStatus
|
|
33
|
+
from mxready.persistence.sqlite import SQLiteStore
|
|
34
|
+
from mxready.repository.git_client import GitClient
|
|
35
|
+
from mxready.repository.identity import build_providers
|
|
36
|
+
from mxready.scanning.analyzer import ScanAnalyzer
|
|
37
|
+
from mxready.scanning.rule_loader import RuleCatalog, load_rule_catalog
|
|
38
|
+
from mxready.services.scans import ScanService
|
|
39
|
+
from mxready.services.worker import ScanWorker
|
|
40
|
+
|
|
41
|
+
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
|
42
|
+
_SEVERITY_ORDER = {"blocker": 0, "warning": 1, "info": 2}
|
|
43
|
+
_DEFAULT_TIMEOUT_SECONDS = 300
|
|
44
|
+
_MIN_TIMEOUT_SECONDS = 30
|
|
45
|
+
_MAX_TIMEOUT_SECONDS = 1800
|
|
46
|
+
_MAX_FINDINGS_CEILING = 500
|
|
47
|
+
_POLL_SECONDS = 0.5
|
|
48
|
+
|
|
49
|
+
_INSTRUCTIONS = (
|
|
50
|
+
"MXReady returns static, evidence-backed readiness findings for PyTorch CUDA "
|
|
51
|
+
"extension repositories that are being considered for migration to MetaX MXMACA. "
|
|
52
|
+
"Every finding carries a rule id, a file and line, the matched evidence, and a "
|
|
53
|
+
"recommendation, so a migration plan can cite verifiable facts instead of a guessed "
|
|
54
|
+
"compatibility verdict. Findings are advisory: a cleared static check is not a "
|
|
55
|
+
"hardware verification result, and verification_status only becomes 'verified' "
|
|
56
|
+
"after a runner result produced on a MetaX GPU is uploaded for the same commit."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _ScanRuntime:
|
|
61
|
+
"""Owns the durable queue, the analyzer and the worker threads for one session."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, settings: Settings) -> None:
|
|
64
|
+
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
settings.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
self.catalog: RuleCatalog = load_rule_catalog(
|
|
67
|
+
settings.rules_dir,
|
|
68
|
+
extra_directory=settings.extra_rules_dir,
|
|
69
|
+
)
|
|
70
|
+
self._store = SQLiteStore(settings.data_dir / "mxready.db")
|
|
71
|
+
self._store.initialize()
|
|
72
|
+
providers = build_providers(settings.allowed_hosts)
|
|
73
|
+
self._service = ScanService(
|
|
74
|
+
self._store,
|
|
75
|
+
GitClient(),
|
|
76
|
+
ScanAnalyzer(
|
|
77
|
+
self.catalog,
|
|
78
|
+
providers=providers,
|
|
79
|
+
enable_prefilter=settings.prefilter_enabled,
|
|
80
|
+
),
|
|
81
|
+
settings,
|
|
82
|
+
providers=providers,
|
|
83
|
+
)
|
|
84
|
+
self._worker = ScanWorker(self._service)
|
|
85
|
+
|
|
86
|
+
def start(self) -> None:
|
|
87
|
+
self._worker.start()
|
|
88
|
+
|
|
89
|
+
def stop(self) -> None:
|
|
90
|
+
self._worker.stop()
|
|
91
|
+
|
|
92
|
+
def scan_and_wait(
|
|
93
|
+
self,
|
|
94
|
+
repo_url: str,
|
|
95
|
+
ref: str | None,
|
|
96
|
+
timeout_seconds: int,
|
|
97
|
+
) -> ScanReport:
|
|
98
|
+
job = self._service.create_scan(repo_url, ref)
|
|
99
|
+
finished = self._wait_for(job, timeout_seconds)
|
|
100
|
+
if finished.status is ScanStatus.FAILED:
|
|
101
|
+
raise MxReadyError(
|
|
102
|
+
finished.failure_code or "SCAN_FAILED",
|
|
103
|
+
finished.failure_message or "The scan failed.",
|
|
104
|
+
{"scan_id": str(finished.id)},
|
|
105
|
+
)
|
|
106
|
+
return self._service.get_report(finished.id)
|
|
107
|
+
|
|
108
|
+
def get_report(self, scan_id: UUID) -> ScanReport:
|
|
109
|
+
return self._service.get_report(scan_id)
|
|
110
|
+
|
|
111
|
+
def _wait_for(self, job: ScanJob, timeout_seconds: int) -> ScanJob:
|
|
112
|
+
deadline = monotonic() + timeout_seconds
|
|
113
|
+
current = job
|
|
114
|
+
while current.status not in (ScanStatus.COMPLETED, ScanStatus.FAILED):
|
|
115
|
+
if monotonic() >= deadline:
|
|
116
|
+
raise MxReadyError(
|
|
117
|
+
"SCAN_TIMEOUT",
|
|
118
|
+
f"The scan did not finish within {timeout_seconds} seconds.",
|
|
119
|
+
{"scan_id": str(job.id), "status": str(current.status)},
|
|
120
|
+
)
|
|
121
|
+
sleep(_POLL_SECONDS)
|
|
122
|
+
current = self._service.get_scan(job.id)
|
|
123
|
+
return current
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _configure_stderr_logging() -> None:
|
|
127
|
+
"""Send MXReady logs to stderr; stdout belongs to the stdio transport."""
|
|
128
|
+
target = logging.getLogger("mxready")
|
|
129
|
+
if target.handlers:
|
|
130
|
+
return
|
|
131
|
+
target.setLevel(logging.INFO)
|
|
132
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
133
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
|
134
|
+
target.addHandler(handler)
|
|
135
|
+
target.propagate = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _bounded_timeout(value: int) -> int:
|
|
139
|
+
return max(_MIN_TIMEOUT_SECONDS, min(value, _MAX_TIMEOUT_SECONDS))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _bounded_findings(value: int) -> int:
|
|
143
|
+
return max(1, min(value, _MAX_FINDINGS_CEILING))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _finding_payload(finding: Finding) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"rule_id": finding.rule_id,
|
|
149
|
+
"severity": str(finding.severity),
|
|
150
|
+
"category": finding.category,
|
|
151
|
+
"title": finding.title,
|
|
152
|
+
"file": finding.relative_path,
|
|
153
|
+
"line_start": finding.line_start,
|
|
154
|
+
"line_end": finding.line_end,
|
|
155
|
+
"occurrences": finding.count,
|
|
156
|
+
"evidence": finding.evidence,
|
|
157
|
+
"message": finding.message,
|
|
158
|
+
"recommendation": finding.recommendation,
|
|
159
|
+
"references": [{"title": item.title, "url": item.url} for item in finding.references],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _report_payload(
|
|
164
|
+
report: ScanReport,
|
|
165
|
+
max_findings: int,
|
|
166
|
+
*,
|
|
167
|
+
offset: int = 0,
|
|
168
|
+
severity: str | None = None,
|
|
169
|
+
file: str | None = None,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
ordered = sorted(
|
|
172
|
+
report.findings,
|
|
173
|
+
key=lambda finding: (
|
|
174
|
+
_SEVERITY_ORDER.get(str(finding.severity), len(_SEVERITY_ORDER)),
|
|
175
|
+
finding.relative_path,
|
|
176
|
+
finding.line_start,
|
|
177
|
+
finding.rule_id,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
ordered = [
|
|
181
|
+
item
|
|
182
|
+
for item in ordered
|
|
183
|
+
if (not severity or item.severity == severity) and (not file or item.relative_path == file)
|
|
184
|
+
]
|
|
185
|
+
selected = ordered[offset : offset + max_findings]
|
|
186
|
+
next_offset = offset + len(selected)
|
|
187
|
+
cursor = (
|
|
188
|
+
base64.urlsafe_b64encode(
|
|
189
|
+
json.dumps(
|
|
190
|
+
{
|
|
191
|
+
"scan_id": str(report.scan_id),
|
|
192
|
+
"offset": next_offset,
|
|
193
|
+
"severity": severity,
|
|
194
|
+
"file": file,
|
|
195
|
+
}
|
|
196
|
+
).encode()
|
|
197
|
+
).decode()
|
|
198
|
+
if next_offset < len(ordered)
|
|
199
|
+
else None
|
|
200
|
+
)
|
|
201
|
+
return {
|
|
202
|
+
"scan_id": str(report.scan_id),
|
|
203
|
+
"repository": {
|
|
204
|
+
"url": report.repository.url,
|
|
205
|
+
"provider": report.repository.provider,
|
|
206
|
+
"commit": report.repository.commit,
|
|
207
|
+
},
|
|
208
|
+
"scanned_at": report.scanned_at.isoformat(),
|
|
209
|
+
"tool_version": report.tool_version,
|
|
210
|
+
"ruleset_version": report.ruleset_version,
|
|
211
|
+
"static_status": str(report.static_status),
|
|
212
|
+
"verification_status": str(report.verification_status),
|
|
213
|
+
"summary": report.summary.model_dump(mode="json"),
|
|
214
|
+
"findings": [_finding_payload(item) for item in selected],
|
|
215
|
+
"returned_findings": len(selected),
|
|
216
|
+
"total_findings": len(report.findings),
|
|
217
|
+
"matched_findings": len(ordered),
|
|
218
|
+
"next_cursor": cursor,
|
|
219
|
+
"findings_truncated": len(report.findings) > len(selected),
|
|
220
|
+
"migration_checklist": [
|
|
221
|
+
item.model_dump(mode="json") for item in report.migration_checklist
|
|
222
|
+
],
|
|
223
|
+
"analysis_warnings": [item.model_dump(mode="json") for item in report.analysis_warnings],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _page_options(
|
|
228
|
+
scan_id: str, cursor: str | None, severity: str | None, file: str | None
|
|
229
|
+
) -> tuple[int, str | None, str | None]:
|
|
230
|
+
offset = 0
|
|
231
|
+
if cursor:
|
|
232
|
+
try:
|
|
233
|
+
if len(cursor) > 4096:
|
|
234
|
+
raise ValueError("cursor too long")
|
|
235
|
+
page = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
|
|
236
|
+
if set(page) != {"scan_id", "offset", "severity", "file"} or page["scan_id"] != scan_id:
|
|
237
|
+
raise ValueError("cursor belongs to a different query")
|
|
238
|
+
offset = page["offset"]
|
|
239
|
+
if type(offset) is not int or not 0 <= offset <= 10_000:
|
|
240
|
+
raise ValueError("invalid offset")
|
|
241
|
+
if severity is not None and severity != page["severity"]:
|
|
242
|
+
raise ValueError("severity changed")
|
|
243
|
+
if file is not None and file != page["file"]:
|
|
244
|
+
raise ValueError("file changed")
|
|
245
|
+
severity, file = page["severity"], page["file"]
|
|
246
|
+
if severity is not None and not isinstance(severity, str):
|
|
247
|
+
raise ValueError("invalid severity")
|
|
248
|
+
if file is not None and (not isinstance(file, str) or len(file) > 1024):
|
|
249
|
+
raise ValueError("invalid file")
|
|
250
|
+
except (ValueError, TypeError, KeyError) as error:
|
|
251
|
+
raise MxReadyError(
|
|
252
|
+
"INVALID_CURSOR", "Use next_cursor from the same report query."
|
|
253
|
+
) from error
|
|
254
|
+
if severity is not None and severity not in _SEVERITY_ORDER:
|
|
255
|
+
raise MxReadyError("INVALID_FILTER", "severity must be blocker, warning or info.")
|
|
256
|
+
if file is not None and len(file) > 1024:
|
|
257
|
+
raise MxReadyError("INVALID_FILTER", "file filter is too long.")
|
|
258
|
+
return offset, severity, file
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _error_payload(error: Exception) -> dict[str, Any]:
|
|
262
|
+
if isinstance(error, MxReadyError):
|
|
263
|
+
return {
|
|
264
|
+
"error": {
|
|
265
|
+
"code": error.code,
|
|
266
|
+
"message": error.message,
|
|
267
|
+
"details": error.details,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {"error": {"code": type(error).__name__, "message": str(error), "details": {}}}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_runtime_lock = Lock()
|
|
274
|
+
_runtime_instance: _ScanRuntime | None = None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _runtime() -> _ScanRuntime:
|
|
278
|
+
global _runtime_instance
|
|
279
|
+
with _runtime_lock:
|
|
280
|
+
if _runtime_instance is None:
|
|
281
|
+
runtime = _ScanRuntime(Settings.from_env())
|
|
282
|
+
runtime.start()
|
|
283
|
+
atexit.register(runtime.stop)
|
|
284
|
+
_runtime_instance = runtime
|
|
285
|
+
return _runtime_instance
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def build_server() -> FastMCP:
|
|
289
|
+
"""Register the MXReady tools on a fresh FastMCP server."""
|
|
290
|
+
server = FastMCP("MXReady", instructions=_INSTRUCTIONS)
|
|
291
|
+
# The SDK reports its own package version in the initialize result by
|
|
292
|
+
# default; surface the MXReady version so a client can tell which build
|
|
293
|
+
# answered. FastMCP exposes no constructor argument for this.
|
|
294
|
+
server._mcp_server.version = __version__
|
|
295
|
+
|
|
296
|
+
@server.tool()
|
|
297
|
+
async def scan_repository(
|
|
298
|
+
repo_url: str,
|
|
299
|
+
ref: str | None = None,
|
|
300
|
+
timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS,
|
|
301
|
+
max_findings: int = 20,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Scan a public GitHub or Gitee repository and return readiness findings.
|
|
304
|
+
|
|
305
|
+
Blocks until the scan finishes or the timeout expires. The report is bound
|
|
306
|
+
to the resolved commit, so the same findings can be reproduced later.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
repo_url: Public HTTPS repository URL on an allowlisted host.
|
|
310
|
+
ref: Optional branch, tag, or 40-character commit to pin.
|
|
311
|
+
timeout_seconds: Wall-clock budget, clamped to 30..1800.
|
|
312
|
+
max_findings: Maximum findings returned, ordered by severity.
|
|
313
|
+
"""
|
|
314
|
+
try:
|
|
315
|
+
|
|
316
|
+
def scan() -> ScanReport:
|
|
317
|
+
return _runtime().scan_and_wait(repo_url, ref, _bounded_timeout(timeout_seconds))
|
|
318
|
+
|
|
319
|
+
report = await asyncio.to_thread(scan)
|
|
320
|
+
return _report_payload(report, _bounded_findings(max_findings))
|
|
321
|
+
except (MxReadyError, OSError, ValueError) as error:
|
|
322
|
+
return _error_payload(error)
|
|
323
|
+
|
|
324
|
+
@server.tool()
|
|
325
|
+
async def get_report(
|
|
326
|
+
scan_id: str,
|
|
327
|
+
max_findings: int = 50,
|
|
328
|
+
cursor: str | None = None,
|
|
329
|
+
severity: str | None = None,
|
|
330
|
+
file: str | None = None,
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
"""Return a previously completed MXReady report by scan id.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
scan_id: UUID returned by scan_repository.
|
|
336
|
+
max_findings: Maximum findings returned, ordered by severity.
|
|
337
|
+
cursor: Opaque next_cursor returned by the preceding page of this query.
|
|
338
|
+
severity: Optional blocker, warning or info filter.
|
|
339
|
+
file: Optional exact repository-relative file path filter.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
normalized_id = str(UUID(scan_id))
|
|
343
|
+
offset, severity, file = _page_options(normalized_id, cursor, severity, file)
|
|
344
|
+
report = await asyncio.to_thread(lambda: _runtime().get_report(UUID(normalized_id)))
|
|
345
|
+
return _report_payload(
|
|
346
|
+
report, _bounded_findings(max_findings), offset=offset, severity=severity, file=file
|
|
347
|
+
)
|
|
348
|
+
except (MxReadyError, ValueError) as error:
|
|
349
|
+
return _error_payload(error)
|
|
350
|
+
|
|
351
|
+
@server.tool()
|
|
352
|
+
async def get_scan(scan_id: str) -> dict[str, Any]:
|
|
353
|
+
"""Read scan progress or failure after a scan timeout; never creates a new scan."""
|
|
354
|
+
try:
|
|
355
|
+
job = await asyncio.to_thread(lambda: _runtime()._service.get_scan(UUID(scan_id)))
|
|
356
|
+
return job.model_dump(mode="json")
|
|
357
|
+
except (MxReadyError, ValueError) as error:
|
|
358
|
+
return _error_payload(error)
|
|
359
|
+
|
|
360
|
+
@server.tool()
|
|
361
|
+
async def list_rules(
|
|
362
|
+
category: str | None = None,
|
|
363
|
+
severity: str | None = None,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
"""List the bundled MXMACA migration rules, optionally filtered.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
category: Case-insensitive category filter, for example "toolchain".
|
|
369
|
+
severity: One of "blocker", "warning", or "info".
|
|
370
|
+
"""
|
|
371
|
+
catalog = await asyncio.to_thread(lambda: _runtime().catalog)
|
|
372
|
+
rules = catalog.rules
|
|
373
|
+
if category:
|
|
374
|
+
rules = [rule for rule in rules if rule.category.casefold() == category.casefold()]
|
|
375
|
+
if severity:
|
|
376
|
+
rules = [rule for rule in rules if str(rule.severity).casefold() == severity.casefold()]
|
|
377
|
+
return {
|
|
378
|
+
"tool_version": __version__,
|
|
379
|
+
"ruleset_version": catalog.version,
|
|
380
|
+
"total": len(rules),
|
|
381
|
+
"rules": [
|
|
382
|
+
{
|
|
383
|
+
"id": rule.id,
|
|
384
|
+
"title": rule.title,
|
|
385
|
+
"severity": str(rule.severity),
|
|
386
|
+
"category": rule.category,
|
|
387
|
+
}
|
|
388
|
+
for rule in rules
|
|
389
|
+
],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@server.tool()
|
|
393
|
+
async def explain_rule(rule_id: str) -> dict[str, Any]:
|
|
394
|
+
"""Return one rule with its rationale, recommendation, and sources.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
rule_id: Exact rule identifier, for example "MXR-WARP-001".
|
|
398
|
+
"""
|
|
399
|
+
catalog = await asyncio.to_thread(lambda: _runtime().catalog)
|
|
400
|
+
for rule in catalog.rules:
|
|
401
|
+
if rule.id == rule_id:
|
|
402
|
+
return {
|
|
403
|
+
"id": rule.id,
|
|
404
|
+
"version": rule.version,
|
|
405
|
+
"title": rule.title,
|
|
406
|
+
"severity": str(rule.severity),
|
|
407
|
+
"category": rule.category,
|
|
408
|
+
"file_globs": list(rule.file_globs),
|
|
409
|
+
"message": rule.message,
|
|
410
|
+
"recommendation": rule.recommendation,
|
|
411
|
+
"confidence": rule.confidence,
|
|
412
|
+
"references": [
|
|
413
|
+
{"title": item.title, "url": item.url} for item in rule.references
|
|
414
|
+
],
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
"error": {
|
|
418
|
+
"code": "RULE_NOT_FOUND",
|
|
419
|
+
"message": f"No rule with id {rule_id}.",
|
|
420
|
+
"details": {},
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return server
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main() -> None:
|
|
428
|
+
_configure_stderr_logging()
|
|
429
|
+
build_server().run()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
main()
|