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,135 @@
|
|
|
1
|
+
"""Evidence collection: every request/response pair the scanner sends is logged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
|
|
8
|
+
from mcpprobe.models import EVIDENCE_BODY_LIMIT, EvidenceRecord, utcnow
|
|
9
|
+
from mcpprobe.redact import redact_secrets, redact_url
|
|
10
|
+
|
|
11
|
+
#: Response headers worth keeping in evidence (lower-case).
|
|
12
|
+
KEPT_RESPONSE_HEADERS: frozenset[str] = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"content-type",
|
|
15
|
+
"content-length",
|
|
16
|
+
"www-authenticate",
|
|
17
|
+
"mcp-session-id",
|
|
18
|
+
"mcp-protocol-version",
|
|
19
|
+
"server",
|
|
20
|
+
"x-powered-by",
|
|
21
|
+
"strict-transport-security",
|
|
22
|
+
"x-content-type-options",
|
|
23
|
+
"x-frame-options",
|
|
24
|
+
"content-security-policy",
|
|
25
|
+
"cache-control",
|
|
26
|
+
"retry-after",
|
|
27
|
+
"location",
|
|
28
|
+
"access-control-allow-origin",
|
|
29
|
+
"access-control-allow-credentials",
|
|
30
|
+
"allow",
|
|
31
|
+
"date",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: Request headers worth keeping in evidence (lower-case).
|
|
36
|
+
KEPT_REQUEST_HEADERS: frozenset[str] = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"accept",
|
|
39
|
+
"content-type",
|
|
40
|
+
"origin",
|
|
41
|
+
"host",
|
|
42
|
+
"user-agent",
|
|
43
|
+
"authorization",
|
|
44
|
+
"mcp-session-id",
|
|
45
|
+
"mcp-protocol-version",
|
|
46
|
+
"mcp-method",
|
|
47
|
+
"mcp-name",
|
|
48
|
+
"last-event-id",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
REDACTED = "<redacted>"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _subset(headers: Mapping[str, str] | Iterable[tuple[str, str]], keep: frozenset[str]) -> dict[str, str]:
|
|
56
|
+
items = headers.items() if isinstance(headers, Mapping) else headers
|
|
57
|
+
out: dict[str, str] = {}
|
|
58
|
+
for key, value in items:
|
|
59
|
+
lk = key.lower()
|
|
60
|
+
if lk not in keep:
|
|
61
|
+
continue
|
|
62
|
+
if lk == "authorization":
|
|
63
|
+
scheme = value.split(" ", 1)[0] if value else ""
|
|
64
|
+
out[lk] = f"{scheme} {REDACTED}".strip()
|
|
65
|
+
else:
|
|
66
|
+
out[lk] = value
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def truncate_body(body: bytes | str | None, limit: int = EVIDENCE_BODY_LIMIT) -> tuple[str, bool]:
|
|
71
|
+
"""Return ``(text, truncated)`` for a body: secrets redacted, at most ``limit`` UTF-8 bytes."""
|
|
72
|
+
if body is None:
|
|
73
|
+
return "", False
|
|
74
|
+
raw = body if isinstance(body, bytes) else body.encode("utf-8", errors="replace")
|
|
75
|
+
truncated = len(raw) > limit
|
|
76
|
+
# Redact on a slightly larger window so a secret straddling the cut is still caught.
|
|
77
|
+
text = redact_secrets(raw[: limit * 2].decode("utf-8", errors="replace"))
|
|
78
|
+
encoded = text.encode("utf-8")
|
|
79
|
+
if len(encoded) > limit:
|
|
80
|
+
truncated = True
|
|
81
|
+
text = encoded[:limit].decode("utf-8", errors="ignore")
|
|
82
|
+
return text, truncated
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EvidenceLog:
|
|
86
|
+
"""Append-only list of :class:`EvidenceRecord` with sequential IDs."""
|
|
87
|
+
|
|
88
|
+
def __init__(self) -> None:
|
|
89
|
+
self._records: list[EvidenceRecord] = []
|
|
90
|
+
self._counter = itertools.count(1)
|
|
91
|
+
|
|
92
|
+
def record(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
transport: str,
|
|
96
|
+
method: str,
|
|
97
|
+
url: str | None = None,
|
|
98
|
+
status: int | None = None,
|
|
99
|
+
request_headers: Mapping[str, str] | None = None,
|
|
100
|
+
response_headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
101
|
+
request_body: bytes | str | None = None,
|
|
102
|
+
response_body: bytes | str | None = None,
|
|
103
|
+
elapsed_ms: float | None = None,
|
|
104
|
+
note: str | None = None,
|
|
105
|
+
error: str | None = None,
|
|
106
|
+
) -> EvidenceRecord:
|
|
107
|
+
"""Create, store and return a new evidence record."""
|
|
108
|
+
req_text, req_trunc = truncate_body(request_body)
|
|
109
|
+
resp_text, resp_trunc = truncate_body(response_body)
|
|
110
|
+
rec = EvidenceRecord(
|
|
111
|
+
id=f"ev-{next(self._counter):04d}",
|
|
112
|
+
timestamp=utcnow(),
|
|
113
|
+
transport=transport,
|
|
114
|
+
method=method,
|
|
115
|
+
url=redact_url(url) if url else url,
|
|
116
|
+
status=status,
|
|
117
|
+
request_headers=_subset(request_headers or {}, KEPT_REQUEST_HEADERS),
|
|
118
|
+
response_headers=_subset(response_headers or {}, KEPT_RESPONSE_HEADERS),
|
|
119
|
+
request_body=req_text,
|
|
120
|
+
response_body=resp_text,
|
|
121
|
+
request_truncated=req_trunc,
|
|
122
|
+
response_truncated=resp_trunc,
|
|
123
|
+
elapsed_ms=elapsed_ms,
|
|
124
|
+
note=note,
|
|
125
|
+
error=error,
|
|
126
|
+
)
|
|
127
|
+
self._records.append(rec)
|
|
128
|
+
return rec
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def records(self) -> list[EvidenceRecord]:
|
|
132
|
+
return list(self._records)
|
|
133
|
+
|
|
134
|
+
def __len__(self) -> int:
|
|
135
|
+
return len(self._records)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Protocol-version fingerprinting.
|
|
2
|
+
|
|
3
|
+
Order of attempts (see the MCP versioning page and the 2026-07-28 changelog):
|
|
4
|
+
|
|
5
|
+
1. ``server/discover`` with the 2026-07-28 ``_meta`` version (stateless revision).
|
|
6
|
+
2. ``initialize`` with ``2025-11-25``, then ``2025-06-18`` (handshake revisions);
|
|
7
|
+
on success ``notifications/initialized`` is sent and any ``Mcp-Session-Id``
|
|
8
|
+
is remembered.
|
|
9
|
+
3. For HTTP targets that answered 400/404/405 to every POST, a single GET is made
|
|
10
|
+
to detect the deprecated 2024-11-05 HTTP+SSE transport (``endpoint`` event).
|
|
11
|
+
|
|
12
|
+
A 401 on any attempt marks the target as auth-protected and stops negotiation.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mcpprobe.models import Fingerprint
|
|
20
|
+
from mcpprobe.transport.base import (
|
|
21
|
+
CLIENT_INFO,
|
|
22
|
+
HANDSHAKE_VERSIONS,
|
|
23
|
+
PROTOCOL_2026_07_28,
|
|
24
|
+
RpcResponse,
|
|
25
|
+
Transport,
|
|
26
|
+
)
|
|
27
|
+
from mcpprobe.transport.http import HttpTransport, parse_sse_block
|
|
28
|
+
|
|
29
|
+
LEGACY_STATUSES = {400, 404, 405}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _attempt(version: str, method: str, resp: RpcResponse) -> dict[str, Any]:
|
|
33
|
+
err = resp.rpc_error
|
|
34
|
+
return {
|
|
35
|
+
"version": version,
|
|
36
|
+
"method": method,
|
|
37
|
+
"status": resp.status,
|
|
38
|
+
"ok": resp.ok,
|
|
39
|
+
"rpc_error": (err or {}).get("message") if err else None,
|
|
40
|
+
"transport_error": resp.error_text,
|
|
41
|
+
"evidence": resp.evidence.id if resp.evidence else None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _absorb_server_result(fp: Fingerprint, result: dict[str, Any]) -> None:
|
|
46
|
+
info = result.get("serverInfo") or result.get("identity") or result.get("server") or {}
|
|
47
|
+
if isinstance(info, dict):
|
|
48
|
+
fp.server_info = info
|
|
49
|
+
caps = result.get("capabilities")
|
|
50
|
+
if isinstance(caps, dict):
|
|
51
|
+
fp.capabilities = caps
|
|
52
|
+
instr = result.get("instructions")
|
|
53
|
+
if isinstance(instr, str):
|
|
54
|
+
fp.instructions = instr
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def fingerprint(transport: Transport) -> Fingerprint:
|
|
58
|
+
"""Negotiate with the target and return what was learned."""
|
|
59
|
+
fp = Fingerprint(transport=transport.name)
|
|
60
|
+
statuses: list[int | None] = []
|
|
61
|
+
|
|
62
|
+
# 1. Stateless 2026-07-28
|
|
63
|
+
resp = await transport.rpc(
|
|
64
|
+
"server/discover",
|
|
65
|
+
{},
|
|
66
|
+
protocol_version=PROTOCOL_2026_07_28,
|
|
67
|
+
note="fingerprint: 2026-07-28 server/discover",
|
|
68
|
+
)
|
|
69
|
+
fp.attempts.append(_attempt(PROTOCOL_2026_07_28, "server/discover", resp))
|
|
70
|
+
statuses.append(resp.status)
|
|
71
|
+
if _is_unauthorised(resp):
|
|
72
|
+
_mark_auth(fp, resp)
|
|
73
|
+
return fp
|
|
74
|
+
if resp.ok and resp.result is not None:
|
|
75
|
+
fp.flavour = "stateless-2026"
|
|
76
|
+
fp.protocol_version = PROTOCOL_2026_07_28
|
|
77
|
+
transport.protocol_version = PROTOCOL_2026_07_28
|
|
78
|
+
_absorb_server_result(fp, resp.result)
|
|
79
|
+
versions = (
|
|
80
|
+
resp.result.get("protocolVersions")
|
|
81
|
+
or resp.result.get("supportedVersions")
|
|
82
|
+
or resp.result.get("versions")
|
|
83
|
+
)
|
|
84
|
+
if versions:
|
|
85
|
+
fp.server_info = {**fp.server_info, "supportedVersions": versions}
|
|
86
|
+
return fp
|
|
87
|
+
|
|
88
|
+
# 2. Handshake revisions
|
|
89
|
+
for version in sorted(HANDSHAKE_VERSIONS, reverse=True):
|
|
90
|
+
resp = await transport.rpc(
|
|
91
|
+
"initialize",
|
|
92
|
+
{"protocolVersion": version, "capabilities": {}, "clientInfo": CLIENT_INFO},
|
|
93
|
+
protocol_version=version,
|
|
94
|
+
track_session=True,
|
|
95
|
+
note=f"fingerprint: initialize {version}",
|
|
96
|
+
)
|
|
97
|
+
fp.attempts.append(_attempt(version, "initialize", resp))
|
|
98
|
+
statuses.append(resp.status)
|
|
99
|
+
if _is_unauthorised(resp):
|
|
100
|
+
_mark_auth(fp, resp)
|
|
101
|
+
return fp
|
|
102
|
+
if resp.ok and resp.result is not None:
|
|
103
|
+
negotiated = resp.result.get("protocolVersion")
|
|
104
|
+
fp.protocol_version = str(negotiated) if negotiated else version
|
|
105
|
+
# Speak the version the server answered with, so later requests match.
|
|
106
|
+
transport.protocol_version = (
|
|
107
|
+
fp.protocol_version if fp.protocol_version in HANDSHAKE_VERSIONS else version
|
|
108
|
+
)
|
|
109
|
+
fp.flavour = "handshake"
|
|
110
|
+
_absorb_server_result(fp, resp.result)
|
|
111
|
+
if isinstance(transport, HttpTransport) and transport.session_id:
|
|
112
|
+
fp.session_id_seen = True
|
|
113
|
+
await transport.notify("notifications/initialized")
|
|
114
|
+
return fp
|
|
115
|
+
|
|
116
|
+
# 3. Legacy HTTP+SSE
|
|
117
|
+
if isinstance(transport, HttpTransport) and all(s in LEGACY_STATUSES for s in statuses if s):
|
|
118
|
+
resp = await transport.legacy_sse_probe()
|
|
119
|
+
fp.attempts.append(_attempt("2024-11-05", "GET", resp))
|
|
120
|
+
if _is_unauthorised(resp):
|
|
121
|
+
_mark_auth(fp, resp)
|
|
122
|
+
return fp
|
|
123
|
+
if resp.status == 200 and "text/event-stream" in resp.headers.get("content-type", ""):
|
|
124
|
+
first = _first_event_name(resp.body)
|
|
125
|
+
if first == "endpoint":
|
|
126
|
+
fp.flavour = "legacy-sse"
|
|
127
|
+
fp.protocol_version = "2024-11-05"
|
|
128
|
+
return fp
|
|
129
|
+
|
|
130
|
+
fp.error = "no supported protocol revision answered; see attempts"
|
|
131
|
+
return fp
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _first_event_name(body: bytes) -> str | None:
|
|
135
|
+
lines = body.decode("utf-8", errors="replace").split("\n")
|
|
136
|
+
block: list[str] = []
|
|
137
|
+
for line in lines:
|
|
138
|
+
if line == "":
|
|
139
|
+
if block:
|
|
140
|
+
ev = parse_sse_block(block)
|
|
141
|
+
return ev.event if ev else None
|
|
142
|
+
continue
|
|
143
|
+
block.append(line)
|
|
144
|
+
ev = parse_sse_block(block) if block else None
|
|
145
|
+
return ev.event if ev else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_unauthorised(resp: RpcResponse) -> bool:
|
|
149
|
+
return resp.status == 401
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _mark_auth(fp: Fingerprint, resp: RpcResponse) -> None:
|
|
153
|
+
fp.auth_required = True
|
|
154
|
+
fp.www_authenticate = resp.headers.get("www-authenticate")
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Streamable HTTP transport (MCP 2025-03-26 onwards, including 2026-07-28).
|
|
2
|
+
|
|
3
|
+
One endpoint, JSON-RPC over POST, responses either ``application/json`` or an
|
|
4
|
+
SSE ``text/event-stream`` that is read until the matching response arrives.
|
|
5
|
+
Every exchange is written to the evidence log.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from mcpprobe import USER_AGENT
|
|
19
|
+
from mcpprobe.transport.base import (
|
|
20
|
+
PROTOCOL_2026_07_28,
|
|
21
|
+
RpcResponse,
|
|
22
|
+
Transport,
|
|
23
|
+
TransportError,
|
|
24
|
+
)
|
|
25
|
+
from mcpprobe.transport.evidence import EvidenceLog
|
|
26
|
+
from mcpprobe.transport.ratelimit import RateLimiter
|
|
27
|
+
|
|
28
|
+
ACCEPT_BOTH = "application/json, text/event-stream"
|
|
29
|
+
#: Hard cap on how many SSE bytes we buffer for one response.
|
|
30
|
+
SSE_MAX_BYTES = 1_000_000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SseEvent:
|
|
34
|
+
"""A parsed Server-Sent Event."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("data", "event", "id")
|
|
37
|
+
|
|
38
|
+
def __init__(self, event: str | None, data: str, event_id: str | None) -> None:
|
|
39
|
+
self.event = event
|
|
40
|
+
self.data = data
|
|
41
|
+
self.id = event_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_sse_block(lines: Iterable[str]) -> SseEvent | None:
|
|
45
|
+
"""Parse one blank-line-delimited SSE block into an event (``None`` if empty)."""
|
|
46
|
+
event: str | None = None
|
|
47
|
+
data_parts: list[str] = []
|
|
48
|
+
event_id: str | None = None
|
|
49
|
+
for line in lines:
|
|
50
|
+
if not line or line.startswith(":"):
|
|
51
|
+
continue
|
|
52
|
+
field, _, value = line.partition(":")
|
|
53
|
+
value = value.removeprefix(" ")
|
|
54
|
+
if field == "event":
|
|
55
|
+
event = value
|
|
56
|
+
elif field == "data":
|
|
57
|
+
data_parts.append(value)
|
|
58
|
+
elif field == "id":
|
|
59
|
+
event_id = value
|
|
60
|
+
if not data_parts and event is None:
|
|
61
|
+
return None
|
|
62
|
+
return SseEvent(event, "\n".join(data_parts), event_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HttpTransport(Transport):
|
|
66
|
+
"""Permissive streamable-HTTP MCP client used by all HTTP checks."""
|
|
67
|
+
|
|
68
|
+
name = "http"
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
url: str,
|
|
73
|
+
*,
|
|
74
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
75
|
+
timeout: float = 15.0,
|
|
76
|
+
evidence: EvidenceLog | None = None,
|
|
77
|
+
limiter: RateLimiter | None = None,
|
|
78
|
+
verify: bool | str = True,
|
|
79
|
+
user_agent: str = USER_AGENT,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(evidence=evidence, limiter=limiter)
|
|
82
|
+
self.url = url
|
|
83
|
+
self.extra_headers = dict(extra_headers or {})
|
|
84
|
+
self.timeout = timeout
|
|
85
|
+
self.user_agent = user_agent
|
|
86
|
+
self.session_id: str | None = None
|
|
87
|
+
self.tls_version: str | None = None
|
|
88
|
+
self.sessions_seen: list[str] = []
|
|
89
|
+
self._client = httpx.AsyncClient(
|
|
90
|
+
timeout=httpx.Timeout(timeout),
|
|
91
|
+
verify=verify,
|
|
92
|
+
follow_redirects=False,
|
|
93
|
+
headers={"User-Agent": user_agent},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ headers
|
|
97
|
+
def default_headers(
|
|
98
|
+
self,
|
|
99
|
+
method: str | None,
|
|
100
|
+
*,
|
|
101
|
+
protocol_version: str | None = None,
|
|
102
|
+
include_session: bool = True,
|
|
103
|
+
) -> dict[str, str]:
|
|
104
|
+
"""Headers a conformant client would send for ``method``."""
|
|
105
|
+
version = protocol_version or self.protocol_version
|
|
106
|
+
headers: dict[str, str] = {
|
|
107
|
+
"Accept": ACCEPT_BOTH,
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"User-Agent": self.user_agent,
|
|
110
|
+
}
|
|
111
|
+
if version:
|
|
112
|
+
headers["MCP-Protocol-Version"] = version
|
|
113
|
+
if version == PROTOCOL_2026_07_28 and method:
|
|
114
|
+
headers["Mcp-Method"] = method
|
|
115
|
+
if include_session and self.session_id and version != PROTOCOL_2026_07_28:
|
|
116
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
117
|
+
headers.update(self.extra_headers)
|
|
118
|
+
return headers
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------ raw I/O
|
|
121
|
+
async def raw_request(
|
|
122
|
+
self,
|
|
123
|
+
http_method: str,
|
|
124
|
+
*,
|
|
125
|
+
body: bytes | str | None = None,
|
|
126
|
+
headers: Mapping[str, str] | None = None,
|
|
127
|
+
url: str | None = None,
|
|
128
|
+
label: str = "raw",
|
|
129
|
+
want_id: int | str | None = None,
|
|
130
|
+
note: str | None = None,
|
|
131
|
+
track_session: bool = False,
|
|
132
|
+
stream_timeout: float | None = None,
|
|
133
|
+
) -> RpcResponse:
|
|
134
|
+
"""Send an arbitrary HTTP request to the endpoint and record it.
|
|
135
|
+
|
|
136
|
+
If the response is an SSE stream it is read until a JSON-RPC message
|
|
137
|
+
with id ``want_id`` is seen (or the stream ends / times out).
|
|
138
|
+
"""
|
|
139
|
+
await self.limiter.acquire()
|
|
140
|
+
target = url or self.url
|
|
141
|
+
hdrs = dict(headers or {})
|
|
142
|
+
data = body.encode("utf-8") if isinstance(body, str) else body
|
|
143
|
+
started = time.monotonic()
|
|
144
|
+
status: int | None = None
|
|
145
|
+
resp_headers: dict[str, str] = {}
|
|
146
|
+
collected = b""
|
|
147
|
+
message: dict[str, Any] | None = None
|
|
148
|
+
stream_messages: list[dict[str, Any]] = []
|
|
149
|
+
error_text: str | None = None
|
|
150
|
+
try:
|
|
151
|
+
async with self._client.stream(http_method, target, content=data, headers=hdrs) as resp:
|
|
152
|
+
status = resp.status_code
|
|
153
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
154
|
+
self._capture_tls(resp)
|
|
155
|
+
ctype = resp_headers.get("content-type", "")
|
|
156
|
+
if "text/event-stream" in ctype:
|
|
157
|
+
collected, stream_messages, message = await self._read_sse(
|
|
158
|
+
resp, want_id, stream_timeout or self.timeout
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
collected = await resp.aread()
|
|
162
|
+
message = _parse_jsonrpc(collected, want_id)
|
|
163
|
+
if message is not None:
|
|
164
|
+
stream_messages = [message]
|
|
165
|
+
except httpx.HTTPError as exc:
|
|
166
|
+
error_text = f"{type(exc).__name__}: {exc}"
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
error_text = "stream timeout"
|
|
169
|
+
elapsed_ms = (time.monotonic() - started) * 1000
|
|
170
|
+
if track_session:
|
|
171
|
+
sid = resp_headers.get("mcp-session-id")
|
|
172
|
+
if sid:
|
|
173
|
+
self.session_id = sid
|
|
174
|
+
self.sessions_seen.append(sid)
|
|
175
|
+
rec = self.evidence.record(
|
|
176
|
+
transport="http",
|
|
177
|
+
method=label,
|
|
178
|
+
url=target,
|
|
179
|
+
status=status,
|
|
180
|
+
request_headers=hdrs,
|
|
181
|
+
response_headers=resp_headers,
|
|
182
|
+
request_body=data,
|
|
183
|
+
response_body=collected,
|
|
184
|
+
elapsed_ms=elapsed_ms,
|
|
185
|
+
note=note,
|
|
186
|
+
error=error_text,
|
|
187
|
+
)
|
|
188
|
+
return RpcResponse(
|
|
189
|
+
method=label,
|
|
190
|
+
status=status,
|
|
191
|
+
headers=resp_headers,
|
|
192
|
+
body=collected,
|
|
193
|
+
message=message,
|
|
194
|
+
evidence=rec,
|
|
195
|
+
elapsed_ms=elapsed_ms,
|
|
196
|
+
error_text=error_text,
|
|
197
|
+
stream_messages=stream_messages,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _capture_tls(self, resp: httpx.Response) -> None:
|
|
201
|
+
if self.tls_version is not None:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
stream = resp.extensions.get("network_stream")
|
|
205
|
+
ssl_object = stream.get_extra_info("ssl_object") if stream else None
|
|
206
|
+
if ssl_object is not None:
|
|
207
|
+
self.tls_version = ssl_object.version()
|
|
208
|
+
except Exception: # pragma: no cover - best effort only
|
|
209
|
+
self.tls_version = None
|
|
210
|
+
|
|
211
|
+
async def _read_sse(
|
|
212
|
+
self,
|
|
213
|
+
resp: httpx.Response,
|
|
214
|
+
want_id: int | str | None,
|
|
215
|
+
timeout: float,
|
|
216
|
+
) -> tuple[bytes, list[dict[str, Any]], dict[str, Any] | None]:
|
|
217
|
+
collected: list[str] = []
|
|
218
|
+
total = 0
|
|
219
|
+
block: list[str] = []
|
|
220
|
+
messages: list[dict[str, Any]] = []
|
|
221
|
+
matched: dict[str, Any] | None = None
|
|
222
|
+
|
|
223
|
+
async def consume() -> None:
|
|
224
|
+
nonlocal total, matched
|
|
225
|
+
async for line in resp.aiter_lines():
|
|
226
|
+
total += len(line) + 1
|
|
227
|
+
if total <= SSE_MAX_BYTES:
|
|
228
|
+
collected.append(line)
|
|
229
|
+
if line == "":
|
|
230
|
+
event = parse_sse_block(block)
|
|
231
|
+
block.clear()
|
|
232
|
+
if event is None or not event.data:
|
|
233
|
+
continue
|
|
234
|
+
msg = _parse_jsonrpc(event.data.encode("utf-8"), None)
|
|
235
|
+
if msg is not None:
|
|
236
|
+
messages.append(msg)
|
|
237
|
+
if want_id is not None and msg.get("id") == want_id:
|
|
238
|
+
matched = msg
|
|
239
|
+
return
|
|
240
|
+
else:
|
|
241
|
+
block.append(line)
|
|
242
|
+
if total > SSE_MAX_BYTES:
|
|
243
|
+
return
|
|
244
|
+
if block:
|
|
245
|
+
event = parse_sse_block(block)
|
|
246
|
+
if event and event.data:
|
|
247
|
+
msg = _parse_jsonrpc(event.data.encode("utf-8"), None)
|
|
248
|
+
if msg is not None:
|
|
249
|
+
messages.append(msg)
|
|
250
|
+
if want_id is not None and msg.get("id") == want_id:
|
|
251
|
+
matched = msg
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
await asyncio.wait_for(consume(), timeout)
|
|
255
|
+
except TimeoutError:
|
|
256
|
+
pass
|
|
257
|
+
if matched is None and want_id is None and messages:
|
|
258
|
+
matched = messages[-1]
|
|
259
|
+
return "\n".join(collected).encode("utf-8"), messages, matched
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------ JSON-RPC
|
|
262
|
+
async def rpc(
|
|
263
|
+
self,
|
|
264
|
+
method: str,
|
|
265
|
+
params: dict[str, Any] | None = None,
|
|
266
|
+
*,
|
|
267
|
+
note: str | None = None,
|
|
268
|
+
headers_override: Mapping[str, str] | None = None,
|
|
269
|
+
drop_headers: Iterable[str] = (),
|
|
270
|
+
protocol_version: str | None = None,
|
|
271
|
+
track_session: bool = False,
|
|
272
|
+
include_session: bool = True,
|
|
273
|
+
**kwargs: Any,
|
|
274
|
+
) -> RpcResponse:
|
|
275
|
+
"""POST a JSON-RPC request. ``headers_override`` lets checks send
|
|
276
|
+
non-conformant headers; ``drop_headers`` removes defaults by name."""
|
|
277
|
+
msg = self.build_message(method, params, protocol_version=protocol_version)
|
|
278
|
+
hdrs = self.default_headers(
|
|
279
|
+
method, protocol_version=protocol_version, include_session=include_session
|
|
280
|
+
)
|
|
281
|
+
for name in drop_headers:
|
|
282
|
+
hdrs = {k: v for k, v in hdrs.items() if k.lower() != name.lower()}
|
|
283
|
+
if headers_override:
|
|
284
|
+
for k, v in headers_override.items():
|
|
285
|
+
hdrs = {hk: hv for hk, hv in hdrs.items() if hk.lower() != k.lower()}
|
|
286
|
+
hdrs[k] = v
|
|
287
|
+
resp = await self.raw_request(
|
|
288
|
+
"POST",
|
|
289
|
+
body=json.dumps(msg),
|
|
290
|
+
headers=hdrs,
|
|
291
|
+
label=method,
|
|
292
|
+
want_id=msg["id"],
|
|
293
|
+
note=note,
|
|
294
|
+
track_session=track_session,
|
|
295
|
+
)
|
|
296
|
+
resp.method = method
|
|
297
|
+
return resp
|
|
298
|
+
|
|
299
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
300
|
+
msg = self.build_message(method, params, notification=True)
|
|
301
|
+
await self.raw_request(
|
|
302
|
+
"POST",
|
|
303
|
+
body=json.dumps(msg),
|
|
304
|
+
headers=self.default_headers(method),
|
|
305
|
+
label=method,
|
|
306
|
+
note="notification",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def get(
|
|
310
|
+
self,
|
|
311
|
+
url: str,
|
|
312
|
+
*,
|
|
313
|
+
headers: Mapping[str, str] | None = None,
|
|
314
|
+
note: str | None = None,
|
|
315
|
+
label: str = "GET",
|
|
316
|
+
) -> RpcResponse:
|
|
317
|
+
"""Plain GET (well-known documents, opt-out, legacy SSE probe)."""
|
|
318
|
+
hdrs = {"User-Agent": self.user_agent, "Accept": "application/json, text/plain, */*"}
|
|
319
|
+
hdrs.update(headers or {})
|
|
320
|
+
return await self.raw_request(
|
|
321
|
+
"GET", headers=hdrs, url=url, label=label, note=note, stream_timeout=3.0
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
async def legacy_sse_probe(self) -> RpcResponse:
|
|
325
|
+
"""GET the endpoint as the deprecated 2024-11-05 HTTP+SSE transport would.
|
|
326
|
+
|
|
327
|
+
A legacy server answers with an ``endpoint`` event. We read only the
|
|
328
|
+
first event (3 s cap) and close the stream.
|
|
329
|
+
"""
|
|
330
|
+
hdrs = {"User-Agent": self.user_agent, "Accept": "text/event-stream"}
|
|
331
|
+
hdrs.update(self.extra_headers)
|
|
332
|
+
return await self.raw_request(
|
|
333
|
+
"GET", headers=hdrs, label="GET(sse)", note="legacy HTTP+SSE probe", stream_timeout=3.0
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def close(self) -> None:
|
|
337
|
+
await self._client.aclose()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse_jsonrpc(body: bytes, want_id: int | str | None) -> dict[str, Any] | None:
|
|
341
|
+
"""Parse a JSON-RPC response body (object or batch), picking the one for ``want_id``."""
|
|
342
|
+
if not body:
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
data = json.loads(body.decode("utf-8", errors="replace"))
|
|
346
|
+
except ValueError:
|
|
347
|
+
return None
|
|
348
|
+
if isinstance(data, list):
|
|
349
|
+
for item in data:
|
|
350
|
+
if isinstance(item, dict) and (want_id is None or item.get("id") == want_id):
|
|
351
|
+
return item
|
|
352
|
+
return None
|
|
353
|
+
if isinstance(data, dict) and "jsonrpc" in data:
|
|
354
|
+
return data
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def ensure_url(target: str) -> str:
|
|
359
|
+
"""Validate an HTTP(S) target."""
|
|
360
|
+
try:
|
|
361
|
+
parsed = httpx.URL(target)
|
|
362
|
+
except httpx.InvalidURL as exc:
|
|
363
|
+
raise TransportError(f"invalid URL: {target}") from exc
|
|
364
|
+
if parsed.scheme not in {"http", "https"} or not parsed.host:
|
|
365
|
+
raise TransportError(f"target must be an http(s) URL: {target}")
|
|
366
|
+
return str(parsed)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A small asyncio token-bucket rate limiter.
|
|
2
|
+
|
|
3
|
+
The default scan budget is 5 requests per second with a burst of one, so the
|
|
4
|
+
cap also holds within any one-second window. Every outbound request the
|
|
5
|
+
scanner makes (including well-known lookups and opt-out checks) passes through
|
|
6
|
+
one limiter so the cap is global, not per check.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
DEFAULT_RATE = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RateLimiter:
|
|
18
|
+
"""Token bucket: ``rate`` tokens per second, burst of ``burst`` tokens."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, rate: float = DEFAULT_RATE, burst: int | None = None) -> None:
|
|
21
|
+
if rate <= 0:
|
|
22
|
+
raise ValueError("rate must be positive")
|
|
23
|
+
self.rate = float(rate)
|
|
24
|
+
self.capacity = float(burst if burst is not None else 1)
|
|
25
|
+
self._tokens = self.capacity
|
|
26
|
+
self._updated = time.monotonic()
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
self.total_waits = 0.0
|
|
29
|
+
self.requests = 0
|
|
30
|
+
|
|
31
|
+
def _refill(self) -> None:
|
|
32
|
+
now = time.monotonic()
|
|
33
|
+
elapsed = now - self._updated
|
|
34
|
+
self._updated = now
|
|
35
|
+
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
|
|
36
|
+
|
|
37
|
+
async def acquire(self) -> None:
|
|
38
|
+
"""Wait until a token is available, then consume it."""
|
|
39
|
+
async with self._lock:
|
|
40
|
+
self._refill()
|
|
41
|
+
if self._tokens < 1.0:
|
|
42
|
+
wait = (1.0 - self._tokens) / self.rate
|
|
43
|
+
self.total_waits += wait
|
|
44
|
+
await asyncio.sleep(wait)
|
|
45
|
+
self._refill()
|
|
46
|
+
self._tokens -= 1.0
|
|
47
|
+
self.requests += 1
|