mcpprobe-scanner 0.1.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.
- mcpprobe/__init__.py +21 -0
- mcpprobe/action.py +69 -0
- mcpprobe/authz.py +313 -0
- mcpprobe/catalogue.py +125 -0
- mcpprobe/checks/__init__.py +100 -0
- mcpprobe/checks/_util.py +102 -0
- mcpprobe/checks/active_stubs.py +212 -0
- mcpprobe/checks/auth.py +473 -0
- mcpprobe/checks/base.py +228 -0
- mcpprobe/checks/baseline.py +219 -0
- mcpprobe/checks/capability.py +171 -0
- mcpprobe/checks/metadata.py +341 -0
- mcpprobe/checks/poisoning.py +480 -0
- mcpprobe/checks/robustness.py +266 -0
- mcpprobe/checks/transport_conformance.py +429 -0
- mcpprobe/cli.py +295 -0
- mcpprobe/dnsutil.py +67 -0
- mcpprobe/governance.py +74 -0
- mcpprobe/models.py +264 -0
- mcpprobe/policy.py +140 -0
- mcpprobe/py.typed +0 -0
- mcpprobe/redact.py +101 -0
- mcpprobe/report/__init__.py +7 -0
- mcpprobe/report/json_report.py +33 -0
- mcpprobe/report/sarif.py +329 -0
- mcpprobe/report/terminal.py +172 -0
- mcpprobe/scanner.py +211 -0
- mcpprobe/transport/__init__.py +34 -0
- mcpprobe/transport/base.py +156 -0
- mcpprobe/transport/evidence.py +135 -0
- mcpprobe/transport/fingerprint.py +154 -0
- mcpprobe/transport/http.py +366 -0
- mcpprobe/transport/ratelimit.py +47 -0
- mcpprobe/transport/stdio.py +201 -0
- mcpprobe_scanner-0.1.0.dist-info/METADATA +240 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +39 -0
- mcpprobe_scanner-0.1.0.dist-info/WHEEL +4 -0
- mcpprobe_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Terminal summary rendered with rich.
|
|
2
|
+
|
|
3
|
+
Every string that originates from the target (tool names, descriptions,
|
|
4
|
+
evidence bodies, server info) is rendered as plain :class:`rich.text.Text`
|
|
5
|
+
after control characters are made visible, so a hostile server cannot inject
|
|
6
|
+
rich markup or ANSI escape sequences into the operator's terminal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from mcpprobe.checks._util import visible
|
|
17
|
+
from mcpprobe.models import CheckStatus, Finding, ScanReport, Severity, Standards
|
|
18
|
+
|
|
19
|
+
#: Brand severity palette (hex), also used for badges and the website.
|
|
20
|
+
SEVERITY_HEX: dict[Severity, str] = {
|
|
21
|
+
Severity.CRITICAL: "#B3261E",
|
|
22
|
+
Severity.HIGH: "#C2410C",
|
|
23
|
+
Severity.MEDIUM: "#9A6200",
|
|
24
|
+
Severity.LOW: "#4A5560",
|
|
25
|
+
Severity.INFO: "#4A5560",
|
|
26
|
+
}
|
|
27
|
+
PASS_HEX = "#2E7D4F"
|
|
28
|
+
|
|
29
|
+
_ORDER = (Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM, Severity.LOW, Severity.INFO)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def severity_style(sev: Severity, *, badge: bool = False) -> str:
|
|
33
|
+
colour = SEVERITY_HEX[sev]
|
|
34
|
+
if badge:
|
|
35
|
+
return f"bold #FFFFFF on {colour}"
|
|
36
|
+
return f"bold {colour}" if sev in (Severity.CRITICAL, Severity.HIGH) else colour
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def standards_text(std: Standards) -> str:
|
|
40
|
+
"""``MCP07:2025 (beta), ASI03, LLM03:2026, LLM06:2025`` (2026 IDs first, per OWASP guidance)."""
|
|
41
|
+
parts = [f"{m} (beta)" for m in std.mcp] + list(std.asi) + list(std.llm_2026) + list(std.llm_2025)
|
|
42
|
+
return ", ".join(parts) if parts else "spec conformance / hygiene"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _plain(value: object, limit: int | None = None) -> str:
|
|
46
|
+
text = visible(str(value))
|
|
47
|
+
if limit is not None and len(text) > limit:
|
|
48
|
+
text = text[: limit - 1] + "…"
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _finding_block(f: Finding, verbose: bool) -> Group:
|
|
53
|
+
head = Text()
|
|
54
|
+
head.append(f" {f.severity.value.upper():<8} ", style=severity_style(f.severity, badge=True))
|
|
55
|
+
head.append(f" {f.id} ", style="bold")
|
|
56
|
+
head.append(_plain(f.title), style="bold")
|
|
57
|
+
meta = Text(" ")
|
|
58
|
+
meta.append("confidence ", style="dim")
|
|
59
|
+
meta.append(f.confidence.value)
|
|
60
|
+
if f.location:
|
|
61
|
+
meta.append(" where ", style="dim")
|
|
62
|
+
meta.append(_plain(f.location, 120))
|
|
63
|
+
lines: list[Text] = [head, meta]
|
|
64
|
+
std = Text(" standards ", style="dim")
|
|
65
|
+
std.append(standards_text(f.standards))
|
|
66
|
+
lines.append(std)
|
|
67
|
+
if verbose:
|
|
68
|
+
desc = Text(" ")
|
|
69
|
+
desc.append(_plain(f.description, 600))
|
|
70
|
+
lines.append(desc)
|
|
71
|
+
if f.evidence_excerpt:
|
|
72
|
+
ev = Text(" evidence ", style="dim")
|
|
73
|
+
ev.append(_plain(f.evidence_excerpt, 300 if not verbose else 1200), style="dim")
|
|
74
|
+
lines.append(ev)
|
|
75
|
+
fix = Text(" fix ", style=f"bold {PASS_HEX}")
|
|
76
|
+
fix.append(_plain(f.fix, 400 if not verbose else 2000))
|
|
77
|
+
lines.append(fix)
|
|
78
|
+
lines.append(Text(""))
|
|
79
|
+
return Group(*lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render(
|
|
83
|
+
report: ScanReport,
|
|
84
|
+
console: Console | None = None,
|
|
85
|
+
*,
|
|
86
|
+
show_evidence: bool = False,
|
|
87
|
+
verbose: bool = False,
|
|
88
|
+
fail_on: Severity | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
console = console or Console()
|
|
91
|
+
fp = report.fingerprint
|
|
92
|
+
header = Text()
|
|
93
|
+
header.append(f"mcpprobe {report.version} ", style="bold")
|
|
94
|
+
header.append(_plain(report.target, 200))
|
|
95
|
+
sub: list[str] = []
|
|
96
|
+
if fp:
|
|
97
|
+
sub.append(f"transport={fp.transport}")
|
|
98
|
+
sub.append(f"revision={fp.protocol_version or 'unknown'} ({fp.flavour})")
|
|
99
|
+
sub.append("auth=required" if fp.auth_required else "auth=none")
|
|
100
|
+
name = fp.server_info.get("name") if isinstance(fp.server_info, dict) else None
|
|
101
|
+
if name:
|
|
102
|
+
sub.append(_plain(f"server={name} {fp.server_info.get('version', '')}".strip(), 60))
|
|
103
|
+
inv = report.inventory_summary
|
|
104
|
+
if inv:
|
|
105
|
+
sub.append(
|
|
106
|
+
f"tools={inv.get('tools', 0)} prompts={inv.get('prompts', 0)} resources={inv.get('resources', 0)}"
|
|
107
|
+
)
|
|
108
|
+
sub.append(f"mode={report.mode.value}")
|
|
109
|
+
console.print(Panel(header, subtitle=Text(" | ".join(sub)), subtitle_align="left"))
|
|
110
|
+
|
|
111
|
+
counts = report.counts()
|
|
112
|
+
summary = Text("Findings ")
|
|
113
|
+
for sev in _ORDER:
|
|
114
|
+
style = severity_style(sev, badge=True) if counts[sev.value] else "dim"
|
|
115
|
+
summary.append(f" {sev.value} {counts[sev.value]} ", style=style)
|
|
116
|
+
summary.append(" ")
|
|
117
|
+
console.print(summary)
|
|
118
|
+
console.print()
|
|
119
|
+
|
|
120
|
+
if report.findings:
|
|
121
|
+
for f in report.findings:
|
|
122
|
+
console.print(_finding_block(f, verbose))
|
|
123
|
+
else:
|
|
124
|
+
console.print(Text("No findings.", style=f"bold {PASS_HEX}"))
|
|
125
|
+
|
|
126
|
+
cov = Table(title="Check coverage", expand=False, show_header=True, header_style="bold")
|
|
127
|
+
cov.add_column("Check")
|
|
128
|
+
cov.add_column("Mode")
|
|
129
|
+
cov.add_column("Status")
|
|
130
|
+
cov.add_column("Findings", justify="right")
|
|
131
|
+
cov.add_column("Reason")
|
|
132
|
+
for c in report.checks:
|
|
133
|
+
if c.status == CheckStatus.RAN:
|
|
134
|
+
status = Text("pass" if not c.findings else "ran", style=PASS_HEX if not c.findings else "")
|
|
135
|
+
if c.findings:
|
|
136
|
+
worst = max((x.severity for x in c.findings), key=lambda s: s.rank)
|
|
137
|
+
status = Text(worst.value, style=severity_style(worst))
|
|
138
|
+
else:
|
|
139
|
+
status = Text(
|
|
140
|
+
c.status.value,
|
|
141
|
+
style={
|
|
142
|
+
CheckStatus.SKIPPED: "dim",
|
|
143
|
+
CheckStatus.NOT_IMPLEMENTED: "dim italic",
|
|
144
|
+
CheckStatus.ERROR: f"bold {SEVERITY_HEX[Severity.CRITICAL]}",
|
|
145
|
+
}.get(c.status, ""),
|
|
146
|
+
)
|
|
147
|
+
cov.add_row(c.check_id, c.mode.value, status, str(len(c.findings)), Text(_plain(c.reason or "", 70)))
|
|
148
|
+
console.print(cov)
|
|
149
|
+
|
|
150
|
+
if report.attestations:
|
|
151
|
+
gov = Text("Attestation only (not assessable by scan): ", style="bold")
|
|
152
|
+
gov.append(", ".join(f"{a.id} {a.title}" for a in report.attestations), style="dim")
|
|
153
|
+
console.print(gov)
|
|
154
|
+
|
|
155
|
+
if report.notes and verbose:
|
|
156
|
+
console.print(Text("Notes", style="bold"))
|
|
157
|
+
for note in report.notes:
|
|
158
|
+
console.print(Text(f" - {_plain(note, 300)}"))
|
|
159
|
+
if show_evidence:
|
|
160
|
+
console.print(
|
|
161
|
+
Text(f"{report.evidence_count} request/response pairs recorded as evidence.", style="dim")
|
|
162
|
+
)
|
|
163
|
+
if fail_on is not None:
|
|
164
|
+
top = report.max_severity()
|
|
165
|
+
if top is not None and top >= fail_on:
|
|
166
|
+
console.print(
|
|
167
|
+
Text(f"Result: FAIL (findings at or above {fail_on.value})", style=severity_style(top))
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
console.print(
|
|
171
|
+
Text(f"Result: PASS (nothing at or above {fail_on.value})", style=f"bold {PASS_HEX}")
|
|
172
|
+
)
|
mcpprobe/scanner.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Scan orchestrator: transport -> opt-out -> fingerprint -> inventory -> checks -> report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcpprobe import USER_AGENT, __version__
|
|
11
|
+
from mcpprobe.authz import AuthorisationError, AuthorisationRecord, require_active_authorisation
|
|
12
|
+
from mcpprobe.checks import Check, ScanContext, ScanOptions, select_checks
|
|
13
|
+
from mcpprobe.dnsutil import TxtLookup
|
|
14
|
+
from mcpprobe.governance import governance_items
|
|
15
|
+
from mcpprobe.models import (
|
|
16
|
+
CheckMode,
|
|
17
|
+
CheckResult,
|
|
18
|
+
CheckStatus,
|
|
19
|
+
Fingerprint,
|
|
20
|
+
Inventory,
|
|
21
|
+
ScanReport,
|
|
22
|
+
utcnow,
|
|
23
|
+
)
|
|
24
|
+
from mcpprobe.policy import DEFAULT_RATE, check_opt_out, clamp_rate
|
|
25
|
+
from mcpprobe.transport import (
|
|
26
|
+
EvidenceLog,
|
|
27
|
+
HttpTransport,
|
|
28
|
+
RateLimiter,
|
|
29
|
+
StdioTransport,
|
|
30
|
+
Transport,
|
|
31
|
+
TransportError,
|
|
32
|
+
ensure_url,
|
|
33
|
+
fingerprint,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MAX_LIST_PAGES = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class ScanConfig:
|
|
41
|
+
"""Everything needed to run one scan."""
|
|
42
|
+
|
|
43
|
+
target: str
|
|
44
|
+
stdio: bool = False
|
|
45
|
+
stdio_env: Mapping[str, str] = field(default_factory=dict)
|
|
46
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
47
|
+
timeout: float = 15.0
|
|
48
|
+
rate: float = DEFAULT_RATE
|
|
49
|
+
options: ScanOptions = field(default_factory=ScanOptions)
|
|
50
|
+
only: Iterable[str] | None = None
|
|
51
|
+
skip: Iterable[str] | None = None
|
|
52
|
+
authorisation: AuthorisationRecord | None = None
|
|
53
|
+
user_agent: str = USER_AGENT
|
|
54
|
+
verify_tls: bool | str = True
|
|
55
|
+
#: DNS TXT resolver for the opt-out check (injected in tests).
|
|
56
|
+
txt_lookup: TxtLookup | None = None
|
|
57
|
+
#: Test-suite escape hatch for the 10 req/s ceiling. Not exposed on the CLI.
|
|
58
|
+
_unsafe_allow_any_rate: bool = False
|
|
59
|
+
|
|
60
|
+
def effective_rate(self) -> float:
|
|
61
|
+
if self._unsafe_allow_any_rate:
|
|
62
|
+
return self.rate
|
|
63
|
+
return clamp_rate(self.rate)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_transport(config: ScanConfig, evidence: EvidenceLog, limiter: RateLimiter) -> Transport:
|
|
67
|
+
if config.stdio:
|
|
68
|
+
return StdioTransport(
|
|
69
|
+
config.target,
|
|
70
|
+
env=config.stdio_env,
|
|
71
|
+
timeout=config.timeout,
|
|
72
|
+
evidence=evidence,
|
|
73
|
+
limiter=limiter,
|
|
74
|
+
)
|
|
75
|
+
return HttpTransport(
|
|
76
|
+
ensure_url(config.target),
|
|
77
|
+
extra_headers=config.headers,
|
|
78
|
+
timeout=config.timeout,
|
|
79
|
+
evidence=evidence,
|
|
80
|
+
limiter=limiter,
|
|
81
|
+
verify=config.verify_tls,
|
|
82
|
+
user_agent=config.user_agent,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def _list_all(
|
|
87
|
+
transport: Transport, method: str, key: str
|
|
88
|
+
) -> tuple[list[dict[str, Any]], str | None, Any]:
|
|
89
|
+
"""Paginate a ``*/list`` method; returns (items, error, first evidence)."""
|
|
90
|
+
items: list[dict[str, Any]] = []
|
|
91
|
+
cursor: str | None = None
|
|
92
|
+
first_evidence = None
|
|
93
|
+
for _ in range(MAX_LIST_PAGES):
|
|
94
|
+
params: dict[str, Any] = {"cursor": cursor} if cursor else {}
|
|
95
|
+
resp = await transport.rpc(method, params, note=f"inventory {method}")
|
|
96
|
+
if first_evidence is None:
|
|
97
|
+
first_evidence = resp.evidence
|
|
98
|
+
if not resp.ok:
|
|
99
|
+
err = resp.rpc_error
|
|
100
|
+
text = err.get("message") if err else (resp.error_text or f"HTTP {resp.status}")
|
|
101
|
+
return items, str(text), first_evidence
|
|
102
|
+
result = resp.result or {}
|
|
103
|
+
page = result.get(key)
|
|
104
|
+
if isinstance(page, list):
|
|
105
|
+
items.extend(x for x in page if isinstance(x, dict))
|
|
106
|
+
cursor = result.get("nextCursor")
|
|
107
|
+
if not cursor:
|
|
108
|
+
break
|
|
109
|
+
return items, None, first_evidence
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def collect_inventory(transport: Transport, fp: Fingerprint) -> Inventory:
|
|
113
|
+
inv = Inventory()
|
|
114
|
+
if fp.auth_required or fp.flavour in {"unknown", "legacy-sse"}:
|
|
115
|
+
inv.tools_error = "not listed: " + ("authentication required" if fp.auth_required else fp.flavour)
|
|
116
|
+
return inv
|
|
117
|
+
inv.tools, inv.tools_error, inv.tools_evidence = await _list_all(transport, "tools/list", "tools")
|
|
118
|
+
caps = fp.capabilities if isinstance(fp.capabilities, dict) else {}
|
|
119
|
+
# Only ask for prompts/resources when advertised (or capabilities unknown) to avoid noise.
|
|
120
|
+
if not caps or "prompts" in caps:
|
|
121
|
+
inv.prompts, inv.prompts_error, inv.prompts_evidence = await _list_all(
|
|
122
|
+
transport, "prompts/list", "prompts"
|
|
123
|
+
)
|
|
124
|
+
if not caps or "resources" in caps:
|
|
125
|
+
inv.resources, inv.resources_error, inv.resources_evidence = await _list_all(
|
|
126
|
+
transport, "resources/list", "resources"
|
|
127
|
+
)
|
|
128
|
+
return inv
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def run_check(check: Check, ctx: ScanContext) -> CheckResult:
|
|
132
|
+
"""Run one check under the gating rules and wrap the outcome."""
|
|
133
|
+
result = CheckResult(check_id=check.id, title=check.title, mode=check.mode, status=CheckStatus.RAN)
|
|
134
|
+
if not check.applies(ctx):
|
|
135
|
+
result.status = CheckStatus.SKIPPED
|
|
136
|
+
result.reason = f"not applicable to {ctx.transport.name} transport"
|
|
137
|
+
return result
|
|
138
|
+
if check.mode == CheckMode.ACTIVE:
|
|
139
|
+
try:
|
|
140
|
+
require_active_authorisation(ctx.authorisation)
|
|
141
|
+
except AuthorisationError:
|
|
142
|
+
result.status = CheckStatus.SKIPPED
|
|
143
|
+
result.reason = "requires --active and verified ownership"
|
|
144
|
+
return result
|
|
145
|
+
started = time.monotonic()
|
|
146
|
+
try:
|
|
147
|
+
result.findings = await check.run(ctx)
|
|
148
|
+
except NotImplementedError as exc:
|
|
149
|
+
result.status = CheckStatus.NOT_IMPLEMENTED
|
|
150
|
+
result.reason = str(exc)
|
|
151
|
+
except PermissionError as exc:
|
|
152
|
+
result.status = CheckStatus.SKIPPED
|
|
153
|
+
result.reason = str(exc)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
result.status = CheckStatus.ERROR
|
|
156
|
+
result.reason = f"{type(exc).__name__}: {exc}"
|
|
157
|
+
result.duration_ms = (time.monotonic() - started) * 1000
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def run_scan(config: ScanConfig) -> tuple[ScanReport, EvidenceLog]:
|
|
162
|
+
"""Execute a full scan. Raises TransportError/OptOutError for fatal conditions."""
|
|
163
|
+
evidence = EvidenceLog()
|
|
164
|
+
try:
|
|
165
|
+
limiter = RateLimiter(rate=config.effective_rate())
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
raise TransportError(str(exc)) from exc
|
|
168
|
+
started = utcnow()
|
|
169
|
+
transport = build_transport(config, evidence, limiter)
|
|
170
|
+
options = config.options
|
|
171
|
+
if options.txt_lookup is None:
|
|
172
|
+
options.txt_lookup = config.txt_lookup
|
|
173
|
+
if config.stdio:
|
|
174
|
+
options.stdio_command = list(transport.argv) # type: ignore[attr-defined]
|
|
175
|
+
options.stdio_env = dict(config.stdio_env)
|
|
176
|
+
report = ScanReport(
|
|
177
|
+
version=__version__,
|
|
178
|
+
target=config.target,
|
|
179
|
+
mode=options.mode,
|
|
180
|
+
started_at=started,
|
|
181
|
+
attestations=governance_items(),
|
|
182
|
+
)
|
|
183
|
+
async with transport:
|
|
184
|
+
if isinstance(transport, HttpTransport):
|
|
185
|
+
# Must be the very first thing that happens: DNS opt-out, then the
|
|
186
|
+
# well-known document, before any MCP request.
|
|
187
|
+
report.notes.extend(await check_opt_out(transport, transport.url, config.txt_lookup))
|
|
188
|
+
fp = await fingerprint(transport)
|
|
189
|
+
report.fingerprint = fp
|
|
190
|
+
if fp.flavour == "unknown" and not fp.auth_required:
|
|
191
|
+
raise TransportError(fp.error or "target did not answer any MCP protocol revision")
|
|
192
|
+
inventory = await collect_inventory(transport, fp)
|
|
193
|
+
report.inventory_summary = inventory.summary()
|
|
194
|
+
ctx = ScanContext(
|
|
195
|
+
target=transport.url if isinstance(transport, HttpTransport) else config.target,
|
|
196
|
+
transport=transport,
|
|
197
|
+
fingerprint=fp,
|
|
198
|
+
inventory=inventory,
|
|
199
|
+
evidence=evidence,
|
|
200
|
+
options=options,
|
|
201
|
+
authorisation=config.authorisation,
|
|
202
|
+
)
|
|
203
|
+
for check in select_checks(config.only, config.skip):
|
|
204
|
+
result = await run_check(check, ctx)
|
|
205
|
+
report.checks.append(result)
|
|
206
|
+
report.findings.extend(result.findings)
|
|
207
|
+
report.notes.extend(ctx.notes)
|
|
208
|
+
report.finished_at = utcnow()
|
|
209
|
+
report.evidence_count = len(evidence)
|
|
210
|
+
report.findings.sort(key=lambda f: (-f.severity.rank, f.id, f.location or ""))
|
|
211
|
+
return report, evidence
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""MCP client transports used by the scanner."""
|
|
2
|
+
|
|
3
|
+
from mcpprobe.transport.base import (
|
|
4
|
+
HANDSHAKE_VERSIONS,
|
|
5
|
+
PROTOCOL_2025_06_18,
|
|
6
|
+
PROTOCOL_2025_11_25,
|
|
7
|
+
PROTOCOL_2026_07_28,
|
|
8
|
+
VERSION_PREFERENCE,
|
|
9
|
+
RpcResponse,
|
|
10
|
+
Transport,
|
|
11
|
+
TransportError,
|
|
12
|
+
)
|
|
13
|
+
from mcpprobe.transport.evidence import EvidenceLog
|
|
14
|
+
from mcpprobe.transport.fingerprint import fingerprint
|
|
15
|
+
from mcpprobe.transport.http import HttpTransport, ensure_url
|
|
16
|
+
from mcpprobe.transport.ratelimit import RateLimiter
|
|
17
|
+
from mcpprobe.transport.stdio import StdioTransport
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"HANDSHAKE_VERSIONS",
|
|
21
|
+
"PROTOCOL_2025_06_18",
|
|
22
|
+
"PROTOCOL_2025_11_25",
|
|
23
|
+
"PROTOCOL_2026_07_28",
|
|
24
|
+
"VERSION_PREFERENCE",
|
|
25
|
+
"EvidenceLog",
|
|
26
|
+
"HttpTransport",
|
|
27
|
+
"RateLimiter",
|
|
28
|
+
"RpcResponse",
|
|
29
|
+
"StdioTransport",
|
|
30
|
+
"Transport",
|
|
31
|
+
"TransportError",
|
|
32
|
+
"ensure_url",
|
|
33
|
+
"fingerprint",
|
|
34
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Shared transport abstractions.
|
|
2
|
+
|
|
3
|
+
The scanner deliberately does **not** use an official MCP SDK client: checks
|
|
4
|
+
need to send malformed and non-conformant requests (bad ``Origin``, bogus
|
|
5
|
+
protocol version header, forged session IDs, wrong-audience tokens) that a
|
|
6
|
+
conformant client refuses to send. The transports here are thin and permissive.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import abc
|
|
12
|
+
import itertools
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mcpprobe.models import EvidenceRecord
|
|
18
|
+
from mcpprobe.transport.evidence import EvidenceLog
|
|
19
|
+
from mcpprobe.transport.ratelimit import RateLimiter
|
|
20
|
+
|
|
21
|
+
#: Current (stateless) revision. ``server/discover`` replaces ``initialize``.
|
|
22
|
+
PROTOCOL_2026_07_28 = "2026-07-28"
|
|
23
|
+
#: Handshake-based revisions still widely deployed.
|
|
24
|
+
PROTOCOL_2025_11_25 = "2025-11-25"
|
|
25
|
+
PROTOCOL_2025_06_18 = "2025-06-18"
|
|
26
|
+
|
|
27
|
+
#: Order in which versions are tried during fingerprinting.
|
|
28
|
+
VERSION_PREFERENCE: tuple[str, ...] = (
|
|
29
|
+
PROTOCOL_2026_07_28,
|
|
30
|
+
PROTOCOL_2025_11_25,
|
|
31
|
+
PROTOCOL_2025_06_18,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: Versions that use the ``initialize`` handshake rather than ``server/discover``.
|
|
35
|
+
HANDSHAKE_VERSIONS: frozenset[str] = frozenset({PROTOCOL_2025_11_25, PROTOCOL_2025_06_18})
|
|
36
|
+
|
|
37
|
+
#: ``_meta`` key carrying the protocol version in 2026-07-28 requests.
|
|
38
|
+
META_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
|
|
39
|
+
|
|
40
|
+
CLIENT_INFO: dict[str, str] = {"name": "mcpprobe", "version": "0.1.0"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TransportError(Exception):
|
|
44
|
+
"""Raised when the target cannot be reached or spoken to at all."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class RpcResponse:
|
|
49
|
+
"""Result of one JSON-RPC exchange, successful or not."""
|
|
50
|
+
|
|
51
|
+
method: str
|
|
52
|
+
status: int | None
|
|
53
|
+
headers: dict[str, str]
|
|
54
|
+
body: bytes
|
|
55
|
+
message: dict[str, Any] | None = None
|
|
56
|
+
evidence: EvidenceRecord | None = None
|
|
57
|
+
elapsed_ms: float = 0.0
|
|
58
|
+
error_text: str | None = None
|
|
59
|
+
#: All JSON-RPC messages seen in an SSE stream (the matching one is ``message``).
|
|
60
|
+
stream_messages: list[dict[str, Any]] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def result(self) -> dict[str, Any] | None:
|
|
64
|
+
value = self.message.get("result") if self.message else None
|
|
65
|
+
return value if isinstance(value, dict) else None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def rpc_error(self) -> dict[str, Any] | None:
|
|
69
|
+
value = self.message.get("error") if self.message else None
|
|
70
|
+
return value if isinstance(value, dict) else None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def ok(self) -> bool:
|
|
74
|
+
"""True when a JSON-RPC ``result`` came back (HTTP 2xx or stdio)."""
|
|
75
|
+
return self.result is not None and (self.status is None or 200 <= self.status < 300)
|
|
76
|
+
|
|
77
|
+
def header(self, name: str) -> str | None:
|
|
78
|
+
return self.headers.get(name.lower())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_jsonrpc_error_body(body: bytes) -> bool:
|
|
82
|
+
"""Whether a body parses as a JSON-RPC error object (used by conformance checks)."""
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(body.decode("utf-8", errors="replace"))
|
|
85
|
+
except (ValueError, UnicodeDecodeError):
|
|
86
|
+
return False
|
|
87
|
+
return isinstance(data, dict) and isinstance(data.get("error"), dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Transport(abc.ABC):
|
|
91
|
+
"""Common interface over streamable HTTP and stdio."""
|
|
92
|
+
|
|
93
|
+
name: str = "abstract"
|
|
94
|
+
|
|
95
|
+
def __init__(self, evidence: EvidenceLog | None = None, limiter: RateLimiter | None = None) -> None:
|
|
96
|
+
self.evidence = evidence if evidence is not None else EvidenceLog()
|
|
97
|
+
self.limiter = limiter if limiter is not None else RateLimiter()
|
|
98
|
+
self._ids = itertools.count(1)
|
|
99
|
+
#: Negotiated protocol version; set by the fingerprinter.
|
|
100
|
+
self.protocol_version: str | None = None
|
|
101
|
+
|
|
102
|
+
def next_id(self) -> int:
|
|
103
|
+
return next(self._ids)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def stateless(self) -> bool:
|
|
107
|
+
"""True when speaking the 2026-07-28 stateless revision."""
|
|
108
|
+
return self.protocol_version == PROTOCOL_2026_07_28
|
|
109
|
+
|
|
110
|
+
def build_message(
|
|
111
|
+
self,
|
|
112
|
+
method: str,
|
|
113
|
+
params: dict[str, Any] | None = None,
|
|
114
|
+
*,
|
|
115
|
+
protocol_version: str | None = None,
|
|
116
|
+
msg_id: int | None = None,
|
|
117
|
+
notification: bool = False,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Build a JSON-RPC message, adding the 2026-07-28 ``_meta`` version when needed."""
|
|
120
|
+
version = protocol_version or self.protocol_version
|
|
121
|
+
body: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
122
|
+
if not notification:
|
|
123
|
+
body["id"] = msg_id if msg_id is not None else self.next_id()
|
|
124
|
+
p: dict[str, Any] = dict(params or {})
|
|
125
|
+
if version == PROTOCOL_2026_07_28:
|
|
126
|
+
meta = dict(p.get("_meta") or {})
|
|
127
|
+
meta.setdefault(META_VERSION_KEY, version)
|
|
128
|
+
p["_meta"] = meta
|
|
129
|
+
if p or not notification:
|
|
130
|
+
body["params"] = p
|
|
131
|
+
return body
|
|
132
|
+
|
|
133
|
+
@abc.abstractmethod
|
|
134
|
+
async def rpc(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
params: dict[str, Any] | None = None,
|
|
138
|
+
*,
|
|
139
|
+
note: str | None = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> RpcResponse:
|
|
142
|
+
"""Send a request and wait for its response."""
|
|
143
|
+
|
|
144
|
+
@abc.abstractmethod
|
|
145
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
146
|
+
"""Send a notification (no response expected)."""
|
|
147
|
+
|
|
148
|
+
@abc.abstractmethod
|
|
149
|
+
async def close(self) -> None:
|
|
150
|
+
"""Release connections or processes."""
|
|
151
|
+
|
|
152
|
+
async def __aenter__(self) -> Transport:
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
156
|
+
await self.close()
|