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/__init__.py +18 -0
- mcpxray/__main__.py +6 -0
- mcpxray/badge.py +49 -0
- mcpxray/cli.py +322 -0
- mcpxray/extract/__init__.py +16 -0
- mcpxray/extract/base.py +61 -0
- mcpxray/extract/manifest.py +97 -0
- mcpxray/extract/python_static.py +316 -0
- mcpxray/extract/typescript_static.py +552 -0
- mcpxray/fix.py +166 -0
- mcpxray/ir.py +186 -0
- mcpxray/report/__init__.py +47 -0
- mcpxray/report/card.py +137 -0
- mcpxray/report/github.py +35 -0
- mcpxray/report/json.py +50 -0
- mcpxray/report/plain.py +35 -0
- mcpxray/report/sarif.py +76 -0
- mcpxray/rules/__init__.py +8 -0
- mcpxray/rules/base.py +67 -0
- mcpxray/rules/builtin/__init__.py +5 -0
- mcpxray/rules/builtin/descriptions.py +103 -0
- mcpxray/rules/builtin/schema.py +115 -0
- mcpxray/rules/builtin/source.py +95 -0
- mcpxray/rules/builtin/supply.py +92 -0
- mcpxray/rules/builtin/transport.py +79 -0
- mcpxray/runtime.py +260 -0
- mcpxray/score.py +71 -0
- mcpxray/source.py +253 -0
- mcpxray/verdict.py +139 -0
- mcpxray_cli-1.0.0.dist-info/METADATA +211 -0
- mcpxray_cli-1.0.0.dist-info/RECORD +33 -0
- mcpxray_cli-1.0.0.dist-info/WHEEL +4 -0
- mcpxray_cli-1.0.0.dist-info/entry_points.txt +7 -0
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)
|
mcpxray/report/github.py
ADDED
|
@@ -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)
|
mcpxray/report/plain.py
ADDED
|
@@ -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)
|