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,165 @@
1
+ """Starlette/FastAPI middleware for authentication.
2
+
3
+ Integrates auth middleware with HTTP frameworks.
4
+ """
5
+
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.requests import Request
8
+ from starlette.responses import JSONResponse
9
+
10
+ from ..domain.contracts.authentication import AuthRequest
11
+ from ..domain.exceptions import AccessDeniedError, AuthenticationError
12
+ from ..infrastructure.auth.middleware import AuthenticationMiddleware
13
+
14
+
15
+ class AuthMiddlewareHTTP(BaseHTTPMiddleware):
16
+ """Starlette middleware for HTTP authentication.
17
+
18
+ Authenticates incoming requests and attaches auth context to request.state.
19
+ Skips authentication for configured paths (health, metrics, etc.).
20
+
21
+ Usage:
22
+ from starlette.applications import Starlette
23
+ from mcp_hangar.server.http_auth_middleware import AuthMiddlewareHTTP
24
+
25
+ app = Starlette()
26
+ app.add_middleware(AuthMiddlewareHTTP, authn=authn_middleware)
27
+
28
+ # In route handler:
29
+ @app.route("/providers")
30
+ async def list_providers(request):
31
+ auth_context = request.state.auth # AuthContext
32
+ principal = auth_context.principal
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ app,
38
+ authn: AuthenticationMiddleware,
39
+ skip_paths: list[str] | None = None,
40
+ ):
41
+ """Initialize the HTTP auth middleware.
42
+
43
+ Args:
44
+ app: The ASGI application.
45
+ authn: Authentication middleware to use.
46
+ skip_paths: Paths to skip authentication (e.g., ["/health", "/metrics"]).
47
+ """
48
+ super().__init__(app)
49
+ self._authn = authn
50
+ self._skip_paths = skip_paths or ["/health", "/ready", "/_ready", "/metrics"]
51
+
52
+ async def dispatch(self, request: Request, call_next):
53
+ """Process request through authentication middleware.
54
+
55
+ Args:
56
+ request: The incoming Starlette request.
57
+ call_next: The next middleware/handler in the chain.
58
+
59
+ Returns:
60
+ Response from the handler or error response.
61
+ """
62
+ # Skip auth for certain paths
63
+ if request.url.path in self._skip_paths:
64
+ return await call_next(request)
65
+
66
+ # Build auth request from HTTP request
67
+ auth_request = self._build_auth_request(request)
68
+
69
+ try:
70
+ # Authenticate
71
+ auth_context = self._authn.authenticate(auth_request)
72
+ request.state.auth = auth_context
73
+ return await call_next(request)
74
+
75
+ except AuthenticationError as e:
76
+ return JSONResponse(
77
+ status_code=401,
78
+ content={
79
+ "error": "authentication_failed",
80
+ "message": e.message,
81
+ "details": e.details,
82
+ },
83
+ headers={"WWW-Authenticate": "Bearer, ApiKey"},
84
+ )
85
+
86
+ except AccessDeniedError as e:
87
+ return JSONResponse(
88
+ status_code=403,
89
+ content={
90
+ "error": "access_denied",
91
+ "message": str(e),
92
+ "principal_id": e.principal_id,
93
+ "action": e.action,
94
+ "resource": e.resource,
95
+ },
96
+ )
97
+
98
+ def _build_auth_request(self, request: Request) -> AuthRequest:
99
+ """Build AuthRequest from Starlette Request.
100
+
101
+ Args:
102
+ request: The Starlette request.
103
+
104
+ Returns:
105
+ AuthRequest for the authentication middleware.
106
+ """
107
+ # Get client IP from socket
108
+ source_ip = "unknown"
109
+ if request.client:
110
+ source_ip = request.client.host
111
+
112
+ # Only trust X-Forwarded-For if request comes from a trusted proxy
113
+ # This prevents IP spoofing attacks
114
+ if source_ip in self._trusted_proxies:
115
+ forwarded_for = request.headers.get("x-forwarded-for")
116
+ if forwarded_for:
117
+ # Take the first IP in the chain (original client)
118
+ # Note: In a chain of proxies, you may need to take a different position
119
+ source_ip = forwarded_for.split(",")[0].strip()
120
+
121
+ return AuthRequest(
122
+ headers=dict(request.headers),
123
+ source_ip=source_ip,
124
+ method=request.method,
125
+ path=request.url.path,
126
+ )
127
+
128
+
129
+ def get_principal_from_request(request: Request):
130
+ """Get authenticated principal from request.
131
+
132
+ Helper function to extract principal from request state.
133
+
134
+ Args:
135
+ request: The Starlette request.
136
+
137
+ Returns:
138
+ Principal from auth context, or None if not authenticated.
139
+ """
140
+ auth_context = getattr(request.state, "auth", None)
141
+ if auth_context:
142
+ return auth_context.principal
143
+ return None
144
+
145
+
146
+ def require_auth(request: Request):
147
+ """Require authentication for a request.
148
+
149
+ Helper function that raises if request is not authenticated.
150
+
151
+ Args:
152
+ request: The Starlette request.
153
+
154
+ Returns:
155
+ Principal if authenticated.
156
+
157
+ Raises:
158
+ AuthenticationError: If not authenticated.
159
+ """
160
+ from ..domain.exceptions import MissingCredentialsError
161
+
162
+ principal = get_principal_from_request(request)
163
+ if principal is None or principal.is_anonymous():
164
+ raise MissingCredentialsError("Authentication required")
165
+ return principal
@@ -0,0 +1,467 @@
1
+ """Server Lifecycle Management.
2
+
3
+ This module handles starting, running, and stopping the MCP Hangar server.
4
+ It manages signal handling for graceful shutdown.
5
+
6
+ The lifecycle flow:
7
+ 1. Setup logging based on CLI config
8
+ 2. Bootstrap application
9
+ 3. Start background components
10
+ 4. Run appropriate server mode (stdio or HTTP)
11
+ 5. Handle shutdown on exit/signal
12
+ """
13
+
14
+ import asyncio
15
+ from pathlib import Path
16
+ import signal
17
+ import sys
18
+ from typing import TYPE_CHECKING
19
+
20
+ import yaml
21
+
22
+ from ..logging_config import get_logger, setup_logging
23
+ from .bootstrap import ApplicationContext, bootstrap
24
+ from .cli import CLIConfig
25
+ from .config import load_config_from_file
26
+ from .state import get_discovery_orchestrator
27
+
28
+ if TYPE_CHECKING:
29
+ pass
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class ServerLifecycle:
35
+ """Manages server start/stop lifecycle.
36
+
37
+ This class coordinates the startup and shutdown of all server components
38
+ including background workers, discovery orchestrator, and the MCP server.
39
+ """
40
+
41
+ def __init__(self, context: ApplicationContext):
42
+ """Initialize server lifecycle.
43
+
44
+ Args:
45
+ context: Fully initialized ApplicationContext from bootstrap.
46
+ """
47
+ self._context = context
48
+ self._running = False
49
+ self._shutdown_requested = False
50
+
51
+ @property
52
+ def is_running(self) -> bool:
53
+ """Check if server is running."""
54
+ return self._running
55
+
56
+ def start(self) -> None:
57
+ """Start all background components.
58
+
59
+ Starts:
60
+ - Background workers (GC, health check)
61
+ - Discovery orchestrator (if enabled)
62
+
63
+ Does NOT start the MCP server - that's handled by run_stdio() or run_http().
64
+ """
65
+ if self._running:
66
+ logger.warning("server_lifecycle_already_running")
67
+ return
68
+
69
+ self._running = True
70
+ logger.info("server_lifecycle_start")
71
+
72
+ # Start background workers
73
+ for worker in self._context.background_workers:
74
+ worker.start()
75
+
76
+ logger.info(
77
+ "background_workers_started",
78
+ workers=[w.task for w in self._context.background_workers],
79
+ )
80
+
81
+ # Start discovery orchestrator
82
+ if self._context.discovery_orchestrator:
83
+ asyncio.run(self._context.discovery_orchestrator.start())
84
+ stats = self._context.discovery_orchestrator.get_stats()
85
+ logger.info("discovery_started", sources_count=stats["sources_count"])
86
+
87
+ def run_stdio(self) -> None:
88
+ """Run MCP server in stdio mode. Blocks until exit.
89
+
90
+ This is the standard mode for Claude Desktop, Cursor, and other
91
+ MCP clients that communicate via stdin/stdout.
92
+ """
93
+ logger.info("starting_stdio_server")
94
+ try:
95
+ self._context.mcp_server.run()
96
+ except KeyboardInterrupt:
97
+ logger.info("stdio_server_shutdown", reason="keyboard_interrupt")
98
+ except Exception as e:
99
+ logger.critical(
100
+ "fatal_server_error",
101
+ error=str(e),
102
+ error_type=type(e).__name__,
103
+ )
104
+ sys.exit(1)
105
+
106
+ def run_http(self, host: str, port: int) -> None:
107
+ """Run MCP server in HTTP mode. Blocks until exit.
108
+
109
+ This mode is compatible with LM Studio and other MCP HTTP clients.
110
+
111
+ Endpoints:
112
+ - /mcp: Streamable HTTP MCP endpoint (POST/GET)
113
+
114
+ Args:
115
+ host: Host to bind to.
116
+ port: Port to bind to.
117
+ """
118
+ import uvicorn
119
+
120
+ logger.info("starting_http_server", host=host, port=port)
121
+
122
+ # Update FastMCP settings for HTTP mode
123
+ mcp_server = self._context.mcp_server
124
+ mcp_server.settings.host = host
125
+ mcp_server.settings.port = port
126
+
127
+ # Get the MCP app from FastMCP
128
+ mcp_app = mcp_server.streamable_http_app()
129
+
130
+ # Create auxiliary routes for /metrics, /health, /ready
131
+ import time
132
+
133
+ from starlette.applications import Starlette
134
+ from starlette.responses import JSONResponse, PlainTextResponse
135
+ from starlette.routing import Route
136
+
137
+ from ..metrics import get_metrics
138
+ from ..server.state import PROVIDERS
139
+
140
+ _start_time = time.time()
141
+ _startup_complete = False
142
+
143
+ def liveness_endpoint(request):
144
+ """Liveness check - is the process alive?"""
145
+ return JSONResponse({"status": "healthy"})
146
+
147
+ def readiness_endpoint(request):
148
+ """Readiness check - can we handle traffic?"""
149
+ ready_count = sum(1 for p in PROVIDERS.values() if p.state.value == "ready")
150
+ total_count = len(PROVIDERS)
151
+ is_ready = ready_count > 0 or total_count == 0
152
+ return JSONResponse(
153
+ {
154
+ "status": "healthy" if is_ready else "unhealthy",
155
+ "ready_providers": ready_count,
156
+ "total_providers": total_count,
157
+ },
158
+ status_code=200 if is_ready else 503,
159
+ )
160
+
161
+ def startup_endpoint(request):
162
+ """Startup check - has initialization completed?"""
163
+ nonlocal _startup_complete
164
+ # Mark startup complete after first check (bootstrap is done by this point)
165
+ _startup_complete = True
166
+ uptime = time.time() - _start_time
167
+ return JSONResponse(
168
+ {
169
+ "status": "healthy",
170
+ "startup_complete": _startup_complete,
171
+ "uptime_seconds": round(uptime, 2),
172
+ }
173
+ )
174
+
175
+ def metrics_endpoint(request):
176
+ """Prometheus metrics endpoint."""
177
+ return PlainTextResponse(
178
+ get_metrics(),
179
+ media_type="text/plain; version=0.0.4; charset=utf-8",
180
+ )
181
+
182
+ routes = [
183
+ Route("/health/live", liveness_endpoint, methods=["GET"]),
184
+ Route("/health/ready", readiness_endpoint, methods=["GET"]),
185
+ Route("/health/startup", startup_endpoint, methods=["GET"]),
186
+ Route("/metrics", metrics_endpoint, methods=["GET"]),
187
+ ]
188
+
189
+ aux_app = Starlette(routes=routes)
190
+
191
+ async def combined_app(scope, receive, send):
192
+ """Combined ASGI app that routes to metrics/health or MCP."""
193
+ if scope["type"] == "http":
194
+ path = scope.get("path", "")
195
+ if path.startswith("/health/") or path == "/metrics":
196
+ await aux_app(scope, receive, send)
197
+ return
198
+ await mcp_app(scope, receive, send)
199
+
200
+ # Apply authentication middleware if enabled
201
+ auth_components = self._context.auth_components
202
+ if auth_components and auth_components.enabled:
203
+ starlette_app = self._create_auth_app(combined_app, auth_components)
204
+ logger.info("http_auth_enabled")
205
+ else:
206
+ starlette_app = combined_app
207
+
208
+ # Configure uvicorn with log_config=None to disable default uvicorn logging
209
+ # Our structlog configuration will handle all logging uniformly
210
+ config = uvicorn.Config(
211
+ starlette_app,
212
+ host=host,
213
+ port=port,
214
+ log_config=None, # Disable uvicorn's default logging
215
+ access_log=False, # Disable access logs (we'll handle them via structlog if needed)
216
+ )
217
+
218
+ async def run_server():
219
+ server = uvicorn.Server(config)
220
+ logger.info("http_server_started", host=host, port=port, endpoint="/mcp")
221
+ await server.serve()
222
+ logger.info("http_server_stopped")
223
+
224
+ try:
225
+ asyncio.run(run_server())
226
+ except KeyboardInterrupt:
227
+ logger.info("http_server_shutdown", reason="keyboard_interrupt")
228
+ except asyncio.CancelledError:
229
+ logger.info("http_server_shutdown", reason="cancelled")
230
+ except Exception as e:
231
+ logger.critical(
232
+ "fatal_server_error",
233
+ error=str(e),
234
+ error_type=type(e).__name__,
235
+ )
236
+ sys.exit(1)
237
+
238
+ def shutdown(self) -> None:
239
+ """Graceful shutdown of all components.
240
+
241
+ Stops:
242
+ - Background workers
243
+ - Discovery orchestrator
244
+ - All providers
245
+
246
+ This method is safe to call multiple times.
247
+ """
248
+ if self._shutdown_requested:
249
+ logger.debug("shutdown_already_requested")
250
+ return
251
+
252
+ self._shutdown_requested = True
253
+ logger.info("server_lifecycle_shutdown_start")
254
+
255
+ self._context.shutdown()
256
+ self._running = False
257
+
258
+ logger.info("server_lifecycle_shutdown_complete")
259
+
260
+ def _create_auth_app(self, inner_app, auth_components):
261
+ """Create auth-enabled ASGI app wrapper.
262
+
263
+ Args:
264
+ inner_app: The inner ASGI app to wrap.
265
+ auth_components: Auth components with middleware.
266
+
267
+ Returns:
268
+ ASGI app with authentication.
269
+ """
270
+ from starlette.responses import JSONResponse
271
+
272
+ from ..domain.contracts.authentication import AuthRequest
273
+ from ..domain.exceptions import AccessDeniedError, AuthenticationError
274
+
275
+ # Paths to skip authentication (health checks, metrics)
276
+ skip_paths = frozenset(["/health/live", "/health/ready", "/health/startup", "/metrics"])
277
+ # Default trusted proxies (should be configured in production)
278
+ trusted_proxies = frozenset(["127.0.0.1", "::1"])
279
+
280
+ async def auth_app(scope, receive, send):
281
+ """ASGI app with authentication middleware."""
282
+ if scope["type"] != "http":
283
+ await inner_app(scope, receive, send)
284
+ return
285
+
286
+ path = scope.get("path", "")
287
+
288
+ # Skip auth for health/metrics endpoints
289
+ if path in skip_paths or path.startswith("/health/"):
290
+ await inner_app(scope, receive, send)
291
+ return
292
+
293
+ # Build headers dict from scope
294
+ headers = {}
295
+ for key, value in scope.get("headers", []):
296
+ headers[key.decode("latin-1").lower()] = value.decode("latin-1")
297
+
298
+ # Get client IP
299
+ client = scope.get("client")
300
+ source_ip = client[0] if client else "unknown"
301
+
302
+ # Trust X-Forwarded-For only from trusted proxies
303
+ if source_ip in trusted_proxies:
304
+ forwarded_for = headers.get("x-forwarded-for")
305
+ if forwarded_for:
306
+ source_ip = forwarded_for.split(",")[0].strip()
307
+
308
+ # Create auth request
309
+ auth_request = AuthRequest(
310
+ headers=headers,
311
+ source_ip=source_ip,
312
+ method=scope.get("method", ""),
313
+ path=path,
314
+ )
315
+
316
+ try:
317
+ # Authenticate
318
+ auth_context = auth_components.authn_middleware.authenticate(auth_request)
319
+
320
+ # Store auth context in scope for downstream handlers
321
+ scope["auth"] = auth_context
322
+
323
+ # Pass to inner app
324
+ await inner_app(scope, receive, send)
325
+
326
+ except AuthenticationError as e:
327
+ response = JSONResponse(
328
+ status_code=401,
329
+ content={
330
+ "error": "authentication_failed",
331
+ "message": e.message,
332
+ },
333
+ headers={"WWW-Authenticate": "Bearer, ApiKey"},
334
+ )
335
+ await response(scope, receive, send)
336
+
337
+ except AccessDeniedError as e:
338
+ response = JSONResponse(
339
+ status_code=403,
340
+ content={
341
+ "error": "access_denied",
342
+ "message": str(e),
343
+ },
344
+ )
345
+ await response(scope, receive, send)
346
+
347
+ return auth_app
348
+
349
+
350
+ def _setup_signal_handlers(lifecycle: ServerLifecycle) -> None:
351
+ """Setup graceful shutdown on SIGTERM/SIGINT.
352
+
353
+ Args:
354
+ lifecycle: ServerLifecycle instance to shutdown on signal.
355
+ """
356
+
357
+ def handler(signum, frame):
358
+ sig_name = signal.Signals(signum).name
359
+ logger.info("shutdown_signal_received", signal=sig_name)
360
+ lifecycle.shutdown()
361
+ sys.exit(0)
362
+
363
+ signal.signal(signal.SIGTERM, handler)
364
+ signal.signal(signal.SIGINT, handler)
365
+
366
+
367
+ def _setup_logging_from_config(cli_config: CLIConfig) -> None:
368
+ """Setup logging based on CLI config and config file.
369
+
370
+ Logging configuration priority:
371
+ 1. CLI arguments (--log-level, --log-file, --json-logs)
372
+ 2. Config file (logging section)
373
+ 3. Environment variables
374
+ 4. Defaults
375
+
376
+ Args:
377
+ cli_config: Parsed CLI configuration.
378
+ """
379
+ log_level = cli_config.log_level
380
+ log_file = cli_config.log_file
381
+ json_format = cli_config.json_logs
382
+
383
+ # Try to load additional settings from config file
384
+ if cli_config.config_path and Path(cli_config.config_path).exists():
385
+ try:
386
+ full_config = load_config_from_file(cli_config.config_path)
387
+ logging_config = full_config.get("logging", {})
388
+
389
+ # Config file values are used only if CLI didn't specify
390
+ if cli_config.log_level == "INFO": # Default value
391
+ log_level = logging_config.get("level", log_level).upper()
392
+
393
+ if not cli_config.log_file:
394
+ log_file = logging_config.get("file", log_file)
395
+
396
+ if not cli_config.json_logs:
397
+ json_format = logging_config.get("json_format", json_format)
398
+
399
+ except (FileNotFoundError, yaml.YAMLError, ValueError, OSError) as e:
400
+ # Config loading failed - use CLI values, log will be set up shortly
401
+ logger.debug("config_preload_failed", error=str(e))
402
+
403
+ setup_logging(level=log_level, json_format=json_format, log_file=log_file)
404
+
405
+
406
+ def run_server(cli_config: CLIConfig) -> None:
407
+ """Main entry point that ties everything together.
408
+
409
+ This function orchestrates:
410
+ 1. Setup logging based on CLI config
411
+ 2. Bootstrap application
412
+ 3. Setup signal handlers
413
+ 4. Start lifecycle (background workers, discovery)
414
+ 5. Run appropriate server mode
415
+ 6. Handle shutdown on exit/signal
416
+
417
+ Args:
418
+ cli_config: Parsed CLI configuration from parse_args().
419
+ """
420
+ # Setup logging first
421
+ _setup_logging_from_config(cli_config)
422
+
423
+ mode_str = "http" if cli_config.http_mode else "stdio"
424
+ logger.info(
425
+ "mcp_registry_starting",
426
+ mode=mode_str,
427
+ log_file=cli_config.log_file,
428
+ )
429
+
430
+ # Bootstrap application
431
+ context = bootstrap(cli_config.config_path)
432
+
433
+ # Create lifecycle manager
434
+ lifecycle = ServerLifecycle(context)
435
+
436
+ # Setup signal handlers for graceful shutdown
437
+ _setup_signal_handlers(lifecycle)
438
+
439
+ # Start background components
440
+ lifecycle.start()
441
+
442
+ # Log ready state
443
+ provider_ids = list(context.runtime.repository.get_all_ids())
444
+ orchestrator = get_discovery_orchestrator()
445
+ discovery_status = "enabled" if orchestrator else "disabled"
446
+
447
+ logger.info(
448
+ "mcp_registry_ready",
449
+ providers=provider_ids,
450
+ discovery=discovery_status,
451
+ )
452
+
453
+ # Run server in appropriate mode
454
+ try:
455
+ if cli_config.http_mode:
456
+ lifecycle.run_http(cli_config.http_host, cli_config.http_port)
457
+ else:
458
+ lifecycle.run_stdio()
459
+ finally:
460
+ # Ensure cleanup on exit
461
+ lifecycle.shutdown()
462
+
463
+
464
+ __all__ = [
465
+ "ServerLifecycle",
466
+ "run_server",
467
+ ]