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,419 @@
|
|
|
1
|
+
"""MCP Client Configuration Loading for OrchestratorRunner.
|
|
2
|
+
|
|
3
|
+
This module provides configuration loading for external MCP servers that
|
|
4
|
+
the orchestrator can connect to for additional tools.
|
|
5
|
+
|
|
6
|
+
Configuration Schema (YAML):
|
|
7
|
+
mcp_servers:
|
|
8
|
+
- name: "filesystem"
|
|
9
|
+
transport: "stdio"
|
|
10
|
+
command: "npx"
|
|
11
|
+
args: ["-y", "@anthropic/mcp-server-filesystem", "/workspace"]
|
|
12
|
+
- name: "github"
|
|
13
|
+
transport: "stdio"
|
|
14
|
+
command: "npx"
|
|
15
|
+
args: ["-y", "@anthropic/mcp-server-github"]
|
|
16
|
+
env:
|
|
17
|
+
GITHUB_TOKEN: "${GITHUB_TOKEN}" # Environment variable reference
|
|
18
|
+
connection:
|
|
19
|
+
timeout_seconds: 30
|
|
20
|
+
retry_attempts: 3
|
|
21
|
+
|
|
22
|
+
Security Features:
|
|
23
|
+
- Credentials via environment variables only (no plaintext)
|
|
24
|
+
- Config file permission warnings (world-readable files)
|
|
25
|
+
- Server name sanitization for logging
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import stat
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
from ouroboros.core.types import Result
|
|
40
|
+
from ouroboros.mcp.errors import MCPClientError
|
|
41
|
+
from ouroboros.mcp.types import MCPServerConfig, TransportType
|
|
42
|
+
from ouroboros.observability.logging import get_logger
|
|
43
|
+
|
|
44
|
+
log = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Pattern for environment variable substitution: ${VAR_NAME}
|
|
48
|
+
ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class MCPConnectionConfig:
|
|
53
|
+
"""Connection configuration for MCP servers.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
timeout_seconds: Default timeout for MCP operations.
|
|
57
|
+
retry_attempts: Number of retry attempts for failed connections.
|
|
58
|
+
health_check_interval: Seconds between health checks.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
timeout_seconds: float = 30.0
|
|
62
|
+
retry_attempts: int = 3
|
|
63
|
+
health_check_interval: float = 60.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class MCPClientConfig:
|
|
68
|
+
"""Complete MCP client configuration.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
servers: List of MCP server configurations.
|
|
72
|
+
connection: Connection settings.
|
|
73
|
+
tool_prefix: Optional prefix for all MCP tool names.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
servers: tuple[MCPServerConfig, ...] = field(default_factory=tuple)
|
|
77
|
+
connection: MCPConnectionConfig = field(default_factory=MCPConnectionConfig)
|
|
78
|
+
tool_prefix: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ConfigError(MCPClientError):
|
|
82
|
+
"""Error during configuration loading."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
message: str,
|
|
87
|
+
*,
|
|
88
|
+
config_path: str | None = None,
|
|
89
|
+
details: dict[str, Any] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Initialize configuration error.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
message: Error description.
|
|
95
|
+
config_path: Path to the config file.
|
|
96
|
+
details: Additional error details.
|
|
97
|
+
"""
|
|
98
|
+
full_details = details or {}
|
|
99
|
+
if config_path:
|
|
100
|
+
full_details["config_path"] = config_path
|
|
101
|
+
super().__init__(message, is_retriable=False, details=full_details)
|
|
102
|
+
self.config_path = config_path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def substitute_env_vars(value: str) -> str:
|
|
106
|
+
"""Substitute environment variable references in a string.
|
|
107
|
+
|
|
108
|
+
Replaces ${VAR_NAME} with the value of the environment variable.
|
|
109
|
+
Raises ValueError if a referenced variable is not set.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
value: String potentially containing ${VAR_NAME} references.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
String with environment variables substituted.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If an environment variable is not set.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def replace_var(match: re.Match[str]) -> str:
|
|
122
|
+
var_name = match.group(1)
|
|
123
|
+
env_value = os.environ.get(var_name)
|
|
124
|
+
if env_value is None:
|
|
125
|
+
raise ValueError(f"Environment variable not set: {var_name}")
|
|
126
|
+
return env_value
|
|
127
|
+
|
|
128
|
+
return ENV_VAR_PATTERN.sub(replace_var, value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def substitute_env_vars_in_dict(data: dict[str, Any]) -> dict[str, Any]:
|
|
132
|
+
"""Recursively substitute environment variables in a dict.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
data: Dictionary potentially containing ${VAR_NAME} references.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Dictionary with environment variables substituted.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If an environment variable is not set.
|
|
142
|
+
"""
|
|
143
|
+
result: dict[str, Any] = {}
|
|
144
|
+
for key, value in data.items():
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
result[key] = substitute_env_vars(value)
|
|
147
|
+
elif isinstance(value, dict):
|
|
148
|
+
result[key] = substitute_env_vars_in_dict(value)
|
|
149
|
+
elif isinstance(value, list):
|
|
150
|
+
result[key] = [
|
|
151
|
+
substitute_env_vars(v) if isinstance(v, str) else v for v in value
|
|
152
|
+
]
|
|
153
|
+
else:
|
|
154
|
+
result[key] = value
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_file_permissions(path: Path) -> list[str]:
|
|
159
|
+
"""Check file permissions and return security warnings.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path: Path to the config file.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of security warning messages.
|
|
166
|
+
"""
|
|
167
|
+
warnings: list[str] = []
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
mode = path.stat().st_mode
|
|
171
|
+
# Check if world-readable
|
|
172
|
+
if mode & stat.S_IROTH:
|
|
173
|
+
warnings.append(
|
|
174
|
+
f"Config file is world-readable: {path}. "
|
|
175
|
+
"Consider restricting permissions with: chmod 600"
|
|
176
|
+
)
|
|
177
|
+
# Check if group-readable (less severe)
|
|
178
|
+
if mode & stat.S_IRGRP:
|
|
179
|
+
warnings.append(
|
|
180
|
+
f"Config file is group-readable: {path}. "
|
|
181
|
+
"Consider restricting permissions with: chmod 600"
|
|
182
|
+
)
|
|
183
|
+
except OSError as e:
|
|
184
|
+
log.warning(
|
|
185
|
+
"orchestrator.mcp_config.permission_check_failed",
|
|
186
|
+
path=str(path),
|
|
187
|
+
error=str(e),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return warnings
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sanitize_server_name(name: str) -> str:
|
|
194
|
+
"""Sanitize server name for safe logging.
|
|
195
|
+
|
|
196
|
+
Removes potentially sensitive information from server names
|
|
197
|
+
while keeping them identifiable.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
name: Server name to sanitize.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Sanitized server name.
|
|
204
|
+
"""
|
|
205
|
+
# Remove anything that looks like a credential or token
|
|
206
|
+
sanitized = re.sub(r"(token|key|secret|password|auth)[^a-z]*[a-z0-9]+", r"\1=***", name, flags=re.IGNORECASE)
|
|
207
|
+
# Truncate if too long
|
|
208
|
+
if len(sanitized) > 50:
|
|
209
|
+
sanitized = sanitized[:47] + "..."
|
|
210
|
+
return sanitized
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_server_config(server_data: dict[str, Any]) -> Result[MCPServerConfig, ConfigError]:
|
|
214
|
+
"""Parse a single server configuration.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
server_data: Server configuration dictionary.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Result containing MCPServerConfig or ConfigError.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
name = server_data.get("name")
|
|
224
|
+
if not name:
|
|
225
|
+
return Result.err(ConfigError("Server configuration missing 'name' field"))
|
|
226
|
+
|
|
227
|
+
transport_str = server_data.get("transport", "stdio")
|
|
228
|
+
try:
|
|
229
|
+
transport = TransportType(transport_str)
|
|
230
|
+
except ValueError:
|
|
231
|
+
return Result.err(
|
|
232
|
+
ConfigError(
|
|
233
|
+
f"Invalid transport type: {transport_str}",
|
|
234
|
+
details={"valid_types": [t.value for t in TransportType]},
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Handle environment variable substitution in env dict
|
|
239
|
+
env_raw = server_data.get("env", {})
|
|
240
|
+
try:
|
|
241
|
+
env = substitute_env_vars_in_dict(env_raw)
|
|
242
|
+
except ValueError as e:
|
|
243
|
+
return Result.err(
|
|
244
|
+
ConfigError(
|
|
245
|
+
f"Environment variable substitution failed for server '{name}': {e}",
|
|
246
|
+
details={"server": sanitize_server_name(name)},
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Build config
|
|
251
|
+
config = MCPServerConfig(
|
|
252
|
+
name=name,
|
|
253
|
+
transport=transport,
|
|
254
|
+
command=server_data.get("command"),
|
|
255
|
+
args=tuple(server_data.get("args", [])),
|
|
256
|
+
url=server_data.get("url"),
|
|
257
|
+
env=env,
|
|
258
|
+
timeout=server_data.get("timeout", 30.0),
|
|
259
|
+
headers=server_data.get("headers", {}),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return Result.ok(config)
|
|
263
|
+
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return Result.err(
|
|
266
|
+
ConfigError(
|
|
267
|
+
f"Failed to parse server configuration: {e}",
|
|
268
|
+
details={"error_type": type(e).__name__},
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def load_mcp_config(config_path: Path) -> Result[MCPClientConfig, ConfigError]:
|
|
274
|
+
"""Load MCP client configuration from a YAML file.
|
|
275
|
+
|
|
276
|
+
Performs:
|
|
277
|
+
- YAML parsing
|
|
278
|
+
- Environment variable substitution for credentials
|
|
279
|
+
- File permission security checks
|
|
280
|
+
- Server configuration validation
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
config_path: Path to the YAML configuration file.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Result containing MCPClientConfig or ConfigError.
|
|
287
|
+
"""
|
|
288
|
+
log.info(
|
|
289
|
+
"orchestrator.mcp_config.loading",
|
|
290
|
+
path=str(config_path),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Check file exists
|
|
294
|
+
if not config_path.exists():
|
|
295
|
+
return Result.err(
|
|
296
|
+
ConfigError(
|
|
297
|
+
f"Configuration file not found: {config_path}",
|
|
298
|
+
config_path=str(config_path),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if not config_path.is_file():
|
|
303
|
+
return Result.err(
|
|
304
|
+
ConfigError(
|
|
305
|
+
f"Configuration path is not a file: {config_path}",
|
|
306
|
+
config_path=str(config_path),
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Check file permissions
|
|
311
|
+
permission_warnings = check_file_permissions(config_path)
|
|
312
|
+
for warning in permission_warnings:
|
|
313
|
+
log.warning(
|
|
314
|
+
"orchestrator.mcp_config.security_warning",
|
|
315
|
+
warning=warning,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Load YAML
|
|
319
|
+
try:
|
|
320
|
+
with open(config_path) as f:
|
|
321
|
+
raw_config = yaml.safe_load(f)
|
|
322
|
+
except yaml.YAMLError as e:
|
|
323
|
+
return Result.err(
|
|
324
|
+
ConfigError(
|
|
325
|
+
f"Invalid YAML in configuration file: {e}",
|
|
326
|
+
config_path=str(config_path),
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
except OSError as e:
|
|
330
|
+
return Result.err(
|
|
331
|
+
ConfigError(
|
|
332
|
+
f"Failed to read configuration file: {e}",
|
|
333
|
+
config_path=str(config_path),
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if not raw_config:
|
|
338
|
+
return Result.err(
|
|
339
|
+
ConfigError(
|
|
340
|
+
"Configuration file is empty",
|
|
341
|
+
config_path=str(config_path),
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if not isinstance(raw_config, dict):
|
|
346
|
+
return Result.err(
|
|
347
|
+
ConfigError(
|
|
348
|
+
"Configuration must be a YAML mapping",
|
|
349
|
+
config_path=str(config_path),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Parse servers
|
|
354
|
+
servers: list[MCPServerConfig] = []
|
|
355
|
+
servers_data = raw_config.get("mcp_servers", [])
|
|
356
|
+
|
|
357
|
+
if not isinstance(servers_data, list):
|
|
358
|
+
return Result.err(
|
|
359
|
+
ConfigError(
|
|
360
|
+
"'mcp_servers' must be a list",
|
|
361
|
+
config_path=str(config_path),
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
for i, server_data in enumerate(servers_data):
|
|
366
|
+
if not isinstance(server_data, dict):
|
|
367
|
+
return Result.err(
|
|
368
|
+
ConfigError(
|
|
369
|
+
f"Server configuration at index {i} must be a mapping",
|
|
370
|
+
config_path=str(config_path),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
result = parse_server_config(server_data)
|
|
375
|
+
if result.is_err:
|
|
376
|
+
return Result.err(
|
|
377
|
+
ConfigError(
|
|
378
|
+
f"Server configuration error at index {i}: {result.error}",
|
|
379
|
+
config_path=str(config_path),
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
servers.append(result.value)
|
|
383
|
+
|
|
384
|
+
# Parse connection config
|
|
385
|
+
connection_data = raw_config.get("connection", {})
|
|
386
|
+
connection = MCPConnectionConfig(
|
|
387
|
+
timeout_seconds=connection_data.get("timeout_seconds", 30.0),
|
|
388
|
+
retry_attempts=connection_data.get("retry_attempts", 3),
|
|
389
|
+
health_check_interval=connection_data.get("health_check_interval", 60.0),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# Get tool prefix
|
|
393
|
+
tool_prefix = raw_config.get("tool_prefix", "")
|
|
394
|
+
|
|
395
|
+
config = MCPClientConfig(
|
|
396
|
+
servers=tuple(servers),
|
|
397
|
+
connection=connection,
|
|
398
|
+
tool_prefix=tool_prefix,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
log.info(
|
|
402
|
+
"orchestrator.mcp_config.loaded",
|
|
403
|
+
server_count=len(servers),
|
|
404
|
+
servers=[sanitize_server_name(s.name) for s in servers],
|
|
405
|
+
tool_prefix=tool_prefix or "(none)",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return Result.ok(config)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
__all__ = [
|
|
412
|
+
"ConfigError",
|
|
413
|
+
"MCPClientConfig",
|
|
414
|
+
"MCPConnectionConfig",
|
|
415
|
+
"check_file_permissions",
|
|
416
|
+
"load_mcp_config",
|
|
417
|
+
"sanitize_server_name",
|
|
418
|
+
"substitute_env_vars",
|
|
419
|
+
]
|