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,203 @@
|
|
|
1
|
+
"""Audit event handler for compliance and debugging."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ...domain.events import DomainEvent
|
|
11
|
+
from ...logging_config import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AuditRecord:
|
|
18
|
+
"""Represents an audit log entry."""
|
|
19
|
+
|
|
20
|
+
event_id: str
|
|
21
|
+
event_type: str
|
|
22
|
+
occurred_at: datetime
|
|
23
|
+
provider_id: Optional[str]
|
|
24
|
+
data: Dict[str, Any]
|
|
25
|
+
recorded_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
28
|
+
"""Convert audit record to dictionary."""
|
|
29
|
+
return {
|
|
30
|
+
"event_id": self.event_id,
|
|
31
|
+
"event_type": self.event_type,
|
|
32
|
+
"occurred_at": (
|
|
33
|
+
self.occurred_at.isoformat() if isinstance(self.occurred_at, datetime) else str(self.occurred_at)
|
|
34
|
+
),
|
|
35
|
+
"provider_id": self.provider_id,
|
|
36
|
+
"data": self.data,
|
|
37
|
+
"recorded_at": self.recorded_at.isoformat(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> str:
|
|
41
|
+
"""Convert to JSON string."""
|
|
42
|
+
return json.dumps(self.to_dict())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuditStore(ABC):
|
|
46
|
+
"""Abstract interface for audit log storage."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def record(self, audit_record: AuditRecord) -> None:
|
|
50
|
+
"""Store an audit record."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def query(
|
|
55
|
+
self,
|
|
56
|
+
provider_id: Optional[str] = None,
|
|
57
|
+
event_type: Optional[str] = None,
|
|
58
|
+
since: Optional[datetime] = None,
|
|
59
|
+
limit: int = 100,
|
|
60
|
+
) -> List[AuditRecord]:
|
|
61
|
+
"""Query audit records."""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InMemoryAuditStore(AuditStore):
|
|
66
|
+
"""In-memory audit store for testing and development."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, max_records: int = 10000):
|
|
69
|
+
self._records: List[AuditRecord] = []
|
|
70
|
+
self._max_records = max_records
|
|
71
|
+
|
|
72
|
+
def record(self, audit_record: AuditRecord) -> None:
|
|
73
|
+
"""Store an audit record."""
|
|
74
|
+
self._records.append(audit_record)
|
|
75
|
+
# Trim old records if over limit
|
|
76
|
+
if len(self._records) > self._max_records:
|
|
77
|
+
self._records = self._records[-self._max_records :]
|
|
78
|
+
|
|
79
|
+
def query(
|
|
80
|
+
self,
|
|
81
|
+
provider_id: Optional[str] = None,
|
|
82
|
+
event_type: Optional[str] = None,
|
|
83
|
+
since: Optional[datetime] = None,
|
|
84
|
+
limit: int = 100,
|
|
85
|
+
) -> List[AuditRecord]:
|
|
86
|
+
"""Query audit records with optional filters."""
|
|
87
|
+
results = []
|
|
88
|
+
for record in reversed(self._records): # Most recent first
|
|
89
|
+
if len(results) >= limit:
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
# Apply filters
|
|
93
|
+
if provider_id and record.provider_id != provider_id:
|
|
94
|
+
continue
|
|
95
|
+
if event_type and record.event_type != event_type:
|
|
96
|
+
continue
|
|
97
|
+
if since and record.recorded_at < since:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
results.append(record)
|
|
101
|
+
|
|
102
|
+
return results
|
|
103
|
+
|
|
104
|
+
def clear(self) -> None:
|
|
105
|
+
"""Clear all records (for testing)."""
|
|
106
|
+
self._records.clear()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def count(self) -> int:
|
|
110
|
+
"""Get number of stored records."""
|
|
111
|
+
return len(self._records)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class LogAuditStore(AuditStore):
|
|
115
|
+
"""Audit store that writes to structured logs."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, logger_name: str = "audit"):
|
|
118
|
+
self._logger = logging.getLogger(logger_name)
|
|
119
|
+
|
|
120
|
+
def record(self, audit_record: AuditRecord) -> None:
|
|
121
|
+
"""Log the audit record."""
|
|
122
|
+
self._logger.info(audit_record.to_json())
|
|
123
|
+
|
|
124
|
+
def query(
|
|
125
|
+
self,
|
|
126
|
+
provider_id: Optional[str] = None,
|
|
127
|
+
event_type: Optional[str] = None,
|
|
128
|
+
since: Optional[datetime] = None,
|
|
129
|
+
limit: int = 100,
|
|
130
|
+
) -> List[AuditRecord]:
|
|
131
|
+
"""Query is not supported for log store."""
|
|
132
|
+
raise NotImplementedError("Log audit store does not support queries")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AuditEventHandler:
|
|
136
|
+
"""
|
|
137
|
+
Event handler that records all events for audit trail.
|
|
138
|
+
|
|
139
|
+
Records every domain event with full details for:
|
|
140
|
+
- Compliance requirements
|
|
141
|
+
- Debugging and troubleshooting
|
|
142
|
+
- Historical analysis
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
store: Optional[AuditStore] = None,
|
|
148
|
+
include_event_types: Optional[List[str]] = None,
|
|
149
|
+
exclude_event_types: Optional[List[str]] = None,
|
|
150
|
+
):
|
|
151
|
+
"""
|
|
152
|
+
Initialize the audit handler.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
store: Audit store to use (defaults to in-memory)
|
|
156
|
+
include_event_types: Only record these event types (None = all)
|
|
157
|
+
exclude_event_types: Exclude these event types
|
|
158
|
+
"""
|
|
159
|
+
self._store = store or InMemoryAuditStore()
|
|
160
|
+
self._include = set(include_event_types) if include_event_types else None
|
|
161
|
+
self._exclude = set(exclude_event_types) if exclude_event_types else set()
|
|
162
|
+
|
|
163
|
+
def handle(self, event: DomainEvent) -> None:
|
|
164
|
+
"""Handle a domain event by recording it."""
|
|
165
|
+
event_type = type(event).__name__
|
|
166
|
+
|
|
167
|
+
# Check filters
|
|
168
|
+
if self._include is not None and event_type not in self._include:
|
|
169
|
+
return
|
|
170
|
+
if event_type in self._exclude:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Extract provider_id if available
|
|
174
|
+
provider_id = getattr(event, "provider_id", None)
|
|
175
|
+
|
|
176
|
+
# Create audit record
|
|
177
|
+
record = AuditRecord(
|
|
178
|
+
event_id=event.event_id,
|
|
179
|
+
event_type=event_type,
|
|
180
|
+
occurred_at=event.occurred_at,
|
|
181
|
+
provider_id=provider_id,
|
|
182
|
+
data=event.to_dict(),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
self._store.record(record)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"Failed to record audit event: {e}")
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def store(self) -> AuditStore:
|
|
192
|
+
"""Get the audit store."""
|
|
193
|
+
return self._store
|
|
194
|
+
|
|
195
|
+
def query(
|
|
196
|
+
self,
|
|
197
|
+
provider_id: Optional[str] = None,
|
|
198
|
+
event_type: Optional[str] = None,
|
|
199
|
+
since: Optional[datetime] = None,
|
|
200
|
+
limit: int = 100,
|
|
201
|
+
) -> List[AuditRecord]:
|
|
202
|
+
"""Query audit records."""
|
|
203
|
+
return self._store.query(provider_id=provider_id, event_type=event_type, since=since, limit=limit)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Knowledge base event handler.
|
|
2
|
+
|
|
3
|
+
Persists domain events to knowledge base (PostgreSQL, SQLite, etc.).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Callable, Dict, Type
|
|
7
|
+
|
|
8
|
+
from ...domain.events import (
|
|
9
|
+
DomainEvent,
|
|
10
|
+
ProviderStarted,
|
|
11
|
+
ProviderStateChanged,
|
|
12
|
+
ProviderStopped,
|
|
13
|
+
ToolInvocationCompleted,
|
|
14
|
+
ToolInvocationFailed,
|
|
15
|
+
)
|
|
16
|
+
from ...infrastructure.async_executor import submit_async
|
|
17
|
+
from ...logging_config import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KnowledgeBaseEventHandler:
|
|
23
|
+
"""Event handler that persists events to knowledge base.
|
|
24
|
+
|
|
25
|
+
Uses strategy pattern to map event types to persistence handlers,
|
|
26
|
+
following Open/Closed Principle - new event types can be added
|
|
27
|
+
without modifying existing code.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initialize handler with event type mappings."""
|
|
32
|
+
# Strategy mapping: event type -> persistence handler
|
|
33
|
+
# Using Any for handlers to avoid complex generic types
|
|
34
|
+
self._handlers: Dict[Type[DomainEvent], Callable] = {
|
|
35
|
+
ProviderStateChanged: self._persist_state_change,
|
|
36
|
+
ProviderStarted: self._persist_provider_started,
|
|
37
|
+
ProviderStopped: self._persist_provider_stopped,
|
|
38
|
+
ToolInvocationCompleted: self._persist_tool_completed,
|
|
39
|
+
ToolInvocationFailed: self._persist_tool_failed,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def handle(self, event: DomainEvent) -> None:
|
|
43
|
+
"""Handle a domain event by persisting to knowledge base.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
event: Domain event to persist
|
|
47
|
+
"""
|
|
48
|
+
from ...infrastructure.knowledge_base import is_available
|
|
49
|
+
|
|
50
|
+
if not is_available():
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Find handler for this event type
|
|
54
|
+
handler = self._handlers.get(type(event))
|
|
55
|
+
if handler is None:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Submit async persistence using executor (fire-and-forget)
|
|
59
|
+
submit_async(
|
|
60
|
+
handler(event),
|
|
61
|
+
on_error=lambda e: logger.debug("kb_persist_error", error=str(e), event_type=type(event).__name__),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def _persist_state_change(self, event: ProviderStateChanged) -> None:
|
|
65
|
+
"""Persist provider state change event."""
|
|
66
|
+
from ...infrastructure.knowledge_base import record_state_change
|
|
67
|
+
|
|
68
|
+
await record_state_change(
|
|
69
|
+
provider_id=event.provider_id,
|
|
70
|
+
old_state=event.old_state,
|
|
71
|
+
new_state=event.new_state,
|
|
72
|
+
reason=None,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def _persist_provider_started(self, event: ProviderStarted) -> None:
|
|
76
|
+
"""Persist provider started event as metric."""
|
|
77
|
+
from ...infrastructure.knowledge_base import record_metric
|
|
78
|
+
|
|
79
|
+
await record_metric(
|
|
80
|
+
provider_id=event.provider_id,
|
|
81
|
+
metric_name="startup_duration_ms",
|
|
82
|
+
metric_value=event.startup_duration_ms,
|
|
83
|
+
labels={"mode": event.mode, "tools_count": event.tools_count},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def _persist_provider_stopped(self, event: ProviderStopped) -> None:
|
|
87
|
+
"""Persist provider stopped event."""
|
|
88
|
+
from ...infrastructure.knowledge_base import record_state_change
|
|
89
|
+
|
|
90
|
+
await record_state_change(
|
|
91
|
+
provider_id=event.provider_id,
|
|
92
|
+
old_state="ready",
|
|
93
|
+
new_state="stopped",
|
|
94
|
+
reason=event.reason,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def _persist_tool_completed(self, event: ToolInvocationCompleted) -> None:
|
|
98
|
+
"""Persist successful tool invocation."""
|
|
99
|
+
from ...infrastructure.knowledge_base import audit_log
|
|
100
|
+
|
|
101
|
+
await audit_log(
|
|
102
|
+
event_type="tool_completed",
|
|
103
|
+
provider=event.provider_id,
|
|
104
|
+
tool=event.tool_name,
|
|
105
|
+
duration_ms=int(event.duration_ms),
|
|
106
|
+
success=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _persist_tool_failed(self, event: ToolInvocationFailed) -> None:
|
|
110
|
+
"""Persist failed tool invocation."""
|
|
111
|
+
from ...infrastructure.knowledge_base import audit_log
|
|
112
|
+
|
|
113
|
+
await audit_log(
|
|
114
|
+
event_type="tool_failed",
|
|
115
|
+
provider=event.provider_id,
|
|
116
|
+
tool=event.tool_name,
|
|
117
|
+
duration_ms=int(event.duration_ms) if hasattr(event, "duration_ms") else None,
|
|
118
|
+
success=False,
|
|
119
|
+
error_message=event.error[:500] if hasattr(event, "error") else None,
|
|
120
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Logging event handler - logs all domain events."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from mcp_hangar.domain.events import (
|
|
6
|
+
DomainEvent,
|
|
7
|
+
HealthCheckFailed,
|
|
8
|
+
ProviderDegraded,
|
|
9
|
+
ProviderStarted,
|
|
10
|
+
ProviderStopped,
|
|
11
|
+
ToolInvocationCompleted,
|
|
12
|
+
ToolInvocationFailed,
|
|
13
|
+
ToolInvocationRequested,
|
|
14
|
+
)
|
|
15
|
+
from mcp_hangar.logging_config import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoggingEventHandler:
|
|
21
|
+
"""
|
|
22
|
+
Event handler that logs all domain events in structured format.
|
|
23
|
+
|
|
24
|
+
This demonstrates the event-driven pattern and provides audit trail.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, log_level: int = logging.INFO):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the logging handler.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
log_level: Logging level for events (default: INFO)
|
|
33
|
+
"""
|
|
34
|
+
self.log_level = log_level
|
|
35
|
+
|
|
36
|
+
def handle(self, event: DomainEvent) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Handle a domain event by logging it.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
event: The domain event to log
|
|
42
|
+
"""
|
|
43
|
+
event_type = event.__class__.__name__
|
|
44
|
+
event_data = event.to_dict()
|
|
45
|
+
# Remove event_type from data if present to avoid duplication
|
|
46
|
+
event_data.pop("event_type", None)
|
|
47
|
+
|
|
48
|
+
# Different events get different log levels
|
|
49
|
+
if isinstance(event, (ProviderDegraded, ToolInvocationFailed, HealthCheckFailed)):
|
|
50
|
+
logger.warning("domain_event", event_type=event_type, **event_data)
|
|
51
|
+
elif isinstance(event, (ProviderStarted, ProviderStopped)):
|
|
52
|
+
logger.info("domain_event", event_type=event_type, **event_data)
|
|
53
|
+
elif isinstance(event, (ToolInvocationRequested, ToolInvocationCompleted)):
|
|
54
|
+
logger.debug("domain_event", event_type=event_type, **event_data)
|
|
55
|
+
else:
|
|
56
|
+
logger.info("domain_event", event_type=event_type, **event_data)
|
|
57
|
+
|
|
58
|
+
def _format_event(self, event: DomainEvent) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Format an event for logging (deprecated - kept for compatibility).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
event: The event to format
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Formatted log message
|
|
67
|
+
"""
|
|
68
|
+
event_type = event.__class__.__name__
|
|
69
|
+
return f"[EVENT:{event_type}] {event.to_dict()}"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Metrics event handler - collects metrics from domain events."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
from mcp_hangar.domain.events import (
|
|
9
|
+
DomainEvent,
|
|
10
|
+
HealthCheckFailed,
|
|
11
|
+
HealthCheckPassed,
|
|
12
|
+
ProviderDegraded,
|
|
13
|
+
ProviderStarted,
|
|
14
|
+
ToolInvocationCompleted,
|
|
15
|
+
ToolInvocationFailed,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProviderMetrics:
|
|
21
|
+
"""Metrics for a single provider."""
|
|
22
|
+
|
|
23
|
+
provider_id: str
|
|
24
|
+
total_invocations: int = 0
|
|
25
|
+
successful_invocations: int = 0
|
|
26
|
+
failed_invocations: int = 0
|
|
27
|
+
total_duration_ms: float = 0.0
|
|
28
|
+
health_checks_passed: int = 0
|
|
29
|
+
health_checks_failed: int = 0
|
|
30
|
+
degradation_count: int = 0
|
|
31
|
+
invocation_latencies: List[float] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def success_rate(self) -> float:
|
|
35
|
+
"""Calculate success rate percentage."""
|
|
36
|
+
if self.total_invocations == 0:
|
|
37
|
+
return 100.0
|
|
38
|
+
return (self.successful_invocations / self.total_invocations) * 100
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def average_latency_ms(self) -> float:
|
|
42
|
+
"""Calculate average latency in milliseconds."""
|
|
43
|
+
if self.total_invocations == 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
return self.total_duration_ms / self.total_invocations
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def p95_latency_ms(self) -> float:
|
|
49
|
+
"""Calculate p95 latency in milliseconds."""
|
|
50
|
+
if not self.invocation_latencies:
|
|
51
|
+
return 0.0
|
|
52
|
+
sorted_latencies = sorted(self.invocation_latencies)
|
|
53
|
+
index = int(len(sorted_latencies) * 0.95)
|
|
54
|
+
return sorted_latencies[index] if index < len(sorted_latencies) else sorted_latencies[-1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MetricsEventHandler:
|
|
58
|
+
"""
|
|
59
|
+
Event handler that collects metrics from domain events.
|
|
60
|
+
|
|
61
|
+
This demonstrates how events can feed into observability systems.
|
|
62
|
+
In production, this might send to Prometheus, DataDog, etc.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
"""Initialize the metrics handler."""
|
|
67
|
+
self._metrics: Dict[str, ProviderMetrics] = defaultdict(lambda: ProviderMetrics(""))
|
|
68
|
+
self._started_at = time.time()
|
|
69
|
+
|
|
70
|
+
def handle(self, event: DomainEvent) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Handle a domain event by updating metrics.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
event: The domain event to process
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(event, ProviderStarted):
|
|
78
|
+
self._handle_provider_started(event)
|
|
79
|
+
elif isinstance(event, ToolInvocationCompleted):
|
|
80
|
+
self._handle_tool_completed(event)
|
|
81
|
+
elif isinstance(event, ToolInvocationFailed):
|
|
82
|
+
self._handle_tool_failed(event)
|
|
83
|
+
elif isinstance(event, HealthCheckPassed):
|
|
84
|
+
self._handle_health_passed(event)
|
|
85
|
+
elif isinstance(event, HealthCheckFailed):
|
|
86
|
+
self._handle_health_failed(event)
|
|
87
|
+
elif isinstance(event, ProviderDegraded):
|
|
88
|
+
self._handle_provider_degraded(event)
|
|
89
|
+
|
|
90
|
+
def _handle_provider_started(self, event: ProviderStarted) -> None:
|
|
91
|
+
"""Handle provider started event."""
|
|
92
|
+
metrics = self._metrics[event.provider_id]
|
|
93
|
+
metrics.provider_id = event.provider_id
|
|
94
|
+
|
|
95
|
+
def _handle_tool_completed(self, event: ToolInvocationCompleted) -> None:
|
|
96
|
+
"""Handle tool invocation completed event."""
|
|
97
|
+
metrics = self._metrics[event.provider_id]
|
|
98
|
+
metrics.total_invocations += 1
|
|
99
|
+
metrics.successful_invocations += 1
|
|
100
|
+
metrics.total_duration_ms += event.duration_ms
|
|
101
|
+
metrics.invocation_latencies.append(event.duration_ms)
|
|
102
|
+
|
|
103
|
+
# Keep only last 1000 latencies for memory efficiency
|
|
104
|
+
if len(metrics.invocation_latencies) > 1000:
|
|
105
|
+
metrics.invocation_latencies = metrics.invocation_latencies[-1000:]
|
|
106
|
+
|
|
107
|
+
def _handle_tool_failed(self, event: ToolInvocationFailed) -> None:
|
|
108
|
+
"""Handle tool invocation failed event."""
|
|
109
|
+
metrics = self._metrics[event.provider_id]
|
|
110
|
+
metrics.total_invocations += 1
|
|
111
|
+
metrics.failed_invocations += 1
|
|
112
|
+
|
|
113
|
+
def _handle_health_passed(self, event: HealthCheckPassed) -> None:
|
|
114
|
+
"""Handle health check passed event."""
|
|
115
|
+
metrics = self._metrics[event.provider_id]
|
|
116
|
+
metrics.health_checks_passed += 1
|
|
117
|
+
|
|
118
|
+
def _handle_health_failed(self, event: HealthCheckFailed) -> None:
|
|
119
|
+
"""Handle health check failed event."""
|
|
120
|
+
metrics = self._metrics[event.provider_id]
|
|
121
|
+
metrics.health_checks_failed += 1
|
|
122
|
+
|
|
123
|
+
def _handle_provider_degraded(self, event: ProviderDegraded) -> None:
|
|
124
|
+
"""Handle provider degraded event."""
|
|
125
|
+
metrics = self._metrics[event.provider_id]
|
|
126
|
+
metrics.degradation_count += 1
|
|
127
|
+
|
|
128
|
+
def get_metrics(self, provider_id: str) -> ProviderMetrics | None:
|
|
129
|
+
"""
|
|
130
|
+
Get metrics for a specific provider.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
provider_id: The provider ID
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
ProviderMetrics if available, None otherwise
|
|
137
|
+
"""
|
|
138
|
+
return self._metrics.get(provider_id)
|
|
139
|
+
|
|
140
|
+
def get_all_metrics(self) -> Dict[str, ProviderMetrics]:
|
|
141
|
+
"""
|
|
142
|
+
Get metrics for all providers.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Dictionary of provider_id -> ProviderMetrics
|
|
146
|
+
"""
|
|
147
|
+
return dict(self._metrics)
|
|
148
|
+
|
|
149
|
+
def reset(self) -> None:
|
|
150
|
+
"""Reset all metrics (mainly for testing)."""
|
|
151
|
+
self._metrics.clear()
|
|
152
|
+
self._started_at = time.time()
|