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