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,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiter for the MCP Registry.
|
|
3
|
+
|
|
4
|
+
Provides rate limiting to prevent DoS attacks and abuse.
|
|
5
|
+
Implements token bucket algorithm for flexible rate control.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Dict, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitScope(Enum):
|
|
17
|
+
"""Scope for rate limiting."""
|
|
18
|
+
|
|
19
|
+
GLOBAL = "global" # Global rate limit
|
|
20
|
+
PER_PROVIDER = "provider" # Per-provider rate limit
|
|
21
|
+
PER_TOOL = "tool" # Per-tool rate limit
|
|
22
|
+
PER_CLIENT = "client" # Per-client rate limit
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RateLimitConfig:
|
|
27
|
+
"""Configuration for rate limiting."""
|
|
28
|
+
|
|
29
|
+
# Token bucket parameters
|
|
30
|
+
requests_per_second: float = 10.0 # Rate at which tokens are added
|
|
31
|
+
burst_size: int = 20 # Maximum tokens (burst capacity)
|
|
32
|
+
|
|
33
|
+
# Scope
|
|
34
|
+
scope: RateLimitScope = RateLimitScope.GLOBAL
|
|
35
|
+
|
|
36
|
+
# Behavior
|
|
37
|
+
block_on_exceed: bool = True # Whether to block or just track
|
|
38
|
+
retry_after_header: bool = True # Whether to include retry-after info
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
"""Validate configuration."""
|
|
42
|
+
if self.requests_per_second <= 0:
|
|
43
|
+
raise ValueError("requests_per_second must be positive")
|
|
44
|
+
if self.burst_size <= 0:
|
|
45
|
+
raise ValueError("burst_size must be positive")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RateLimitResult:
|
|
50
|
+
"""Result of a rate limit check."""
|
|
51
|
+
|
|
52
|
+
allowed: bool
|
|
53
|
+
remaining: int # Remaining requests in window
|
|
54
|
+
reset_at: float # When the limit resets (timestamp)
|
|
55
|
+
retry_after: Optional[float] = None # Seconds until request would be allowed
|
|
56
|
+
limit: int = 0 # The configured limit
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> Dict[str, any]:
|
|
59
|
+
"""Convert to dictionary for API responses."""
|
|
60
|
+
result = {
|
|
61
|
+
"allowed": self.allowed,
|
|
62
|
+
"remaining": self.remaining,
|
|
63
|
+
"reset_at": self.reset_at,
|
|
64
|
+
"limit": self.limit,
|
|
65
|
+
}
|
|
66
|
+
if self.retry_after is not None:
|
|
67
|
+
result["retry_after"] = round(self.retry_after, 2)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
def to_headers(self) -> Dict[str, str]:
|
|
71
|
+
"""Convert to rate limit headers."""
|
|
72
|
+
headers = {
|
|
73
|
+
"X-RateLimit-Limit": str(self.limit),
|
|
74
|
+
"X-RateLimit-Remaining": str(max(0, self.remaining)),
|
|
75
|
+
"X-RateLimit-Reset": str(int(self.reset_at)),
|
|
76
|
+
}
|
|
77
|
+
if self.retry_after is not None and self.retry_after > 0:
|
|
78
|
+
headers["Retry-After"] = str(int(self.retry_after) + 1)
|
|
79
|
+
return headers
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TokenBucket:
|
|
83
|
+
"""
|
|
84
|
+
Token bucket implementation for rate limiting.
|
|
85
|
+
|
|
86
|
+
Tokens are added at a fixed rate up to a maximum (burst) capacity.
|
|
87
|
+
Each request consumes one token. Requests are allowed if tokens are available.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
rate: float,
|
|
93
|
+
capacity: int,
|
|
94
|
+
initial_tokens: Optional[int] = None,
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
Initialize token bucket.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
rate: Tokens added per second
|
|
101
|
+
capacity: Maximum tokens (burst size)
|
|
102
|
+
initial_tokens: Starting tokens (defaults to capacity)
|
|
103
|
+
"""
|
|
104
|
+
self.rate = rate
|
|
105
|
+
self.capacity = capacity
|
|
106
|
+
self.tokens = float(initial_tokens if initial_tokens is not None else capacity)
|
|
107
|
+
self.last_update = time.monotonic()
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
|
|
110
|
+
def _refill(self) -> None:
|
|
111
|
+
"""Refill tokens based on elapsed time."""
|
|
112
|
+
now = time.monotonic()
|
|
113
|
+
elapsed = now - self.last_update
|
|
114
|
+
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
|
|
115
|
+
self.last_update = now
|
|
116
|
+
|
|
117
|
+
def consume(self, tokens: int = 1) -> Tuple[bool, float]:
|
|
118
|
+
"""
|
|
119
|
+
Try to consume tokens.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
tokens: Number of tokens to consume
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Tuple of (allowed, wait_time) where wait_time is seconds until
|
|
126
|
+
enough tokens would be available (0 if allowed)
|
|
127
|
+
"""
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._refill()
|
|
130
|
+
|
|
131
|
+
if self.tokens >= tokens:
|
|
132
|
+
self.tokens -= tokens
|
|
133
|
+
return True, 0.0
|
|
134
|
+
else:
|
|
135
|
+
# Calculate wait time
|
|
136
|
+
needed = tokens - self.tokens
|
|
137
|
+
wait_time = needed / self.rate
|
|
138
|
+
return False, wait_time
|
|
139
|
+
|
|
140
|
+
def peek(self) -> Tuple[int, float]:
|
|
141
|
+
"""
|
|
142
|
+
Check current state without consuming.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Tuple of (available_tokens, time_to_full)
|
|
146
|
+
"""
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._refill()
|
|
149
|
+
available = int(self.tokens)
|
|
150
|
+
time_to_full = (self.capacity - self.tokens) / self.rate if self.tokens < self.capacity else 0
|
|
151
|
+
return available, time_to_full
|
|
152
|
+
|
|
153
|
+
def reset(self) -> None:
|
|
154
|
+
"""Reset bucket to full capacity."""
|
|
155
|
+
with self._lock:
|
|
156
|
+
self.tokens = float(self.capacity)
|
|
157
|
+
self.last_update = time.monotonic()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RateLimiter(ABC):
|
|
161
|
+
"""Abstract base class for rate limiters."""
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def check(self, key: str = "global") -> RateLimitResult:
|
|
165
|
+
"""
|
|
166
|
+
Check if a request is allowed.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
key: Identifier for the rate limit bucket
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
RateLimitResult indicating if request is allowed
|
|
173
|
+
"""
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
@abstractmethod
|
|
177
|
+
def consume(self, key: str = "global", tokens: int = 1) -> RateLimitResult:
|
|
178
|
+
"""
|
|
179
|
+
Consume tokens and return result.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
key: Identifier for the rate limit bucket
|
|
183
|
+
tokens: Number of tokens to consume
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
RateLimitResult indicating if request was allowed
|
|
187
|
+
"""
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def reset(self, key: str = "global") -> None:
|
|
192
|
+
"""Reset rate limit for a key."""
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class InMemoryRateLimiter(RateLimiter):
|
|
197
|
+
"""
|
|
198
|
+
In-memory rate limiter using token buckets.
|
|
199
|
+
|
|
200
|
+
Suitable for single-instance deployments. For distributed systems,
|
|
201
|
+
use a Redis-backed implementation.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
config: Optional[RateLimitConfig] = None,
|
|
207
|
+
cleanup_interval: float = 60.0,
|
|
208
|
+
):
|
|
209
|
+
"""
|
|
210
|
+
Initialize rate limiter.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
config: Rate limit configuration
|
|
214
|
+
cleanup_interval: How often to clean up old buckets (seconds)
|
|
215
|
+
"""
|
|
216
|
+
self.config = config or RateLimitConfig()
|
|
217
|
+
self.cleanup_interval = cleanup_interval
|
|
218
|
+
self._buckets: Dict[str, TokenBucket] = {}
|
|
219
|
+
self._bucket_last_used: Dict[str, float] = {}
|
|
220
|
+
self._lock = threading.Lock()
|
|
221
|
+
self._last_cleanup = time.monotonic()
|
|
222
|
+
|
|
223
|
+
def _get_bucket(self, key: str) -> TokenBucket:
|
|
224
|
+
"""Get or create a token bucket for the given key."""
|
|
225
|
+
with self._lock:
|
|
226
|
+
if key not in self._buckets:
|
|
227
|
+
self._buckets[key] = TokenBucket(
|
|
228
|
+
rate=self.config.requests_per_second,
|
|
229
|
+
capacity=self.config.burst_size,
|
|
230
|
+
)
|
|
231
|
+
self._bucket_last_used[key] = time.monotonic()
|
|
232
|
+
|
|
233
|
+
# Periodic cleanup
|
|
234
|
+
self._maybe_cleanup()
|
|
235
|
+
|
|
236
|
+
return self._buckets[key]
|
|
237
|
+
|
|
238
|
+
def _maybe_cleanup(self) -> None:
|
|
239
|
+
"""Clean up old buckets to prevent memory growth."""
|
|
240
|
+
now = time.monotonic()
|
|
241
|
+
if now - self._last_cleanup < self.cleanup_interval:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
self._last_cleanup = now
|
|
245
|
+
|
|
246
|
+
# Remove buckets not used in the last cleanup interval
|
|
247
|
+
cutoff = now - self.cleanup_interval
|
|
248
|
+
keys_to_remove = [key for key, last_used in self._bucket_last_used.items() if last_used < cutoff]
|
|
249
|
+
|
|
250
|
+
for key in keys_to_remove:
|
|
251
|
+
self._buckets.pop(key, None)
|
|
252
|
+
self._bucket_last_used.pop(key, None)
|
|
253
|
+
|
|
254
|
+
def check(self, key: str = "global") -> RateLimitResult:
|
|
255
|
+
"""Check if a request would be allowed without consuming."""
|
|
256
|
+
bucket = self._get_bucket(key)
|
|
257
|
+
available, time_to_full = bucket.peek()
|
|
258
|
+
|
|
259
|
+
return RateLimitResult(
|
|
260
|
+
allowed=available > 0,
|
|
261
|
+
remaining=available,
|
|
262
|
+
reset_at=time.time() + time_to_full,
|
|
263
|
+
retry_after=(None if available > 0 else 1.0 / self.config.requests_per_second),
|
|
264
|
+
limit=self.config.burst_size,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def consume(self, key: str = "global", tokens: int = 1) -> RateLimitResult:
|
|
268
|
+
"""Consume tokens and return result."""
|
|
269
|
+
bucket = self._get_bucket(key)
|
|
270
|
+
allowed, wait_time = bucket.consume(tokens)
|
|
271
|
+
available, time_to_full = bucket.peek()
|
|
272
|
+
|
|
273
|
+
return RateLimitResult(
|
|
274
|
+
allowed=allowed,
|
|
275
|
+
remaining=available,
|
|
276
|
+
reset_at=time.time() + time_to_full,
|
|
277
|
+
retry_after=wait_time if not allowed else None,
|
|
278
|
+
limit=self.config.burst_size,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def reset(self, key: str = "global") -> None:
|
|
282
|
+
"""Reset rate limit for a key."""
|
|
283
|
+
with self._lock:
|
|
284
|
+
if key in self._buckets:
|
|
285
|
+
self._buckets[key].reset()
|
|
286
|
+
|
|
287
|
+
def reset_all(self) -> None:
|
|
288
|
+
"""Reset all rate limits."""
|
|
289
|
+
with self._lock:
|
|
290
|
+
self._buckets.clear()
|
|
291
|
+
self._bucket_last_used.clear()
|
|
292
|
+
|
|
293
|
+
def get_stats(self) -> Dict[str, any]:
|
|
294
|
+
"""Get rate limiter statistics."""
|
|
295
|
+
with self._lock:
|
|
296
|
+
return {
|
|
297
|
+
"active_buckets": len(self._buckets),
|
|
298
|
+
"config": {
|
|
299
|
+
"requests_per_second": self.config.requests_per_second,
|
|
300
|
+
"burst_size": self.config.burst_size,
|
|
301
|
+
"scope": self.config.scope.value,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class CompositeRateLimiter(RateLimiter):
|
|
307
|
+
"""
|
|
308
|
+
Composite rate limiter that combines multiple limiters.
|
|
309
|
+
|
|
310
|
+
All limiters must allow the request for it to be allowed.
|
|
311
|
+
Useful for implementing both global and per-provider limits.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, limiters: Dict[str, RateLimiter]):
|
|
315
|
+
"""
|
|
316
|
+
Initialize composite limiter.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
limiters: Dictionary mapping names to rate limiters
|
|
320
|
+
"""
|
|
321
|
+
self.limiters = limiters
|
|
322
|
+
|
|
323
|
+
def check(self, key: str = "global") -> RateLimitResult:
|
|
324
|
+
"""Check if request would be allowed by all limiters."""
|
|
325
|
+
results = []
|
|
326
|
+
for name, limiter in self.limiters.items():
|
|
327
|
+
result = limiter.check(key)
|
|
328
|
+
results.append((name, result))
|
|
329
|
+
|
|
330
|
+
# Find the most restrictive result
|
|
331
|
+
allowed = all(r[1].allowed for r in results)
|
|
332
|
+
min_remaining = min(r[1].remaining for r in results) if results else 0
|
|
333
|
+
max_reset = max(r[1].reset_at for r in results) if results else time.time()
|
|
334
|
+
max_retry = max((r[1].retry_after or 0 for r in results), default=None)
|
|
335
|
+
|
|
336
|
+
return RateLimitResult(
|
|
337
|
+
allowed=allowed,
|
|
338
|
+
remaining=min_remaining,
|
|
339
|
+
reset_at=max_reset,
|
|
340
|
+
retry_after=max_retry if not allowed else None,
|
|
341
|
+
limit=min(r[1].limit for r in results) if results else 0,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def consume(self, key: str = "global", tokens: int = 1) -> RateLimitResult:
|
|
345
|
+
"""Consume tokens from all limiters."""
|
|
346
|
+
results = []
|
|
347
|
+
for name, limiter in self.limiters.items():
|
|
348
|
+
result = limiter.consume(key, tokens)
|
|
349
|
+
results.append((name, result))
|
|
350
|
+
|
|
351
|
+
# Find the most restrictive result
|
|
352
|
+
allowed = all(r[1].allowed for r in results)
|
|
353
|
+
min_remaining = min(r[1].remaining for r in results) if results else 0
|
|
354
|
+
max_reset = max(r[1].reset_at for r in results) if results else time.time()
|
|
355
|
+
max_retry = max((r[1].retry_after or 0 for r in results), default=None)
|
|
356
|
+
|
|
357
|
+
return RateLimitResult(
|
|
358
|
+
allowed=allowed,
|
|
359
|
+
remaining=min_remaining,
|
|
360
|
+
reset_at=max_reset,
|
|
361
|
+
retry_after=max_retry if not allowed else None,
|
|
362
|
+
limit=min(r[1].limit for r in results) if results else 0,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def reset(self, key: str = "global") -> None:
|
|
366
|
+
"""Reset all limiters for a key."""
|
|
367
|
+
for limiter in self.limiters.values():
|
|
368
|
+
limiter.reset(key)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# --- Global rate limiter instance ---
|
|
372
|
+
|
|
373
|
+
_global_limiter: Optional[InMemoryRateLimiter] = None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_rate_limiter(config: Optional[RateLimitConfig] = None) -> InMemoryRateLimiter:
|
|
377
|
+
"""Get or create the global rate limiter instance."""
|
|
378
|
+
global _global_limiter
|
|
379
|
+
if _global_limiter is None:
|
|
380
|
+
_global_limiter = InMemoryRateLimiter(config)
|
|
381
|
+
return _global_limiter
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def reset_rate_limiter() -> None:
|
|
385
|
+
"""Reset the global rate limiter (for testing)."""
|
|
386
|
+
global _global_limiter
|
|
387
|
+
_global_limiter = None
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Built-in roles and permissions for MCP Hangar.
|
|
2
|
+
|
|
3
|
+
This module defines the default RBAC configuration with predefined
|
|
4
|
+
roles suitable for most use cases. Custom roles can be added via
|
|
5
|
+
configuration or the role store API.
|
|
6
|
+
|
|
7
|
+
Roles follow the principle of least privilege - each role grants
|
|
8
|
+
only the permissions needed for its intended purpose.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ..value_objects import Permission, Role
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# Predefined Permissions
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
# Provider management permissions
|
|
18
|
+
PERMISSION_PROVIDER_CREATE = Permission("provider", "create")
|
|
19
|
+
PERMISSION_PROVIDER_READ = Permission("provider", "read")
|
|
20
|
+
PERMISSION_PROVIDER_UPDATE = Permission("provider", "update")
|
|
21
|
+
PERMISSION_PROVIDER_DELETE = Permission("provider", "delete")
|
|
22
|
+
PERMISSION_PROVIDER_LIST = Permission("provider", "list")
|
|
23
|
+
PERMISSION_PROVIDER_START = Permission("provider", "start")
|
|
24
|
+
PERMISSION_PROVIDER_STOP = Permission("provider", "stop")
|
|
25
|
+
|
|
26
|
+
# Tool invocation permissions
|
|
27
|
+
PERMISSION_TOOL_INVOKE = Permission("tool", "invoke")
|
|
28
|
+
PERMISSION_TOOL_LIST = Permission("tool", "list")
|
|
29
|
+
|
|
30
|
+
# Configuration permissions
|
|
31
|
+
PERMISSION_CONFIG_READ = Permission("config", "read")
|
|
32
|
+
PERMISSION_CONFIG_UPDATE = Permission("config", "update")
|
|
33
|
+
|
|
34
|
+
# Audit permissions
|
|
35
|
+
PERMISSION_AUDIT_READ = Permission("audit", "read")
|
|
36
|
+
|
|
37
|
+
# Metrics permissions
|
|
38
|
+
PERMISSION_METRICS_READ = Permission("metrics", "read")
|
|
39
|
+
|
|
40
|
+
# Group management permissions
|
|
41
|
+
PERMISSION_GROUP_CREATE = Permission("group", "create")
|
|
42
|
+
PERMISSION_GROUP_READ = Permission("group", "read")
|
|
43
|
+
PERMISSION_GROUP_UPDATE = Permission("group", "update")
|
|
44
|
+
PERMISSION_GROUP_DELETE = Permission("group", "delete")
|
|
45
|
+
PERMISSION_GROUP_LIST = Permission("group", "list")
|
|
46
|
+
|
|
47
|
+
# Discovery permissions
|
|
48
|
+
PERMISSION_DISCOVERY_READ = Permission("discovery", "read")
|
|
49
|
+
PERMISSION_DISCOVERY_TRIGGER = Permission("discovery", "trigger")
|
|
50
|
+
PERMISSION_DISCOVERY_APPROVE = Permission("discovery", "approve")
|
|
51
|
+
|
|
52
|
+
# Admin wildcard permission
|
|
53
|
+
PERMISSION_ADMIN_ALL = Permission("*", "*")
|
|
54
|
+
|
|
55
|
+
# Permission registry for easy lookup
|
|
56
|
+
PERMISSIONS: dict[str, Permission] = {
|
|
57
|
+
# Provider
|
|
58
|
+
"provider:create": PERMISSION_PROVIDER_CREATE,
|
|
59
|
+
"provider:read": PERMISSION_PROVIDER_READ,
|
|
60
|
+
"provider:update": PERMISSION_PROVIDER_UPDATE,
|
|
61
|
+
"provider:delete": PERMISSION_PROVIDER_DELETE,
|
|
62
|
+
"provider:list": PERMISSION_PROVIDER_LIST,
|
|
63
|
+
"provider:start": PERMISSION_PROVIDER_START,
|
|
64
|
+
"provider:stop": PERMISSION_PROVIDER_STOP,
|
|
65
|
+
# Tool
|
|
66
|
+
"tool:invoke": PERMISSION_TOOL_INVOKE,
|
|
67
|
+
"tool:list": PERMISSION_TOOL_LIST,
|
|
68
|
+
# Config
|
|
69
|
+
"config:read": PERMISSION_CONFIG_READ,
|
|
70
|
+
"config:update": PERMISSION_CONFIG_UPDATE,
|
|
71
|
+
# Audit
|
|
72
|
+
"audit:read": PERMISSION_AUDIT_READ,
|
|
73
|
+
# Metrics
|
|
74
|
+
"metrics:read": PERMISSION_METRICS_READ,
|
|
75
|
+
# Group
|
|
76
|
+
"group:create": PERMISSION_GROUP_CREATE,
|
|
77
|
+
"group:read": PERMISSION_GROUP_READ,
|
|
78
|
+
"group:update": PERMISSION_GROUP_UPDATE,
|
|
79
|
+
"group:delete": PERMISSION_GROUP_DELETE,
|
|
80
|
+
"group:list": PERMISSION_GROUP_LIST,
|
|
81
|
+
# Discovery
|
|
82
|
+
"discovery:read": PERMISSION_DISCOVERY_READ,
|
|
83
|
+
"discovery:trigger": PERMISSION_DISCOVERY_TRIGGER,
|
|
84
|
+
"discovery:approve": PERMISSION_DISCOVERY_APPROVE,
|
|
85
|
+
# Admin
|
|
86
|
+
"admin:*": PERMISSION_ADMIN_ALL,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# =============================================================================
|
|
91
|
+
# Built-in Roles
|
|
92
|
+
# =============================================================================
|
|
93
|
+
|
|
94
|
+
ROLE_ADMIN = Role(
|
|
95
|
+
name="admin",
|
|
96
|
+
description="Full administrative access to all resources",
|
|
97
|
+
permissions=frozenset([PERMISSION_ADMIN_ALL]),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
ROLE_PROVIDER_ADMIN = Role(
|
|
101
|
+
name="provider-admin",
|
|
102
|
+
description="Manage providers and invoke tools",
|
|
103
|
+
permissions=frozenset(
|
|
104
|
+
[
|
|
105
|
+
PERMISSION_PROVIDER_CREATE,
|
|
106
|
+
PERMISSION_PROVIDER_READ,
|
|
107
|
+
PERMISSION_PROVIDER_UPDATE,
|
|
108
|
+
PERMISSION_PROVIDER_DELETE,
|
|
109
|
+
PERMISSION_PROVIDER_LIST,
|
|
110
|
+
PERMISSION_PROVIDER_START,
|
|
111
|
+
PERMISSION_PROVIDER_STOP,
|
|
112
|
+
PERMISSION_TOOL_INVOKE,
|
|
113
|
+
PERMISSION_TOOL_LIST,
|
|
114
|
+
PERMISSION_METRICS_READ,
|
|
115
|
+
PERMISSION_GROUP_CREATE,
|
|
116
|
+
PERMISSION_GROUP_READ,
|
|
117
|
+
PERMISSION_GROUP_UPDATE,
|
|
118
|
+
PERMISSION_GROUP_DELETE,
|
|
119
|
+
PERMISSION_GROUP_LIST,
|
|
120
|
+
PERMISSION_DISCOVERY_READ,
|
|
121
|
+
PERMISSION_DISCOVERY_TRIGGER,
|
|
122
|
+
PERMISSION_DISCOVERY_APPROVE,
|
|
123
|
+
]
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
ROLE_DEVELOPER = Role(
|
|
128
|
+
name="developer",
|
|
129
|
+
description="Invoke tools and view providers",
|
|
130
|
+
permissions=frozenset(
|
|
131
|
+
[
|
|
132
|
+
PERMISSION_PROVIDER_READ,
|
|
133
|
+
PERMISSION_PROVIDER_LIST,
|
|
134
|
+
PERMISSION_PROVIDER_START, # Can start providers on-demand
|
|
135
|
+
PERMISSION_TOOL_INVOKE,
|
|
136
|
+
PERMISSION_TOOL_LIST,
|
|
137
|
+
PERMISSION_GROUP_READ,
|
|
138
|
+
PERMISSION_GROUP_LIST,
|
|
139
|
+
PERMISSION_DISCOVERY_READ,
|
|
140
|
+
]
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
ROLE_VIEWER = Role(
|
|
145
|
+
name="viewer",
|
|
146
|
+
description="Read-only access to providers and tools",
|
|
147
|
+
permissions=frozenset(
|
|
148
|
+
[
|
|
149
|
+
PERMISSION_PROVIDER_READ,
|
|
150
|
+
PERMISSION_PROVIDER_LIST,
|
|
151
|
+
PERMISSION_TOOL_LIST,
|
|
152
|
+
PERMISSION_METRICS_READ,
|
|
153
|
+
PERMISSION_GROUP_READ,
|
|
154
|
+
PERMISSION_GROUP_LIST,
|
|
155
|
+
PERMISSION_DISCOVERY_READ,
|
|
156
|
+
]
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
ROLE_AUDITOR = Role(
|
|
161
|
+
name="auditor",
|
|
162
|
+
description="Read-only access to audit logs and metrics",
|
|
163
|
+
permissions=frozenset(
|
|
164
|
+
[
|
|
165
|
+
PERMISSION_AUDIT_READ,
|
|
166
|
+
PERMISSION_METRICS_READ,
|
|
167
|
+
PERMISSION_PROVIDER_LIST,
|
|
168
|
+
PERMISSION_GROUP_LIST,
|
|
169
|
+
PERMISSION_DISCOVERY_READ,
|
|
170
|
+
]
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
ROLE_SERVICE_ACCOUNT = Role(
|
|
175
|
+
name="service-account",
|
|
176
|
+
description="Default role for service accounts - tool invocation only",
|
|
177
|
+
permissions=frozenset(
|
|
178
|
+
[
|
|
179
|
+
PERMISSION_PROVIDER_READ,
|
|
180
|
+
PERMISSION_PROVIDER_LIST,
|
|
181
|
+
PERMISSION_TOOL_INVOKE,
|
|
182
|
+
PERMISSION_TOOL_LIST,
|
|
183
|
+
]
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Role registry for easy lookup
|
|
188
|
+
BUILTIN_ROLES: dict[str, Role] = {
|
|
189
|
+
"admin": ROLE_ADMIN,
|
|
190
|
+
"provider-admin": ROLE_PROVIDER_ADMIN,
|
|
191
|
+
"developer": ROLE_DEVELOPER,
|
|
192
|
+
"viewer": ROLE_VIEWER,
|
|
193
|
+
"auditor": ROLE_AUDITOR,
|
|
194
|
+
"service-account": ROLE_SERVICE_ACCOUNT,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_builtin_role(name: str) -> Role | None:
|
|
199
|
+
"""Get a built-in role by name.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
name: Name of the built-in role.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Role if found, None otherwise.
|
|
206
|
+
"""
|
|
207
|
+
return BUILTIN_ROLES.get(name)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_permission(key: str) -> Permission | None:
|
|
211
|
+
"""Get a predefined permission by key.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
key: Permission key in format 'resource:action'.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Permission if found, None otherwise.
|
|
218
|
+
"""
|
|
219
|
+
return PERMISSIONS.get(key)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def list_builtin_roles() -> list[str]:
|
|
223
|
+
"""Get list of all built-in role names.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of role names.
|
|
227
|
+
"""
|
|
228
|
+
return list(BUILTIN_ROLES.keys())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_permissions() -> list[str]:
|
|
232
|
+
"""Get list of all predefined permission keys.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of permission keys.
|
|
236
|
+
"""
|
|
237
|
+
return list(PERMISSIONS.keys())
|