mlspace-plugin 0.2.1__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 (53) hide show
  1. mlspace_mcp/__init__.py +3 -0
  2. mlspace_mcp/__main__.py +125 -0
  3. mlspace_mcp/_status.py +18 -0
  4. mlspace_mcp/auth.py +109 -0
  5. mlspace_mcp/bodyspec.py +184 -0
  6. mlspace_mcp/client.py +170 -0
  7. mlspace_mcp/client_setup.py +235 -0
  8. mlspace_mcp/config.py +283 -0
  9. mlspace_mcp/errors.py +241 -0
  10. mlspace_mcp/formatting.py +502 -0
  11. mlspace_mcp/init_cli.py +356 -0
  12. mlspace_mcp/job_hooks.py +242 -0
  13. mlspace_mcp/jsonc.py +90 -0
  14. mlspace_mcp/overview.py +421 -0
  15. mlspace_mcp/registry.py +632 -0
  16. mlspace_mcp/resource_refs.py +188 -0
  17. mlspace_mcp/server.py +190 -0
  18. mlspace_mcp/setup_ui.py +103 -0
  19. mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
  20. mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
  21. mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
  22. mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
  23. mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
  24. mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
  25. mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
  26. mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
  27. mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
  28. mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
  29. mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
  30. mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
  31. mlspace_mcp/spec/body_schemas.json +7506 -0
  32. mlspace_mcp/spec/openapi.json +28347 -0
  33. mlspace_mcp/tls.py +46 -0
  34. mlspace_mcp/tools/__init__.py +1 -0
  35. mlspace_mcp/tools/allocations.py +92 -0
  36. mlspace_mcp/tools/async_inference.py +77 -0
  37. mlspace_mcp/tools/build_image.py +76 -0
  38. mlspace_mcp/tools/dalle.py +52 -0
  39. mlspace_mcp/tools/data_transfer.py +260 -0
  40. mlspace_mcp/tools/docker_registry.py +156 -0
  41. mlspace_mcp/tools/inference.py +88 -0
  42. mlspace_mcp/tools/jobs.py +155 -0
  43. mlspace_mcp/tools/notebooks.py +174 -0
  44. mlspace_mcp/tools/queues.py +202 -0
  45. mlspace_mcp/tools/resources.py +86 -0
  46. mlspace_mcp/tools/tensorboards.py +112 -0
  47. mlspace_mcp/tools/workspaces.py +92 -0
  48. mlspace_mcp/workspace_context.py +107 -0
  49. mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
  50. mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
  51. mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
  52. mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
  53. mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,632 @@
1
+ """Declarative umbrella-tool model + the generic dispatcher.
2
+
3
+ Each domain module declares pure DATA: a ``DomainTool`` whose ``actions`` map
4
+ (action name → :class:`Op`) is the single source of truth for action→endpoint.
5
+ ALL mechanics — readonly enum-stripping, path templating, repeated-query
6
+ encoding, body attachment, required-param validation, confirm gating,
7
+ pagination clamp, response formatting, annotations — live here, once. Domain
8
+ modules cannot drift on mechanics because they contain none.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import inspect
14
+ import logging
15
+ import re
16
+ import urllib.parse
17
+ from collections.abc import Awaitable, Callable
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Literal, Optional
20
+
21
+ from mcp.server.fastmcp import Context, FastMCP
22
+ from mcp.server.fastmcp.exceptions import ToolError
23
+ from mcp.types import ToolAnnotations
24
+ from pydantic import Field
25
+
26
+ from . import bodyspec, formatting
27
+ from .config import Settings
28
+ from .errors import MLSpaceError
29
+ from .job_hooks import (
30
+ guard_job_delete,
31
+ postprocess_job_delete,
32
+ preflight_job_run,
33
+ preflight_jobs_list,
34
+ )
35
+ from .resource_refs import (
36
+ ResourceRef,
37
+ apply_reference,
38
+ parse_reference,
39
+ response_references,
40
+ with_context,
41
+ )
42
+ from .workspace_context import select_target
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # ---- declarative data model ------------------------------------------------
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Op:
51
+ """One MLSpace API operation reachable through an umbrella ``action``."""
52
+
53
+ method: str # GET / POST / PUT / PATCH / DELETE
54
+ path: str # may contain {placeholders}
55
+ # placeholder-in-path -> input field name supplying its value
56
+ path_params: dict[str, str] = field(default_factory=dict)
57
+ # placeholders whose value is a deliberate multi-segment path TAIL (may contain
58
+ # "/"), e.g. async_inference `predict_path`. Every OTHER placeholder is a single
59
+ # opaque SEGMENT: its value is percent-encoded and forbidden from carrying "/",
60
+ # "..", "?", "#" or control chars, so a name can never re-route the request to a
61
+ # different endpoint (route-confusion / path traversal). See `_encode_path_param`.
62
+ path_tail: frozenset[str] = frozenset()
63
+ # query-key -> input field name (list values become repeated keys)
64
+ query_params: dict[str, str] = field(default_factory=dict)
65
+ body_field: str | None = None # input field carrying the JSON body
66
+ required: tuple[str, ...] = () # input fields that must be present for this action
67
+ write: bool = False # mutates state → stripped/blocked in readonly mode
68
+ confirm: bool = False # irreversible → needs confirm=true
69
+ # external side effect / cost (e.g. predict runs a paid model) but NOT a state
70
+ # mutation: allowed in readonly mode, yet the tool is not "read-only" for hints.
71
+ side_effect: bool = False
72
+ kind: str = "item" # item | list | log | binary
73
+ help: str = "" # one-line description for the tool's action index
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Param:
78
+ """An input field of an umbrella tool (optional at the schema level;
79
+ per-action requiredness is enforced at dispatch via :attr:`Op.required`)."""
80
+
81
+ name: str
82
+ type: Any
83
+ description: str = ""
84
+ default: Any = None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class DomainTool:
89
+ domain: str # "inference"
90
+ name: str # "mlspace_inference"
91
+ title: str # human-readable tool title
92
+ summary: str # base description (action index is appended)
93
+ actions: dict[str, Op]
94
+ params: list[Param]
95
+ redact_result_keys: tuple[str, ...] = () # result keys whose values are secrets
96
+
97
+
98
+ # universal params injected into every tool
99
+ CONFIRM_PARAM = Param(
100
+ "confirm", bool,
101
+ "Set true to run an irreversible (destructive) action. Required by such actions.",
102
+ default=False,
103
+ )
104
+ RESPONSE_FORMAT_PARAM = Param(
105
+ "response_format", Literal["json", "markdown"],
106
+ "Output format: 'json' (default, compact structured) or 'markdown' (fenced JSON).",
107
+ default="json",
108
+ )
109
+
110
+
111
+ # ---- pure helpers (unit-tested without FastMCP) ----------------------------
112
+
113
+
114
+ def available_actions(dt: DomainTool, readonly: bool) -> list[str]:
115
+ return [a for a, op in dt.actions.items() if not (readonly and op.write)]
116
+
117
+
118
+ def build_annotations(dt: DomainTool, actions: list[str]) -> ToolAnnotations:
119
+ ops = [dt.actions[a] for a in actions]
120
+ has_write = any(o.write for o in ops)
121
+ has_destructive = any(o.confirm for o in ops)
122
+ has_side_effect = any(o.side_effect for o in ops)
123
+ return ToolAnnotations(
124
+ title=dt.title,
125
+ # a side-effecting predict (paid/async) is not a state mutation but is not
126
+ # "read-only" either — don't advertise readOnlyHint for such tools.
127
+ readOnlyHint=not (has_write or has_side_effect),
128
+ destructiveHint=has_destructive,
129
+ idempotentHint=False,
130
+ openWorldHint=True,
131
+ )
132
+
133
+
134
+ def build_description(dt: DomainTool, actions: list[str]) -> str:
135
+ lines = [dt.summary, "", "Actions:"]
136
+ for a in actions:
137
+ op = dt.actions[a]
138
+ flags = []
139
+ if op.write:
140
+ flags.append("write")
141
+ if op.confirm:
142
+ flags.append("destructive, needs confirm=true")
143
+ flag = f" [{', '.join(flags)}]" if flags else ""
144
+ req = f" (requires: {', '.join(op.required)})" if op.required else ""
145
+ body = ""
146
+ if op.body_field:
147
+ schema = bodyspec.schema_for(op.method, op.path)
148
+ hint = bodyspec.summarize(schema) if schema else ""
149
+ if hint:
150
+ body = f" — {op.body_field}: {{{hint}}}"
151
+ lines.append(f"- {a}: {op.help}{req}{flag}{body}")
152
+ return "\n".join(lines)
153
+
154
+
155
+ # ---- path-parameter encoding (route-confusion / traversal defence) ----------
156
+ #
157
+ # A path parameter is user-controlled text spliced into the request path. Without
158
+ # encoding, a value like ``../docker_registry/v1/users/generate_password`` passed
159
+ # as a jobs `job_name` makes the client normalise the ``..`` and land on a totally
160
+ # different (mutating) endpoint, bypassing the domain's readonly/redaction policy.
161
+ # This is a SAFETY validator, so it is fail-closed: a value that could alter the
162
+ # route is refused with an honest MLSpaceError rather than silently sanitised away.
163
+
164
+ _CTRL_RE = re.compile(r"[\x00-\x1f\x7f]")
165
+ # A path segment that would traverse or inject a route boundary. ``..``/``.`` are
166
+ # traversal; the reserved chars start a new segment / query / fragment.
167
+ _SEGMENT_FORBIDDEN = ("/", "\\", "?", "#")
168
+ # Extra characters kept unescaped in a path TAIL beyond the "/" separators, so a
169
+ # legitimate predict path like ``v1/models/svc:predict`` is not mangled (":" and
170
+ # the RFC-3986 unreserved set are valid in a path and need no escaping).
171
+ _TAIL_SAFE = "/:@-._~"
172
+
173
+
174
+ def _reject_path_value(fname: str, value: str, *, detail: str) -> None:
175
+ raise MLSpaceError(
176
+ f"Path parameter '{fname}'={value!r} {detail}.",
177
+ hint="Path parameters address a single resource; they may not contain path "
178
+ "separators, '..', '?', '#' or control characters. Pass the bare "
179
+ "name/id only.",
180
+ )
181
+
182
+
183
+ def _encode_path_param(fname: str, value: str, *, is_tail: bool) -> str:
184
+ """Percent-encode ONE path-parameter value, fail-closed on anything that could
185
+ re-route the request. A normal SEGMENT may not contain "/" (encoded away here
186
+ would still be refused, to keep the failure honest); a TAIL keeps its "/"
187
+ separators but every segment is still traversal-checked."""
188
+ if _CTRL_RE.search(value):
189
+ _reject_path_value(fname, value, detail="contains control characters")
190
+ if is_tail:
191
+ for seg in value.split("/"):
192
+ if seg in ("..", "."):
193
+ _reject_path_value(fname, value, detail="contains a '..'/'.' path segment")
194
+ for bad in ("\\", "?", "#"):
195
+ if bad in value:
196
+ _reject_path_value(fname, value, detail=f"contains {bad!r}")
197
+ return urllib.parse.quote(value, safe=_TAIL_SAFE)
198
+ # single opaque segment
199
+ if value in ("..", "."):
200
+ _reject_path_value(fname, value, detail="is a '..'/'.' path traversal segment")
201
+ for bad in _SEGMENT_FORBIDDEN:
202
+ if bad in value:
203
+ _reject_path_value(fname, value, detail=f"contains {bad!r}")
204
+ return urllib.parse.quote(value, safe="")
205
+
206
+
207
+ def _assert_route_shape(op: Op, encoded: dict[str, str]) -> None:
208
+ """Allowlist check: the resolved path must have the SAME static skeleton as the
209
+ declared ``op.path``. After per-segment encoding this is already guaranteed, but
210
+ asserting it explicitly turns any future encoding gap into a hard failure instead
211
+ of a silent re-route."""
212
+ template_segs = op.path.split("/")
213
+ resolved_segs = op.path.format(**encoded).split("/")
214
+ has_tail = bool(op.path_tail)
215
+ if not has_tail and len(resolved_segs) != len(template_segs):
216
+ raise MLSpaceError(
217
+ f"Resolved path {op.path.format(**encoded)!r} does not match the declared "
218
+ f"route {op.path!r} (segment count differs)."
219
+ )
220
+ for i, seg in enumerate(template_segs):
221
+ if seg.startswith("{") and seg.endswith("}"):
222
+ continue # placeholder — value already encoded/validated
223
+ if i >= len(resolved_segs) or resolved_segs[i] != seg:
224
+ raise MLSpaceError(
225
+ f"Resolved path {op.path.format(**encoded)!r} diverges from the declared "
226
+ f"route {op.path!r} at a static segment."
227
+ )
228
+
229
+
230
+ def resolve_request(
231
+ op: Op, values: dict[str, Any]
232
+ ) -> tuple[str, str, dict[str, str], list[tuple[str, Any]], Any]:
233
+ """Pure: (method, path_template, path_params, query_pairs, json_body).
234
+
235
+ Raises MLSpaceError for missing required params / unconfirmed destructive ops.
236
+ ``values`` must contain only non-None provided inputs (plus optional confirm).
237
+ """
238
+ missing = [f for f in op.required if values.get(f) in (None, "")]
239
+ if missing:
240
+ raise MLSpaceError(
241
+ f"This action requires: {', '.join(missing)}.",
242
+ hint="Provide the listed parameter(s) and retry.",
243
+ )
244
+ if op.confirm and not values.get("confirm"):
245
+ raise MLSpaceError(
246
+ "This action is destructive and irreversible.",
247
+ hint="Re-run with confirm=true to proceed.",
248
+ )
249
+
250
+ # Per-endpoint preflight: a sync validator that runs BEFORE the query/body are
251
+ # assembled. It may raise a deliberate MLSpaceError refusal (an honest "no",
252
+ # e.g. jobs `list` without a region) OR normalise `values` in place (e.g. lift a
253
+ # status filter to the exact enum casing the API accepts) — mirroring the silent
254
+ # limit-clamp the handler already does. No entry for (op.method, op.path) is a
255
+ # no-op, so every other op is byte-for-byte unaffected.
256
+ check = PREFLIGHT.get((op.method, op.path))
257
+ if check is not None:
258
+ check(values)
259
+
260
+ path_params: dict[str, str] = {}
261
+ for placeholder, fname in op.path_params.items():
262
+ value = values.get(fname)
263
+ if value in (None, ""):
264
+ raise MLSpaceError(f"This action requires path parameter '{fname}'.")
265
+ path_params[placeholder] = _encode_path_param(
266
+ fname, str(value), is_tail=placeholder in op.path_tail
267
+ )
268
+ if path_params:
269
+ _assert_route_shape(op, path_params)
270
+
271
+ query: list[tuple[str, Any]] = []
272
+ for qkey, fname in op.query_params.items():
273
+ value = values.get(fname)
274
+ if value is None:
275
+ continue
276
+ if isinstance(value, (list, tuple)):
277
+ for item in value:
278
+ query.append((qkey, item))
279
+ else:
280
+ query.append((qkey, value))
281
+
282
+ body = values.get(op.body_field) if op.body_field else None
283
+ return op.method, op.path, path_params, query, body
284
+
285
+
286
+ # ---- request hooks: preflight (sync) / guard (async) / postprocess (pure) --
287
+ #
288
+ # Three per-endpoint extension points keyed by (METHOD, path). They exist because
289
+ # a handful of live MLSpace endpoints return a *false success* or a misleading
290
+ # empty result that a blind caller reads as truth (audited in docs/); the wrapper
291
+ # translates those into an honest signal WITHOUT hiding or inventing any field.
292
+ #
293
+ # PREFLIGHT sync, runs inside resolve_request before the request is built.
294
+ # May raise a deliberate refusal or mutate `values` in place.
295
+ # GUARDS async, runs in the handler just before a (usually destructive)
296
+ # request. May issue its own precheck call and raise to abort.
297
+ # POSTPROCESS pure, runs in the handler on the decoded response before
298
+ # formatting. Returns a (possibly re-shaped) result.
299
+ #
300
+ # Preflight and guards may refuse requests (fail-closed). Postprocessing is
301
+ # fail-open: unexpected response shapes or transform errors preserve the result.
302
+
303
+ PREFLIGHT: dict[tuple[str, str], Callable[[dict[str, Any]], None]] = {}
304
+ GUARDS: dict[tuple[str, str], Callable[[Any, dict[str, Any]], Awaitable[None]]] = {}
305
+ POSTPROCESS: dict[tuple[str, str], Callable[[Any, dict[str, Any]], Any]] = {}
306
+
307
+
308
+ # Explicit registrations keep domain compatibility rules visible at dispatch.
309
+ PREFLIGHT[("POST", "/public/v2/jobs")] = preflight_job_run
310
+ PREFLIGHT[("GET", "/public/v2/jobs")] = preflight_jobs_list
311
+ GUARDS[("DELETE", "/public/v2/jobs/{job_name}")] = guard_job_delete
312
+ POSTPROCESS[("DELETE", "/public/v2/jobs/{job_name}")] = postprocess_job_delete
313
+
314
+
315
+ def _postprocess_delete_connectors(data: Any, values: dict[str, Any]) -> Any:
316
+ """Re-shape delete_connectors' bare 200 list into an explicit {deleted, not_found}.
317
+
318
+ delete_connectors replies 200 with ONLY the ids it actually deleted; ids that
319
+ did not exist are silently absent (its siblings delete_transfers/delete_history
320
+ already surface them under ``not_found_ids``). We compute the wrapper's OWN diff
321
+ of requested-minus-returned and, only when that diff is non-empty (an
322
+ already-broken case), re-pack into the honest sibling shape. A full success
323
+ passes through as the untouched list.
324
+
325
+ NOTE: the success shape (the response echoes the deleted ids verbatim) is
326
+ inferred from the schema and the sibling endpoints, NOT verified against a live
327
+ delete (no-write red line). A future verifier should confirm it on a
328
+ self-created connector once that is permitted.
329
+ """
330
+ ids = values.get("ids") or []
331
+ if isinstance(ids, str):
332
+ ids = [ids]
333
+ if isinstance(data, list) and ids:
334
+ not_found = [i for i in ids if i not in data]
335
+ if not_found:
336
+ return {
337
+ "deleted_ids": data,
338
+ "not_found_ids": not_found,
339
+ "_wrapper_note": (
340
+ "delete_connectors replies 200 with only the ids it actually deleted. "
341
+ "These not_found_ids were NOT among the ids the API returned in its 200 "
342
+ "response — this is the wrapper's own diff of requested-minus-returned, "
343
+ "not a platform claim; the sibling endpoints delete_transfers/"
344
+ "delete_history return such ids under not_found_ids. Empty deleted_ids "
345
+ "means nothing was deleted; check for a wrong id/connector_type."
346
+ ),
347
+ }
348
+ return data
349
+
350
+
351
+ POSTPROCESS[("DELETE", "/public/v2/data_transfer/v2/connectors")] = _postprocess_delete_connectors
352
+
353
+
354
+ def _is_empty_result(data: Any) -> bool:
355
+ """True for a bare-empty result ([]/None/{}) or a dict whose every list value is
356
+ empty (and which has at least one list value). Fail-open: anything else is not
357
+ treated as empty."""
358
+ if data == [] or data is None or data == {}:
359
+ return True
360
+ if isinstance(data, dict):
361
+ lists = [v for v in data.values() if isinstance(v, list)]
362
+ return bool(lists) and all(len(v) == 0 for v in lists)
363
+ return False
364
+
365
+
366
+ def _annotate_empty(reason: str) -> Callable[[Any, dict[str, Any]], Any]:
367
+ """Build a postprocessor that annotates a *documented-ambiguous* empty read.
368
+
369
+ Several allocation-scoped reads answer 200 ``[]`` for multiple distinct causes
370
+ (bad id / feature disabled / genuinely none) with no way to tell them apart. We
371
+ re-pack an empty result as ``{result, _wrapper_note}`` with a strictly
372
+ conditional caveat that never asserts a specific cause; a non-empty result is
373
+ returned untouched.
374
+ """
375
+ def fn(data: Any, values: dict[str, Any]) -> Any:
376
+ if _is_empty_result(data):
377
+ return {"result": data, "_wrapper_note": reason}
378
+ return data
379
+
380
+ return fn
381
+
382
+
383
+ POSTPROCESS[("GET", "/public/v2/allocations/")] = _annotate_empty(
384
+ "Empty allocations list. If this server runs under a SERVICE account, GET "
385
+ "/allocations/ is DOCUMENTED not to work for it and returns [] (not an error) — "
386
+ "the real allocations are at mlspace_workspaces action=allocations. Under a "
387
+ "personal account the workspace genuinely has none."
388
+ )
389
+ POSTPROCESS[
390
+ ("GET", "/public/v2/workspaces/v3/{workspace_id}/allocations/{allocation_id}/queues")
391
+ ] = _annotate_empty(
392
+ "Empty queue list is AMBIGUOUS: the allocation_id may not exist, custom queues "
393
+ "may be disabled for it, or there are genuinely none — the API returns 200 [] for "
394
+ "all three. Verify the allocation_id via mlspace_workspaces action=allocations."
395
+ )
396
+ POSTPROCESS[("GET", "/public/v2/queues/defaults")] = _annotate_empty(
397
+ "Empty defaults are AMBIGUOUS (bad allocation_id / feature off / none). The "
398
+ "sibling mlspace_queues action=list returns 404 'allocation not found' for a "
399
+ "bogus allocation_id — cross-check there."
400
+ )
401
+
402
+
403
+ # ---- FastMCP registration --------------------------------------------------
404
+
405
+
406
+ def _optional(tp: Any) -> Any:
407
+ """``Optional[tp]`` built from a runtime value (tp is a value, not a static type)."""
408
+ return Optional[tp] # noqa: UP045
409
+
410
+
411
+ def _build_handler(dt: DomainTool, actions: list[str], settings: Settings):
412
+ action_type = Literal[tuple(actions)] # type: ignore[valid-type]
413
+ params = list(dt.params) + [
414
+ Param("target", str, "Connected workspace ID or unambiguous name from mlspace_contexts. "
415
+ "Required when several workspaces are connected; separate from API workspace_id."),
416
+ Param("resource_ref", ResourceRef, "Typed address of an existing job/notebook. "
417
+ "Must agree with target and explicit address arguments; does not replace confirm."),
418
+ CONFIRM_PARAM, RESPONSE_FORMAT_PARAM,
419
+ ]
420
+
421
+ sig_params = [
422
+ inspect.Parameter("action", inspect.Parameter.KEYWORD_ONLY, annotation=action_type)
423
+ ]
424
+ annotations: dict[str, Any] = {"action": action_type}
425
+ for p in params:
426
+ anno = _optional(p.type)
427
+ field_info = Field(default=p.default, description=p.description)
428
+ sig_params.append(
429
+ inspect.Parameter(
430
+ p.name, inspect.Parameter.KEYWORD_ONLY, default=field_info, annotation=anno
431
+ )
432
+ )
433
+ annotations[p.name] = anno
434
+ sig_params.append(inspect.Parameter("ctx", inspect.Parameter.KEYWORD_ONLY, annotation=Context))
435
+ annotations["ctx"] = Context
436
+ annotations["return"] = str
437
+
438
+ actions_set = set(actions)
439
+
440
+ async def handler(**kwargs: Any) -> str:
441
+ ctx: Context = kwargs.pop("ctx")
442
+ action: str = kwargs.pop("action")
443
+ response_format = kwargs.pop("response_format", None) or "json"
444
+ values = {k: v for k, v in kwargs.items() if v is not None}
445
+
446
+ try:
447
+ if action not in actions_set:
448
+ # write action hidden by readonly, or simply unknown
449
+ raise MLSpaceError(
450
+ f"Action '{action}' is not available"
451
+ + (" (server is in read-only mode; set MLSPACE_READONLY=false)."
452
+ if action in dt.actions else ".")
453
+ )
454
+ op = dt.actions[action]
455
+ if settings.readonly and op.write:
456
+ raise MLSpaceError(
457
+ f"Action '{action}' is disabled in read-only mode.",
458
+ hint="Set MLSPACE_READONLY=false to enable write actions.",
459
+ )
460
+
461
+ # pagination: only touch "limit" when it is a real query param of THIS op
462
+ # (avoids corrupting an unrelated field that happens to be named "limit").
463
+ if "limit" in op.query_params:
464
+ if values.get("limit") is not None:
465
+ values["limit"] = max(1, min(int(values["limit"]), settings.list_max_limit))
466
+ elif op.kind == "list":
467
+ values["limit"] = settings.list_default_limit
468
+
469
+ clients = ctx.request_context.lifespan_context.clients
470
+ selected = clients.selected
471
+ environment = clients.environment
472
+ ref = parse_reference(values.pop("resource_ref", None), environment)
473
+ target = values.pop("target", None)
474
+ if ref is not None:
475
+ ref_selection = select_target(selected, ref.target)
476
+ if ref_selection.id != ref.target:
477
+ raise MLSpaceError("resource_ref target must be a canonical configured ID.")
478
+ if target is not None and select_target(selected, target).id != ref.target:
479
+ raise MLSpaceError("resource_ref conflicts with explicit target.")
480
+ target = ref.target
481
+ selection = select_target(selected, target)
482
+ apply_reference(ref, dt.domain, action, values)
483
+ # Check confirmation before even lazy key/namespace lookups.
484
+ if op.confirm and not values.get("confirm"):
485
+ raise MLSpaceError("This action is destructive and irreversible.",
486
+ hint="Re-run with confirm=true to proceed.")
487
+ client = None
488
+
489
+ async def selected_client():
490
+ return await clients.resolve(selection.id)
491
+
492
+ # auto-fill server-known ids the model omitted, so workspace-scoped
493
+ # actions work without the model discovering them: workspace_id comes
494
+ # straight from config; namespace is resolved (and cached) from it.
495
+ # Falls back to the required-check below if namespace can't be resolved.
496
+ needed = set(op.path_params.values()) | set(op.required)
497
+ if "workspace_id" in needed and not values.get("workspace_id") and selection.id:
498
+ values["workspace_id"] = selection.id
499
+ if "namespace" in needed and not values.get("namespace"):
500
+ client = await selected_client()
501
+ ns = await client.workspace_namespace()
502
+ if ns:
503
+ values["namespace"] = ns
504
+
505
+ method, path, path_params, query, body = resolve_request(op, values)
506
+
507
+ # lenient, spec-driven body check: catch obvious structural mistakes
508
+ # (missing required / wrong scalar type / bad enum) before a round-trip.
509
+ # Stays silent on anything ambiguous; the API remains the real validator.
510
+ if body is not None and op.body_field:
511
+ body_schema = bodyspec.schema_for(op.method, op.path)
512
+ if body_schema:
513
+ issues = bodyspec.lint(body_schema, body)
514
+ if issues:
515
+ raise MLSpaceError(
516
+ "Request body is invalid: " + "; ".join(issues[:8]),
517
+ hint="Fix the listed field(s) and retry. See the action's "
518
+ "description for the required body shape.",
519
+ )
520
+
521
+ # dry-run: validated but not sent — return a preview of the request that
522
+ # WOULD be issued (lets an LLM assemble & check a write before applying it).
523
+ if settings.dry_run and (op.write or op.side_effect):
524
+ resolved = path.format(**path_params) if path_params else path
525
+ # keep repeated query keys as ordered pairs (dict(query) would collapse a
526
+ # multi-delete's ids to the last one) and redact any secret-looking body
527
+ # field so the preview never duplicates a credential into output/traces.
528
+ preview = {
529
+ "dry_run": True,
530
+ "would_send": {
531
+ "method": method,
532
+ "path": resolved,
533
+ "query": [[k, v] for k, v in query] if query else None,
534
+ "body": formatting.redact_secrets(body) if body is not None else None,
535
+ },
536
+ "note": "Not executed (server in dry-run). Body passed validation; "
537
+ "re-run without dry-run to apply.",
538
+ }
539
+ formatted = formatting.format_response(
540
+ preview, response_format, kind="item",
541
+ redact_keys=dt.redact_result_keys, list_cap=settings.list_max_limit,
542
+ log_tail=settings.log_tail_lines,
543
+ )
544
+ if len(selected) == 1:
545
+ return formatted
546
+ return with_context(formatted, environment=environment, target=selection.id,
547
+ workspace_name=selection.name, refs=[],
548
+ response_format=response_format, kind="item")
549
+
550
+ if client is None:
551
+ client = await selected_client()
552
+
553
+ # Per-endpoint async guard: a precondition/existence check issued just
554
+ # before a (usually destructive) request so the wrapper never reports a
555
+ # false success. Existence guards fail closed; a deliberate
556
+ # MLSpaceError refusal propagates to `except MLSpaceError` below and the
557
+ # real request is never sent.
558
+ guard = GUARDS.get((op.method, op.path))
559
+ if guard is not None:
560
+ await guard(client, values)
561
+
562
+ data = await client.request(
563
+ method, path, path_params=path_params, params=query or None, json_body=body
564
+ )
565
+
566
+ # Per-endpoint postprocess: a pure re-shaping that turns a platform false
567
+ # success into an honest signal (adds `_wrapper_note`, never hides fields).
568
+ # Fail-open: on any error `data` is left exactly as returned.
569
+ post = POSTPROCESS.get((op.method, op.path))
570
+ if post is not None:
571
+ try:
572
+ data = post(data, values)
573
+ except Exception:
574
+ logger.exception("postprocess %s failed", op.path)
575
+
576
+ formatted = formatting.format_response(
577
+ data,
578
+ response_format,
579
+ kind=op.kind,
580
+ redact_keys=dt.redact_result_keys,
581
+ list_cap=settings.list_max_limit,
582
+ log_tail=settings.log_tail_lines,
583
+ # configs (catalog) returns every region; the /configs endpoint has no
584
+ # region filter, so 'region' is applied here client-side (not as a query
585
+ # param) to keep one region only. None => all regions.
586
+ catalog_region=values.get("region") if op.kind == "catalog" else None,
587
+ )
588
+ if len(selected) == 1:
589
+ return formatted
590
+ refs = response_references(dt.domain, action, data, environment=environment,
591
+ target=selection.id, values=values,
592
+ list_cap=settings.list_max_limit)
593
+ note = "New resource address is unknown from the response." if (
594
+ (dt.domain, action) in {("jobs", "restart"), ("jobs", "run"),
595
+ ("notebooks", "create")} and not refs
596
+ ) else None
597
+ return with_context(formatted, environment=environment, target=selection.id,
598
+ workspace_name=selection.name, refs=refs,
599
+ response_format=response_format, kind=op.kind,
600
+ address_note=note)
601
+ except MLSpaceError as exc:
602
+ # Protocol-correct failure: raising out of the tool makes FastMCP set
603
+ # CallToolResult.isError=true, so a refusal (preflight, 4xx/5xx, missing
604
+ # confirm, route-confusion) is no longer indistinguishable from success.
605
+ # ToolError carries the SAME human text the caller used to receive as a
606
+ # string (exc.to_text(), message + hint); FastMCP surfaces it verbatim.
607
+ raise ToolError(exc.to_text()) from exc
608
+ except Exception as exc: # unexpected: still surface as isError=true, not a crash
609
+ logger.exception("tool %s action=%s failed unexpectedly", dt.name, action)
610
+ raise ToolError(f"Error: unexpected failure ({type(exc).__name__}).") from exc
611
+
612
+ handler.__signature__ = inspect.Signature(sig_params) # type: ignore[attr-defined]
613
+ handler.__annotations__ = annotations
614
+ handler.__name__ = dt.name
615
+ handler.__doc__ = dt.summary
616
+ return handler
617
+
618
+
619
+ def register_domain(mcp: FastMCP, settings: Settings, dt: DomainTool) -> bool:
620
+ """Register one umbrella tool. Returns False if it has no available actions
621
+ (e.g. an all-write domain while readonly)."""
622
+ actions = available_actions(dt, settings.readonly)
623
+ if not actions:
624
+ return False
625
+ handler = _build_handler(dt, actions, settings)
626
+ mcp.add_tool(
627
+ handler,
628
+ name=dt.name,
629
+ description=build_description(dt, actions),
630
+ annotations=build_annotations(dt, actions),
631
+ )
632
+ return True