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.
- mlspace_mcp/__init__.py +3 -0
- mlspace_mcp/__main__.py +125 -0
- mlspace_mcp/_status.py +18 -0
- mlspace_mcp/auth.py +109 -0
- mlspace_mcp/bodyspec.py +184 -0
- mlspace_mcp/client.py +170 -0
- mlspace_mcp/client_setup.py +235 -0
- mlspace_mcp/config.py +283 -0
- mlspace_mcp/errors.py +241 -0
- mlspace_mcp/formatting.py +502 -0
- mlspace_mcp/init_cli.py +356 -0
- mlspace_mcp/job_hooks.py +242 -0
- mlspace_mcp/jsonc.py +90 -0
- mlspace_mcp/overview.py +421 -0
- mlspace_mcp/registry.py +632 -0
- mlspace_mcp/resource_refs.py +188 -0
- mlspace_mcp/server.py +190 -0
- mlspace_mcp/setup_ui.py +103 -0
- mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
- mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
- mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
- mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
- mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
- mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
- mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
- mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
- mlspace_mcp/spec/body_schemas.json +7506 -0
- mlspace_mcp/spec/openapi.json +28347 -0
- mlspace_mcp/tls.py +46 -0
- mlspace_mcp/tools/__init__.py +1 -0
- mlspace_mcp/tools/allocations.py +92 -0
- mlspace_mcp/tools/async_inference.py +77 -0
- mlspace_mcp/tools/build_image.py +76 -0
- mlspace_mcp/tools/dalle.py +52 -0
- mlspace_mcp/tools/data_transfer.py +260 -0
- mlspace_mcp/tools/docker_registry.py +156 -0
- mlspace_mcp/tools/inference.py +88 -0
- mlspace_mcp/tools/jobs.py +155 -0
- mlspace_mcp/tools/notebooks.py +174 -0
- mlspace_mcp/tools/queues.py +202 -0
- mlspace_mcp/tools/resources.py +86 -0
- mlspace_mcp/tools/tensorboards.py +112 -0
- mlspace_mcp/tools/workspaces.py +92 -0
- mlspace_mcp/workspace_context.py +107 -0
- mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
- mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
- mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
- mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
- mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
mlspace_mcp/registry.py
ADDED
|
@@ -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
|