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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Validated resource addresses and small jobs/notebooks operation adapters.
|
|
2
|
+
|
|
3
|
+
A reference is an address, not permission to perform the operation. Output
|
|
4
|
+
references are best-effort and only read recognized resource response shapes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from .errors import MLSpaceError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Address(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
19
|
+
|
|
20
|
+
@field_validator("*", check_fields=False)
|
|
21
|
+
@classmethod
|
|
22
|
+
def safe_segment(cls, value: Any) -> Any:
|
|
23
|
+
if isinstance(value, str) and (
|
|
24
|
+
not value.strip() or value in {".", ".."}
|
|
25
|
+
or re.search(r"[/\\?#\x00-\x1f\x7f]", value)
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("address fields must be non-empty opaque names or IDs")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JobAddress(Address):
|
|
32
|
+
job_name: str
|
|
33
|
+
region: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotebookAddress(Address):
|
|
37
|
+
notebook_uuid: str | None = None
|
|
38
|
+
notebook_name: str | None = None
|
|
39
|
+
namespace: str | None = None
|
|
40
|
+
region: str | None = None
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def has_identity(self) -> NotebookAddress:
|
|
44
|
+
if not self.notebook_uuid and not (self.notebook_name and self.namespace):
|
|
45
|
+
raise ValueError("notebook address requires UUID or name and namespace")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ResourceRef(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid", strict=True, frozen=True)
|
|
51
|
+
version: Literal[1]
|
|
52
|
+
environment: str
|
|
53
|
+
target: str
|
|
54
|
+
kind: Literal["job", "notebook"]
|
|
55
|
+
address: JobAddress | NotebookAddress
|
|
56
|
+
|
|
57
|
+
@model_validator(mode="after")
|
|
58
|
+
def matching_kind(self) -> ResourceRef:
|
|
59
|
+
if (self.kind == "job") != isinstance(self.address, JobAddress):
|
|
60
|
+
raise ValueError("resource kind does not match address")
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_reference(value: Any, environment: str) -> ResourceRef | None:
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
ref = ResourceRef.model_validate(value)
|
|
69
|
+
except (ValidationError, TypeError, ValueError) as exc:
|
|
70
|
+
# Do not echo arbitrary input (it may contain credentials or huge payloads).
|
|
71
|
+
raise MLSpaceError("Invalid resource_ref: use the typed resource address schema.") from exc
|
|
72
|
+
if ref.environment != environment:
|
|
73
|
+
raise MLSpaceError("resource_ref belongs to a different configured environment.")
|
|
74
|
+
return ref
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _supply(values: dict[str, Any], name: str, value: Any) -> None:
|
|
78
|
+
if value is None:
|
|
79
|
+
return
|
|
80
|
+
if name in values and values[name] != value:
|
|
81
|
+
raise MLSpaceError(f"resource_ref conflicts with explicit {name}.")
|
|
82
|
+
values[name] = value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def apply_reference(ref: ResourceRef | None, domain: str, action: str,
|
|
86
|
+
values: dict[str, Any]) -> None:
|
|
87
|
+
if ref is None:
|
|
88
|
+
return
|
|
89
|
+
if domain == "jobs" and ref.kind == "job" and isinstance(ref.address, JobAddress):
|
|
90
|
+
if action not in {"get", "logs", "list_nodes", "list_pods", "get_params",
|
|
91
|
+
"get_preemptors", "delete", "restart"}:
|
|
92
|
+
raise MLSpaceError("resource_ref is not supported for this jobs action.")
|
|
93
|
+
_supply(values, "job_name", ref.address.job_name)
|
|
94
|
+
_supply(values, "region", ref.address.region)
|
|
95
|
+
if action == "restart":
|
|
96
|
+
body = values.get("body", {})
|
|
97
|
+
if not isinstance(body, dict):
|
|
98
|
+
raise MLSpaceError("resource_ref restart requires an object body.")
|
|
99
|
+
body = dict(body)
|
|
100
|
+
_supply(body, "job_name", ref.address.job_name)
|
|
101
|
+
values["body"] = body
|
|
102
|
+
return
|
|
103
|
+
if domain == "notebooks" and ref.kind == "notebook" and isinstance(ref.address, NotebookAddress):
|
|
104
|
+
address = ref.address
|
|
105
|
+
if action in {"users_list", "users_set", "users_revoke"}:
|
|
106
|
+
if not (address.notebook_name and address.namespace):
|
|
107
|
+
raise MLSpaceError("resource_ref for users actions requires notebook name and namespace.")
|
|
108
|
+
elif action in {"get", "delete", "pause", "resume", "modify"}:
|
|
109
|
+
if not address.notebook_uuid:
|
|
110
|
+
raise MLSpaceError("resource_ref for this notebook action requires notebook UUID.")
|
|
111
|
+
else:
|
|
112
|
+
raise MLSpaceError("resource_ref is not supported for this notebooks action.")
|
|
113
|
+
# Body region may be a legitimate resume/modify override, not an address.
|
|
114
|
+
for name, value in address.model_dump(exclude_none=True).items():
|
|
115
|
+
_supply(values, name, value)
|
|
116
|
+
return
|
|
117
|
+
raise MLSpaceError("resource_ref kind is not supported for this domain/action.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def response_references(domain: str, action: str, data: Any, *, environment: str,
|
|
121
|
+
target: str, values: dict[str, Any], list_cap: int) -> list[dict[str, Any]]:
|
|
122
|
+
"""No recursive id/name scan: only known jobs/notebooks resource operations."""
|
|
123
|
+
allowed = {"jobs": {"list", "get", "run", "restart"},
|
|
124
|
+
"notebooks": {"list", "get", "create", "resume", "modify"}}
|
|
125
|
+
if action not in allowed.get(domain, set()):
|
|
126
|
+
return []
|
|
127
|
+
if action == "list":
|
|
128
|
+
rows = data if isinstance(data, list) else None
|
|
129
|
+
if isinstance(data, dict):
|
|
130
|
+
for key in ("items", "jobs", "notebooks", "results"):
|
|
131
|
+
if isinstance(data.get(key), list):
|
|
132
|
+
rows = data[key]
|
|
133
|
+
break
|
|
134
|
+
rows = rows[:list_cap] if isinstance(rows, list) else []
|
|
135
|
+
else:
|
|
136
|
+
rows = [data]
|
|
137
|
+
refs = []
|
|
138
|
+
for row in rows:
|
|
139
|
+
if not isinstance(row, dict):
|
|
140
|
+
continue
|
|
141
|
+
# These domain endpoints are workspace-scoped. An explicit owner wins;
|
|
142
|
+
# a foreign owner is not inferred from the context that observed it.
|
|
143
|
+
owner = row.get("workspace_id", target)
|
|
144
|
+
if not isinstance(owner, str) or not owner:
|
|
145
|
+
continue
|
|
146
|
+
body = values.get("body")
|
|
147
|
+
region = row.get("region") or values.get("region")
|
|
148
|
+
if region is None and isinstance(body, dict):
|
|
149
|
+
region = body.get("region")
|
|
150
|
+
if domain == "jobs":
|
|
151
|
+
identity = row.get("job_name")
|
|
152
|
+
# A restart must confirm a NEW identity, not repeat the old address.
|
|
153
|
+
original_name = body.get("job_name") if isinstance(body, dict) else None
|
|
154
|
+
if action == "restart" and identity == original_name:
|
|
155
|
+
continue
|
|
156
|
+
address = {"job_name": identity, "region": region}
|
|
157
|
+
kind = "job"
|
|
158
|
+
else:
|
|
159
|
+
address = {"notebook_uuid": row.get("uid") or row.get("uuid"),
|
|
160
|
+
"notebook_name": row.get("name"),
|
|
161
|
+
"namespace": row.get("namespace") or values.get("namespace"),
|
|
162
|
+
"region": region}
|
|
163
|
+
kind = "notebook"
|
|
164
|
+
try:
|
|
165
|
+
reference = ResourceRef.model_validate({"version": 1, "environment": environment,
|
|
166
|
+
"target": owner, "kind": kind, "address": address})
|
|
167
|
+
except (ValidationError, TypeError, ValueError):
|
|
168
|
+
continue # an applied write must remain success even if its new address is unknown
|
|
169
|
+
refs.append(reference.model_dump(exclude_none=True))
|
|
170
|
+
return refs
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def with_context(formatted: str, *, environment: str, target: str, workspace_name: str,
|
|
174
|
+
refs: list[dict[str, Any]], response_format: str,
|
|
175
|
+
kind: str, address_note: str | None = None) -> str:
|
|
176
|
+
data: Any = formatted
|
|
177
|
+
if response_format == "json" and kind != "log":
|
|
178
|
+
try:
|
|
179
|
+
data = json.loads(formatted)
|
|
180
|
+
except (ValueError, TypeError):
|
|
181
|
+
pass
|
|
182
|
+
result = {"request_context": {"environment": environment, "target": target,
|
|
183
|
+
"workspace_name": workspace_name},
|
|
184
|
+
"data": data, "resource_refs": refs}
|
|
185
|
+
if address_note:
|
|
186
|
+
result["address_note"] = address_note
|
|
187
|
+
text = json.dumps(result, ensure_ascii=False)
|
|
188
|
+
return f"```json\n{text}\n```" if response_format == "markdown" else text
|
mlspace_mcp/server.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Assemble the FastMCP server: shared async client via lifespan, transport
|
|
2
|
+
security, and registration of the enabled domain umbrella tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
15
|
+
from mcp.types import ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from .auth import TokenManager
|
|
18
|
+
from .config import Settings
|
|
19
|
+
from .overview import register_overviews
|
|
20
|
+
from .registry import DomainTool, register_domain
|
|
21
|
+
from .tls import tls_context
|
|
22
|
+
from .workspace_context import (
|
|
23
|
+
WorkspaceClients,
|
|
24
|
+
environment_id,
|
|
25
|
+
normalized_endpoint,
|
|
26
|
+
selected_workspaces,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Domain modules, each exposing a module-level ``DOMAIN: DomainTool``.
|
|
32
|
+
DOMAIN_MODULES: tuple[str, ...] = (
|
|
33
|
+
"inference",
|
|
34
|
+
"async_inference",
|
|
35
|
+
"dalle",
|
|
36
|
+
"jobs",
|
|
37
|
+
"build_image",
|
|
38
|
+
"notebooks",
|
|
39
|
+
"tensorboards",
|
|
40
|
+
"workspaces",
|
|
41
|
+
"allocations",
|
|
42
|
+
"queues",
|
|
43
|
+
"docker_registry",
|
|
44
|
+
"data_transfer",
|
|
45
|
+
"resources",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Shared rules every task needs. Step-by-step recipes ship as skills in the plugin
|
|
49
|
+
# (plugin/skills/); only these invariants live in the server, so a client sees them
|
|
50
|
+
# once whether or not a skill is active.
|
|
51
|
+
CONTEXT_INSTRUCTIONS = """\
|
|
52
|
+
Context and evidence rules (apply to every step):
|
|
53
|
+
- mlspace_contexts is the fixed selected catalogue, for one, three or ten workspaces.
|
|
54
|
+
mlspace_workspaces list shows credential-visible workspaces and never expands it.
|
|
55
|
+
No global default/current workspace exists. A standalone overview (including
|
|
56
|
+
"now show jobs") covers the selected set even after inspecting an object in A.
|
|
57
|
+
- Address an existing object with its resource_ref, or exact target and resource
|
|
58
|
+
address. Keep that address for diagnosis/restart; retain the new restart reference.
|
|
59
|
+
An explicit task in A scopes its steps; an unrelated read in B does not change it.
|
|
60
|
+
A singleton needs no placement question. Creation uses explicit placement, the
|
|
61
|
+
established task, the singleton, or a delegated criterion (e.g. any suitable GPU
|
|
62
|
+
in A/B). Evaluate that criterion and report the choice without asking again.
|
|
63
|
+
A comparison/example alone does not choose placement. Ask only for real ambiguity;
|
|
64
|
+
recover a lost object reference or clarify it rather than select the first match.
|
|
65
|
+
- Broad jobs/notebooks reads use mlspace_jobs_overview/mlspace_notebooks_overview.
|
|
66
|
+
targets omitted means all selected contexts; explicit targets restrict observation.
|
|
67
|
+
Inspect requested_scope, checked_scope, failures, observed_at, fetch_complete,
|
|
68
|
+
output_truncated and complete. observed_count counts observations, not a complete
|
|
69
|
+
inventory total or unique shared capacity. Partial search proves neither absence
|
|
70
|
+
nor uniqueness. "All" after a shortened list needs a defined set (shown vs matching);
|
|
71
|
+
an explicitly chosen object remains actionable despite unrelated failed reads.
|
|
72
|
+
- "My jobs" needs a confirmed author mapping or an explicitly supplied author filter.
|
|
73
|
+
Reuse a user-specified saved author filter if available, label it as such; it is
|
|
74
|
+
not verified identity/ownership. Membership, LLM login and a shared service account
|
|
75
|
+
do not identify an author. If no usable mapping/filter exists, explain and obtain
|
|
76
|
+
an author or offer the workspace overview; never silently label colleagues' jobs mine.
|
|
77
|
+
- Keep paused notebooks in inventory with their status. Configured GPU/instance size
|
|
78
|
+
is not current usage; do not recommend pausing a paused notebook to free capacity.
|
|
79
|
+
Compute nodes and Jupyter servers are different objects. Pending demand, running
|
|
80
|
+
pods and node load are separate observations; do not sum repeated shared-resource
|
|
81
|
+
views. Missing or inaccessible workload means unknown, not idle or safe to move.
|
|
82
|
+
- Use existing write/confirm rules. Context selection and resource_ref do not add a
|
|
83
|
+
confirmation requirement or replace a required confirm=true. Read-only diagnosis
|
|
84
|
+
never submits a paid canary merely to test quota.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
INSTRUCTIONS = (
|
|
88
|
+
"Tools for the Cloud.ru MLSpace platform (ML training, inference, notebooks, "
|
|
89
|
+
"data transfer, registry, resources). Each tool is an umbrella over one domain; "
|
|
90
|
+
"pass an `action` plus that action's parameters. Read the tool description for "
|
|
91
|
+
"the per-action parameter index. Destructive actions require confirm=true; all "
|
|
92
|
+
"write actions disappear when the server runs with MLSPACE_READONLY=true."
|
|
93
|
+
) + "\n\n" + CONTEXT_INSTRUCTIONS
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AppContext:
|
|
98
|
+
"""What lives in the FastMCP lifespan context (read by tool handlers)."""
|
|
99
|
+
|
|
100
|
+
clients: WorkspaceClients
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_domain_tools(settings: Settings) -> list[DomainTool]:
|
|
104
|
+
enabled = settings.enabled_domain_set
|
|
105
|
+
if enabled is not None:
|
|
106
|
+
unknown = enabled - set(DOMAIN_MODULES)
|
|
107
|
+
if unknown:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"MLSPACE_ENABLED_DOMAINS lists unknown domain(s) %s; known domains: %s",
|
|
110
|
+
", ".join(sorted(unknown)),
|
|
111
|
+
", ".join(DOMAIN_MODULES),
|
|
112
|
+
)
|
|
113
|
+
tools: list[DomainTool] = []
|
|
114
|
+
for mod_name in DOMAIN_MODULES:
|
|
115
|
+
if enabled is not None and mod_name not in enabled:
|
|
116
|
+
continue
|
|
117
|
+
full = f"{__package__}.tools.{mod_name}"
|
|
118
|
+
try:
|
|
119
|
+
module = importlib.import_module(full)
|
|
120
|
+
except ModuleNotFoundError as exc:
|
|
121
|
+
# Not-yet-implemented domain module: skip. A genuine missing dependency
|
|
122
|
+
# inside an existing module (different name) is re-raised.
|
|
123
|
+
if exc.name == full:
|
|
124
|
+
continue
|
|
125
|
+
raise
|
|
126
|
+
tools.append(module.DOMAIN)
|
|
127
|
+
return tools
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _transport_security(settings: Settings) -> TransportSecuritySettings:
|
|
131
|
+
def forms(host: str) -> set[str]:
|
|
132
|
+
# IPv6 literals appear bracketed in the Host header ([::1], [::1]:8000)
|
|
133
|
+
if ":" in host and not host.startswith("["):
|
|
134
|
+
host = f"[{host}]"
|
|
135
|
+
return {host, f"{host}:{settings.port}"}
|
|
136
|
+
|
|
137
|
+
hosts = forms(settings.host) | forms("127.0.0.1") | forms("localhost")
|
|
138
|
+
origins = {f"http://{h}" for h in hosts} | {f"https://{h}" for h in hosts}
|
|
139
|
+
return TransportSecuritySettings(
|
|
140
|
+
enable_dns_rebinding_protection=True,
|
|
141
|
+
allowed_hosts=sorted(hosts),
|
|
142
|
+
allowed_origins=sorted(origins),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_server(settings: Settings) -> FastMCP:
|
|
147
|
+
"""Build (but do not run) the FastMCP server. Does not require credentials —
|
|
148
|
+
credentials are only needed to serve requests."""
|
|
149
|
+
|
|
150
|
+
@asynccontextmanager
|
|
151
|
+
async def lifespan(_server: FastMCP):
|
|
152
|
+
async with httpx.AsyncClient(timeout=settings.timeout, verify=tls_context(settings.ca_file)) as http:
|
|
153
|
+
tokens = TokenManager(
|
|
154
|
+
http,
|
|
155
|
+
settings.base_url,
|
|
156
|
+
settings.client_id,
|
|
157
|
+
settings.client_secret.get_secret_value(),
|
|
158
|
+
)
|
|
159
|
+
clients = WorkspaceClients(settings, http, tokens)
|
|
160
|
+
yield AppContext(clients=clients)
|
|
161
|
+
|
|
162
|
+
mcp = FastMCP(
|
|
163
|
+
"mlspace_mcp",
|
|
164
|
+
instructions=INSTRUCTIONS,
|
|
165
|
+
host=settings.host,
|
|
166
|
+
port=settings.port,
|
|
167
|
+
lifespan=lifespan,
|
|
168
|
+
transport_security=_transport_security(settings),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
for dt in _load_domain_tools(settings):
|
|
172
|
+
register_domain(mcp, settings, dt)
|
|
173
|
+
|
|
174
|
+
catalogue = {
|
|
175
|
+
"environment": environment_id(settings.base_url),
|
|
176
|
+
"endpoint": normalized_endpoint(settings.base_url),
|
|
177
|
+
"workspaces": [{"id": item.id, "name": item.name, "project_name": item.project_name}
|
|
178
|
+
for item in selected_workspaces(settings)],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async def mlspace_contexts() -> str:
|
|
182
|
+
return json.dumps(catalogue, ensure_ascii=False)
|
|
183
|
+
|
|
184
|
+
mcp.add_tool(mlspace_contexts, description="Saved connected workspaces available as target. "
|
|
185
|
+
"This fixed catalogue differs from credential-visible mlspace_workspaces list.",
|
|
186
|
+
annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False,
|
|
187
|
+
idempotentHint=True, openWorldHint=False))
|
|
188
|
+
|
|
189
|
+
register_overviews(mcp, settings)
|
|
190
|
+
return mcp
|
mlspace_mcp/setup_ui.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Bounded terminal setup UI, also usable over SSH without a browser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import Application, PromptSession
|
|
9
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
10
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
11
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
12
|
+
from prompt_toolkit.output.defaults import create_output
|
|
13
|
+
from prompt_toolkit.widgets import TextArea
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def display(value: str) -> str:
|
|
17
|
+
"""API-provided labels must not control the user's terminal."""
|
|
18
|
+
return ''.join(char if char.isprintable() else ' ' for char in value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def masked_prompt(label: str, **kwargs: Any) -> str:
|
|
22
|
+
return str(PromptSession(is_password=True,
|
|
23
|
+
output=kwargs.pop('output', None) or create_output(stdout=sys.stderr),
|
|
24
|
+
**kwargs).prompt(label))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class WorkspaceSelection:
|
|
29
|
+
rows: list[dict[str, str]]
|
|
30
|
+
selected: set[str] = field(default_factory=set)
|
|
31
|
+
query: str = ''
|
|
32
|
+
cursor: int = 0
|
|
33
|
+
|
|
34
|
+
def matches(self) -> list[dict[str, str]]:
|
|
35
|
+
terms = self.query.casefold().split()
|
|
36
|
+
return [row for row in self.rows if all(term in ' '.join(
|
|
37
|
+
row.get(k, '') for k in ('id', 'name', 'project_name')).casefold() for term in terms)]
|
|
38
|
+
|
|
39
|
+
def toggle(self) -> None:
|
|
40
|
+
rows = self.matches()
|
|
41
|
+
if rows:
|
|
42
|
+
key = rows[self.cursor % len(rows)]['id']
|
|
43
|
+
self.selected.symmetric_difference_update({key})
|
|
44
|
+
|
|
45
|
+
def lines(self) -> list[str]:
|
|
46
|
+
rows = self.matches()
|
|
47
|
+
if not rows:
|
|
48
|
+
return ['No matches. Change the search; previous selections are retained.']
|
|
49
|
+
self.cursor %= len(rows)
|
|
50
|
+
start = self.cursor // 12 * 12
|
|
51
|
+
return [display(f"{'>' if start+i == self.cursor else ' '} "
|
|
52
|
+
f"[{'x' if r['id'] in self.selected else ' '}] {r['name']} "
|
|
53
|
+
f"| {r.get('project_name', '')} | {r['id']}")
|
|
54
|
+
for i, r in enumerate(rows[start:start+12])]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def choose_workspaces(rows: list[dict[str, str]], selected_ids: list[str] | None = None,
|
|
58
|
+
**kwargs: Any) -> list[dict[str, str]]:
|
|
59
|
+
state = WorkspaceSelection(rows, set(selected_ids or []) & {r['id'] for r in rows})
|
|
60
|
+
search = TextArea(height=1, prompt='Search name / project / ID: ', multiline=False)
|
|
61
|
+
bindings = KeyBindings()
|
|
62
|
+
|
|
63
|
+
def changed(_buffer: Any) -> None:
|
|
64
|
+
state.query = search.text
|
|
65
|
+
state.cursor = 0
|
|
66
|
+
|
|
67
|
+
search.buffer.on_text_changed += changed
|
|
68
|
+
|
|
69
|
+
@bindings.add('up')
|
|
70
|
+
def up(event: Any) -> None:
|
|
71
|
+
state.cursor = (state.cursor - 1) % max(1, len(state.matches()))
|
|
72
|
+
|
|
73
|
+
@bindings.add('down')
|
|
74
|
+
def down(event: Any) -> None:
|
|
75
|
+
state.cursor = (state.cursor + 1) % max(1, len(state.matches()))
|
|
76
|
+
|
|
77
|
+
@bindings.add(' ')
|
|
78
|
+
def toggle(event: Any) -> None:
|
|
79
|
+
state.toggle()
|
|
80
|
+
|
|
81
|
+
@bindings.add('enter')
|
|
82
|
+
def done(event: Any) -> None:
|
|
83
|
+
if state.selected:
|
|
84
|
+
event.app.exit(result=[row for row in rows if row['id'] in state.selected])
|
|
85
|
+
|
|
86
|
+
@bindings.add('c-c')
|
|
87
|
+
@bindings.add('c-d')
|
|
88
|
+
def cancel(event: Any) -> None:
|
|
89
|
+
event.app.exit(exception=KeyboardInterrupt())
|
|
90
|
+
|
|
91
|
+
app: Application[list[dict[str, str]]] = Application(
|
|
92
|
+
layout=Layout(HSplit([
|
|
93
|
+
search,
|
|
94
|
+
Window(FormattedTextControl(lambda: '\n'.join(state.lines())), height=12),
|
|
95
|
+
Window(FormattedTextControl(lambda: (
|
|
96
|
+
f'{len(state.matches())} matches / {len(rows)} total; {len(state.selected)} selected. '
|
|
97
|
+
'Arrows: move | Space: select | Ctrl-U: clear search | Enter: finish | Ctrl-C: cancel'
|
|
98
|
+
)), height=2),
|
|
99
|
+
]), focused_element=search),
|
|
100
|
+
key_bindings=bindings, full_screen=False, erase_when_done=True,
|
|
101
|
+
output=kwargs.pop('output', None) or create_output(stdout=sys.stderr), **kwargs,
|
|
102
|
+
)
|
|
103
|
+
return app.run()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mlspace-call-deployed-model-predict
|
|
3
|
+
description: "Call a deployed model (sync predict) on Cloud.ru MLSpace. Run a live prediction against a deployed inference service. Uses the mlspace_contexts, mlspace_inference tools."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires the `mlspace` MCP server from this plugin (Cloud.ru MLSpace credentials needed).
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Call a deployed model (sync predict)
|
|
9
|
+
|
|
10
|
+
Run a live prediction against a deployed inference service.
|
|
11
|
+
|
|
12
|
+
This skill drives the `mlspace` MCP server shipped in the same plugin. If its
|
|
13
|
+
tools are not connected, say so instead of guessing — none of these steps can be
|
|
14
|
+
carried out any other way.
|
|
15
|
+
|
|
16
|
+
## Arguments
|
|
17
|
+
|
|
18
|
+
- `service_name` **(required)** — Deployed inference service name.
|
|
19
|
+
- `input_payload` **(required)** — JSON input forwarded to the model.
|
|
20
|
+
- `model_name` — Model name within the service (default = service).
|
|
21
|
+
- `region` — Service region (default DGX2-INF-001).
|
|
22
|
+
- `target` — Connected workspace ID or unambiguous name, if scoped.
|
|
23
|
+
|
|
24
|
+
## Procedure
|
|
25
|
+
|
|
26
|
+
Goal: run a synchronous prediction against a deployed inference service.
|
|
27
|
+
NOTE: inference.predict is a side effect — it runs the deployed (billed) model.
|
|
28
|
+
1. Resolve the service target from its address/task or mlspace_contexts selected catalogue.
|
|
29
|
+
2. mlspace_inference list -> find the service whose manifest name matches service_name.
|
|
30
|
+
3. Resolve model_name (often == service_name) and region (arg, else default
|
|
31
|
+
DGX2-INF-001). mlspace_inference get (inference_name, region) to confirm it's Ready.
|
|
32
|
+
4. Echo back service/model/region and that this is a billed call; then
|
|
33
|
+
mlspace_inference predict (inference_name=service_name, model_name, body=input_payload
|
|
34
|
+
passed through unchanged).
|
|
35
|
+
Output: the model's raw prediction JSON + a one-line note of what was called.
|
|
36
|
+
Caveat: the predict response shape is model-defined (no fixed schema).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mlspace-deploy-inference-service
|
|
3
|
+
description: "Deploy a model image as an inference service on Cloud.ru MLSpace. Deploy an already-built registry image as a new inference service. Write action. Uses the mlspace_docker_registry, mlspace_inference, mlspace_resources tools."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires the `mlspace` MCP server from this plugin (Cloud.ru MLSpace credentials needed).
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Deploy a model image as an inference service
|
|
9
|
+
|
|
10
|
+
Deploy an already-built registry image as a new inference service. Write action.
|
|
11
|
+
|
|
12
|
+
This skill drives the `mlspace` MCP server shipped in the same plugin. If its
|
|
13
|
+
tools are not connected, say so instead of guessing — none of these steps can be
|
|
14
|
+
carried out any other way.
|
|
15
|
+
|
|
16
|
+
## Arguments
|
|
17
|
+
|
|
18
|
+
- `image` **(required)** — Registry image ref to deploy.
|
|
19
|
+
- `region` — Target region (default CCE-INF).
|
|
20
|
+
- `instance_type` — Inference instance type.
|
|
21
|
+
- `replicas_min` — Min replicas.
|
|
22
|
+
- `replicas_max` — Max replicas.
|
|
23
|
+
- `alias` — Service alias/name.
|
|
24
|
+
- `description` — Service description.
|
|
25
|
+
- `target` — Connected workspace ID or unambiguous name, if scoped.
|
|
26
|
+
|
|
27
|
+
## Procedure
|
|
28
|
+
|
|
29
|
+
Goal: deploy an already-built registry image as an inference service (write
|
|
30
|
+
action; refused under MLSPACE_READONLY=true).
|
|
31
|
+
1. INTENT GATE: if the user actually wants to BUILD an image, STOP — image build is not
|
|
32
|
+
possible via this API (build-image is multipart-only; no create-repo). Hand off to
|
|
33
|
+
the console/CLI for the build, then come back to deploy the resulting image ref.
|
|
34
|
+
2. (best-effort) verify the image: docker_registry current_registry -> list_repos /
|
|
35
|
+
tags to confirm the ref exists.
|
|
36
|
+
3. mlspace_resources configs cluster_type=INF -> pick a valid inference instance_type.
|
|
37
|
+
4. Build CreateInferenceServiceRequestV2 {image, region (default CCE-INF), instance_type,
|
|
38
|
+
replicas:{min,max}, optional alias/description}. Echo it.
|
|
39
|
+
5. mlspace_inference create (body=...). Capture the service name/status.
|
|
40
|
+
6. mlspace_inference get (inference_name, region) to confirm provisioning.
|
|
41
|
+
Output: the created service alias, image, region, instance_type, replicas, and status.
|
|
42
|
+
Caveat: you can deploy an existing image but cannot build one here.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mlspace-diagnose-data-transfer
|
|
3
|
+
description: "Diagnose a failed / partial data transfer on Cloud.ru MLSpace. Did my data transfer actually fail / land incompletely, and why. Uses the mlspace_data_transfer tools."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires the `mlspace` MCP server from this plugin (Cloud.ru MLSpace credentials needed).
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Diagnose a failed / partial data transfer
|
|
9
|
+
|
|
10
|
+
Did my data transfer actually fail / land incompletely, and why.
|
|
11
|
+
|
|
12
|
+
This skill drives the `mlspace` MCP server shipped in the same plugin. If its
|
|
13
|
+
tools are not connected, say so instead of guessing — none of these steps can be
|
|
14
|
+
carried out any other way.
|
|
15
|
+
|
|
16
|
+
## Arguments
|
|
17
|
+
|
|
18
|
+
- `transfer_id` — Transfer id (or give a name).
|
|
19
|
+
- `transfer_name` — Transfer name to look up.
|
|
20
|
+
- `expected_rows` — Rows you expected, to judge completeness.
|
|
21
|
+
- `act` — If true, may rerun a failed run (after confirm).
|
|
22
|
+
- `target` — Connected workspace ID or unambiguous name, if scoped.
|
|
23
|
+
|
|
24
|
+
## Procedure
|
|
25
|
+
|
|
26
|
+
Goal: determine whether a data transfer truly failed / was partial, and why.
|
|
27
|
+
1. Resolve the transfer: if transfer_id given use it, else data_transfer list_transfers
|
|
28
|
+
and match transfer_name.
|
|
29
|
+
2. data_transfer list_history (transfer_id, page=1, page_size=20 — this endpoint uses
|
|
30
|
+
page/page_size). Take the latest run's history_id + metrics (size_bytes, rows_count,
|
|
31
|
+
progress, status).
|
|
32
|
+
3. data_transfer get_history_status (ids=[history_id]) for the authoritative status
|
|
33
|
+
(in_progress|success|failed|cancelled).
|
|
34
|
+
4. If failed/cancelled, data_transfer get_event_logs (history_id, offset=0, limit=50,
|
|
35
|
+
as_enum_resolved=true) for the decoded reason.
|
|
36
|
+
5. Compare rows_count/size_bytes vs the user's expectation to judge "partial".
|
|
37
|
+
Output: status + hard numbers (rows/bytes/progress) + completeness verdict + decoded
|
|
38
|
+
event reasons; separate "genuinely failed" from "clean exit / fewer rows than expected".
|
|
39
|
+
Only if act=true: rerun_history (body={history_id}) for a failed run, after confirming.
|
|
40
|
+
Caveat: status is only a 4-value enum; "finished but data looks wrong" is inferred from
|
|
41
|
+
the numbers, not a dedicated field.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mlspace-diagnose-failed-training-job
|
|
3
|
+
description: "Diagnose a failed / stuck training job on Cloud.ru MLSpace. Why did my training job fail or why is it stuck Pending; or roll up recent failures. Uses the mlspace_jobs, mlspace_jobs_overview tools."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires the `mlspace` MCP server from this plugin (Cloud.ru MLSpace credentials needed).
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Diagnose a failed / stuck training job
|
|
9
|
+
|
|
10
|
+
Why did my training job fail or why is it stuck Pending; or roll up recent failures.
|
|
11
|
+
|
|
12
|
+
This skill drives the `mlspace` MCP server shipped in the same plugin. If its
|
|
13
|
+
tools are not connected, say so instead of guessing — none of these steps can be
|
|
14
|
+
carried out any other way.
|
|
15
|
+
|
|
16
|
+
## Arguments
|
|
17
|
+
|
|
18
|
+
- `job_name` — Job to diagnose (omit for a recent-failures roll-up).
|
|
19
|
+
- `region` — Region of the job, if known.
|
|
20
|
+
- `days` — Look-back window in days for the roll-up.
|
|
21
|
+
- `job_author` — Filter the roll-up by author email.
|
|
22
|
+
- `queue_id` — Filter the roll-up by queue id.
|
|
23
|
+
- `target` — Connected workspace ID or unambiguous name, if scoped.
|
|
24
|
+
|
|
25
|
+
## Procedure
|
|
26
|
+
|
|
27
|
+
Goal: explain why a training job FAILED or is STUCK PENDING, and/or roll up recent failures.
|
|
28
|
+
Use mlspace_jobs and relevant read-only resource/queue helpers; never run/restart/delete.
|
|
29
|
+
|
|
30
|
+
If a job name is given:
|
|
31
|
+
1. jobs get (job_name only — no region arg). Read status (lowercase) + timeline
|
|
32
|
+
(created_at/pending_at/running_at/completed_at). NOTE error_code/error_message
|
|
33
|
+
here are deprecated constants — ignore them.
|
|
34
|
+
2. jobs get_params (job_name) and record the authoritative script, region, image and
|
|
35
|
+
instance_type. `/home/jovyan` is region-scoped; the same absolute path in another
|
|
36
|
+
notebook region is a different NFS volume.
|
|
37
|
+
3. Classify from the timeline BEFORE logs: running_at>0 & failed -> runtime crash
|
|
38
|
+
(expect traceback); pending with running_at=0 -> never scheduled (capacity/quota);
|
|
39
|
+
terminated -> likely preempted.
|
|
40
|
+
4. jobs logs (job_name, region from get_params, tail=100, verbose=true; raise tail
|
|
41
|
+
to ~300 if a traceback is cut off). This is the actual error.
|
|
42
|
+
If the job failed before the first user-script line and the launcher cannot access
|
|
43
|
+
the entrypoint: for type=binary check that the command/executable exists in the
|
|
44
|
+
selected image and that its arguments are valid; for an NFS script, treat a wrong
|
|
45
|
+
regional NFS as the leading cause even when the path begins with /home/jovyan.
|
|
46
|
+
5. jobs list_pods (job_name) -> per-pod status + `reason` (OOMKilled / Unschedulable /
|
|
47
|
+
ImagePull...). This is the key "why" for both crash and pending.
|
|
48
|
+
6. If pending/terminated: jobs get_preemptors (job_name) -> who preempted; and check
|
|
49
|
+
capacity (see the discover_launchable_resources / capacity playbook): instance
|
|
50
|
+
availability + node load in the job's region.
|
|
51
|
+
|
|
52
|
+
For a recent-failures roll-up: mlspace_jobs_overview with status=["Failed","Terminated"]
|
|
53
|
+
and author=job_author when explicitly known, plus targets for an explicitly restricted
|
|
54
|
+
workspace scope. Regions are discovered dynamically and pages are collected by the
|
|
55
|
+
helper. Filter returned data by the requested days/queue/region, disclosing incomplete
|
|
56
|
+
coverage. If exact server-side date/queue/region filtering is needed, use jobs list
|
|
57
|
+
in each selected context and discovered MT region with start_date/end_date (RFC3339 Z),
|
|
58
|
+
queue_id and pagination; never treat one page as the full set. Group observed failures
|
|
59
|
+
by instance_type/author/queue, retaining each source/resource_ref.
|
|
60
|
+
|
|
61
|
+
Output: a one-line verdict (status + inferred cause), the phase timeline, the key
|
|
62
|
+
log excerpt, pod reason, and a concrete next step.
|
|
63
|
+
Caveats: no per-pod logs beyond the job log stream; capacity is inferred from node
|
|
64
|
+
load + preemptors (no quota/cost numbers exist in this API).
|