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
mcp_hangar/server/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command Line Interface for MCP Hangar Server.
|
|
2
|
+
|
|
3
|
+
This module handles CLI argument parsing and environment variable resolution.
|
|
4
|
+
It is designed to be pure - no side effects, no logging setup, no server startup.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from mcp_hangar.server.cli import parse_args, CLIConfig
|
|
8
|
+
|
|
9
|
+
config = parse_args()
|
|
10
|
+
# or for testing:
|
|
11
|
+
config = parse_args(["--http", "--port", "9000"])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
import os
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CLIConfig:
|
|
22
|
+
"""Parsed CLI configuration.
|
|
23
|
+
|
|
24
|
+
This is a frozen dataclass to ensure immutability after parsing.
|
|
25
|
+
All values are resolved from CLI arguments and environment variables.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
http_mode: bool
|
|
29
|
+
"""Whether to run in HTTP mode (True) or stdio mode (False)."""
|
|
30
|
+
|
|
31
|
+
http_host: str
|
|
32
|
+
"""Host to bind HTTP server to."""
|
|
33
|
+
|
|
34
|
+
http_port: int
|
|
35
|
+
"""Port to bind HTTP server to."""
|
|
36
|
+
|
|
37
|
+
config_path: Optional[str]
|
|
38
|
+
"""Path to config.yaml file."""
|
|
39
|
+
|
|
40
|
+
log_file: Optional[str]
|
|
41
|
+
"""Path to log file for server output."""
|
|
42
|
+
|
|
43
|
+
log_level: str
|
|
44
|
+
"""Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)."""
|
|
45
|
+
|
|
46
|
+
json_logs: bool
|
|
47
|
+
"""Whether to format logs as JSON."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_args(args: Optional[List[str]] = None) -> CLIConfig:
|
|
51
|
+
"""Parse command line arguments.
|
|
52
|
+
|
|
53
|
+
Resolves values in this order (later overrides earlier):
|
|
54
|
+
1. Default values
|
|
55
|
+
2. Environment variables
|
|
56
|
+
3. CLI arguments
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
args: Optional argument list (for testing). Uses sys.argv if None.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Parsed CLIConfig dataclass with all resolved values.
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
# Default parsing from sys.argv
|
|
66
|
+
config = parse_args()
|
|
67
|
+
|
|
68
|
+
# Testing with specific arguments
|
|
69
|
+
config = parse_args(["--http", "--port", "9000"])
|
|
70
|
+
assert config.http_mode is True
|
|
71
|
+
assert config.http_port == 9000
|
|
72
|
+
"""
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
description="MCP Hangar - Production-grade MCP provider registry",
|
|
75
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
76
|
+
epilog="""
|
|
77
|
+
Examples:
|
|
78
|
+
mcp-hangar --config config.yaml
|
|
79
|
+
mcp-hangar --config config.yaml --http --port 8000
|
|
80
|
+
mcp-hangar --http --host 0.0.0.0 --port 9000
|
|
81
|
+
|
|
82
|
+
Environment Variables:
|
|
83
|
+
MCP_MODE Set to "http" to enable HTTP mode
|
|
84
|
+
MCP_HTTP_HOST HTTP server host (default: 0.0.0.0)
|
|
85
|
+
MCP_HTTP_PORT HTTP server port (default: 8000)
|
|
86
|
+
MCP_LOG_LEVEL Log level (default: INFO)
|
|
87
|
+
MCP_JSON_LOGS Set to "true" for JSON formatted logs
|
|
88
|
+
""",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--http",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Run HTTP server mode instead of stdio",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--host",
|
|
98
|
+
type=str,
|
|
99
|
+
default=None,
|
|
100
|
+
help="HTTP server host (default: 0.0.0.0)",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--port",
|
|
104
|
+
type=int,
|
|
105
|
+
default=None,
|
|
106
|
+
help="HTTP server port (default: 8000)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--config",
|
|
110
|
+
type=str,
|
|
111
|
+
default=None,
|
|
112
|
+
help="Path to config.yaml file",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--log-file",
|
|
116
|
+
type=str,
|
|
117
|
+
default=None,
|
|
118
|
+
help="Path to log file for server output",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--log-level",
|
|
122
|
+
type=str,
|
|
123
|
+
default=None,
|
|
124
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
125
|
+
help="Log level (default: INFO)",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--json-logs",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="Format logs as JSON",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
parsed = parser.parse_args(args)
|
|
134
|
+
|
|
135
|
+
# Resolve values: defaults -> env -> CLI args
|
|
136
|
+
# Environment variable defaults
|
|
137
|
+
env_http_mode = os.getenv("MCP_MODE", "stdio") == "http"
|
|
138
|
+
env_http_host = os.getenv("MCP_HTTP_HOST", "0.0.0.0")
|
|
139
|
+
env_http_port = int(os.getenv("MCP_HTTP_PORT", "8000"))
|
|
140
|
+
env_log_level = os.getenv("MCP_LOG_LEVEL", "INFO").upper()
|
|
141
|
+
env_json_logs = os.getenv("MCP_JSON_LOGS", "false").lower() == "true"
|
|
142
|
+
|
|
143
|
+
# CLI overrides env
|
|
144
|
+
http_mode = parsed.http or env_http_mode
|
|
145
|
+
http_host = parsed.host if parsed.host is not None else env_http_host
|
|
146
|
+
http_port = parsed.port if parsed.port is not None else env_http_port
|
|
147
|
+
log_level = parsed.log_level if parsed.log_level is not None else env_log_level
|
|
148
|
+
json_logs = parsed.json_logs or env_json_logs
|
|
149
|
+
|
|
150
|
+
return CLIConfig(
|
|
151
|
+
http_mode=http_mode,
|
|
152
|
+
http_host=http_host,
|
|
153
|
+
http_port=http_port,
|
|
154
|
+
config_path=parsed.config,
|
|
155
|
+
log_file=parsed.log_file,
|
|
156
|
+
log_level=log_level,
|
|
157
|
+
json_logs=json_logs,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["CLIConfig", "parse_args"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Configuration loading and provider registration.
|
|
2
|
+
|
|
3
|
+
Uses ApplicationContext for dependency injection (DIP).
|
|
4
|
+
|
|
5
|
+
Note: This module uses PROVIDERS and GROUPS from state.py for backward
|
|
6
|
+
compatibility. The config is loaded during startup before context is
|
|
7
|
+
fully initialized, so we populate the global collections which are then
|
|
8
|
+
shared with ApplicationContext.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from ..domain.model import LoadBalancerStrategy, Provider, ProviderGroup
|
|
18
|
+
from ..domain.security.input_validator import validate_provider_id
|
|
19
|
+
from ..logging_config import get_logger
|
|
20
|
+
|
|
21
|
+
# Backward compatibility - config populates these collections
|
|
22
|
+
# which are then shared with ApplicationContext
|
|
23
|
+
from .state import get_group_rebalance_saga, GROUPS, PROVIDERS
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config_from_file(config_path: str) -> Dict[str, Any]:
|
|
29
|
+
"""
|
|
30
|
+
Load configuration from YAML file.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config_path: Path to YAML configuration file
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Configuration dictionary
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
FileNotFoundError: If config file doesn't exist
|
|
40
|
+
yaml.YAMLError: If config file is invalid YAML
|
|
41
|
+
"""
|
|
42
|
+
path = Path(config_path)
|
|
43
|
+
if not path.exists():
|
|
44
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
45
|
+
|
|
46
|
+
with open(path, "r") as f:
|
|
47
|
+
config = yaml.safe_load(f)
|
|
48
|
+
|
|
49
|
+
if not config or "providers" not in config:
|
|
50
|
+
raise ValueError(f"Invalid configuration: missing 'providers' section in {config_path}")
|
|
51
|
+
|
|
52
|
+
return config
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(config: Dict[str, Any]) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Load provider and group configuration.
|
|
58
|
+
|
|
59
|
+
Creates Provider aggregates and ProviderGroup aggregates based on mode.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
config: Dictionary mapping provider IDs to provider spec dictionaries
|
|
63
|
+
"""
|
|
64
|
+
for provider_id, spec_dict in config.items():
|
|
65
|
+
result = validate_provider_id(provider_id)
|
|
66
|
+
if not result.valid:
|
|
67
|
+
logger.warning("skipping_invalid_provider_id", provider_id=provider_id)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
mode = spec_dict.get("mode", "subprocess")
|
|
71
|
+
|
|
72
|
+
if mode == "group":
|
|
73
|
+
_load_group_config(provider_id, spec_dict)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
_load_provider_config(provider_id, spec_dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_strategy(strategy_str: str, group_id: str) -> LoadBalancerStrategy:
|
|
80
|
+
"""Parse load balancer strategy string."""
|
|
81
|
+
try:
|
|
82
|
+
return LoadBalancerStrategy(strategy_str)
|
|
83
|
+
except ValueError:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"unknown_strategy_using_default",
|
|
86
|
+
strategy=strategy_str,
|
|
87
|
+
group_id=group_id,
|
|
88
|
+
default="round_robin",
|
|
89
|
+
)
|
|
90
|
+
return LoadBalancerStrategy.ROUND_ROBIN
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_group_members(
|
|
94
|
+
group: ProviderGroup,
|
|
95
|
+
group_id: str,
|
|
96
|
+
members: List[Dict[str, Any]],
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Load group members from configuration."""
|
|
99
|
+
saga = get_group_rebalance_saga()
|
|
100
|
+
|
|
101
|
+
for member_spec in members:
|
|
102
|
+
member_id = member_spec.get("id")
|
|
103
|
+
if not member_id:
|
|
104
|
+
logger.warning("skipping_group_member_without_id", group_id=group_id)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
result = validate_provider_id(member_id)
|
|
108
|
+
if not result.valid:
|
|
109
|
+
logger.warning("skipping_invalid_member_id", member_id=member_id)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
member_provider = _load_provider_config(member_id, member_spec)
|
|
113
|
+
group.add_member(
|
|
114
|
+
member_provider,
|
|
115
|
+
weight=member_spec.get("weight", 1),
|
|
116
|
+
priority=member_spec.get("priority", 1),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if saga:
|
|
120
|
+
saga.register_member(member_id, group_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_provider_config(provider_id: str, spec_dict: Dict[str, Any]) -> Provider:
|
|
124
|
+
"""Load a single provider configuration."""
|
|
125
|
+
user = spec_dict.get("user")
|
|
126
|
+
if user == "current":
|
|
127
|
+
user = f"{os.getuid()}:{os.getgid()}"
|
|
128
|
+
|
|
129
|
+
tools_config = spec_dict.get("tools")
|
|
130
|
+
tools = None
|
|
131
|
+
if tools_config:
|
|
132
|
+
tools = []
|
|
133
|
+
for tool_spec in tools_config:
|
|
134
|
+
tools.append(
|
|
135
|
+
{
|
|
136
|
+
"name": tool_spec.get("name"),
|
|
137
|
+
"description": tool_spec.get("description", ""),
|
|
138
|
+
"inputSchema": tool_spec.get("inputSchema", tool_spec.get("input_schema", {})),
|
|
139
|
+
"outputSchema": tool_spec.get("outputSchema", tool_spec.get("output_schema")),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
provider = Provider(
|
|
144
|
+
provider_id=provider_id,
|
|
145
|
+
mode=spec_dict.get("mode", "subprocess"),
|
|
146
|
+
command=spec_dict.get("command"),
|
|
147
|
+
image=spec_dict.get("image"),
|
|
148
|
+
endpoint=spec_dict.get("endpoint"),
|
|
149
|
+
env=spec_dict.get("env", {}),
|
|
150
|
+
idle_ttl_s=spec_dict.get("idle_ttl_s", 300),
|
|
151
|
+
health_check_interval_s=spec_dict.get("health_check_interval_s", 60),
|
|
152
|
+
max_consecutive_failures=spec_dict.get("max_consecutive_failures", 3),
|
|
153
|
+
volumes=spec_dict.get("volumes", []),
|
|
154
|
+
build=spec_dict.get("build"),
|
|
155
|
+
resources=spec_dict.get("resources", {"memory": "512m", "cpu": "1.0"}),
|
|
156
|
+
network=spec_dict.get("network", "none"),
|
|
157
|
+
read_only=spec_dict.get("read_only", True),
|
|
158
|
+
user=user,
|
|
159
|
+
description=spec_dict.get("description"),
|
|
160
|
+
tools=tools,
|
|
161
|
+
)
|
|
162
|
+
PROVIDERS[provider_id] = provider
|
|
163
|
+
logger.debug(
|
|
164
|
+
"provider_loaded",
|
|
165
|
+
provider_id=provider_id,
|
|
166
|
+
mode=spec_dict.get("mode", "subprocess"),
|
|
167
|
+
)
|
|
168
|
+
return provider
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _load_group_config(group_id: str, spec_dict: Dict[str, Any]) -> None:
|
|
172
|
+
"""Load a provider group configuration."""
|
|
173
|
+
strategy = _parse_strategy(spec_dict.get("strategy", "round_robin"), group_id)
|
|
174
|
+
health_config = spec_dict.get("health", {})
|
|
175
|
+
circuit_config = spec_dict.get("circuit_breaker", {})
|
|
176
|
+
|
|
177
|
+
group = ProviderGroup(
|
|
178
|
+
group_id=group_id,
|
|
179
|
+
strategy=strategy,
|
|
180
|
+
min_healthy=spec_dict.get("min_healthy", 1),
|
|
181
|
+
auto_start=spec_dict.get("auto_start", True),
|
|
182
|
+
unhealthy_threshold=health_config.get("unhealthy_threshold", 2),
|
|
183
|
+
healthy_threshold=health_config.get("healthy_threshold", 1),
|
|
184
|
+
circuit_failure_threshold=circuit_config.get("failure_threshold", 10),
|
|
185
|
+
circuit_reset_timeout_s=circuit_config.get("reset_timeout_s", 60.0),
|
|
186
|
+
description=spec_dict.get("description"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
_load_group_members(group, group_id, spec_dict.get("members", []))
|
|
190
|
+
|
|
191
|
+
GROUPS[group_id] = group
|
|
192
|
+
logger.info(
|
|
193
|
+
"group_loaded",
|
|
194
|
+
group_id=group_id,
|
|
195
|
+
member_count=group.total_count,
|
|
196
|
+
strategy=strategy.value,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def load_configuration(config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
201
|
+
"""Load provider configuration from file or use defaults.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Full configuration dictionary
|
|
205
|
+
"""
|
|
206
|
+
if config_path is None:
|
|
207
|
+
config_path = os.getenv("MCP_CONFIG", "config.yaml")
|
|
208
|
+
|
|
209
|
+
if Path(config_path).exists():
|
|
210
|
+
logger.info("loading_config_from_file", config_path=config_path)
|
|
211
|
+
full_config = load_config_from_file(config_path)
|
|
212
|
+
load_config(full_config.get("providers", {}))
|
|
213
|
+
return full_config
|
|
214
|
+
else:
|
|
215
|
+
logger.info("config_not_found_using_default", config_path=config_path)
|
|
216
|
+
default_config = {
|
|
217
|
+
"math_subprocess": {
|
|
218
|
+
"mode": "subprocess",
|
|
219
|
+
"command": ["python", "-m", "examples.provider_math.server"],
|
|
220
|
+
"idle_ttl_s": 180,
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
load_config(default_config)
|
|
224
|
+
return {"providers": default_config}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Application context for dependency injection.
|
|
2
|
+
|
|
3
|
+
Provides a clean way to access application services without global state.
|
|
4
|
+
Follows the Dependency Inversion Principle - high-level modules don't depend
|
|
5
|
+
on low-level modules, both depend on abstractions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, Optional, Protocol, runtime_checkable, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..application.discovery import DiscoveryOrchestrator
|
|
13
|
+
from ..application.sagas import GroupRebalanceSaga
|
|
14
|
+
from ..bootstrap.runtime import Runtime
|
|
15
|
+
from ..domain.model import Provider, ProviderGroup
|
|
16
|
+
from ..domain.repository import IProviderRepository
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# =============================================================================
|
|
20
|
+
# Protocol Interfaces (DIP - Dependency Inversion Principle)
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class ICommandBus(Protocol):
|
|
26
|
+
"""Interface for command bus."""
|
|
27
|
+
|
|
28
|
+
def send(self, command: Any) -> Any:
|
|
29
|
+
"""Send a command and return result."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class IQueryBus(Protocol):
|
|
35
|
+
"""Interface for query bus."""
|
|
36
|
+
|
|
37
|
+
def execute(self, query: Any) -> Any:
|
|
38
|
+
"""Execute a query and return result."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class IEventBus(Protocol):
|
|
44
|
+
"""Interface for event bus."""
|
|
45
|
+
|
|
46
|
+
def publish(self, event: Any) -> None:
|
|
47
|
+
"""Publish an event."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def subscribe_to_all(self, handler: Any) -> None:
|
|
51
|
+
"""Subscribe to all events."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class IRateLimitResult(Protocol):
|
|
57
|
+
"""Interface for rate limit check result."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def allowed(self) -> bool:
|
|
61
|
+
"""Whether the request is allowed."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def limit(self) -> int:
|
|
66
|
+
"""The rate limit."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class IRateLimiter(Protocol):
|
|
72
|
+
"""Interface for rate limiter."""
|
|
73
|
+
|
|
74
|
+
def consume(self, key: str) -> IRateLimitResult:
|
|
75
|
+
"""Check rate limit for a key."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
79
|
+
"""Get rate limiter statistics."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@runtime_checkable
|
|
84
|
+
class ISecurityHandler(Protocol):
|
|
85
|
+
"""Interface for security handler."""
|
|
86
|
+
|
|
87
|
+
def log_rate_limit_exceeded(self, limit: int, window_seconds: int) -> None:
|
|
88
|
+
"""Log rate limit exceeded event."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def log_validation_failed(
|
|
92
|
+
self,
|
|
93
|
+
field: str,
|
|
94
|
+
message: str,
|
|
95
|
+
provider_id: Optional[str] = None,
|
|
96
|
+
value: Optional[str] = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Log validation failure."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def handle(self, event: Any) -> None:
|
|
102
|
+
"""Handle a security event."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ApplicationContext:
|
|
108
|
+
"""Dependency injection container for the application.
|
|
109
|
+
|
|
110
|
+
Instead of using global variables, components receive this context
|
|
111
|
+
which contains all dependencies they need. This makes testing easier
|
|
112
|
+
and dependencies explicit.
|
|
113
|
+
|
|
114
|
+
Attributes:
|
|
115
|
+
runtime: The application runtime with all infrastructure
|
|
116
|
+
groups: Provider groups for load balancing
|
|
117
|
+
discovery_orchestrator: Optional discovery service
|
|
118
|
+
group_rebalance_saga: Optional saga for group rebalancing
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
runtime: "Runtime"
|
|
122
|
+
groups: Dict[str, "ProviderGroup"] = field(default_factory=dict)
|
|
123
|
+
discovery_orchestrator: Optional["DiscoveryOrchestrator"] = None
|
|
124
|
+
group_rebalance_saga: Optional["GroupRebalanceSaga"] = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def repository(self) -> "IProviderRepository":
|
|
128
|
+
"""Get the provider repository."""
|
|
129
|
+
return self.runtime.repository
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def command_bus(self) -> ICommandBus:
|
|
133
|
+
"""Get the command bus."""
|
|
134
|
+
return self.runtime.command_bus
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def query_bus(self) -> IQueryBus:
|
|
138
|
+
"""Get the query bus."""
|
|
139
|
+
return self.runtime.query_bus
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def event_bus(self) -> IEventBus:
|
|
143
|
+
"""Get the event bus."""
|
|
144
|
+
return self.runtime.event_bus
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def rate_limiter(self) -> IRateLimiter:
|
|
148
|
+
"""Get the rate limiter."""
|
|
149
|
+
return self.runtime.rate_limiter
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def security_handler(self) -> ISecurityHandler:
|
|
153
|
+
"""Get the security handler."""
|
|
154
|
+
return self.runtime.security_handler
|
|
155
|
+
|
|
156
|
+
def get_provider(self, provider_id: str) -> Optional["Provider"]:
|
|
157
|
+
"""Get a provider by ID."""
|
|
158
|
+
return self.runtime.repository.get(provider_id)
|
|
159
|
+
|
|
160
|
+
def provider_exists(self, provider_id: str) -> bool:
|
|
161
|
+
"""Check if a provider exists."""
|
|
162
|
+
return self.runtime.repository.exists(provider_id)
|
|
163
|
+
|
|
164
|
+
def get_group(self, group_id: str) -> Optional["ProviderGroup"]:
|
|
165
|
+
"""Get a group by ID."""
|
|
166
|
+
return self.groups.get(group_id)
|
|
167
|
+
|
|
168
|
+
def group_exists(self, group_id: str) -> bool:
|
|
169
|
+
"""Check if a group exists."""
|
|
170
|
+
return group_id in self.groups
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Singleton context - initialized lazily or explicitly
|
|
174
|
+
_context: Optional[ApplicationContext] = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_context() -> ApplicationContext:
|
|
178
|
+
"""Get the application context.
|
|
179
|
+
|
|
180
|
+
If context is not initialized, it will be lazily initialized
|
|
181
|
+
with the default runtime. This supports both:
|
|
182
|
+
- Explicit initialization via init_context() for full control
|
|
183
|
+
- Lazy initialization for backward compatibility with tests
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
ApplicationContext instance
|
|
187
|
+
"""
|
|
188
|
+
global _context
|
|
189
|
+
if _context is None:
|
|
190
|
+
# Lazy initialization with default runtime
|
|
191
|
+
from ..bootstrap.runtime import create_runtime
|
|
192
|
+
|
|
193
|
+
runtime = create_runtime()
|
|
194
|
+
_context = ApplicationContext(runtime=runtime)
|
|
195
|
+
return _context
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def init_context(runtime: "Runtime") -> ApplicationContext:
|
|
199
|
+
"""Initialize the application context.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
runtime: The application runtime
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Initialized ApplicationContext
|
|
206
|
+
"""
|
|
207
|
+
global _context
|
|
208
|
+
_context = ApplicationContext(runtime=runtime)
|
|
209
|
+
return _context
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def reset_context() -> None:
|
|
213
|
+
"""Reset context (for testing)."""
|
|
214
|
+
global _context
|
|
215
|
+
_context = None
|