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,177 @@
|
|
|
1
|
+
"""Model catalog application service."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from ml_mcp.application.models.catalog_seed import APPROVED_MODELS
|
|
8
|
+
from ml_mcp.domain.errors import (
|
|
9
|
+
ModelNotApprovedError,
|
|
10
|
+
ResourceNotFoundError,
|
|
11
|
+
ResourceVersionRevokedError,
|
|
12
|
+
)
|
|
13
|
+
from ml_mcp.domain.value_objects import ApprovalStatus
|
|
14
|
+
from ml_mcp.infrastructure.postgres.base import generate_uuid7
|
|
15
|
+
from ml_mcp.infrastructure.postgres.models import ModelOrm, ModelVersionOrm
|
|
16
|
+
from ml_mcp.infrastructure.postgres.repositories.models import ModelRepository
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ModelService:
|
|
20
|
+
"""Service managing approved ML model architectures and hyperparameter contracts."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
23
|
+
self.session = session
|
|
24
|
+
self.repo = ModelRepository(session)
|
|
25
|
+
|
|
26
|
+
async def seed_catalog(self) -> None:
|
|
27
|
+
"""Seed all 7 approved tabular models and versions if missing, or synchronize digests."""
|
|
28
|
+
for meta in APPROVED_MODELS:
|
|
29
|
+
existing = await self.repo.get_by_id(meta.model_id)
|
|
30
|
+
if not existing:
|
|
31
|
+
model_orm = ModelOrm(
|
|
32
|
+
id=meta.model_id,
|
|
33
|
+
tenant_id="system",
|
|
34
|
+
name=meta.name,
|
|
35
|
+
description=meta.description,
|
|
36
|
+
family=meta.family.value,
|
|
37
|
+
publisher=meta.publisher,
|
|
38
|
+
)
|
|
39
|
+
await self.repo.create_model(model_orm)
|
|
40
|
+
else:
|
|
41
|
+
existing.name = meta.name
|
|
42
|
+
existing.description = meta.description
|
|
43
|
+
existing.family = meta.family.value
|
|
44
|
+
|
|
45
|
+
existing_version = await self.repo.get_version(meta.model_id, meta.version)
|
|
46
|
+
if not existing_version:
|
|
47
|
+
version_orm = ModelVersionOrm(
|
|
48
|
+
id=generate_uuid7(),
|
|
49
|
+
model_id=meta.model_id,
|
|
50
|
+
version=meta.version,
|
|
51
|
+
container_image_digest=meta.container_image_digest,
|
|
52
|
+
supported_task_types=[t.value for t in meta.supported_task_types],
|
|
53
|
+
hyperparameters_schema=[h.model_dump() for h in meta.supported_hyperparameters],
|
|
54
|
+
approval_status=meta.approval_status.value,
|
|
55
|
+
)
|
|
56
|
+
await self.repo.create_version(version_orm)
|
|
57
|
+
else:
|
|
58
|
+
existing_version.container_image_digest = meta.container_image_digest
|
|
59
|
+
existing_version.supported_task_types = [t.value for t in meta.supported_task_types]
|
|
60
|
+
existing_version.hyperparameters_schema = [h.model_dump() for h in meta.supported_hyperparameters]
|
|
61
|
+
existing_version.approval_status = meta.approval_status.value
|
|
62
|
+
|
|
63
|
+
async def list_models(self) -> list[dict[str, Any]]:
|
|
64
|
+
models = await self.repo.list_models()
|
|
65
|
+
return [
|
|
66
|
+
{
|
|
67
|
+
"model_id": m.id,
|
|
68
|
+
"name": m.name,
|
|
69
|
+
"description": m.description,
|
|
70
|
+
"family": m.family,
|
|
71
|
+
"publisher": m.publisher,
|
|
72
|
+
"versions": [v.version for v in m.versions if v.approval_status == ApprovalStatus.APPROVED.value],
|
|
73
|
+
}
|
|
74
|
+
for m in models
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
async def get_model(self, model_id: str) -> dict[str, Any]:
|
|
78
|
+
model = await self.repo.get_by_id(model_id)
|
|
79
|
+
if not model:
|
|
80
|
+
raise ResourceNotFoundError("Model", model_id)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"model_id": model.id,
|
|
84
|
+
"name": model.name,
|
|
85
|
+
"description": model.description,
|
|
86
|
+
"family": model.family,
|
|
87
|
+
"publisher": model.publisher,
|
|
88
|
+
"versions": [
|
|
89
|
+
{
|
|
90
|
+
"version_id": v.id,
|
|
91
|
+
"version": v.version,
|
|
92
|
+
"container_image_digest": v.container_image_digest,
|
|
93
|
+
"supported_task_types": v.supported_task_types,
|
|
94
|
+
"hyperparameters_schema": v.hyperparameters_schema,
|
|
95
|
+
"approval_status": v.approval_status,
|
|
96
|
+
}
|
|
97
|
+
for v in model.versions
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async def list_model_versions(self, model_id: str) -> list[dict[str, Any]]:
|
|
102
|
+
versions = await self.repo.list_versions(model_id)
|
|
103
|
+
if not versions:
|
|
104
|
+
raise ResourceNotFoundError("Model", model_id)
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
"version_id": v.id,
|
|
109
|
+
"version": v.version,
|
|
110
|
+
"approval_status": v.approval_status,
|
|
111
|
+
"supported_task_types": v.supported_task_types,
|
|
112
|
+
"container_image_digest": v.container_image_digest,
|
|
113
|
+
}
|
|
114
|
+
for v in versions
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
async def validate_model_for_experiment(
|
|
118
|
+
self,
|
|
119
|
+
task_type: str | None = None,
|
|
120
|
+
hyperparameters: dict[str, Any] | None = None,
|
|
121
|
+
model_version_id: str | None = None,
|
|
122
|
+
model_id: str | None = None,
|
|
123
|
+
version: str | None = None,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> ModelVersionOrm:
|
|
126
|
+
"""Verify model approval, task compatibility, and hyperparameter bounds."""
|
|
127
|
+
resolved_task_type = task_type or kwargs.get("task_type")
|
|
128
|
+
resolved_hypers = hyperparameters if hyperparameters is not None else kwargs.get("hyperparameters", {})
|
|
129
|
+
resolved_model_id = model_id or kwargs.get("model_id")
|
|
130
|
+
resolved_version = version or kwargs.get("version")
|
|
131
|
+
resolved_mv_id = model_version_id or kwargs.get("model_version_id")
|
|
132
|
+
|
|
133
|
+
if resolved_mv_id:
|
|
134
|
+
v = await self.repo.get_version_by_id(resolved_mv_id)
|
|
135
|
+
elif resolved_model_id and resolved_version:
|
|
136
|
+
v = await self.repo.get_version(resolved_model_id, resolved_version)
|
|
137
|
+
else:
|
|
138
|
+
raise ResourceNotFoundError("ModelVersion", resolved_mv_id or f"{resolved_model_id}:{resolved_version}")
|
|
139
|
+
|
|
140
|
+
if not v:
|
|
141
|
+
raise ResourceNotFoundError("ModelVersion", resolved_mv_id or f"{resolved_model_id}:{resolved_version}")
|
|
142
|
+
|
|
143
|
+
mid = resolved_model_id or v.model_id
|
|
144
|
+
ver = resolved_version or v.version
|
|
145
|
+
|
|
146
|
+
if v.approval_status == ApprovalStatus.REVOKED.value:
|
|
147
|
+
raise ResourceVersionRevokedError("Model", f"{mid}:{ver}")
|
|
148
|
+
|
|
149
|
+
if v.approval_status != ApprovalStatus.APPROVED.value:
|
|
150
|
+
raise ModelNotApprovedError(mid, ver)
|
|
151
|
+
|
|
152
|
+
if resolved_task_type and resolved_task_type not in v.supported_task_types:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Model '{mid}' does not support task type '{resolved_task_type}'. Supported: {v.supported_task_types}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Validate hyperparameter constraints
|
|
158
|
+
schema_map = {item["name"]: item for item in v.hyperparameters_schema}
|
|
159
|
+
for param_name, param_val in resolved_hypers.items():
|
|
160
|
+
if param_name not in schema_map:
|
|
161
|
+
continue # Allow extra params or warn
|
|
162
|
+
constraint = schema_map[param_name]
|
|
163
|
+
# Range validation
|
|
164
|
+
if constraint.get("min_value") is not None and param_val < constraint["min_value"]:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Hyperparameter '{param_name}' value {param_val} < minimum {constraint['min_value']}"
|
|
167
|
+
)
|
|
168
|
+
if constraint.get("max_value") is not None and param_val > constraint["max_value"]:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Hyperparameter '{param_name}' value {param_val} > maximum {constraint['max_value']}"
|
|
171
|
+
)
|
|
172
|
+
if constraint.get("allowed_values") and param_val not in constraint["allowed_values"]:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Hyperparameter '{param_name}' value {param_val} not in allowed: {constraint['allowed_values']}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return v
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Project management application service."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from ml_mcp.domain.errors import InvalidInputError, ResourceNotFoundError
|
|
8
|
+
from ml_mcp.infrastructure.postgres.base import generate_uuid7
|
|
9
|
+
from ml_mcp.infrastructure.postgres.models import ProjectOrm
|
|
10
|
+
from ml_mcp.infrastructure.postgres.repositories.projects import ProjectRepository
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProjectService:
|
|
14
|
+
"""Service coordinating project creation, retrieval, and defaults."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
17
|
+
self.session = session
|
|
18
|
+
self.repo = ProjectRepository(session)
|
|
19
|
+
|
|
20
|
+
async def create_project(
|
|
21
|
+
self,
|
|
22
|
+
tenant_id: str,
|
|
23
|
+
name: str,
|
|
24
|
+
description: str = "",
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Create a new project under the specified tenant."""
|
|
27
|
+
clean_name = name.strip()
|
|
28
|
+
if not clean_name:
|
|
29
|
+
raise InvalidInputError("Project name cannot be empty.")
|
|
30
|
+
|
|
31
|
+
existing = await self.repo.get_project_by_name(tenant_id, clean_name)
|
|
32
|
+
if existing:
|
|
33
|
+
raise InvalidInputError(f"Project with name '{clean_name}' already exists.")
|
|
34
|
+
|
|
35
|
+
project = ProjectOrm(
|
|
36
|
+
id=generate_uuid7(),
|
|
37
|
+
tenant_id=tenant_id,
|
|
38
|
+
name=clean_name,
|
|
39
|
+
description=description.strip(),
|
|
40
|
+
)
|
|
41
|
+
saved = await self.repo.create_project(project)
|
|
42
|
+
return {
|
|
43
|
+
"project_id": saved.id,
|
|
44
|
+
"name": saved.name,
|
|
45
|
+
"description": saved.description,
|
|
46
|
+
"tenant_id": saved.tenant_id,
|
|
47
|
+
"created_at": saved.created_at.isoformat(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async def get_project(self, tenant_id: str, project_id: str) -> dict[str, Any]:
|
|
51
|
+
"""Retrieve project by ID or raise ResourceNotFoundError."""
|
|
52
|
+
project = await self.repo.get_project(tenant_id, project_id)
|
|
53
|
+
if not project:
|
|
54
|
+
raise ResourceNotFoundError("Project", project_id)
|
|
55
|
+
return {
|
|
56
|
+
"project_id": project.id,
|
|
57
|
+
"name": project.name,
|
|
58
|
+
"description": project.description,
|
|
59
|
+
"tenant_id": project.tenant_id,
|
|
60
|
+
"created_at": project.created_at.isoformat(),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async def list_projects(
|
|
64
|
+
self,
|
|
65
|
+
tenant_id: str,
|
|
66
|
+
limit: int = 50,
|
|
67
|
+
offset: int = 0,
|
|
68
|
+
) -> list[dict[str, Any]]:
|
|
69
|
+
"""List all projects belonging to the tenant."""
|
|
70
|
+
projects = await self.repo.list_projects(tenant_id, limit=limit, offset=offset)
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
"project_id": p.id,
|
|
74
|
+
"name": p.name,
|
|
75
|
+
"description": p.description,
|
|
76
|
+
"tenant_id": p.tenant_id,
|
|
77
|
+
"created_at": p.created_at.isoformat(),
|
|
78
|
+
}
|
|
79
|
+
for p in projects
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
async def ensure_default_project(
|
|
83
|
+
self, tenant_id: str = "default-tenant"
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Ensure the tenant and default-project exist, returning its details."""
|
|
86
|
+
default_proj = await self.repo.get_or_create_default_project(tenant_id)
|
|
87
|
+
return {
|
|
88
|
+
"project_id": default_proj.id,
|
|
89
|
+
"name": default_proj.name,
|
|
90
|
+
"description": default_proj.description,
|
|
91
|
+
"tenant_id": default_proj.tenant_id,
|
|
92
|
+
"created_at": default_proj.created_at.isoformat(),
|
|
93
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Centralized production configuration using Pydantic Settings."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from pydantic import Field, SecretStr, field_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
# Load local .env to prioritize ModelLab local services over host environment variables
|
|
11
|
+
load_dotenv(override=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AppSettings(BaseSettings):
|
|
16
|
+
"""Application level configuration."""
|
|
17
|
+
|
|
18
|
+
model_config = SettingsConfigDict(env_prefix="APP_", extra="ignore")
|
|
19
|
+
|
|
20
|
+
name: str = "ml-mcp"
|
|
21
|
+
env: Literal["development", "staging", "production", "testing"] = "development"
|
|
22
|
+
debug: bool = False
|
|
23
|
+
secret_key: SecretStr = Field(
|
|
24
|
+
default=SecretStr("insecure-dev-secret-key-change-in-production-min32bytes"),
|
|
25
|
+
description="Master cryptographic key for token signing or derivation",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class McpSettings(BaseSettings):
|
|
30
|
+
"""MCP protocol and transport settings."""
|
|
31
|
+
|
|
32
|
+
model_config = SettingsConfigDict(env_prefix="MCP_", extra="ignore")
|
|
33
|
+
|
|
34
|
+
server_name: str = "ModelLab-ML-MCP"
|
|
35
|
+
server_version: str = "0.1.0"
|
|
36
|
+
protocol_version: str = "2026-07-28"
|
|
37
|
+
host: str = "127.0.0.1"
|
|
38
|
+
port: int = 8000
|
|
39
|
+
streamable_http_path: str = "/mcp"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthSettings(BaseSettings):
|
|
43
|
+
"""Authentication and OIDC configuration."""
|
|
44
|
+
|
|
45
|
+
model_config = SettingsConfigDict(env_prefix="AUTH_", extra="ignore")
|
|
46
|
+
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
issuer: str = "https://auth.modellab.local"
|
|
49
|
+
audience: str = "modellab-mcp-api"
|
|
50
|
+
algorithms: list[str] = ["RS256", "HS256"]
|
|
51
|
+
secret_key: SecretStr = Field(
|
|
52
|
+
default=SecretStr("dev-jwt-secret-key-min-32-characters-long-production-change"),
|
|
53
|
+
description="Secret key for symmetric JWT algorithms like HS256",
|
|
54
|
+
)
|
|
55
|
+
public_key_pem: str | None = None
|
|
56
|
+
jwks_url: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DatabaseSettings(BaseSettings):
|
|
60
|
+
"""PostgreSQL database configuration."""
|
|
61
|
+
|
|
62
|
+
model_config = SettingsConfigDict(env_prefix="DATABASE_", extra="ignore")
|
|
63
|
+
|
|
64
|
+
url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/modellab"
|
|
65
|
+
pool_size: int = 10
|
|
66
|
+
max_overflow: int = 20
|
|
67
|
+
pool_timeout: float = 30.0
|
|
68
|
+
pool_recycle: int = 1800
|
|
69
|
+
echo: bool = False
|
|
70
|
+
|
|
71
|
+
@field_validator("url", mode="after")
|
|
72
|
+
@classmethod
|
|
73
|
+
def ensure_asyncpg_driver(cls, v: str) -> str:
|
|
74
|
+
if v.startswith("postgresql://"):
|
|
75
|
+
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RedisSettings(BaseSettings):
|
|
81
|
+
"""Redis / Valkey configuration for caching, rate limiting, and queues."""
|
|
82
|
+
|
|
83
|
+
model_config = SettingsConfigDict(env_prefix="REDIS_", extra="ignore")
|
|
84
|
+
|
|
85
|
+
url: str = "redis://localhost:6379/0"
|
|
86
|
+
max_connections: int = 20
|
|
87
|
+
task_queue_name: str = "ml_mcp:tasks:experiments"
|
|
88
|
+
retry_backoff_seconds: float = 2.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ObjectStoreSettings(BaseSettings):
|
|
92
|
+
"""S3-compatible object storage configuration."""
|
|
93
|
+
|
|
94
|
+
model_config = SettingsConfigDict(env_prefix="OBJECT_STORE_", extra="ignore")
|
|
95
|
+
|
|
96
|
+
endpoint_url: str | None = "http://localhost:9000"
|
|
97
|
+
access_key_id: SecretStr = Field(default=SecretStr("minioadmin"))
|
|
98
|
+
secret_access_key: SecretStr = Field(default=SecretStr("minioadmin"))
|
|
99
|
+
bucket_name: str = "modellab-artifacts"
|
|
100
|
+
region: str = "us-east-1"
|
|
101
|
+
presigned_ttl_seconds: int = 900
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class WorkerSettings(BaseSettings):
|
|
105
|
+
"""ML Worker execution policy and resources."""
|
|
106
|
+
|
|
107
|
+
model_config = SettingsConfigDict(env_prefix="WORKER_", extra="ignore")
|
|
108
|
+
|
|
109
|
+
in_process: bool = True
|
|
110
|
+
max_concurrent_experiments: int = 4
|
|
111
|
+
default_timeout_seconds: int = 1800
|
|
112
|
+
max_memory_mb: int = 4096
|
|
113
|
+
tmp_dir: str = "./tmp/worker"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RateLimitSettings(BaseSettings):
|
|
117
|
+
"""Layered rate limiting thresholds (requests per minute)."""
|
|
118
|
+
|
|
119
|
+
model_config = SettingsConfigDict(env_prefix="RATE_LIMIT_", extra="ignore")
|
|
120
|
+
|
|
121
|
+
read_per_minute: int = 120
|
|
122
|
+
metadata_per_minute: int = 60
|
|
123
|
+
experiment_per_minute: int = 10
|
|
124
|
+
analysis_per_minute: int = 20
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class OtelSettings(BaseSettings):
|
|
128
|
+
"""OpenTelemetry configuration."""
|
|
129
|
+
|
|
130
|
+
model_config = SettingsConfigDict(env_prefix="OTEL_", extra="ignore")
|
|
131
|
+
|
|
132
|
+
enabled: bool = False
|
|
133
|
+
service_name: str = "ml-mcp-service"
|
|
134
|
+
exporter_otlp_endpoint: str = "http://localhost:4317"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SecuritySettings(BaseSettings):
|
|
138
|
+
"""Input limits and security bounds."""
|
|
139
|
+
|
|
140
|
+
model_config = SettingsConfigDict(env_prefix="SECURITY_", extra="ignore")
|
|
141
|
+
|
|
142
|
+
max_request_body_bytes: int = 10 * 1024 * 1024 # 10 MB
|
|
143
|
+
max_dataset_upload_bytes: int = 100 * 1024 * 1024 # 100 MB
|
|
144
|
+
max_dataset_rows: int = 5_000_000
|
|
145
|
+
max_dataset_columns: int = 1_000
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Settings(BaseSettings):
|
|
149
|
+
"""Master aggregated settings."""
|
|
150
|
+
|
|
151
|
+
model_config = SettingsConfigDict(
|
|
152
|
+
env_file=".env",
|
|
153
|
+
env_file_encoding="utf-8",
|
|
154
|
+
extra="ignore",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
app: AppSettings = Field(default_factory=AppSettings)
|
|
158
|
+
mcp: McpSettings = Field(default_factory=McpSettings)
|
|
159
|
+
auth: AuthSettings = Field(default_factory=AuthSettings)
|
|
160
|
+
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
|
161
|
+
redis: RedisSettings = Field(default_factory=RedisSettings)
|
|
162
|
+
object_store: ObjectStoreSettings = Field(default_factory=ObjectStoreSettings)
|
|
163
|
+
worker: WorkerSettings = Field(default_factory=WorkerSettings)
|
|
164
|
+
rate_limit: RateLimitSettings = Field(default_factory=RateLimitSettings)
|
|
165
|
+
otel: OtelSettings = Field(default_factory=OtelSettings)
|
|
166
|
+
security: SecuritySettings = Field(default_factory=SecuritySettings)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def app_env(self) -> str:
|
|
170
|
+
return self.app.env
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@lru_cache(maxsize=1)
|
|
174
|
+
def get_settings() -> Settings:
|
|
175
|
+
"""Return cached singleton Settings instance."""
|
|
176
|
+
return Settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain layer package for ModelLab ML-MCP."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Domain errors package."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.domain.errors.base import (
|
|
4
|
+
ArtifactAccessDeniedError,
|
|
5
|
+
AuthenticationRequiredError,
|
|
6
|
+
AuthorizationDeniedError,
|
|
7
|
+
DatasetValidationError,
|
|
8
|
+
DependencyUnavailableError,
|
|
9
|
+
DomainError,
|
|
10
|
+
ExperimentAlreadyExistsError,
|
|
11
|
+
ExperimentNotCancellableError,
|
|
12
|
+
InvalidExperimentError,
|
|
13
|
+
InvalidInputError,
|
|
14
|
+
ModelNotApprovedError,
|
|
15
|
+
ResourceLimitExceededError,
|
|
16
|
+
ResourceNotFoundError,
|
|
17
|
+
ResourceVersionRevokedError,
|
|
18
|
+
TaskUnavailableError,
|
|
19
|
+
)
|
|
20
|
+
from ml_mcp.domain.errors.codes import ErrorCode
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ErrorCode",
|
|
24
|
+
"DomainError",
|
|
25
|
+
"AuthenticationRequiredError",
|
|
26
|
+
"AuthorizationDeniedError",
|
|
27
|
+
"ResourceNotFoundError",
|
|
28
|
+
"ResourceVersionRevokedError",
|
|
29
|
+
"InvalidExperimentError",
|
|
30
|
+
"InvalidInputError",
|
|
31
|
+
"DatasetValidationError",
|
|
32
|
+
"ModelNotApprovedError",
|
|
33
|
+
"ExperimentAlreadyExistsError",
|
|
34
|
+
"ExperimentNotCancellableError",
|
|
35
|
+
"TaskUnavailableError",
|
|
36
|
+
"ArtifactAccessDeniedError",
|
|
37
|
+
"ResourceLimitExceededError",
|
|
38
|
+
"DependencyUnavailableError",
|
|
39
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Domain exceptions with sanitized public messages and structured error codes."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ml_mcp.domain.errors.codes import ErrorCode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DomainError(Exception):
|
|
9
|
+
"""Base domain exception that never leaks internal sensitive details to external clients."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
code: ErrorCode,
|
|
14
|
+
message: str,
|
|
15
|
+
details: dict[str, Any] | None = None,
|
|
16
|
+
status_code: int = 400,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.details = details or {}
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
"""Return safe, machine-readable dictionary representation for API / MCP responses."""
|
|
26
|
+
payload: dict[str, Any] = {
|
|
27
|
+
"error_code": self.code.value,
|
|
28
|
+
"message": self.message,
|
|
29
|
+
}
|
|
30
|
+
if self.details:
|
|
31
|
+
payload["details"] = self.details
|
|
32
|
+
return payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationRequiredError(DomainError):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str = "Authentication required to perform this action",
|
|
39
|
+
details: dict[str, Any] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(ErrorCode.AUTHENTICATION_REQUIRED, message, details, status_code=401)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthorizationDeniedError(DomainError):
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
message: str = "Access denied for the requested resource",
|
|
48
|
+
details: dict[str, Any] | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(ErrorCode.AUTHORIZATION_DENIED, message, details, status_code=403)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResourceNotFoundError(DomainError):
|
|
54
|
+
def __init__(self, resource_type: str, resource_id: str) -> None:
|
|
55
|
+
super().__init__(
|
|
56
|
+
ErrorCode.RESOURCE_NOT_FOUND,
|
|
57
|
+
f"{resource_type} '{resource_id}' was not found",
|
|
58
|
+
details={"resource_type": resource_type, "resource_id": resource_id},
|
|
59
|
+
status_code=404,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ResourceVersionRevokedError(DomainError):
|
|
64
|
+
def __init__(self, resource_type: str, version: str) -> None:
|
|
65
|
+
super().__init__(
|
|
66
|
+
ErrorCode.RESOURCE_VERSION_REVOKED,
|
|
67
|
+
f"{resource_type} version '{version}' has been revoked",
|
|
68
|
+
details={"resource_type": resource_type, "version": version},
|
|
69
|
+
status_code=410,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InvalidExperimentError(DomainError):
|
|
74
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
75
|
+
super().__init__(ErrorCode.INVALID_EXPERIMENT, message, details, status_code=422)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DatasetValidationError(DomainError):
|
|
79
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
80
|
+
super().__init__(ErrorCode.DATASET_VALIDATION_FAILED, message, details, status_code=422)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ModelNotApprovedError(DomainError):
|
|
84
|
+
def __init__(self, model_id: str, version: str) -> None:
|
|
85
|
+
super().__init__(
|
|
86
|
+
ErrorCode.MODEL_NOT_APPROVED,
|
|
87
|
+
f"Model '{model_id}' version '{version}' is not approved for execution",
|
|
88
|
+
details={"model_id": model_id, "version": version},
|
|
89
|
+
status_code=403,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ExperimentAlreadyExistsError(DomainError):
|
|
94
|
+
def __init__(self, experiment_id: str) -> None:
|
|
95
|
+
super().__init__(
|
|
96
|
+
ErrorCode.EXPERIMENT_ALREADY_EXISTS,
|
|
97
|
+
f"Experiment '{experiment_id}' already exists",
|
|
98
|
+
details={"experiment_id": experiment_id},
|
|
99
|
+
status_code=409,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ExperimentNotCancellableError(DomainError):
|
|
104
|
+
def __init__(self, experiment_id: str, current_status: str) -> None:
|
|
105
|
+
super().__init__(
|
|
106
|
+
ErrorCode.EXPERIMENT_NOT_CANCELLABLE,
|
|
107
|
+
f"Experiment '{experiment_id}' in status '{current_status}' cannot be cancelled",
|
|
108
|
+
details={"experiment_id": experiment_id, "current_status": current_status},
|
|
109
|
+
status_code=409,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TaskUnavailableError(DomainError):
|
|
114
|
+
def __init__(self, message: str = "Execution task or worker is currently unavailable") -> None:
|
|
115
|
+
super().__init__(ErrorCode.TASK_UNAVAILABLE, message, status_code=503)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ArtifactAccessDeniedError(DomainError):
|
|
119
|
+
def __init__(self, artifact_id: str) -> None:
|
|
120
|
+
super().__init__(
|
|
121
|
+
ErrorCode.ARTIFACT_ACCESS_DENIED,
|
|
122
|
+
f"Access to artifact '{artifact_id}' is denied",
|
|
123
|
+
details={"artifact_id": artifact_id},
|
|
124
|
+
status_code=403,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ResourceLimitExceededError(DomainError):
|
|
129
|
+
def __init__(self, resource: str, limit: Any, current: Any) -> None:
|
|
130
|
+
super().__init__(
|
|
131
|
+
ErrorCode.RESOURCE_LIMIT_EXCEEDED,
|
|
132
|
+
f"Resource limit exceeded for '{resource}': limit is {limit}, requested/current is {current}",
|
|
133
|
+
details={"resource": resource, "limit": limit, "current": current},
|
|
134
|
+
status_code=429,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DependencyUnavailableError(DomainError):
|
|
139
|
+
def __init__(self, dependency: str) -> None:
|
|
140
|
+
super().__init__(
|
|
141
|
+
ErrorCode.DEPENDENCY_UNAVAILABLE,
|
|
142
|
+
f"Dependent service '{dependency}' is currently unavailable",
|
|
143
|
+
details={"dependency": dependency},
|
|
144
|
+
status_code=503,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class InvalidInputError(DomainError):
|
|
149
|
+
def __init__(self, message: str = "Invalid input provided", details: dict[str, Any] | None = None) -> None:
|
|
150
|
+
super().__init__(
|
|
151
|
+
ErrorCode.INVALID_INPUT,
|
|
152
|
+
message,
|
|
153
|
+
details=details,
|
|
154
|
+
status_code=400,
|
|
155
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Stable, machine-readable error codes for the ML-MCP domain."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ErrorCode(StrEnum):
|
|
7
|
+
"""Enumeration of standardized domain error codes."""
|
|
8
|
+
|
|
9
|
+
AUTHENTICATION_REQUIRED = "AUTHENTICATION_REQUIRED"
|
|
10
|
+
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
|
|
11
|
+
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
|
|
12
|
+
RESOURCE_VERSION_REVOKED = "RESOURCE_VERSION_REVOKED"
|
|
13
|
+
INVALID_EXPERIMENT = "INVALID_EXPERIMENT"
|
|
14
|
+
DATASET_VALIDATION_FAILED = "DATASET_VALIDATION_FAILED"
|
|
15
|
+
MODEL_NOT_APPROVED = "MODEL_NOT_APPROVED"
|
|
16
|
+
EXPERIMENT_ALREADY_EXISTS = "EXPERIMENT_ALREADY_EXISTS"
|
|
17
|
+
EXPERIMENT_NOT_CANCELLABLE = "EXPERIMENT_NOT_CANCELLABLE"
|
|
18
|
+
TASK_UNAVAILABLE = "TASK_UNAVAILABLE"
|
|
19
|
+
ARTIFACT_ACCESS_DENIED = "ARTIFACT_ACCESS_DENIED"
|
|
20
|
+
RESOURCE_LIMIT_EXCEEDED = "RESOURCE_LIMIT_EXCEEDED"
|
|
21
|
+
DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"
|
|
22
|
+
INVALID_INPUT = "INVALID_INPUT"
|
|
23
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|