mcp-hangar 0.2.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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain exceptions for the MCP Registry.
|
|
3
|
+
|
|
4
|
+
All domain-specific exceptions should be defined here.
|
|
5
|
+
These exceptions carry context and can be serialized to structured error responses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MCPError(Exception):
|
|
12
|
+
"""Base exception for all MCP registry errors.
|
|
13
|
+
|
|
14
|
+
Provides structured error information with context for debugging and logging.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
provider_id: str = "",
|
|
21
|
+
operation: str = "",
|
|
22
|
+
details: Optional[Dict[str, Any]] = None,
|
|
23
|
+
):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.provider_id = provider_id
|
|
27
|
+
self.operation = operation
|
|
28
|
+
self.details = details or {}
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
31
|
+
"""Convert to structured error dictionary for API responses."""
|
|
32
|
+
return {
|
|
33
|
+
"error": self.message,
|
|
34
|
+
"provider_id": self.provider_id,
|
|
35
|
+
"operation": self.operation,
|
|
36
|
+
"details": self.details,
|
|
37
|
+
"type": self.__class__.__name__,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
f"{self.__class__.__name__}("
|
|
43
|
+
f"message={self.message!r}, "
|
|
44
|
+
f"provider_id={self.provider_id!r}, "
|
|
45
|
+
f"operation={self.operation!r})"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- Provider Lifecycle Exceptions ---
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProviderError(MCPError):
|
|
53
|
+
"""Base exception for provider-related errors."""
|
|
54
|
+
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ProviderNotFoundError(ProviderError):
|
|
59
|
+
"""Raised when a provider is not found in the registry."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, provider_id: str):
|
|
62
|
+
super().__init__(
|
|
63
|
+
message=f"Provider not found: {provider_id}",
|
|
64
|
+
provider_id=provider_id,
|
|
65
|
+
operation="lookup",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProviderStartError(ProviderError):
|
|
70
|
+
"""Raised when a provider fails to start."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, provider_id: str, reason: str, details: Optional[Dict[str, Any]] = None):
|
|
73
|
+
super().__init__(
|
|
74
|
+
message=f"Failed to start provider: {reason}",
|
|
75
|
+
provider_id=provider_id,
|
|
76
|
+
operation="start",
|
|
77
|
+
details=details or {},
|
|
78
|
+
)
|
|
79
|
+
self.reason = reason
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProviderDegradedError(ProviderError):
|
|
83
|
+
"""Raised when a provider is in degraded state and cannot accept requests."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
provider_id: str,
|
|
88
|
+
backoff_remaining: float = 0,
|
|
89
|
+
consecutive_failures: int = 0,
|
|
90
|
+
):
|
|
91
|
+
super().__init__(
|
|
92
|
+
message=f"Provider is degraded, retry in {backoff_remaining:.1f}s",
|
|
93
|
+
provider_id=provider_id,
|
|
94
|
+
operation="ensure_ready",
|
|
95
|
+
details={
|
|
96
|
+
"backoff_remaining_s": backoff_remaining,
|
|
97
|
+
"consecutive_failures": consecutive_failures,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
self.backoff_remaining = backoff_remaining
|
|
101
|
+
self.consecutive_failures = consecutive_failures
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CannotStartProviderError(ProviderError):
|
|
105
|
+
"""Raised when provider cannot be started due to backoff or other constraints."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, provider_id: str, reason: str, time_until_retry: float = 0):
|
|
108
|
+
super().__init__(
|
|
109
|
+
message=f"Cannot start provider: {reason}",
|
|
110
|
+
provider_id=provider_id,
|
|
111
|
+
operation="start",
|
|
112
|
+
details={"time_until_retry_s": time_until_retry},
|
|
113
|
+
)
|
|
114
|
+
self.reason = reason
|
|
115
|
+
self.time_until_retry = time_until_retry
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ProviderNotReadyError(ProviderError):
|
|
119
|
+
"""Raised when an operation requires READY state but provider is not ready."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, provider_id: str, current_state: str):
|
|
122
|
+
super().__init__(
|
|
123
|
+
message=f"Provider is not ready (state={current_state})",
|
|
124
|
+
provider_id=provider_id,
|
|
125
|
+
operation="invoke",
|
|
126
|
+
details={"current_state": current_state},
|
|
127
|
+
)
|
|
128
|
+
self.current_state = current_state
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class InvalidStateTransitionError(ProviderError):
|
|
132
|
+
"""Raised when an invalid state transition is attempted."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, provider_id: str, from_state: str, to_state: str):
|
|
135
|
+
super().__init__(
|
|
136
|
+
message=f"Invalid state transition: {from_state} -> {to_state}",
|
|
137
|
+
provider_id=provider_id,
|
|
138
|
+
operation="transition",
|
|
139
|
+
details={"from_state": from_state, "to_state": to_state},
|
|
140
|
+
)
|
|
141
|
+
self.from_state = from_state
|
|
142
|
+
self.to_state = to_state
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- Tool Invocation Exceptions ---
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ToolError(MCPError):
|
|
149
|
+
"""Base exception for tool-related errors."""
|
|
150
|
+
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ToolNotFoundError(ToolError):
|
|
155
|
+
"""Raised when a tool is not found in the provider's catalog."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, provider_id: str, tool_name: str):
|
|
158
|
+
super().__init__(
|
|
159
|
+
message=f"Tool not found: {tool_name}",
|
|
160
|
+
provider_id=provider_id,
|
|
161
|
+
operation="invoke",
|
|
162
|
+
details={"tool_name": tool_name},
|
|
163
|
+
)
|
|
164
|
+
self.tool_name = tool_name
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ToolInvocationError(ToolError):
|
|
168
|
+
"""Raised when a tool invocation fails."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, provider_id: str, message: str, details: Optional[Dict[str, Any]] = None):
|
|
171
|
+
super().__init__(
|
|
172
|
+
message=message,
|
|
173
|
+
provider_id=provider_id,
|
|
174
|
+
operation="invoke",
|
|
175
|
+
details=details or {},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ToolTimeoutError(ToolError):
|
|
180
|
+
"""Raised when a tool invocation times out."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, provider_id: str, tool_name: str, timeout: float):
|
|
183
|
+
super().__init__(
|
|
184
|
+
message=f"Tool invocation timed out after {timeout}s",
|
|
185
|
+
provider_id=provider_id,
|
|
186
|
+
operation="invoke",
|
|
187
|
+
details={"tool_name": tool_name, "timeout_s": timeout},
|
|
188
|
+
)
|
|
189
|
+
self.tool_name = tool_name
|
|
190
|
+
self.timeout = timeout
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- Client/Communication Exceptions ---
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ClientError(MCPError):
|
|
197
|
+
"""Raised when the stdio client encounters an error."""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
message: str,
|
|
202
|
+
provider_id: str = "",
|
|
203
|
+
details: Optional[Dict[str, Any]] = None,
|
|
204
|
+
):
|
|
205
|
+
super().__init__(
|
|
206
|
+
message=message,
|
|
207
|
+
provider_id=provider_id,
|
|
208
|
+
operation="client",
|
|
209
|
+
details=details or {},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ClientNotConnectedError(ClientError):
|
|
214
|
+
"""Raised when attempting to use a client that is not connected."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, provider_id: str = ""):
|
|
217
|
+
super().__init__(message="Client is not connected", provider_id=provider_id)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ClientTimeoutError(ClientError):
|
|
221
|
+
"""Raised when a client operation times out."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, provider_id: str = "", timeout: float = 0, operation: str = "call"):
|
|
224
|
+
super().__init__(
|
|
225
|
+
message=f"Client operation timed out after {timeout}s",
|
|
226
|
+
provider_id=provider_id,
|
|
227
|
+
details={"timeout_s": timeout, "operation": operation},
|
|
228
|
+
)
|
|
229
|
+
self.timeout = timeout
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- Validation Exceptions ---
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ValidationError(MCPError):
|
|
236
|
+
"""Raised when input validation fails."""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
message: str,
|
|
241
|
+
field: str = "",
|
|
242
|
+
value: Any = None,
|
|
243
|
+
details: Optional[Dict[str, Any]] = None,
|
|
244
|
+
):
|
|
245
|
+
base_details = {"field": field}
|
|
246
|
+
if value is not None:
|
|
247
|
+
# Sanitize value for logging (truncate if too long)
|
|
248
|
+
str_value = str(value)
|
|
249
|
+
if len(str_value) > 100:
|
|
250
|
+
str_value = str_value[:100] + "..."
|
|
251
|
+
base_details["value"] = str_value
|
|
252
|
+
if details:
|
|
253
|
+
base_details.update(details)
|
|
254
|
+
|
|
255
|
+
super().__init__(message=message, operation="validation", details=base_details)
|
|
256
|
+
self.field = field
|
|
257
|
+
self.value = value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ConfigurationError(MCPError):
|
|
261
|
+
"""Raised when configuration is invalid."""
|
|
262
|
+
|
|
263
|
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
|
264
|
+
super().__init__(message=message, operation="configuration", details=details or {})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- Rate Limiting Exceptions ---
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class RateLimitExceeded(MCPError):
|
|
271
|
+
"""Raised when rate limit is exceeded."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, provider_id: str = "", limit: int = 0, window_seconds: int = 0):
|
|
274
|
+
super().__init__(
|
|
275
|
+
message=f"Rate limit exceeded: {limit} requests per {window_seconds}s",
|
|
276
|
+
provider_id=provider_id,
|
|
277
|
+
operation="rate_limit",
|
|
278
|
+
details={"limit": limit, "window_seconds": window_seconds},
|
|
279
|
+
)
|
|
280
|
+
self.limit = limit
|
|
281
|
+
self.window_seconds = window_seconds
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --- Authentication Exceptions ---
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class AuthenticationError(MCPError):
|
|
288
|
+
"""Base class for authentication errors.
|
|
289
|
+
|
|
290
|
+
All authentication-related failures inherit from this class,
|
|
291
|
+
enabling unified handling of auth errors.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
message: str,
|
|
297
|
+
auth_method: str = "",
|
|
298
|
+
details: Optional[Dict[str, Any]] = None,
|
|
299
|
+
):
|
|
300
|
+
super().__init__(
|
|
301
|
+
message=message,
|
|
302
|
+
operation="authentication",
|
|
303
|
+
details={"auth_method": auth_method, **(details or {})},
|
|
304
|
+
)
|
|
305
|
+
self.auth_method = auth_method
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class InvalidCredentialsError(AuthenticationError):
|
|
309
|
+
"""Credentials are invalid or malformed.
|
|
310
|
+
|
|
311
|
+
Raised when:
|
|
312
|
+
- API key format is invalid
|
|
313
|
+
- JWT signature verification fails
|
|
314
|
+
- Token is malformed
|
|
315
|
+
- Unknown API key
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __init__(
|
|
319
|
+
self,
|
|
320
|
+
message: str = "Invalid credentials",
|
|
321
|
+
auth_method: str = "",
|
|
322
|
+
details: Optional[Dict[str, Any]] = None,
|
|
323
|
+
):
|
|
324
|
+
super().__init__(
|
|
325
|
+
message=message,
|
|
326
|
+
auth_method=auth_method,
|
|
327
|
+
details=details,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ExpiredCredentialsError(AuthenticationError):
|
|
332
|
+
"""Credentials have expired.
|
|
333
|
+
|
|
334
|
+
Raised when:
|
|
335
|
+
- JWT exp claim is in the past
|
|
336
|
+
- API key has passed its expiration date
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
message: str = "Credentials have expired",
|
|
342
|
+
auth_method: str = "",
|
|
343
|
+
expired_at: Optional[float] = None,
|
|
344
|
+
):
|
|
345
|
+
super().__init__(
|
|
346
|
+
message=message,
|
|
347
|
+
auth_method=auth_method,
|
|
348
|
+
details={"expired_at": expired_at} if expired_at else None,
|
|
349
|
+
)
|
|
350
|
+
self.expired_at = expired_at
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class RevokedCredentialsError(AuthenticationError):
|
|
354
|
+
"""Credentials have been revoked.
|
|
355
|
+
|
|
356
|
+
Raised when:
|
|
357
|
+
- API key has been explicitly revoked
|
|
358
|
+
- JWT is on a revocation list
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(
|
|
362
|
+
self,
|
|
363
|
+
message: str = "Credentials have been revoked",
|
|
364
|
+
auth_method: str = "",
|
|
365
|
+
revoked_at: Optional[float] = None,
|
|
366
|
+
):
|
|
367
|
+
super().__init__(
|
|
368
|
+
message=message,
|
|
369
|
+
auth_method=auth_method,
|
|
370
|
+
details={"revoked_at": revoked_at} if revoked_at else None,
|
|
371
|
+
)
|
|
372
|
+
self.revoked_at = revoked_at
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class MissingCredentialsError(AuthenticationError):
|
|
376
|
+
"""No credentials provided when authentication is required.
|
|
377
|
+
|
|
378
|
+
Raised when:
|
|
379
|
+
- No Authorization header present
|
|
380
|
+
- No API key header present
|
|
381
|
+
- Authentication is required but allow_anonymous is False
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(
|
|
385
|
+
self,
|
|
386
|
+
message: str = "No credentials provided",
|
|
387
|
+
expected_methods: Optional[list[str]] = None,
|
|
388
|
+
):
|
|
389
|
+
super().__init__(
|
|
390
|
+
message=message,
|
|
391
|
+
auth_method="none",
|
|
392
|
+
details={"expected_methods": expected_methods} if expected_methods else None,
|
|
393
|
+
)
|
|
394
|
+
self.expected_methods = expected_methods or []
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class RateLimitExceededError(AuthenticationError):
|
|
398
|
+
"""Rate limit exceeded for authentication attempts.
|
|
399
|
+
|
|
400
|
+
Raised when:
|
|
401
|
+
- Too many failed authentication attempts from an IP
|
|
402
|
+
- IP is temporarily locked out
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
def __init__(
|
|
406
|
+
self,
|
|
407
|
+
message: str = "Rate limit exceeded",
|
|
408
|
+
retry_after: Optional[float] = None,
|
|
409
|
+
):
|
|
410
|
+
super().__init__(
|
|
411
|
+
message=message,
|
|
412
|
+
auth_method="rate_limit",
|
|
413
|
+
details={"retry_after": retry_after} if retry_after else None,
|
|
414
|
+
)
|
|
415
|
+
self.retry_after = retry_after
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# --- Authorization Exceptions ---
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class AuthorizationError(MCPError):
|
|
422
|
+
"""Base class for authorization errors.
|
|
423
|
+
|
|
424
|
+
All authorization-related failures inherit from this class.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
message: str,
|
|
430
|
+
principal_id: str = "",
|
|
431
|
+
action: str = "",
|
|
432
|
+
resource: str = "",
|
|
433
|
+
details: Optional[Dict[str, Any]] = None,
|
|
434
|
+
):
|
|
435
|
+
super().__init__(
|
|
436
|
+
message=message,
|
|
437
|
+
operation="authorization",
|
|
438
|
+
details={
|
|
439
|
+
"principal_id": principal_id,
|
|
440
|
+
"action": action,
|
|
441
|
+
"resource": resource,
|
|
442
|
+
**(details or {}),
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
self.principal_id = principal_id
|
|
446
|
+
self.action = action
|
|
447
|
+
self.resource = resource
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class AccessDeniedError(AuthorizationError):
|
|
451
|
+
"""Principal does not have permission for the requested action.
|
|
452
|
+
|
|
453
|
+
The most common authorization error - principal is authenticated
|
|
454
|
+
but lacks the necessary permissions.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
def __init__(
|
|
458
|
+
self,
|
|
459
|
+
principal_id: str,
|
|
460
|
+
action: str,
|
|
461
|
+
resource: str,
|
|
462
|
+
reason: str = "",
|
|
463
|
+
):
|
|
464
|
+
message = f"Access denied: {principal_id} cannot {action} on {resource}"
|
|
465
|
+
if reason:
|
|
466
|
+
message = f"{message} ({reason})"
|
|
467
|
+
super().__init__(
|
|
468
|
+
message=message,
|
|
469
|
+
principal_id=principal_id,
|
|
470
|
+
action=action,
|
|
471
|
+
resource=resource,
|
|
472
|
+
details={"reason": reason} if reason else None,
|
|
473
|
+
)
|
|
474
|
+
self.reason = reason
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class InsufficientScopeError(AuthorizationError):
|
|
478
|
+
"""Token does not have required scope.
|
|
479
|
+
|
|
480
|
+
Raised when JWT token scopes don't include the required scope
|
|
481
|
+
for the requested operation.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
def __init__(
|
|
485
|
+
self,
|
|
486
|
+
principal_id: str,
|
|
487
|
+
required_scope: str,
|
|
488
|
+
available_scopes: list[str] | None = None,
|
|
489
|
+
):
|
|
490
|
+
super().__init__(
|
|
491
|
+
message=f"Insufficient scope: required '{required_scope}'",
|
|
492
|
+
principal_id=principal_id,
|
|
493
|
+
action="scope_check",
|
|
494
|
+
resource=required_scope,
|
|
495
|
+
details={"available_scopes": available_scopes} if available_scopes else None,
|
|
496
|
+
)
|
|
497
|
+
self.required_scope = required_scope
|
|
498
|
+
self.available_scopes = available_scopes or []
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class TenantAccessDeniedError(AuthorizationError):
|
|
502
|
+
"""Principal cannot access resources in the specified tenant.
|
|
503
|
+
|
|
504
|
+
Raised when a principal attempts to access resources in a tenant
|
|
505
|
+
they don't belong to.
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
def __init__(
|
|
509
|
+
self,
|
|
510
|
+
principal_id: str,
|
|
511
|
+
principal_tenant: str | None,
|
|
512
|
+
resource_tenant: str,
|
|
513
|
+
):
|
|
514
|
+
super().__init__(
|
|
515
|
+
message=f"Access denied: cannot access tenant '{resource_tenant}'",
|
|
516
|
+
principal_id=principal_id,
|
|
517
|
+
action="tenant_access",
|
|
518
|
+
resource=resource_tenant,
|
|
519
|
+
details={
|
|
520
|
+
"principal_tenant": principal_tenant,
|
|
521
|
+
"resource_tenant": resource_tenant,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
self.principal_tenant = principal_tenant
|
|
525
|
+
self.resource_tenant = resource_tenant
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Domain model - Aggregates and entities."""
|
|
2
|
+
|
|
3
|
+
# Re-export ProviderState from value_objects for convenience
|
|
4
|
+
from ..value_objects import GroupState, LoadBalancerStrategy, MemberPriority, MemberWeight, ProviderState
|
|
5
|
+
from .aggregate import AggregateRoot
|
|
6
|
+
from .circuit_breaker import CircuitBreaker, CircuitBreakerConfig, CircuitState
|
|
7
|
+
from .event_sourced_provider import EventSourcedProvider, ProviderSnapshot
|
|
8
|
+
from .health_tracker import HealthTracker
|
|
9
|
+
from .load_balancer import (
|
|
10
|
+
BaseStrategy,
|
|
11
|
+
LeastConnectionsStrategy,
|
|
12
|
+
LoadBalancer,
|
|
13
|
+
PriorityStrategy,
|
|
14
|
+
RandomStrategy,
|
|
15
|
+
RoundRobinStrategy,
|
|
16
|
+
WeightedRoundRobinStrategy,
|
|
17
|
+
)
|
|
18
|
+
from .provider import Provider
|
|
19
|
+
from .provider_group import (
|
|
20
|
+
GroupCircuitClosed,
|
|
21
|
+
GroupCircuitOpened,
|
|
22
|
+
GroupCreated,
|
|
23
|
+
GroupMember,
|
|
24
|
+
GroupMemberAdded,
|
|
25
|
+
GroupMemberHealthChanged,
|
|
26
|
+
GroupMemberRemoved,
|
|
27
|
+
GroupStateChanged,
|
|
28
|
+
ProviderGroup,
|
|
29
|
+
)
|
|
30
|
+
from .tool_catalog import ToolCatalog, ToolSchema
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Base
|
|
34
|
+
"AggregateRoot",
|
|
35
|
+
# Circuit Breaker
|
|
36
|
+
"CircuitBreaker",
|
|
37
|
+
"CircuitBreakerConfig",
|
|
38
|
+
"CircuitState",
|
|
39
|
+
# Provider
|
|
40
|
+
"HealthTracker",
|
|
41
|
+
"ToolCatalog",
|
|
42
|
+
"ToolSchema",
|
|
43
|
+
"Provider",
|
|
44
|
+
"ProviderState",
|
|
45
|
+
"EventSourcedProvider",
|
|
46
|
+
"ProviderSnapshot",
|
|
47
|
+
# Provider Group
|
|
48
|
+
"ProviderGroup",
|
|
49
|
+
"GroupMember",
|
|
50
|
+
"GroupState",
|
|
51
|
+
"LoadBalancerStrategy",
|
|
52
|
+
"MemberWeight",
|
|
53
|
+
"MemberPriority",
|
|
54
|
+
# Load Balancer
|
|
55
|
+
"LoadBalancer",
|
|
56
|
+
"BaseStrategy",
|
|
57
|
+
"RoundRobinStrategy",
|
|
58
|
+
"WeightedRoundRobinStrategy",
|
|
59
|
+
"LeastConnectionsStrategy",
|
|
60
|
+
"RandomStrategy",
|
|
61
|
+
"PriorityStrategy",
|
|
62
|
+
# Group Events
|
|
63
|
+
"GroupCreated",
|
|
64
|
+
"GroupMemberAdded",
|
|
65
|
+
"GroupMemberRemoved",
|
|
66
|
+
"GroupMemberHealthChanged",
|
|
67
|
+
"GroupStateChanged",
|
|
68
|
+
"GroupCircuitOpened",
|
|
69
|
+
"GroupCircuitClosed",
|
|
70
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Base class for aggregate roots in the domain model."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ..events import DomainEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AggregateRoot(ABC):
|
|
10
|
+
"""
|
|
11
|
+
Base class for aggregate roots.
|
|
12
|
+
|
|
13
|
+
Aggregate roots are the entry points to aggregates and ensure consistency
|
|
14
|
+
within their boundaries. They collect domain events that can be published
|
|
15
|
+
after the aggregate is persisted.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._uncommitted_events: List[DomainEvent] = []
|
|
20
|
+
self._version: int = 0
|
|
21
|
+
|
|
22
|
+
def _record_event(self, event: DomainEvent) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Record a domain event to be published after persistence.
|
|
25
|
+
|
|
26
|
+
Events are collected and published as a batch to ensure consistency.
|
|
27
|
+
The event bus should handle failures gracefully to avoid breaking
|
|
28
|
+
the aggregate's core functionality.
|
|
29
|
+
"""
|
|
30
|
+
self._uncommitted_events.append(event)
|
|
31
|
+
|
|
32
|
+
def collect_events(self) -> List[DomainEvent]:
|
|
33
|
+
"""
|
|
34
|
+
Collect and clear pending domain events.
|
|
35
|
+
|
|
36
|
+
This should be called after the aggregate is persisted to publish
|
|
37
|
+
events to the event bus. Returns a copy and clears internal list.
|
|
38
|
+
"""
|
|
39
|
+
events = list(self._uncommitted_events)
|
|
40
|
+
self._uncommitted_events.clear()
|
|
41
|
+
return events
|
|
42
|
+
|
|
43
|
+
def has_uncommitted_events(self) -> bool:
|
|
44
|
+
"""Check if there are uncommitted events."""
|
|
45
|
+
return len(self._uncommitted_events) > 0
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def version(self) -> int:
|
|
49
|
+
"""
|
|
50
|
+
Get the aggregate version for optimistic concurrency control.
|
|
51
|
+
|
|
52
|
+
Version is incremented after each state change.
|
|
53
|
+
"""
|
|
54
|
+
return self._version
|
|
55
|
+
|
|
56
|
+
def _increment_version(self) -> None:
|
|
57
|
+
"""Increment version after state change."""
|
|
58
|
+
self._version += 1
|