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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,183 @@
1
+ """Health tracking entity for providers."""
2
+
3
+ from dataclasses import dataclass, field
4
+ import time
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class HealthTracker:
10
+ """Tracks health metrics for a provider.
11
+
12
+ This is a mutable entity (not a value object) that encapsulates
13
+ health-related business logic including:
14
+ - Failure counting and threshold detection
15
+ - Backoff calculation for retry logic
16
+ - Success/failure recording with timestamps
17
+
18
+ Note:
19
+ This class is intentionally mutable as it tracks state over time.
20
+ It is not a value object and should not be compared by value.
21
+
22
+ Attributes:
23
+ max_consecutive_failures: Threshold for triggering degradation.
24
+
25
+ Example:
26
+ >>> tracker = HealthTracker(max_consecutive_failures=3)
27
+ >>> tracker.record_failure()
28
+ >>> tracker.consecutive_failures
29
+ 1
30
+ """
31
+
32
+ max_consecutive_failures: int = 3
33
+ _consecutive_failures: int = field(default=0, init=False)
34
+ _last_success_at: Optional[float] = field(default=None, init=False)
35
+ _last_failure_at: Optional[float] = field(default=None, init=False)
36
+ _total_invocations: int = field(default=0, init=False)
37
+ _total_failures: int = field(default=0, init=False)
38
+
39
+ @property
40
+ def consecutive_failures(self) -> int:
41
+ """Get the current consecutive failure count.
42
+
43
+ Returns:
44
+ Number of consecutive failures since last success.
45
+ """
46
+ return self._consecutive_failures
47
+
48
+ @property
49
+ def last_success_at(self) -> Optional[float]:
50
+ """Get the timestamp of last successful operation.
51
+
52
+ Returns:
53
+ Unix timestamp of last success, or None if never succeeded.
54
+ """
55
+ return self._last_success_at
56
+
57
+ @property
58
+ def last_failure_at(self) -> Optional[float]:
59
+ """Get the timestamp of last failed operation.
60
+
61
+ Returns:
62
+ Unix timestamp of last failure, or None if never failed.
63
+ """
64
+ return self._last_failure_at
65
+
66
+ @property
67
+ def total_invocations(self) -> int:
68
+ """Get the total number of invocations.
69
+
70
+ Returns:
71
+ Total count of success + failure invocations.
72
+ """
73
+ return self._total_invocations
74
+
75
+ @property
76
+ def total_failures(self) -> int:
77
+ """Get the total number of failures.
78
+
79
+ Returns:
80
+ Total count of failed invocations.
81
+ """
82
+ return self._total_failures
83
+
84
+ @property
85
+ def success_rate(self) -> float:
86
+ """Calculate the success rate as a percentage.
87
+
88
+ Returns:
89
+ Success rate from 0.0 to 1.0. Returns 1.0 if no invocations yet.
90
+ """
91
+ if self._total_invocations == 0:
92
+ return 1.0
93
+ return (self._total_invocations - self._total_failures) / self._total_invocations
94
+
95
+ def record_success(self) -> None:
96
+ """Record a successful operation.
97
+
98
+ Resets the consecutive failure counter and updates timestamps.
99
+ """
100
+ self._consecutive_failures = 0
101
+ self._last_success_at = time.time()
102
+ self._total_invocations += 1
103
+
104
+ def record_failure(self) -> None:
105
+ """Record a failed operation.
106
+
107
+ Increments both consecutive and total failure counters.
108
+ """
109
+ self._consecutive_failures += 1
110
+ self._last_failure_at = time.time()
111
+ self._total_failures += 1
112
+ self._total_invocations += 1
113
+
114
+ def record_invocation_failure(self) -> None:
115
+ """Record a failed tool invocation.
116
+
117
+ Increments total_failures but not consecutive failures.
118
+ Use this for application-level errors that shouldn't trigger degradation.
119
+ """
120
+ self._total_failures += 1
121
+ self._total_invocations += 1
122
+
123
+ def should_degrade(self) -> bool:
124
+ """Check if provider should transition to DEGRADED state.
125
+
126
+ Returns:
127
+ True when consecutive failures reach the threshold.
128
+ """
129
+ return self._consecutive_failures >= self.max_consecutive_failures
130
+
131
+ def can_retry(self) -> bool:
132
+ """Check if enough time has passed for a retry attempt.
133
+
134
+ Uses exponential backoff: min(60, 2^consecutive_failures) seconds.
135
+
136
+ Returns:
137
+ True if retry is allowed, False if still in backoff period.
138
+ """
139
+ if self._last_failure_at is None:
140
+ return True
141
+
142
+ backoff = self._calculate_backoff()
143
+ elapsed = time.time() - self._last_failure_at
144
+ return elapsed >= backoff
145
+
146
+ def time_until_retry(self) -> float:
147
+ """Calculate time remaining until retry is allowed.
148
+
149
+ Returns:
150
+ Seconds until retry is allowed. Returns 0 if retry is already allowed.
151
+ """
152
+ if self._last_failure_at is None:
153
+ return 0.0
154
+
155
+ backoff = self._calculate_backoff()
156
+ elapsed = time.time() - self._last_failure_at
157
+ remaining = backoff - elapsed
158
+ return max(0.0, remaining)
159
+
160
+ def _calculate_backoff(self) -> float:
161
+ """Calculate backoff duration based on consecutive failures."""
162
+ return min(60.0, 2**self._consecutive_failures)
163
+
164
+ def reset(self) -> None:
165
+ """Reset health tracker to initial state."""
166
+ self._consecutive_failures = 0
167
+ self._last_success_at = None
168
+ self._last_failure_at = None
169
+ self._total_invocations = 0
170
+ self._total_failures = 0
171
+
172
+ def to_dict(self) -> dict:
173
+ """Convert to dictionary representation."""
174
+ return {
175
+ "consecutive_failures": self._consecutive_failures,
176
+ "last_success_at": self._last_success_at,
177
+ "last_failure_at": self._last_failure_at,
178
+ "total_invocations": self._total_invocations,
179
+ "total_failures": self._total_failures,
180
+ "success_rate": self.success_rate,
181
+ "can_retry": self.can_retry(),
182
+ "time_until_retry": self.time_until_retry(),
183
+ }
@@ -0,0 +1,185 @@
1
+ """Load balancing strategies for provider groups.
2
+
3
+ This module implements various load balancing strategies for distributing
4
+ requests across group members.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ import random
9
+ import threading
10
+ from typing import List, Optional, TYPE_CHECKING
11
+
12
+ from ..value_objects import LoadBalancerStrategy
13
+
14
+ if TYPE_CHECKING:
15
+ from .provider_group import GroupMember
16
+
17
+
18
+ class BaseStrategy(ABC):
19
+ """Abstract base class for load balancing strategies."""
20
+
21
+ @abstractmethod
22
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
23
+ """
24
+ Select a member from available members.
25
+
26
+ Args:
27
+ members: List of available (in_rotation) group members
28
+
29
+ Returns:
30
+ Selected member or None if no members available
31
+ """
32
+ pass
33
+
34
+ @abstractmethod
35
+ def reset(self) -> None:
36
+ """Reset strategy state (e.g., after rebalancing)."""
37
+ pass
38
+
39
+
40
+ class RoundRobinStrategy(BaseStrategy):
41
+ """Simple round-robin selection.
42
+
43
+ Each member gets selected in order, cycling through the list.
44
+ """
45
+
46
+ def __init__(self):
47
+ self._index = 0
48
+ self._lock = threading.Lock()
49
+
50
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
51
+ if not members:
52
+ return None
53
+ with self._lock:
54
+ selected = members[self._index % len(members)]
55
+ self._index += 1
56
+ return selected
57
+
58
+ def reset(self) -> None:
59
+ with self._lock:
60
+ self._index = 0
61
+
62
+
63
+ class WeightedRoundRobinStrategy(BaseStrategy):
64
+ """
65
+ Weighted round-robin using smooth weighted algorithm.
66
+
67
+ Members with higher weights get proportionally more requests.
68
+ Uses the Nginx smooth weighted round-robin algorithm for even distribution.
69
+ """
70
+
71
+ def __init__(self):
72
+ self._current_weights: dict = {}
73
+ self._lock = threading.Lock()
74
+
75
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
76
+ if not members:
77
+ return None
78
+
79
+ with self._lock:
80
+ total_weight = sum(m.weight for m in members)
81
+
82
+ # Initialize or update current weights
83
+ for m in members:
84
+ mid = id(m)
85
+ if mid not in self._current_weights:
86
+ self._current_weights[mid] = 0
87
+ self._current_weights[mid] += m.weight
88
+
89
+ # Select member with highest current weight
90
+ best = max(members, key=lambda m: self._current_weights.get(id(m), 0))
91
+
92
+ # Reduce selected member's weight by total weight
93
+ self._current_weights[id(best)] -= total_weight
94
+
95
+ return best
96
+
97
+ def reset(self) -> None:
98
+ with self._lock:
99
+ self._current_weights.clear()
100
+
101
+
102
+ class LeastConnectionsStrategy(BaseStrategy):
103
+ """Select member with fewest recent selections.
104
+
105
+ Uses last_selected_at timestamp as a proxy for "connections".
106
+ Prefers members that haven't been selected recently.
107
+ """
108
+
109
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
110
+ if not members:
111
+ return None
112
+ # Select member with oldest last_selected_at (least recently used)
113
+ return min(members, key=lambda m: m.last_selected_at)
114
+
115
+ def reset(self) -> None:
116
+ # No state to reset
117
+ pass
118
+
119
+
120
+ class RandomStrategy(BaseStrategy):
121
+ """Random selection with optional weights.
122
+
123
+ Uses weighted random selection where higher weight = higher probability.
124
+ """
125
+
126
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
127
+ if not members:
128
+ return None
129
+ weights = [m.weight for m in members]
130
+ return random.choices(members, weights=weights, k=1)[0]
131
+
132
+ def reset(self) -> None:
133
+ # No state to reset
134
+ pass
135
+
136
+
137
+ class PriorityStrategy(BaseStrategy):
138
+ """Always select lowest priority member that's healthy.
139
+
140
+ Priority 1 is highest priority. Falls back to next priority if 1 is unavailable.
141
+ Useful for primary/backup scenarios.
142
+ """
143
+
144
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
145
+ if not members:
146
+ return None
147
+ # Select member with lowest priority number (highest priority)
148
+ return min(members, key=lambda m: m.priority)
149
+
150
+ def reset(self) -> None:
151
+ # No state to reset
152
+ pass
153
+
154
+
155
+ class LoadBalancer:
156
+ """
157
+ Load balancer that selects members based on configured strategy.
158
+
159
+ Thread-safe - each strategy implementation handles its own locking.
160
+ """
161
+
162
+ _STRATEGIES = {
163
+ LoadBalancerStrategy.ROUND_ROBIN: RoundRobinStrategy,
164
+ LoadBalancerStrategy.WEIGHTED_ROUND_ROBIN: WeightedRoundRobinStrategy,
165
+ LoadBalancerStrategy.LEAST_CONNECTIONS: LeastConnectionsStrategy,
166
+ LoadBalancerStrategy.RANDOM: RandomStrategy,
167
+ LoadBalancerStrategy.PRIORITY: PriorityStrategy,
168
+ }
169
+
170
+ def __init__(self, strategy: LoadBalancerStrategy):
171
+ self._strategy_type = strategy
172
+ self._impl = self._STRATEGIES[strategy]()
173
+
174
+ @property
175
+ def strategy(self) -> LoadBalancerStrategy:
176
+ """Get the current strategy type."""
177
+ return self._strategy_type
178
+
179
+ def select(self, members: List["GroupMember"]) -> Optional["GroupMember"]:
180
+ """Select a member from available members."""
181
+ return self._impl.select(members)
182
+
183
+ def reset(self) -> None:
184
+ """Reset strategy state."""
185
+ self._impl.reset()