mcpxray-cli 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.
mcpxray/fix.py ADDED
@@ -0,0 +1,166 @@
1
+ """Auto-fix machinery: turn rule-proposed :class:`~mcpxray.ir.Fix`es into file
2
+ edits and diffs.
3
+
4
+ Only **MCP108** (unpinned dependencies) is mechanically fixable today — it pins
5
+ a floating version spec to its concrete floor (``requests>=2.30`` →
6
+ ``requests==2.30.0``, ``"^1.2.3"`` → ``"1.2.3"``). Specs with no resolvable
7
+ floor (``*``, ``latest``, a bare name like ``flask``) are skipped and left for
8
+ manual pinning — mcpxray never invents a version by resolving a registry.
9
+
10
+ ``--fix`` is opt-in and **static-source-only** (it rewrites files in place);
11
+ ``--diff`` prints a unified diff of the same edits without writing. Both flow
12
+ through :func:`plan_fixes`, which collects every diagnostic's ``fix`` and groups
13
+ it by file.
14
+
15
+ Safety: every edit is a *literal* substring replacement applied only when its
16
+ ``old`` text occurs exactly once in the file (see :func:`_apply_edits`) — so a
17
+ spec that doesn't match the common form, or that would match more than one site,
18
+ is skipped rather than applied wrongly. Writes are atomic (temp file + replace).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import difflib
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from mcpxray.ir import Fix, McpServer, TextEdit
29
+
30
+ # A concrete MAJOR.MINOR.PATCH (optionally with pre-release/build) extractable
31
+ # from a floating spec — the floor it pins down to. The lookbehind only rejects a
32
+ # preceding digit/dot (so we don't match a partial version mid-number); version
33
+ # prefix chars (``v``/``=``/``^``/``~``/``>``/``<``) and letters are allowed.
34
+ _FLOOR_RE = re.compile(r"(?<![0-9.])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)")
35
+
36
+
37
+ def pin_floor(spec: str) -> str | None:
38
+ """The concrete ``X.Y.Z`` a floating spec resolves down to, or ``None``.
39
+
40
+ ``^1.2.3`` / ``~1.2.3`` / ``>=1.2.3`` / ``=1.2.3`` / ``v1.2.3`` → ``1.2.3``.
41
+ ``*`` / ``latest`` / a bare name (``flask``) / ``1.x`` / ``>=2`` → ``None``
42
+ (no full floor → can't pin without resolving from a registry).
43
+ """
44
+ m = _FLOOR_RE.search(spec)
45
+ return m.group(1) if m else None
46
+
47
+
48
+ def exact_pin(spec: str, *, pip: bool) -> str | None:
49
+ """Exact pinned form of ``spec`` for its ecosystem, or ``None`` if unresolvable.
50
+
51
+ pip → ``==X.Y.Z``; npm → ``X.Y.Z``. Returns ``None`` when :func:`pin_floor`
52
+ can't find a floor.
53
+ """
54
+ floor = pin_floor(spec)
55
+ if floor is None:
56
+ return None
57
+ return f"=={floor}" if pip else floor
58
+
59
+
60
+ @dataclass
61
+ class ApplySummary:
62
+ """Outcome of :func:`apply_fixes`: how much changed and what was declined."""
63
+
64
+ files_changed: int = 0
65
+ edits_applied: int = 0
66
+ skipped: list[str] = field(default_factory=list)
67
+
68
+
69
+ def plan_fixes(doc: McpServer) -> list[Fix]:
70
+ """Collect every diagnostic's ``fix`` and merge them into one :class:`Fix` per file."""
71
+ by_file: dict[str, Fix] = {}
72
+ for diag in doc.diagnostics:
73
+ fix = diag.fix
74
+ if fix is None:
75
+ continue
76
+ bucket = by_file.setdefault(fix.file, Fix(description=fix.description, file=fix.file))
77
+ bucket.edits.extend(fix.edits)
78
+ return list(by_file.values())
79
+
80
+
81
+ def _apply_edits(text: str, edits: list[TextEdit]) -> tuple[str, list[TextEdit], list[str]]:
82
+ """Apply ``edits`` to ``text``; return ``(new_text, applied, skipped_messages)``.
83
+
84
+ An edit is applied only when its ``old`` occurs exactly once and its span
85
+ doesn't overlap another kept edit. Absent/ambiguous/overlapping edits are
86
+ skipped (never applied partially).
87
+ """
88
+ spans: list[tuple[int, int, TextEdit]] = []
89
+ skipped: list[str] = []
90
+ for edit in edits:
91
+ if not edit.old or edit.old == edit.new:
92
+ continue
93
+ first = text.find(edit.old)
94
+ if first == -1:
95
+ skipped.append(f"not found in source: {edit.old!r}")
96
+ continue
97
+ if text.find(edit.old, first + 1) != -1:
98
+ skipped.append(f"ambiguous — matches more than one site: {edit.old!r}")
99
+ continue
100
+ spans.append((first, first + len(edit.old), edit))
101
+
102
+ spans.sort()
103
+ kept: list[TextEdit] = []
104
+ last_end = -1
105
+ for start, end, edit in spans:
106
+ if start < last_end:
107
+ skipped.append(f"overlaps another edit: {edit.old!r}")
108
+ continue
109
+ kept.append(edit)
110
+ last_end = end
111
+
112
+ out = text
113
+ for edit in kept: # each `old` is unique, so order is irrelevant
114
+ out = out.replace(edit.old, edit.new, 1)
115
+ return out, kept, skipped
116
+
117
+
118
+ def _atomic_write(path: Path, text: str) -> None:
119
+ tmp = path.with_name(path.name + ".mcpxray-tmp")
120
+ tmp.write_text(text, encoding="utf-8")
121
+ tmp.replace(path)
122
+
123
+
124
+ def apply_fixes(fixes: list[Fix]) -> ApplySummary:
125
+ """Write every fix's file in place (atomic); return an :class:`ApplySummary`."""
126
+ summary = ApplySummary()
127
+ for fix in fixes:
128
+ path = Path(fix.file)
129
+ try:
130
+ text = path.read_text(encoding="utf-8")
131
+ except OSError:
132
+ summary.skipped.append(f"unreadable file: {fix.file}")
133
+ continue
134
+ new_text, applied, skipped = _apply_edits(text, fix.edits)
135
+ summary.skipped.extend(skipped)
136
+ if not applied:
137
+ continue
138
+ _atomic_write(path, new_text)
139
+ summary.files_changed += 1
140
+ summary.edits_applied += len(applied)
141
+ return summary
142
+
143
+
144
+ def render_diff(fixes: list[Fix]) -> str:
145
+ """Unified diff of every fix's planned edits — writes nothing."""
146
+ parts: list[str] = []
147
+ for fix in fixes:
148
+ path = Path(fix.file)
149
+ try:
150
+ text = path.read_text(encoding="utf-8")
151
+ except OSError:
152
+ continue
153
+ new_text, _applied, _skipped = _apply_edits(text, fix.edits)
154
+ diff = difflib.unified_diff(
155
+ text.splitlines(keepends=True),
156
+ new_text.splitlines(keepends=True),
157
+ fromfile=path.name,
158
+ tofile=path.name,
159
+ )
160
+ parts.append("".join(diff))
161
+ return "".join(parts)
162
+
163
+
164
+ def has_pending(fixes: list[Fix]) -> bool:
165
+ """True if any planned fix carries at least one edit."""
166
+ return any(fix.edits for fix in fixes)
mcpxray/ir.py ADDED
@@ -0,0 +1,186 @@
1
+ """Intermediate representation for an MCP server under analysis.
2
+
3
+ Every extractor emits a :class:`McpServer`; every rule consumes one. The IR is
4
+ extractor-agnostic — it models the union of what static source parsing and a
5
+ ``tools/list`` manifest can tell us, with provenance (``location``) so findings
6
+ point back to real source lines.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ # Public, stable surface for this module. Names listed here are the plugin API
15
+ # (see CONTRIBUTING.md → "Plugin API stability"); everything else — including
16
+ # ``_SEVERITY_RANK`` and ``RISK_WEIGHT`` — is internal and may change at any
17
+ # release. ``from mcpxray.ir import *`` yields exactly this list.
18
+ __all__ = [
19
+ # severities
20
+ "SEVERITY_ERROR",
21
+ "SEVERITY_WARNING",
22
+ "SEVERITY_INFO",
23
+ # risk tiers
24
+ "RISK_CRITICAL",
25
+ "RISK_HIGH",
26
+ "RISK_MEDIUM",
27
+ "RISK_LOW",
28
+ # scoring
29
+ "ERROR_SCORE_CAP",
30
+ # how the IR was obtained
31
+ "SOURCE_STATIC",
32
+ "SOURCE_MANIFEST",
33
+ "SOURCE_RUNTIME",
34
+ # helpers
35
+ "severity_rank",
36
+ # dataclasses
37
+ "Diagnostic",
38
+ "Tool",
39
+ "Resource",
40
+ "Prompt",
41
+ "ServerMeta",
42
+ "McpServer",
43
+ ]
44
+
45
+ # --- severities --------------------------------------------------------------
46
+ SEVERITY_ERROR = "error"
47
+ SEVERITY_WARNING = "warning"
48
+ SEVERITY_INFO = "info"
49
+ _SEVERITY_RANK = {SEVERITY_ERROR: 3, SEVERITY_WARNING: 2, SEVERITY_INFO: 1}
50
+
51
+ # --- risk tiers (drive score weighting, mirroring OpenSSF Scorecard) ---------
52
+ # Higher weight = a failing check drags the score down harder.
53
+ RISK_CRITICAL = "critical"
54
+ RISK_HIGH = "high"
55
+ RISK_MEDIUM = "medium"
56
+ RISK_LOW = "low"
57
+ RISK_WEIGHT = {
58
+ RISK_CRITICAL: 10,
59
+ RISK_HIGH: 5,
60
+ RISK_MEDIUM: 2,
61
+ RISK_LOW: 1,
62
+ }
63
+
64
+ # A score ceiling applied whenever any ERROR-severity finding exists, so a single
65
+ # critical bug can't be diluted to a green score by clean tooling elsewhere.
66
+ ERROR_SCORE_CAP = 60
67
+
68
+ SOURCE_STATIC = "static"
69
+ SOURCE_MANIFEST = "manifest"
70
+ SOURCE_RUNTIME = "runtime" # tools captured by spawning the server (tools/list)
71
+
72
+
73
+ def severity_rank(sev: str) -> int:
74
+ """Higher = more severe. Unknown severities rank below INFO."""
75
+ return _SEVERITY_RANK.get(sev, 0)
76
+
77
+
78
+ @dataclass
79
+ class TextEdit:
80
+ """A single literal substring replacement (auto-fix machinery).
81
+
82
+ ``old`` must occur exactly once in the target file or the edit is skipped —
83
+ it is never applied ambiguously. Experimental: not part of the stable plugin
84
+ API (``__all__``) until a second rule emits fixes.
85
+ """
86
+
87
+ old: str
88
+ new: str
89
+
90
+
91
+ @dataclass
92
+ class Fix:
93
+ """A proposed auto-fix: apply ``edits`` to ``file`` (non-overlapping).
94
+
95
+ Experimental plugin surface (pre-stable); see CONTRIBUTING.md → "Plugin API
96
+ stability". A rule attaches one to :attr:`Diagnostic.fix`.
97
+ """
98
+
99
+ description: str
100
+ file: str
101
+ edits: list[TextEdit] = field(default_factory=list)
102
+
103
+
104
+ @dataclass
105
+ class Diagnostic:
106
+ """A single lint finding. Stable ``rule_id``; ``line``/``col`` are 1-indexed."""
107
+
108
+ rule_id: str
109
+ severity: str
110
+ message: str
111
+ tool: str | None = None # name of the offending tool, if any
112
+ file: str | None = None
113
+ line: int | None = None
114
+ col: int | None = None
115
+ fix: Fix | None = None # proposed auto-fix, if the rule can produce one
116
+
117
+
118
+ @dataclass
119
+ class Tool:
120
+ """An MCP tool: name + LLM-visible description + JSON Schema for its inputs."""
121
+
122
+ name: str
123
+ description: str | None = None
124
+ input_schema: dict[str, Any] = field(default_factory=dict)
125
+ source_path: str | None = None # file the tool was declared in
126
+ line: int | None = None # 1-indexed line of the declaration
127
+ runtime_only: bool = False # True when learned from a manifest, not source
128
+ # Destructured handler parameter names, for MCP105 (schema/impl drift).
129
+ # ``None`` = undeterminable (bare ``args`` identifier, Python, or a manifest
130
+ # tool with no source handler) → the rule can't compare and skips.
131
+ # ``[]`` = the handler explicitly takes no params (``()``).
132
+ # ``[...]``= the names a destructured ``{a, b}`` handler actually reads.
133
+ handler_params: list[str] | None = None
134
+
135
+
136
+ @dataclass
137
+ class Resource:
138
+ """An MCP resource (``resources/list``). Analyzed in v0.2."""
139
+
140
+ uri: str
141
+ name: str | None = None
142
+ description: str | None = None
143
+ mime_type: str | None = None
144
+
145
+
146
+ @dataclass
147
+ class Prompt:
148
+ """An MCP prompt template (``prompts/list``). Analyzed in v0.2."""
149
+
150
+ name: str
151
+ description: str | None = None
152
+
153
+
154
+ @dataclass
155
+ class ServerMeta:
156
+ """Identity metadata for the server under analysis. All optional."""
157
+
158
+ name: str | None = None
159
+ version: str | None = None
160
+ language: str | None = None # "python" | "typescript" | None
161
+ path: str | None = None # root path analyzed
162
+ repo: str | None = None # optional repo URL
163
+
164
+
165
+ @dataclass
166
+ class McpServer:
167
+ """The universal IR for an MCP server. Extractors emit it; rules read it."""
168
+
169
+ meta: ServerMeta
170
+ tools: list[Tool] = field(default_factory=list)
171
+ resources: list[Resource] = field(default_factory=list)
172
+ prompts: list[Prompt] = field(default_factory=list)
173
+ dependencies: dict[str, str] = field(default_factory=dict) # name -> spec
174
+ sources: dict[str, str] = field(default_factory=dict) # source_path -> text
175
+ lockfiles: list[str] = field(default_factory=list) # lockfile basenames found
176
+ diagnostics: list[Diagnostic] = field(default_factory=list)
177
+ source_mode: str = SOURCE_STATIC # how the IR was obtained
178
+ dep_file: str | None = None # pyproject.toml/package.json that supplied deps (fix provenance)
179
+
180
+ @property
181
+ def errors(self) -> list[Diagnostic]:
182
+ return [d for d in self.diagnostics if d.severity == SEVERITY_ERROR]
183
+
184
+ @property
185
+ def has_errors(self) -> bool:
186
+ return any(d.severity == SEVERITY_ERROR for d in self.diagnostics)
@@ -0,0 +1,47 @@
1
+ """Report formatters: plain, json, github, sarif.
2
+
3
+ Every formatter shares the signature ``render(diags, doc, score_result) -> str``
4
+ so the CLI can dispatch by name. ``doc`` and ``score_result`` are optional;
5
+ formatters include the score line only when a :class:`~mcpxray.score.ScoreResult`
6
+ is supplied.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from mcpxray.report import card as card_fmt
14
+ from mcpxray.report import github as github_fmt
15
+ from mcpxray.report import json as json_fmt
16
+ from mcpxray.report import plain, sarif
17
+
18
+ if TYPE_CHECKING:
19
+ from mcpxray.ir import Diagnostic, McpServer
20
+ from mcpxray.score import ScoreResult
21
+
22
+ _FORMATTERS = {
23
+ "plain": plain.render,
24
+ "json": json_fmt.render,
25
+ "github": github_fmt.render,
26
+ "sarif": sarif.render,
27
+ "card": card_fmt.render,
28
+ }
29
+
30
+ SUPPORTED_FORMATS = tuple(_FORMATTERS)
31
+
32
+
33
+ def render(
34
+ diags: list[Diagnostic],
35
+ fmt: str,
36
+ *,
37
+ doc: McpServer | None = None,
38
+ score_result: ScoreResult | None = None,
39
+ ) -> str:
40
+ """Render diagnostics in the requested format."""
41
+ try:
42
+ formatter = _FORMATTERS[fmt.lower()]
43
+ except KeyError as e:
44
+ raise ValueError(
45
+ f"unknown format {fmt!r}; choose from {', '.join(SUPPORTED_FORMATS)}"
46
+ ) from e
47
+ return formatter(diags, doc, score_result)
mcpxray/report/card.py ADDED
@@ -0,0 +1,137 @@
1
+ """Consumer-facing verdict card — a traffic-light summary, not a raw score.
2
+
3
+ ``render`` shares the standard formatter signature so ``mcpxray scan -f card``
4
+ works for free; ``render_verdict`` is the richer entry point the ``check``
5
+ command uses (it already holds a :class:`~mcpxray.verdict.Verdict` and wants
6
+ ``--details`` control).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from typing import TYPE_CHECKING
13
+
14
+ from mcpxray.ir import SOURCE_RUNTIME
15
+ from mcpxray.report import plain as plain_fmt
16
+ from mcpxray.verdict import TIER_CAUTION, TIER_DANGER, TIER_OK, TIER_UNKNOWN, Verdict, verdict
17
+
18
+ if TYPE_CHECKING:
19
+ from mcpxray.ir import Diagnostic, McpServer
20
+ from mcpxray.score import ScoreResult
21
+
22
+ # (emoji, ascii tag) per tier. The tag embeds the label so the ASCII fallback
23
+ # reads "[DANGER]" while the emoji form renders "🔴 DANGER".
24
+ _GLYPHS = {
25
+ TIER_OK: ("🟢", "[OK]"),
26
+ TIER_CAUTION: ("🟡", "[CAUTION]"),
27
+ TIER_DANGER: ("🔴", "[DANGER]"),
28
+ TIER_UNKNOWN: ("⚪", "[?]"),
29
+ }
30
+
31
+
32
+ def _supports_unicode() -> bool:
33
+ """True when stdout can render emoji/bullets. Monkeypatchable in tests."""
34
+ enc = (sys.stdout.encoding or "").lower().replace("-", "").replace("_", "")
35
+ return "utf8" in enc
36
+
37
+
38
+ def _header(v: Verdict) -> str:
39
+ emoji, tag = _GLYPHS[v.tier]
40
+ return f"{emoji} {v.tier.upper()}" if _supports_unicode() else tag
41
+
42
+
43
+ def _bullet() -> str:
44
+ return "•" if _supports_unicode() else "-"
45
+
46
+
47
+ def _times() -> str:
48
+ return "×" if _supports_unicode() else "x"
49
+
50
+
51
+ def _dash() -> str:
52
+ return "—" if _supports_unicode() else "-"
53
+
54
+
55
+ def _tool_detail(doc: McpServer | None) -> str:
56
+ """The '(Python, 3 tools)' qualifier for the checked line."""
57
+ if doc is None:
58
+ return ""
59
+ n = len(doc.tools)
60
+ word = "tool" if n == 1 else "tools"
61
+ lang = doc.meta.language if doc.meta else None
62
+ if not lang and getattr(doc, "source_mode", None) == SOURCE_RUNTIME:
63
+ lang = "runtime" # tools captured live — not a parsed source language
64
+ if lang:
65
+ return f" ({lang}, {n} {word})"
66
+ if n:
67
+ return f" ({n} {word})"
68
+ return ""
69
+
70
+
71
+ def _checked_line(v: Verdict, doc: McpServer | None, sr: ScoreResult | None) -> str:
72
+ name = (doc.meta.name if doc is not None and doc.meta else None) or "server"
73
+ if v.tier == TIER_UNKNOWN:
74
+ return f'mcpxray checked "{name}" {_dash()} no MCP tool definitions found.'
75
+ score_part = f" {_dash()} score {sr.score}/100 ({sr.grade})" if sr is not None else ""
76
+ return f'mcpxray checked "{name}"{_tool_detail(doc)}{score_part}'
77
+
78
+
79
+ def render_verdict(
80
+ v: Verdict,
81
+ *,
82
+ doc: McpServer | None = None,
83
+ score_result: ScoreResult | None = None,
84
+ details: bool = False,
85
+ ) -> str:
86
+ """Render a :class:`Verdict` as the human-facing card text."""
87
+ lines: list[str] = [
88
+ f"{_header(v)} {_dash()} {v.headline}",
89
+ "",
90
+ _checked_line(v, doc, score_result),
91
+ "",
92
+ ]
93
+
94
+ if v.tier == TIER_UNKNOWN:
95
+ lines += [
96
+ "mcpxray couldn't find tool definitions statically (unsupported",
97
+ "language, or tools built dynamically at runtime).",
98
+ "",
99
+ "To check it anyway:",
100
+ " 1. Capture its tools/list response to a JSON file, then run:",
101
+ " mcpxray check --manifest tools-list.json",
102
+ " 2. Or spawn it and let mcpxray capture tools/list live:",
103
+ " mcpxray check --runtime --command '<launch cmd>'",
104
+ ]
105
+ elif v.reasons:
106
+ lines.append("Why:")
107
+ width = max(len(r.phrase) for r in v.reasons)
108
+ for r in v.reasons:
109
+ lines.append(f" {_bullet()} {r.phrase.ljust(width)} {r.rule_id} {_times()}{r.count}")
110
+ else:
111
+ lines.append("No issues found.")
112
+
113
+ lines += ["", f"Recommendation: {v.recommendation}"]
114
+
115
+ if v.tier != TIER_OK and v.reasons and not details:
116
+ lines += ["", "Pass --details for the full finding list."]
117
+
118
+ if details and doc is not None and doc.diagnostics:
119
+ lines += [
120
+ "",
121
+ f"--- full findings ({len(doc.diagnostics)}) ---",
122
+ plain_fmt.render(doc.diagnostics, doc=doc, score_result=score_result),
123
+ ]
124
+
125
+ return "\n".join(lines).rstrip() + "\n"
126
+
127
+
128
+ def render(
129
+ diags: list[Diagnostic],
130
+ doc: McpServer | None = None,
131
+ score_result: ScoreResult | None = None,
132
+ ) -> str:
133
+ """Standard formatter signature — render the card from a server + score."""
134
+ if doc is None:
135
+ return plain_fmt.render(diags, doc=doc, score_result=score_result)
136
+ v = verdict(doc, score_result)
137
+ return render_verdict(v, doc=doc, score_result=score_result, details=False)
@@ -0,0 +1,35 @@
1
+ """GitHub Actions annotations (::error / ::warning / ::notice)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from mcpxray.ir import SEVERITY_ERROR, SEVERITY_INFO, SEVERITY_WARNING
8
+
9
+ if TYPE_CHECKING:
10
+ from mcpxray.ir import Diagnostic, McpServer
11
+ from mcpxray.score import ScoreResult
12
+
13
+ _LEVEL = {
14
+ SEVERITY_ERROR: "error",
15
+ SEVERITY_WARNING: "warning",
16
+ SEVERITY_INFO: "notice",
17
+ }
18
+
19
+
20
+ def render(
21
+ diags: list[Diagnostic],
22
+ doc: McpServer | None = None,
23
+ score_result: ScoreResult | None = None,
24
+ ) -> str:
25
+ lines: list[str] = []
26
+ for d in diags:
27
+ level = _LEVEL.get(d.severity, "warning")
28
+ loc = ""
29
+ if d.file:
30
+ loc = f" file={d.file}"
31
+ if d.line:
32
+ loc += f",line={d.line}"
33
+ message = d.message.replace("\n", " ")
34
+ lines.append(f"::{level}{loc}::{d.rule_id}: {message}")
35
+ return "\n".join(lines)
mcpxray/report/json.py ADDED
@@ -0,0 +1,50 @@
1
+ """JSON report (machine-readable)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from mcpxray.ir import Diagnostic, McpServer
10
+ from mcpxray.score import ScoreResult
11
+
12
+
13
+ def render(
14
+ diags: list[Diagnostic],
15
+ doc: McpServer | None = None,
16
+ score_result: ScoreResult | None = None,
17
+ ) -> str:
18
+ summary: dict[str, object] = {}
19
+ if score_result is not None:
20
+ summary = {
21
+ "score": score_result.score,
22
+ "grade": score_result.grade,
23
+ "errors": score_result.errors,
24
+ "warnings": score_result.warnings,
25
+ "infos": score_result.infos,
26
+ "capped": score_result.capped,
27
+ }
28
+ payload = {
29
+ "summary": summary,
30
+ "findings": [
31
+ {
32
+ "rule_id": d.rule_id,
33
+ "severity": d.severity,
34
+ "message": d.message,
35
+ "tool": d.tool,
36
+ "file": d.file,
37
+ "line": d.line,
38
+ "col": d.col,
39
+ }
40
+ for d in diags
41
+ ],
42
+ }
43
+ if doc is not None:
44
+ payload["server"] = {
45
+ "name": doc.meta.name,
46
+ "language": doc.meta.language,
47
+ "tools": len(doc.tools),
48
+ "source_mode": doc.source_mode,
49
+ }
50
+ return _json.dumps(payload, indent=2)
@@ -0,0 +1,35 @@
1
+ """Human-readable plain-text report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from mcpxray.ir import Diagnostic, McpServer
9
+ from mcpxray.score import ScoreResult
10
+
11
+
12
+ def render(
13
+ diags: list[Diagnostic],
14
+ doc: McpServer | None = None,
15
+ score_result: ScoreResult | None = None,
16
+ ) -> str:
17
+ lines: list[str] = []
18
+ if score_result is not None:
19
+ cap = " [capped by error finding]" if score_result.capped else ""
20
+ lines.append(f"Score: {score_result.score}/100 (grade {score_result.grade}){cap}")
21
+ lines.append(
22
+ f" {score_result.errors} error(s), "
23
+ f"{score_result.warnings} warning(s), {score_result.infos} info"
24
+ )
25
+ if not diags:
26
+ lines.append("No findings.")
27
+ return "\n".join(lines)
28
+ for d in diags:
29
+ loc = d.file or ""
30
+ if d.line:
31
+ loc = f"{loc}:{d.line}" if loc else f"line {d.line}"
32
+ loc = f" {loc}" if loc else ""
33
+ tool = f" [{d.tool}]" if d.tool else ""
34
+ lines.append(f"{d.rule_id:<7} {d.severity:<8}{loc}{tool} {d.message}")
35
+ return "\n".join(lines)