onetool-mcp 1.0.0b1__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.
- bench/__init__.py +5 -0
- bench/cli.py +69 -0
- bench/harness/__init__.py +66 -0
- bench/harness/client.py +692 -0
- bench/harness/config.py +397 -0
- bench/harness/csv_writer.py +109 -0
- bench/harness/evaluate.py +512 -0
- bench/harness/metrics.py +283 -0
- bench/harness/runner.py +899 -0
- bench/py.typed +0 -0
- bench/reporter.py +629 -0
- bench/run.py +487 -0
- bench/secrets.py +101 -0
- bench/utils.py +16 -0
- onetool/__init__.py +4 -0
- onetool/cli.py +391 -0
- onetool/py.typed +0 -0
- onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
- onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
- onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
- onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
- onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
- ot/__init__.py +37 -0
- ot/__main__.py +6 -0
- ot/_cli.py +107 -0
- ot/_tui.py +53 -0
- ot/config/__init__.py +46 -0
- ot/config/defaults/bench.yaml +4 -0
- ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
- ot/config/defaults/diagram-templates/c4-context.puml +30 -0
- ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
- ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
- ot/config/defaults/diagram-templates/microservices.d2 +81 -0
- ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
- ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
- ot/config/defaults/onetool.yaml +25 -0
- ot/config/defaults/prompts.yaml +97 -0
- ot/config/defaults/servers.yaml +7 -0
- ot/config/defaults/snippets.yaml +4 -0
- ot/config/defaults/tool_templates/__init__.py +7 -0
- ot/config/defaults/tool_templates/extension.py +52 -0
- ot/config/defaults/tool_templates/isolated.py +61 -0
- ot/config/dynamic.py +121 -0
- ot/config/global_templates/__init__.py +2 -0
- ot/config/global_templates/bench-secrets-template.yaml +6 -0
- ot/config/global_templates/bench.yaml +9 -0
- ot/config/global_templates/onetool.yaml +27 -0
- ot/config/global_templates/secrets-template.yaml +44 -0
- ot/config/global_templates/servers.yaml +18 -0
- ot/config/global_templates/snippets.yaml +235 -0
- ot/config/loader.py +1087 -0
- ot/config/mcp.py +145 -0
- ot/config/secrets.py +190 -0
- ot/config/tool_config.py +125 -0
- ot/decorators.py +116 -0
- ot/executor/__init__.py +35 -0
- ot/executor/base.py +16 -0
- ot/executor/fence_processor.py +83 -0
- ot/executor/linter.py +142 -0
- ot/executor/pack_proxy.py +260 -0
- ot/executor/param_resolver.py +140 -0
- ot/executor/pep723.py +288 -0
- ot/executor/result_store.py +369 -0
- ot/executor/runner.py +496 -0
- ot/executor/simple.py +163 -0
- ot/executor/tool_loader.py +396 -0
- ot/executor/validator.py +398 -0
- ot/executor/worker_pool.py +388 -0
- ot/executor/worker_proxy.py +189 -0
- ot/http_client.py +145 -0
- ot/logging/__init__.py +37 -0
- ot/logging/config.py +315 -0
- ot/logging/entry.py +213 -0
- ot/logging/format.py +188 -0
- ot/logging/span.py +349 -0
- ot/meta.py +1555 -0
- ot/paths.py +453 -0
- ot/prompts.py +218 -0
- ot/proxy/__init__.py +21 -0
- ot/proxy/manager.py +396 -0
- ot/py.typed +0 -0
- ot/registry/__init__.py +189 -0
- ot/registry/models.py +57 -0
- ot/registry/parser.py +269 -0
- ot/registry/registry.py +413 -0
- ot/server.py +315 -0
- ot/shortcuts/__init__.py +15 -0
- ot/shortcuts/aliases.py +87 -0
- ot/shortcuts/snippets.py +258 -0
- ot/stats/__init__.py +35 -0
- ot/stats/html.py +250 -0
- ot/stats/jsonl_writer.py +283 -0
- ot/stats/reader.py +354 -0
- ot/stats/timing.py +57 -0
- ot/support.py +63 -0
- ot/tools.py +114 -0
- ot/utils/__init__.py +81 -0
- ot/utils/batch.py +161 -0
- ot/utils/cache.py +120 -0
- ot/utils/deps.py +403 -0
- ot/utils/exceptions.py +23 -0
- ot/utils/factory.py +179 -0
- ot/utils/format.py +65 -0
- ot/utils/http.py +202 -0
- ot/utils/platform.py +45 -0
- ot/utils/sanitize.py +130 -0
- ot/utils/truncate.py +69 -0
- ot_tools/__init__.py +4 -0
- ot_tools/_convert/__init__.py +12 -0
- ot_tools/_convert/excel.py +279 -0
- ot_tools/_convert/pdf.py +254 -0
- ot_tools/_convert/powerpoint.py +268 -0
- ot_tools/_convert/utils.py +358 -0
- ot_tools/_convert/word.py +283 -0
- ot_tools/brave_search.py +604 -0
- ot_tools/code_search.py +736 -0
- ot_tools/context7.py +495 -0
- ot_tools/convert.py +614 -0
- ot_tools/db.py +415 -0
- ot_tools/diagram.py +1604 -0
- ot_tools/diagram.yaml +167 -0
- ot_tools/excel.py +1372 -0
- ot_tools/file.py +1348 -0
- ot_tools/firecrawl.py +732 -0
- ot_tools/grounding_search.py +646 -0
- ot_tools/package.py +604 -0
- ot_tools/py.typed +0 -0
- ot_tools/ripgrep.py +544 -0
- ot_tools/scaffold.py +471 -0
- ot_tools/transform.py +213 -0
- ot_tools/web_fetch.py +384 -0
bench/harness/client.py
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""MCP client utilities for connecting to MCP servers via stdio and HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from mcp import types
|
|
14
|
+
from mcp.client.session import ClientSession
|
|
15
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
16
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
17
|
+
|
|
18
|
+
from ot.logging import LogSpan
|
|
19
|
+
from ot.utils import flatten_exception_group
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
|
|
24
|
+
from openai.types.chat import ChatCompletionToolParam
|
|
25
|
+
|
|
26
|
+
from bench.harness.config import ServerConfig
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Default timeout for MCP operations (30 seconds)
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0
|
|
31
|
+
|
|
32
|
+
# Known server error hints for better diagnostics
|
|
33
|
+
_SERVER_ERROR_HINTS: dict[str, str] = {
|
|
34
|
+
"chunkhound": (
|
|
35
|
+
"ChunkHound requires: 1) Valid OPENAI_API_KEY, "
|
|
36
|
+
"2) Indexed codebase (run 'uvx chunkhound index <path>' first)"
|
|
37
|
+
),
|
|
38
|
+
"github": (
|
|
39
|
+
"GitHub Copilot MCP requires a token with 'Copilot Requests' permission "
|
|
40
|
+
"from an account with active Copilot subscription"
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _enhance_error_message(server_name: str, error: str) -> str:
|
|
46
|
+
"""Add helpful context to known server errors."""
|
|
47
|
+
hint = _SERVER_ERROR_HINTS.get(server_name)
|
|
48
|
+
if hint and ("closed" in error.lower() or "401" in error or "unauthorized" in error.lower()):
|
|
49
|
+
return f"{error}. Hint: {hint}"
|
|
50
|
+
return error
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class MCPConnection:
|
|
55
|
+
"""Represents an active MCP connection."""
|
|
56
|
+
|
|
57
|
+
session: ClientSession
|
|
58
|
+
tools: list[types.Tool]
|
|
59
|
+
server_name: str = ""
|
|
60
|
+
instructions: str | None = None
|
|
61
|
+
prompts: list[types.Prompt] = field(default_factory=list)
|
|
62
|
+
resources: list[types.Resource] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ServerHealth:
|
|
67
|
+
"""Health check result for an MCP server."""
|
|
68
|
+
|
|
69
|
+
name: str
|
|
70
|
+
healthy: bool
|
|
71
|
+
tool_count: int = 0
|
|
72
|
+
error: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class MultiServerConnection:
|
|
77
|
+
"""Represents connections to multiple MCP servers."""
|
|
78
|
+
|
|
79
|
+
connections: dict[str, MCPConnection] = field(default_factory=dict)
|
|
80
|
+
health: list[ServerHealth] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def all_tools(self) -> list[types.Tool]:
|
|
84
|
+
"""Get all tools from all connected servers."""
|
|
85
|
+
tools: list[types.Tool] = []
|
|
86
|
+
for conn in self.connections.values():
|
|
87
|
+
tools.extend(conn.tools)
|
|
88
|
+
return tools
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def all_instructions(self) -> list[tuple[str, str]]:
|
|
92
|
+
"""Get instructions from all connected servers.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of (server_name, instructions) tuples for servers with instructions.
|
|
96
|
+
"""
|
|
97
|
+
instructions: list[tuple[str, str]] = []
|
|
98
|
+
for name, conn in self.connections.items():
|
|
99
|
+
if conn.instructions:
|
|
100
|
+
instructions.append((name, conn.instructions))
|
|
101
|
+
return instructions
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def all_prompts(self) -> list[tuple[str, types.Prompt]]:
|
|
105
|
+
"""Get prompts from all connected servers.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of (server_name, prompt) tuples.
|
|
109
|
+
"""
|
|
110
|
+
prompts: list[tuple[str, types.Prompt]] = []
|
|
111
|
+
for name, conn in self.connections.items():
|
|
112
|
+
for prompt in conn.prompts:
|
|
113
|
+
prompts.append((name, prompt))
|
|
114
|
+
return prompts
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def all_resources(self) -> list[tuple[str, types.Resource]]:
|
|
118
|
+
"""Get resources from all connected servers.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of (server_name, resource) tuples.
|
|
122
|
+
"""
|
|
123
|
+
resources: list[tuple[str, types.Resource]] = []
|
|
124
|
+
for name, conn in self.connections.items():
|
|
125
|
+
for resource in conn.resources:
|
|
126
|
+
resources.append((name, resource))
|
|
127
|
+
return resources
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def healthy_count(self) -> int:
|
|
131
|
+
"""Count of healthy servers."""
|
|
132
|
+
return sum(1 for h in self.health if h.healthy)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def failed_count(self) -> int:
|
|
136
|
+
"""Count of failed servers."""
|
|
137
|
+
return sum(1 for h in self.health if not h.healthy)
|
|
138
|
+
|
|
139
|
+
def get_session_for_tool(self, tool_name: str) -> ClientSession | None:
|
|
140
|
+
"""Get the session that owns a specific tool."""
|
|
141
|
+
for conn in self.connections.values():
|
|
142
|
+
if any(t.name == tool_name for t in conn.tools):
|
|
143
|
+
return conn.session
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def call_tool(
|
|
148
|
+
session: ClientSession,
|
|
149
|
+
tool_name: str,
|
|
150
|
+
arguments: dict[str, Any],
|
|
151
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""Call an MCP tool and return the text result.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
session: Active MCP client session.
|
|
157
|
+
tool_name: Name of the tool to call.
|
|
158
|
+
arguments: Arguments to pass to the tool.
|
|
159
|
+
timeout: Timeout for the call in seconds.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Text content from the tool response.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
TimeoutError: If the call times out.
|
|
166
|
+
RuntimeError: If the tool returns an error or no text content.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
result = await asyncio.wait_for(
|
|
170
|
+
session.call_tool(tool_name, arguments),
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
)
|
|
173
|
+
except TimeoutError:
|
|
174
|
+
logger.error(
|
|
175
|
+
f"Tool call timeout | tool={tool_name} | timeout={timeout}s | "
|
|
176
|
+
f"args={str(arguments)[:100]}"
|
|
177
|
+
)
|
|
178
|
+
raise TimeoutError(f"Tool {tool_name} timed out after {timeout}s") from None
|
|
179
|
+
|
|
180
|
+
if result.isError:
|
|
181
|
+
error_text = ""
|
|
182
|
+
for content in result.content:
|
|
183
|
+
if isinstance(content, types.TextContent):
|
|
184
|
+
error_text += content.text
|
|
185
|
+
# Log structured error details
|
|
186
|
+
logger.warning(
|
|
187
|
+
f"Tool returned error | tool={tool_name} | "
|
|
188
|
+
f"error={error_text[:200]} | args={str(arguments)[:100]}"
|
|
189
|
+
)
|
|
190
|
+
# "No results" type errors are not fatal - return as message
|
|
191
|
+
error_lower = error_text.lower()
|
|
192
|
+
if any(
|
|
193
|
+
phrase in error_lower
|
|
194
|
+
for phrase in ["no web results", "no results", "no matches", "not found"]
|
|
195
|
+
):
|
|
196
|
+
return "Search returned no results for this query."
|
|
197
|
+
raise RuntimeError(f"Tool {tool_name} returned error: {error_text}")
|
|
198
|
+
|
|
199
|
+
text_parts: list[str] = []
|
|
200
|
+
for content in result.content:
|
|
201
|
+
if isinstance(content, types.TextContent):
|
|
202
|
+
text_parts.append(content.text)
|
|
203
|
+
elif hasattr(content, "data"):
|
|
204
|
+
# Handle binary/image content
|
|
205
|
+
text_parts.append(f"[Binary content: {type(content).__name__}]")
|
|
206
|
+
|
|
207
|
+
if not text_parts:
|
|
208
|
+
logger.warning(
|
|
209
|
+
f"Tool returned empty content | tool={tool_name} | "
|
|
210
|
+
f"content_types={[type(c).__name__ for c in result.content]}"
|
|
211
|
+
)
|
|
212
|
+
return "Tool returned empty response."
|
|
213
|
+
|
|
214
|
+
return "\n".join(text_parts)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def mcp_tools_to_openai(
|
|
220
|
+
tools: list[types.Tool],
|
|
221
|
+
duplicate: int = 1,
|
|
222
|
+
) -> list[ChatCompletionToolParam]:
|
|
223
|
+
"""Convert MCP tools to OpenAI ChatCompletionToolParam format.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
tools: List of MCP Tool objects from MCPConnection.tools.
|
|
227
|
+
duplicate: Number of times to duplicate each tool (simulates many MCP servers).
|
|
228
|
+
Each duplicate gets a suffix like _2, _3, etc.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of tool definitions in OpenAI function calling format.
|
|
232
|
+
"""
|
|
233
|
+
openai_tools: list[ChatCompletionToolParam] = []
|
|
234
|
+
|
|
235
|
+
for copy_num in range(duplicate):
|
|
236
|
+
suffix = "" if copy_num == 0 else f"_{copy_num + 1}"
|
|
237
|
+
for tool in tools:
|
|
238
|
+
parameters = tool.inputSchema
|
|
239
|
+
|
|
240
|
+
openai_tool: ChatCompletionToolParam = {
|
|
241
|
+
"type": "function",
|
|
242
|
+
"function": {
|
|
243
|
+
"name": f"{tool.name}{suffix}",
|
|
244
|
+
"description": tool.description or "",
|
|
245
|
+
"parameters": parameters,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
openai_tools.append(openai_tool)
|
|
249
|
+
|
|
250
|
+
return openai_tools
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def multi_server_tools_to_openai(
|
|
254
|
+
multi: MultiServerConnection,
|
|
255
|
+
) -> tuple[list[ChatCompletionToolParam], dict[str, tuple[str, str]]]:
|
|
256
|
+
"""Convert tools from multiple MCP servers to OpenAI format with prefixed names.
|
|
257
|
+
|
|
258
|
+
When multiple servers are connected, tool names are prefixed with the server name
|
|
259
|
+
to avoid collisions (e.g., github and supabase both having 'create_branch').
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
multi: MultiServerConnection with connected servers.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Tuple of:
|
|
266
|
+
- List of tool definitions in OpenAI function calling format
|
|
267
|
+
- Mapping from prefixed tool name to (server_name, original_tool_name)
|
|
268
|
+
"""
|
|
269
|
+
openai_tools: list[ChatCompletionToolParam] = []
|
|
270
|
+
tool_mapping: dict[str, tuple[str, str]] = {}
|
|
271
|
+
|
|
272
|
+
# Only prefix if multiple servers are connected
|
|
273
|
+
use_prefix = len(multi.connections) > 1
|
|
274
|
+
|
|
275
|
+
for server_name, conn in multi.connections.items():
|
|
276
|
+
for tool in conn.tools:
|
|
277
|
+
# Create prefixed name for multi-server, original name for single-server
|
|
278
|
+
prefixed_name = f"{server_name}__{tool.name}" if use_prefix else tool.name
|
|
279
|
+
|
|
280
|
+
parameters = tool.inputSchema
|
|
281
|
+
|
|
282
|
+
# Build description with server context for multi-server
|
|
283
|
+
description = tool.description or ""
|
|
284
|
+
if use_prefix:
|
|
285
|
+
description = f"[{server_name}] {description}"
|
|
286
|
+
|
|
287
|
+
openai_tool: ChatCompletionToolParam = {
|
|
288
|
+
"type": "function",
|
|
289
|
+
"function": {
|
|
290
|
+
"name": prefixed_name,
|
|
291
|
+
"description": description,
|
|
292
|
+
"parameters": parameters,
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
openai_tools.append(openai_tool)
|
|
296
|
+
tool_mapping[prefixed_name] = (server_name, tool.name)
|
|
297
|
+
|
|
298
|
+
return openai_tools, tool_mapping
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@asynccontextmanager
|
|
302
|
+
async def connect_to_server(
|
|
303
|
+
name: str,
|
|
304
|
+
config: ServerConfig,
|
|
305
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
306
|
+
) -> AsyncIterator[MCPConnection]:
|
|
307
|
+
"""Connect to any MCP server based on its configuration.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
name: Server name for identification.
|
|
311
|
+
config: Server configuration.
|
|
312
|
+
timeout: Timeout for operations in seconds.
|
|
313
|
+
|
|
314
|
+
Yields:
|
|
315
|
+
MCPConnection with active session and available tools.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
RuntimeError: If connection fails or required config is missing.
|
|
319
|
+
"""
|
|
320
|
+
if config.type == "http":
|
|
321
|
+
if not config.url:
|
|
322
|
+
raise RuntimeError(f"Server {name}: HTTP server requires url")
|
|
323
|
+
|
|
324
|
+
# Headers should be fully expanded by config loader - error if not
|
|
325
|
+
headers = {}
|
|
326
|
+
for key, value in config.headers.items():
|
|
327
|
+
# Check if value still contains unexpanded ${VAR} pattern
|
|
328
|
+
if "${" in value and "}" in value:
|
|
329
|
+
match = re.search(r"\$\{([^}]+)\}", value)
|
|
330
|
+
if match:
|
|
331
|
+
var_name = match.group(1)
|
|
332
|
+
raise RuntimeError(
|
|
333
|
+
f"Server {name}: Unexpanded variable ${{{var_name}}} in header. "
|
|
334
|
+
f"Add {var_name} to .onetool/config/bench-secrets.yaml"
|
|
335
|
+
)
|
|
336
|
+
headers[key] = value
|
|
337
|
+
|
|
338
|
+
import httpx
|
|
339
|
+
|
|
340
|
+
# Create httpx client with headers and timeout
|
|
341
|
+
http_client = httpx.AsyncClient(
|
|
342
|
+
headers=headers,
|
|
343
|
+
timeout=httpx.Timeout(timeout, read=timeout * 10),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
async with (
|
|
348
|
+
streamable_http_client(
|
|
349
|
+
url=config.url,
|
|
350
|
+
http_client=http_client,
|
|
351
|
+
) as (read, write, _get_session_id),
|
|
352
|
+
ClientSession(read, write) as session,
|
|
353
|
+
):
|
|
354
|
+
init_result = await asyncio.wait_for(
|
|
355
|
+
session.initialize(), timeout=timeout
|
|
356
|
+
)
|
|
357
|
+
# Fetch all server capabilities in parallel
|
|
358
|
+
tools_result, prompts_result, resources_result = await asyncio.gather(
|
|
359
|
+
asyncio.wait_for(session.list_tools(), timeout=timeout),
|
|
360
|
+
asyncio.wait_for(session.list_prompts(), timeout=timeout),
|
|
361
|
+
asyncio.wait_for(session.list_resources(), timeout=timeout),
|
|
362
|
+
return_exceptions=True,
|
|
363
|
+
)
|
|
364
|
+
# Handle potential errors (some servers may not support all features)
|
|
365
|
+
tools = (
|
|
366
|
+
tools_result.tools
|
|
367
|
+
if not isinstance(tools_result, Exception)
|
|
368
|
+
else []
|
|
369
|
+
)
|
|
370
|
+
prompts = (
|
|
371
|
+
prompts_result.prompts
|
|
372
|
+
if not isinstance(prompts_result, Exception)
|
|
373
|
+
else []
|
|
374
|
+
)
|
|
375
|
+
resources = (
|
|
376
|
+
resources_result.resources
|
|
377
|
+
if not isinstance(resources_result, Exception)
|
|
378
|
+
else []
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
yield MCPConnection(
|
|
382
|
+
session=session,
|
|
383
|
+
tools=tools,
|
|
384
|
+
server_name=name,
|
|
385
|
+
instructions=init_result.instructions,
|
|
386
|
+
prompts=prompts,
|
|
387
|
+
resources=resources,
|
|
388
|
+
)
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
# Connection was cancelled (e.g., server closed while waiting)
|
|
391
|
+
logger.debug(f"HTTP connection cancelled | name={name}")
|
|
392
|
+
except BaseExceptionGroup as eg:
|
|
393
|
+
# Handle race condition where server tries to send response
|
|
394
|
+
# after client has closed the connection.
|
|
395
|
+
from anyio import BrokenResourceError, ClosedResourceError
|
|
396
|
+
|
|
397
|
+
leaf_exceptions = flatten_exception_group(eg)
|
|
398
|
+
connection_errors = [
|
|
399
|
+
e
|
|
400
|
+
for e in leaf_exceptions
|
|
401
|
+
if isinstance(
|
|
402
|
+
e,
|
|
403
|
+
(
|
|
404
|
+
ClosedResourceError,
|
|
405
|
+
BrokenResourceError,
|
|
406
|
+
asyncio.CancelledError,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
]
|
|
410
|
+
if len(connection_errors) == len(leaf_exceptions):
|
|
411
|
+
logger.debug(
|
|
412
|
+
f"HTTP connection closed during cleanup | name={name} | "
|
|
413
|
+
f"errors={len(connection_errors)}"
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
raise
|
|
417
|
+
finally:
|
|
418
|
+
await http_client.aclose()
|
|
419
|
+
|
|
420
|
+
elif config.type == "stdio":
|
|
421
|
+
if not config.command:
|
|
422
|
+
raise RuntimeError(f"Server {name}: stdio server requires command")
|
|
423
|
+
|
|
424
|
+
# Build environment: PATH only + explicit config.env
|
|
425
|
+
from bench.harness.config import expand_subprocess_env
|
|
426
|
+
|
|
427
|
+
env = {"PATH": os.environ.get("PATH", "")}
|
|
428
|
+
for key, value in config.env.items():
|
|
429
|
+
env[key] = expand_subprocess_env(value)
|
|
430
|
+
|
|
431
|
+
server_params = StdioServerParameters(
|
|
432
|
+
command=config.command,
|
|
433
|
+
args=config.args,
|
|
434
|
+
env=env,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
logger.debug(
|
|
438
|
+
f"Starting stdio server | name={name} | "
|
|
439
|
+
f"command={config.command} {' '.join(config.args[:3])}... | timeout={timeout}s"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
# Timeout applies to entire case execution (connection + tool calls)
|
|
444
|
+
async with asyncio.timeout(timeout):
|
|
445
|
+
async with (
|
|
446
|
+
stdio_client(server_params) as (read, write),
|
|
447
|
+
ClientSession(read, write) as session,
|
|
448
|
+
):
|
|
449
|
+
init_result = await session.initialize()
|
|
450
|
+
# Fetch all server capabilities in parallel
|
|
451
|
+
(
|
|
452
|
+
tools_result,
|
|
453
|
+
prompts_result,
|
|
454
|
+
resources_result,
|
|
455
|
+
) = await asyncio.gather(
|
|
456
|
+
session.list_tools(),
|
|
457
|
+
session.list_prompts(),
|
|
458
|
+
session.list_resources(),
|
|
459
|
+
return_exceptions=True,
|
|
460
|
+
)
|
|
461
|
+
# Handle potential errors (some servers may not support all features)
|
|
462
|
+
tools = (
|
|
463
|
+
tools_result.tools
|
|
464
|
+
if not isinstance(tools_result, Exception)
|
|
465
|
+
else []
|
|
466
|
+
)
|
|
467
|
+
prompts = (
|
|
468
|
+
prompts_result.prompts
|
|
469
|
+
if not isinstance(prompts_result, Exception)
|
|
470
|
+
else []
|
|
471
|
+
)
|
|
472
|
+
resources = (
|
|
473
|
+
resources_result.resources
|
|
474
|
+
if not isinstance(resources_result, Exception)
|
|
475
|
+
else []
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
logger.debug(
|
|
479
|
+
f"Server ready | name={name} | tools={len(tools)} | "
|
|
480
|
+
f"prompts={len(prompts)} | resources={len(resources)}"
|
|
481
|
+
)
|
|
482
|
+
yield MCPConnection(
|
|
483
|
+
session=session,
|
|
484
|
+
tools=tools,
|
|
485
|
+
server_name=name,
|
|
486
|
+
instructions=init_result.instructions,
|
|
487
|
+
prompts=prompts,
|
|
488
|
+
resources=resources,
|
|
489
|
+
)
|
|
490
|
+
except asyncio.CancelledError:
|
|
491
|
+
# Connection was cancelled (e.g., server closed while client waiting)
|
|
492
|
+
logger.debug(f"Connection cancelled | name={name}")
|
|
493
|
+
except BaseExceptionGroup as eg:
|
|
494
|
+
# Handle race condition where server tries to send response
|
|
495
|
+
# after client has closed the connection. Extract leaf errors
|
|
496
|
+
# and check if they're all connection-related.
|
|
497
|
+
from anyio import BrokenResourceError, ClosedResourceError
|
|
498
|
+
|
|
499
|
+
leaf_exceptions = flatten_exception_group(eg)
|
|
500
|
+
connection_errors = [
|
|
501
|
+
e
|
|
502
|
+
for e in leaf_exceptions
|
|
503
|
+
if isinstance(
|
|
504
|
+
e,
|
|
505
|
+
(
|
|
506
|
+
ClosedResourceError,
|
|
507
|
+
BrokenResourceError,
|
|
508
|
+
asyncio.CancelledError,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
]
|
|
512
|
+
if len(connection_errors) == len(leaf_exceptions):
|
|
513
|
+
# All errors are connection-related, suppress them
|
|
514
|
+
logger.debug(
|
|
515
|
+
f"Connection closed during cleanup | name={name} | "
|
|
516
|
+
f"errors={len(connection_errors)}"
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
# Some errors are not connection-related, re-raise
|
|
520
|
+
raise
|
|
521
|
+
except TimeoutError:
|
|
522
|
+
logger.error(
|
|
523
|
+
f"Server connection timeout | name={name} | timeout={timeout}s | "
|
|
524
|
+
f"command={config.command}"
|
|
525
|
+
)
|
|
526
|
+
raise TimeoutError(
|
|
527
|
+
f"Server {name} connection timed out after {timeout}s"
|
|
528
|
+
) from None
|
|
529
|
+
else:
|
|
530
|
+
raise RuntimeError(f"Server {name}: Unknown server type {config.type}")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class ServerConnectionCallback:
|
|
534
|
+
"""Callback for server connection progress."""
|
|
535
|
+
|
|
536
|
+
def on_connecting(self, name: str) -> None:
|
|
537
|
+
"""Called when starting to connect to a server."""
|
|
538
|
+
|
|
539
|
+
def on_connected(self, name: str, tool_count: int) -> None:
|
|
540
|
+
"""Called when successfully connected to a server."""
|
|
541
|
+
|
|
542
|
+
def on_failed(self, name: str, error: str) -> None:
|
|
543
|
+
"""Called when connection to a server fails."""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class MultiServerContextManager:
|
|
547
|
+
"""Context manager for connecting to multiple servers."""
|
|
548
|
+
|
|
549
|
+
def __init__(
|
|
550
|
+
self,
|
|
551
|
+
servers: dict[str, ServerConfig],
|
|
552
|
+
server_names: list[str],
|
|
553
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
554
|
+
on_progress: ServerConnectionCallback | None = None,
|
|
555
|
+
) -> None:
|
|
556
|
+
"""Initialize multi-server connection.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
servers: All available server configs.
|
|
560
|
+
server_names: Names of servers to connect to.
|
|
561
|
+
timeout: Timeout for each server connection.
|
|
562
|
+
on_progress: Optional callback for connection progress.
|
|
563
|
+
"""
|
|
564
|
+
self.servers = servers
|
|
565
|
+
self.server_names = server_names
|
|
566
|
+
self.timeout = timeout
|
|
567
|
+
self.on_progress = on_progress
|
|
568
|
+
self._contexts: list[Any] = []
|
|
569
|
+
self._result: MultiServerConnection | None = None
|
|
570
|
+
|
|
571
|
+
async def __aenter__(self) -> MultiServerConnection:
|
|
572
|
+
"""Connect to all servers in parallel and return combined connection."""
|
|
573
|
+
result = MultiServerConnection()
|
|
574
|
+
|
|
575
|
+
# Separate valid and invalid server names
|
|
576
|
+
valid_servers: list[tuple[str, ServerConfig]] = []
|
|
577
|
+
for name in self.server_names:
|
|
578
|
+
if name not in self.servers:
|
|
579
|
+
error_msg = f"Server '{name}' not found in config"
|
|
580
|
+
result.health.append(
|
|
581
|
+
ServerHealth(name=name, healthy=False, error=error_msg)
|
|
582
|
+
)
|
|
583
|
+
logger.warning(error_msg)
|
|
584
|
+
if self.on_progress:
|
|
585
|
+
self.on_progress.on_failed(name, error_msg)
|
|
586
|
+
else:
|
|
587
|
+
valid_servers.append((name, self.servers[name]))
|
|
588
|
+
if self.on_progress:
|
|
589
|
+
self.on_progress.on_connecting(name)
|
|
590
|
+
|
|
591
|
+
if not valid_servers:
|
|
592
|
+
self._result = result
|
|
593
|
+
return result
|
|
594
|
+
|
|
595
|
+
async def connect_one(
|
|
596
|
+
name: str, config: ServerConfig
|
|
597
|
+
) -> tuple[str, MCPConnection | Exception]:
|
|
598
|
+
"""Connect to a single server, returning result or exception."""
|
|
599
|
+
try:
|
|
600
|
+
ctx = connect_to_server(name, config, timeout=self.timeout)
|
|
601
|
+
conn = await ctx.__aenter__()
|
|
602
|
+
self._contexts.append(ctx)
|
|
603
|
+
return (name, conn)
|
|
604
|
+
except BaseExceptionGroup as eg:
|
|
605
|
+
leaf_errors: list[str] = []
|
|
606
|
+
for exc in eg.exceptions:
|
|
607
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
608
|
+
for inner in exc.exceptions:
|
|
609
|
+
leaf_errors.append(str(inner))
|
|
610
|
+
else:
|
|
611
|
+
leaf_errors.append(str(exc))
|
|
612
|
+
return (
|
|
613
|
+
name,
|
|
614
|
+
Exception("; ".join(leaf_errors) if leaf_errors else str(eg)),
|
|
615
|
+
)
|
|
616
|
+
except Exception as e:
|
|
617
|
+
return (name, e)
|
|
618
|
+
|
|
619
|
+
# Connect to all servers in parallel
|
|
620
|
+
with LogSpan(span="bench.servers.connect", count=len(valid_servers)):
|
|
621
|
+
results = await asyncio.gather(
|
|
622
|
+
*[connect_one(name, config) for name, config in valid_servers]
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Process results
|
|
626
|
+
for name, conn_or_error in results:
|
|
627
|
+
if isinstance(conn_or_error, Exception):
|
|
628
|
+
error_msg = _enhance_error_message(name, str(conn_or_error))
|
|
629
|
+
result.health.append(
|
|
630
|
+
ServerHealth(name=name, healthy=False, error=error_msg)
|
|
631
|
+
)
|
|
632
|
+
logger.error(f" ✗ {name}: {error_msg}")
|
|
633
|
+
if self.on_progress:
|
|
634
|
+
self.on_progress.on_failed(name, error_msg)
|
|
635
|
+
else:
|
|
636
|
+
result.connections[name] = conn_or_error
|
|
637
|
+
result.health.append(
|
|
638
|
+
ServerHealth(
|
|
639
|
+
name=name, healthy=True, tool_count=len(conn_or_error.tools)
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
if self.on_progress:
|
|
643
|
+
self.on_progress.on_connected(name, len(conn_or_error.tools))
|
|
644
|
+
|
|
645
|
+
self._result = result
|
|
646
|
+
return result
|
|
647
|
+
|
|
648
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
649
|
+
"""Close all server connections."""
|
|
650
|
+
for ctx in reversed(self._contexts):
|
|
651
|
+
try:
|
|
652
|
+
await ctx.__aexit__(exc_type, exc_val, exc_tb)
|
|
653
|
+
except BaseExceptionGroup as eg:
|
|
654
|
+
# Extract leaf errors from nested TaskGroups
|
|
655
|
+
leaf_errors: list[str] = []
|
|
656
|
+
for exc in eg.exceptions:
|
|
657
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
658
|
+
for inner in exc.exceptions:
|
|
659
|
+
leaf_errors.append(str(inner))
|
|
660
|
+
else:
|
|
661
|
+
leaf_errors.append(str(exc))
|
|
662
|
+
error_msg = "; ".join(leaf_errors) if leaf_errors else str(eg)
|
|
663
|
+
logger.debug(f"Connection cleanup: {error_msg}")
|
|
664
|
+
except Exception as e:
|
|
665
|
+
logger.debug(f"Connection cleanup: {e}")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def connect_to_servers(
|
|
669
|
+
servers: dict[str, ServerConfig],
|
|
670
|
+
server_names: list[str],
|
|
671
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
672
|
+
on_progress: ServerConnectionCallback | None = None,
|
|
673
|
+
) -> MultiServerContextManager:
|
|
674
|
+
"""Connect to multiple MCP servers.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
servers: All available server configs from harness config.
|
|
678
|
+
server_names: Names of servers to connect to.
|
|
679
|
+
timeout: Timeout for each server connection.
|
|
680
|
+
on_progress: Optional callback for connection progress.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
Context manager that yields MultiServerConnection.
|
|
684
|
+
|
|
685
|
+
Example:
|
|
686
|
+
async with connect_to_servers(config.servers, ["context7", "github"]) as multi:
|
|
687
|
+
print(f"Connected to {multi.healthy_count} servers")
|
|
688
|
+
for health in multi.health:
|
|
689
|
+
if not health.healthy:
|
|
690
|
+
print(f"Failed: {health.name}: {health.error}")
|
|
691
|
+
"""
|
|
692
|
+
return MultiServerContextManager(servers, server_names, timeout, on_progress)
|