QuickLab-MCP 0.1.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 (92) hide show
  1. ml_mcp/__init__.py +3 -0
  2. ml_mcp/application/__init__.py +1 -0
  3. ml_mcp/application/analysis/__init__.py +15 -0
  4. ml_mcp/application/analysis/diagnostics.py +122 -0
  5. ml_mcp/application/analysis/service.py +155 -0
  6. ml_mcp/application/artifacts/__init__.py +5 -0
  7. ml_mcp/application/artifacts/service.py +149 -0
  8. ml_mcp/application/datasets/__init__.py +6 -0
  9. ml_mcp/application/datasets/service.py +178 -0
  10. ml_mcp/application/datasets/validator.py +156 -0
  11. ml_mcp/application/experiments/__init__.py +5 -0
  12. ml_mcp/application/experiments/service.py +254 -0
  13. ml_mcp/application/models/__init__.py +6 -0
  14. ml_mcp/application/models/catalog_seed.py +131 -0
  15. ml_mcp/application/models/service.py +177 -0
  16. ml_mcp/application/projects/__init__.py +5 -0
  17. ml_mcp/application/projects/service.py +93 -0
  18. ml_mcp/config/__init__.py +5 -0
  19. ml_mcp/config/settings.py +176 -0
  20. ml_mcp/domain/__init__.py +1 -0
  21. ml_mcp/domain/errors/__init__.py +39 -0
  22. ml_mcp/domain/errors/base.py +155 -0
  23. ml_mcp/domain/errors/codes.py +23 -0
  24. ml_mcp/domain/policies/__init__.py +16 -0
  25. ml_mcp/domain/policies/rbac.py +126 -0
  26. ml_mcp/domain/policies/tenant_isolation.py +34 -0
  27. ml_mcp/domain/policies/tool_policy.py +185 -0
  28. ml_mcp/domain/value_objects/__init__.py +41 -0
  29. ml_mcp/domain/value_objects/dataset.py +60 -0
  30. ml_mcp/domain/value_objects/experiment.py +131 -0
  31. ml_mcp/domain/value_objects/model.py +48 -0
  32. ml_mcp/infrastructure/__init__.py +1 -0
  33. ml_mcp/infrastructure/object_storage/__init__.py +5 -0
  34. ml_mcp/infrastructure/object_storage/s3.py +272 -0
  35. ml_mcp/infrastructure/postgres/__init__.py +14 -0
  36. ml_mcp/infrastructure/postgres/base.py +40 -0
  37. ml_mcp/infrastructure/postgres/models.py +356 -0
  38. ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
  39. ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
  40. ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
  41. ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
  42. ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
  43. ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
  44. ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
  45. ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
  46. ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
  47. ml_mcp/infrastructure/postgres/session.py +117 -0
  48. ml_mcp/infrastructure/queue/__init__.py +6 -0
  49. ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
  50. ml_mcp/infrastructure/queue/task_queue.py +98 -0
  51. ml_mcp/infrastructure/redis/__init__.py +11 -0
  52. ml_mcp/infrastructure/redis/client.py +64 -0
  53. ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
  54. ml_mcp/infrastructure/telemetry/__init__.py +23 -0
  55. ml_mcp/infrastructure/telemetry/logging.py +155 -0
  56. ml_mcp/infrastructure/telemetry/otel.py +141 -0
  57. ml_mcp/mcp/__init__.py +1 -0
  58. ml_mcp/mcp/prompts.py +38 -0
  59. ml_mcp/mcp/resources.py +35 -0
  60. ml_mcp/mcp/schemas/__init__.py +1 -0
  61. ml_mcp/mcp/schemas/tools.py +124 -0
  62. ml_mcp/mcp/tools/__init__.py +63 -0
  63. ml_mcp/mcp/tools/analysis.py +37 -0
  64. ml_mcp/mcp/tools/datasets.py +82 -0
  65. ml_mcp/mcp/tools/discovery.py +82 -0
  66. ml_mcp/mcp/tools/experiments.py +96 -0
  67. ml_mcp/mcp/tools/projects.py +39 -0
  68. ml_mcp/mcp/tools/results.py +59 -0
  69. ml_mcp/server/__init__.py +1 -0
  70. ml_mcp/server/app.py +488 -0
  71. ml_mcp/server/auth.py +146 -0
  72. ml_mcp/server/context.py +59 -0
  73. ml_mcp/server/http.py +183 -0
  74. ml_mcp/server/middleware.py +121 -0
  75. ml_mcp/server/stdio.py +42 -0
  76. ml_mcp/workers/__init__.py +1 -0
  77. ml_mcp/workers/daemon.py +130 -0
  78. ml_mcp/workers/metrics.py +110 -0
  79. ml_mcp/workers/preprocessing.py +170 -0
  80. ml_mcp/workers/runner.py +250 -0
  81. ml_mcp/workers/sandbox.py +35 -0
  82. ml_mcp/workers/trainers/__init__.py +52 -0
  83. ml_mcp/workers/trainers/base.py +52 -0
  84. ml_mcp/workers/trainers/catboost_model.py +85 -0
  85. ml_mcp/workers/trainers/lightgbm_model.py +81 -0
  86. ml_mcp/workers/trainers/sklearn_models.py +266 -0
  87. ml_mcp/workers/trainers/xgboost_model.py +82 -0
  88. quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
  89. quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
  90. quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
  91. quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  92. quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,67 @@
1
+ """Transactional Outbox processor dispatching experiment events to the task queue."""
2
+
3
+ import logging
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from ml_mcp.infrastructure.postgres.repositories.outbox import OutboxRepository
8
+ from ml_mcp.infrastructure.queue.task_queue import RedisTaskQueue
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class OutboxProcessor:
14
+ """Polls transactional outbox and guarantees reliable message delivery to Redis task queue."""
15
+
16
+ def __init__(
17
+ self,
18
+ session: AsyncSession,
19
+ task_queue: RedisTaskQueue | None = None,
20
+ max_retries: int = 5,
21
+ ) -> None:
22
+ self.session = session
23
+ self.repo = OutboxRepository(session)
24
+ self.queue = task_queue or RedisTaskQueue()
25
+ self.max_retries = max_retries
26
+
27
+ async def process_batch(self, limit: int = 50) -> int:
28
+ """Process scheduled pending outbox events and dispatch them to the worker queue."""
29
+ events = await self.repo.fetch_pending(limit=limit)
30
+ delivered_count = 0
31
+
32
+ for event in events:
33
+ try:
34
+ payload = event.payload_json
35
+ experiment_id = payload.get("experiment_id", event.aggregate_id)
36
+ tenant_id = payload.get("tenant_id", "system")
37
+
38
+ # Push to Redis queue
39
+ await self.queue.enqueue(
40
+ experiment_id=experiment_id,
41
+ tenant_id=tenant_id,
42
+ payload=payload,
43
+ )
44
+
45
+ # Mark delivered in outbox table
46
+ await self.repo.mark_delivered(event.id)
47
+ delivered_count += 1
48
+ logger.info(
49
+ "Successfully dispatched outbox event %s for experiment %s",
50
+ event.id,
51
+ experiment_id,
52
+ )
53
+
54
+ except Exception as exc:
55
+ new_retry = event.retry_count + 1
56
+ logger.error(
57
+ "Failed to dispatch outbox event %s (attempt %d/%d): %s",
58
+ event.id,
59
+ new_retry,
60
+ self.max_retries,
61
+ exc,
62
+ )
63
+ await self.repo.mark_failed(
64
+ event.id, retry_count=new_retry, max_retries=self.max_retries
65
+ )
66
+
67
+ return delivered_count
@@ -0,0 +1,98 @@
1
+ """Durable task queue implementation backed by Redis list/streams with local queue fallback."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from dataclasses import asdict, dataclass
8
+ from typing import Any
9
+
10
+ from ml_mcp.config import Settings, get_settings
11
+ from ml_mcp.infrastructure.postgres.base import generate_uuid7
12
+ from ml_mcp.infrastructure.redis.client import RedisManager, get_redis_manager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class TaskMessage:
19
+ """Message payload transmitted from Outbox processor to isolated ML worker."""
20
+
21
+ task_id: str
22
+ experiment_id: str
23
+ tenant_id: str
24
+ payload: dict[str, Any]
25
+ created_at: float
26
+
27
+ def to_json(self) -> str:
28
+ return json.dumps(asdict(self))
29
+
30
+ @classmethod
31
+ def from_json(cls, data_str: str) -> "TaskMessage":
32
+ data = json.loads(data_str)
33
+ return cls(**data)
34
+
35
+
36
+ class RedisTaskQueue:
37
+ """Reliable Redis-backed queue for orchestrating ML worker tasks."""
38
+
39
+ def __init__(
40
+ self,
41
+ redis_manager: RedisManager | None = None,
42
+ settings: Settings | None = None,
43
+ ) -> None:
44
+ self.settings = settings or get_settings()
45
+ self.redis_mgr = redis_manager or get_redis_manager()
46
+ self.queue_name = self.settings.redis.task_queue_name
47
+ # In-memory queue fallback for isolated testing
48
+ self._local_queue: asyncio.Queue[TaskMessage] = asyncio.Queue()
49
+
50
+ async def enqueue(
51
+ self,
52
+ experiment_id: str,
53
+ tenant_id: str,
54
+ payload: dict[str, Any],
55
+ ) -> str:
56
+ """Enqueue an experiment task for execution. Returns task_id."""
57
+ task_id = generate_uuid7()
58
+ message = TaskMessage(
59
+ task_id=task_id,
60
+ experiment_id=experiment_id,
61
+ tenant_id=tenant_id,
62
+ payload=payload,
63
+ created_at=time.time(),
64
+ )
65
+
66
+ try:
67
+ client = self.redis_mgr.client
68
+ await client.rpush(self.queue_name, message.to_json())
69
+ logger.info("Enqueued task %s for experiment %s in Redis", task_id, experiment_id)
70
+ except Exception as exc:
71
+ logger.warning("Redis unavailable, enqueuing to in-memory queue fallback: %s", exc)
72
+ await self._local_queue.put(message)
73
+
74
+ return task_id
75
+
76
+ async def dequeue(self, timeout_seconds: int = 5) -> TaskMessage | None:
77
+ """Pop the next available task from the queue with timeout."""
78
+ try:
79
+ client = self.redis_mgr.client
80
+ result = await client.blpop(self.queue_name, timeout=timeout_seconds)
81
+ if result:
82
+ _, data_str = result
83
+ return TaskMessage.from_json(data_str)
84
+ return None
85
+ except Exception:
86
+ # Fallback to local queue
87
+ try:
88
+ return await asyncio.wait_for(self._local_queue.get(), timeout=timeout_seconds)
89
+ except TimeoutError:
90
+ return None
91
+
92
+ async def get_queue_depth(self) -> int:
93
+ """Return total count of waiting tasks in the queue."""
94
+ try:
95
+ client = self.redis_mgr.client
96
+ return await client.llen(self.queue_name)
97
+ except Exception:
98
+ return self._local_queue.qsize()
@@ -0,0 +1,11 @@
1
+ """Redis client and rate limiting infrastructure."""
2
+
3
+ from ml_mcp.infrastructure.redis.client import RedisManager, get_redis_manager
4
+ from ml_mcp.infrastructure.redis.rate_limiter import RateLimitResult, SlidingWindowRateLimiter
5
+
6
+ __all__ = [
7
+ "RedisManager",
8
+ "get_redis_manager",
9
+ "SlidingWindowRateLimiter",
10
+ "RateLimitResult",
11
+ ]
@@ -0,0 +1,64 @@
1
+ """Async Redis connection management."""
2
+
3
+ import logging
4
+
5
+ from redis.asyncio import ConnectionPool, Redis
6
+
7
+ from ml_mcp.config import Settings, get_settings
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class RedisManager:
13
+ """Manages Redis connection pool and async client."""
14
+
15
+ def __init__(self, settings: Settings | None = None) -> None:
16
+ self.settings = settings or get_settings()
17
+ self._pool: ConnectionPool | None = None
18
+ self._client: Redis | None = None
19
+
20
+ def initialize(self, custom_url: str | None = None) -> None:
21
+ redis_url = custom_url or self.settings.redis.url
22
+ self._pool = ConnectionPool.from_url(
23
+ redis_url,
24
+ max_connections=self.settings.redis.max_connections,
25
+ decode_responses=True,
26
+ socket_connect_timeout=0.5,
27
+ socket_timeout=1.0,
28
+ )
29
+ self._client = Redis(connection_pool=self._pool)
30
+ logger.info("Redis client initialized")
31
+
32
+ @property
33
+ def client(self) -> Redis:
34
+ if self._client is None:
35
+ self.initialize()
36
+ assert self._client is not None
37
+ return self._client
38
+
39
+ async def check_health(self) -> bool:
40
+ """Ping Redis server to assert availability."""
41
+ try:
42
+ return await self.client.ping() is True
43
+ except Exception as exc:
44
+ logger.error("Redis healthcheck failed: %s", exc)
45
+ return False
46
+
47
+ async def close(self) -> None:
48
+ if self._client is not None:
49
+ await self._client.aclose()
50
+ if self._pool is not None:
51
+ await self._pool.disconnect()
52
+ self._client = None
53
+ self._pool = None
54
+ logger.info("Redis connection closed")
55
+
56
+
57
+ _redis_manager: RedisManager | None = None
58
+
59
+
60
+ def get_redis_manager() -> RedisManager:
61
+ global _redis_manager
62
+ if _redis_manager is None:
63
+ _redis_manager = RedisManager()
64
+ return _redis_manager
@@ -0,0 +1,129 @@
1
+ """Sliding window distributed rate limiter using Redis sorted sets and atomic Lua script."""
2
+
3
+ import time
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+
7
+ from redis.asyncio import Redis
8
+
9
+ from ml_mcp.infrastructure.redis.client import RedisManager, get_redis_manager
10
+
11
+ LUA_RATE_LIMIT_SCRIPT = """
12
+ local key = KEYS[1]
13
+ local now = tonumber(ARGV[1])
14
+ local window_seconds = tonumber(ARGV[2])
15
+ local limit = tonumber(ARGV[3])
16
+ local member = ARGV[4]
17
+
18
+ local window_start = now - window_seconds
19
+
20
+ -- 1. Remove entries older than window_start
21
+ redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
22
+
23
+ -- 2. Count existing entries
24
+ local current_count = redis.call('ZCARD', key)
25
+
26
+ if current_count < limit then
27
+ -- Allowed: add entry and update TTL
28
+ redis.call('ZADD', key, now, member)
29
+ redis.call('EXPIRE', key, math.ceil(window_seconds * 2))
30
+ local remaining = limit - (current_count + 1)
31
+ return {1, limit, remaining, tostring(now + window_seconds), "0.0"}
32
+ else
33
+ -- Denied: find oldest entry to determine reset time
34
+ local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
35
+ local reset_time = now + window_seconds
36
+ if oldest and #oldest >= 2 then
37
+ reset_time = tonumber(oldest[2]) + window_seconds
38
+ end
39
+ local retry_after = reset_time - now
40
+ if retry_after < 0 then
41
+ retry_after = 0
42
+ end
43
+ return {0, limit, 0, tostring(reset_time), tostring(retry_after)}
44
+ end
45
+ """
46
+
47
+
48
+ @dataclass
49
+ class RateLimitResult:
50
+ allowed: bool
51
+ limit: int
52
+ remaining: int
53
+ reset_epoch_seconds: float
54
+ retry_after_seconds: float
55
+
56
+
57
+ class SlidingWindowRateLimiter:
58
+ """Sliding-window rate limiter ensuring atomic Lua script execution across all tool tiers."""
59
+
60
+ def __init__(self, redis_manager: RedisManager | None = None) -> None:
61
+ self.redis_mgr = redis_manager or get_redis_manager()
62
+ # In-memory fallback for testing or when Redis is unavailable
63
+ self._local_history: dict[str, list[float]] = defaultdict(list)
64
+
65
+ async def check_rate_limit(
66
+ self,
67
+ identifier: str,
68
+ category: str = "default",
69
+ limit: int = 60,
70
+ window_seconds: int = 60,
71
+ ) -> RateLimitResult:
72
+ """Check and record a rate limit hit atomically using Redis Lua script.
73
+
74
+ Returns:
75
+ RateLimitResult with allowed status, remaining tokens, and reset epoch.
76
+ """
77
+ key = f"ratelimit:{category}:{identifier}"
78
+ now = time.time()
79
+ member = f"{now}:{time.time_ns()}"
80
+ window_start = now - window_seconds
81
+
82
+ try:
83
+ client: Redis = self.redis_mgr.client
84
+ result = await client.eval(
85
+ LUA_RATE_LIMIT_SCRIPT,
86
+ 1,
87
+ key,
88
+ str(now),
89
+ str(window_seconds),
90
+ str(limit),
91
+ member,
92
+ )
93
+ allowed = bool(result[0])
94
+ limit_val = int(result[1])
95
+ remaining_val = int(result[2])
96
+ reset_epoch = float(result[3])
97
+ retry_after = float(result[4])
98
+ return RateLimitResult(
99
+ allowed=allowed,
100
+ limit=limit_val,
101
+ remaining=remaining_val,
102
+ reset_epoch_seconds=reset_epoch,
103
+ retry_after_seconds=retry_after,
104
+ )
105
+
106
+ except Exception:
107
+ # In-memory fallback if Redis is offline/unconfigured in testing
108
+ history = self._local_history[key]
109
+ valid = [ts for ts in history if ts > window_start]
110
+ self._local_history[key] = valid
111
+
112
+ if len(valid) >= limit:
113
+ reset_time = valid[0] + window_seconds if valid else now + window_seconds
114
+ return RateLimitResult(
115
+ allowed=False,
116
+ limit=limit,
117
+ remaining=0,
118
+ reset_epoch_seconds=reset_time,
119
+ retry_after_seconds=max(0.0, reset_time - now),
120
+ )
121
+
122
+ valid.append(now)
123
+ return RateLimitResult(
124
+ allowed=True,
125
+ limit=limit,
126
+ remaining=max(0, limit - len(valid)),
127
+ reset_epoch_seconds=now + window_seconds,
128
+ retry_after_seconds=0.0,
129
+ )
@@ -0,0 +1,23 @@
1
+ """Telemetry and observability module."""
2
+
3
+ from ml_mcp.infrastructure.telemetry.logging import (
4
+ clear_request_context,
5
+ configure_logging,
6
+ get_logger,
7
+ set_request_context,
8
+ )
9
+ from ml_mcp.infrastructure.telemetry.otel import (
10
+ get_meter,
11
+ get_tracer,
12
+ init_telemetry,
13
+ )
14
+
15
+ __all__ = [
16
+ "configure_logging",
17
+ "get_logger",
18
+ "set_request_context",
19
+ "clear_request_context",
20
+ "init_telemetry",
21
+ "get_tracer",
22
+ "get_meter",
23
+ ]
@@ -0,0 +1,155 @@
1
+ """Structured JSON logging strictly directed to sys.stderr to preserve STDIO transport."""
2
+
3
+ import contextvars
4
+ import datetime
5
+ import json
6
+ import logging
7
+ import sys
8
+ from typing import Any
9
+
10
+ # Context variables for correlation
11
+ ctx_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
12
+ "ctx_request_id", default=None
13
+ )
14
+ ctx_trace_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
15
+ "ctx_trace_id", default=None
16
+ )
17
+ ctx_tenant_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
18
+ "ctx_tenant_id", default=None
19
+ )
20
+ ctx_principal_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
21
+ "ctx_principal_id", default=None
22
+ )
23
+
24
+
25
+ def set_request_context(
26
+ request_id: str | None = None,
27
+ trace_id: str | None = None,
28
+ tenant_id: str | None = None,
29
+ principal_id: str | None = None,
30
+ ) -> None:
31
+ """Set request-scoped telemetry and correlation IDs."""
32
+ if request_id is not None:
33
+ ctx_request_id.set(request_id)
34
+ if trace_id is not None:
35
+ ctx_trace_id.set(trace_id)
36
+ if tenant_id is not None:
37
+ ctx_tenant_id.set(tenant_id)
38
+ if principal_id is not None:
39
+ ctx_principal_id.set(principal_id)
40
+
41
+
42
+ def clear_request_context() -> None:
43
+ """Reset all correlation IDs in current context."""
44
+ ctx_request_id.set(None)
45
+ ctx_trace_id.set(None)
46
+ ctx_tenant_id.set(None)
47
+ ctx_principal_id.set(None)
48
+
49
+
50
+ class JSONFormatter(logging.Formatter):
51
+ """Custom JSON formatter producing structured log entries for centralized log ingestion."""
52
+
53
+ _SENSITIVE_KEYS = {
54
+ "password",
55
+ "secret",
56
+ "token",
57
+ "authorization",
58
+ "key",
59
+ "api_key",
60
+ "access_token",
61
+ "secret_key",
62
+ }
63
+
64
+ def _sanitize(self, val: Any) -> Any:
65
+ if isinstance(val, dict):
66
+ return {
67
+ k: (
68
+ "***REDACTED***"
69
+ if any(s in k.lower() for s in self._SENSITIVE_KEYS)
70
+ else self._sanitize(v)
71
+ )
72
+ for k, v in val.items()
73
+ }
74
+ if isinstance(val, (list, tuple)):
75
+ return [self._sanitize(item) for item in val]
76
+ return val
77
+
78
+ def format(self, record: logging.LogRecord) -> str:
79
+ payload: dict[str, Any] = {
80
+ "timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
81
+ "level": record.levelname,
82
+ "logger": record.name,
83
+ "message": record.getMessage(),
84
+ "request_id": ctx_request_id.get(),
85
+ "trace_id": ctx_trace_id.get(),
86
+ "tenant_id": ctx_tenant_id.get(),
87
+ "principal_id": ctx_principal_id.get(),
88
+ }
89
+
90
+ if record.exc_info:
91
+ payload["exception"] = self.formatException(record.exc_info)
92
+
93
+ # Include custom extra fields attached to the log record
94
+ standard_attrs = {
95
+ "name",
96
+ "msg",
97
+ "args",
98
+ "levelname",
99
+ "levelno",
100
+ "pathname",
101
+ "filename",
102
+ "module",
103
+ "exc_info",
104
+ "exc_text",
105
+ "stack_info",
106
+ "lineno",
107
+ "funcName",
108
+ "created",
109
+ "msecs",
110
+ "relativeCreated",
111
+ "thread",
112
+ "threadName",
113
+ "processName",
114
+ "process",
115
+ "message",
116
+ }
117
+ extras = {
118
+ k: (
119
+ "***REDACTED***"
120
+ if any(s in k.lower() for s in self._SENSITIVE_KEYS)
121
+ else self._sanitize(v)
122
+ )
123
+ for k, v in record.__dict__.items()
124
+ if k not in standard_attrs and not k.startswith("_")
125
+ }
126
+ if extras:
127
+ payload["extra"] = extras
128
+
129
+ return json.dumps(payload, default=str)
130
+
131
+
132
+ def configure_logging(level: str = "INFO") -> None:
133
+ """Configure root logger to use JSON formatting directed exclusively to stderr."""
134
+ root_logger = logging.getLogger()
135
+ root_logger.setLevel(level.upper())
136
+
137
+ # Remove any existing handlers
138
+ for handler in list(root_logger.handlers):
139
+ root_logger.removeHandler(handler)
140
+
141
+ # Output strictly to sys.stderr
142
+ stderr_handler = logging.StreamHandler(sys.stderr)
143
+ stderr_handler.setFormatter(JSONFormatter())
144
+ root_logger.addHandler(stderr_handler)
145
+
146
+ # Ensure critical third-party loggers do not write raw stdout
147
+ for noisy in ("uvicorn", "uvicorn.access", "uvicorn.error", "fastmcp"):
148
+ noisy_logger = logging.getLogger(noisy)
149
+ noisy_logger.handlers = []
150
+ noisy_logger.propagate = True
151
+
152
+
153
+ def get_logger(name: str) -> logging.Logger:
154
+ """Return a logger instance by name."""
155
+ return logging.getLogger(name)
@@ -0,0 +1,141 @@
1
+ """OpenTelemetry instrumentation setup for traces and metrics."""
2
+
3
+ import logging
4
+
5
+ from opentelemetry import metrics, trace
6
+ from opentelemetry.sdk.metrics import MeterProvider
7
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
8
+ from opentelemetry.sdk.resources import Resource
9
+ from opentelemetry.sdk.trace import TracerProvider
10
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ # Global metric instruments container
16
+ class MetricInstruments:
17
+ def __init__(self, meter: metrics.Meter) -> None:
18
+ self.meter = meter
19
+ self.mcp_requests_total = meter.create_counter(
20
+ name="mcp_requests_total",
21
+ description="Total number of MCP requests handled",
22
+ unit="1",
23
+ )
24
+ self.mcp_request_duration_seconds = meter.create_histogram(
25
+ name="mcp_request_duration_seconds",
26
+ description="Latency distribution of MCP requests",
27
+ unit="s",
28
+ )
29
+ self.mcp_errors_total = meter.create_counter(
30
+ name="mcp_errors_total",
31
+ description="Total number of MCP error responses returned",
32
+ unit="1",
33
+ )
34
+ self.auth_failures_total = meter.create_counter(
35
+ name="auth_failures_total",
36
+ description="Total authentication failures",
37
+ unit="1",
38
+ )
39
+ self.authorization_denials_total = meter.create_counter(
40
+ name="authorization_denials_total",
41
+ description="Total authorization rejections",
42
+ unit="1",
43
+ )
44
+ self.rate_limit_denials_total = meter.create_counter(
45
+ name="rate_limit_denials_total",
46
+ description="Total rate limit throttle events",
47
+ unit="1",
48
+ )
49
+ self.experiments_created_total = meter.create_counter(
50
+ name="experiments_created_total",
51
+ description="Total experiments created",
52
+ unit="1",
53
+ )
54
+ self.experiments_succeeded_total = meter.create_counter(
55
+ name="experiments_succeeded_total",
56
+ description="Total experiments successfully completed",
57
+ unit="1",
58
+ )
59
+ self.experiments_failed_total = meter.create_counter(
60
+ name="experiments_failed_total",
61
+ description="Total experiments that failed during execution",
62
+ unit="1",
63
+ )
64
+ self.experiment_runtime_seconds = meter.create_histogram(
65
+ name="experiment_runtime_seconds",
66
+ description="Execution duration of ML experiment runs",
67
+ unit="s",
68
+ )
69
+ self.artifact_bytes_written = meter.create_counter(
70
+ name="artifact_bytes_written",
71
+ description="Total bytes written to artifact store",
72
+ unit="By",
73
+ )
74
+ self.analysis_runtime_seconds = meter.create_histogram(
75
+ name="analysis_runtime_seconds",
76
+ description="Runtime of structured analysis evaluation",
77
+ unit="s",
78
+ )
79
+
80
+
81
+ _instruments: MetricInstruments | None = None
82
+
83
+
84
+ def init_telemetry(
85
+ service_name: str = "ml-mcp-service",
86
+ enabled: bool = False,
87
+ otlp_endpoint: str = "http://localhost:4317",
88
+ ) -> MetricInstruments:
89
+ """Initialize OpenTelemetry SDK with resource attributes and OTLP exporters if enabled."""
90
+ global _instruments
91
+ resource = Resource.create({"service.name": service_name})
92
+
93
+ if enabled:
94
+ try:
95
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
96
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
97
+
98
+ # Traces
99
+ tracer_provider = TracerProvider(resource=resource)
100
+ span_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
101
+ tracer_provider.add_span_processor(BatchSpanProcessor(span_exporter))
102
+ trace.set_tracer_provider(tracer_provider)
103
+
104
+ # Metrics
105
+ metric_exporter = OTLPMetricExporter(endpoint=otlp_endpoint, insecure=True)
106
+ reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=15000)
107
+ meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
108
+ metrics.set_meter_provider(meter_provider)
109
+ logger.info("OpenTelemetry initialized with OTLP endpoint: %s", otlp_endpoint)
110
+ except Exception as exc:
111
+ logger.warning(
112
+ "Failed to initialize OTLP exporter, falling back to default providers: %s", exc
113
+ )
114
+ trace.set_tracer_provider(TracerProvider(resource=resource))
115
+ metrics.set_meter_provider(MeterProvider(resource=resource))
116
+ else:
117
+ trace.set_tracer_provider(TracerProvider(resource=resource))
118
+ metrics.set_meter_provider(MeterProvider(resource=resource))
119
+
120
+ meter = metrics.get_meter("ml-mcp", "0.1.0")
121
+ _instruments = MetricInstruments(meter)
122
+ return _instruments
123
+
124
+
125
+ def get_tracer(name: str = "ml-mcp") -> trace.Tracer:
126
+ """Return an OpenTelemetry Tracer instance."""
127
+ return trace.get_tracer(name, "0.1.0")
128
+
129
+
130
+ def get_meter(name: str = "ml-mcp") -> metrics.Meter:
131
+ """Return an OpenTelemetry Meter instance."""
132
+ return metrics.get_meter(name, "0.1.0")
133
+
134
+
135
+ def get_instruments() -> MetricInstruments:
136
+ """Return instantiated metric counters and histograms."""
137
+ global _instruments
138
+ if _instruments is None:
139
+ meter = metrics.get_meter("ml-mcp", "0.1.0")
140
+ _instruments = MetricInstruments(meter)
141
+ return _instruments
ml_mcp/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MCP protocol integration layer."""