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
ml_mcp/server/app.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""ModelLab FastMCP Server Factory.
|
|
2
|
+
|
|
3
|
+
Assembles all 21 ML experimentation control plane tools, resources, and prompts
|
|
4
|
+
into a production-grade FastMCP 4 server instance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from ml_mcp.mcp.prompts import error_diagnosis_prompt, experiment_design_prompt
|
|
16
|
+
from ml_mcp.mcp.resources import (
|
|
17
|
+
read_experiment_metrics_resource,
|
|
18
|
+
read_model_details_resource,
|
|
19
|
+
read_models_resource,
|
|
20
|
+
)
|
|
21
|
+
from ml_mcp.mcp.tools.analysis import (
|
|
22
|
+
handle_analyze_experiment,
|
|
23
|
+
handle_analyze_model_errors,
|
|
24
|
+
handle_check_experiment_validity,
|
|
25
|
+
)
|
|
26
|
+
from ml_mcp.mcp.tools.datasets import (
|
|
27
|
+
handle_inspect_dataset,
|
|
28
|
+
handle_register_dataset,
|
|
29
|
+
handle_validate_dataset,
|
|
30
|
+
)
|
|
31
|
+
from ml_mcp.mcp.tools.discovery import (
|
|
32
|
+
handle_get_dataset,
|
|
33
|
+
handle_get_model,
|
|
34
|
+
handle_list_dataset_versions,
|
|
35
|
+
handle_list_datasets,
|
|
36
|
+
handle_list_model_versions,
|
|
37
|
+
handle_list_models,
|
|
38
|
+
)
|
|
39
|
+
from ml_mcp.mcp.tools.experiments import (
|
|
40
|
+
handle_cancel_experiment,
|
|
41
|
+
handle_create_experiment,
|
|
42
|
+
handle_get_experiment,
|
|
43
|
+
handle_list_experiments,
|
|
44
|
+
)
|
|
45
|
+
from ml_mcp.mcp.tools.projects import (
|
|
46
|
+
handle_create_project,
|
|
47
|
+
handle_list_projects,
|
|
48
|
+
)
|
|
49
|
+
from ml_mcp.mcp.tools.results import (
|
|
50
|
+
handle_compare_experiments,
|
|
51
|
+
handle_get_experiment_metrics,
|
|
52
|
+
handle_get_experiment_predictions,
|
|
53
|
+
handle_list_experiment_artifacts,
|
|
54
|
+
handle_read_experiment_artifact,
|
|
55
|
+
)
|
|
56
|
+
from ml_mcp.server.context import get_current_principal
|
|
57
|
+
from ml_mcp.server.middleware import get_security_pipeline
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _execute_secured[T](
|
|
63
|
+
tool_name: str,
|
|
64
|
+
fn: Callable[..., Awaitable[T]],
|
|
65
|
+
*args: Any,
|
|
66
|
+
**kwargs: Any,
|
|
67
|
+
) -> T:
|
|
68
|
+
"""Execute a tool handler with rate limiting, scope checks, and audit logging."""
|
|
69
|
+
principal = get_current_principal()
|
|
70
|
+
pipeline = get_security_pipeline()
|
|
71
|
+
await pipeline.pre_tool_call(tool_name, principal)
|
|
72
|
+
try:
|
|
73
|
+
res = await fn(*args, principal=principal, **kwargs)
|
|
74
|
+
await pipeline.post_tool_call(tool_name, principal, res, success=True)
|
|
75
|
+
return res
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
await pipeline.post_tool_call(
|
|
78
|
+
tool_name,
|
|
79
|
+
principal,
|
|
80
|
+
None,
|
|
81
|
+
success=False,
|
|
82
|
+
error_code=type(exc).__name__,
|
|
83
|
+
)
|
|
84
|
+
from sqlalchemy.exc import DBAPIError, IntegrityError
|
|
85
|
+
|
|
86
|
+
from ml_mcp.domain.errors import InvalidInputError, ResourceNotFoundError
|
|
87
|
+
|
|
88
|
+
if isinstance(exc, IntegrityError):
|
|
89
|
+
orig_msg = str(getattr(exc, "orig", exc)).lower()
|
|
90
|
+
if "foreign key constraint" in orig_msg:
|
|
91
|
+
if "project_id" in orig_msg or "projects" in orig_msg:
|
|
92
|
+
raise ResourceNotFoundError("Project", "specified in request") from None
|
|
93
|
+
if "dataset_id" in orig_msg or "dataset_version_id" in orig_msg:
|
|
94
|
+
raise ResourceNotFoundError("Dataset", "specified in request") from None
|
|
95
|
+
if "model_id" in orig_msg or "model_version_id" in orig_msg:
|
|
96
|
+
raise ResourceNotFoundError("Model", "specified in request") from None
|
|
97
|
+
raise ResourceNotFoundError("Referenced resource", "specified in request") from None
|
|
98
|
+
if "unique constraint" in orig_msg:
|
|
99
|
+
raise InvalidInputError("A resource with this name or identifier already exists.") from None
|
|
100
|
+
raise InvalidInputError("Database constraint violation.") from None
|
|
101
|
+
if isinstance(exc, DBAPIError):
|
|
102
|
+
raise InvalidInputError("Database operation failed due to invalid input.") from None
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@contextlib.asynccontextmanager
|
|
107
|
+
async def app_lifespan(server: FastMCP) -> AsyncIterator[None]:
|
|
108
|
+
"""Lifespan context manager: seed model catalog, ensure default project, and start in-process worker if configured."""
|
|
109
|
+
logger.info("Initializing ModelLab server lifespan...")
|
|
110
|
+
worker_task: asyncio.Task[None] | None = None
|
|
111
|
+
daemon: Any = None
|
|
112
|
+
try:
|
|
113
|
+
from ml_mcp.application.models.service import ModelService
|
|
114
|
+
from ml_mcp.application.projects.service import ProjectService
|
|
115
|
+
from ml_mcp.config import get_settings
|
|
116
|
+
from ml_mcp.infrastructure.postgres.session import get_db_manager
|
|
117
|
+
|
|
118
|
+
async with get_db_manager().session() as sess:
|
|
119
|
+
service = ModelService(sess)
|
|
120
|
+
await service.seed_catalog()
|
|
121
|
+
proj_service = ProjectService(sess)
|
|
122
|
+
await proj_service.ensure_default_project("default-tenant")
|
|
123
|
+
logger.info("Model catalog and default project initialized and verified.")
|
|
124
|
+
|
|
125
|
+
settings = get_settings()
|
|
126
|
+
if settings.app.env != "testing" and getattr(settings.worker, "in_process", True):
|
|
127
|
+
from ml_mcp.workers.daemon import WorkerDaemon
|
|
128
|
+
|
|
129
|
+
daemon = WorkerDaemon(drain_outbox=True)
|
|
130
|
+
worker_task = asyncio.create_task(daemon.run())
|
|
131
|
+
logger.info("In-process ML worker daemon started successfully.")
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning("Startup initialization warning: %s", exc)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
yield
|
|
137
|
+
finally:
|
|
138
|
+
logger.info("Shutting down ModelLab server lifespan...")
|
|
139
|
+
if daemon is not None and worker_task is not None:
|
|
140
|
+
daemon.stop()
|
|
141
|
+
worker_task.cancel()
|
|
142
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
143
|
+
await asyncio.wait_for(worker_task, timeout=5.0)
|
|
144
|
+
logger.info("In-process ML worker daemon stopped.")
|
|
145
|
+
logger.info("ModelLab server lifespan shutdown complete.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def create_server(name: str = "ModelLab") -> FastMCP:
|
|
149
|
+
"""Construct and configure the FastMCP server instance with tools, resources, and prompts."""
|
|
150
|
+
server = FastMCP(
|
|
151
|
+
name=name,
|
|
152
|
+
instructions=(
|
|
153
|
+
"ModelLab ML Experimentation Control Plane. "
|
|
154
|
+
"Enables AI agents to discover approved ML architectures, validate tabular datasets, "
|
|
155
|
+
"submit reproducible training experiments, retrieve predictions/metrics, and perform "
|
|
156
|
+
"deep diagnostic analysis."
|
|
157
|
+
),
|
|
158
|
+
lifespan=app_lifespan,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# -------------------------------------------------------------------------
|
|
162
|
+
# 0. Project Management Tools
|
|
163
|
+
# -------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
@server.tool(
|
|
166
|
+
name="create_project",
|
|
167
|
+
description="Create a new ML experimentation project for organizing datasets and experiments.",
|
|
168
|
+
)
|
|
169
|
+
async def create_project(
|
|
170
|
+
name: str,
|
|
171
|
+
description: str = "",
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Create a new project."""
|
|
174
|
+
return await _execute_secured(
|
|
175
|
+
"create_project",
|
|
176
|
+
handle_create_project,
|
|
177
|
+
name=name,
|
|
178
|
+
description=description,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@server.tool(
|
|
182
|
+
name="list_projects",
|
|
183
|
+
description="List all ML experimentation projects for the current tenant.",
|
|
184
|
+
)
|
|
185
|
+
async def list_projects(
|
|
186
|
+
limit: int = 50,
|
|
187
|
+
offset: int = 0,
|
|
188
|
+
) -> list[dict[str, Any]]:
|
|
189
|
+
"""List all projects."""
|
|
190
|
+
return await _execute_secured(
|
|
191
|
+
"list_projects",
|
|
192
|
+
handle_list_projects,
|
|
193
|
+
limit=limit,
|
|
194
|
+
offset=offset,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# -------------------------------------------------------------------------
|
|
198
|
+
# 1. Discovery Tools
|
|
199
|
+
# -------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
@server.tool(
|
|
202
|
+
name="list_models",
|
|
203
|
+
description="List all approved tabular ML models in the catalog.",
|
|
204
|
+
)
|
|
205
|
+
async def list_models() -> list[dict[str, Any]]:
|
|
206
|
+
return await _execute_secured("list_models", handle_list_models)
|
|
207
|
+
|
|
208
|
+
@server.tool(
|
|
209
|
+
name="get_model",
|
|
210
|
+
description="Get detailed specification, bounds, and hyperparameter schema for a model.",
|
|
211
|
+
)
|
|
212
|
+
async def get_model(model_id: str) -> dict[str, Any]:
|
|
213
|
+
return await _execute_secured("get_model", handle_get_model, model_id)
|
|
214
|
+
|
|
215
|
+
@server.tool(
|
|
216
|
+
name="list_model_versions",
|
|
217
|
+
description="List all registered versions for a specific model family.",
|
|
218
|
+
)
|
|
219
|
+
async def list_model_versions(model_id: str) -> list[dict[str, Any]]:
|
|
220
|
+
return await _execute_secured("list_model_versions", handle_list_model_versions, model_id)
|
|
221
|
+
|
|
222
|
+
@server.tool(
|
|
223
|
+
name="list_datasets",
|
|
224
|
+
description="List datasets available for the caller's tenant and project.",
|
|
225
|
+
)
|
|
226
|
+
async def list_datasets(project_id: str | None = None) -> list[dict[str, Any]]:
|
|
227
|
+
return await _execute_secured("list_datasets", handle_list_datasets, project_id=project_id)
|
|
228
|
+
|
|
229
|
+
@server.tool(
|
|
230
|
+
name="get_dataset",
|
|
231
|
+
description="Get metadata, schema, and version lineage for a specific dataset.",
|
|
232
|
+
)
|
|
233
|
+
async def get_dataset(dataset_id: str) -> dict[str, Any]:
|
|
234
|
+
return await _execute_secured("get_dataset", handle_get_dataset, dataset_id)
|
|
235
|
+
|
|
236
|
+
@server.tool(
|
|
237
|
+
name="list_dataset_versions",
|
|
238
|
+
description="List immutable versions for a specific dataset.",
|
|
239
|
+
)
|
|
240
|
+
async def list_dataset_versions(dataset_id: str) -> list[dict[str, Any]]:
|
|
241
|
+
return await _execute_secured(
|
|
242
|
+
"list_dataset_versions", handle_list_dataset_versions, dataset_id
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# -------------------------------------------------------------------------
|
|
246
|
+
# 2. Dataset Management & Validation Tools
|
|
247
|
+
# -------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
@server.tool(
|
|
250
|
+
name="register_dataset",
|
|
251
|
+
description="Register a new tabular dataset version with validation and SHA-256 hash.",
|
|
252
|
+
)
|
|
253
|
+
async def register_dataset(
|
|
254
|
+
project_id: str,
|
|
255
|
+
name: str,
|
|
256
|
+
description: str,
|
|
257
|
+
format: str,
|
|
258
|
+
data_base64: str,
|
|
259
|
+
version: str = "1.0",
|
|
260
|
+
) -> dict[str, Any]:
|
|
261
|
+
return await _execute_secured(
|
|
262
|
+
"register_dataset",
|
|
263
|
+
handle_register_dataset,
|
|
264
|
+
project_id=project_id,
|
|
265
|
+
name=name,
|
|
266
|
+
description=description,
|
|
267
|
+
format=format,
|
|
268
|
+
data_base64=data_base64,
|
|
269
|
+
version=version,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@server.tool(
|
|
273
|
+
name="validate_dataset",
|
|
274
|
+
description="Validate raw dataset bytes against tabular constraints without persisting.",
|
|
275
|
+
)
|
|
276
|
+
async def validate_dataset(format: str, data_base64: str) -> dict[str, Any]:
|
|
277
|
+
return await _execute_secured(
|
|
278
|
+
"validate_dataset",
|
|
279
|
+
handle_validate_dataset,
|
|
280
|
+
format=format,
|
|
281
|
+
data_base64=data_base64,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@server.tool(
|
|
285
|
+
name="inspect_dataset",
|
|
286
|
+
description="Inspect dataset schema, data types, null counts, and preview rows.",
|
|
287
|
+
)
|
|
288
|
+
async def inspect_dataset(
|
|
289
|
+
dataset_id: str,
|
|
290
|
+
version: str = "1.0",
|
|
291
|
+
sample_rows: int = 5,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
return await _execute_secured(
|
|
294
|
+
"inspect_dataset",
|
|
295
|
+
handle_inspect_dataset,
|
|
296
|
+
dataset_id=dataset_id,
|
|
297
|
+
version=version,
|
|
298
|
+
sample_rows=sample_rows,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# -------------------------------------------------------------------------
|
|
302
|
+
# 3. Experimentation Tools
|
|
303
|
+
# -------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
@server.tool(
|
|
306
|
+
name="create_experiment",
|
|
307
|
+
description="Create and queue an ML experiment for asynchronous training execution.",
|
|
308
|
+
)
|
|
309
|
+
async def create_experiment(
|
|
310
|
+
project_id: str,
|
|
311
|
+
dataset_version_id: str,
|
|
312
|
+
model_version_id: str,
|
|
313
|
+
task_type: str,
|
|
314
|
+
target_column: str,
|
|
315
|
+
feature_columns: list[str] | None = None,
|
|
316
|
+
primary_metric: str = "roc_auc",
|
|
317
|
+
additional_metrics: list[str] | None = None,
|
|
318
|
+
hyperparameters: dict[str, Any] | None = None,
|
|
319
|
+
random_seed: int = 42,
|
|
320
|
+
idempotency_key: str | None = None,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
return await _execute_secured(
|
|
323
|
+
"create_experiment",
|
|
324
|
+
handle_create_experiment,
|
|
325
|
+
project_id=project_id,
|
|
326
|
+
dataset_version_id=dataset_version_id,
|
|
327
|
+
model_version_id=model_version_id,
|
|
328
|
+
task_type=task_type,
|
|
329
|
+
target_column=target_column,
|
|
330
|
+
feature_columns=feature_columns,
|
|
331
|
+
primary_metric=primary_metric,
|
|
332
|
+
additional_metrics=additional_metrics,
|
|
333
|
+
hyperparameters=hyperparameters,
|
|
334
|
+
random_seed=random_seed,
|
|
335
|
+
idempotency_key=idempotency_key,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@server.tool(
|
|
339
|
+
name="get_experiment",
|
|
340
|
+
description="Get current status, execution logs, timing, and error details (if failed) of an experiment.",
|
|
341
|
+
)
|
|
342
|
+
async def get_experiment(experiment_id: str) -> dict[str, Any]:
|
|
343
|
+
return await _execute_secured("get_experiment", handle_get_experiment, experiment_id)
|
|
344
|
+
|
|
345
|
+
@server.tool(
|
|
346
|
+
name="cancel_experiment",
|
|
347
|
+
description="Request graceful cancellation of an active or queued experiment.",
|
|
348
|
+
)
|
|
349
|
+
async def cancel_experiment(experiment_id: str) -> dict[str, Any]:
|
|
350
|
+
return await _execute_secured("cancel_experiment", handle_cancel_experiment, experiment_id)
|
|
351
|
+
|
|
352
|
+
@server.tool(
|
|
353
|
+
name="list_experiments",
|
|
354
|
+
description="List experiments within a project with optional status filter.",
|
|
355
|
+
)
|
|
356
|
+
async def list_experiments(
|
|
357
|
+
project_id: str,
|
|
358
|
+
status: str | None = None,
|
|
359
|
+
limit: int = 50,
|
|
360
|
+
offset: int = 0,
|
|
361
|
+
) -> list[dict[str, Any]]:
|
|
362
|
+
return await _execute_secured(
|
|
363
|
+
"list_experiments",
|
|
364
|
+
handle_list_experiments,
|
|
365
|
+
project_id=project_id,
|
|
366
|
+
status=status,
|
|
367
|
+
limit=limit,
|
|
368
|
+
offset=offset,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# -------------------------------------------------------------------------
|
|
372
|
+
# 4. Results & Artifacts Tools
|
|
373
|
+
# -------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
@server.tool(
|
|
376
|
+
name="get_experiment_metrics",
|
|
377
|
+
description="Retrieve all computed evaluation metrics across train/validation splits.",
|
|
378
|
+
)
|
|
379
|
+
async def get_experiment_metrics(experiment_id: str) -> dict[str, Any]:
|
|
380
|
+
return await _execute_secured(
|
|
381
|
+
"get_experiment_metrics", handle_get_experiment_metrics, experiment_id
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
@server.tool(
|
|
385
|
+
name="get_experiment_predictions",
|
|
386
|
+
description="Retrieve sample predictions with ground truth, predicted values, and probabilities.",
|
|
387
|
+
)
|
|
388
|
+
async def get_experiment_predictions(experiment_id: str, limit: int = 100) -> dict[str, Any]:
|
|
389
|
+
return await _execute_secured(
|
|
390
|
+
"get_experiment_predictions",
|
|
391
|
+
handle_get_experiment_predictions,
|
|
392
|
+
experiment_id=experiment_id,
|
|
393
|
+
limit=limit,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@server.tool(
|
|
397
|
+
name="list_experiment_artifacts",
|
|
398
|
+
description="List all persisted artifacts (model weights, preprocessor, metrics) for an experiment.",
|
|
399
|
+
)
|
|
400
|
+
async def list_experiment_artifacts(experiment_id: str) -> list[dict[str, Any]]:
|
|
401
|
+
return await _execute_secured(
|
|
402
|
+
"list_experiment_artifacts", handle_list_experiment_artifacts, experiment_id
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
@server.tool(
|
|
406
|
+
name="read_experiment_artifact",
|
|
407
|
+
description="Get artifact metadata and presigned download URL.",
|
|
408
|
+
)
|
|
409
|
+
async def read_experiment_artifact(artifact_id: str) -> dict[str, Any]:
|
|
410
|
+
return await _execute_secured(
|
|
411
|
+
"read_experiment_artifact", handle_read_experiment_artifact, artifact_id
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
@server.tool(
|
|
415
|
+
name="compare_experiments",
|
|
416
|
+
description="Compare hyperparameters and performance metrics across multiple experiments.",
|
|
417
|
+
)
|
|
418
|
+
async def compare_experiments(experiment_ids: list[str]) -> dict[str, Any]:
|
|
419
|
+
return await _execute_secured(
|
|
420
|
+
"compare_experiments", handle_compare_experiments, experiment_ids
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# -------------------------------------------------------------------------
|
|
424
|
+
# 5. Diagnostic Analysis Tools
|
|
425
|
+
# -------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
@server.tool(
|
|
428
|
+
name="analyze_experiment",
|
|
429
|
+
description="Run automated diagnostic checks for overfitting, data leakage, and training health.",
|
|
430
|
+
)
|
|
431
|
+
async def analyze_experiment(experiment_id: str) -> dict[str, Any]:
|
|
432
|
+
return await _execute_secured(
|
|
433
|
+
"analyze_experiment", handle_analyze_experiment, experiment_id
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
@server.tool(
|
|
437
|
+
name="analyze_model_errors",
|
|
438
|
+
description="Perform deep error slice analysis (residuals, false positives/negatives, error patterns).",
|
|
439
|
+
)
|
|
440
|
+
async def analyze_model_errors(experiment_id: str) -> dict[str, Any]:
|
|
441
|
+
return await _execute_secured(
|
|
442
|
+
"analyze_model_errors", handle_analyze_model_errors, experiment_id
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
@server.tool(
|
|
446
|
+
name="check_experiment_validity",
|
|
447
|
+
description="Perform automated verification of experiment reproducibility, hashes, and validity.",
|
|
448
|
+
)
|
|
449
|
+
async def check_experiment_validity(experiment_id: str) -> dict[str, Any]:
|
|
450
|
+
return await _execute_secured(
|
|
451
|
+
"check_experiment_validity", handle_check_experiment_validity, experiment_id
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# -------------------------------------------------------------------------
|
|
455
|
+
# MCP Resources
|
|
456
|
+
# -------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
@server.resource("modellab://models")
|
|
459
|
+
async def res_models() -> str:
|
|
460
|
+
"""Read full catalog of approved models."""
|
|
461
|
+
return await read_models_resource()
|
|
462
|
+
|
|
463
|
+
@server.resource("modellab://models/{model_id}")
|
|
464
|
+
async def res_model_details(model_id: str) -> str:
|
|
465
|
+
"""Read detailed specification of a model."""
|
|
466
|
+
return await read_model_details_resource(model_id)
|
|
467
|
+
|
|
468
|
+
@server.resource("modellab://experiments/{experiment_id}/metrics")
|
|
469
|
+
async def res_experiment_metrics(experiment_id: str) -> str:
|
|
470
|
+
"""Read metrics for an experiment."""
|
|
471
|
+
principal = get_current_principal()
|
|
472
|
+
return await read_experiment_metrics_resource(experiment_id, tenant_id=principal.tenant_id)
|
|
473
|
+
|
|
474
|
+
# -------------------------------------------------------------------------
|
|
475
|
+
# MCP Prompts
|
|
476
|
+
# -------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
@server.prompt("experiment_design")
|
|
479
|
+
def prompt_experiment_design(dataset_description: str, task_type: str) -> str:
|
|
480
|
+
"""Prompt guiding agents to design high-yield ML experiments."""
|
|
481
|
+
return experiment_design_prompt(dataset_description, task_type)
|
|
482
|
+
|
|
483
|
+
@server.prompt("error_diagnosis")
|
|
484
|
+
def prompt_error_diagnosis(experiment_id: str, problem_summary: str) -> str:
|
|
485
|
+
"""Prompt assisting agents in diagnosing training regressions or anomalies."""
|
|
486
|
+
return error_diagnosis_prompt(experiment_id, problem_summary)
|
|
487
|
+
|
|
488
|
+
return server
|
ml_mcp/server/auth.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Authentication and JWT validation adhering to OAuth 2.1 / OIDC standards."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
from jwt.exceptions import InvalidTokenError, PyJWTError
|
|
9
|
+
|
|
10
|
+
from ml_mcp.config import Settings, get_settings
|
|
11
|
+
from ml_mcp.domain.errors import AuthenticationRequiredError
|
|
12
|
+
from ml_mcp.domain.policies import Principal, Role
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenValidator:
|
|
18
|
+
"""Validates incoming OAuth 2.1 / OIDC Bearer JWTs."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
21
|
+
self.settings = settings or get_settings()
|
|
22
|
+
|
|
23
|
+
def validate_token(self, token: str) -> Principal:
|
|
24
|
+
"""Validate signature, claims, and issuer bindings on a JWT string.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
AuthenticationRequiredError: on expired, malformed, or unauthorized tokens.
|
|
28
|
+
"""
|
|
29
|
+
if not token:
|
|
30
|
+
raise AuthenticationRequiredError("Missing bearer token")
|
|
31
|
+
|
|
32
|
+
# 1. Inspect unverified header to guard against algorithm confusion/none attacks
|
|
33
|
+
try:
|
|
34
|
+
unverified_header = jwt.get_unverified_header(token)
|
|
35
|
+
except PyJWTError as exc:
|
|
36
|
+
raise AuthenticationRequiredError("Malformed JWT header") from exc
|
|
37
|
+
|
|
38
|
+
alg = unverified_header.get("alg")
|
|
39
|
+
expected_alg = (
|
|
40
|
+
"RS256"
|
|
41
|
+
if self.settings.auth.public_key_pem
|
|
42
|
+
else (
|
|
43
|
+
self.settings.auth.algorithms[0]
|
|
44
|
+
if len(self.settings.auth.algorithms) == 1
|
|
45
|
+
else "HS256"
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
if not alg or alg.lower() == "none" or alg != expected_alg:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"Rejected token with algorithm '%s', expected pinned algorithm '%s'",
|
|
51
|
+
alg,
|
|
52
|
+
expected_alg,
|
|
53
|
+
)
|
|
54
|
+
raise AuthenticationRequiredError(f"Token algorithm '{alg}' is not permitted")
|
|
55
|
+
|
|
56
|
+
# 2. Select verification key
|
|
57
|
+
key: str = (
|
|
58
|
+
self.settings.auth.public_key_pem
|
|
59
|
+
if expected_alg.startswith("RS") and self.settings.auth.public_key_pem
|
|
60
|
+
else self.settings.auth.secret_key.get_secret_value()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 3. Verify signature and claims
|
|
64
|
+
try:
|
|
65
|
+
payload: dict[str, Any] = jwt.decode(
|
|
66
|
+
token,
|
|
67
|
+
key=key,
|
|
68
|
+
algorithms=[expected_alg],
|
|
69
|
+
issuer=self.settings.auth.issuer,
|
|
70
|
+
audience=self.settings.auth.audience,
|
|
71
|
+
options={
|
|
72
|
+
"require": ["exp", "iss", "aud", "sub"],
|
|
73
|
+
"verify_exp": True,
|
|
74
|
+
"verify_iss": True,
|
|
75
|
+
"verify_aud": True,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
except jwt.ExpiredSignatureError as exc:
|
|
79
|
+
raise AuthenticationRequiredError("Bearer token has expired") from exc
|
|
80
|
+
except jwt.InvalidIssuerError as exc:
|
|
81
|
+
raise AuthenticationRequiredError("Invalid token issuer") from exc
|
|
82
|
+
except jwt.InvalidAudienceError as exc:
|
|
83
|
+
raise AuthenticationRequiredError("Invalid token audience") from exc
|
|
84
|
+
except InvalidTokenError as exc:
|
|
85
|
+
raise AuthenticationRequiredError(f"Invalid authentication token: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
# 4. Map claims to Principal
|
|
88
|
+
principal_id = payload["sub"]
|
|
89
|
+
tenant_id = payload.get("tenant_id") or payload.get("tid", "default-tenant")
|
|
90
|
+
project_ids = payload.get("project_ids") or payload.get("projects", [])
|
|
91
|
+
role_str = payload.get("role", "viewer").lower()
|
|
92
|
+
role = Role(role_str) if role_str in Role else Role.VIEWER
|
|
93
|
+
|
|
94
|
+
# Scopes can be space-delimited string or list
|
|
95
|
+
raw_scopes = payload.get("scope") or payload.get("scopes", "")
|
|
96
|
+
if isinstance(raw_scopes, str):
|
|
97
|
+
scopes = set(raw_scopes.split()) if raw_scopes else set()
|
|
98
|
+
else:
|
|
99
|
+
scopes = set(raw_scopes)
|
|
100
|
+
|
|
101
|
+
return Principal(
|
|
102
|
+
principal_id=principal_id,
|
|
103
|
+
tenant_id=tenant_id,
|
|
104
|
+
project_ids=project_ids,
|
|
105
|
+
role=role,
|
|
106
|
+
scopes=scopes,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def create_access_token(
|
|
110
|
+
self,
|
|
111
|
+
principal_id: str,
|
|
112
|
+
tenant_id: str,
|
|
113
|
+
role: Role = Role.RESEARCHER,
|
|
114
|
+
scopes: list[str] | None = None,
|
|
115
|
+
project_ids: list[str] | None = None,
|
|
116
|
+
expires_in_minutes: int = 60,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Helper to generate signed JWT for development, testing, and internal callers."""
|
|
119
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
120
|
+
payload = {
|
|
121
|
+
"iss": self.settings.auth.issuer,
|
|
122
|
+
"aud": self.settings.auth.audience,
|
|
123
|
+
"sub": principal_id,
|
|
124
|
+
"tenant_id": tenant_id,
|
|
125
|
+
"role": role.value,
|
|
126
|
+
"scope": " ".join(scopes or []),
|
|
127
|
+
"project_ids": project_ids or [],
|
|
128
|
+
"iat": now,
|
|
129
|
+
"nbf": now,
|
|
130
|
+
"exp": now + datetime.timedelta(minutes=expires_in_minutes),
|
|
131
|
+
}
|
|
132
|
+
return jwt.encode(
|
|
133
|
+
payload,
|
|
134
|
+
key=self.settings.auth.secret_key.get_secret_value(),
|
|
135
|
+
algorithm="HS256",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_token_validator: TokenValidator | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_token_validator() -> TokenValidator:
|
|
143
|
+
global _token_validator
|
|
144
|
+
if _token_validator is None:
|
|
145
|
+
_token_validator = TokenValidator()
|
|
146
|
+
return _token_validator
|
ml_mcp/server/context.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Execution context and principal resolution for MCP tool invocations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
|
|
6
|
+
from ml_mcp.domain.errors import AuthenticationRequiredError
|
|
7
|
+
from ml_mcp.domain.policies import Principal
|
|
8
|
+
from ml_mcp.server.middleware import LOCAL_STDIO_PRINCIPAL
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_current_principal_var: ContextVar[Principal | None] = ContextVar(
|
|
13
|
+
"current_principal_var", default=None
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_current_principal(principal: Principal | None) -> None:
|
|
18
|
+
"""Set the active principal in context."""
|
|
19
|
+
_current_principal_var.set(principal)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_current_principal() -> Principal:
|
|
23
|
+
"""Resolve active principal from context, HTTP request headers, or default local boundary.
|
|
24
|
+
|
|
25
|
+
Resolution order:
|
|
26
|
+
1. Explicit Principal set in ContextVar (e.g. from middleware, worker, or test context).
|
|
27
|
+
2. HTTP request Authorization header (if running within an active HTTP request).
|
|
28
|
+
3. Default LOCAL_STDIO_PRINCIPAL (if running via STDIO transport or local CLI).
|
|
29
|
+
"""
|
|
30
|
+
p = _current_principal_var.get()
|
|
31
|
+
if p is not None:
|
|
32
|
+
return p
|
|
33
|
+
|
|
34
|
+
# Check for active HTTP request in FastMCP context
|
|
35
|
+
try:
|
|
36
|
+
from fastmcp.server.dependencies import get_http_request
|
|
37
|
+
|
|
38
|
+
req = get_http_request()
|
|
39
|
+
except RuntimeError:
|
|
40
|
+
req = None
|
|
41
|
+
|
|
42
|
+
if req is not None:
|
|
43
|
+
auth_header = req.headers.get("Authorization") or req.headers.get("authorization")
|
|
44
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
45
|
+
from ml_mcp.config import get_settings
|
|
46
|
+
|
|
47
|
+
if not get_settings().auth.enabled:
|
|
48
|
+
return LOCAL_STDIO_PRINCIPAL
|
|
49
|
+
raise AuthenticationRequiredError(
|
|
50
|
+
"Missing or invalid Bearer token in Authorization header"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
token = auth_header.split(" ", 1)[1].strip()
|
|
54
|
+
from ml_mcp.server.auth import get_token_validator
|
|
55
|
+
|
|
56
|
+
validator = get_token_validator()
|
|
57
|
+
return validator.validate_token(token)
|
|
58
|
+
|
|
59
|
+
return LOCAL_STDIO_PRINCIPAL
|