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
+ Sanitizer module for the MCP Registry.
3
+
4
+ Provides sanitization utilities for:
5
+ - Command arguments (prevent injection)
6
+ - Environment variables (prevent injection)
7
+ - Log messages (prevent log injection)
8
+ - File paths (prevent traversal)
9
+ """
10
+
11
+ import html
12
+ import re
13
+ from typing import Any, Dict, List, Optional
14
+ import unicodedata
15
+
16
+ # Characters that could enable injection attacks
17
+ SHELL_METACHARACTERS = set(";&|`$(){}[]<>!#*?~\n\r\t\0\\'\"")
18
+
19
+ # Patterns for control characters and dangerous sequences
20
+ CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x1f\x7f-\x9f]")
21
+ NEWLINE_PATTERN = re.compile(r"[\r\n]")
22
+ NULL_BYTE_PATTERN = re.compile(r"\x00")
23
+ PATH_TRAVERSAL_PATTERN = re.compile(r"\.\.[\\/]")
24
+
25
+
26
+ class Sanitizer:
27
+ """
28
+ Comprehensive sanitizer for security-critical operations.
29
+
30
+ Provides methods to sanitize various types of inputs to prevent
31
+ injection attacks, log injection, and path traversal.
32
+ """
33
+
34
+ # Configurable limits
35
+ MAX_ARGUMENT_LENGTH = 4096
36
+ MAX_PATH_LENGTH = 4096
37
+ MAX_LOG_MESSAGE_LENGTH = 10000
38
+ MAX_ENV_VALUE_LENGTH = 32768
39
+
40
+ def __init__(
41
+ self,
42
+ max_argument_length: int = MAX_ARGUMENT_LENGTH,
43
+ max_path_length: int = MAX_PATH_LENGTH,
44
+ max_log_message_length: int = MAX_LOG_MESSAGE_LENGTH,
45
+ ):
46
+ """
47
+ Initialize sanitizer with configuration.
48
+
49
+ Args:
50
+ max_argument_length: Maximum length for command arguments
51
+ max_path_length: Maximum length for file paths
52
+ max_log_message_length: Maximum length for log messages
53
+ """
54
+ self.max_argument_length = max_argument_length
55
+ self.max_path_length = max_path_length
56
+ self.max_log_message_length = max_log_message_length
57
+
58
+ def sanitize_command_argument(
59
+ self,
60
+ argument: str,
61
+ allow_spaces: bool = True,
62
+ allow_quotes: bool = False,
63
+ replacement: str = "_",
64
+ ) -> str:
65
+ """
66
+ Sanitize a command-line argument to prevent shell injection.
67
+
68
+ This method removes or replaces characters that could be interpreted
69
+ by the shell as metacharacters.
70
+
71
+ Args:
72
+ argument: The argument to sanitize
73
+ allow_spaces: Whether to allow space characters
74
+ allow_quotes: Whether to allow quote characters
75
+ replacement: Character to replace dangerous chars with
76
+
77
+ Returns:
78
+ Sanitized argument string
79
+
80
+ Note:
81
+ This is a defense-in-depth measure. Always use subprocess with
82
+ shell=False and pass arguments as a list.
83
+ """
84
+ if not isinstance(argument, str):
85
+ argument = str(argument)
86
+
87
+ # Truncate if too long
88
+ if len(argument) > self.max_argument_length:
89
+ argument = argument[: self.max_argument_length]
90
+
91
+ # Remove null bytes
92
+ argument = argument.replace("\0", "")
93
+
94
+ # Remove control characters
95
+ argument = CONTROL_CHAR_PATTERN.sub(replacement, argument)
96
+
97
+ # Build set of allowed metacharacters
98
+ dangerous = SHELL_METACHARACTERS.copy()
99
+ if allow_spaces:
100
+ dangerous.discard(" ")
101
+ if allow_quotes:
102
+ dangerous.discard('"')
103
+ dangerous.discard("'")
104
+
105
+ # Replace dangerous characters
106
+ result = []
107
+ for char in argument:
108
+ if char in dangerous:
109
+ result.append(replacement)
110
+ else:
111
+ result.append(char)
112
+
113
+ return "".join(result)
114
+
115
+ def sanitize_command_list(
116
+ self,
117
+ command: List[str],
118
+ allow_spaces: bool = True,
119
+ ) -> List[str]:
120
+ """
121
+ Sanitize an entire command list.
122
+
123
+ Args:
124
+ command: List of command arguments
125
+ allow_spaces: Whether to allow spaces in arguments
126
+
127
+ Returns:
128
+ List of sanitized arguments
129
+ """
130
+ return [self.sanitize_command_argument(arg, allow_spaces=allow_spaces) for arg in command]
131
+
132
+ def sanitize_environment_value(
133
+ self,
134
+ value: str,
135
+ allow_newlines: bool = False,
136
+ ) -> str:
137
+ """
138
+ Sanitize an environment variable value.
139
+
140
+ Args:
141
+ value: The value to sanitize
142
+ allow_newlines: Whether to allow newline characters
143
+
144
+ Returns:
145
+ Sanitized value string
146
+ """
147
+ if not isinstance(value, str):
148
+ value = str(value)
149
+
150
+ # Truncate if too long
151
+ if len(value) > self.MAX_ENV_VALUE_LENGTH:
152
+ value = value[: self.MAX_ENV_VALUE_LENGTH]
153
+
154
+ # Remove null bytes (always dangerous)
155
+ value = value.replace("\0", "")
156
+
157
+ # Optionally remove newlines
158
+ if not allow_newlines:
159
+ value = NEWLINE_PATTERN.sub(" ", value)
160
+
161
+ return value
162
+
163
+ def sanitize_environment_dict(
164
+ self,
165
+ env: Dict[str, str],
166
+ allow_newlines: bool = False,
167
+ ) -> Dict[str, str]:
168
+ """
169
+ Sanitize all environment variable values in a dictionary.
170
+
171
+ Args:
172
+ env: Dictionary of environment variables
173
+ allow_newlines: Whether to allow newlines in values
174
+
175
+ Returns:
176
+ Dictionary with sanitized values
177
+ """
178
+ return {key: self.sanitize_environment_value(value, allow_newlines) for key, value in env.items()}
179
+
180
+ def sanitize_path(
181
+ self,
182
+ path: str,
183
+ allow_absolute: bool = False,
184
+ allow_hidden: bool = True,
185
+ ) -> str:
186
+ """
187
+ Sanitize a file path to prevent path traversal attacks.
188
+
189
+ Args:
190
+ path: The path to sanitize
191
+ allow_absolute: Whether to allow absolute paths
192
+ allow_hidden: Whether to allow hidden files (starting with .)
193
+
194
+ Returns:
195
+ Sanitized path string
196
+
197
+ Raises:
198
+ ValueError: If the path is invalid or dangerous
199
+ """
200
+ if not isinstance(path, str):
201
+ path = str(path)
202
+
203
+ # Truncate if too long
204
+ if len(path) > self.max_path_length:
205
+ raise ValueError(f"Path exceeds maximum length ({self.max_path_length})")
206
+
207
+ # Remove null bytes
208
+ path = path.replace("\0", "")
209
+
210
+ # Normalize unicode to detect obfuscation
211
+ path = unicodedata.normalize("NFKC", path)
212
+
213
+ # Check for path traversal
214
+ if PATH_TRAVERSAL_PATTERN.search(path) or ".." in path.split("/"):
215
+ raise ValueError("Path contains traversal sequences")
216
+
217
+ # Check for absolute paths
218
+ if not allow_absolute:
219
+ if path.startswith("/") or (len(path) > 1 and path[1] == ":"):
220
+ raise ValueError("Absolute paths are not allowed")
221
+
222
+ # Check for hidden files
223
+ if not allow_hidden:
224
+ parts = path.replace("\\", "/").split("/")
225
+ for part in parts:
226
+ if part.startswith(".") and part not in (".", ".."):
227
+ raise ValueError("Hidden files/directories are not allowed")
228
+
229
+ # Check for control characters
230
+ if CONTROL_CHAR_PATTERN.search(path):
231
+ raise ValueError("Path contains control characters")
232
+
233
+ return path
234
+
235
+ def sanitize_log_message(
236
+ self,
237
+ message: str,
238
+ max_length: Optional[int] = None,
239
+ ) -> str:
240
+ """
241
+ Sanitize a message for safe logging.
242
+
243
+ Prevents log injection attacks by:
244
+ - Escaping newlines
245
+ - Escaping control characters
246
+ - Truncating long messages
247
+ - Normalizing unicode
248
+
249
+ Args:
250
+ message: The message to sanitize
251
+ max_length: Optional override for max length
252
+
253
+ Returns:
254
+ Sanitized message string
255
+ """
256
+ if not isinstance(message, str):
257
+ message = str(message)
258
+
259
+ max_len = max_length or self.max_log_message_length
260
+
261
+ # Truncate if too long
262
+ if len(message) > max_len:
263
+ message = message[:max_len] + "...[truncated]"
264
+
265
+ # Normalize unicode
266
+ message = unicodedata.normalize("NFKC", message)
267
+
268
+ # Replace newlines with visible escape sequences
269
+ message = message.replace("\r\n", "\\r\\n")
270
+ message = message.replace("\r", "\\r")
271
+ message = message.replace("\n", "\\n")
272
+
273
+ # Replace other control characters with their escape representation
274
+ def escape_control(match):
275
+ char = match.group(0)
276
+ if char == "\t":
277
+ return "\\t"
278
+ return f"\\x{ord(char):02x}"
279
+
280
+ message = CONTROL_CHAR_PATTERN.sub(escape_control, message)
281
+
282
+ return message
283
+
284
+ def sanitize_for_json(self, value: Any) -> Any:
285
+ """
286
+ Sanitize a value for safe JSON serialization.
287
+
288
+ Handles nested structures recursively.
289
+
290
+ Args:
291
+ value: The value to sanitize
292
+
293
+ Returns:
294
+ Sanitized value safe for JSON
295
+ """
296
+ if isinstance(value, str):
297
+ # Remove null bytes and control chars except common whitespace
298
+ result = value.replace("\0", "")
299
+ # Keep tabs and newlines but remove other control chars
300
+ result = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", result)
301
+ return result
302
+ elif isinstance(value, dict):
303
+ return {self.sanitize_for_json(k): self.sanitize_for_json(v) for k, v in value.items()}
304
+ elif isinstance(value, list):
305
+ return [self.sanitize_for_json(item) for item in value]
306
+ elif isinstance(value, (int, float, bool, type(None))):
307
+ return value
308
+ else:
309
+ # Convert unknown types to string and sanitize
310
+ return self.sanitize_for_json(str(value))
311
+
312
+ def escape_html(self, value: str) -> str:
313
+ """
314
+ Escape HTML special characters.
315
+
316
+ Args:
317
+ value: The string to escape
318
+
319
+ Returns:
320
+ HTML-escaped string
321
+ """
322
+ return html.escape(value, quote=True)
323
+
324
+ def mask_value(
325
+ self,
326
+ value: str,
327
+ visible_chars: int = 4,
328
+ mask_char: str = "*",
329
+ ) -> str:
330
+ """
331
+ Mask a sensitive value, showing only first few characters.
332
+
333
+ Args:
334
+ value: The value to mask
335
+ visible_chars: Number of characters to show at the start
336
+ mask_char: Character to use for masking
337
+
338
+ Returns:
339
+ Masked string
340
+ """
341
+ if not value:
342
+ return ""
343
+
344
+ if len(value) <= visible_chars:
345
+ return mask_char * len(value)
346
+
347
+ return value[:visible_chars] + mask_char * (len(value) - visible_chars)
348
+
349
+
350
+ # --- Convenience Functions ---
351
+
352
+ # Global sanitizer instance with default settings
353
+ _default_sanitizer = Sanitizer()
354
+
355
+
356
+ def sanitize_command_argument(
357
+ argument: str,
358
+ allow_spaces: bool = True,
359
+ allow_quotes: bool = False,
360
+ ) -> str:
361
+ """Sanitize a command argument using default sanitizer."""
362
+ return _default_sanitizer.sanitize_command_argument(argument, allow_spaces, allow_quotes)
363
+
364
+
365
+ def sanitize_environment_value(
366
+ value: str,
367
+ allow_newlines: bool = False,
368
+ ) -> str:
369
+ """Sanitize an environment value using default sanitizer."""
370
+ return _default_sanitizer.sanitize_environment_value(value, allow_newlines)
371
+
372
+
373
+ def sanitize_log_message(
374
+ message: str,
375
+ max_length: Optional[int] = None,
376
+ ) -> str:
377
+ """Sanitize a log message using default sanitizer."""
378
+ return _default_sanitizer.sanitize_log_message(message, max_length)
379
+
380
+
381
+ def sanitize_path(
382
+ path: str,
383
+ allow_absolute: bool = False,
384
+ allow_hidden: bool = True,
385
+ ) -> str:
386
+ """Sanitize a file path using default sanitizer."""
387
+ return _default_sanitizer.sanitize_path(path, allow_absolute, allow_hidden)