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,202 @@
1
+ """Knowledge Base abstractions and contracts.
2
+
3
+ Defines interfaces for knowledge base operations that can be implemented
4
+ by different backends (PostgreSQL, SQLite, etc.).
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Any, Optional
12
+
13
+
14
+ class KnowledgeBaseDriver(Enum):
15
+ """Supported knowledge base drivers."""
16
+
17
+ POSTGRES = "postgres"
18
+ SQLITE = "sqlite"
19
+ MEMORY = "memory" # For testing
20
+
21
+
22
+ @dataclass
23
+ class KnowledgeBaseConfig:
24
+ """Knowledge base configuration."""
25
+
26
+ enabled: bool = False
27
+ driver: KnowledgeBaseDriver = KnowledgeBaseDriver.SQLITE
28
+ dsn: str = ""
29
+ pool_size: int = 5
30
+ cache_ttl_s: int = 300
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: dict) -> "KnowledgeBaseConfig":
34
+ """Create config from dictionary."""
35
+ if not data.get("enabled", False):
36
+ return cls(enabled=False)
37
+
38
+ dsn = data.get("dsn", "")
39
+
40
+ # Auto-detect driver from DSN
41
+ if dsn.startswith("postgresql://") or dsn.startswith("postgres://"):
42
+ driver = KnowledgeBaseDriver.POSTGRES
43
+ elif dsn.startswith("sqlite://") or dsn.endswith(".db"):
44
+ driver = KnowledgeBaseDriver.SQLITE
45
+ else:
46
+ # Explicit driver override
47
+ driver_str = data.get("driver", "sqlite")
48
+ driver = KnowledgeBaseDriver(driver_str)
49
+
50
+ return cls(
51
+ enabled=True,
52
+ driver=driver,
53
+ dsn=dsn,
54
+ pool_size=data.get("pool_size", 5),
55
+ cache_ttl_s=data.get("cache_ttl_s", 300),
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class AuditEntry:
61
+ """Audit log entry."""
62
+
63
+ event_type: str
64
+ provider: Optional[str] = None
65
+ tool: Optional[str] = None
66
+ arguments: Optional[dict] = None
67
+ result_summary: Optional[str] = None
68
+ duration_ms: Optional[int] = None
69
+ success: bool = True
70
+ error_message: Optional[str] = None
71
+ correlation_id: Optional[str] = None
72
+ timestamp: Optional[datetime] = None
73
+
74
+
75
+ @dataclass
76
+ class ProviderStateEntry:
77
+ """Provider state history entry."""
78
+
79
+ provider_id: str
80
+ old_state: Optional[str]
81
+ new_state: str
82
+ reason: Optional[str] = None
83
+ timestamp: Optional[datetime] = None
84
+
85
+
86
+ @dataclass
87
+ class MetricEntry:
88
+ """Provider metric entry."""
89
+
90
+ provider_id: str
91
+ metric_name: str
92
+ metric_value: float
93
+ labels: Optional[dict] = None
94
+ timestamp: Optional[datetime] = None
95
+
96
+
97
+ class IKnowledgeBase(ABC):
98
+ """Abstract interface for knowledge base operations.
99
+
100
+ Implementations must be thread-safe and handle their own connection management.
101
+ """
102
+
103
+ @abstractmethod
104
+ async def initialize(self) -> bool:
105
+ """Initialize the knowledge base (create tables, run migrations).
106
+
107
+ Returns True if successful.
108
+ """
109
+ pass
110
+
111
+ @abstractmethod
112
+ async def close(self) -> None:
113
+ """Close connections and cleanup resources."""
114
+ pass
115
+
116
+ @abstractmethod
117
+ async def is_healthy(self) -> bool:
118
+ """Check if knowledge base is operational."""
119
+ pass
120
+
121
+ # === Cache Operations ===
122
+
123
+ @abstractmethod
124
+ async def cache_get(self, provider: str, tool: str, arguments: dict) -> Optional[dict]:
125
+ """Get cached tool result. Returns None if not found or expired."""
126
+ pass
127
+
128
+ @abstractmethod
129
+ async def cache_set(
130
+ self,
131
+ provider: str,
132
+ tool: str,
133
+ arguments: dict,
134
+ result: Any,
135
+ ttl_s: Optional[int] = None,
136
+ ) -> bool:
137
+ """Cache tool result. Returns True if successful."""
138
+ pass
139
+
140
+ @abstractmethod
141
+ async def cache_invalidate(self, provider: Optional[str] = None, tool: Optional[str] = None) -> int:
142
+ """Invalidate cache entries. Returns count of invalidated entries."""
143
+ pass
144
+
145
+ @abstractmethod
146
+ async def cache_cleanup(self) -> int:
147
+ """Remove expired cache entries. Returns count of removed entries."""
148
+ pass
149
+
150
+ # === Audit Operations ===
151
+
152
+ @abstractmethod
153
+ async def audit_log(self, entry: AuditEntry) -> bool:
154
+ """Log audit entry. Returns True if successful."""
155
+ pass
156
+
157
+ @abstractmethod
158
+ async def audit_query(
159
+ self,
160
+ provider: Optional[str] = None,
161
+ tool: Optional[str] = None,
162
+ success: Optional[bool] = None,
163
+ since: Optional[datetime] = None,
164
+ limit: int = 100,
165
+ ) -> list[AuditEntry]:
166
+ """Query audit log entries."""
167
+ pass
168
+
169
+ @abstractmethod
170
+ async def audit_stats(self, hours: int = 24) -> dict:
171
+ """Get audit statistics for last N hours."""
172
+ pass
173
+
174
+ # === Provider State Operations ===
175
+
176
+ @abstractmethod
177
+ async def record_state_change(self, entry: ProviderStateEntry) -> bool:
178
+ """Record provider state change. Returns True if successful."""
179
+ pass
180
+
181
+ @abstractmethod
182
+ async def get_state_history(self, provider_id: str, limit: int = 100) -> list[ProviderStateEntry]:
183
+ """Get provider state history."""
184
+ pass
185
+
186
+ # === Metrics Operations ===
187
+
188
+ @abstractmethod
189
+ async def record_metric(self, entry: MetricEntry) -> bool:
190
+ """Record provider metric. Returns True if successful."""
191
+ pass
192
+
193
+ @abstractmethod
194
+ async def get_metrics(
195
+ self,
196
+ provider_id: str,
197
+ metric_name: Optional[str] = None,
198
+ since: Optional[datetime] = None,
199
+ limit: int = 100,
200
+ ) -> list[MetricEntry]:
201
+ """Get provider metrics."""
202
+ pass
@@ -0,0 +1,177 @@
1
+ """In-memory implementation of IKnowledgeBase for testing."""
2
+
3
+ from collections import defaultdict
4
+ from datetime import datetime, timedelta, timezone
5
+ import hashlib
6
+ import json
7
+ from typing import Any, Optional
8
+
9
+ from ...logging_config import get_logger
10
+ from .contracts import AuditEntry, IKnowledgeBase, KnowledgeBaseConfig, MetricEntry, ProviderStateEntry
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class MemoryKnowledgeBase(IKnowledgeBase):
16
+ """In-memory implementation for testing."""
17
+
18
+ def __init__(self, config: KnowledgeBaseConfig):
19
+ self._config = config
20
+ self._cache: dict[str, tuple[Any, datetime]] = {} # key -> (result, expires_at)
21
+ self._audit_log: list[AuditEntry] = []
22
+ self._state_history: dict[str, list[ProviderStateEntry]] = defaultdict(list)
23
+ self._metrics: dict[str, list[MetricEntry]] = defaultdict(list)
24
+ self._initialized = False
25
+
26
+ async def initialize(self) -> bool:
27
+ self._initialized = True
28
+ logger.info("memory_kb_initialized")
29
+ return True
30
+
31
+ async def close(self) -> None:
32
+ self._cache.clear()
33
+ self._audit_log.clear()
34
+ self._state_history.clear()
35
+ self._metrics.clear()
36
+ self._initialized = False
37
+ logger.info("memory_kb_closed")
38
+
39
+ async def is_healthy(self) -> bool:
40
+ return self._initialized
41
+
42
+ def _hash_arguments(self, arguments: dict) -> str:
43
+ serialized = json.dumps(arguments, sort_keys=True, default=str)
44
+ return hashlib.sha256(serialized.encode()).hexdigest()[:32]
45
+
46
+ def _cache_key(self, provider: str, tool: str, arguments: dict) -> str:
47
+ return f"{provider}:{tool}:{self._hash_arguments(arguments)}"
48
+
49
+ # === Cache Operations ===
50
+
51
+ async def cache_get(self, provider: str, tool: str, arguments: dict) -> Optional[dict]:
52
+ key = self._cache_key(provider, tool, arguments)
53
+ if key in self._cache:
54
+ result, expires_at = self._cache[key]
55
+ if expires_at > datetime.now(timezone.utc):
56
+ logger.debug("cache_hit", provider=provider, tool=tool)
57
+ return result
58
+ else:
59
+ del self._cache[key]
60
+ return None
61
+
62
+ async def cache_set(
63
+ self,
64
+ provider: str,
65
+ tool: str,
66
+ arguments: dict,
67
+ result: Any,
68
+ ttl_s: Optional[int] = None,
69
+ ) -> bool:
70
+ key = self._cache_key(provider, tool, arguments)
71
+ ttl = ttl_s or self._config.cache_ttl_s
72
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
73
+ self._cache[key] = (result, expires_at)
74
+ return True
75
+
76
+ async def cache_invalidate(self, provider: Optional[str] = None, tool: Optional[str] = None) -> int:
77
+ if not provider and not tool:
78
+ count = len(self._cache)
79
+ self._cache.clear()
80
+ return count
81
+
82
+ prefix = f"{provider}:" if provider else ""
83
+ keys_to_delete = [k for k in self._cache if k.startswith(prefix) and (not tool or f":{tool}:" in k)]
84
+ for k in keys_to_delete:
85
+ del self._cache[k]
86
+ return len(keys_to_delete)
87
+
88
+ async def cache_cleanup(self) -> int:
89
+ now = datetime.now(timezone.utc)
90
+ expired = [k for k, (_, exp) in self._cache.items() if exp < now]
91
+ for k in expired:
92
+ del self._cache[k]
93
+ logger.info("cache_cleanup", deleted=len(expired))
94
+ return len(expired)
95
+
96
+ # === Audit Operations ===
97
+
98
+ async def audit_log(self, entry: AuditEntry) -> bool:
99
+ entry.timestamp = entry.timestamp or datetime.now(timezone.utc)
100
+ self._audit_log.append(entry)
101
+ return True
102
+
103
+ async def audit_query(
104
+ self,
105
+ provider: Optional[str] = None,
106
+ tool: Optional[str] = None,
107
+ success: Optional[bool] = None,
108
+ since: Optional[datetime] = None,
109
+ limit: int = 100,
110
+ ) -> list[AuditEntry]:
111
+ results = []
112
+ for entry in reversed(self._audit_log):
113
+ if provider and entry.provider != provider:
114
+ continue
115
+ if tool and entry.tool != tool:
116
+ continue
117
+ if success is not None and entry.success != success:
118
+ continue
119
+ if since and entry.timestamp and entry.timestamp < since:
120
+ continue
121
+ results.append(entry)
122
+ if len(results) >= limit:
123
+ break
124
+ return results
125
+
126
+ async def audit_stats(self, hours: int = 24) -> dict:
127
+ since = datetime.now(timezone.utc) - timedelta(hours=hours)
128
+ recent = [e for e in self._audit_log if e.timestamp and e.timestamp >= since]
129
+
130
+ durations = [e.duration_ms for e in recent if e.duration_ms is not None]
131
+
132
+ return {
133
+ "total": len(recent),
134
+ "success_count": sum(1 for e in recent if e.success),
135
+ "error_count": sum(1 for e in recent if not e.success),
136
+ "providers": len(set(e.provider for e in recent if e.provider)),
137
+ "tools": len(set(e.tool for e in recent if e.tool)),
138
+ "avg_duration_ms": sum(durations) / len(durations) if durations else None,
139
+ }
140
+
141
+ # === Provider State Operations ===
142
+
143
+ async def record_state_change(self, entry: ProviderStateEntry) -> bool:
144
+ entry.timestamp = entry.timestamp or datetime.now(timezone.utc)
145
+ self._state_history[entry.provider_id].append(entry)
146
+ return True
147
+
148
+ async def get_state_history(self, provider_id: str, limit: int = 100) -> list[ProviderStateEntry]:
149
+ history = self._state_history.get(provider_id, [])
150
+ return list(reversed(history[-limit:]))
151
+
152
+ # === Metrics Operations ===
153
+
154
+ async def record_metric(self, entry: MetricEntry) -> bool:
155
+ entry.timestamp = entry.timestamp or datetime.now(timezone.utc)
156
+ self._metrics[entry.provider_id].append(entry)
157
+ return True
158
+
159
+ async def get_metrics(
160
+ self,
161
+ provider_id: str,
162
+ metric_name: Optional[str] = None,
163
+ since: Optional[datetime] = None,
164
+ limit: int = 100,
165
+ ) -> list[MetricEntry]:
166
+ metrics = self._metrics.get(provider_id, [])
167
+
168
+ results = []
169
+ for m in reversed(metrics):
170
+ if metric_name and m.metric_name != metric_name:
171
+ continue
172
+ if since and m.timestamp and m.timestamp < since:
173
+ continue
174
+ results.append(m)
175
+ if len(results) >= limit:
176
+ break
177
+ return results