ouroboros-ai 0.2.3__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of ouroboros-ai might be problematic. Click here for more details.
- ouroboros/__init__.py +1 -1
- ouroboros/bigbang/__init__.py +9 -0
- ouroboros/bigbang/interview.py +16 -18
- ouroboros/bigbang/ontology.py +180 -0
- ouroboros/cli/commands/__init__.py +2 -0
- ouroboros/cli/commands/init.py +162 -97
- ouroboros/cli/commands/mcp.py +161 -0
- ouroboros/cli/commands/run.py +165 -27
- ouroboros/cli/main.py +2 -1
- ouroboros/core/ontology_aspect.py +455 -0
- ouroboros/core/ontology_questions.py +462 -0
- ouroboros/evaluation/__init__.py +16 -1
- ouroboros/evaluation/consensus.py +569 -11
- ouroboros/evaluation/models.py +81 -0
- ouroboros/events/ontology.py +135 -0
- ouroboros/mcp/__init__.py +83 -0
- ouroboros/mcp/client/__init__.py +20 -0
- ouroboros/mcp/client/adapter.py +632 -0
- ouroboros/mcp/client/manager.py +600 -0
- ouroboros/mcp/client/protocol.py +161 -0
- ouroboros/mcp/errors.py +377 -0
- ouroboros/mcp/resources/__init__.py +22 -0
- ouroboros/mcp/resources/handlers.py +328 -0
- ouroboros/mcp/server/__init__.py +21 -0
- ouroboros/mcp/server/adapter.py +408 -0
- ouroboros/mcp/server/protocol.py +291 -0
- ouroboros/mcp/server/security.py +636 -0
- ouroboros/mcp/tools/__init__.py +24 -0
- ouroboros/mcp/tools/definitions.py +351 -0
- ouroboros/mcp/tools/registry.py +269 -0
- ouroboros/mcp/types.py +333 -0
- ouroboros/orchestrator/__init__.py +31 -0
- ouroboros/orchestrator/events.py +40 -0
- ouroboros/orchestrator/mcp_config.py +419 -0
- ouroboros/orchestrator/mcp_tools.py +483 -0
- ouroboros/orchestrator/runner.py +119 -2
- ouroboros/providers/claude_code_adapter.py +75 -0
- ouroboros/strategies/__init__.py +23 -0
- ouroboros/strategies/devil_advocate.py +197 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/METADATA +73 -17
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/RECORD +44 -19
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/WHEEL +0 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/entry_points.txt +0 -0
- {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""MCP Client Manager for multi-server connection management.
|
|
2
|
+
|
|
3
|
+
This module provides the MCPClientManager class for managing connections to
|
|
4
|
+
multiple MCP servers with connection pooling, health checks, and per-request
|
|
5
|
+
timeouts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
import contextlib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from ouroboros.core.types import Result
|
|
18
|
+
from ouroboros.mcp.client.adapter import MCPClientAdapter
|
|
19
|
+
from ouroboros.mcp.errors import (
|
|
20
|
+
MCPClientError,
|
|
21
|
+
MCPConnectionError,
|
|
22
|
+
)
|
|
23
|
+
from ouroboros.mcp.types import (
|
|
24
|
+
MCPResourceContent,
|
|
25
|
+
MCPResourceDefinition,
|
|
26
|
+
MCPServerConfig,
|
|
27
|
+
MCPServerInfo,
|
|
28
|
+
MCPToolDefinition,
|
|
29
|
+
MCPToolResult,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
log = structlog.get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConnectionState(StrEnum):
|
|
36
|
+
"""State of a server connection."""
|
|
37
|
+
|
|
38
|
+
DISCONNECTED = "disconnected"
|
|
39
|
+
CONNECTING = "connecting"
|
|
40
|
+
CONNECTED = "connected"
|
|
41
|
+
UNHEALTHY = "unhealthy"
|
|
42
|
+
ERROR = "error"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ServerConnection:
|
|
47
|
+
"""Information about a server connection.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
config: Server configuration.
|
|
51
|
+
adapter: The client adapter for this connection.
|
|
52
|
+
state: Current connection state.
|
|
53
|
+
last_error: Last error message if any.
|
|
54
|
+
tools: Cached list of tools from this server.
|
|
55
|
+
resources: Cached list of resources from this server.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
config: MCPServerConfig
|
|
59
|
+
adapter: MCPClientAdapter
|
|
60
|
+
state: ConnectionState = ConnectionState.DISCONNECTED
|
|
61
|
+
last_error: str | None = None
|
|
62
|
+
tools: tuple[MCPToolDefinition, ...] = field(default_factory=tuple)
|
|
63
|
+
resources: tuple[MCPResourceDefinition, ...] = field(default_factory=tuple)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MCPClientManager:
|
|
67
|
+
"""Manager for multiple MCP server connections.
|
|
68
|
+
|
|
69
|
+
Provides connection pooling, health checks, and unified access to tools
|
|
70
|
+
and resources across multiple MCP servers.
|
|
71
|
+
|
|
72
|
+
Features:
|
|
73
|
+
- Connection pooling: Reuses connections to servers
|
|
74
|
+
- Health checks: Periodic checks for connection health
|
|
75
|
+
- Per-request timeouts: Individual timeout per operation
|
|
76
|
+
- Tool aggregation: Access all tools across servers
|
|
77
|
+
- Auto-reconnection: Attempts to reconnect on failure
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
manager = MCPClientManager()
|
|
81
|
+
|
|
82
|
+
# Add servers
|
|
83
|
+
await manager.add_server(MCPServerConfig(...))
|
|
84
|
+
await manager.add_server(MCPServerConfig(...))
|
|
85
|
+
|
|
86
|
+
# Connect to all
|
|
87
|
+
await manager.connect_all()
|
|
88
|
+
|
|
89
|
+
# Use tools from any server
|
|
90
|
+
tools = await manager.list_all_tools()
|
|
91
|
+
result = await manager.call_tool("server1", "tool_name", {...})
|
|
92
|
+
|
|
93
|
+
# Cleanup
|
|
94
|
+
await manager.disconnect_all()
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
max_retries: int = 3,
|
|
101
|
+
health_check_interval: float = 60.0,
|
|
102
|
+
default_timeout: float = 30.0,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize the manager.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
max_retries: Maximum retry attempts for connections.
|
|
108
|
+
health_check_interval: Seconds between health checks.
|
|
109
|
+
default_timeout: Default timeout for operations.
|
|
110
|
+
"""
|
|
111
|
+
self._max_retries = max_retries
|
|
112
|
+
self._health_check_interval = health_check_interval
|
|
113
|
+
self._default_timeout = default_timeout
|
|
114
|
+
self._connections: dict[str, ServerConnection] = {}
|
|
115
|
+
self._health_check_task: asyncio.Task[None] | None = None
|
|
116
|
+
self._lock = asyncio.Lock()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def servers(self) -> Sequence[str]:
|
|
120
|
+
"""Return list of server names."""
|
|
121
|
+
return tuple(self._connections.keys())
|
|
122
|
+
|
|
123
|
+
def get_connection_state(self, server_name: str) -> ConnectionState | None:
|
|
124
|
+
"""Get the connection state for a server.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
server_name: Name of the server.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
ConnectionState or None if server not found.
|
|
131
|
+
"""
|
|
132
|
+
conn = self._connections.get(server_name)
|
|
133
|
+
return conn.state if conn else None
|
|
134
|
+
|
|
135
|
+
async def add_server(
|
|
136
|
+
self,
|
|
137
|
+
config: MCPServerConfig,
|
|
138
|
+
*,
|
|
139
|
+
connect: bool = False,
|
|
140
|
+
) -> Result[MCPServerInfo | None, MCPClientError]:
|
|
141
|
+
"""Add a server configuration.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
config: Server configuration.
|
|
145
|
+
connect: Whether to immediately connect.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Result containing server info if connect=True, None otherwise.
|
|
149
|
+
"""
|
|
150
|
+
async with self._lock:
|
|
151
|
+
if config.name in self._connections:
|
|
152
|
+
return Result.err(
|
|
153
|
+
MCPClientError(
|
|
154
|
+
f"Server already exists: {config.name}",
|
|
155
|
+
server_name=config.name,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
adapter = MCPClientAdapter(max_retries=self._max_retries)
|
|
160
|
+
self._connections[config.name] = ServerConnection(
|
|
161
|
+
config=config,
|
|
162
|
+
adapter=adapter,
|
|
163
|
+
state=ConnectionState.DISCONNECTED,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
log.info("mcp.manager.server_added", server=config.name)
|
|
167
|
+
|
|
168
|
+
if connect:
|
|
169
|
+
connect_result = await self.connect(config.name)
|
|
170
|
+
if connect_result.is_ok:
|
|
171
|
+
return Result.ok(connect_result.value)
|
|
172
|
+
return Result.err(connect_result.error)
|
|
173
|
+
|
|
174
|
+
return Result.ok(None)
|
|
175
|
+
|
|
176
|
+
async def remove_server(self, server_name: str) -> Result[None, MCPClientError]:
|
|
177
|
+
"""Remove a server and disconnect if connected.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
server_name: Name of the server to remove.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Result indicating success or failure.
|
|
184
|
+
"""
|
|
185
|
+
async with self._lock:
|
|
186
|
+
conn = self._connections.get(server_name)
|
|
187
|
+
if not conn:
|
|
188
|
+
return Result.err(
|
|
189
|
+
MCPClientError(
|
|
190
|
+
f"Server not found: {server_name}",
|
|
191
|
+
is_retriable=False,
|
|
192
|
+
details={"resource_type": "server", "resource_id": server_name},
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Disconnect if connected
|
|
197
|
+
if conn.adapter.is_connected:
|
|
198
|
+
await conn.adapter.disconnect()
|
|
199
|
+
|
|
200
|
+
del self._connections[server_name]
|
|
201
|
+
log.info("mcp.manager.server_removed", server=server_name)
|
|
202
|
+
|
|
203
|
+
return Result.ok(None)
|
|
204
|
+
|
|
205
|
+
async def connect(self, server_name: str) -> Result[MCPServerInfo, MCPClientError]:
|
|
206
|
+
"""Connect to a specific server.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
server_name: Name of the server to connect to.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Result containing server info or error.
|
|
213
|
+
"""
|
|
214
|
+
async with self._lock:
|
|
215
|
+
conn = self._connections.get(server_name)
|
|
216
|
+
if not conn:
|
|
217
|
+
return Result.err(
|
|
218
|
+
MCPClientError(
|
|
219
|
+
f"Server not found: {server_name}",
|
|
220
|
+
is_retriable=False,
|
|
221
|
+
details={"resource_type": "server", "resource_id": server_name},
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Update state to connecting
|
|
226
|
+
self._connections[server_name] = ServerConnection(
|
|
227
|
+
config=conn.config,
|
|
228
|
+
adapter=conn.adapter,
|
|
229
|
+
state=ConnectionState.CONNECTING,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Connect outside the lock
|
|
233
|
+
await conn.adapter.__aenter__()
|
|
234
|
+
result = await conn.adapter.connect(conn.config)
|
|
235
|
+
|
|
236
|
+
async with self._lock:
|
|
237
|
+
if result.is_ok:
|
|
238
|
+
# Cache tools and resources
|
|
239
|
+
tools = await self._fetch_tools(conn.adapter, server_name)
|
|
240
|
+
resources = await self._fetch_resources(conn.adapter, server_name)
|
|
241
|
+
|
|
242
|
+
self._connections[server_name] = ServerConnection(
|
|
243
|
+
config=conn.config,
|
|
244
|
+
adapter=conn.adapter,
|
|
245
|
+
state=ConnectionState.CONNECTED,
|
|
246
|
+
tools=tools,
|
|
247
|
+
resources=resources,
|
|
248
|
+
)
|
|
249
|
+
log.info(
|
|
250
|
+
"mcp.manager.connected",
|
|
251
|
+
server=server_name,
|
|
252
|
+
tools=len(tools),
|
|
253
|
+
resources=len(resources),
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
self._connections[server_name] = ServerConnection(
|
|
257
|
+
config=conn.config,
|
|
258
|
+
adapter=conn.adapter,
|
|
259
|
+
state=ConnectionState.ERROR,
|
|
260
|
+
last_error=str(result.error),
|
|
261
|
+
)
|
|
262
|
+
log.error(
|
|
263
|
+
"mcp.manager.connect_failed",
|
|
264
|
+
server=server_name,
|
|
265
|
+
error=str(result.error),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
async def _fetch_tools(
|
|
271
|
+
self,
|
|
272
|
+
adapter: MCPClientAdapter,
|
|
273
|
+
server_name: str,
|
|
274
|
+
) -> tuple[MCPToolDefinition, ...]:
|
|
275
|
+
"""Fetch tools from an adapter, returning empty tuple on error."""
|
|
276
|
+
result = await adapter.list_tools()
|
|
277
|
+
if result.is_ok:
|
|
278
|
+
return tuple(result.value)
|
|
279
|
+
log.warning(
|
|
280
|
+
"mcp.manager.fetch_tools_failed",
|
|
281
|
+
server=server_name,
|
|
282
|
+
error=str(result.error),
|
|
283
|
+
)
|
|
284
|
+
return ()
|
|
285
|
+
|
|
286
|
+
async def _fetch_resources(
|
|
287
|
+
self,
|
|
288
|
+
adapter: MCPClientAdapter,
|
|
289
|
+
server_name: str,
|
|
290
|
+
) -> tuple[MCPResourceDefinition, ...]:
|
|
291
|
+
"""Fetch resources from an adapter, returning empty tuple on error."""
|
|
292
|
+
result = await adapter.list_resources()
|
|
293
|
+
if result.is_ok:
|
|
294
|
+
return tuple(result.value)
|
|
295
|
+
log.warning(
|
|
296
|
+
"mcp.manager.fetch_resources_failed",
|
|
297
|
+
server=server_name,
|
|
298
|
+
error=str(result.error),
|
|
299
|
+
)
|
|
300
|
+
return ()
|
|
301
|
+
|
|
302
|
+
async def disconnect(self, server_name: str) -> Result[None, MCPClientError]:
|
|
303
|
+
"""Disconnect from a specific server.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
server_name: Name of the server to disconnect from.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Result indicating success or failure.
|
|
310
|
+
"""
|
|
311
|
+
async with self._lock:
|
|
312
|
+
conn = self._connections.get(server_name)
|
|
313
|
+
if not conn:
|
|
314
|
+
return Result.err(
|
|
315
|
+
MCPClientError(
|
|
316
|
+
f"Server not found: {server_name}",
|
|
317
|
+
is_retriable=False,
|
|
318
|
+
details={"resource_type": "server", "resource_id": server_name},
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
result = await conn.adapter.disconnect()
|
|
323
|
+
await conn.adapter.__aexit__(None, None, None)
|
|
324
|
+
|
|
325
|
+
async with self._lock:
|
|
326
|
+
self._connections[server_name] = ServerConnection(
|
|
327
|
+
config=conn.config,
|
|
328
|
+
adapter=MCPClientAdapter(max_retries=self._max_retries),
|
|
329
|
+
state=ConnectionState.DISCONNECTED,
|
|
330
|
+
last_error=str(result.error) if result.is_err else None,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
async def connect_all(self) -> dict[str, Result[MCPServerInfo, MCPClientError]]:
|
|
336
|
+
"""Connect to all registered servers.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Dict mapping server names to their connection results.
|
|
340
|
+
"""
|
|
341
|
+
results: dict[str, Result[MCPServerInfo, MCPClientError]] = {}
|
|
342
|
+
|
|
343
|
+
# Get list of servers to connect
|
|
344
|
+
servers = list(self._connections.keys())
|
|
345
|
+
|
|
346
|
+
# Connect concurrently
|
|
347
|
+
async def connect_server(name: str) -> tuple[str, Result[MCPServerInfo, MCPClientError]]:
|
|
348
|
+
return (name, await self.connect(name))
|
|
349
|
+
|
|
350
|
+
tasks = [connect_server(name) for name in servers]
|
|
351
|
+
for name, result in await asyncio.gather(*[asyncio.create_task(t) for t in tasks]):
|
|
352
|
+
results[name] = result
|
|
353
|
+
|
|
354
|
+
return results
|
|
355
|
+
|
|
356
|
+
async def disconnect_all(self) -> dict[str, Result[None, MCPClientError]]:
|
|
357
|
+
"""Disconnect from all servers.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Dict mapping server names to their disconnect results.
|
|
361
|
+
"""
|
|
362
|
+
results: dict[str, Result[None, MCPClientError]] = {}
|
|
363
|
+
servers = list(self._connections.keys())
|
|
364
|
+
|
|
365
|
+
for name in servers:
|
|
366
|
+
results[name] = await self.disconnect(name)
|
|
367
|
+
|
|
368
|
+
# Stop health check task if running
|
|
369
|
+
if self._health_check_task and not self._health_check_task.done():
|
|
370
|
+
self._health_check_task.cancel()
|
|
371
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
372
|
+
await self._health_check_task
|
|
373
|
+
self._health_check_task = None
|
|
374
|
+
|
|
375
|
+
return results
|
|
376
|
+
|
|
377
|
+
async def list_all_tools(self) -> Sequence[MCPToolDefinition]:
|
|
378
|
+
"""List all tools from all connected servers.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Sequence of all tool definitions across servers.
|
|
382
|
+
"""
|
|
383
|
+
tools: list[MCPToolDefinition] = []
|
|
384
|
+
for conn in self._connections.values():
|
|
385
|
+
if conn.state == ConnectionState.CONNECTED:
|
|
386
|
+
tools.extend(conn.tools)
|
|
387
|
+
return tools
|
|
388
|
+
|
|
389
|
+
async def list_all_resources(self) -> Sequence[MCPResourceDefinition]:
|
|
390
|
+
"""List all resources from all connected servers.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Sequence of all resource definitions across servers.
|
|
394
|
+
"""
|
|
395
|
+
resources: list[MCPResourceDefinition] = []
|
|
396
|
+
for conn in self._connections.values():
|
|
397
|
+
if conn.state == ConnectionState.CONNECTED:
|
|
398
|
+
resources.extend(conn.resources)
|
|
399
|
+
return resources
|
|
400
|
+
|
|
401
|
+
def find_tool_server(self, tool_name: str) -> str | None:
|
|
402
|
+
"""Find which server provides a given tool.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
tool_name: Name of the tool to find.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Server name or None if not found.
|
|
409
|
+
"""
|
|
410
|
+
for name, conn in self._connections.items():
|
|
411
|
+
if conn.state == ConnectionState.CONNECTED:
|
|
412
|
+
for tool in conn.tools:
|
|
413
|
+
if tool.name == tool_name:
|
|
414
|
+
return name
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
async def call_tool(
|
|
418
|
+
self,
|
|
419
|
+
server_name: str,
|
|
420
|
+
tool_name: str,
|
|
421
|
+
arguments: dict[str, Any] | None = None,
|
|
422
|
+
*,
|
|
423
|
+
timeout: float | None = None,
|
|
424
|
+
) -> Result[MCPToolResult, MCPClientError]:
|
|
425
|
+
"""Call a tool on a specific server.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
server_name: Name of the server.
|
|
429
|
+
tool_name: Name of the tool to call.
|
|
430
|
+
arguments: Arguments for the tool.
|
|
431
|
+
timeout: Optional timeout override.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Result containing tool result or error.
|
|
435
|
+
"""
|
|
436
|
+
conn = self._connections.get(server_name)
|
|
437
|
+
if not conn:
|
|
438
|
+
return Result.err(
|
|
439
|
+
MCPClientError(
|
|
440
|
+
f"Server not found: {server_name}",
|
|
441
|
+
is_retriable=False,
|
|
442
|
+
details={"resource_type": "server", "resource_id": server_name},
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
if conn.state != ConnectionState.CONNECTED:
|
|
447
|
+
return Result.err(
|
|
448
|
+
MCPConnectionError(
|
|
449
|
+
f"Server not connected: {server_name}",
|
|
450
|
+
server_name=server_name,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
effective_timeout = timeout or self._default_timeout
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
return await asyncio.wait_for(
|
|
458
|
+
conn.adapter.call_tool(tool_name, arguments),
|
|
459
|
+
timeout=effective_timeout,
|
|
460
|
+
)
|
|
461
|
+
except TimeoutError:
|
|
462
|
+
from ouroboros.mcp.errors import MCPTimeoutError
|
|
463
|
+
|
|
464
|
+
return Result.err(
|
|
465
|
+
MCPTimeoutError(
|
|
466
|
+
f"Tool call timed out: {tool_name}",
|
|
467
|
+
server_name=server_name,
|
|
468
|
+
timeout_seconds=effective_timeout,
|
|
469
|
+
operation="call_tool",
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
async def call_tool_auto(
|
|
474
|
+
self,
|
|
475
|
+
tool_name: str,
|
|
476
|
+
arguments: dict[str, Any] | None = None,
|
|
477
|
+
*,
|
|
478
|
+
timeout: float | None = None,
|
|
479
|
+
) -> Result[MCPToolResult, MCPClientError]:
|
|
480
|
+
"""Call a tool, automatically finding the server that provides it.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
tool_name: Name of the tool to call.
|
|
484
|
+
arguments: Arguments for the tool.
|
|
485
|
+
timeout: Optional timeout override.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Result containing tool result or error.
|
|
489
|
+
"""
|
|
490
|
+
server_name = self.find_tool_server(tool_name)
|
|
491
|
+
if not server_name:
|
|
492
|
+
return Result.err(
|
|
493
|
+
MCPClientError(
|
|
494
|
+
f"Tool not found on any server: {tool_name}",
|
|
495
|
+
is_retriable=False,
|
|
496
|
+
details={"resource_type": "tool", "resource_id": tool_name},
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return await self.call_tool(server_name, tool_name, arguments, timeout=timeout)
|
|
501
|
+
|
|
502
|
+
async def read_resource(
|
|
503
|
+
self,
|
|
504
|
+
server_name: str,
|
|
505
|
+
uri: str,
|
|
506
|
+
*,
|
|
507
|
+
timeout: float | None = None,
|
|
508
|
+
) -> Result[MCPResourceContent, MCPClientError]:
|
|
509
|
+
"""Read a resource from a specific server.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
server_name: Name of the server.
|
|
513
|
+
uri: URI of the resource.
|
|
514
|
+
timeout: Optional timeout override.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Result containing resource content or error.
|
|
518
|
+
"""
|
|
519
|
+
conn = self._connections.get(server_name)
|
|
520
|
+
if not conn:
|
|
521
|
+
return Result.err(
|
|
522
|
+
MCPClientError(
|
|
523
|
+
f"Server not found: {server_name}",
|
|
524
|
+
is_retriable=False,
|
|
525
|
+
details={"resource_type": "server", "resource_id": server_name},
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
if conn.state != ConnectionState.CONNECTED:
|
|
530
|
+
return Result.err(
|
|
531
|
+
MCPConnectionError(
|
|
532
|
+
f"Server not connected: {server_name}",
|
|
533
|
+
server_name=server_name,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
effective_timeout = timeout or self._default_timeout
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
return await asyncio.wait_for(
|
|
541
|
+
conn.adapter.read_resource(uri),
|
|
542
|
+
timeout=effective_timeout,
|
|
543
|
+
)
|
|
544
|
+
except TimeoutError:
|
|
545
|
+
from ouroboros.mcp.errors import MCPTimeoutError
|
|
546
|
+
|
|
547
|
+
return Result.err(
|
|
548
|
+
MCPTimeoutError(
|
|
549
|
+
f"Resource read timed out: {uri}",
|
|
550
|
+
server_name=server_name,
|
|
551
|
+
timeout_seconds=effective_timeout,
|
|
552
|
+
operation="read_resource",
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def start_health_checks(self) -> None:
|
|
557
|
+
"""Start periodic health checks for all connections."""
|
|
558
|
+
if self._health_check_task and not self._health_check_task.done():
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
self._health_check_task = asyncio.create_task(self._health_check_loop())
|
|
562
|
+
log.info("mcp.manager.health_checks_started")
|
|
563
|
+
|
|
564
|
+
async def _health_check_loop(self) -> None:
|
|
565
|
+
"""Run periodic health checks."""
|
|
566
|
+
while True:
|
|
567
|
+
try:
|
|
568
|
+
await asyncio.sleep(self._health_check_interval)
|
|
569
|
+
await self._perform_health_checks()
|
|
570
|
+
except asyncio.CancelledError:
|
|
571
|
+
break
|
|
572
|
+
except Exception as e:
|
|
573
|
+
log.error("mcp.manager.health_check_error", error=str(e))
|
|
574
|
+
|
|
575
|
+
async def _perform_health_checks(self) -> None:
|
|
576
|
+
"""Perform health checks on all connections."""
|
|
577
|
+
for server_name, conn in list(self._connections.items()):
|
|
578
|
+
if conn.state == ConnectionState.CONNECTED:
|
|
579
|
+
# Simple health check: try to list tools
|
|
580
|
+
result = await conn.adapter.list_tools()
|
|
581
|
+
if result.is_err:
|
|
582
|
+
async with self._lock:
|
|
583
|
+
self._connections[server_name] = ServerConnection(
|
|
584
|
+
config=conn.config,
|
|
585
|
+
adapter=conn.adapter,
|
|
586
|
+
state=ConnectionState.UNHEALTHY,
|
|
587
|
+
last_error=str(result.error),
|
|
588
|
+
tools=conn.tools,
|
|
589
|
+
resources=conn.resources,
|
|
590
|
+
)
|
|
591
|
+
log.warning(
|
|
592
|
+
"mcp.manager.health_check_failed",
|
|
593
|
+
server=server_name,
|
|
594
|
+
error=str(result.error),
|
|
595
|
+
)
|
|
596
|
+
elif conn.state == ConnectionState.UNHEALTHY:
|
|
597
|
+
# Try to reconnect
|
|
598
|
+
reconnect_result = await self.connect(server_name)
|
|
599
|
+
if reconnect_result.is_ok:
|
|
600
|
+
log.info("mcp.manager.reconnected", server=server_name)
|