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,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()