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.
- ml_mcp/__init__.py +3 -0
- ml_mcp/application/__init__.py +1 -0
- ml_mcp/application/analysis/__init__.py +15 -0
- ml_mcp/application/analysis/diagnostics.py +122 -0
- ml_mcp/application/analysis/service.py +155 -0
- ml_mcp/application/artifacts/__init__.py +5 -0
- ml_mcp/application/artifacts/service.py +149 -0
- ml_mcp/application/datasets/__init__.py +6 -0
- ml_mcp/application/datasets/service.py +178 -0
- ml_mcp/application/datasets/validator.py +156 -0
- ml_mcp/application/experiments/__init__.py +5 -0
- ml_mcp/application/experiments/service.py +254 -0
- ml_mcp/application/models/__init__.py +6 -0
- ml_mcp/application/models/catalog_seed.py +131 -0
- ml_mcp/application/models/service.py +177 -0
- ml_mcp/application/projects/__init__.py +5 -0
- ml_mcp/application/projects/service.py +93 -0
- ml_mcp/config/__init__.py +5 -0
- ml_mcp/config/settings.py +176 -0
- ml_mcp/domain/__init__.py +1 -0
- ml_mcp/domain/errors/__init__.py +39 -0
- ml_mcp/domain/errors/base.py +155 -0
- ml_mcp/domain/errors/codes.py +23 -0
- ml_mcp/domain/policies/__init__.py +16 -0
- ml_mcp/domain/policies/rbac.py +126 -0
- ml_mcp/domain/policies/tenant_isolation.py +34 -0
- ml_mcp/domain/policies/tool_policy.py +185 -0
- ml_mcp/domain/value_objects/__init__.py +41 -0
- ml_mcp/domain/value_objects/dataset.py +60 -0
- ml_mcp/domain/value_objects/experiment.py +131 -0
- ml_mcp/domain/value_objects/model.py +48 -0
- ml_mcp/infrastructure/__init__.py +1 -0
- ml_mcp/infrastructure/object_storage/__init__.py +5 -0
- ml_mcp/infrastructure/object_storage/s3.py +272 -0
- ml_mcp/infrastructure/postgres/__init__.py +14 -0
- ml_mcp/infrastructure/postgres/base.py +40 -0
- ml_mcp/infrastructure/postgres/models.py +356 -0
- ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
- ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
- ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
- ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
- ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
- ml_mcp/infrastructure/postgres/session.py +117 -0
- ml_mcp/infrastructure/queue/__init__.py +6 -0
- ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
- ml_mcp/infrastructure/queue/task_queue.py +98 -0
- ml_mcp/infrastructure/redis/__init__.py +11 -0
- ml_mcp/infrastructure/redis/client.py +64 -0
- ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
- ml_mcp/infrastructure/telemetry/__init__.py +23 -0
- ml_mcp/infrastructure/telemetry/logging.py +155 -0
- ml_mcp/infrastructure/telemetry/otel.py +141 -0
- ml_mcp/mcp/__init__.py +1 -0
- ml_mcp/mcp/prompts.py +38 -0
- ml_mcp/mcp/resources.py +35 -0
- ml_mcp/mcp/schemas/__init__.py +1 -0
- ml_mcp/mcp/schemas/tools.py +124 -0
- ml_mcp/mcp/tools/__init__.py +63 -0
- ml_mcp/mcp/tools/analysis.py +37 -0
- ml_mcp/mcp/tools/datasets.py +82 -0
- ml_mcp/mcp/tools/discovery.py +82 -0
- ml_mcp/mcp/tools/experiments.py +96 -0
- ml_mcp/mcp/tools/projects.py +39 -0
- ml_mcp/mcp/tools/results.py +59 -0
- ml_mcp/server/__init__.py +1 -0
- ml_mcp/server/app.py +488 -0
- ml_mcp/server/auth.py +146 -0
- ml_mcp/server/context.py +59 -0
- ml_mcp/server/http.py +183 -0
- ml_mcp/server/middleware.py +121 -0
- ml_mcp/server/stdio.py +42 -0
- ml_mcp/workers/__init__.py +1 -0
- ml_mcp/workers/daemon.py +130 -0
- ml_mcp/workers/metrics.py +110 -0
- ml_mcp/workers/preprocessing.py +170 -0
- ml_mcp/workers/runner.py +250 -0
- ml_mcp/workers/sandbox.py +35 -0
- ml_mcp/workers/trainers/__init__.py +52 -0
- ml_mcp/workers/trainers/base.py +52 -0
- ml_mcp/workers/trainers/catboost_model.py +85 -0
- ml_mcp/workers/trainers/lightgbm_model.py +81 -0
- ml_mcp/workers/trainers/sklearn_models.py +266 -0
- ml_mcp/workers/trainers/xgboost_model.py +82 -0
- quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
- quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
- quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
- quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- 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."""
|