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