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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
@@ -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)