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,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
|
+
]
|