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,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,5 @@
1
+ """Projects application package."""
2
+
3
+ from ml_mcp.application.projects.service import ProjectService
4
+
5
+ __all__ = ["ProjectService"]
@@ -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,5 @@
1
+ """Configuration module for ML-MCP server."""
2
+
3
+ from ml_mcp.config.settings import Settings, get_settings
4
+
5
+ __all__ = ["Settings", "get_settings"]
@@ -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"