agent-capmap 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.
Files changed (48) hide show
  1. agent_capmap-0.1.0.dist-info/METADATA +295 -0
  2. agent_capmap-0.1.0.dist-info/RECORD +48 -0
  3. agent_capmap-0.1.0.dist-info/WHEEL +4 -0
  4. agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
  5. agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. agentcapmap/__init__.py +3 -0
  7. agentcapmap/__main__.py +5 -0
  8. agentcapmap/baseline.py +125 -0
  9. agentcapmap/classify/__init__.py +221 -0
  10. agentcapmap/classify/cache.py +74 -0
  11. agentcapmap/classify/prompt.py +160 -0
  12. agentcapmap/classify/provider.py +122 -0
  13. agentcapmap/classify/reference.py +115 -0
  14. agentcapmap/classify/reference_labels.json +6135 -0
  15. agentcapmap/cli.py +392 -0
  16. agentcapmap/diff.py +103 -0
  17. agentcapmap/discovery/__init__.py +209 -0
  18. agentcapmap/discovery/_common.py +297 -0
  19. agentcapmap/discovery/claude_code.py +258 -0
  20. agentcapmap/discovery/claude_desktop.py +28 -0
  21. agentcapmap/discovery/cursor.py +33 -0
  22. agentcapmap/discovery/mcp_json.py +29 -0
  23. agentcapmap/discovery/vscode.py +65 -0
  24. agentcapmap/enumerate/__init__.py +278 -0
  25. agentcapmap/enumerate/hashing.py +37 -0
  26. agentcapmap/enumerate/http.py +313 -0
  27. agentcapmap/enumerate/netguard.py +60 -0
  28. agentcapmap/enumerate/stdio.py +273 -0
  29. agentcapmap/enumerate/transport.py +86 -0
  30. agentcapmap/evaluation.py +498 -0
  31. agentcapmap/findings.py +129 -0
  32. agentcapmap/graph/__init__.py +264 -0
  33. agentcapmap/graph/server_attrs.py +164 -0
  34. agentcapmap/limits.py +35 -0
  35. agentcapmap/models.py +186 -0
  36. agentcapmap/policies/default.yaml +113 -0
  37. agentcapmap/policy/__init__.py +24 -0
  38. agentcapmap/policy/engine.py +229 -0
  39. agentcapmap/policy/loader.py +223 -0
  40. agentcapmap/py.typed +0 -0
  41. agentcapmap/redact.py +268 -0
  42. agentcapmap/report/__init__.py +36 -0
  43. agentcapmap/report/_text.py +62 -0
  44. agentcapmap/report/markdown.py +152 -0
  45. agentcapmap/report/sarif.py +209 -0
  46. agentcapmap/schemas/policy.schema.json +172 -0
  47. agentcapmap/schemas/tool-tags.schema.json +57 -0
  48. agentcapmap/taxonomy.py +56 -0
@@ -0,0 +1,37 @@
1
+ """Canonical tool-definition hashing.
2
+
3
+ The hash identifies a tool definition independent of JSON key order or whitespace, so
4
+ it can key the classifier cache and detect rug pulls (definition changed after review).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from collections.abc import Iterable
12
+ from typing import Any
13
+
14
+
15
+ def canonical_tool_definition(
16
+ name: str, description: str | None, input_schema: dict[str, Any] | None
17
+ ) -> bytes:
18
+ payload = {
19
+ "name": name,
20
+ "description": description or "",
21
+ "inputSchema": input_schema or {},
22
+ }
23
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
24
+ "utf-8"
25
+ )
26
+
27
+
28
+ def tool_definition_hash(
29
+ name: str, description: str | None, input_schema: dict[str, Any] | None
30
+ ) -> str:
31
+ """Hex SHA-256 of the canonical (name, description, inputSchema) JSON."""
32
+ return hashlib.sha256(canonical_tool_definition(name, description, input_schema)).hexdigest()
33
+
34
+
35
+ def server_definition_hash(tool_hashes: Iterable[str]) -> str:
36
+ """Order-independent hash over a server's tool hashes."""
37
+ return hashlib.sha256("\n".join(sorted(tool_hashes)).encode("ascii")).hexdigest()
@@ -0,0 +1,313 @@
1
+ """HTTP transports: Streamable HTTP (current spec) and legacy HTTP+SSE.
2
+
3
+ Security properties:
4
+ - Redirects are never followed, so configured credentials (Authorization, API-key
5
+ headers) cannot be forwarded to another host.
6
+ - The legacy SSE `endpoint` must be same-origin as the configured URL, for the same
7
+ reason.
8
+ - Response bodies, lines and SSE events are size-bounded.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import queue
15
+ import threading
16
+ import time
17
+ from collections.abc import Iterable, Iterator
18
+ from typing import Any
19
+ from urllib.parse import urljoin, urlsplit
20
+
21
+ import httpx
22
+
23
+ from agentcapmap import __version__
24
+ from agentcapmap.enumerate.transport import (
25
+ AuthRequired,
26
+ IdCounter,
27
+ RequestTimeout,
28
+ TransportError,
29
+ check_allowed,
30
+ is_response_to,
31
+ unwrap_response,
32
+ )
33
+ from agentcapmap.limits import MAX_MESSAGE_BYTES, MAX_QUEUED_MESSAGES, loads_untrusted
34
+ from agentcapmap.limits import UntrustedJSONError as _BadJSON
35
+ from agentcapmap.redact import get_logger, redact_url
36
+
37
+ log = get_logger(__name__)
38
+
39
+ _USER_AGENT = f"agent-capmap/{__version__}"
40
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
41
+
42
+
43
+ def iter_sse(lines: Iterable[str], max_event_chars: int | None = None) -> Iterator[tuple[str, str]]:
44
+ """Yield (event, data) pairs from a text/event-stream line iterator."""
45
+ max_event_chars = max_event_chars or MAX_MESSAGE_BYTES
46
+ event, data, size = "message", list[str](), 0
47
+ for line in lines:
48
+ if line == "":
49
+ if data:
50
+ yield event, "\n".join(data)
51
+ event, data, size = "message", [], 0
52
+ continue
53
+ if line.startswith(":"):
54
+ continue
55
+ field, _, value = line.partition(":")
56
+ value = value.removeprefix(" ")
57
+ if field == "event":
58
+ event = value
59
+ elif field == "data":
60
+ size += len(value) + 1
61
+ if size > max_event_chars:
62
+ raise TransportError(f"SSE event larger than {max_event_chars} bytes")
63
+ data.append(value)
64
+ if data:
65
+ yield event, "\n".join(data)
66
+
67
+
68
+ def _bounded_lines(response: httpx.Response) -> Iterator[str]:
69
+ """Decode a streamed body into lines, refusing any line longer than the limit."""
70
+ max_line = MAX_MESSAGE_BYTES
71
+ buffer = ""
72
+ for chunk in response.iter_text():
73
+ buffer += chunk
74
+ while (newline := buffer.find("\n")) != -1:
75
+ yield buffer[:newline].removesuffix("\r")
76
+ buffer = buffer[newline + 1 :]
77
+ if len(buffer) > max_line:
78
+ raise TransportError(f"response line longer than {max_line} bytes")
79
+ if buffer:
80
+ yield buffer.removesuffix("\r")
81
+
82
+
83
+ def _read_body(response: httpx.Response) -> bytes:
84
+ limit = MAX_MESSAGE_BYTES
85
+ declared = response.headers.get("content-length")
86
+ if declared and declared.isdigit() and int(declared) > limit:
87
+ raise TransportError(f"response body larger than {limit} bytes")
88
+ body = bytearray()
89
+ for chunk in response.iter_bytes():
90
+ body += chunk
91
+ if len(body) > limit:
92
+ raise TransportError(f"response body larger than {limit} bytes")
93
+ return bytes(body)
94
+
95
+
96
+ def _raise_for_status(response: httpx.Response, url: str) -> None:
97
+ status = response.status_code
98
+ if status == 401:
99
+ raise AuthRequired(
100
+ f"HTTP 401 from {redact_url(url)}: authentication required",
101
+ response.headers.get("www-authenticate"),
102
+ )
103
+ if 300 <= status < 400:
104
+ raise TransportError(
105
+ f"HTTP {status} redirect from {redact_url(url)} refused "
106
+ "(credentials are never forwarded to another location)"
107
+ )
108
+ if status >= 400:
109
+ raise TransportError(f"HTTP {status} from {redact_url(url)}")
110
+
111
+
112
+ def _http_error(url: str, exc: httpx.HTTPError) -> TransportError:
113
+ # The exception text can embed the full URL (including secrets); report only its type.
114
+ return TransportError(f"HTTP error contacting {redact_url(url)}: {type(exc).__name__}")
115
+
116
+
117
+ def _parse_json_messages(body: bytes) -> list[Any]:
118
+ try:
119
+ parsed = loads_untrusted(body)
120
+ except _BadJSON as exc:
121
+ raise TransportError(f"server returned invalid JSON ({exc})") from None
122
+ return parsed if isinstance(parsed, list) else [parsed]
123
+
124
+
125
+ def _origin(url: str) -> tuple[str, str, int | None]:
126
+ parts = urlsplit(url)
127
+ scheme = parts.scheme.lower()
128
+ return scheme, (parts.hostname or "").lower(), parts.port or _DEFAULT_PORTS.get(scheme)
129
+
130
+
131
+ def _client(headers: dict[str, str], timeout: float) -> httpx.Client:
132
+ return httpx.Client(
133
+ headers={"User-Agent": _USER_AGENT, **headers},
134
+ timeout=httpx.Timeout(timeout),
135
+ follow_redirects=False,
136
+ )
137
+
138
+
139
+ class StreamableHttpTransport:
140
+ """POST each JSON-RPC message; responses come back as JSON or as an SSE stream."""
141
+
142
+ def __init__(self, url: str, headers: dict[str, str], timeout: float) -> None:
143
+ self._url = url
144
+ self._http = _client(headers, timeout)
145
+ self._ids = IdCounter()
146
+ self._session_id: str | None = None
147
+ self._protocol_version: str | None = None
148
+
149
+ def _headers(self) -> dict[str, str]:
150
+ headers = {
151
+ "Accept": "application/json, text/event-stream",
152
+ "Content-Type": "application/json",
153
+ }
154
+ if self._session_id:
155
+ headers["Mcp-Session-Id"] = self._session_id
156
+ if self._protocol_version:
157
+ headers["MCP-Protocol-Version"] = self._protocol_version
158
+ return headers
159
+
160
+ def _find_response(
161
+ self, response: httpx.Response, method: str, request_id: int, deadline: float
162
+ ) -> dict[str, Any]:
163
+ content_type = response.headers.get("content-type", "").split(";")[0].strip()
164
+ if content_type == "text/event-stream":
165
+ for _event, data in iter_sse(_bounded_lines(response)):
166
+ if time.monotonic() > deadline:
167
+ raise RequestTimeout(f"{method} timed out")
168
+ try:
169
+ message = loads_untrusted(data)
170
+ except _BadJSON:
171
+ continue
172
+ if is_response_to(message, request_id):
173
+ return unwrap_response(message)
174
+ raise TransportError(f"SSE stream ended without a response to {method}")
175
+ for message in _parse_json_messages(_read_body(response)):
176
+ if is_response_to(message, request_id):
177
+ return unwrap_response(message)
178
+ raise TransportError(f"no response to {method} in HTTP body")
179
+
180
+ def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
181
+ check_allowed(method)
182
+ request_id = self._ids.next()
183
+ body = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
184
+ deadline = time.monotonic() + timeout
185
+ try:
186
+ with self._http.stream(
187
+ "POST", self._url, json=body, headers=self._headers(), timeout=timeout
188
+ ) as response:
189
+ _raise_for_status(response, self._url)
190
+ if session_id := response.headers.get("mcp-session-id"):
191
+ self._session_id = session_id
192
+ result = self._find_response(response, method, request_id, deadline)
193
+ except httpx.TimeoutException:
194
+ raise RequestTimeout(f"{method} timed out after {timeout:.0f}s") from None
195
+ except httpx.HTTPError as exc:
196
+ raise _http_error(self._url, exc) from None
197
+
198
+ if method == "initialize":
199
+ version = result.get("protocolVersion")
200
+ self._protocol_version = version if isinstance(version, str) else None
201
+ return result
202
+
203
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
204
+ check_allowed(method, notification=True)
205
+ body: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
206
+ if params is not None:
207
+ body["params"] = params
208
+ try:
209
+ response = self._http.post(self._url, json=body, headers=self._headers())
210
+ except httpx.HTTPError as exc:
211
+ raise _http_error(self._url, exc) from None
212
+ _raise_for_status(response, self._url)
213
+
214
+ def close(self) -> None:
215
+ if self._session_id:
216
+ with contextlib.suppress(httpx.HTTPError):
217
+ self._http.delete(self._url, headers=self._headers(), timeout=5)
218
+ self._http.close()
219
+
220
+
221
+ class LegacySseTransport:
222
+ """Deprecated 2024-11-05 transport: GET an SSE stream, POST to the advertised endpoint."""
223
+
224
+ def __init__(self, url: str, headers: dict[str, str], timeout: float) -> None:
225
+ self._url = url
226
+ self._http = _client(headers, timeout)
227
+ self._ids = IdCounter()
228
+ self._messages: queue.Queue[Any] = queue.Queue(maxsize=MAX_QUEUED_MESSAGES)
229
+ self._endpoint: queue.Queue[str | BaseException] = queue.Queue()
230
+ self._closed = threading.Event()
231
+ threading.Thread(target=self._listen, daemon=True).start()
232
+ try:
233
+ item = self._endpoint.get(timeout=timeout)
234
+ except queue.Empty:
235
+ self.close()
236
+ raise RequestTimeout(f"no SSE endpoint event within {timeout:.0f}s") from None
237
+ if isinstance(item, BaseException):
238
+ self.close()
239
+ raise item
240
+ self._post_url: str = item
241
+
242
+ def _endpoint_url(self, data: str) -> str:
243
+ endpoint = urljoin(self._url, data.strip())
244
+ if _origin(endpoint) != _origin(self._url):
245
+ raise TransportError(
246
+ "SSE endpoint is on a different origin than the configured URL; refusing to "
247
+ "send requests (and credentials) there"
248
+ )
249
+ return endpoint
250
+
251
+ def _listen(self) -> None:
252
+ try:
253
+ with self._http.stream(
254
+ "GET",
255
+ self._url,
256
+ headers={"Accept": "text/event-stream"},
257
+ timeout=httpx.Timeout(10.0, read=None),
258
+ ) as response:
259
+ _raise_for_status(response, self._url)
260
+ for event, data in iter_sse(_bounded_lines(response)):
261
+ if self._closed.is_set():
262
+ return
263
+ if event == "endpoint":
264
+ self._endpoint.put(self._endpoint_url(data))
265
+ elif event == "message":
266
+ with contextlib.suppress(_BadJSON):
267
+ self._messages.put(loads_untrusted(data))
268
+ except TransportError as exc:
269
+ self._endpoint.put(exc)
270
+ except httpx.HTTPError as exc:
271
+ if not self._closed.is_set():
272
+ self._endpoint.put(_http_error(self._url, exc))
273
+ finally:
274
+ with contextlib.suppress(queue.Full):
275
+ self._messages.put_nowait(None)
276
+
277
+ def _post(self, body: dict[str, Any]) -> None:
278
+ try:
279
+ response = self._http.post(
280
+ self._post_url, json=body, headers={"Content-Type": "application/json"}
281
+ )
282
+ except httpx.HTTPError as exc:
283
+ raise _http_error(self._url, exc) from None
284
+ _raise_for_status(response, self._post_url)
285
+
286
+ def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
287
+ check_allowed(method)
288
+ request_id = self._ids.next()
289
+ self._post({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
290
+ deadline = time.monotonic() + timeout
291
+ while True:
292
+ remaining = deadline - time.monotonic()
293
+ if remaining <= 0:
294
+ raise RequestTimeout(f"{method} timed out after {timeout:.0f}s")
295
+ try:
296
+ message = self._messages.get(timeout=remaining)
297
+ except queue.Empty:
298
+ continue
299
+ if message is None:
300
+ raise TransportError("SSE stream closed before response")
301
+ if is_response_to(message, request_id):
302
+ return unwrap_response(message)
303
+
304
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
305
+ check_allowed(method, notification=True)
306
+ body: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
307
+ if params is not None:
308
+ body["params"] = params
309
+ self._post(body)
310
+
311
+ def close(self) -> None:
312
+ self._closed.set()
313
+ self._http.close()
@@ -0,0 +1,60 @@
1
+ """Refuse remote MCP URLs that point at link-local or cloud-metadata addresses.
2
+
3
+ Loopback and private ranges are deliberately allowed: local MCP servers commonly
4
+ listen on localhost, and a config the user chose to enumerate may name a LAN host.
5
+ Link-local space (169.254.0.0/16, fe80::/10) holds the cloud instance-metadata
6
+ services, which hand out credentials and are never an MCP server.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import socket
13
+ from urllib.parse import urlsplit
14
+
15
+ from agentcapmap.enumerate.transport import TransportError
16
+
17
+ _BLOCKED_NETWORKS = (
18
+ ipaddress.ip_network("169.254.0.0/16"), # AWS/Azure/GCP/OpenStack metadata, link-local
19
+ ipaddress.ip_network("fe80::/10"), # IPv6 link-local
20
+ ipaddress.ip_network("fd00:ec2::/32"), # AWS IPv6 metadata
21
+ ipaddress.ip_network("100.100.100.200/32"), # Alibaba Cloud metadata
22
+ )
23
+ _BLOCKED_HOSTS = frozenset({"metadata.google.internal", "metadata", "instance-data"})
24
+
25
+
26
+ def _blocked(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
27
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
28
+ address = address.ipv4_mapped
29
+ return any(address in network for network in _BLOCKED_NETWORKS)
30
+
31
+
32
+ def check_remote_url(url: str) -> None:
33
+ """Raise TransportError if the URL's host is, or resolves to, a blocked address."""
34
+ host = (urlsplit(url).hostname or "").lower().rstrip(".")
35
+ if not host:
36
+ raise TransportError("remote server URL has no host")
37
+ if host in _BLOCKED_HOSTS:
38
+ raise TransportError("remote server host is a cloud metadata name; refusing to connect")
39
+ try:
40
+ literal = ipaddress.ip_address(host)
41
+ except ValueError:
42
+ pass
43
+ else:
44
+ if _blocked(literal):
45
+ raise TransportError("remote server address is link-local/metadata; refusing")
46
+ return
47
+ try:
48
+ infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
49
+ except OSError:
50
+ return # unresolvable: the connection attempt will fail with its own error
51
+ # SECURITY-REVIEW: this resolves the name once to check it, then httpx resolves it
52
+ # again to connect, so a DNS-rebinding server could still switch to a blocked
53
+ # address in between. Pinning the checked IP needs a custom httpx transport.
54
+ for info in infos:
55
+ try:
56
+ resolved = ipaddress.ip_address(info[4][0])
57
+ except ValueError:
58
+ continue
59
+ if _blocked(resolved):
60
+ raise TransportError("remote server host resolves to a link-local/metadata address")
@@ -0,0 +1,273 @@
1
+ """stdio transport: launch the server as a subprocess, newline-delimited JSON-RPC.
2
+
3
+ Launching the configured server command is this module's purpose, which is why it
4
+ imports `subprocess` (bandit B404). It is reached only through the warned, opt-in
5
+ `--enumerate` flag.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import collections
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import queue
15
+ import shutil
16
+ import signal
17
+ import subprocess # nosec B404
18
+ import sys
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import IO, Any
23
+
24
+ from agentcapmap.enumerate.transport import (
25
+ JSONRPC_METHOD_NOT_FOUND,
26
+ IdCounter,
27
+ RequestTimeout,
28
+ TransportError,
29
+ check_allowed,
30
+ is_response_to,
31
+ unwrap_response,
32
+ )
33
+ from agentcapmap.limits import (
34
+ MAX_MESSAGE_BYTES,
35
+ MAX_QUEUED_MESSAGES,
36
+ MAX_STDERR_LINE_CHARS,
37
+ UntrustedJSONError,
38
+ loads_untrusted,
39
+ )
40
+ from agentcapmap.redact import get_logger, redact_text
41
+
42
+ log = get_logger(__name__)
43
+
44
+ _EOF = object()
45
+ _OVERLONG = object()
46
+ _STDERR_TAIL_LINES = 20
47
+
48
+ # Only these variables are inherited from our own environment, mirroring the official
49
+ # MCP SDK. Everything else (ANTHROPIC_API_KEY, cloud credentials, tokens) stays with us;
50
+ # a server gets extra variables only if its config sets them explicitly.
51
+ _INHERITED_ENV_WINDOWS = frozenset(
52
+ {
53
+ "APPDATA",
54
+ "COMSPEC",
55
+ "HOMEDRIVE",
56
+ "HOMEPATH",
57
+ "LOCALAPPDATA",
58
+ "PATH",
59
+ "PATHEXT",
60
+ "PROCESSOR_ARCHITECTURE",
61
+ "PROGRAMDATA",
62
+ "PROGRAMFILES",
63
+ "PROGRAMFILES(X86)",
64
+ "SYSTEMDRIVE",
65
+ "SYSTEMROOT",
66
+ "TEMP",
67
+ "TMP",
68
+ "USERNAME",
69
+ "USERPROFILE",
70
+ "WINDIR",
71
+ }
72
+ )
73
+ _INHERITED_ENV_POSIX = frozenset(
74
+ {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TERM", "TMPDIR", "USER"}
75
+ )
76
+
77
+
78
+ def default_environment() -> dict[str, str]:
79
+ """The minimal environment a launched server inherits from agent-capmap."""
80
+ allowed = _INHERITED_ENV_WINDOWS if sys.platform == "win32" else _INHERITED_ENV_POSIX
81
+ return {
82
+ key: value
83
+ for key, value in os.environ.items()
84
+ # Skip exported shell functions ("() { ...") as the MCP SDK does.
85
+ if key.upper() in allowed and not value.startswith("()")
86
+ }
87
+
88
+
89
+ def _read_bounded_line(stream: IO[bytes], limit: int) -> bytes | object | None:
90
+ """Next line (None at EOF), or _OVERLONG after discarding a line longer than `limit`."""
91
+ chunk = stream.readline(limit)
92
+ if not chunk:
93
+ return None
94
+ if chunk.endswith(b"\n") or len(chunk) < limit:
95
+ return chunk
96
+ while True: # discard the rest of the oversized line
97
+ more = stream.readline(limit)
98
+ if not more or more.endswith(b"\n"):
99
+ return _OVERLONG
100
+
101
+
102
+ class StdioTransport:
103
+ def __init__(
104
+ self,
105
+ command: str,
106
+ args: list[str],
107
+ env: dict[str, str],
108
+ cwd: str | None = None,
109
+ ) -> None:
110
+ full_env = {**default_environment(), **env}
111
+ executable = shutil.which(command, path=full_env.get("PATH")) or command
112
+ kwargs: dict[str, Any] = {}
113
+ if sys.platform == "win32":
114
+ kwargs["creationflags"] = (
115
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
116
+ )
117
+ else:
118
+ kwargs["start_new_session"] = True
119
+ try:
120
+ # Launching the configured server command is this transport's purpose, and is
121
+ # only reached via the explicit, warned --enumerate opt-in. The command comes
122
+ # from a config the user chose to enumerate; no shell is involved (argv list,
123
+ # shell=False), so arguments cannot be reinterpreted as shell syntax.
124
+ self._proc = subprocess.Popen( # noqa: S603 # nosec B603
125
+ [executable, *args],
126
+ stdin=subprocess.PIPE,
127
+ stdout=subprocess.PIPE,
128
+ stderr=subprocess.PIPE,
129
+ env=full_env,
130
+ cwd=cwd,
131
+ shell=False,
132
+ **kwargs,
133
+ )
134
+ except OSError as exc:
135
+ raise TransportError(f"failed to start {command!r}: {exc.strerror or exc}") from None
136
+
137
+ if self._proc.stdin is None or self._proc.stdout is None or self._proc.stderr is None:
138
+ _kill_tree(self._proc)
139
+ raise TransportError("failed to open pipes to the server process")
140
+ self._stdin = self._proc.stdin
141
+ self._ids = IdCounter()
142
+ self._messages: queue.Queue[Any] = queue.Queue(maxsize=MAX_QUEUED_MESSAGES)
143
+ self._stderr_tail: collections.deque[str] = collections.deque(maxlen=_STDERR_TAIL_LINES)
144
+ threading.Thread(target=self._read_stdout, args=(self._proc.stdout,), daemon=True).start()
145
+ threading.Thread(target=self._read_stderr, args=(self._proc.stderr,), daemon=True).start()
146
+
147
+ def _read_stdout(self, stream: IO[bytes]) -> None:
148
+ try:
149
+ while (raw := _read_bounded_line(stream, MAX_MESSAGE_BYTES)) is not None:
150
+ if not isinstance(raw, bytes): # _OVERLONG
151
+ log.debug("dropping stdout line longer than %d bytes", MAX_MESSAGE_BYTES)
152
+ continue
153
+ line = raw.decode("utf-8", errors="replace").strip()
154
+ if not line:
155
+ continue
156
+ try:
157
+ message = loads_untrusted(line)
158
+ except UntrustedJSONError:
159
+ log.debug("ignoring non-JSON stdout line: %s", line[:200])
160
+ continue
161
+ self._messages.put(message)
162
+ except (OSError, ValueError):
163
+ pass # pipe closed underneath us
164
+ finally:
165
+ self._messages.put(_EOF)
166
+
167
+ def _read_stderr(self, stream: IO[bytes]) -> None:
168
+ try:
169
+ while (raw := _read_bounded_line(stream, MAX_MESSAGE_BYTES)) is not None:
170
+ if not isinstance(raw, bytes): # _OVERLONG
171
+ continue
172
+ text = raw.decode("utf-8", errors="replace").rstrip()[:MAX_STDERR_LINE_CHARS]
173
+ line = redact_text(text)
174
+ if line:
175
+ self._stderr_tail.append(line)
176
+ log.debug("server stderr: %s", line)
177
+ except (OSError, ValueError):
178
+ pass # pipe closed underneath us; stderr is diagnostic only
179
+
180
+ def _write(self, message: dict[str, Any]) -> None:
181
+ data = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
182
+ try:
183
+ self._stdin.write(data)
184
+ self._stdin.flush()
185
+ except OSError:
186
+ raise self._exited_error() from None
187
+
188
+ def _exited_error(self) -> TransportError:
189
+ with contextlib.suppress(subprocess.TimeoutExpired):
190
+ self._proc.wait(timeout=1)
191
+ code = self._proc.returncode
192
+ detail = f"server exited (code {code})" if code is not None else "server closed stdout"
193
+ if self._stderr_tail:
194
+ detail += "; stderr: " + " | ".join(list(self._stderr_tail)[-5:])
195
+ return TransportError(redact_text(detail))
196
+
197
+ def _reply_to_server_request(self, message: dict[str, Any]) -> None:
198
+ # Servers may ping or ask for roots/sampling. We answer pings, decline the rest.
199
+ if message.get("method") == "ping":
200
+ self._write({"jsonrpc": "2.0", "id": message["id"], "result": {}})
201
+ else:
202
+ self._write(
203
+ {
204
+ "jsonrpc": "2.0",
205
+ "id": message["id"],
206
+ "error": {"code": JSONRPC_METHOD_NOT_FOUND, "message": "not supported"},
207
+ }
208
+ )
209
+
210
+ def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
211
+ check_allowed(method)
212
+ request_id = self._ids.next()
213
+ self._write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
214
+ deadline = time.monotonic() + timeout
215
+ while True:
216
+ remaining = deadline - time.monotonic()
217
+ if remaining <= 0:
218
+ raise RequestTimeout(f"{method} timed out after {timeout:.0f}s")
219
+ try:
220
+ message = self._messages.get(timeout=remaining)
221
+ except queue.Empty:
222
+ continue
223
+ if message is _EOF:
224
+ raise self._exited_error()
225
+ if is_response_to(message, request_id):
226
+ return unwrap_response(message)
227
+ if isinstance(message, dict) and "method" in message and "id" in message:
228
+ self._reply_to_server_request(message)
229
+ # Anything else (notifications, stray responses) is ignored.
230
+
231
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
232
+ check_allowed(method, notification=True)
233
+ message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
234
+ if params is not None:
235
+ message["params"] = params
236
+ self._write(message)
237
+
238
+ def close(self) -> None:
239
+ # Spec shutdown: close stdin, give the server a moment, then terminate the tree.
240
+ with contextlib.suppress(OSError):
241
+ self._stdin.close()
242
+ with contextlib.suppress(subprocess.TimeoutExpired):
243
+ self._proc.wait(timeout=2)
244
+ # Always kill the tree: children (e.g. npx -> node) may outlive the parent.
245
+ _kill_tree(self._proc)
246
+
247
+
248
+ def _taskkill_path() -> str:
249
+ system_root = os.environ.get("SYSTEMROOT") or r"C:\Windows"
250
+ return str(Path(system_root) / "System32" / "taskkill.exe")
251
+
252
+
253
+ def _kill_tree(proc: subprocess.Popen[bytes]) -> None:
254
+ if sys.platform == "win32":
255
+ # Fixed system binary by absolute path; the only variable argument is our own
256
+ # child's integer PID.
257
+ subprocess.run( # noqa: S603 # nosec B603
258
+ [_taskkill_path(), "/F", "/T", "/PID", str(int(proc.pid))],
259
+ stdout=subprocess.DEVNULL,
260
+ stderr=subprocess.DEVNULL,
261
+ check=False,
262
+ shell=False,
263
+ )
264
+ else:
265
+ with contextlib.suppress(ProcessLookupError, PermissionError):
266
+ os.killpg(proc.pid, signal.SIGTERM)
267
+ try:
268
+ proc.wait(timeout=2)
269
+ except subprocess.TimeoutExpired:
270
+ with contextlib.suppress(ProcessLookupError, PermissionError):
271
+ os.killpg(proc.pid, signal.SIGKILL)
272
+ with contextlib.suppress(subprocess.TimeoutExpired):
273
+ proc.wait(timeout=2)