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.
- ouroboros/__init__.py +1 -1
- ouroboros/bigbang/__init__.py +9 -0
- ouroboros/bigbang/ontology.py +180 -0
- ouroboros/cli/commands/__init__.py +2 -0
- 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.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/METADATA +10 -5
- {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/RECORD +42 -17
- {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/WHEEL +0 -0
- {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/entry_points.txt +0 -0
- {ouroboros_ai-0.3.0.dist-info → ouroboros_ai-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""MCP Server security layer.
|
|
2
|
+
|
|
3
|
+
This module provides security features for the MCP server including:
|
|
4
|
+
- Authentication (API key, token validation)
|
|
5
|
+
- Authorization (tool-level permissions)
|
|
6
|
+
- Input validation
|
|
7
|
+
- Rate limiting
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
import structlog
|
|
21
|
+
|
|
22
|
+
from ouroboros.core.types import Result
|
|
23
|
+
from ouroboros.mcp.errors import MCPAuthError, MCPServerError
|
|
24
|
+
|
|
25
|
+
log = structlog.get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthMethod(StrEnum):
|
|
31
|
+
"""Authentication method type."""
|
|
32
|
+
|
|
33
|
+
NONE = "none"
|
|
34
|
+
API_KEY = "api_key"
|
|
35
|
+
BEARER_TOKEN = "bearer_token"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Permission(StrEnum):
|
|
39
|
+
"""Permission levels for tool access."""
|
|
40
|
+
|
|
41
|
+
READ = "read"
|
|
42
|
+
WRITE = "write"
|
|
43
|
+
EXECUTE = "execute"
|
|
44
|
+
ADMIN = "admin"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class AuthConfig:
|
|
49
|
+
"""Authentication configuration.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
method: Authentication method to use.
|
|
53
|
+
api_keys: Valid API keys (for API_KEY method).
|
|
54
|
+
token_secret: Secret for token validation (for BEARER_TOKEN method).
|
|
55
|
+
required: Whether authentication is required.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
method: AuthMethod = AuthMethod.NONE
|
|
59
|
+
api_keys: frozenset[str] = field(default_factory=frozenset)
|
|
60
|
+
token_secret: str | None = None
|
|
61
|
+
required: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class RateLimitConfig:
|
|
66
|
+
"""Rate limiting configuration.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
enabled: Whether rate limiting is enabled.
|
|
70
|
+
requests_per_minute: Maximum requests per minute per client.
|
|
71
|
+
burst_size: Maximum burst size.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
enabled: bool = False
|
|
75
|
+
requests_per_minute: int = 60
|
|
76
|
+
burst_size: int = 10
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class ToolPermission:
|
|
81
|
+
"""Permission configuration for a tool.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
tool_name: Name of the tool.
|
|
85
|
+
required_permissions: Permissions required to call this tool.
|
|
86
|
+
allowed_roles: Roles that can access this tool.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
tool_name: str
|
|
90
|
+
required_permissions: frozenset[Permission] = field(
|
|
91
|
+
default_factory=lambda: frozenset({Permission.EXECUTE})
|
|
92
|
+
)
|
|
93
|
+
allowed_roles: frozenset[str] = field(default_factory=frozenset)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class AuthContext:
|
|
98
|
+
"""Context for an authenticated request.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
authenticated: Whether the request is authenticated.
|
|
102
|
+
client_id: Identifier for the client.
|
|
103
|
+
permissions: Granted permissions.
|
|
104
|
+
roles: Assigned roles.
|
|
105
|
+
metadata: Additional auth metadata.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
authenticated: bool = False
|
|
109
|
+
client_id: str | None = None
|
|
110
|
+
permissions: frozenset[Permission] = field(default_factory=frozenset)
|
|
111
|
+
roles: frozenset[str] = field(default_factory=frozenset)
|
|
112
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RateLimiter:
|
|
116
|
+
"""Token bucket rate limiter.
|
|
117
|
+
|
|
118
|
+
Implements a token bucket algorithm for rate limiting requests
|
|
119
|
+
per client.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
requests_per_minute: int,
|
|
125
|
+
burst_size: int,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Initialize rate limiter.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
requests_per_minute: Maximum requests per minute.
|
|
131
|
+
burst_size: Maximum burst size (bucket capacity).
|
|
132
|
+
"""
|
|
133
|
+
self._rate = requests_per_minute / 60.0 # Requests per second
|
|
134
|
+
self._burst_size = burst_size
|
|
135
|
+
self._buckets: dict[str, tuple[float, float]] = {} # client_id -> (tokens, last_update)
|
|
136
|
+
self._lock = asyncio.Lock()
|
|
137
|
+
self._sync_lock = threading.Lock()
|
|
138
|
+
|
|
139
|
+
async def check(self, client_id: str) -> bool:
|
|
140
|
+
"""Check if a request is allowed.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
client_id: Identifier for the client.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if the request is allowed, False if rate limited.
|
|
147
|
+
"""
|
|
148
|
+
async with self._lock:
|
|
149
|
+
now = time.monotonic()
|
|
150
|
+
tokens, last_update = self._buckets.get(client_id, (self._burst_size, now))
|
|
151
|
+
|
|
152
|
+
# Add tokens based on time elapsed
|
|
153
|
+
elapsed = now - last_update
|
|
154
|
+
tokens = min(self._burst_size, tokens + elapsed * self._rate)
|
|
155
|
+
|
|
156
|
+
if tokens >= 1:
|
|
157
|
+
self._buckets[client_id] = (tokens - 1, now)
|
|
158
|
+
return True
|
|
159
|
+
else:
|
|
160
|
+
self._buckets[client_id] = (tokens, now)
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
def reset(self, client_id: str) -> None:
|
|
164
|
+
"""Reset rate limit for a client.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
client_id: Identifier for the client.
|
|
168
|
+
"""
|
|
169
|
+
with self._sync_lock:
|
|
170
|
+
if client_id in self._buckets:
|
|
171
|
+
del self._buckets[client_id]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Authenticator:
|
|
175
|
+
"""Handles authentication for MCP requests."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, config: AuthConfig) -> None:
|
|
178
|
+
"""Initialize authenticator.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
config: Authentication configuration.
|
|
182
|
+
"""
|
|
183
|
+
self._config = config
|
|
184
|
+
# Hash API keys for secure comparison
|
|
185
|
+
self._hashed_keys: frozenset[str] = frozenset(
|
|
186
|
+
self._hash_key(key) for key in config.api_keys
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _hash_key(key: str) -> str:
|
|
191
|
+
"""Hash an API key for secure storage and comparison."""
|
|
192
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
193
|
+
|
|
194
|
+
def authenticate(
|
|
195
|
+
self,
|
|
196
|
+
credentials: dict[str, str] | None,
|
|
197
|
+
) -> Result[AuthContext, MCPAuthError]:
|
|
198
|
+
"""Authenticate a request.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
credentials: Credentials provided by the client.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Result containing auth context or auth error.
|
|
205
|
+
"""
|
|
206
|
+
if self._config.method == AuthMethod.NONE:
|
|
207
|
+
return Result.ok(
|
|
208
|
+
AuthContext(
|
|
209
|
+
authenticated=not self._config.required,
|
|
210
|
+
permissions=frozenset(Permission),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if not credentials:
|
|
215
|
+
if self._config.required:
|
|
216
|
+
return Result.err(
|
|
217
|
+
MCPAuthError(
|
|
218
|
+
"Authentication required",
|
|
219
|
+
auth_method=self._config.method.value,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return Result.ok(AuthContext(authenticated=False))
|
|
223
|
+
|
|
224
|
+
if self._config.method == AuthMethod.API_KEY:
|
|
225
|
+
return self._authenticate_api_key(credentials)
|
|
226
|
+
elif self._config.method == AuthMethod.BEARER_TOKEN:
|
|
227
|
+
return self._authenticate_token(credentials)
|
|
228
|
+
|
|
229
|
+
return Result.err(
|
|
230
|
+
MCPAuthError(
|
|
231
|
+
f"Unknown auth method: {self._config.method}",
|
|
232
|
+
auth_method=self._config.method.value,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _authenticate_api_key(
|
|
237
|
+
self,
|
|
238
|
+
credentials: dict[str, str],
|
|
239
|
+
) -> Result[AuthContext, MCPAuthError]:
|
|
240
|
+
"""Authenticate using API key.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
credentials: Must contain 'api_key'.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Result containing auth context or auth error.
|
|
247
|
+
"""
|
|
248
|
+
api_key = credentials.get("api_key")
|
|
249
|
+
if not api_key:
|
|
250
|
+
return Result.err(
|
|
251
|
+
MCPAuthError(
|
|
252
|
+
"API key required",
|
|
253
|
+
auth_method=AuthMethod.API_KEY.value,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
hashed = self._hash_key(api_key)
|
|
258
|
+
if hashed in self._hashed_keys:
|
|
259
|
+
log.info("mcp.auth.api_key_valid")
|
|
260
|
+
return Result.ok(
|
|
261
|
+
AuthContext(
|
|
262
|
+
authenticated=True,
|
|
263
|
+
client_id=hashed[:16], # Use prefix as client ID
|
|
264
|
+
permissions=frozenset(Permission),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
log.warning("mcp.auth.invalid_api_key")
|
|
269
|
+
return Result.err(
|
|
270
|
+
MCPAuthError(
|
|
271
|
+
"Invalid API key",
|
|
272
|
+
auth_method=AuthMethod.API_KEY.value,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _authenticate_token(
|
|
277
|
+
self,
|
|
278
|
+
credentials: dict[str, str],
|
|
279
|
+
) -> Result[AuthContext, MCPAuthError]:
|
|
280
|
+
"""Authenticate using bearer token.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
credentials: Must contain 'token'.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Result containing auth context or auth error.
|
|
287
|
+
"""
|
|
288
|
+
token = credentials.get("token")
|
|
289
|
+
if not token:
|
|
290
|
+
return Result.err(
|
|
291
|
+
MCPAuthError(
|
|
292
|
+
"Bearer token required",
|
|
293
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not self._config.token_secret:
|
|
298
|
+
return Result.err(
|
|
299
|
+
MCPAuthError(
|
|
300
|
+
"Token validation not configured",
|
|
301
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Simple token validation (in production, use JWT or similar)
|
|
306
|
+
# Format: client_id:timestamp:signature
|
|
307
|
+
parts = token.split(":")
|
|
308
|
+
if len(parts) != 3:
|
|
309
|
+
return Result.err(
|
|
310
|
+
MCPAuthError(
|
|
311
|
+
"Invalid token format",
|
|
312
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
client_id, timestamp_str, signature = parts
|
|
317
|
+
|
|
318
|
+
# Verify signature
|
|
319
|
+
expected = hmac.new(
|
|
320
|
+
self._config.token_secret.encode(),
|
|
321
|
+
f"{client_id}:{timestamp_str}".encode(),
|
|
322
|
+
hashlib.sha256,
|
|
323
|
+
).hexdigest()
|
|
324
|
+
|
|
325
|
+
if not hmac.compare_digest(signature, expected):
|
|
326
|
+
log.warning("mcp.auth.invalid_token_signature")
|
|
327
|
+
return Result.err(
|
|
328
|
+
MCPAuthError(
|
|
329
|
+
"Invalid token signature",
|
|
330
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Check timestamp (tokens valid for 1 hour, 60s clock skew tolerance)
|
|
335
|
+
try:
|
|
336
|
+
timestamp = int(timestamp_str)
|
|
337
|
+
now = time.time()
|
|
338
|
+
if timestamp > now + 60:
|
|
339
|
+
return Result.err(
|
|
340
|
+
MCPAuthError(
|
|
341
|
+
"Token timestamp is in the future",
|
|
342
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
if now - timestamp > 3600:
|
|
346
|
+
return Result.err(
|
|
347
|
+
MCPAuthError(
|
|
348
|
+
"Token expired",
|
|
349
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
except ValueError:
|
|
353
|
+
return Result.err(
|
|
354
|
+
MCPAuthError(
|
|
355
|
+
"Invalid token timestamp",
|
|
356
|
+
auth_method=AuthMethod.BEARER_TOKEN.value,
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
log.info("mcp.auth.token_valid", client_id=client_id)
|
|
361
|
+
return Result.ok(
|
|
362
|
+
AuthContext(
|
|
363
|
+
authenticated=True,
|
|
364
|
+
client_id=client_id,
|
|
365
|
+
permissions=frozenset(Permission),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Authorizer:
|
|
371
|
+
"""Handles authorization for MCP tool calls."""
|
|
372
|
+
|
|
373
|
+
def __init__(self) -> None:
|
|
374
|
+
"""Initialize authorizer."""
|
|
375
|
+
self._tool_permissions: dict[str, ToolPermission] = {}
|
|
376
|
+
|
|
377
|
+
def register_tool_permission(self, permission: ToolPermission) -> None:
|
|
378
|
+
"""Register permission requirements for a tool.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
permission: Permission configuration for the tool.
|
|
382
|
+
"""
|
|
383
|
+
self._tool_permissions[permission.tool_name] = permission
|
|
384
|
+
|
|
385
|
+
def authorize(
|
|
386
|
+
self,
|
|
387
|
+
tool_name: str,
|
|
388
|
+
auth_context: AuthContext,
|
|
389
|
+
) -> Result[None, MCPAuthError]:
|
|
390
|
+
"""Check if a request is authorized to call a tool.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
tool_name: Name of the tool being called.
|
|
394
|
+
auth_context: Authentication context.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Result.ok(None) if authorized, Result.err otherwise.
|
|
398
|
+
"""
|
|
399
|
+
permission = self._tool_permissions.get(tool_name)
|
|
400
|
+
|
|
401
|
+
# If no specific permission is registered, allow authenticated users
|
|
402
|
+
if permission is None:
|
|
403
|
+
if auth_context.authenticated:
|
|
404
|
+
return Result.ok(None)
|
|
405
|
+
return Result.err(
|
|
406
|
+
MCPAuthError(
|
|
407
|
+
f"Authentication required for tool: {tool_name}",
|
|
408
|
+
required_permission=Permission.EXECUTE.value,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Check if user has required permissions
|
|
413
|
+
if not permission.required_permissions.issubset(auth_context.permissions):
|
|
414
|
+
missing = permission.required_permissions - auth_context.permissions
|
|
415
|
+
return Result.err(
|
|
416
|
+
MCPAuthError(
|
|
417
|
+
f"Missing permissions for tool {tool_name}: {missing}",
|
|
418
|
+
required_permission=", ".join(p.value for p in missing),
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Check if user has an allowed role (if roles are specified)
|
|
423
|
+
if permission.allowed_roles and not permission.allowed_roles.intersection(
|
|
424
|
+
auth_context.roles
|
|
425
|
+
):
|
|
426
|
+
return Result.err(
|
|
427
|
+
MCPAuthError(
|
|
428
|
+
f"Role not authorized for tool: {tool_name}",
|
|
429
|
+
required_permission=f"roles: {permission.allowed_roles}",
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return Result.ok(None)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class InputValidator:
|
|
437
|
+
"""Validates tool input arguments."""
|
|
438
|
+
|
|
439
|
+
def __init__(self) -> None:
|
|
440
|
+
"""Initialize validator."""
|
|
441
|
+
self._validators: dict[str, Callable[[dict[str, Any]], Result[None, str]]] = {}
|
|
442
|
+
|
|
443
|
+
def register_validator(
|
|
444
|
+
self,
|
|
445
|
+
tool_name: str,
|
|
446
|
+
validator: Callable[[dict[str, Any]], Result[None, str]],
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Register a custom validator for a tool.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
tool_name: Name of the tool.
|
|
452
|
+
validator: Validation function.
|
|
453
|
+
"""
|
|
454
|
+
self._validators[tool_name] = validator
|
|
455
|
+
|
|
456
|
+
def validate(
|
|
457
|
+
self,
|
|
458
|
+
tool_name: str,
|
|
459
|
+
arguments: dict[str, Any],
|
|
460
|
+
_schema: dict[str, Any] | None = None,
|
|
461
|
+
) -> Result[None, MCPServerError]:
|
|
462
|
+
"""Validate tool arguments.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
tool_name: Name of the tool.
|
|
466
|
+
arguments: Arguments to validate.
|
|
467
|
+
_schema: Optional JSON schema for validation (reserved for future use).
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
Result.ok(None) if valid, Result.err otherwise.
|
|
471
|
+
"""
|
|
472
|
+
# Check for dangerous patterns in string arguments
|
|
473
|
+
dangerous_patterns = [
|
|
474
|
+
"__import__", "subprocess", "os.popen", "os.system",
|
|
475
|
+
"eval(", "exec(", "compile(", "open(",
|
|
476
|
+
]
|
|
477
|
+
path_traversal_patterns = ["../", "..\\"]
|
|
478
|
+
shell_metacharacters = [";", "|", "&&", "||"]
|
|
479
|
+
|
|
480
|
+
for key, value in arguments.items():
|
|
481
|
+
if isinstance(value, str):
|
|
482
|
+
for pattern in dangerous_patterns:
|
|
483
|
+
if pattern in value:
|
|
484
|
+
return Result.err(
|
|
485
|
+
MCPServerError(
|
|
486
|
+
f"Potentially dangerous input in {key}",
|
|
487
|
+
details={"pattern": pattern},
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
for pattern in path_traversal_patterns:
|
|
491
|
+
if pattern in value:
|
|
492
|
+
return Result.err(
|
|
493
|
+
MCPServerError(
|
|
494
|
+
f"Path traversal detected in {key}",
|
|
495
|
+
details={"pattern": pattern},
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
for char in shell_metacharacters:
|
|
499
|
+
if char in value:
|
|
500
|
+
return Result.err(
|
|
501
|
+
MCPServerError(
|
|
502
|
+
f"Shell metacharacter detected in {key}",
|
|
503
|
+
details={"pattern": char},
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Run custom validator if registered
|
|
508
|
+
if tool_name in self._validators:
|
|
509
|
+
result = self._validators[tool_name](arguments)
|
|
510
|
+
if result.is_err:
|
|
511
|
+
return Result.err(
|
|
512
|
+
MCPServerError(
|
|
513
|
+
f"Validation failed for {tool_name}: {result.error}",
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
return Result.ok(None)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@dataclass
|
|
521
|
+
class SecurityLayer:
|
|
522
|
+
"""Combined security layer for MCP server.
|
|
523
|
+
|
|
524
|
+
Provides authentication, authorization, rate limiting, and input validation
|
|
525
|
+
in a single interface.
|
|
526
|
+
"""
|
|
527
|
+
|
|
528
|
+
auth_config: AuthConfig = field(default_factory=AuthConfig)
|
|
529
|
+
rate_limit_config: RateLimitConfig = field(default_factory=RateLimitConfig)
|
|
530
|
+
|
|
531
|
+
def __post_init__(self) -> None:
|
|
532
|
+
"""Initialize security components."""
|
|
533
|
+
self._authenticator = Authenticator(self.auth_config)
|
|
534
|
+
self._authorizer = Authorizer()
|
|
535
|
+
self._validator = InputValidator()
|
|
536
|
+
self._rate_limiter: RateLimiter | None = None
|
|
537
|
+
|
|
538
|
+
if self.rate_limit_config.enabled:
|
|
539
|
+
self._rate_limiter = RateLimiter(
|
|
540
|
+
self.rate_limit_config.requests_per_minute,
|
|
541
|
+
self.rate_limit_config.burst_size,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def register_tool_permission(self, permission: ToolPermission) -> None:
|
|
545
|
+
"""Register permission requirements for a tool."""
|
|
546
|
+
self._authorizer.register_tool_permission(permission)
|
|
547
|
+
|
|
548
|
+
def register_validator(
|
|
549
|
+
self,
|
|
550
|
+
tool_name: str,
|
|
551
|
+
validator: Callable[[dict[str, Any]], Result[None, str]],
|
|
552
|
+
) -> None:
|
|
553
|
+
"""Register a custom validator for a tool."""
|
|
554
|
+
self._validator.register_validator(tool_name, validator)
|
|
555
|
+
|
|
556
|
+
async def check_request(
|
|
557
|
+
self,
|
|
558
|
+
tool_name: str,
|
|
559
|
+
arguments: dict[str, Any],
|
|
560
|
+
credentials: dict[str, str] | None = None,
|
|
561
|
+
) -> Result[AuthContext, MCPServerError]:
|
|
562
|
+
"""Check if a request passes all security checks.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
tool_name: Name of the tool being called.
|
|
566
|
+
arguments: Arguments for the tool.
|
|
567
|
+
credentials: Client credentials.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
Result containing auth context or security error.
|
|
571
|
+
"""
|
|
572
|
+
# 1. Authenticate
|
|
573
|
+
auth_result = self._authenticator.authenticate(credentials)
|
|
574
|
+
if auth_result.is_err:
|
|
575
|
+
return Result.err(auth_result.error)
|
|
576
|
+
|
|
577
|
+
auth_context = auth_result.value
|
|
578
|
+
|
|
579
|
+
# 2. Rate limit (if enabled)
|
|
580
|
+
if (
|
|
581
|
+
self._rate_limiter
|
|
582
|
+
and auth_context.client_id
|
|
583
|
+
and not await self._rate_limiter.check(auth_context.client_id)
|
|
584
|
+
):
|
|
585
|
+
return Result.err(
|
|
586
|
+
MCPServerError(
|
|
587
|
+
"Rate limit exceeded",
|
|
588
|
+
is_retriable=True,
|
|
589
|
+
details={"retry_after": 60},
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
# 3. Authorize
|
|
594
|
+
authz_result = self._authorizer.authorize(tool_name, auth_context)
|
|
595
|
+
if authz_result.is_err:
|
|
596
|
+
return Result.err(authz_result.error)
|
|
597
|
+
|
|
598
|
+
# 4. Validate input
|
|
599
|
+
valid_result = self._validator.validate(tool_name, arguments)
|
|
600
|
+
if valid_result.is_err:
|
|
601
|
+
return Result.err(valid_result.error)
|
|
602
|
+
|
|
603
|
+
return Result.ok(auth_context)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def create_security_middleware(
|
|
607
|
+
security_layer: SecurityLayer,
|
|
608
|
+
) -> Callable[
|
|
609
|
+
[str, dict[str, Any], dict[str, str] | None, Callable[..., Awaitable[Result[T, MCPServerError]]]],
|
|
610
|
+
Awaitable[Result[T, MCPServerError]],
|
|
611
|
+
]:
|
|
612
|
+
"""Create a security middleware function.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
security_layer: The security layer to use.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
A middleware function that wraps tool handlers.
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
async def middleware(
|
|
622
|
+
tool_name: str,
|
|
623
|
+
arguments: dict[str, Any],
|
|
624
|
+
credentials: dict[str, str] | None,
|
|
625
|
+
handler: Callable[..., Awaitable[Result[T, MCPServerError]]],
|
|
626
|
+
) -> Result[T, MCPServerError]:
|
|
627
|
+
"""Security middleware that checks requests before calling handlers."""
|
|
628
|
+
check_result = await security_layer.check_request(
|
|
629
|
+
tool_name, arguments, credentials
|
|
630
|
+
)
|
|
631
|
+
if check_result.is_err:
|
|
632
|
+
return Result.err(check_result.error)
|
|
633
|
+
|
|
634
|
+
return await handler(arguments)
|
|
635
|
+
|
|
636
|
+
return middleware
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""MCP Tools package.
|
|
2
|
+
|
|
3
|
+
This package provides tool registration and management for the MCP server.
|
|
4
|
+
|
|
5
|
+
Public API:
|
|
6
|
+
ToolRegistry: Registry for managing tool handlers
|
|
7
|
+
Tool definitions for Ouroboros functionality
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ouroboros.mcp.tools.definitions import (
|
|
11
|
+
OUROBOROS_TOOLS,
|
|
12
|
+
execute_seed_handler,
|
|
13
|
+
query_events_handler,
|
|
14
|
+
session_status_handler,
|
|
15
|
+
)
|
|
16
|
+
from ouroboros.mcp.tools.registry import ToolRegistry
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ToolRegistry",
|
|
20
|
+
"OUROBOROS_TOOLS",
|
|
21
|
+
"execute_seed_handler",
|
|
22
|
+
"session_status_handler",
|
|
23
|
+
"query_events_handler",
|
|
24
|
+
]
|