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,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
@@ -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).