hmdl 0.0.1__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.
hmdl/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Heimdall Observability SDK for MCP Servers.
3
+
4
+ A Python SDK for instrumenting MCP (Model Context Protocol) servers with
5
+ OpenTelemetry-based observability tracking.
6
+ """
7
+
8
+ from hmdl.client import HeimdallClient
9
+ from hmdl.decorators import trace_mcp_tool
10
+ from hmdl.config import HeimdallConfig
11
+ from hmdl.types import SpanKind, SpanStatus
12
+
13
+ __version__ = "0.0.1"
14
+
15
+ __all__ = [
16
+ # Client
17
+ "HeimdallClient",
18
+ # Decorators
19
+ "trace_mcp_tool",
20
+ # Configuration
21
+ "HeimdallConfig",
22
+ # Types
23
+ "SpanKind",
24
+ "SpanStatus",
25
+ # Version
26
+ "__version__",
27
+ ]
28
+
hmdl/client.py ADDED
@@ -0,0 +1,196 @@
1
+ """Heimdall client for OpenTelemetry-based observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ from typing import Optional, Dict, Any
8
+
9
+ from opentelemetry import trace
10
+ from opentelemetry.sdk.trace import TracerProvider
11
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
12
+ from opentelemetry.sdk.resources import Resource, SERVICE_NAME
13
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
14
+
15
+ from hmdl.config import HeimdallConfig
16
+ from hmdl.types import HeimdallAttributes
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class HeimdallClient:
22
+ """Client for sending observability data to Heimdall platform.
23
+
24
+ This client sets up OpenTelemetry tracing and provides methods for
25
+ creating spans and recording MCP operations.
26
+
27
+ Example:
28
+ >>> from hmdl import HeimdallClient
29
+ >>> client = HeimdallClient(api_key="your-api-key")
30
+ >>> with client.start_span("my-operation") as span:
31
+ ... # Your code here
32
+ ... span.set_attribute("custom.attribute", "value")
33
+ """
34
+
35
+ _instance: Optional["HeimdallClient"] = None
36
+ _initialized: bool = False
37
+
38
+ def __new__(cls, *args: Any, **kwargs: Any) -> "HeimdallClient":
39
+ """Singleton pattern to ensure only one client instance."""
40
+ if cls._instance is None:
41
+ cls._instance = super().__new__(cls)
42
+ return cls._instance
43
+
44
+ def __init__(
45
+ self,
46
+ config: Optional[HeimdallConfig] = None,
47
+ api_key: Optional[str] = None,
48
+ endpoint: Optional[str] = None,
49
+ service_name: Optional[str] = None,
50
+ environment: Optional[str] = None,
51
+ org_id: Optional[str] = None,
52
+ project_id: Optional[str] = None,
53
+ ) -> None:
54
+ """Initialize the Heimdall client.
55
+
56
+ Args:
57
+ config: Full configuration object. If provided, other args are ignored.
58
+ api_key: API key for Heimdall platform.
59
+ endpoint: Heimdall platform endpoint URL.
60
+ service_name: Name of the service being instrumented.
61
+ environment: Deployment environment.
62
+ org_id: Organization ID from Heimdall dashboard.
63
+ project_id: Project ID from Heimdall dashboard.
64
+ """
65
+ if self._initialized:
66
+ return
67
+
68
+ # Build config from arguments or use provided config
69
+ if config is not None:
70
+ self.config = config
71
+ else:
72
+ self.config = HeimdallConfig(
73
+ api_key=api_key or HeimdallConfig().api_key,
74
+ endpoint=endpoint or HeimdallConfig().endpoint,
75
+ service_name=service_name or HeimdallConfig().service_name,
76
+ environment=environment or HeimdallConfig().environment,
77
+ org_id=org_id or HeimdallConfig().org_id,
78
+ project_id=project_id or HeimdallConfig().project_id,
79
+ )
80
+
81
+ self._tracer: Optional[trace.Tracer] = None
82
+ self._provider: Optional[TracerProvider] = None
83
+
84
+ if self.config.enabled:
85
+ self._setup_tracing()
86
+
87
+ self._initialized = True
88
+
89
+ # Register cleanup on exit
90
+ atexit.register(self.shutdown)
91
+
92
+ def _setup_tracing(self) -> None:
93
+ """Set up OpenTelemetry tracing."""
94
+ if self.config.debug:
95
+ logging.basicConfig(level=logging.DEBUG)
96
+ logger.setLevel(logging.DEBUG)
97
+
98
+ # Create resource with service information
99
+ resource = Resource.create({
100
+ SERVICE_NAME: self.config.service_name,
101
+ HeimdallAttributes.HEIMDALL_ENVIRONMENT: self.config.environment,
102
+ HeimdallAttributes.HEIMDALL_ORG_ID: self.config.org_id,
103
+ HeimdallAttributes.HEIMDALL_PROJECT_ID: self.config.project_id,
104
+ })
105
+
106
+ # Create tracer provider
107
+ self._provider = TracerProvider(resource=resource)
108
+
109
+ # Set up OTLP HTTP exporter
110
+ otlp_endpoint = f"{self.config.endpoint}/v1/traces"
111
+
112
+ # Only add auth header if API key is provided
113
+ headers = {}
114
+ if self.config.api_key:
115
+ headers["Authorization"] = f"Bearer {self.config.api_key}"
116
+
117
+ exporter = OTLPSpanExporter(
118
+ endpoint=otlp_endpoint,
119
+ headers=headers if headers else None,
120
+ )
121
+
122
+ # Add batch processor for efficient span export
123
+ processor = BatchSpanProcessor(
124
+ exporter,
125
+ max_queue_size=self.config.max_queue_size,
126
+ max_export_batch_size=self.config.batch_size,
127
+ schedule_delay_millis=self.config.flush_interval_ms,
128
+ )
129
+ self._provider.add_span_processor(processor)
130
+
131
+ # Set as global tracer provider
132
+ trace.set_tracer_provider(self._provider)
133
+
134
+ # Get tracer
135
+ self._tracer = trace.get_tracer("hmdl", "0.1.0")
136
+
137
+ logger.debug(f"Heimdall tracing initialized for service: {self.config.service_name}")
138
+
139
+ @property
140
+ def tracer(self) -> trace.Tracer:
141
+ """Get the OpenTelemetry tracer."""
142
+ if self._tracer is None:
143
+ # Return a no-op tracer if not initialized
144
+ return trace.get_tracer("hmdl-noop")
145
+ return self._tracer
146
+
147
+ def start_span(
148
+ self,
149
+ name: str,
150
+ kind: trace.SpanKind = trace.SpanKind.INTERNAL,
151
+ attributes: Optional[Dict[str, Any]] = None,
152
+ ) -> trace.Span:
153
+ """Start a new span.
154
+
155
+ Args:
156
+ name: Name of the span.
157
+ kind: Kind of span (INTERNAL, CLIENT, SERVER, etc.).
158
+ attributes: Initial attributes for the span.
159
+
160
+ Returns:
161
+ The created span as a context manager.
162
+ """
163
+ return self.tracer.start_as_current_span(
164
+ name=name,
165
+ kind=kind,
166
+ attributes=attributes,
167
+ )
168
+
169
+ def get_current_span(self) -> trace.Span:
170
+ """Get the current active span."""
171
+ return trace.get_current_span()
172
+
173
+ def flush(self) -> None:
174
+ """Flush all pending spans."""
175
+ if self._provider is not None:
176
+ self._provider.force_flush()
177
+
178
+ def shutdown(self) -> None:
179
+ """Shutdown the client and flush remaining spans."""
180
+ if self._provider is not None:
181
+ self._provider.shutdown()
182
+ logger.debug("Heimdall client shutdown complete")
183
+
184
+ @classmethod
185
+ def get_instance(cls) -> Optional["HeimdallClient"]:
186
+ """Get the singleton client instance."""
187
+ return cls._instance
188
+
189
+ @classmethod
190
+ def reset(cls) -> None:
191
+ """Reset the singleton instance (mainly for testing)."""
192
+ if cls._instance is not None:
193
+ cls._instance.shutdown()
194
+ cls._instance = None
195
+ cls._initialized = False
196
+
hmdl/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Configuration for Heimdall SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, Dict, Any
8
+
9
+
10
+ @dataclass
11
+ class HeimdallConfig:
12
+ """Configuration for the Heimdall observability client.
13
+
14
+ Attributes:
15
+ api_key: API key for authenticating with Heimdall platform.
16
+ endpoint: The Heimdall platform endpoint URL.
17
+ service_name: Name of the service being instrumented.
18
+ environment: Deployment environment (e.g., 'production', 'staging').
19
+ org_id: Organization ID from Heimdall dashboard.
20
+ project_id: Project ID to associate traces with in Heimdall.
21
+ enabled: Whether tracing is enabled.
22
+ debug: Enable debug logging.
23
+ batch_size: Number of spans to batch before sending.
24
+ flush_interval_ms: Interval in milliseconds to flush spans.
25
+ max_queue_size: Maximum number of spans to queue.
26
+ metadata: Additional metadata to attach to all spans.
27
+ """
28
+
29
+ api_key: Optional[str] = field(
30
+ default_factory=lambda: os.environ.get("HEIMDALL_API_KEY")
31
+ )
32
+ endpoint: str = field(
33
+ default_factory=lambda: os.environ.get(
34
+ "HEIMDALL_ENDPOINT", "https://api.heimdall.dev"
35
+ )
36
+ )
37
+ service_name: str = field(
38
+ default_factory=lambda: os.environ.get("HEIMDALL_SERVICE_NAME", "mcp-server")
39
+ )
40
+ environment: str = field(
41
+ default_factory=lambda: os.environ.get("HEIMDALL_ENVIRONMENT", "development")
42
+ )
43
+ org_id: str = field(
44
+ default_factory=lambda: os.environ.get("HEIMDALL_ORG_ID", "default")
45
+ )
46
+ project_id: str = field(
47
+ default_factory=lambda: os.environ.get("HEIMDALL_PROJECT_ID", "default")
48
+ )
49
+ enabled: bool = field(
50
+ default_factory=lambda: os.environ.get("HEIMDALL_ENABLED", "true").lower() == "true"
51
+ )
52
+ debug: bool = field(
53
+ default_factory=lambda: os.environ.get("HEIMDALL_DEBUG", "false").lower() == "true"
54
+ )
55
+ batch_size: int = field(
56
+ default_factory=lambda: int(os.environ.get("HEIMDALL_BATCH_SIZE", "100"))
57
+ )
58
+ flush_interval_ms: int = field(
59
+ default_factory=lambda: int(os.environ.get("HEIMDALL_FLUSH_INTERVAL_MS", "5000"))
60
+ )
61
+ max_queue_size: int = field(
62
+ default_factory=lambda: int(os.environ.get("HEIMDALL_MAX_QUEUE_SIZE", "1000"))
63
+ )
64
+ metadata: Dict[str, Any] = field(default_factory=dict)
65
+
66
+ def validate(self) -> None:
67
+ """Validate the configuration."""
68
+ # API key is optional for local development
69
+ if self.batch_size < 1:
70
+ raise ValueError("batch_size must be at least 1")
71
+ if self.flush_interval_ms < 100:
72
+ raise ValueError("flush_interval_ms must be at least 100")
73
+ if self.max_queue_size < self.batch_size:
74
+ raise ValueError("max_queue_size must be at least batch_size")
75
+
76
+ @classmethod
77
+ def from_env(cls) -> "HeimdallConfig":
78
+ """Create configuration from environment variables."""
79
+ return cls()
80
+
hmdl/decorators.py ADDED
@@ -0,0 +1,317 @@
1
+ """Decorators for instrumenting MCP functions with Heimdall observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import json
8
+ import time
9
+ from typing import Any, Callable, Optional, TypeVar, Union, overload
10
+
11
+ from opentelemetry import trace
12
+ from opentelemetry.trace import Status, StatusCode
13
+
14
+ from hmdl.types import HeimdallAttributes, SpanKind, SpanStatus
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+
18
+
19
+ def _serialize_value(value: Any) -> str:
20
+ """Safely serialize a value to string for span attributes."""
21
+ try:
22
+ return json.dumps(value, default=str)
23
+ except (TypeError, ValueError):
24
+ return str(value)
25
+
26
+
27
+ def _get_client() -> Any:
28
+ """Get the Heimdall client instance."""
29
+ from hmdl.client import HeimdallClient
30
+ return HeimdallClient.get_instance()
31
+
32
+
33
+ def _create_span_decorator(
34
+ span_kind: SpanKind,
35
+ name_attr: str,
36
+ args_attr: str,
37
+ result_attr: str,
38
+ ) -> Callable[[Optional[str]], Callable[[F], F]]:
39
+ """Factory for creating MCP-specific decorators."""
40
+
41
+ def decorator(name: Optional[str] = None) -> Callable[[F], F]:
42
+ def wrapper(func: F) -> F:
43
+ span_name = name or func.__name__
44
+ is_async = inspect.iscoroutinefunction(func)
45
+
46
+ if is_async:
47
+ @functools.wraps(func)
48
+ async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
49
+ client = _get_client()
50
+ if client is None:
51
+ return await func(*args, **kwargs)
52
+
53
+ tracer = client.tracer
54
+ with tracer.start_as_current_span(
55
+ name=span_name,
56
+ kind=trace.SpanKind.SERVER,
57
+ ) as span:
58
+ start_time = time.perf_counter()
59
+
60
+ # Set input attributes
61
+ span.set_attribute(name_attr, span_name)
62
+ span.set_attribute("heimdall.span_kind", span_kind.value)
63
+
64
+ # Capture arguments
65
+ try:
66
+ all_args = _capture_arguments(func, args, kwargs)
67
+ span.set_attribute(args_attr, _serialize_value(all_args))
68
+ except Exception:
69
+ pass
70
+
71
+ try:
72
+ result = await func(*args, **kwargs)
73
+
74
+ # Set output attributes
75
+ span.set_attribute(result_attr, _serialize_value(result))
76
+ span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
77
+ span.set_status(Status(StatusCode.OK))
78
+
79
+ return result
80
+ except Exception as e:
81
+ _record_error(span, e)
82
+ raise
83
+ finally:
84
+ duration_ms = (time.perf_counter() - start_time) * 1000
85
+ span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
86
+
87
+ return async_wrapped # type: ignore
88
+ else:
89
+ @functools.wraps(func)
90
+ def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
91
+ client = _get_client()
92
+ if client is None:
93
+ return func(*args, **kwargs)
94
+
95
+ tracer = client.tracer
96
+ with tracer.start_as_current_span(
97
+ name=span_name,
98
+ kind=trace.SpanKind.SERVER,
99
+ ) as span:
100
+ start_time = time.perf_counter()
101
+
102
+ # Set input attributes
103
+ span.set_attribute(name_attr, span_name)
104
+ span.set_attribute("heimdall.span_kind", span_kind.value)
105
+
106
+ # Capture arguments
107
+ try:
108
+ all_args = _capture_arguments(func, args, kwargs)
109
+ span.set_attribute(args_attr, _serialize_value(all_args))
110
+ except Exception:
111
+ pass
112
+
113
+ try:
114
+ result = func(*args, **kwargs)
115
+
116
+ # Set output attributes
117
+ span.set_attribute(result_attr, _serialize_value(result))
118
+ span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
119
+ span.set_status(Status(StatusCode.OK))
120
+
121
+ return result
122
+ except Exception as e:
123
+ _record_error(span, e)
124
+ raise
125
+ finally:
126
+ duration_ms = (time.perf_counter() - start_time) * 1000
127
+ span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
128
+
129
+ return sync_wrapped # type: ignore
130
+
131
+ return wrapper
132
+
133
+ return decorator
134
+
135
+
136
+ def _capture_arguments(func: Callable[..., Any], args: tuple, kwargs: dict) -> dict:
137
+ """Capture function arguments as a dictionary."""
138
+ sig = inspect.signature(func)
139
+ bound = sig.bind_partial(*args, **kwargs)
140
+ bound.apply_defaults()
141
+ return dict(bound.arguments)
142
+
143
+
144
+ def _record_error(span: trace.Span, error: Exception) -> None:
145
+ """Record an error on a span."""
146
+ span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.ERROR.value)
147
+ span.set_attribute(HeimdallAttributes.ERROR_MESSAGE, str(error))
148
+ span.set_attribute(HeimdallAttributes.ERROR_TYPE, type(error).__name__)
149
+ span.set_status(Status(StatusCode.ERROR, str(error)))
150
+ span.record_exception(error)
151
+
152
+
153
+ # Create MCP-specific decorators
154
+ trace_mcp_tool = _create_span_decorator(
155
+ span_kind=SpanKind.MCP_TOOL,
156
+ name_attr=HeimdallAttributes.MCP_TOOL_NAME,
157
+ args_attr=HeimdallAttributes.MCP_TOOL_ARGUMENTS,
158
+ result_attr=HeimdallAttributes.MCP_TOOL_RESULT,
159
+ )
160
+ trace_mcp_tool.__doc__ = """
161
+ Decorator to trace MCP tool calls.
162
+
163
+ Example:
164
+ >>> @trace_mcp_tool()
165
+ ... def my_tool(arg1: str, arg2: int) -> str:
166
+ ... return f"Result: {arg1}, {arg2}"
167
+
168
+ >>> @trace_mcp_tool("custom-tool-name")
169
+ ... async def async_tool(data: dict) -> dict:
170
+ ... return {"processed": data}
171
+ """
172
+
173
+ trace_mcp_resource = _create_span_decorator(
174
+ span_kind=SpanKind.MCP_RESOURCE,
175
+ name_attr=HeimdallAttributes.MCP_RESOURCE_URI,
176
+ args_attr="mcp.resource.arguments",
177
+ result_attr="mcp.resource.result",
178
+ )
179
+ trace_mcp_resource.__doc__ = """
180
+ Decorator to trace MCP resource access.
181
+
182
+ Example:
183
+ >>> @trace_mcp_resource()
184
+ ... def read_file(uri: str) -> str:
185
+ ... return open(uri).read()
186
+ """
187
+
188
+ trace_mcp_prompt = _create_span_decorator(
189
+ span_kind=SpanKind.MCP_PROMPT,
190
+ name_attr=HeimdallAttributes.MCP_PROMPT_NAME,
191
+ args_attr=HeimdallAttributes.MCP_PROMPT_ARGUMENTS,
192
+ result_attr=HeimdallAttributes.MCP_PROMPT_MESSAGES,
193
+ )
194
+ trace_mcp_prompt.__doc__ = """
195
+ Decorator to trace MCP prompt calls.
196
+
197
+ Example:
198
+ >>> @trace_mcp_prompt()
199
+ ... def generate_prompt(context: str) -> list:
200
+ ... return [{"role": "user", "content": context}]
201
+ """
202
+
203
+
204
+ @overload
205
+ def observe(func: F) -> F: ...
206
+
207
+ @overload
208
+ def observe(
209
+ name: Optional[str] = None,
210
+ *,
211
+ capture_input: bool = True,
212
+ capture_output: bool = True,
213
+ ) -> Callable[[F], F]: ...
214
+
215
+ def observe(
216
+ func: Optional[F] = None,
217
+ name: Optional[str] = None,
218
+ *,
219
+ capture_input: bool = True,
220
+ capture_output: bool = True,
221
+ ) -> Union[F, Callable[[F], F]]:
222
+ """
223
+ General-purpose decorator to observe any function.
224
+
225
+ Can be used with or without arguments:
226
+
227
+ Example:
228
+ >>> @observe
229
+ ... def my_function():
230
+ ... pass
231
+
232
+ >>> @observe(name="custom-name", capture_output=False)
233
+ ... def another_function():
234
+ ... pass
235
+ """
236
+ def decorator(fn: F) -> F:
237
+ span_name = name or fn.__name__
238
+ is_async = inspect.iscoroutinefunction(fn)
239
+
240
+ if is_async:
241
+ @functools.wraps(fn)
242
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
243
+ client = _get_client()
244
+ if client is None:
245
+ return await fn(*args, **kwargs)
246
+
247
+ tracer = client.tracer
248
+ with tracer.start_as_current_span(
249
+ name=span_name,
250
+ kind=trace.SpanKind.INTERNAL,
251
+ ) as span:
252
+ start_time = time.perf_counter()
253
+ span.set_attribute("heimdall.span_kind", SpanKind.INTERNAL.value)
254
+
255
+ if capture_input:
256
+ try:
257
+ all_args = _capture_arguments(fn, args, kwargs)
258
+ span.set_attribute("heimdall.input", _serialize_value(all_args))
259
+ except Exception:
260
+ pass
261
+
262
+ try:
263
+ result = await fn(*args, **kwargs)
264
+ if capture_output:
265
+ span.set_attribute("heimdall.output", _serialize_value(result))
266
+ span.set_status(Status(StatusCode.OK))
267
+ return result
268
+ except Exception as e:
269
+ _record_error(span, e)
270
+ raise
271
+ finally:
272
+ duration_ms = (time.perf_counter() - start_time) * 1000
273
+ span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
274
+
275
+ return async_wrapper # type: ignore
276
+ else:
277
+ @functools.wraps(fn)
278
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
279
+ client = _get_client()
280
+ if client is None:
281
+ return fn(*args, **kwargs)
282
+
283
+ tracer = client.tracer
284
+ with tracer.start_as_current_span(
285
+ name=span_name,
286
+ kind=trace.SpanKind.INTERNAL,
287
+ ) as span:
288
+ start_time = time.perf_counter()
289
+ span.set_attribute("heimdall.span_kind", SpanKind.INTERNAL.value)
290
+
291
+ if capture_input:
292
+ try:
293
+ all_args = _capture_arguments(fn, args, kwargs)
294
+ span.set_attribute("heimdall.input", _serialize_value(all_args))
295
+ except Exception:
296
+ pass
297
+
298
+ try:
299
+ result = fn(*args, **kwargs)
300
+ if capture_output:
301
+ span.set_attribute("heimdall.output", _serialize_value(result))
302
+ span.set_status(Status(StatusCode.OK))
303
+ return result
304
+ except Exception as e:
305
+ _record_error(span, e)
306
+ raise
307
+ finally:
308
+ duration_ms = (time.perf_counter() - start_time) * 1000
309
+ span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
310
+
311
+ return sync_wrapper # type: ignore
312
+
313
+ # Handle both @observe and @observe() syntax
314
+ if func is not None:
315
+ return decorator(func)
316
+ return decorator
317
+
hmdl/py.typed ADDED
@@ -0,0 +1 @@
1
+
hmdl/types.py ADDED
@@ -0,0 +1,114 @@
1
+ """Type definitions for Heimdall SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, Optional, List
8
+ from datetime import datetime
9
+
10
+
11
+ class SpanKind(str, Enum):
12
+ """Kind of span being recorded."""
13
+
14
+ MCP_TOOL = "mcp.tool"
15
+ MCP_RESOURCE = "mcp.resource"
16
+ MCP_PROMPT = "mcp.prompt"
17
+ MCP_REQUEST = "mcp.request"
18
+ INTERNAL = "internal"
19
+ CLIENT = "client"
20
+ SERVER = "server"
21
+
22
+
23
+ class SpanStatus(str, Enum):
24
+ """Status of a span."""
25
+
26
+ UNSET = "unset"
27
+ OK = "ok"
28
+ ERROR = "error"
29
+
30
+
31
+ @dataclass
32
+ class MCPToolCall:
33
+ """Represents an MCP tool call."""
34
+
35
+ name: str
36
+ arguments: Dict[str, Any] = field(default_factory=dict)
37
+ result: Optional[Any] = None
38
+ error: Optional[str] = None
39
+ duration_ms: Optional[float] = None
40
+ timestamp: datetime = field(default_factory=datetime.utcnow)
41
+
42
+
43
+ @dataclass
44
+ class MCPResourceAccess:
45
+ """Represents an MCP resource access."""
46
+
47
+ uri: str
48
+ method: str = "read"
49
+ content_type: Optional[str] = None
50
+ content_length: Optional[int] = None
51
+ error: Optional[str] = None
52
+ duration_ms: Optional[float] = None
53
+ timestamp: datetime = field(default_factory=datetime.utcnow)
54
+
55
+
56
+ @dataclass
57
+ class MCPPromptCall:
58
+ """Represents an MCP prompt call."""
59
+
60
+ name: str
61
+ arguments: Dict[str, Any] = field(default_factory=dict)
62
+ messages: List[Dict[str, Any]] = field(default_factory=list)
63
+ error: Optional[str] = None
64
+ duration_ms: Optional[float] = None
65
+ timestamp: datetime = field(default_factory=datetime.utcnow)
66
+
67
+
68
+ @dataclass
69
+ class TraceContext:
70
+ """Context for a trace."""
71
+
72
+ trace_id: str
73
+ span_id: str
74
+ parent_span_id: Optional[str] = None
75
+ session_id: Optional[str] = None
76
+ user_id: Optional[str] = None
77
+ metadata: Dict[str, Any] = field(default_factory=dict)
78
+ tags: List[str] = field(default_factory=list)
79
+
80
+
81
+ # Attribute keys for OpenTelemetry spans
82
+ class HeimdallAttributes:
83
+ """Standard attribute keys for Heimdall spans."""
84
+
85
+ # MCP specific attributes
86
+ MCP_TOOL_NAME = "mcp.tool.name"
87
+ MCP_TOOL_ARGUMENTS = "mcp.tool.arguments"
88
+ MCP_TOOL_RESULT = "mcp.tool.result"
89
+
90
+ MCP_RESOURCE_URI = "mcp.resource.uri"
91
+ MCP_RESOURCE_METHOD = "mcp.resource.method"
92
+ MCP_RESOURCE_CONTENT_TYPE = "mcp.resource.content_type"
93
+ MCP_RESOURCE_CONTENT_LENGTH = "mcp.resource.content_length"
94
+
95
+ MCP_PROMPT_NAME = "mcp.prompt.name"
96
+ MCP_PROMPT_ARGUMENTS = "mcp.prompt.arguments"
97
+ MCP_PROMPT_MESSAGES = "mcp.prompt.messages"
98
+
99
+ # Heimdall specific attributes
100
+ HEIMDALL_SESSION_ID = "heimdall.session_id"
101
+ HEIMDALL_USER_ID = "heimdall.user_id"
102
+ HEIMDALL_ENVIRONMENT = "heimdall.environment"
103
+ HEIMDALL_SERVICE_NAME = "heimdall.service_name"
104
+ HEIMDALL_ORG_ID = "heimdall.org_id"
105
+ HEIMDALL_PROJECT_ID = "heimdall.project_id"
106
+
107
+ # Status and error attributes
108
+ STATUS = "heimdall.status"
109
+ ERROR_MESSAGE = "heimdall.error.message"
110
+ ERROR_TYPE = "heimdall.error.type"
111
+
112
+ # Timing attributes
113
+ DURATION_MS = "heimdall.duration_ms"
114
+
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: hmdl
3
+ Version: 0.0.1
4
+ Summary: Observability SDK for MCP (Model Context Protocol) servers - Heimdall Platform
5
+ Project-URL: Homepage, https://tryheimdall.com
6
+ Project-URL: Documentation, https://docs.tryheimdall.com
7
+ Project-URL: Repository, https://github.com/hmdl-inc/heimdall-python
8
+ Project-URL: Issues, https://github.com/hmdl-inc/heimdall-python/issues
9
+ Author-email: Heimdall Team <founder@tryheimdall.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai,llm,mcp,model-context-protocol,monitoring,observability,opentelemetry,tracing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: System :: Monitoring
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: opentelemetry-api>=1.20.0
26
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20.0
27
+ Requires-Dist: opentelemetry-sdk>=1.20.0
28
+ Requires-Dist: opentelemetry-semantic-conventions>=0.41b0
29
+ Requires-Dist: typing-extensions>=4.0.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: black>=23.0.0; extra == 'dev'
32
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
33
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
34
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
35
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # hmdl - Heimdall Observability SDK for Python
40
+
41
+ [![PyPI version](https://badge.fury.io/py/hmdl.svg)](https://badge.fury.io/py/hmdl)
42
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
44
+
45
+ Observability SDK for MCP (Model Context Protocol) servers, built on OpenTelemetry.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install hmdl
51
+ ```
52
+
53
+ ## Quick Start
54
+
55
+ ### 1. Create Organization and Project in Heimdall
56
+
57
+ Before using the SDK, you need to set up your organization and project in the Heimdall dashboard:
58
+
59
+ 1. Start the Heimdall backend and frontend (see [Heimdall Documentation](https://docs.tryheimdall.com))
60
+ 2. Navigate to http://localhost:5173
61
+ 3. **Create an account** with your email and password
62
+ 4. **Create an Organization** - this groups your projects together
63
+ 5. **Create a Project** - each project has a unique ID for trace collection
64
+ 6. Go to **Settings** to find your **Organization ID** and **Project ID**
65
+
66
+ ### 2. Set up environment variables
67
+
68
+ ```bash
69
+ # Required for local development
70
+ export HEIMDALL_ENDPOINT="http://localhost:4318" # Your Heimdall backend
71
+ export HEIMDALL_ORG_ID="your-org-id" # From Heimdall Settings page
72
+ export HEIMDALL_PROJECT_ID="your-project-id" # From Heimdall Settings page
73
+ export HEIMDALL_ENABLED="true"
74
+
75
+ # Optional
76
+ export HEIMDALL_SERVICE_NAME="my-mcp-server"
77
+ export HEIMDALL_ENVIRONMENT="development"
78
+
79
+ # For production (with API key)
80
+ export HEIMDALL_API_KEY="your-api-key"
81
+ export HEIMDALL_ENDPOINT="https://api.heimdall.dev"
82
+ ```
83
+
84
+ ### 3. Initialize the client
85
+
86
+ ```python
87
+ from hmdl import HeimdallClient
88
+
89
+ # Initialize (uses environment variables by default)
90
+ client = HeimdallClient()
91
+
92
+ # Or with explicit configuration
93
+ client = HeimdallClient(
94
+ endpoint="http://localhost:4318",
95
+ org_id="your-org-id", # From Settings page
96
+ project_id="your-project-id", # From Settings page
97
+ service_name="my-mcp-server",
98
+ environment="development"
99
+ )
100
+ ```
101
+
102
+ ### 4. Instrument your MCP tool functions
103
+
104
+ ```python
105
+ from hmdl import trace_mcp_tool
106
+
107
+ @trace_mcp_tool()
108
+ def search_documents(query: str, limit: int = 10) -> list:
109
+ """Search for documents matching the query."""
110
+ # Your implementation here
111
+ return results
112
+
113
+ @trace_mcp_tool("custom-tool-name")
114
+ def another_tool(data: dict) -> dict:
115
+ """Another MCP tool with custom name."""
116
+ return {"processed": True, **data}
117
+ ```
118
+
119
+ ### 5. Async support
120
+
121
+ The decorator works with async functions:
122
+
123
+ ```python
124
+ @trace_mcp_tool()
125
+ async def async_search(query: str) -> list:
126
+ results = await database.search(query)
127
+ return results
128
+ ```
129
+
130
+ ## Configuration
131
+
132
+ | Environment Variable | Description | Default |
133
+ |---------------------|-------------|---------|
134
+ | `HEIMDALL_ENDPOINT` | Heimdall backend URL | `http://localhost:4318` |
135
+ | `HEIMDALL_ORG_ID` | Organization ID (from Settings page) | `default` |
136
+ | `HEIMDALL_PROJECT_ID` | Project ID (from Settings page) | `default` |
137
+ | `HEIMDALL_ENABLED` | Enable/disable tracing | `true` |
138
+ | `HEIMDALL_SERVICE_NAME` | Service name for traces | `mcp-server` |
139
+ | `HEIMDALL_ENVIRONMENT` | Deployment environment | `development` |
140
+ | `HEIMDALL_API_KEY` | API key (optional for local dev) | - |
141
+ | `HEIMDALL_DEBUG` | Enable debug logging | `false` |
142
+ | `HEIMDALL_BATCH_SIZE` | Spans per batch | `100` |
143
+ | `HEIMDALL_FLUSH_INTERVAL_MS` | Flush interval (ms) | `5000` |
144
+
145
+ ### Local Development
146
+
147
+ For local development, you don't need an API key. Just set:
148
+
149
+ ```bash
150
+ export HEIMDALL_ENDPOINT="http://localhost:4318"
151
+ export HEIMDALL_ORG_ID="your-org-id" # Copy from Settings page
152
+ export HEIMDALL_PROJECT_ID="your-project-id" # Copy from Settings page
153
+ export HEIMDALL_ENABLED="true"
154
+ ```
155
+
156
+ ## Advanced Usage
157
+
158
+ ### Custom span names
159
+
160
+ ```python
161
+ @trace_mcp_tool("custom-tool-name")
162
+ def my_tool():
163
+ pass
164
+ ```
165
+
166
+ ### Manual spans
167
+
168
+ ```python
169
+ from hmdl import HeimdallClient
170
+
171
+ client = HeimdallClient()
172
+
173
+ with client.start_span("my-operation") as span:
174
+ span.set_attribute("custom.attribute", "value")
175
+ # Your code here
176
+ ```
177
+
178
+ ### Flush on shutdown
179
+
180
+ ```python
181
+ import atexit
182
+ from hmdl import HeimdallClient
183
+
184
+ client = HeimdallClient()
185
+
186
+ # Ensure spans are flushed on exit
187
+ atexit.register(client.flush)
188
+ ```
189
+
190
+ ## What gets tracked?
191
+
192
+ For each MCP function call, Heimdall tracks:
193
+
194
+ - **Input parameters**: Function arguments (serialized to JSON)
195
+ - **Output/response**: Return value (serialized to JSON)
196
+ - **Status**: Success or error
197
+ - **Latency**: Execution time in milliseconds
198
+ - **Errors**: Exception type, message, and stack trace
199
+ - **Metadata**: Service name, environment, timestamps
200
+
201
+ ## OpenTelemetry Integration
202
+
203
+ This SDK is built on OpenTelemetry, making it compatible with the broader observability ecosystem. You can:
204
+
205
+ - Use existing OTel instrumentations alongside Heimdall
206
+ - Export to multiple backends simultaneously
207
+ - Leverage OTel's context propagation for distributed tracing
208
+
209
+ ## License
210
+
211
+ MIT License - see [LICENSE](LICENSE) for details.
212
+
@@ -0,0 +1,10 @@
1
+ hmdl/__init__.py,sha256=EA49ssIg0WJTzi1-Oi0J8GWV6k55R-oav45xbufd4WU,570
2
+ hmdl/client.py,sha256=_VHVIlfq8JV_FucLN9rjgj_0fE_C0h5DZWQCUPXSSCw,6782
3
+ hmdl/config.py,sha256=jXo0XC962JM3-N2uUl6o8Dt0hUZjIWvDJcthm04Ux0U,3028
4
+ hmdl/decorators.py,sha256=yUywG_AMCA-tqT-w4fO1bSpOrVn3TDk7Y981R2UmJjQ,11697
5
+ hmdl/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ hmdl/types.py,sha256=C_QDl4YTJjnrhvMUdnDnTG35jKmovLFqMrHXL4LHOG0,3130
7
+ hmdl-0.0.1.dist-info/METADATA,sha256=gbak-CcmYZkcegGv0GqSvMaC41EtCo99IgWC4or3UE8,6743
8
+ hmdl-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ hmdl-0.0.1.dist-info/licenses/LICENSE,sha256=b8jAb5oXJiKCT9GmhRp2uDLqZXIA63QnLT4_3JvzxhE,1064
10
+ hmdl-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hmdl-inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.