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
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
@@ -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