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,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())