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,534 @@
1
+ """Langfuse adapter for MCP Hangar observability.
2
+
3
+ This module provides thread-safe integration with Langfuse for tracing
4
+ MCP tool invocations, recording health scores, and correlating traces
5
+ with external LLM applications.
6
+
7
+ Example:
8
+ config = LangfuseConfig(
9
+ public_key="pk-...",
10
+ secret_key="sk-...",
11
+ )
12
+ adapter = LangfuseObservabilityAdapter(config)
13
+
14
+ span = adapter.start_tool_span("math", "add", {"a": 1, "b": 2})
15
+ try:
16
+ result = invoke_tool(...)
17
+ span.end_success(result)
18
+ except Exception as e:
19
+ span.end_error(e)
20
+
21
+ Note:
22
+ Requires `langfuse` package. Install with:
23
+ pip install mcp-hangar[observability]
24
+ """
25
+
26
+ from dataclasses import dataclass
27
+ import logging
28
+ import threading
29
+ import time
30
+ from typing import Any
31
+ import uuid
32
+
33
+ from ...application.ports.observability import ObservabilityPort, SpanHandle, TraceContext
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Lazy import to handle optional dependency
38
+ _langfuse_available = False
39
+ _Langfuse: type | None = None
40
+
41
+ try:
42
+ from langfuse import Langfuse as _LangfuseClient
43
+
44
+ _langfuse_available = True
45
+ _Langfuse = _LangfuseClient
46
+ except ImportError:
47
+ _LangfuseClient = None # type: ignore[misc, assignment]
48
+ logger.debug("Langfuse not installed. Install with: pip install mcp-hangar[observability]")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class LangfuseConfig:
53
+ """Configuration for Langfuse integration.
54
+
55
+ Attributes:
56
+ enabled: Whether Langfuse tracing is active.
57
+ public_key: Langfuse public API key.
58
+ secret_key: Langfuse secret API key.
59
+ host: Langfuse host URL.
60
+ flush_interval_s: Interval for background flushes.
61
+ sample_rate: Fraction of traces to sample (0.0 to 1.0).
62
+ scrub_inputs: Whether to redact sensitive input data.
63
+ scrub_outputs: Whether to redact sensitive output data.
64
+ """
65
+
66
+ enabled: bool = True
67
+ public_key: str = ""
68
+ secret_key: str = ""
69
+ host: str = "https://cloud.langfuse.com"
70
+ flush_interval_s: float = 1.0
71
+ sample_rate: float = 1.0
72
+ scrub_inputs: bool = False
73
+ scrub_outputs: bool = False
74
+
75
+ def validate(self) -> list[str]:
76
+ """Validate configuration, return list of errors."""
77
+ errors = []
78
+ if self.enabled:
79
+ if not self.public_key:
80
+ errors.append("langfuse.public_key is required when enabled")
81
+ if not self.secret_key:
82
+ errors.append("langfuse.secret_key is required when enabled")
83
+ if not 0.0 <= self.sample_rate <= 1.0:
84
+ errors.append("langfuse.sample_rate must be between 0.0 and 1.0")
85
+ return errors
86
+
87
+
88
+ class LangfuseAdapter:
89
+ """Low-level thread-safe wrapper around Langfuse SDK.
90
+
91
+ This adapter handles SDK initialization, connection management,
92
+ and provides thread-safe access to Langfuse operations.
93
+
94
+ Compatible with Langfuse SDK v3.x API.
95
+ """
96
+
97
+ def __init__(self, config: LangfuseConfig) -> None:
98
+ """Initialize Langfuse adapter.
99
+
100
+ Args:
101
+ config: Langfuse configuration.
102
+
103
+ Raises:
104
+ ImportError: If langfuse package is not installed.
105
+ ValueError: If configuration is invalid.
106
+ """
107
+ self._config = config
108
+ self._lock = threading.Lock()
109
+ self._client: Any = None
110
+
111
+ if not config.enabled:
112
+ logger.info("Langfuse integration disabled by configuration")
113
+ return
114
+
115
+ if not _langfuse_available:
116
+ raise ImportError("Langfuse package not installed. Install with: pip install mcp-hangar[observability]")
117
+
118
+ errors = config.validate()
119
+ if errors:
120
+ raise ValueError(f"Invalid Langfuse config: {'; '.join(errors)}")
121
+
122
+ self._client = _Langfuse(
123
+ public_key=config.public_key,
124
+ secret_key=config.secret_key,
125
+ host=config.host,
126
+ )
127
+ logger.info("Langfuse adapter initialized", extra={"host": config.host})
128
+
129
+ @property
130
+ def is_enabled(self) -> bool:
131
+ """Check if Langfuse is enabled and initialized."""
132
+ return self._config.enabled and self._client is not None
133
+
134
+ def start_span(
135
+ self,
136
+ name: str,
137
+ trace_id: str | None = None,
138
+ input_data: dict[str, Any] | None = None,
139
+ metadata: dict[str, Any] | None = None,
140
+ user_id: str | None = None,
141
+ session_id: str | None = None,
142
+ ) -> tuple[Any, str]:
143
+ """Start a new span (creates trace automatically in v3 API).
144
+
145
+ Args:
146
+ name: Span name.
147
+ trace_id: Optional trace ID for correlation.
148
+ input_data: Input data for the span.
149
+ metadata: Optional metadata.
150
+ user_id: Optional user ID for attribution.
151
+ session_id: Optional session ID for grouping.
152
+
153
+ Returns:
154
+ Tuple of (Langfuse span object, trace_id) or (None, "") if disabled.
155
+ """
156
+ if not self.is_enabled:
157
+ return None, ""
158
+
159
+ input_to_send = input_data
160
+ if self._config.scrub_inputs and input_data:
161
+ input_to_send = {"scrubbed": True, "keys": list(input_data.keys())}
162
+
163
+ # Generate or use provided trace_id
164
+ # Langfuse v3 requires 32 lowercase hex char trace id
165
+ if trace_id:
166
+ # Convert UUID format to hex if needed
167
+ effective_trace_id = trace_id.replace("-", "").lower()[:32]
168
+ if len(effective_trace_id) < 32:
169
+ effective_trace_id = effective_trace_id.ljust(32, "0")
170
+ else:
171
+ effective_trace_id = uuid.uuid4().hex
172
+
173
+ # Build trace_context for Langfuse v3 API
174
+ try:
175
+ from langfuse.types import TraceContext as LangfuseTraceContext
176
+
177
+ trace_context = LangfuseTraceContext(
178
+ trace_id=effective_trace_id,
179
+ user_id=user_id,
180
+ session_id=session_id,
181
+ )
182
+ except ImportError:
183
+ trace_context = None
184
+
185
+ with self._lock:
186
+ span = self._client.start_span(
187
+ name=name,
188
+ trace_context=trace_context,
189
+ input=input_to_send,
190
+ metadata=metadata or {},
191
+ )
192
+ return span, effective_trace_id
193
+
194
+ def end_span(
195
+ self,
196
+ span: Any,
197
+ output: Any = None,
198
+ level: str = "DEFAULT",
199
+ status_message: str | None = None,
200
+ ) -> None:
201
+ """End a span with output.
202
+
203
+ Args:
204
+ span: Span object to end.
205
+ output: Output data.
206
+ level: Log level (DEFAULT, DEBUG, WARNING, ERROR).
207
+ status_message: Optional status message.
208
+ """
209
+ if not self.is_enabled or span is None:
210
+ return
211
+
212
+ output_to_send = output
213
+ if self._config.scrub_outputs and output is not None:
214
+ if isinstance(output, dict):
215
+ output_to_send = {"scrubbed": True, "keys": list(output.keys())}
216
+ else:
217
+ output_to_send = {"scrubbed": True, "type": type(output).__name__}
218
+
219
+ with self._lock:
220
+ # Update span with output before ending
221
+ span.update(
222
+ output=output_to_send,
223
+ level=level,
224
+ status_message=status_message,
225
+ )
226
+ span.end()
227
+
228
+ def create_score(
229
+ self,
230
+ trace_id: str,
231
+ name: str,
232
+ value: float,
233
+ comment: str | None = None,
234
+ ) -> None:
235
+ """Record a score on a trace.
236
+
237
+ Args:
238
+ trace_id: Trace ID.
239
+ name: Score name.
240
+ value: Score value.
241
+ comment: Optional comment.
242
+ """
243
+ if not self.is_enabled:
244
+ return
245
+
246
+ with self._lock:
247
+ self._client.create_score(
248
+ trace_id=trace_id,
249
+ name=name,
250
+ value=value,
251
+ comment=comment,
252
+ )
253
+
254
+ def flush(self) -> None:
255
+ """Flush pending events to Langfuse."""
256
+ if not self.is_enabled:
257
+ return
258
+
259
+ with self._lock:
260
+ self._client.flush()
261
+
262
+ def shutdown(self) -> None:
263
+ """Shutdown with final flush."""
264
+ if not self.is_enabled:
265
+ return
266
+
267
+ logger.info("Shutting down Langfuse adapter")
268
+ with self._lock:
269
+ self._client.shutdown()
270
+
271
+
272
+ class LangfuseSpanHandle(SpanHandle):
273
+ """Span handle implementation for Langfuse.
274
+
275
+ Tracks timing and manages span lifecycle with proper
276
+ success/error handling.
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ adapter: LangfuseAdapter,
282
+ span: Any,
283
+ trace_id: str,
284
+ ) -> None:
285
+ """Initialize span handle.
286
+
287
+ Args:
288
+ adapter: Langfuse adapter instance.
289
+ span: Span object.
290
+ trace_id: Trace ID for scoring.
291
+ """
292
+ self._adapter = adapter
293
+ self._span = span
294
+ self._trace_id = trace_id
295
+ self._start_time = time.perf_counter()
296
+ self._ended = False
297
+
298
+ def end_success(self, output: Any) -> None:
299
+ """End span with successful outcome.
300
+
301
+ Args:
302
+ output: Result data.
303
+ """
304
+ if self._ended:
305
+ logger.warning("Span already ended, ignoring duplicate end_success call")
306
+ return
307
+
308
+ self._ended = True
309
+ duration_ms = (time.perf_counter() - self._start_time) * 1000
310
+
311
+ self._adapter.end_span(
312
+ self._span,
313
+ output=output,
314
+ level="DEFAULT",
315
+ )
316
+
317
+ # Record latency as score
318
+ self._adapter.create_score(
319
+ trace_id=self._trace_id,
320
+ name="tool_latency_ms",
321
+ value=duration_ms,
322
+ )
323
+
324
+ def end_error(self, error: Exception) -> None:
325
+ """End span with error.
326
+
327
+ Args:
328
+ error: The exception that occurred.
329
+ """
330
+ if self._ended:
331
+ logger.warning("Span already ended, ignoring duplicate end_error call")
332
+ return
333
+
334
+ self._ended = True
335
+ duration_ms = (time.perf_counter() - self._start_time) * 1000
336
+
337
+ self._adapter.end_span(
338
+ self._span,
339
+ output={"error": str(error), "type": type(error).__name__},
340
+ level="ERROR",
341
+ status_message=f"Tool invocation failed: {error}",
342
+ )
343
+
344
+ # Record failure score
345
+ self._adapter.create_score(
346
+ trace_id=self._trace_id,
347
+ name="tool_success",
348
+ value=0.0,
349
+ comment=str(error),
350
+ )
351
+
352
+ self._adapter.create_score(
353
+ trace_id=self._trace_id,
354
+ name="tool_latency_ms",
355
+ value=duration_ms,
356
+ )
357
+
358
+ def set_metadata(self, key: str, value: Any) -> None:
359
+ """Add metadata to span.
360
+
361
+ Note: Langfuse spans don't support updating metadata after creation.
362
+ This logs a warning and stores for debugging.
363
+
364
+ Args:
365
+ key: Metadata key.
366
+ value: Metadata value.
367
+ """
368
+ logger.debug(
369
+ "Span metadata update requested (not supported by Langfuse)",
370
+ extra={"key": key, "value": value},
371
+ )
372
+
373
+
374
+ class LangfuseObservabilityAdapter(ObservabilityPort):
375
+ """Observability port implementation using Langfuse.
376
+
377
+ Provides full tracing and scoring capabilities for MCP tool
378
+ invocations, integrating with the Langfuse observability platform.
379
+ """
380
+
381
+ def __init__(self, config: LangfuseConfig) -> None:
382
+ """Initialize adapter.
383
+
384
+ Args:
385
+ config: Langfuse configuration.
386
+ """
387
+ self._config = config
388
+ self._adapter = LangfuseAdapter(config)
389
+ self._sample_lock = threading.Lock()
390
+
391
+ def _should_sample(self) -> bool:
392
+ """Determine if this trace should be sampled."""
393
+ if self._config.sample_rate >= 1.0:
394
+ return True
395
+ if self._config.sample_rate <= 0.0:
396
+ return False
397
+
398
+ import random
399
+
400
+ with self._sample_lock:
401
+ return random.random() < self._config.sample_rate
402
+
403
+ def start_tool_span(
404
+ self,
405
+ provider_name: str,
406
+ tool_name: str,
407
+ input_params: dict[str, Any],
408
+ trace_context: TraceContext | None = None,
409
+ ) -> SpanHandle:
410
+ """Start a traced span for tool invocation.
411
+
412
+ Args:
413
+ provider_name: Provider name.
414
+ tool_name: Tool name.
415
+ input_params: Tool input parameters.
416
+ trace_context: Optional trace context for correlation.
417
+
418
+ Returns:
419
+ Span handle for managing the span lifecycle.
420
+ """
421
+ if not self._adapter.is_enabled or not self._should_sample():
422
+ from ...application.ports.observability import NullSpanHandle
423
+
424
+ return NullSpanHandle()
425
+
426
+ span, trace_id = self._adapter.start_span(
427
+ name=f"mcp/{provider_name}/{tool_name}",
428
+ trace_id=trace_context.trace_id if trace_context else None,
429
+ input_data=input_params,
430
+ metadata={
431
+ "provider": provider_name,
432
+ "tool": tool_name,
433
+ "mcp_hangar": True,
434
+ },
435
+ user_id=trace_context.user_id if trace_context else None,
436
+ session_id=trace_context.session_id if trace_context else None,
437
+ )
438
+
439
+ # Record success score at start (will be updated on error)
440
+ self._adapter.create_score(
441
+ trace_id=trace_id,
442
+ name="tool_success",
443
+ value=1.0,
444
+ )
445
+
446
+ return LangfuseSpanHandle(
447
+ adapter=self._adapter,
448
+ span=span,
449
+ trace_id=trace_id,
450
+ )
451
+
452
+ def record_score(
453
+ self,
454
+ trace_id: str,
455
+ name: str,
456
+ value: float,
457
+ comment: str | None = None,
458
+ ) -> None:
459
+ """Record a score on a trace.
460
+
461
+ Args:
462
+ trace_id: Trace ID.
463
+ name: Score name.
464
+ value: Score value.
465
+ comment: Optional comment.
466
+ """
467
+ self._adapter.create_score(
468
+ trace_id=trace_id,
469
+ name=name,
470
+ value=value,
471
+ comment=comment,
472
+ )
473
+
474
+ def record_health_check(
475
+ self,
476
+ provider_name: str,
477
+ healthy: bool,
478
+ latency_ms: float,
479
+ trace_id: str | None = None,
480
+ ) -> None:
481
+ """Record provider health check result.
482
+
483
+ If no trace_id is provided, creates a standalone span for the
484
+ health check event.
485
+
486
+ Args:
487
+ provider_name: Provider name.
488
+ healthy: Whether the check passed.
489
+ latency_ms: Check latency in milliseconds.
490
+ trace_id: Optional trace to attach to.
491
+ """
492
+ if not self._adapter.is_enabled:
493
+ return
494
+
495
+ effective_trace_id = trace_id
496
+
497
+ if trace_id is None:
498
+ # Create standalone health check span
499
+ span, effective_trace_id = self._adapter.start_span(
500
+ name=f"health/{provider_name}",
501
+ metadata={
502
+ "provider": provider_name,
503
+ "type": "health_check",
504
+ },
505
+ )
506
+ if span:
507
+ self._adapter.end_span(
508
+ span,
509
+ output={"healthy": healthy, "latency_ms": latency_ms},
510
+ level="DEFAULT" if healthy else "WARNING",
511
+ )
512
+ else:
513
+ effective_trace_id = trace_id
514
+
515
+ self._adapter.create_score(
516
+ trace_id=effective_trace_id,
517
+ name="provider_healthy",
518
+ value=1.0 if healthy else 0.0,
519
+ comment=f"Provider: {provider_name}",
520
+ )
521
+
522
+ self._adapter.create_score(
523
+ trace_id=effective_trace_id,
524
+ name="health_check_latency_ms",
525
+ value=latency_ms,
526
+ )
527
+
528
+ def flush(self) -> None:
529
+ """Flush pending events."""
530
+ self._adapter.flush()
531
+
532
+ def shutdown(self) -> None:
533
+ """Shutdown with final flush."""
534
+ self._adapter.shutdown()
@@ -0,0 +1,33 @@
1
+ """Infrastructure persistence layer.
2
+
3
+ Provides implementations of domain persistence contracts
4
+ using SQLite, in-memory storage, and other backends.
5
+ """
6
+
7
+ from .audit_repository import InMemoryAuditRepository, SQLiteAuditRepository
8
+ from .config_repository import InMemoryProviderConfigRepository, SQLiteProviderConfigRepository
9
+ from .database import Database, DatabaseConfig
10
+ from .event_serializer import EventSerializationError, EventSerializer, register_event_type
11
+ from .event_upcaster import IEventUpcaster, UpcasterChain
12
+ from .in_memory_event_store import InMemoryEventStore
13
+ from .recovery_service import RecoveryService
14
+ from .sqlite_event_store import SQLiteEventStore
15
+ from .unit_of_work import SQLiteUnitOfWork
16
+
17
+ __all__ = [
18
+ "Database",
19
+ "DatabaseConfig",
20
+ "EventSerializationError",
21
+ "EventSerializer",
22
+ "IEventUpcaster",
23
+ "InMemoryAuditRepository",
24
+ "InMemoryEventStore",
25
+ "InMemoryProviderConfigRepository",
26
+ "RecoveryService",
27
+ "UpcasterChain",
28
+ "register_event_type",
29
+ "SQLiteAuditRepository",
30
+ "SQLiteEventStore",
31
+ "SQLiteProviderConfigRepository",
32
+ "SQLiteUnitOfWork",
33
+ ]