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.
@@ -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