agentsentinel-cli 0.3.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,206 @@
1
+ """Rich terminal output for sentinel discover results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.rule import Rule
8
+ from rich.text import Text
9
+
10
+ from agentsentinel_cli.discover import DiscoveredAgent, SubnetScanStats
11
+
12
+ console = Console()
13
+
14
+ _RISK_COLOR = {
15
+ "CRITICAL": "bold red",
16
+ "HIGH": "bold orange1",
17
+ "MEDIUM": "bold yellow",
18
+ "LOW": "bold cyan",
19
+ "UNKNOWN": "dim white",
20
+ }
21
+ _RISK_ICON = {
22
+ "CRITICAL": "๐Ÿ”ด",
23
+ "HIGH": "๐ŸŸ ",
24
+ "MEDIUM": "๐ŸŸก",
25
+ "LOW": "๐ŸŸข",
26
+ "UNKNOWN": "โšช",
27
+ }
28
+ _SOURCE_LABEL = {
29
+ "process": "PROCESS",
30
+ "network": "NETWORK",
31
+ "subnet": "SUBNET",
32
+ "file": "FILE",
33
+ "docker": "DOCKER",
34
+ }
35
+
36
+
37
+ def print_subnet_progress(completed: int, total: int, current_ip: str) -> None:
38
+ """Inline progress updater for subnet scan โ€” overwrites the same line."""
39
+ pct = int(completed / total * 100) if total else 0
40
+ console.print(
41
+ f"\r [dim]Scanning {current_ip} โ€ฆ {pct}% ({completed}/{total})[/dim]",
42
+ end="",
43
+ highlight=False,
44
+ )
45
+ if completed == total:
46
+ console.print() # newline when done
47
+
48
+
49
+ def print_discover_result(
50
+ agents: list[DiscoveredAgent],
51
+ vectors: list[str],
52
+ verbose: bool = False,
53
+ subnet_stats: SubnetScanStats | None = None,
54
+ ) -> None:
55
+ console.print()
56
+ console.print(Panel.fit(
57
+ f"[bold white]AgentSentinel โ€” Discover[/bold white]\n"
58
+ f"[dim]Scanning: {' ยท '.join(vectors)}[/dim]",
59
+ border_style="bright_blue",
60
+ padding=(0, 2),
61
+ ))
62
+ console.print()
63
+
64
+ if not agents:
65
+ console.print(" [green]โœ“ No AI agents found in the scanned environment.[/green]")
66
+ if subnet_stats:
67
+ console.print(
68
+ f" [dim]Subnet scan: {subnet_stats.cidr} โ€” "
69
+ f"{subnet_stats.hosts_scanned:,} host{'s' if subnet_stats.hosts_scanned != 1 else ''} ยท "
70
+ f"{subnet_stats.open_ports_found} open port{'s' if subnet_stats.open_ports_found != 1 else ''} ยท "
71
+ f"{subnet_stats.elapsed_seconds:.1f}s[/dim]"
72
+ )
73
+ console.print()
74
+ console.print(" [dim]Tip: use [bold]--path ./your/code[/bold] to scan source files, "
75
+ "or [bold]--docker[/bold] to inspect containers.[/dim]")
76
+ console.print()
77
+ return
78
+
79
+ # Group by source
80
+ by_source: dict[str, list[DiscoveredAgent]] = {}
81
+ for agent in agents:
82
+ by_source.setdefault(agent.source, []).append(agent)
83
+
84
+ for source, source_agents in by_source.items():
85
+ console.print(Rule(
86
+ f" {_SOURCE_LABEL.get(source, source.upper())} SCAN",
87
+ style="bright_blue",
88
+ align="left",
89
+ ))
90
+ console.print()
91
+
92
+ for agent in source_agents:
93
+ _print_agent(agent, verbose=verbose)
94
+
95
+ console.print()
96
+
97
+ _print_summary(agents, subnet_stats=subnet_stats)
98
+
99
+
100
+ def _print_agent(agent: DiscoveredAgent, verbose: bool) -> None:
101
+ risk_color = _RISK_COLOR.get(agent.risk, "white")
102
+ icon = _RISK_ICON.get(agent.risk, "โšช")
103
+
104
+ # Framework + provider display
105
+ framework_str = agent.framework
106
+ if agent.provider:
107
+ framework_str = f"{agent.framework} + {agent.provider}" if agent.framework not in (
108
+ agent.provider, "Unknown"
109
+ ) else agent.provider
110
+
111
+ # Location pill
112
+ loc = f"[dim]{agent.location}[/dim]"
113
+
114
+ # Header line
115
+ name_text = Text(agent.name, style="bold white")
116
+ framework_text = Text(f"{framework_str:<28}", style="cyan")
117
+ risk_text = Text(f"{agent.risk:<8}", style=risk_color)
118
+
119
+ console.print(
120
+ f" {icon} [{risk_color}]{agent.risk:<8}[/{risk_color}] "
121
+ f"[bold white]{agent.name:<30}[/bold white] "
122
+ f"[cyan]{framework_str:<28}[/cyan] "
123
+ f"[dim]{agent.location}[/dim]"
124
+ )
125
+
126
+ # Model
127
+ if agent.model:
128
+ console.print(f" {'':>10}[dim]Model:[/dim] {agent.model}")
129
+
130
+ # API key exposure (always shown โ€” this is the "oh shit" moment)
131
+ for key in agent.api_keys:
132
+ console.print(f" {'':>10}[bold red]โš  API key exposed:[/bold red] [red]{key}[/red]")
133
+
134
+ # Live LLM connections
135
+ if agent.live_connections:
136
+ hosts = ", ".join(sorted(set(agent.live_connections)))
137
+ console.print(f" {'':>10}[dim]Live connections:[/dim] {hosts}")
138
+
139
+ # Risk reason
140
+ console.print(f" {'':>10}[dim]{agent.risk_reason}[/dim]")
141
+
142
+ # Next step suggestion
143
+ console.print(
144
+ f" {'':>10}[dim]โ†’ [/dim][bold dim]{agent.next_step}[/bold dim]"
145
+ )
146
+
147
+ console.print()
148
+
149
+
150
+ def _print_summary(
151
+ agents: list[DiscoveredAgent],
152
+ subnet_stats: SubnetScanStats | None = None,
153
+ ) -> None:
154
+ console.rule(style="bright_blue")
155
+ console.print()
156
+
157
+ total = len(agents)
158
+ critical = sum(1 for a in agents if a.risk == "CRITICAL")
159
+ high = sum(1 for a in agents if a.risk == "HIGH")
160
+ medium = sum(1 for a in agents if a.risk == "MEDIUM")
161
+ low = sum(1 for a in agents if a.risk == "LOW")
162
+ unknown = sum(1 for a in agents if a.risk == "UNKNOWN")
163
+ exposed = sum(1 for a in agents if a.api_keys)
164
+
165
+ parts: list[str] = [f"[bold white]{total}[/bold white] agent{'s' if total != 1 else ''} found"]
166
+ if critical:
167
+ parts.append(f"[bold red]{critical} CRITICAL[/bold red]")
168
+ if high:
169
+ parts.append(f"[bold orange1]{high} HIGH[/bold orange1]")
170
+ if medium:
171
+ parts.append(f"[bold yellow]{medium} MEDIUM[/bold yellow]")
172
+ if low:
173
+ parts.append(f"[bold cyan]{low} LOW[/bold cyan]")
174
+ if unknown:
175
+ parts.append(f"[dim]{unknown} UNKNOWN[/dim]")
176
+
177
+ console.print(" " + " ยท ".join(parts))
178
+
179
+ if exposed:
180
+ console.print()
181
+ console.print(
182
+ f" [bold red]โš  {exposed} agent{'s have' if exposed != 1 else ' has'} "
183
+ f"API key{'s' if exposed != 1 else ''} exposed in the environment.[/bold red]"
184
+ )
185
+ console.print(
186
+ " [dim]Exposed keys are visible to all processes on this host. "
187
+ "Rotate them and move to a secrets manager.[/dim]"
188
+ )
189
+
190
+ if critical or high:
191
+ console.print()
192
+ console.print(
193
+ " [dim]Run [bold]sentinel scan <file or --pid or --url>[/bold] "
194
+ "for a full posture analysis on any agent above.[/dim]"
195
+ )
196
+
197
+ if subnet_stats:
198
+ console.print()
199
+ console.print(
200
+ f" [dim]Subnet scan: {subnet_stats.cidr} โ€” "
201
+ f"{subnet_stats.hosts_scanned:,} hosts ยท "
202
+ f"{subnet_stats.open_ports_found} open port{'s' if subnet_stats.open_ports_found != 1 else ''} ยท "
203
+ f"{subnet_stats.elapsed_seconds:.1f}s[/dim]"
204
+ )
205
+
206
+ console.print()
@@ -0,0 +1,144 @@
1
+ """Framework fingerprinting โ€” identifies which AI agent framework a process or file uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # โ”€โ”€ Framework signals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
6
+ # Ordered most-specific first so the first match wins.
7
+ # Each entry: (signal_string, display_name, provider)
8
+
9
+ _FRAMEWORK_SIGNALS: list[tuple[str, str, str]] = [
10
+ # LangChain variants
11
+ ("langchain_anthropic", "LangChain", "Anthropic"),
12
+ ("langchain_openai", "LangChain", "OpenAI"),
13
+ ("langchain_google", "LangChain", "Google"),
14
+ ("langchain_community", "LangChain", ""),
15
+ ("langchain", "LangChain", ""),
16
+ # OpenAI
17
+ ("openai.agents", "OpenAI Agents SDK", "OpenAI"),
18
+ ("agents_sdk", "OpenAI Agents SDK", "OpenAI"),
19
+ # CrewAI
20
+ ("crewai", "CrewAI", ""),
21
+ # AutoGen
22
+ ("pyautogen", "AutoGen", "Microsoft"),
23
+ ("autogen", "AutoGen", "Microsoft"),
24
+ # MCP
25
+ ("mcp.server", "MCP Server", ""),
26
+ ("mcp.client", "MCP Client", ""),
27
+ ("mcp", "MCP", ""),
28
+ # Microsoft Semantic Kernel
29
+ ("semantic_kernel", "Semantic Kernel", "Microsoft"),
30
+ # LlamaIndex
31
+ ("llama_index", "LlamaIndex", ""),
32
+ ("llama-index", "LlamaIndex", ""),
33
+ # Haystack
34
+ ("haystack", "Haystack", "deepset"),
35
+ # PydanticAI
36
+ ("pydantic_ai", "PydanticAI", "Pydantic"),
37
+ # Google ADK
38
+ ("google.adk", "Google ADK", "Google"),
39
+ ("google_adk", "Google ADK", "Google"),
40
+ # AgentSentinel (self-monitored)
41
+ ("agentsentinel", "AgentSentinel", ""),
42
+ # Raw SDKs (least specific โ€” match last)
43
+ ("anthropic", "Anthropic SDK", "Anthropic"),
44
+ ("openai", "OpenAI SDK", "OpenAI"),
45
+ ]
46
+
47
+ # LLM API environment variable โ†’ (provider_label, key_prefix)
48
+ LLM_ENV_VARS: dict[str, tuple[str, str]] = {
49
+ "OPENAI_API_KEY": ("OpenAI", "sk-"),
50
+ "ANTHROPIC_API_KEY": ("Anthropic", "sk-ant-"),
51
+ "GOOGLE_API_KEY": ("Google", "AIza"),
52
+ "GEMINI_API_KEY": ("Google Gemini", ""),
53
+ "COHERE_API_KEY": ("Cohere", ""),
54
+ "HUGGINGFACE_TOKEN": ("HuggingFace", "hf_"),
55
+ "HF_TOKEN": ("HuggingFace", "hf_"),
56
+ "AZURE_OPENAI_API_KEY": ("Azure OpenAI", ""),
57
+ "GROQ_API_KEY": ("Groq", "gsk_"),
58
+ "MISTRAL_API_KEY": ("Mistral", ""),
59
+ "TOGETHER_API_KEY": ("Together AI", ""),
60
+ "REPLICATE_API_TOKEN": ("Replicate", "r8_"),
61
+ "PERPLEXITY_API_KEY": ("Perplexity", "pplx-"),
62
+ "BEDROCK_API_KEY": ("AWS Bedrock", ""),
63
+ }
64
+
65
+ # Known LLM API hostnames โ€” presence in process connections confirms active agent
66
+ LLM_API_HOSTS: frozenset[str] = frozenset({
67
+ "api.openai.com",
68
+ "api.anthropic.com",
69
+ "generativelanguage.googleapis.com",
70
+ "aiplatform.googleapis.com",
71
+ "api.cohere.com",
72
+ "api.groq.com",
73
+ "api.mistral.ai",
74
+ "api.together.xyz",
75
+ "api.perplexity.ai",
76
+ "bedrock-runtime.us-east-1.amazonaws.com",
77
+ "bedrock-runtime.ap-southeast-1.amazonaws.com",
78
+ })
79
+
80
+ # Model name fragments โ†’ canonical label
81
+ _MODEL_PATTERNS: list[tuple[str, str]] = [
82
+ ("claude-opus", "claude-opus"),
83
+ ("claude-sonnet", "claude-sonnet"),
84
+ ("claude-haiku", "claude-haiku"),
85
+ ("claude", "claude"),
86
+ ("gpt-4o", "gpt-4o"),
87
+ ("gpt-4", "gpt-4"),
88
+ ("gpt-3.5", "gpt-3.5-turbo"),
89
+ ("o1", "o1"),
90
+ ("o3", "o3"),
91
+ ("gemini-2", "gemini-2"),
92
+ ("gemini-1", "gemini-1"),
93
+ ("gemini", "gemini"),
94
+ ("mistral", "mistral"),
95
+ ("llama", "llama"),
96
+ ("mixtral", "mixtral"),
97
+ ("command", "command"),
98
+ ]
99
+
100
+
101
+ def detect_framework(text: str) -> tuple[str, str]:
102
+ """Return (framework_name, provider) from any text blob (cmdline, env, source).
103
+
104
+ Returns ("Unknown", "") if no framework is detected.
105
+ """
106
+ lower = text.lower()
107
+ for signal, framework, provider in _FRAMEWORK_SIGNALS:
108
+ if signal in lower:
109
+ return framework, provider
110
+ return "Unknown", ""
111
+
112
+
113
+ def detect_provider_from_env(env: dict[str, str]) -> str:
114
+ """Identify which LLM provider a process is using from its environment variables."""
115
+ for var, (provider, _) in LLM_ENV_VARS.items():
116
+ if var in env and env[var]:
117
+ return provider
118
+ return ""
119
+
120
+
121
+ def detect_model(text: str) -> str:
122
+ """Extract a model name from any text blob."""
123
+ lower = text.lower()
124
+ for fragment, label in _MODEL_PATTERNS:
125
+ if fragment in lower:
126
+ return label
127
+ return ""
128
+
129
+
130
+ def mask_key(value: str) -> str:
131
+ """Mask an API key, showing only prefix and last 4 chars."""
132
+ if len(value) <= 12:
133
+ return "***"
134
+ return f"{value[:8]}...{value[-4:]}"
135
+
136
+
137
+ def extract_api_keys(env: dict[str, str]) -> list[str]:
138
+ """Return masked API key strings from a process environment."""
139
+ found = []
140
+ for var, (provider, _) in LLM_ENV_VARS.items():
141
+ val = env.get(var, "")
142
+ if val and len(val) > 8:
143
+ found.append(f"{var}={mask_key(val)}")
144
+ return found
@@ -0,0 +1,241 @@
1
+ """Minimal MCP (Model Context Protocol) client for security scanning.
2
+
3
+ Implements the initialize + tools/list exchange over stdio and streamable-HTTP
4
+ transports. Only what an auditor needs โ€” no full MCP client dependency.
5
+ """
6
+
7
+ import dataclasses
8
+ import json
9
+ import queue
10
+ import shlex
11
+ import subprocess
12
+ import threading
13
+ from typing import Any
14
+
15
+ from agentsentinel_cli.scanner import classify_tool
16
+
17
+ _PROTOCOL_VERSION = "2024-11-05"
18
+ _CLIENT_INFO = {"name": "sentinel-mcp-scanner", "version": "0.2.0"}
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class McpToolInfo:
23
+ """A single tool exposed by an MCP server."""
24
+
25
+ name: str
26
+ description: str
27
+ input_schema: dict[str, Any]
28
+ scope: str = "read"
29
+ is_dangerous: bool = False
30
+ category: str = "other"
31
+
32
+
33
+ @dataclasses.dataclass
34
+ class McpServerInfo:
35
+ """Result of a successful MCP server connection."""
36
+
37
+ name: str
38
+ version: str
39
+ tools: list[McpToolInfo]
40
+ transport: str # "http" | "stdio"
41
+
42
+
43
+ class McpError(Exception):
44
+ """Raised when the MCP connection or protocol exchange fails."""
45
+
46
+
47
+ class McpAuthRequired(McpError):
48
+ """Raised when the server returns 401/403 and no credentials were provided."""
49
+
50
+ def __init__(self, status_code: int) -> None:
51
+ super().__init__(f"HTTP {status_code}: authentication required")
52
+ self.status_code = status_code
53
+
54
+
55
+ def _rpc(method: str, params: dict[str, Any], req_id: int) -> dict[str, Any]:
56
+ return {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
57
+
58
+
59
+ def _make_tool(raw: dict[str, Any]) -> McpToolInfo:
60
+ name = raw.get("name", "")
61
+ description = raw.get("description", "")
62
+ scope, is_dangerous, category = classify_tool(name, description)
63
+ return McpToolInfo(
64
+ name=name,
65
+ description=description,
66
+ input_schema=raw.get("inputSchema", {}),
67
+ scope=scope,
68
+ is_dangerous=is_dangerous,
69
+ category=category,
70
+ )
71
+
72
+
73
+ # โ”€โ”€ Streamable HTTP transport โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
74
+
75
+ def scan_http(
76
+ url: str,
77
+ extra_headers: dict[str, str] | None = None,
78
+ timeout: float = 10.0,
79
+ ) -> McpServerInfo:
80
+ """Scan an MCP server via streamable HTTP (POST-based) transport.
81
+
82
+ Raises McpAuthRequired if the server requires credentials.
83
+ Raises McpError for all other connection or protocol failures.
84
+ """
85
+ try:
86
+ import httpx
87
+ except ImportError:
88
+ raise McpError("httpx is required: pip install 'agentsentinel-cli[mcp]'")
89
+
90
+ base = url.rstrip("/")
91
+ headers: dict[str, str] = {
92
+ "Content-Type": "application/json",
93
+ "Accept": "application/json, text/event-stream",
94
+ }
95
+ if extra_headers:
96
+ headers.update(extra_headers)
97
+
98
+ with httpx.Client(timeout=timeout) as client:
99
+ resp = client.post(base, json=_rpc("initialize", {
100
+ "protocolVersion": _PROTOCOL_VERSION,
101
+ "capabilities": {},
102
+ "clientInfo": _CLIENT_INFO,
103
+ }, 1), headers=headers)
104
+
105
+ if resp.status_code in (401, 403):
106
+ raise McpAuthRequired(resp.status_code)
107
+ if resp.status_code == 405:
108
+ raise McpError(
109
+ "Server returned 405 Method Not Allowed. "
110
+ "This server may use the older SSE transport (GET /sse). "
111
+ "Try scanning it locally with: sentinel mcp scan --stdio 'python server.py'"
112
+ )
113
+
114
+ content_type = resp.headers.get("content-type", "")
115
+ if "text/event-stream" in content_type:
116
+ raise McpError(
117
+ "Server responded with SSE stream (older transport). "
118
+ "Try scanning it locally with: sentinel mcp scan --stdio 'python server.py'"
119
+ )
120
+
121
+ resp.raise_for_status()
122
+ init_data = _parse_rpc_response(resp.text)
123
+
124
+ # Send initialized notification โ€” no response expected
125
+ client.post(base, json={
126
+ "jsonrpc": "2.0",
127
+ "method": "notifications/initialized",
128
+ "params": {},
129
+ }, headers=headers)
130
+
131
+ resp = client.post(base, json=_rpc("tools/list", {}, 2), headers=headers)
132
+ resp.raise_for_status()
133
+ tools_data = _parse_rpc_response(resp.text)
134
+
135
+ server_meta = init_data.get("result", {}).get("serverInfo", {})
136
+ raw_tools = tools_data.get("result", {}).get("tools", [])
137
+
138
+ return McpServerInfo(
139
+ name=server_meta.get("name", "unknown"),
140
+ version=server_meta.get("version", "unknown"),
141
+ tools=[_make_tool(t) for t in raw_tools],
142
+ transport="http",
143
+ )
144
+
145
+
146
+ def _parse_rpc_response(text: str) -> dict[str, Any]:
147
+ """Parse a JSON-RPC response body, unwrapping SSE data-lines if present."""
148
+ text = text.strip()
149
+ if not text:
150
+ raise McpError("Empty response from MCP server")
151
+ if text.startswith("data:") or text.startswith("event:"):
152
+ for line in text.splitlines():
153
+ if line.startswith("data:"):
154
+ text = line[5:].strip()
155
+ break
156
+ try:
157
+ return json.loads(text)
158
+ except json.JSONDecodeError as exc:
159
+ raise McpError(f"Invalid JSON from server: {exc}") from exc
160
+
161
+
162
+ # โ”€โ”€ Stdio transport โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
163
+
164
+ def scan_stdio(command: str, timeout: float = 15.0) -> McpServerInfo:
165
+ """Launch an MCP server as a subprocess and scan it via stdio transport."""
166
+ args = shlex.split(command)
167
+ try:
168
+ proc = subprocess.Popen(
169
+ args,
170
+ stdin=subprocess.PIPE,
171
+ stdout=subprocess.PIPE,
172
+ stderr=subprocess.DEVNULL,
173
+ text=True,
174
+ )
175
+ except FileNotFoundError as exc:
176
+ raise McpError(f"Command not found: {args[0]}") from exc
177
+
178
+ try:
179
+ return _stdio_exchange(proc, timeout)
180
+ finally:
181
+ proc.terminate()
182
+ try:
183
+ proc.wait(timeout=3.0)
184
+ except subprocess.TimeoutExpired:
185
+ proc.kill()
186
+
187
+
188
+ def _stdio_send(proc: subprocess.Popen, obj: dict[str, Any]) -> None:
189
+ assert proc.stdin is not None
190
+ proc.stdin.write(json.dumps(obj) + "\n")
191
+ proc.stdin.flush()
192
+
193
+
194
+ def _stdio_recv(proc: subprocess.Popen, timeout: float) -> dict[str, Any]:
195
+ """Read one newline-delimited JSON-RPC message from subprocess stdout."""
196
+ assert proc.stdout is not None
197
+ result_q: queue.Queue[str | None] = queue.Queue()
198
+
199
+ def _reader() -> None:
200
+ try:
201
+ result_q.put(proc.stdout.readline()) # type: ignore[union-attr]
202
+ except Exception:
203
+ result_q.put(None)
204
+
205
+ threading.Thread(target=_reader, daemon=True).start()
206
+
207
+ try:
208
+ line = result_q.get(timeout=timeout)
209
+ except queue.Empty:
210
+ raise McpError(f"No response from stdio server within {timeout}s")
211
+
212
+ if not line or not line.strip():
213
+ raise McpError("MCP server closed stdout without responding")
214
+ try:
215
+ return json.loads(line.strip())
216
+ except json.JSONDecodeError as exc:
217
+ raise McpError(f"Invalid JSON from stdio server: {exc}") from exc
218
+
219
+
220
+ def _stdio_exchange(proc: subprocess.Popen, timeout: float) -> McpServerInfo:
221
+ _stdio_send(proc, _rpc("initialize", {
222
+ "protocolVersion": _PROTOCOL_VERSION,
223
+ "capabilities": {},
224
+ "clientInfo": _CLIENT_INFO,
225
+ }, 1))
226
+ init_resp = _stdio_recv(proc, timeout)
227
+
228
+ _stdio_send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
229
+
230
+ _stdio_send(proc, _rpc("tools/list", {}, 2))
231
+ tools_resp = _stdio_recv(proc, timeout)
232
+
233
+ server_meta = init_resp.get("result", {}).get("serverInfo", {})
234
+ raw_tools = tools_resp.get("result", {}).get("tools", [])
235
+
236
+ return McpServerInfo(
237
+ name=server_meta.get("name", "unknown"),
238
+ version=server_meta.get("version", "unknown"),
239
+ tools=[_make_tool(t) for t in raw_tools],
240
+ transport="stdio",
241
+ )