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,483 @@
|
|
|
1
|
+
"""MCP Tool Provider for OrchestratorRunner.
|
|
2
|
+
|
|
3
|
+
This module provides the MCPToolProvider class that wraps external MCP tools
|
|
4
|
+
as agent-callable tools during workflow execution.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Converts MCPClientManager tools to agent tool format
|
|
8
|
+
- Handles tool execution with configurable timeouts
|
|
9
|
+
- Implements retry policy for transient failures
|
|
10
|
+
- Provides graceful error handling (no crashes on MCP failures)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
provider = MCPToolProvider(mcp_manager)
|
|
14
|
+
tools = await provider.get_tools()
|
|
15
|
+
result = await provider.call_tool("tool_name", {"arg": "value"})
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
import stamina
|
|
26
|
+
|
|
27
|
+
from ouroboros.core.types import Result
|
|
28
|
+
from ouroboros.mcp.errors import (
|
|
29
|
+
MCPClientError,
|
|
30
|
+
MCPConnectionError,
|
|
31
|
+
MCPTimeoutError,
|
|
32
|
+
MCPToolError,
|
|
33
|
+
)
|
|
34
|
+
from ouroboros.mcp.types import MCPToolDefinition, MCPToolResult
|
|
35
|
+
from ouroboros.observability.logging import get_logger
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from ouroboros.mcp.client.manager import MCPClientManager
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Default timeout for tool execution (30 seconds)
|
|
44
|
+
DEFAULT_TOOL_TIMEOUT = 30.0
|
|
45
|
+
|
|
46
|
+
# Maximum retries for transient failures
|
|
47
|
+
MAX_RETRIES = 3
|
|
48
|
+
|
|
49
|
+
# Retry wait range (exponential backoff)
|
|
50
|
+
RETRY_WAIT_MIN = 0.5
|
|
51
|
+
RETRY_WAIT_MAX = 5.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ToolConflict:
|
|
56
|
+
"""Information about a tool name conflict.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
tool_name: Name of the conflicting tool.
|
|
60
|
+
source: Where the conflict originated (built-in, server name).
|
|
61
|
+
shadowed_by: What is shadowing this tool.
|
|
62
|
+
resolution: How the conflict was resolved.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
tool_name: str
|
|
66
|
+
source: str
|
|
67
|
+
shadowed_by: str
|
|
68
|
+
resolution: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class MCPToolInfo:
|
|
73
|
+
"""Information about an available MCP tool.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
name: Tool name (possibly prefixed).
|
|
77
|
+
original_name: Original tool name from MCP server.
|
|
78
|
+
server_name: Name of the MCP server providing this tool.
|
|
79
|
+
description: Tool description.
|
|
80
|
+
input_schema: JSON Schema for tool parameters.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
original_name: str
|
|
85
|
+
server_name: str
|
|
86
|
+
description: str
|
|
87
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MCPToolProvider:
|
|
91
|
+
"""Provider for MCP tools to integrate with OrchestratorRunner.
|
|
92
|
+
|
|
93
|
+
This class wraps an MCPClientManager and provides:
|
|
94
|
+
- Tool discovery and conversion to agent format
|
|
95
|
+
- Tool execution with timeout handling
|
|
96
|
+
- Retry policy for transient failures
|
|
97
|
+
- Graceful error handling
|
|
98
|
+
|
|
99
|
+
All errors are wrapped and returned as results, not raised as exceptions,
|
|
100
|
+
to ensure MCP failures don't crash the orchestrator.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
manager = MCPClientManager()
|
|
104
|
+
await manager.add_server(config)
|
|
105
|
+
await manager.connect_all()
|
|
106
|
+
|
|
107
|
+
provider = MCPToolProvider(manager)
|
|
108
|
+
tools = await provider.get_tools()
|
|
109
|
+
|
|
110
|
+
result = await provider.call_tool("file_read", {"path": "/tmp/test"})
|
|
111
|
+
if result.is_ok:
|
|
112
|
+
print(result.value.text_content)
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
mcp_manager: MCPClientManager,
|
|
118
|
+
*,
|
|
119
|
+
default_timeout: float = DEFAULT_TOOL_TIMEOUT,
|
|
120
|
+
tool_prefix: str = "",
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Initialize the MCP tool provider.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
mcp_manager: MCPClientManager with connected servers.
|
|
126
|
+
default_timeout: Default timeout for tool execution in seconds.
|
|
127
|
+
tool_prefix: Optional prefix to add to all MCP tool names
|
|
128
|
+
(e.g., "mcp_" to namespace tools).
|
|
129
|
+
"""
|
|
130
|
+
self._manager = mcp_manager
|
|
131
|
+
self._default_timeout = default_timeout
|
|
132
|
+
self._tool_prefix = tool_prefix
|
|
133
|
+
self._tool_map: dict[str, MCPToolInfo] = {}
|
|
134
|
+
self._conflicts: list[ToolConflict] = []
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def tool_prefix(self) -> str:
|
|
138
|
+
"""Return the tool name prefix."""
|
|
139
|
+
return self._tool_prefix
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def conflicts(self) -> Sequence[ToolConflict]:
|
|
143
|
+
"""Return any tool conflicts detected during tool loading."""
|
|
144
|
+
return tuple(self._conflicts)
|
|
145
|
+
|
|
146
|
+
async def get_tools(
|
|
147
|
+
self,
|
|
148
|
+
builtin_tools: Sequence[str] | None = None,
|
|
149
|
+
) -> Sequence[MCPToolInfo]:
|
|
150
|
+
"""Get all available MCP tools.
|
|
151
|
+
|
|
152
|
+
Discovers tools from all connected MCP servers and converts them
|
|
153
|
+
to the agent tool format. Handles tool name conflicts by:
|
|
154
|
+
- Skipping tools that conflict with built-in tools
|
|
155
|
+
- Using first server's tool when multiple servers provide same name
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
builtin_tools: List of built-in tool names to avoid conflicts with.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Sequence of MCPToolInfo for available tools.
|
|
162
|
+
"""
|
|
163
|
+
builtin_set = set(builtin_tools or [])
|
|
164
|
+
self._tool_map.clear()
|
|
165
|
+
self._conflicts.clear()
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
mcp_tools = await self._manager.list_all_tools()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
log.error(
|
|
171
|
+
"orchestrator.mcp_tools.list_failed",
|
|
172
|
+
error=str(e),
|
|
173
|
+
)
|
|
174
|
+
return ()
|
|
175
|
+
|
|
176
|
+
# Track which tools we've seen (for server conflict detection)
|
|
177
|
+
seen_tools: dict[str, str] = {} # tool_name -> first_server_name
|
|
178
|
+
|
|
179
|
+
for tool in mcp_tools:
|
|
180
|
+
prefixed_name = f"{self._tool_prefix}{tool.name}"
|
|
181
|
+
|
|
182
|
+
# Check for built-in tool conflict
|
|
183
|
+
if prefixed_name in builtin_set or tool.name in builtin_set:
|
|
184
|
+
self._conflicts.append(
|
|
185
|
+
ToolConflict(
|
|
186
|
+
tool_name=tool.name,
|
|
187
|
+
source=tool.server_name or "unknown",
|
|
188
|
+
shadowed_by="built-in",
|
|
189
|
+
resolution="MCP tool skipped",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
log.warning(
|
|
193
|
+
"orchestrator.mcp_tools.shadowed_by_builtin",
|
|
194
|
+
tool_name=tool.name,
|
|
195
|
+
server=tool.server_name,
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
# Check for server conflict (same tool from multiple servers)
|
|
200
|
+
if prefixed_name in seen_tools:
|
|
201
|
+
first_server = seen_tools[prefixed_name]
|
|
202
|
+
self._conflicts.append(
|
|
203
|
+
ToolConflict(
|
|
204
|
+
tool_name=tool.name,
|
|
205
|
+
source=tool.server_name or "unknown",
|
|
206
|
+
shadowed_by=first_server,
|
|
207
|
+
resolution="Later server's tool skipped",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
log.warning(
|
|
211
|
+
"orchestrator.mcp_tools.shadowed_by_server",
|
|
212
|
+
tool_name=tool.name,
|
|
213
|
+
server=tool.server_name,
|
|
214
|
+
shadowed_by=first_server,
|
|
215
|
+
)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Register the tool
|
|
219
|
+
seen_tools[prefixed_name] = tool.server_name or "unknown"
|
|
220
|
+
tool_info = MCPToolInfo(
|
|
221
|
+
name=prefixed_name,
|
|
222
|
+
original_name=tool.name,
|
|
223
|
+
server_name=tool.server_name or "unknown",
|
|
224
|
+
description=tool.description,
|
|
225
|
+
input_schema=tool.to_input_schema(),
|
|
226
|
+
)
|
|
227
|
+
self._tool_map[prefixed_name] = tool_info
|
|
228
|
+
|
|
229
|
+
log.info(
|
|
230
|
+
"orchestrator.mcp_tools.loaded",
|
|
231
|
+
tool_count=len(self._tool_map),
|
|
232
|
+
conflict_count=len(self._conflicts),
|
|
233
|
+
servers=list(set(t.server_name for t in self._tool_map.values())),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return tuple(self._tool_map.values())
|
|
237
|
+
|
|
238
|
+
def get_tool_names(self) -> Sequence[str]:
|
|
239
|
+
"""Get list of available tool names.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Sequence of tool names (with prefix if configured).
|
|
243
|
+
"""
|
|
244
|
+
return tuple(self._tool_map.keys())
|
|
245
|
+
|
|
246
|
+
def has_tool(self, name: str) -> bool:
|
|
247
|
+
"""Check if a tool is available.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
name: Tool name to check (with prefix if applicable).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if tool is available.
|
|
254
|
+
"""
|
|
255
|
+
return name in self._tool_map
|
|
256
|
+
|
|
257
|
+
def get_tool_info(self, name: str) -> MCPToolInfo | None:
|
|
258
|
+
"""Get info for a specific tool.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
name: Tool name (with prefix if applicable).
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
MCPToolInfo or None if not found.
|
|
265
|
+
"""
|
|
266
|
+
return self._tool_map.get(name)
|
|
267
|
+
|
|
268
|
+
async def call_tool(
|
|
269
|
+
self,
|
|
270
|
+
name: str,
|
|
271
|
+
arguments: dict[str, Any] | None = None,
|
|
272
|
+
*,
|
|
273
|
+
timeout: float | None = None,
|
|
274
|
+
) -> Result[MCPToolResult, MCPToolError]:
|
|
275
|
+
"""Call an MCP tool with the given arguments.
|
|
276
|
+
|
|
277
|
+
Handles:
|
|
278
|
+
- Timeout with configurable duration
|
|
279
|
+
- Retry for transient failures (network errors, connection issues)
|
|
280
|
+
- Graceful error handling (returns error result, doesn't raise)
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
name: Tool name (with prefix if applicable).
|
|
284
|
+
arguments: Tool arguments as a dict.
|
|
285
|
+
timeout: Optional timeout override in seconds.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Result containing MCPToolResult on success or MCPToolError on failure.
|
|
289
|
+
"""
|
|
290
|
+
tool_info = self._tool_map.get(name)
|
|
291
|
+
if not tool_info:
|
|
292
|
+
return Result.err(
|
|
293
|
+
MCPToolError(
|
|
294
|
+
f"Tool not found: {name}",
|
|
295
|
+
tool_name=name,
|
|
296
|
+
is_retriable=False,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
effective_timeout = timeout or self._default_timeout
|
|
301
|
+
|
|
302
|
+
log.debug(
|
|
303
|
+
"orchestrator.mcp_tools.call_start",
|
|
304
|
+
tool_name=name,
|
|
305
|
+
server=tool_info.server_name,
|
|
306
|
+
timeout=effective_timeout,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
# Use stamina for retries on transient failures
|
|
311
|
+
result = await self._call_with_retry(
|
|
312
|
+
tool_info=tool_info,
|
|
313
|
+
arguments=arguments or {},
|
|
314
|
+
timeout=effective_timeout,
|
|
315
|
+
)
|
|
316
|
+
return result
|
|
317
|
+
except Exception as e:
|
|
318
|
+
# Catch any unexpected errors and wrap them
|
|
319
|
+
log.exception(
|
|
320
|
+
"orchestrator.mcp_tools.unexpected_error",
|
|
321
|
+
tool_name=name,
|
|
322
|
+
error=str(e),
|
|
323
|
+
)
|
|
324
|
+
return Result.err(
|
|
325
|
+
MCPToolError(
|
|
326
|
+
f"Unexpected error calling tool {name}: {e}",
|
|
327
|
+
tool_name=name,
|
|
328
|
+
server_name=tool_info.server_name,
|
|
329
|
+
is_retriable=False,
|
|
330
|
+
details={"exception_type": type(e).__name__},
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
async def _call_with_retry(
|
|
335
|
+
self,
|
|
336
|
+
tool_info: MCPToolInfo,
|
|
337
|
+
arguments: dict[str, Any],
|
|
338
|
+
timeout: float,
|
|
339
|
+
) -> Result[MCPToolResult, MCPToolError]:
|
|
340
|
+
"""Call tool with retry logic for transient failures.
|
|
341
|
+
|
|
342
|
+
Uses stamina for exponential backoff retries on:
|
|
343
|
+
- Connection errors
|
|
344
|
+
- Timeout errors (if marked retriable)
|
|
345
|
+
- Other transient MCPClientErrors
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
tool_info: Information about the tool to call.
|
|
349
|
+
arguments: Tool arguments.
|
|
350
|
+
timeout: Timeout in seconds.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Result containing MCPToolResult or MCPToolError.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
@stamina.retry(
|
|
357
|
+
on=(MCPConnectionError, asyncio.TimeoutError),
|
|
358
|
+
attempts=MAX_RETRIES,
|
|
359
|
+
wait_initial=RETRY_WAIT_MIN,
|
|
360
|
+
wait_max=RETRY_WAIT_MAX,
|
|
361
|
+
wait_jitter=0.5,
|
|
362
|
+
)
|
|
363
|
+
async def _do_call() -> Result[MCPToolResult, MCPClientError]:
|
|
364
|
+
# Use call_tool with server name for explicit routing
|
|
365
|
+
return await self._manager.call_tool(
|
|
366
|
+
server_name=tool_info.server_name,
|
|
367
|
+
tool_name=tool_info.original_name,
|
|
368
|
+
arguments=arguments,
|
|
369
|
+
timeout=timeout,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
result = await _do_call()
|
|
374
|
+
except asyncio.TimeoutError:
|
|
375
|
+
log.warning(
|
|
376
|
+
"orchestrator.mcp_tools.timeout_after_retries",
|
|
377
|
+
tool_name=tool_info.name,
|
|
378
|
+
timeout=timeout,
|
|
379
|
+
)
|
|
380
|
+
return Result.err(
|
|
381
|
+
MCPToolError(
|
|
382
|
+
f"Tool call timed out after {MAX_RETRIES} retries: {tool_info.name}",
|
|
383
|
+
tool_name=tool_info.name,
|
|
384
|
+
server_name=tool_info.server_name,
|
|
385
|
+
is_retriable=False,
|
|
386
|
+
details={"timeout_seconds": timeout, "retries": MAX_RETRIES},
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
except MCPConnectionError as e:
|
|
390
|
+
log.warning(
|
|
391
|
+
"orchestrator.mcp_tools.connection_failed_after_retries",
|
|
392
|
+
tool_name=tool_info.name,
|
|
393
|
+
error=str(e),
|
|
394
|
+
)
|
|
395
|
+
return Result.err(
|
|
396
|
+
MCPToolError(
|
|
397
|
+
f"Connection failed after {MAX_RETRIES} retries: {e}",
|
|
398
|
+
tool_name=tool_info.name,
|
|
399
|
+
server_name=tool_info.server_name,
|
|
400
|
+
is_retriable=False,
|
|
401
|
+
details={"retries": MAX_RETRIES},
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# Convert MCPClientError to MCPToolError for consistency
|
|
406
|
+
if result.is_err:
|
|
407
|
+
error = result.error
|
|
408
|
+
log.warning(
|
|
409
|
+
"orchestrator.mcp_tools.call_failed",
|
|
410
|
+
tool_name=tool_info.name,
|
|
411
|
+
error=str(error),
|
|
412
|
+
)
|
|
413
|
+
return Result.err(
|
|
414
|
+
MCPToolError(
|
|
415
|
+
f"Tool execution failed: {error}",
|
|
416
|
+
tool_name=tool_info.name,
|
|
417
|
+
server_name=tool_info.server_name,
|
|
418
|
+
is_retriable=error.is_retriable if isinstance(error, MCPClientError) else False,
|
|
419
|
+
details={"original_error": str(error)},
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
log.debug(
|
|
424
|
+
"orchestrator.mcp_tools.call_success",
|
|
425
|
+
tool_name=tool_info.name,
|
|
426
|
+
is_error=result.value.is_error,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return Result.ok(result.value)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass(frozen=True, slots=True)
|
|
433
|
+
class MCPToolsLoadedEvent:
|
|
434
|
+
"""Event data when MCP tools are loaded.
|
|
435
|
+
|
|
436
|
+
Attributes:
|
|
437
|
+
tool_count: Number of tools loaded.
|
|
438
|
+
server_names: Names of servers providing tools.
|
|
439
|
+
conflict_count: Number of tool conflicts detected.
|
|
440
|
+
conflicts: Details of any conflicts.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
tool_count: int
|
|
444
|
+
server_names: tuple[str, ...]
|
|
445
|
+
conflict_count: int
|
|
446
|
+
conflicts: tuple[ToolConflict, ...] = field(default_factory=tuple)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def create_mcp_tools_loaded_event(
|
|
450
|
+
session_id: str,
|
|
451
|
+
provider: MCPToolProvider,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
"""Create event data for MCP tools loaded.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
session_id: Current session ID.
|
|
457
|
+
provider: MCPToolProvider with loaded tools.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Event data dict for inclusion in BaseEvent.
|
|
461
|
+
"""
|
|
462
|
+
tools = list(provider._tool_map.values())
|
|
463
|
+
server_names = tuple(set(t.server_name for t in tools))
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
"session_id": session_id,
|
|
467
|
+
"tool_count": len(tools),
|
|
468
|
+
"server_names": server_names,
|
|
469
|
+
"conflict_count": len(provider.conflicts),
|
|
470
|
+
"tool_names": [t.name for t in tools],
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
__all__ = [
|
|
475
|
+
"DEFAULT_TOOL_TIMEOUT",
|
|
476
|
+
"MAX_RETRIES",
|
|
477
|
+
"MCPToolError",
|
|
478
|
+
"MCPToolInfo",
|
|
479
|
+
"MCPToolProvider",
|
|
480
|
+
"MCPToolsLoadedEvent",
|
|
481
|
+
"ToolConflict",
|
|
482
|
+
"create_mcp_tools_loaded_event",
|
|
483
|
+
]
|
ouroboros/orchestrator/runner.py
CHANGED
|
@@ -34,16 +34,19 @@ from ouroboros.core.types import Result
|
|
|
34
34
|
from ouroboros.observability.logging import get_logger
|
|
35
35
|
from ouroboros.orchestrator.adapter import DEFAULT_TOOLS, AgentMessage, ClaudeAgentAdapter
|
|
36
36
|
from ouroboros.orchestrator.events import (
|
|
37
|
+
create_mcp_tools_loaded_event,
|
|
37
38
|
create_progress_event,
|
|
38
39
|
create_session_completed_event,
|
|
39
40
|
create_session_failed_event,
|
|
40
41
|
create_session_started_event,
|
|
41
42
|
create_tool_called_event,
|
|
42
43
|
)
|
|
44
|
+
from ouroboros.orchestrator.mcp_tools import MCPToolProvider
|
|
43
45
|
from ouroboros.orchestrator.session import SessionRepository, SessionStatus
|
|
44
46
|
|
|
45
47
|
if TYPE_CHECKING:
|
|
46
48
|
from ouroboros.core.seed import Seed
|
|
49
|
+
from ouroboros.mcp.client.manager import MCPClientManager
|
|
47
50
|
from ouroboros.persistence.event_store import EventStore
|
|
48
51
|
|
|
49
52
|
log = get_logger(__name__)
|
|
@@ -168,6 +171,9 @@ class OrchestratorRunner:
|
|
|
168
171
|
|
|
169
172
|
Converts Seed specifications to agent prompts, executes via adapter,
|
|
170
173
|
tracks progress through event emission, and displays status via Rich.
|
|
174
|
+
|
|
175
|
+
Optionally integrates with external MCP servers via MCPClientManager
|
|
176
|
+
to provide additional tools to the Claude Agent during execution.
|
|
171
177
|
"""
|
|
172
178
|
|
|
173
179
|
def __init__(
|
|
@@ -175,6 +181,8 @@ class OrchestratorRunner:
|
|
|
175
181
|
adapter: ClaudeAgentAdapter,
|
|
176
182
|
event_store: EventStore,
|
|
177
183
|
console: Console | None = None,
|
|
184
|
+
mcp_manager: MCPClientManager | None = None,
|
|
185
|
+
mcp_tool_prefix: str = "",
|
|
178
186
|
) -> None:
|
|
179
187
|
"""Initialize orchestrator runner.
|
|
180
188
|
|
|
@@ -182,11 +190,108 @@ class OrchestratorRunner:
|
|
|
182
190
|
adapter: Claude Agent adapter for task execution.
|
|
183
191
|
event_store: Event store for persistence.
|
|
184
192
|
console: Rich console for output. Uses default if not provided.
|
|
193
|
+
mcp_manager: Optional MCP client manager for external tool integration.
|
|
194
|
+
When provided, tools from connected MCP servers will be
|
|
195
|
+
made available to the Claude Agent during execution.
|
|
196
|
+
mcp_tool_prefix: Optional prefix to add to MCP tool names to avoid
|
|
197
|
+
conflicts (e.g., "mcp_" makes "read" become "mcp_read").
|
|
185
198
|
"""
|
|
186
199
|
self._adapter = adapter
|
|
187
200
|
self._event_store = event_store
|
|
188
201
|
self._console = console or Console()
|
|
189
202
|
self._session_repo = SessionRepository(event_store)
|
|
203
|
+
self._mcp_manager: MCPClientManager | None = mcp_manager
|
|
204
|
+
self._mcp_tool_prefix = mcp_tool_prefix
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def mcp_manager(self) -> MCPClientManager | None:
|
|
208
|
+
"""Return the MCP client manager if configured.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The MCPClientManager instance or None if not configured.
|
|
212
|
+
"""
|
|
213
|
+
return self._mcp_manager
|
|
214
|
+
|
|
215
|
+
async def _get_merged_tools(
|
|
216
|
+
self,
|
|
217
|
+
session_id: str,
|
|
218
|
+
tool_prefix: str = "",
|
|
219
|
+
) -> tuple[list[str], MCPToolProvider | None]:
|
|
220
|
+
"""Get merged tool list from DEFAULT_TOOLS and MCP tools.
|
|
221
|
+
|
|
222
|
+
If MCP manager is configured, discovers tools from connected servers
|
|
223
|
+
and merges them with DEFAULT_TOOLS. DEFAULT_TOOLS always take priority.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
session_id: Current session ID for event emission.
|
|
227
|
+
tool_prefix: Optional prefix for MCP tool names.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Tuple of (merged tool names list, MCPToolProvider or None).
|
|
231
|
+
"""
|
|
232
|
+
# Start with default tools
|
|
233
|
+
merged_tools = list(DEFAULT_TOOLS)
|
|
234
|
+
|
|
235
|
+
if self._mcp_manager is None:
|
|
236
|
+
return merged_tools, None
|
|
237
|
+
|
|
238
|
+
# Create provider and get MCP tools
|
|
239
|
+
provider = MCPToolProvider(
|
|
240
|
+
self._mcp_manager,
|
|
241
|
+
tool_prefix=tool_prefix,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
mcp_tools = await provider.get_tools(builtin_tools=DEFAULT_TOOLS)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
log.warning(
|
|
248
|
+
"orchestrator.runner.mcp_tools_load_failed",
|
|
249
|
+
session_id=session_id,
|
|
250
|
+
error=str(e),
|
|
251
|
+
)
|
|
252
|
+
return merged_tools, None
|
|
253
|
+
|
|
254
|
+
if not mcp_tools:
|
|
255
|
+
log.info(
|
|
256
|
+
"orchestrator.runner.no_mcp_tools_available",
|
|
257
|
+
session_id=session_id,
|
|
258
|
+
)
|
|
259
|
+
return merged_tools, provider
|
|
260
|
+
|
|
261
|
+
# Add MCP tool names to merged list
|
|
262
|
+
mcp_tool_names = [t.name for t in mcp_tools]
|
|
263
|
+
merged_tools.extend(mcp_tool_names)
|
|
264
|
+
|
|
265
|
+
# Log conflicts
|
|
266
|
+
for conflict in provider.conflicts:
|
|
267
|
+
log.warning(
|
|
268
|
+
"orchestrator.runner.tool_conflict",
|
|
269
|
+
tool_name=conflict.tool_name,
|
|
270
|
+
source=conflict.source,
|
|
271
|
+
shadowed_by=conflict.shadowed_by,
|
|
272
|
+
resolution=conflict.resolution,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Emit MCP tools loaded event
|
|
276
|
+
server_names = tuple(set(t.server_name for t in mcp_tools))
|
|
277
|
+
mcp_event = create_mcp_tools_loaded_event(
|
|
278
|
+
session_id=session_id,
|
|
279
|
+
tool_count=len(mcp_tools),
|
|
280
|
+
server_names=server_names,
|
|
281
|
+
conflict_count=len(provider.conflicts),
|
|
282
|
+
tool_names=mcp_tool_names,
|
|
283
|
+
)
|
|
284
|
+
await self._event_store.append(mcp_event)
|
|
285
|
+
|
|
286
|
+
log.info(
|
|
287
|
+
"orchestrator.runner.mcp_tools_loaded",
|
|
288
|
+
session_id=session_id,
|
|
289
|
+
mcp_tool_count=len(mcp_tools),
|
|
290
|
+
total_tools=len(merged_tools),
|
|
291
|
+
servers=server_names,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return merged_tools, provider
|
|
190
295
|
|
|
191
296
|
async def execute_seed(
|
|
192
297
|
self,
|
|
@@ -245,6 +350,12 @@ class OrchestratorRunner:
|
|
|
245
350
|
system_prompt = build_system_prompt(seed)
|
|
246
351
|
task_prompt = build_task_prompt(seed)
|
|
247
352
|
|
|
353
|
+
# Get merged tools (DEFAULT_TOOLS + MCP tools if configured)
|
|
354
|
+
merged_tools, mcp_provider = await self._get_merged_tools(
|
|
355
|
+
session_id=tracker.session_id,
|
|
356
|
+
tool_prefix=self._mcp_tool_prefix,
|
|
357
|
+
)
|
|
358
|
+
|
|
248
359
|
# Execute with progress display
|
|
249
360
|
messages_processed = 0
|
|
250
361
|
final_message = ""
|
|
@@ -265,7 +376,7 @@ class OrchestratorRunner:
|
|
|
265
376
|
|
|
266
377
|
async for message in self._adapter.execute_task(
|
|
267
378
|
prompt=task_prompt,
|
|
268
|
-
tools=
|
|
379
|
+
tools=merged_tools,
|
|
269
380
|
system_prompt=system_prompt,
|
|
270
381
|
):
|
|
271
382
|
messages_processed += 1
|
|
@@ -460,6 +571,12 @@ Note: This is a resumed session. Please continue from where execution was interr
|
|
|
460
571
|
# Get Claude Agent session ID if stored
|
|
461
572
|
agent_session_id = tracker.progress.get("agent_session_id")
|
|
462
573
|
|
|
574
|
+
# Get merged tools (DEFAULT_TOOLS + MCP tools if configured)
|
|
575
|
+
merged_tools, mcp_provider = await self._get_merged_tools(
|
|
576
|
+
session_id=session_id,
|
|
577
|
+
tool_prefix=self._mcp_tool_prefix,
|
|
578
|
+
)
|
|
579
|
+
|
|
463
580
|
start_time = datetime.now(UTC)
|
|
464
581
|
messages_processed = tracker.messages_processed
|
|
465
582
|
final_message = ""
|
|
@@ -480,7 +597,7 @@ Note: This is a resumed session. Please continue from where execution was interr
|
|
|
480
597
|
|
|
481
598
|
async for message in self._adapter.execute_task(
|
|
482
599
|
prompt=resume_prompt,
|
|
483
|
-
tools=
|
|
600
|
+
tools=merged_tools,
|
|
484
601
|
system_prompt=system_prompt,
|
|
485
602
|
resume_session_id=agent_session_id,
|
|
486
603
|
):
|