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,158 @@
1
+ """MCP tool wiring utilities.
2
+
3
+ This module provides a decorator for MCP tool functions to standardize:
4
+ - rate limiting
5
+ - input validation
6
+ - consistent error mapping
7
+ - structured security logging hooks
8
+
9
+ It is intentionally framework-agnostic: it does not import FastMCP directly.
10
+ The decorator is meant to be applied to functions already registered via
11
+ `@mcp.tool(...)` in `registry/server.py`.
12
+
13
+ Design notes:
14
+ - The decorator takes callables for rate limiting, validation, and error mapping.
15
+ - It keeps the wrapped function signature compatible with MCP tool calling.
16
+ - There is no async support here (current tools are sync). If you add async tools,
17
+ we can extend this with an async-aware wrapper.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from functools import wraps
24
+ from typing import Any, Callable, Dict, Optional, TypeVar
25
+
26
+ from ...logging_config import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ F = TypeVar("F", bound=Callable[..., Any])
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ToolErrorPayload:
35
+ """Normalized error payload returned to MCP client.
36
+
37
+ MCP tools often return structured output; we keep this minimal and stable.
38
+ """
39
+
40
+ error: str
41
+ error_type: str
42
+ details: Dict[str, Any]
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ return {
46
+ "error": self.error,
47
+ "type": self.error_type,
48
+ "details": self.details,
49
+ }
50
+
51
+
52
+ def _default_error_mapper(exc: Exception) -> ToolErrorPayload:
53
+ """Fallback error mapper."""
54
+ return ToolErrorPayload(
55
+ error=str(exc) or "unknown error",
56
+ error_type=type(exc).__name__,
57
+ details={},
58
+ )
59
+
60
+
61
+ def mcp_tool_wrapper(
62
+ *,
63
+ tool_name: str,
64
+ rate_limit_key: Callable[..., str],
65
+ check_rate_limit: Callable[[str], None],
66
+ validate: Optional[Callable[..., None]] = None,
67
+ error_mapper: Optional[Callable[[Exception], ToolErrorPayload]] = None,
68
+ on_error: Optional[Callable[[Exception, Dict[str, Any]], None]] = None,
69
+ ) -> Callable[[F], F]:
70
+ """Decorator to standardize MCP tool behavior.
71
+
72
+ Args:
73
+ tool_name: Human-readable tool name (used in error payload metadata).
74
+ rate_limit_key: Callable that builds a rate limit bucket key from args/kwargs.
75
+ check_rate_limit: Callable that enforces rate limit for the computed key.
76
+ Should raise (e.g. RateLimitExceeded) when exceeded.
77
+ validate: Optional callable to validate inputs. Should raise ValueError on invalid input.
78
+ Signature should match the wrapped tool function.
79
+ error_mapper: Optional callable mapping Exception -> ToolErrorPayload.
80
+ If omitted, a minimal default is used.
81
+ on_error: Optional hook called on exception with (exc, context_dict).
82
+
83
+ Returns:
84
+ Decorated function.
85
+ """
86
+ mapper = error_mapper or _default_error_mapper
87
+
88
+ def decorator(func: F) -> F:
89
+ @wraps(func)
90
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
91
+ # Rate limit first (cheapest check) to reduce abuse surface.
92
+ key = rate_limit_key(*args, **kwargs)
93
+ check_rate_limit(key)
94
+
95
+ # Validate inputs if provided.
96
+ if validate is not None:
97
+ validate(*args, **kwargs)
98
+
99
+ try:
100
+ return func(*args, **kwargs)
101
+ except Exception as exc:
102
+ # Optional error hook (e.g. security auditing).
103
+ if on_error is not None:
104
+ try:
105
+ on_error(
106
+ exc,
107
+ {
108
+ "tool": tool_name,
109
+ "rate_limit_key": key,
110
+ "args_count": len(args),
111
+ "kwargs_keys": list(kwargs.keys()),
112
+ },
113
+ )
114
+ except (TypeError, ValueError, RuntimeError) as hook_err:
115
+ # Never let the error hook override the original failure.
116
+ # Log but don't propagate hook errors.
117
+ logger.debug(
118
+ "error_hook_failed",
119
+ tool=tool_name,
120
+ hook_error=str(hook_err),
121
+ )
122
+
123
+ payload = mapper(exc)
124
+ # Return a stable, tool-friendly dict. MCP will surface this as tool error.
125
+ return payload.to_dict()
126
+
127
+ return wrapped # type: ignore[misc]
128
+
129
+ return decorator
130
+
131
+
132
+ def key_global(*_: Any, **__: Any) -> str:
133
+ """Rate limit key for globally-scoped tools."""
134
+ return "global"
135
+
136
+
137
+ def key_per_provider(provider: str, *_: Any, **__: Any) -> str:
138
+ """Rate limit key scoped per provider."""
139
+ return f"provider:{provider}"
140
+
141
+
142
+ def key_registry_invoke(provider: str, tool: str, *_: Any, **__: Any) -> str:
143
+ """Rate limit key specialized for tool invocation (per provider)."""
144
+ # Keep it coarse by default to avoid key explosion; include tool name if desired.
145
+ return f"registry_invoke:{provider}"
146
+
147
+
148
+ def chain_validators(*validators: Callable[..., None]) -> Callable[..., None]:
149
+ """Combine multiple validators into a single callable.
150
+
151
+ Each validator is called in order. First exception stops the chain.
152
+ """
153
+
154
+ def _combined(*args: Any, **kwargs: Any) -> None:
155
+ for v in validators:
156
+ v(*args, **kwargs)
157
+
158
+ return _combined
@@ -0,0 +1,9 @@
1
+ """Application ports - interfaces for external dependencies."""
2
+
3
+ from .observability import NullObservabilityAdapter, ObservabilityPort, SpanHandle
4
+
5
+ __all__ = [
6
+ "ObservabilityPort",
7
+ "SpanHandle",
8
+ "NullObservabilityAdapter",
9
+ ]
@@ -0,0 +1,237 @@
1
+ """Port for observability integrations (Langfuse, OpenTelemetry, etc.).
2
+
3
+ This module defines the interface for tracing tool invocations and recording
4
+ metrics. Implementations adapt external observability platforms to this contract.
5
+
6
+ Example usage:
7
+ observability = get_observability_adapter(config)
8
+ span = observability.start_tool_span("math", "add", {"a": 1, "b": 2})
9
+ try:
10
+ result = provider.invoke(...)
11
+ span.end_success(result)
12
+ except Exception as e:
13
+ span.end_error(e)
14
+ """
15
+
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ import logging
19
+ from typing import Any
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TraceContext:
26
+ """Value object for trace context propagation across MCP calls.
27
+
28
+ Enables correlation of traces from external LLM applications
29
+ through MCP Hangar to individual provider invocations.
30
+
31
+ Attributes:
32
+ trace_id: Unique identifier for the trace.
33
+ span_id: Identifier for the parent span (optional).
34
+ user_id: User identifier for attribution (optional).
35
+ session_id: Session identifier for grouping (optional).
36
+ """
37
+
38
+ trace_id: str
39
+ span_id: str | None = None
40
+ user_id: str | None = None
41
+ session_id: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class SpanData:
46
+ """Data collected during a traced span.
47
+
48
+ Attributes:
49
+ name: Human-readable span name.
50
+ input_data: Input parameters for the operation.
51
+ output_data: Result of the operation (set on completion).
52
+ metadata: Additional context for the span.
53
+ error: Error if the span ended with failure.
54
+ duration_ms: Duration of the span in milliseconds.
55
+ """
56
+
57
+ name: str
58
+ input_data: dict[str, Any] = field(default_factory=dict)
59
+ output_data: Any = None
60
+ metadata: dict[str, Any] = field(default_factory=dict)
61
+ error: Exception | None = None
62
+ duration_ms: float | None = None
63
+
64
+
65
+ class SpanHandle(ABC):
66
+ """Handle for an active observability span.
67
+
68
+ Implementations should capture timing and allow setting
69
+ success/failure status with output data.
70
+ """
71
+
72
+ @abstractmethod
73
+ def end_success(self, output: Any) -> None:
74
+ """End the span with a successful outcome.
75
+
76
+ Args:
77
+ output: The result data from the traced operation.
78
+ """
79
+ ...
80
+
81
+ @abstractmethod
82
+ def end_error(self, error: Exception) -> None:
83
+ """End the span with an error.
84
+
85
+ Args:
86
+ error: The exception that caused the failure.
87
+ """
88
+ ...
89
+
90
+ @abstractmethod
91
+ def set_metadata(self, key: str, value: Any) -> None:
92
+ """Add metadata to the span.
93
+
94
+ Args:
95
+ key: Metadata key.
96
+ value: Metadata value (must be JSON-serializable).
97
+ """
98
+ ...
99
+
100
+
101
+ class ObservabilityPort(ABC):
102
+ """Port interface for observability integrations.
103
+
104
+ Implementations adapt external platforms (Langfuse, OpenTelemetry, etc.)
105
+ to provide tracing and scoring capabilities for MCP tool invocations.
106
+ """
107
+
108
+ @abstractmethod
109
+ def start_tool_span(
110
+ self,
111
+ provider_name: str,
112
+ tool_name: str,
113
+ input_params: dict[str, Any],
114
+ trace_context: TraceContext | None = None,
115
+ ) -> SpanHandle:
116
+ """Start a traced span for a tool invocation.
117
+
118
+ Args:
119
+ provider_name: Name of the MCP provider.
120
+ tool_name: Name of the tool being invoked.
121
+ input_params: Input arguments for the tool.
122
+ trace_context: Optional context for trace propagation.
123
+
124
+ Returns:
125
+ Handle to manage the span lifecycle.
126
+ """
127
+ ...
128
+
129
+ @abstractmethod
130
+ def record_score(
131
+ self,
132
+ trace_id: str,
133
+ name: str,
134
+ value: float,
135
+ comment: str | None = None,
136
+ ) -> None:
137
+ """Record a score/metric on a trace.
138
+
139
+ Useful for recording provider health, latency, or quality metrics.
140
+
141
+ Args:
142
+ trace_id: The trace to attach the score to.
143
+ name: Score name (e.g., "provider_health", "latency_ms").
144
+ value: Numeric score value.
145
+ comment: Optional description.
146
+ """
147
+ ...
148
+
149
+ @abstractmethod
150
+ def record_health_check(
151
+ self,
152
+ provider_name: str,
153
+ healthy: bool,
154
+ latency_ms: float,
155
+ trace_id: str | None = None,
156
+ ) -> None:
157
+ """Record a health check result.
158
+
159
+ Args:
160
+ provider_name: Name of the provider.
161
+ healthy: Whether the health check passed.
162
+ latency_ms: Health check latency in milliseconds.
163
+ trace_id: Optional trace to attach the result to.
164
+ """
165
+ ...
166
+
167
+ @abstractmethod
168
+ def flush(self) -> None:
169
+ """Flush any pending events to the backend."""
170
+ ...
171
+
172
+ @abstractmethod
173
+ def shutdown(self) -> None:
174
+ """Gracefully shutdown with final flush."""
175
+ ...
176
+
177
+
178
+ class NullSpanHandle(SpanHandle):
179
+ """No-op span handle when observability is disabled."""
180
+
181
+ def end_success(self, output: Any) -> None:
182
+ """No-op success."""
183
+ pass
184
+
185
+ def end_error(self, error: Exception) -> None:
186
+ """No-op error."""
187
+ pass
188
+
189
+ def set_metadata(self, key: str, value: Any) -> None:
190
+ """No-op metadata."""
191
+ pass
192
+
193
+
194
+ class NullObservabilityAdapter(ObservabilityPort):
195
+ """No-op implementation when observability is disabled.
196
+
197
+ This adapter silently discards all tracing and scoring calls,
198
+ allowing the application to run without any observability overhead.
199
+ """
200
+
201
+ def start_tool_span(
202
+ self,
203
+ provider_name: str,
204
+ tool_name: str,
205
+ input_params: dict[str, Any],
206
+ trace_context: TraceContext | None = None,
207
+ ) -> SpanHandle:
208
+ """Return a no-op span handle."""
209
+ return NullSpanHandle()
210
+
211
+ def record_score(
212
+ self,
213
+ trace_id: str,
214
+ name: str,
215
+ value: float,
216
+ comment: str | None = None,
217
+ ) -> None:
218
+ """Discard the score."""
219
+ pass
220
+
221
+ def record_health_check(
222
+ self,
223
+ provider_name: str,
224
+ healthy: bool,
225
+ latency_ms: float,
226
+ trace_id: str | None = None,
227
+ ) -> None:
228
+ """Discard the health check result."""
229
+ pass
230
+
231
+ def flush(self) -> None:
232
+ """No-op flush."""
233
+ pass
234
+
235
+ def shutdown(self) -> None:
236
+ """No-op shutdown."""
237
+ pass
@@ -0,0 +1,52 @@
1
+ """Query handlers for CQRS."""
2
+
3
+ from .auth_handlers import (
4
+ CheckPermissionHandler,
5
+ GetApiKeyCountHandler,
6
+ GetApiKeysByPrincipalHandler,
7
+ GetRoleHandler,
8
+ GetRolesForPrincipalHandler,
9
+ ListBuiltinRolesHandler,
10
+ register_auth_query_handlers,
11
+ )
12
+ from .auth_queries import (
13
+ CheckPermissionQuery,
14
+ GetApiKeyCountQuery,
15
+ GetApiKeysByPrincipalQuery,
16
+ GetRoleQuery,
17
+ GetRolesForPrincipalQuery,
18
+ ListBuiltinRolesQuery,
19
+ )
20
+ from .handlers import (
21
+ GetProviderHandler,
22
+ GetProviderHealthHandler,
23
+ GetProviderToolsHandler,
24
+ GetSystemMetricsHandler,
25
+ ListProvidersHandler,
26
+ register_all_handlers,
27
+ )
28
+
29
+ __all__ = [
30
+ # Provider Query Handlers
31
+ "ListProvidersHandler",
32
+ "GetProviderHandler",
33
+ "GetProviderToolsHandler",
34
+ "GetProviderHealthHandler",
35
+ "GetSystemMetricsHandler",
36
+ "register_all_handlers",
37
+ # Auth Queries
38
+ "GetApiKeysByPrincipalQuery",
39
+ "GetApiKeyCountQuery",
40
+ "GetRolesForPrincipalQuery",
41
+ "GetRoleQuery",
42
+ "ListBuiltinRolesQuery",
43
+ "CheckPermissionQuery",
44
+ # Auth Query Handlers
45
+ "GetApiKeysByPrincipalHandler",
46
+ "GetApiKeyCountHandler",
47
+ "GetRolesForPrincipalHandler",
48
+ "GetRoleHandler",
49
+ "ListBuiltinRolesHandler",
50
+ "CheckPermissionHandler",
51
+ "register_auth_query_handlers",
52
+ ]
@@ -0,0 +1,237 @@
1
+ """Authentication and Authorization query handlers.
2
+
3
+ Implements CQRS query handlers for auth read operations.
4
+ These handlers only read data, never modify state.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from ...domain.contracts.authentication import IApiKeyStore
10
+ from ...domain.contracts.authorization import IRoleStore
11
+ from ...domain.security.roles import BUILTIN_ROLES
12
+ from ...infrastructure.query_bus import QueryHandler
13
+ from ...logging_config import get_logger
14
+ from .auth_queries import (
15
+ CheckPermissionQuery,
16
+ GetApiKeyCountQuery,
17
+ GetApiKeysByPrincipalQuery,
18
+ GetRoleQuery,
19
+ GetRolesForPrincipalQuery,
20
+ ListBuiltinRolesQuery,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ # =============================================================================
27
+ # API Key Query Handlers
28
+ # =============================================================================
29
+
30
+
31
+ class GetApiKeysByPrincipalHandler(QueryHandler):
32
+ """Handler for GetApiKeysByPrincipalQuery."""
33
+
34
+ def __init__(self, api_key_store: IApiKeyStore):
35
+ self._store = api_key_store
36
+
37
+ def handle(self, query: GetApiKeysByPrincipalQuery) -> dict[str, Any]:
38
+ """Get all API keys for a principal.
39
+
40
+ Returns:
41
+ Dict with list of key metadata.
42
+ """
43
+ keys = self._store.list_keys(query.principal_id)
44
+
45
+ if not query.include_revoked:
46
+ keys = [k for k in keys if not k.revoked]
47
+
48
+ return {
49
+ "principal_id": query.principal_id,
50
+ "keys": [
51
+ {
52
+ "key_id": k.key_id,
53
+ "name": k.name,
54
+ "created_at": k.created_at.isoformat() if k.created_at else None,
55
+ "expires_at": k.expires_at.isoformat() if k.expires_at else None,
56
+ "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
57
+ "revoked": k.revoked,
58
+ }
59
+ for k in keys
60
+ ],
61
+ "total": len(keys),
62
+ "active": sum(1 for k in keys if not k.revoked),
63
+ }
64
+
65
+
66
+ class GetApiKeyCountHandler(QueryHandler):
67
+ """Handler for GetApiKeyCountQuery."""
68
+
69
+ def __init__(self, api_key_store: IApiKeyStore):
70
+ self._store = api_key_store
71
+
72
+ def handle(self, query: GetApiKeyCountQuery) -> dict[str, Any]:
73
+ """Get count of active API keys for a principal.
74
+
75
+ Returns:
76
+ Dict with key count.
77
+ """
78
+ count = self._store.count_keys(query.principal_id)
79
+
80
+ return {
81
+ "principal_id": query.principal_id,
82
+ "active_keys": count,
83
+ }
84
+
85
+
86
+ # =============================================================================
87
+ # Role Query Handlers
88
+ # =============================================================================
89
+
90
+
91
+ class GetRolesForPrincipalHandler(QueryHandler):
92
+ """Handler for GetRolesForPrincipalQuery."""
93
+
94
+ def __init__(self, role_store: IRoleStore):
95
+ self._store = role_store
96
+
97
+ def handle(self, query: GetRolesForPrincipalQuery) -> dict[str, Any]:
98
+ """Get all roles assigned to a principal.
99
+
100
+ Returns:
101
+ Dict with list of roles.
102
+ """
103
+ roles = self._store.get_roles_for_principal(
104
+ principal_id=query.principal_id,
105
+ scope=query.scope,
106
+ )
107
+
108
+ return {
109
+ "principal_id": query.principal_id,
110
+ "scope": query.scope,
111
+ "roles": [
112
+ {
113
+ "name": r.name,
114
+ "description": r.description,
115
+ "permissions": [str(p) for p in r.permissions],
116
+ }
117
+ for r in roles
118
+ ],
119
+ "count": len(roles),
120
+ }
121
+
122
+
123
+ class GetRoleHandler(QueryHandler):
124
+ """Handler for GetRoleQuery."""
125
+
126
+ def __init__(self, role_store: IRoleStore):
127
+ self._store = role_store
128
+
129
+ def handle(self, query: GetRoleQuery) -> dict[str, Any]:
130
+ """Get a specific role by name.
131
+
132
+ Returns:
133
+ Dict with role details or None.
134
+ """
135
+ role = self._store.get_role(query.role_name)
136
+
137
+ if role is None:
138
+ return {"role": None, "found": False}
139
+
140
+ return {
141
+ "found": True,
142
+ "role": {
143
+ "name": role.name,
144
+ "description": role.description,
145
+ "permissions": [str(p) for p in role.permissions],
146
+ "permissions_count": len(role.permissions),
147
+ },
148
+ }
149
+
150
+
151
+ class ListBuiltinRolesHandler(QueryHandler):
152
+ """Handler for ListBuiltinRolesQuery."""
153
+
154
+ def handle(self, query: ListBuiltinRolesQuery) -> dict[str, Any]:
155
+ """List all built-in roles.
156
+
157
+ Returns:
158
+ Dict with list of built-in roles.
159
+ """
160
+ return {
161
+ "roles": [
162
+ {
163
+ "name": name,
164
+ "description": role.description,
165
+ "permissions_count": len(role.permissions),
166
+ }
167
+ for name, role in BUILTIN_ROLES.items()
168
+ ],
169
+ "count": len(BUILTIN_ROLES),
170
+ }
171
+
172
+
173
+ class CheckPermissionHandler(QueryHandler):
174
+ """Handler for CheckPermissionQuery."""
175
+
176
+ def __init__(self, role_store: IRoleStore):
177
+ self._store = role_store
178
+
179
+ def handle(self, query: CheckPermissionQuery) -> dict[str, Any]:
180
+ """Check if a principal has a specific permission.
181
+
182
+ Returns:
183
+ Dict with permission check result.
184
+ """
185
+ roles = self._store.get_roles_for_principal(query.principal_id)
186
+
187
+ for role in roles:
188
+ if role.has_permission(
189
+ resource_type=query.resource_type,
190
+ action=query.action,
191
+ resource_id=query.resource_id,
192
+ ):
193
+ return {
194
+ "principal_id": query.principal_id,
195
+ "action": query.action,
196
+ "resource_type": query.resource_type,
197
+ "resource_id": query.resource_id,
198
+ "allowed": True,
199
+ "granted_by_role": role.name,
200
+ }
201
+
202
+ return {
203
+ "principal_id": query.principal_id,
204
+ "action": query.action,
205
+ "resource_type": query.resource_type,
206
+ "resource_id": query.resource_id,
207
+ "allowed": False,
208
+ "granted_by_role": None,
209
+ }
210
+
211
+
212
+ def register_auth_query_handlers(
213
+ query_bus,
214
+ api_key_store: IApiKeyStore | None = None,
215
+ role_store: IRoleStore | None = None,
216
+ ) -> None:
217
+ """Register all auth query handlers with the query bus.
218
+
219
+ Args:
220
+ query_bus: QueryBus instance.
221
+ api_key_store: API key store (optional, handlers skipped if None).
222
+ role_store: Role store (optional, handlers skipped if None).
223
+ """
224
+ if api_key_store:
225
+ query_bus.register(GetApiKeysByPrincipalQuery, GetApiKeysByPrincipalHandler(api_key_store))
226
+ query_bus.register(GetApiKeyCountQuery, GetApiKeyCountHandler(api_key_store))
227
+ logger.info("auth_api_key_query_handlers_registered")
228
+
229
+ if role_store:
230
+ query_bus.register(GetRolesForPrincipalQuery, GetRolesForPrincipalHandler(role_store))
231
+ query_bus.register(GetRoleQuery, GetRoleHandler(role_store))
232
+ query_bus.register(CheckPermissionQuery, CheckPermissionHandler(role_store))
233
+ logger.info("auth_role_query_handlers_registered")
234
+
235
+ # ListBuiltinRolesQuery doesn't need a store
236
+ query_bus.register(ListBuiltinRolesQuery, ListBuiltinRolesHandler())
237
+ logger.info("auth_builtin_roles_query_handler_registered")