ouroboros-ai 0.3.0__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.

Files changed (42) hide show
  1. ouroboros/__init__.py +1 -1
  2. ouroboros/bigbang/__init__.py +9 -0
  3. ouroboros/bigbang/ontology.py +180 -0
  4. ouroboros/cli/commands/__init__.py +2 -0
  5. ouroboros/cli/commands/mcp.py +161 -0
  6. ouroboros/cli/commands/run.py +165 -27
  7. ouroboros/cli/main.py +2 -1
  8. ouroboros/core/ontology_aspect.py +455 -0
  9. ouroboros/core/ontology_questions.py +462 -0
  10. ouroboros/evaluation/__init__.py +16 -1
  11. ouroboros/evaluation/consensus.py +569 -11
  12. ouroboros/evaluation/models.py +81 -0
  13. ouroboros/events/ontology.py +135 -0
  14. ouroboros/mcp/__init__.py +83 -0
  15. ouroboros/mcp/client/__init__.py +20 -0
  16. ouroboros/mcp/client/adapter.py +632 -0
  17. ouroboros/mcp/client/manager.py +600 -0
  18. ouroboros/mcp/client/protocol.py +161 -0
  19. ouroboros/mcp/errors.py +377 -0
  20. ouroboros/mcp/resources/__init__.py +22 -0
  21. ouroboros/mcp/resources/handlers.py +328 -0
  22. ouroboros/mcp/server/__init__.py +21 -0
  23. ouroboros/mcp/server/adapter.py +408 -0
  24. ouroboros/mcp/server/protocol.py +291 -0
  25. ouroboros/mcp/server/security.py +636 -0
  26. ouroboros/mcp/tools/__init__.py +24 -0
  27. ouroboros/mcp/tools/definitions.py +351 -0
  28. ouroboros/mcp/tools/registry.py +269 -0
  29. ouroboros/mcp/types.py +333 -0
  30. ouroboros/orchestrator/__init__.py +31 -0
  31. ouroboros/orchestrator/events.py +40 -0
  32. ouroboros/orchestrator/mcp_config.py +419 -0
  33. ouroboros/orchestrator/mcp_tools.py +483 -0
  34. ouroboros/orchestrator/runner.py +119 -2
  35. ouroboros/providers/claude_code_adapter.py +75 -0
  36. ouroboros/strategies/__init__.py +23 -0
  37. ouroboros/strategies/devil_advocate.py +197 -0
  38. {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/METADATA +10 -5
  39. {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/RECORD +42 -17
  40. {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/WHEEL +0 -0
  41. {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/entry_points.txt +0 -0
  42. {ouroboros_ai-0.3.0.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)