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