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.

Files changed (44) hide show
  1. ouroboros/__init__.py +1 -1
  2. ouroboros/bigbang/__init__.py +9 -0
  3. ouroboros/bigbang/interview.py +16 -18
  4. ouroboros/bigbang/ontology.py +180 -0
  5. ouroboros/cli/commands/__init__.py +2 -0
  6. ouroboros/cli/commands/init.py +162 -97
  7. ouroboros/cli/commands/mcp.py +161 -0
  8. ouroboros/cli/commands/run.py +165 -27
  9. ouroboros/cli/main.py +2 -1
  10. ouroboros/core/ontology_aspect.py +455 -0
  11. ouroboros/core/ontology_questions.py +462 -0
  12. ouroboros/evaluation/__init__.py +16 -1
  13. ouroboros/evaluation/consensus.py +569 -11
  14. ouroboros/evaluation/models.py +81 -0
  15. ouroboros/events/ontology.py +135 -0
  16. ouroboros/mcp/__init__.py +83 -0
  17. ouroboros/mcp/client/__init__.py +20 -0
  18. ouroboros/mcp/client/adapter.py +632 -0
  19. ouroboros/mcp/client/manager.py +600 -0
  20. ouroboros/mcp/client/protocol.py +161 -0
  21. ouroboros/mcp/errors.py +377 -0
  22. ouroboros/mcp/resources/__init__.py +22 -0
  23. ouroboros/mcp/resources/handlers.py +328 -0
  24. ouroboros/mcp/server/__init__.py +21 -0
  25. ouroboros/mcp/server/adapter.py +408 -0
  26. ouroboros/mcp/server/protocol.py +291 -0
  27. ouroboros/mcp/server/security.py +636 -0
  28. ouroboros/mcp/tools/__init__.py +24 -0
  29. ouroboros/mcp/tools/definitions.py +351 -0
  30. ouroboros/mcp/tools/registry.py +269 -0
  31. ouroboros/mcp/types.py +333 -0
  32. ouroboros/orchestrator/__init__.py +31 -0
  33. ouroboros/orchestrator/events.py +40 -0
  34. ouroboros/orchestrator/mcp_config.py +419 -0
  35. ouroboros/orchestrator/mcp_tools.py +483 -0
  36. ouroboros/orchestrator/runner.py +119 -2
  37. ouroboros/providers/claude_code_adapter.py +75 -0
  38. ouroboros/strategies/__init__.py +23 -0
  39. ouroboros/strategies/devil_advocate.py +197 -0
  40. {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/METADATA +73 -17
  41. {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/RECORD +44 -19
  42. {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/WHEEL +0 -0
  43. {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/entry_points.txt +0 -0
  44. {ouroboros_ai-0.2.3.dist-info → ouroboros_ai-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,632 @@
1
+ """MCP Client adapter implementation.
2
+
3
+ This module provides the MCPClientAdapter class that implements the MCPClient
4
+ protocol using the MCP SDK. It handles connection management, retries, and
5
+ error handling.
6
+ """
7
+
8
+ from collections.abc import AsyncIterator, Sequence
9
+ from contextlib import asynccontextmanager
10
+ from typing import Any, Self
11
+
12
+ import stamina
13
+ import structlog
14
+
15
+ from ouroboros.core.types import Result
16
+ from ouroboros.mcp.errors import (
17
+ MCPClientError,
18
+ MCPConnectionError,
19
+ MCPTimeoutError,
20
+ )
21
+ from ouroboros.mcp.types import (
22
+ ContentType,
23
+ MCPCapabilities,
24
+ MCPContentItem,
25
+ MCPPromptArgument,
26
+ MCPPromptDefinition,
27
+ MCPResourceContent,
28
+ MCPResourceDefinition,
29
+ MCPServerConfig,
30
+ MCPServerInfo,
31
+ MCPToolDefinition,
32
+ MCPToolParameter,
33
+ MCPToolResult,
34
+ ToolInputType,
35
+ TransportType,
36
+ )
37
+
38
+ log = structlog.get_logger(__name__)
39
+
40
+ # Exceptions that are safe to retry
41
+ RETRIABLE_EXCEPTIONS = (
42
+ TimeoutError,
43
+ ConnectionError,
44
+ OSError,
45
+ )
46
+
47
+
48
+ class MCPClientAdapter:
49
+ """Concrete implementation of MCPClient protocol.
50
+
51
+ Uses the MCP SDK to connect to MCP servers and provides automatic retry
52
+ logic using stamina for transient failures.
53
+
54
+ Example:
55
+ config = MCPServerConfig(
56
+ name="my-server",
57
+ transport=TransportType.STDIO,
58
+ command="my-mcp-server",
59
+ )
60
+
61
+ async with MCPClientAdapter() as client:
62
+ result = await client.connect(config)
63
+ if result.is_ok:
64
+ tools = await client.list_tools()
65
+
66
+ # Or use as regular async context manager
67
+ adapter = MCPClientAdapter()
68
+ await adapter.__aenter__()
69
+ try:
70
+ await adapter.connect(config)
71
+ finally:
72
+ await adapter.__aexit__(None, None, None)
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ max_retries: int = 3,
79
+ retry_wait_initial: float = 1.0,
80
+ retry_wait_max: float = 10.0,
81
+ ) -> None:
82
+ """Initialize the adapter.
83
+
84
+ Args:
85
+ max_retries: Maximum number of retry attempts for transient failures.
86
+ retry_wait_initial: Initial wait time between retries in seconds.
87
+ retry_wait_max: Maximum wait time between retries in seconds.
88
+ """
89
+ self._max_retries = max_retries
90
+ self._retry_wait_initial = retry_wait_initial
91
+ self._retry_wait_max = retry_wait_max
92
+ self._session: Any = None
93
+ self._read_stream: Any = None
94
+ self._write_stream: Any = None
95
+ self._server_info: MCPServerInfo | None = None
96
+ self._config: MCPServerConfig | None = None
97
+
98
+ async def __aenter__(self) -> Self:
99
+ """Enter async context manager."""
100
+ return self
101
+
102
+ async def __aexit__(
103
+ self,
104
+ exc_type: type[BaseException] | None,
105
+ exc_val: BaseException | None,
106
+ exc_tb: Any,
107
+ ) -> None:
108
+ """Exit async context manager, ensuring disconnect."""
109
+ await self.disconnect()
110
+
111
+ @property
112
+ def is_connected(self) -> bool:
113
+ """Return True if currently connected to a server."""
114
+ return self._session is not None
115
+
116
+ @property
117
+ def server_info(self) -> MCPServerInfo | None:
118
+ """Return information about the connected server."""
119
+ return self._server_info
120
+
121
+ async def connect(
122
+ self,
123
+ config: MCPServerConfig,
124
+ ) -> Result[MCPServerInfo, MCPClientError]:
125
+ """Connect to an MCP server.
126
+
127
+ Establishes a connection using the appropriate transport (stdio, SSE, etc.)
128
+ and initializes the session. Uses stamina for automatic retries.
129
+
130
+ Args:
131
+ config: Configuration for the server connection.
132
+
133
+ Returns:
134
+ Result containing server info on success or MCPClientError on failure.
135
+ """
136
+ if self._session is not None:
137
+ disconnect_result = await self.disconnect()
138
+ if disconnect_result.is_err:
139
+ log.warning(
140
+ "mcp.disconnect_before_connect_failed",
141
+ error=disconnect_result.error,
142
+ )
143
+
144
+ self._config = config
145
+
146
+ @stamina.retry(
147
+ on=RETRIABLE_EXCEPTIONS,
148
+ attempts=self._max_retries,
149
+ wait_initial=self._retry_wait_initial,
150
+ wait_max=self._retry_wait_max,
151
+ wait_jitter=1.0,
152
+ )
153
+ async def _connect_with_retry() -> None:
154
+ await self._raw_connect(config)
155
+
156
+ try:
157
+ await _connect_with_retry()
158
+ log.info(
159
+ "mcp.connected",
160
+ server=config.name,
161
+ transport=config.transport.value,
162
+ )
163
+ return Result.ok(self._server_info) # type: ignore[arg-type]
164
+ except TimeoutError as e:
165
+ timeout_error = MCPTimeoutError(
166
+ f"Connection timeout: {e}",
167
+ server_name=config.name,
168
+ timeout_seconds=config.timeout,
169
+ operation="connect",
170
+ )
171
+ timeout_error.__cause__ = e
172
+ return Result.err(timeout_error)
173
+ except ConnectionError as e:
174
+ conn_error = MCPConnectionError(
175
+ f"Connection failed: {e}",
176
+ server_name=config.name,
177
+ transport=config.transport.value,
178
+ )
179
+ conn_error.__cause__ = e
180
+ return Result.err(conn_error)
181
+ except Exception as e:
182
+ client_error = MCPClientError.from_exception(
183
+ e,
184
+ server_name=config.name,
185
+ is_retriable=False,
186
+ )
187
+ return Result.err(client_error)
188
+
189
+ async def _raw_connect(self, config: MCPServerConfig) -> None:
190
+ """Perform the actual connection without retry logic.
191
+
192
+ Args:
193
+ config: Server configuration.
194
+
195
+ Raises:
196
+ Various exceptions depending on connection issues.
197
+ """
198
+ try:
199
+ from mcp import ClientSession, StdioServerParameters
200
+ from mcp.client.stdio import stdio_client
201
+ except ImportError as e:
202
+ msg = "mcp package not installed. Install with: pip install mcp"
203
+ raise ImportError(msg) from e
204
+
205
+ if config.transport == TransportType.STDIO:
206
+ if not config.command:
207
+ msg = "command is required for stdio transport"
208
+ raise ValueError(msg)
209
+
210
+ server_params = StdioServerParameters(
211
+ command=config.command,
212
+ args=list(config.args),
213
+ env=config.env if config.env else None,
214
+ )
215
+
216
+ self._read_stream, self._write_stream = await stdio_client(server_params).__aenter__()
217
+ self._session = ClientSession(self._read_stream, self._write_stream)
218
+ await self._session.__aenter__()
219
+
220
+ # Initialize the session
221
+ result = await self._session.initialize()
222
+ self._server_info = self._parse_server_info(result, config.name)
223
+
224
+ elif config.transport in (TransportType.SSE, TransportType.STREAMABLE_HTTP):
225
+ # SSE/HTTP transport would be implemented here
226
+ msg = f"Transport {config.transport} not yet implemented"
227
+ raise NotImplementedError(msg)
228
+ else:
229
+ msg = f"Unknown transport: {config.transport}"
230
+ raise ValueError(msg)
231
+
232
+ def _parse_server_info(self, init_result: Any, server_name: str) -> MCPServerInfo:
233
+ """Parse server info from initialization result.
234
+
235
+ Args:
236
+ init_result: Result from session.initialize().
237
+ server_name: Name of the server.
238
+
239
+ Returns:
240
+ Parsed MCPServerInfo.
241
+ """
242
+ capabilities = MCPCapabilities(
243
+ tools=getattr(init_result.capabilities, "tools", None) is not None,
244
+ resources=getattr(init_result.capabilities, "resources", None) is not None,
245
+ prompts=getattr(init_result.capabilities, "prompts", None) is not None,
246
+ logging=getattr(init_result.capabilities, "logging", None) is not None,
247
+ )
248
+
249
+ return MCPServerInfo(
250
+ name=server_name,
251
+ version=getattr(init_result, "protocolVersion", "1.0.0"),
252
+ capabilities=capabilities,
253
+ )
254
+
255
+ async def disconnect(self) -> Result[None, MCPClientError]:
256
+ """Disconnect from the current MCP server.
257
+
258
+ Returns:
259
+ Result containing None on success or MCPClientError on failure.
260
+ """
261
+ if self._session is None:
262
+ return Result.ok(None)
263
+
264
+ try:
265
+ await self._session.__aexit__(None, None, None)
266
+ self._session = None
267
+ self._server_info = None
268
+ log.info("mcp.disconnected", server=self._config.name if self._config else "unknown")
269
+ return Result.ok(None)
270
+ except Exception as e:
271
+ return Result.err(
272
+ MCPClientError.from_exception(
273
+ e,
274
+ server_name=self._config.name if self._config else None,
275
+ )
276
+ )
277
+
278
+ def _ensure_connected(self) -> Result[None, MCPClientError]:
279
+ """Ensure we're connected to a server.
280
+
281
+ Returns:
282
+ Result.ok(None) if connected, Result.err otherwise.
283
+ """
284
+ if self._session is None:
285
+ return Result.err(
286
+ MCPConnectionError(
287
+ "Not connected to any server",
288
+ server_name=self._config.name if self._config else None,
289
+ )
290
+ )
291
+ return Result.ok(None)
292
+
293
+ async def list_tools(self) -> Result[Sequence[MCPToolDefinition], MCPClientError]:
294
+ """List available tools from the connected server.
295
+
296
+ Returns:
297
+ Result containing sequence of tool definitions or MCPClientError.
298
+ """
299
+ connected = self._ensure_connected()
300
+ if connected.is_err:
301
+ return Result.err(connected.error)
302
+
303
+ try:
304
+ result = await self._session.list_tools()
305
+ tools = tuple(
306
+ self._parse_tool_definition(tool, self._config.name if self._config else None)
307
+ for tool in result.tools
308
+ )
309
+ return Result.ok(tools)
310
+ except Exception as e:
311
+ return Result.err(
312
+ MCPClientError.from_exception(
313
+ e,
314
+ server_name=self._config.name if self._config else None,
315
+ )
316
+ )
317
+
318
+ def _parse_tool_definition(
319
+ self, tool: Any, server_name: str | None
320
+ ) -> MCPToolDefinition:
321
+ """Parse a tool definition from the MCP SDK format.
322
+
323
+ Args:
324
+ tool: Tool object from MCP SDK.
325
+ server_name: Name of the server providing this tool.
326
+
327
+ Returns:
328
+ Parsed MCPToolDefinition.
329
+ """
330
+ parameters: list[MCPToolParameter] = []
331
+
332
+ if hasattr(tool, "inputSchema") and tool.inputSchema:
333
+ schema = tool.inputSchema
334
+ properties = schema.get("properties", {})
335
+ required = set(schema.get("required", []))
336
+
337
+ for name, prop in properties.items():
338
+ param_type = ToolInputType(prop.get("type", "string"))
339
+ parameters.append(
340
+ MCPToolParameter(
341
+ name=name,
342
+ type=param_type,
343
+ description=prop.get("description", ""),
344
+ required=name in required,
345
+ default=prop.get("default"),
346
+ enum=tuple(prop["enum"]) if "enum" in prop else None,
347
+ )
348
+ )
349
+
350
+ return MCPToolDefinition(
351
+ name=tool.name,
352
+ description=getattr(tool, "description", "") or "",
353
+ parameters=tuple(parameters),
354
+ server_name=server_name,
355
+ )
356
+
357
+ async def call_tool(
358
+ self,
359
+ name: str,
360
+ arguments: dict[str, Any] | None = None,
361
+ ) -> Result[MCPToolResult, MCPClientError]:
362
+ """Call a tool on the connected server.
363
+
364
+ Args:
365
+ name: Name of the tool to call.
366
+ arguments: Arguments to pass to the tool.
367
+
368
+ Returns:
369
+ Result containing tool result or MCPClientError.
370
+ """
371
+ connected = self._ensure_connected()
372
+ if connected.is_err:
373
+ return Result.err(connected.error)
374
+
375
+ try:
376
+ result = await self._session.call_tool(name, arguments or {})
377
+ return Result.ok(self._parse_tool_result(result, name))
378
+ except Exception as e:
379
+ error_msg = str(e).lower()
380
+ if "not found" in error_msg or "unknown tool" in error_msg:
381
+ return Result.err(
382
+ MCPClientError(
383
+ f"Tool not found: {name}",
384
+ server_name=self._config.name if self._config else None,
385
+ is_retriable=False,
386
+ details={"resource_type": "tool", "resource_id": name},
387
+ )
388
+ )
389
+ return Result.err(
390
+ MCPClientError(
391
+ f"Tool execution failed: {e}",
392
+ server_name=self._config.name if self._config else None,
393
+ is_retriable=False,
394
+ details={"tool_name": name},
395
+ )
396
+ )
397
+
398
+ def _parse_tool_result(self, result: Any, _tool_name: str) -> MCPToolResult:
399
+ """Parse a tool result from the MCP SDK format.
400
+
401
+ Args:
402
+ result: Result object from MCP SDK.
403
+ tool_name: Name of the tool that was called.
404
+
405
+ Returns:
406
+ Parsed MCPToolResult.
407
+ """
408
+ content_items: list[MCPContentItem] = []
409
+
410
+ for item in getattr(result, "content", []):
411
+ if hasattr(item, "text"):
412
+ content_items.append(
413
+ MCPContentItem(type=ContentType.TEXT, text=item.text)
414
+ )
415
+ elif hasattr(item, "data"):
416
+ content_items.append(
417
+ MCPContentItem(
418
+ type=ContentType.IMAGE,
419
+ data=item.data,
420
+ mime_type=getattr(item, "mimeType", "image/png"),
421
+ )
422
+ )
423
+ elif hasattr(item, "uri"):
424
+ content_items.append(
425
+ MCPContentItem(type=ContentType.RESOURCE, uri=item.uri)
426
+ )
427
+
428
+ return MCPToolResult(
429
+ content=tuple(content_items),
430
+ is_error=getattr(result, "isError", False),
431
+ )
432
+
433
+ async def list_resources(self) -> Result[Sequence[MCPResourceDefinition], MCPClientError]:
434
+ """List available resources from the connected server.
435
+
436
+ Returns:
437
+ Result containing sequence of resource definitions or MCPClientError.
438
+ """
439
+ connected = self._ensure_connected()
440
+ if connected.is_err:
441
+ return Result.err(connected.error)
442
+
443
+ try:
444
+ result = await self._session.list_resources()
445
+ resources = tuple(
446
+ MCPResourceDefinition(
447
+ uri=res.uri,
448
+ name=getattr(res, "name", res.uri),
449
+ description=getattr(res, "description", "") or "",
450
+ mime_type=getattr(res, "mimeType", "text/plain"),
451
+ )
452
+ for res in result.resources
453
+ )
454
+ return Result.ok(resources)
455
+ except Exception as e:
456
+ return Result.err(
457
+ MCPClientError.from_exception(
458
+ e,
459
+ server_name=self._config.name if self._config else None,
460
+ )
461
+ )
462
+
463
+ async def read_resource(
464
+ self,
465
+ uri: str,
466
+ ) -> Result[MCPResourceContent, MCPClientError]:
467
+ """Read a resource from the connected server.
468
+
469
+ Args:
470
+ uri: URI of the resource to read.
471
+
472
+ Returns:
473
+ Result containing resource content or MCPClientError.
474
+ """
475
+ connected = self._ensure_connected()
476
+ if connected.is_err:
477
+ return Result.err(connected.error)
478
+
479
+ try:
480
+ result = await self._session.read_resource(uri)
481
+ contents = result.contents
482
+
483
+ if not contents:
484
+ return Result.err(
485
+ MCPClientError(
486
+ f"Resource not found: {uri}",
487
+ server_name=self._config.name if self._config else None,
488
+ is_retriable=False,
489
+ details={"resource_type": "resource", "resource_id": uri},
490
+ )
491
+ )
492
+
493
+ first_content = contents[0]
494
+ return Result.ok(
495
+ MCPResourceContent(
496
+ uri=uri,
497
+ text=getattr(first_content, "text", None),
498
+ blob=getattr(first_content, "blob", None),
499
+ mime_type=getattr(first_content, "mimeType", "text/plain"),
500
+ )
501
+ )
502
+ except Exception as e:
503
+ error_msg = str(e).lower()
504
+ if "not found" in error_msg:
505
+ return Result.err(
506
+ MCPClientError(
507
+ f"Resource not found: {uri}",
508
+ server_name=self._config.name if self._config else None,
509
+ is_retriable=False,
510
+ details={"resource_type": "resource", "resource_id": uri},
511
+ )
512
+ )
513
+ return Result.err(
514
+ MCPClientError.from_exception(
515
+ e,
516
+ server_name=self._config.name if self._config else None,
517
+ )
518
+ )
519
+
520
+ async def list_prompts(self) -> Result[Sequence[MCPPromptDefinition], MCPClientError]:
521
+ """List available prompts from the connected server.
522
+
523
+ Returns:
524
+ Result containing sequence of prompt definitions or MCPClientError.
525
+ """
526
+ connected = self._ensure_connected()
527
+ if connected.is_err:
528
+ return Result.err(connected.error)
529
+
530
+ try:
531
+ result = await self._session.list_prompts()
532
+ prompts = tuple(
533
+ MCPPromptDefinition(
534
+ name=prompt.name,
535
+ description=getattr(prompt, "description", "") or "",
536
+ arguments=tuple(
537
+ MCPPromptArgument(
538
+ name=arg.name,
539
+ description=getattr(arg, "description", "") or "",
540
+ required=getattr(arg, "required", True),
541
+ )
542
+ for arg in getattr(prompt, "arguments", [])
543
+ ),
544
+ )
545
+ for prompt in result.prompts
546
+ )
547
+ return Result.ok(prompts)
548
+ except Exception as e:
549
+ return Result.err(
550
+ MCPClientError.from_exception(
551
+ e,
552
+ server_name=self._config.name if self._config else None,
553
+ )
554
+ )
555
+
556
+ async def get_prompt(
557
+ self,
558
+ name: str,
559
+ arguments: dict[str, str] | None = None,
560
+ ) -> Result[str, MCPClientError]:
561
+ """Get a prompt from the connected server.
562
+
563
+ Args:
564
+ name: Name of the prompt to get.
565
+ arguments: Arguments to fill in the prompt template.
566
+
567
+ Returns:
568
+ Result containing the prompt text or MCPClientError.
569
+ """
570
+ connected = self._ensure_connected()
571
+ if connected.is_err:
572
+ return Result.err(connected.error)
573
+
574
+ try:
575
+ result = await self._session.get_prompt(name, arguments or {})
576
+ # Combine all text messages into a single prompt
577
+ texts = [
578
+ msg.content.text
579
+ for msg in result.messages
580
+ if hasattr(msg.content, "text")
581
+ ]
582
+ return Result.ok("\n".join(texts))
583
+ except Exception as e:
584
+ error_msg = str(e).lower()
585
+ if "not found" in error_msg:
586
+ return Result.err(
587
+ MCPClientError(
588
+ f"Prompt not found: {name}",
589
+ server_name=self._config.name if self._config else None,
590
+ is_retriable=False,
591
+ details={"resource_type": "prompt", "resource_id": name},
592
+ )
593
+ )
594
+ return Result.err(
595
+ MCPClientError.from_exception(
596
+ e,
597
+ server_name=self._config.name if self._config else None,
598
+ )
599
+ )
600
+
601
+
602
+ @asynccontextmanager
603
+ async def create_mcp_client(
604
+ config: MCPServerConfig,
605
+ *,
606
+ max_retries: int = 3,
607
+ ) -> AsyncIterator[MCPClientAdapter]:
608
+ """Create and connect an MCP client as an async context manager.
609
+
610
+ Convenience function that creates an MCPClientAdapter, connects to
611
+ the specified server, and yields the connected client.
612
+
613
+ Args:
614
+ config: Configuration for the server connection.
615
+ max_retries: Maximum number of retry attempts.
616
+
617
+ Yields:
618
+ Connected MCPClientAdapter.
619
+
620
+ Raises:
621
+ MCPConnectionError: If connection fails after all retries.
622
+
623
+ Example:
624
+ async with create_mcp_client(config) as client:
625
+ result = await client.list_tools()
626
+ """
627
+ adapter = MCPClientAdapter(max_retries=max_retries)
628
+ async with adapter:
629
+ result = await adapter.connect(config)
630
+ if result.is_err:
631
+ raise result.error
632
+ yield adapter