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
mlspace_mcp/jsonc.py ADDED
@@ -0,0 +1,90 @@
1
+ """Small JSONC editor: validate fully, replace only the requested object's member."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import Any
7
+
8
+ _TOKEN = re.compile(r'"(?:[^"\\]|\\.)*"|//[^\n]*|/\*[\s\S]*?\*/|\s+|[^\s"{}\[\],:]+|[{}\[\],:]')
9
+
10
+
11
+ def _tokens(text: str) -> list[re.Match[str]]:
12
+ tokens = list(_TOKEN.finditer(text))
13
+ if ''.join(t.group() for t in tokens) != text:
14
+ raise ValueError('Invalid JSONC')
15
+ return [t for t in tokens if not t.group().isspace() and not t.group().startswith(('//', '/*'))]
16
+
17
+
18
+ def _unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
19
+ result = {}
20
+ for key, value in pairs:
21
+ if key in result:
22
+ raise ValueError('Duplicate configuration key; resolve it before setup.')
23
+ result[key] = value
24
+ return result
25
+
26
+
27
+ def loads(text: str) -> dict[str, Any]:
28
+ tokens = _tokens(text)
29
+ cleaned = ''.join(t.group() for i, t in enumerate(tokens)
30
+ if not (t.group() == ',' and i + 1 < len(tokens)
31
+ and tokens[i+1].group() in ('}', ']')))
32
+ value = json.loads(cleaned, object_pairs_hook=_unique)
33
+ if not isinstance(value, dict):
34
+ raise ValueError('Configuration must be an object.')
35
+ return value
36
+
37
+
38
+ def _members(text: str) -> tuple[dict[str, tuple[int, int]], int, bool]:
39
+ tokens = _tokens(text)
40
+ members = {}
41
+ i = 1
42
+ while i < len(tokens) and tokens[i].group() != '}':
43
+ key = json.loads(tokens[i].group())
44
+ i += 2 # key and colon
45
+ start = tokens[i].start()
46
+ depth = 0
47
+ while i < len(tokens):
48
+ token = tokens[i].group()
49
+ if token in ('{', '['):
50
+ depth += 1
51
+ elif token in ('}', ']'):
52
+ if depth == 0:
53
+ break
54
+ depth -= 1
55
+ elif token == ',' and depth == 0:
56
+ break
57
+ end = tokens[i].end()
58
+ i += 1
59
+ members[key] = (start, end)
60
+ if tokens[i].group() == ',':
61
+ i += 1
62
+ return members, tokens[i].start(), tokens[i-1].group() == ','
63
+
64
+
65
+ def set_member(text: str, keys: list[str], value: Any) -> str:
66
+ data = loads(text) # validates nesting, duplicate keys, syntax before any modification
67
+ key = keys[0]
68
+ members, closing, trailing_comma = _members(text)
69
+ if key in members:
70
+ start, end = members[key]
71
+ if len(keys) > 1:
72
+ replacement = set_member(text[start:end], keys[1:], value)
73
+ else:
74
+ if data[key] == value:
75
+ return text
76
+ replacement = json.dumps(value, ensure_ascii=False, indent=2)
77
+ return text[:start] + replacement + text[end:]
78
+ nested = value
79
+ for part in reversed(keys[1:]):
80
+ nested = {part: nested}
81
+ prefix = ',' if members and not trailing_comma else ''
82
+ # Insert comma immediately after the preceding value, before any // comment.
83
+ if prefix:
84
+ last_end = list(members.values())[-1][1]
85
+ text = text[:last_end] + ',' + text[last_end:]
86
+ closing += 1
87
+ insertion = '\n ' + json.dumps(key) + ': ' + json.dumps(nested, ensure_ascii=False, indent=2) + '\n'
88
+ result = text[:closing] + insertion + text[closing:]
89
+ loads(result)
90
+ return result
@@ -0,0 +1,421 @@
1
+ """Bounded read inventories. Fetch coverage is not an attestation of API visibility."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Literal
11
+ from urllib.parse import quote
12
+
13
+ from mcp.server.fastmcp import Context, FastMCP
14
+ from mcp.types import ToolAnnotations
15
+
16
+ from ._status import JOB_STATUS_CANON
17
+ from .config import Settings
18
+ from .errors import MLSpaceError
19
+ from .resource_refs import response_references
20
+
21
+ PRIORITIES = ('shared-low', 'shared-medium', 'shared-cluster', 'low', 'medium', 'high')
22
+ # Positive field selection: inventory endpoints can contain S3 credentials, env, etc.
23
+ _FIELDS = frozenset(('id uid uuid name job_name job_id notebook_uuid namespace workspace '
24
+ 'workspace_id region status phase state author job_author user_email node_name nodes '
25
+ 'instance_type instanceType creationTimestamp created_at type priority gpu_count '
26
+ 'unschedulable_worker_count n_workers launch_order allocation_id queue_id queue_label '
27
+ 'allocation_label allocation_name queue_name created_dt updated_dt completed_dt job_desc '
28
+ 'unschedulable resource_usage queue gpu_type cpu memory ram gpu used total allocated '
29
+ 'requested available limit limits count postfix instance_types jobs notebooks').split())
30
+
31
+
32
+ def _inventory(value: Any) -> Any:
33
+ if isinstance(value, dict):
34
+ return {k: _inventory(v) for k, v in value.items() if k in _FIELDS}
35
+ if isinstance(value, list):
36
+ return [_inventory(v) for v in value]
37
+ return value
38
+
39
+
40
+ def _segment(value: str) -> str:
41
+ if not value.strip() or value in {'.', '..'} or re.search(r'[/\\?#%\x00-\x1f\x7f]', value):
42
+ raise MLSpaceError('Invalid opaque resource address.')
43
+ return quote(value, safe='')
44
+
45
+
46
+ def _rows(data: Any, key: str) -> list[dict]:
47
+ if not isinstance(data, dict) or not isinstance(data.get(key), list):
48
+ raise MLSpaceError('malformed_response')
49
+ rows = data[key]
50
+ if not all(isinstance(row, dict) for row in rows):
51
+ raise MLSpaceError('malformed_response')
52
+ return rows
53
+
54
+
55
+ def _node_facts(row: dict) -> None:
56
+ """Validate the operational facts required by NodeOverview before claiming coverage."""
57
+ if any(not isinstance(row.get(key), str) or not row[key].strip()
58
+ for key in ('name', 'status', 'gpu_type')) or type(row.get('unschedulable')) is not bool:
59
+ raise MLSpaceError('malformed_response')
60
+ _rows(row, 'instance_types')
61
+ usage = row.get('resource_usage')
62
+ if not isinstance(usage, dict):
63
+ raise MLSpaceError('malformed_response')
64
+ for key in ('total', 'allocated', 'available'):
65
+ resources = usage.get(key)
66
+ if not isinstance(resources, dict):
67
+ raise MLSpaceError('malformed_response')
68
+ for field in ('cpu', 'ram', 'gpu'):
69
+ value = resources.get(field)
70
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
71
+ raise MLSpaceError('malformed_response')
72
+
73
+
74
+ def _identity(row: dict) -> str:
75
+ for key in ('id', 'uid', 'uuid', 'job_name', 'name'):
76
+ if isinstance(row.get(key), str) and row[key]:
77
+ return json.dumps([key, row[key], row.get('workspace_id'), row.get('namespace'),
78
+ row.get('type')], sort_keys=True)
79
+ # No documented stable identity: exact duplicate pages still must not loop.
80
+ return hashlib.sha256(json.dumps(row, sort_keys=True).encode()).hexdigest()
81
+
82
+
83
+ class _Read:
84
+ def __init__(self, clients: Any, targets: list[str] | None, page_size: int,
85
+ max_pages: int, max_requests: int, max_items: int):
86
+ for value, upper in ((page_size, 1000), (max_pages, 200),
87
+ (max_requests, 1000), (max_items, 1000)):
88
+ if type(value) is not int or not 1 <= value <= upper:
89
+ raise MLSpaceError(f'Budget must be an integer between 1 and {upper}.')
90
+ self.clients = clients
91
+ self.page_size, self.max_pages = page_size, max_pages
92
+ self.remaining, self.max_items = max_requests, max_items
93
+ self.semaphore = asyncio.Semaphore(4)
94
+ self.output_bytes = 0
95
+ self.out: dict[str, Any] = {
96
+ 'environment': clients.environment,
97
+ 'observed_at': datetime.now(timezone.utc).isoformat(),
98
+ 'requested_scope': {'targets': targets if targets is not None
99
+ else [s.id for s in clients.selected]},
100
+ 'connected_contexts': [{'id': s.id, 'name': s.name, 'project_name': s.project_name}
101
+ for s in clients.selected],
102
+ 'checked_scope': [], 'failures': [], 'items': [], 'observed_count': 0,
103
+ 'output_truncated': False,
104
+ }
105
+ self.targets: list[str] = []
106
+ for target in self.out['requested_scope']['targets']:
107
+ try:
108
+ ident = clients.select(target).id
109
+ if ident not in self.targets:
110
+ self.targets.append(ident)
111
+ except MLSpaceError:
112
+ self.fail({'target': target}, 'unconfigured_or_ambiguous_target')
113
+ if not self.targets:
114
+ self.fail({}, 'empty_scope')
115
+
116
+ def fail(self, scope: dict, reason: str) -> None:
117
+ self.out['failures'].append({**scope, 'reason': reason})
118
+
119
+ async def get(self, target: str, path: str, params: dict | None = None) -> Any:
120
+ async with self.semaphore:
121
+ if self.remaining <= 0:
122
+ raise MLSpaceError('request_budget_exhausted')
123
+ self.remaining -= 1
124
+
125
+ async def request() -> Any:
126
+ client = await self.clients.resolve(target)
127
+ return await client.request('GET', path, params=params)
128
+ return await asyncio.wait_for(request(), timeout=30)
129
+
130
+ def error(self, scope: dict, exc: Exception) -> None:
131
+ # Provider bodies / exception strings may carry credentials: never echo them.
132
+ safe = {'request_budget_exhausted', 'malformed_response', 'inconsistent_count',
133
+ 'repeated_items', 'unknown_regions', 'missing_node_facts', 'queue_identity_mismatch'}
134
+ reason = str(exc) if isinstance(exc, MLSpaceError) and str(exc) in safe else type(exc).__name__
135
+ self.fail(scope, reason)
136
+ status = getattr(exc, 'status', None)
137
+ if type(status) is int:
138
+ self.out['failures'][-1]['http_status'] = status
139
+
140
+ def add(self, row: dict, scope: dict, *, owner: bool = False) -> None:
141
+ provenance = {'environment': self.clients.environment,
142
+ 'request_workspace_id': scope['target'],
143
+ 'endpoint': scope['endpoint']}
144
+ if owner:
145
+ provenance['workspace_id'] = row.get('workspace_id', scope['target'])
146
+ if scope.get('region'):
147
+ provenance['region'] = scope['region']
148
+ item = {'source': scope['source'], 'data': _inventory(row), 'provenance': provenance}
149
+ if owner:
150
+ refs = response_references(scope['source'], 'list', [row], environment=self.clients.environment,
151
+ target=scope['target'], values=scope, list_cap=1)
152
+ if refs:
153
+ item['resource_ref'] = refs[0]
154
+ if 'priority' in scope:
155
+ item['priority'] = scope['priority']
156
+ self.out['observed_count'] += 1
157
+ size = len(json.dumps(item).encode())
158
+ if len(self.out['items']) >= self.max_items or self.output_bytes + size > 200_000:
159
+ self.out['output_truncated'] = True
160
+ else:
161
+ self.out['items'].append(item)
162
+ self.output_bytes += size
163
+
164
+ async def pages(self, target: str, path: str, key: str, *, counted: bool,
165
+ params: dict | None = None, owner: bool = False, source: str | None = None) -> None:
166
+ query = dict(params or {})
167
+ scope = {'target': target, 'endpoint': path, 'source': source or key, **query,
168
+ 'pages_read': 0, 'next_offset': 0, 'fetch_complete': False}
169
+ self.out['checked_scope'].append(scope)
170
+ seen: set[str] = set()
171
+ expected = None
172
+ try:
173
+ for _ in range(self.max_pages):
174
+ data = await self.get(target, path, {**query, 'limit': self.page_size,
175
+ 'offset': scope['next_offset']})
176
+ rows = _rows(data, key)
177
+ scope['pages_read'] += 1
178
+ if counted:
179
+ count = data.get('count')
180
+ if type(count) is not int or count < 0 or (expected is not None and count != expected):
181
+ raise MLSpaceError('inconsistent_count')
182
+ expected = count
183
+ if scope['next_offset'] + len(rows) > count or (not rows and scope['next_offset'] != count):
184
+ raise MLSpaceError('inconsistent_count')
185
+ identities = [_identity(row) for row in rows]
186
+ # Repeated identities make coverage uncertain, but the observed
187
+ # workload must remain visible. add() still applies redaction and
188
+ # output limits; observed_count is not a unique-resource total.
189
+ for row in rows:
190
+ self.add(row, scope, owner=owner)
191
+ if len(set(identities)) != len(identities) or seen.intersection(identities):
192
+ raise MLSpaceError('repeated_items')
193
+ seen.update(identities)
194
+ scope['next_offset'] += len(rows)
195
+ if not rows or (counted and scope['next_offset'] == expected):
196
+ scope['fetch_complete'] = True
197
+ return
198
+ self.fail(scope, 'page_budget_exhausted')
199
+ except Exception as exc:
200
+ self.error(scope, exc)
201
+
202
+ def finish(self) -> dict:
203
+ self.out['fetch_complete'] = not self.out['failures']
204
+ self.out['complete'] = self.out['fetch_complete'] and not self.out['output_truncated']
205
+ self.out['budget_basis'] = 'max_requests counts logical inventory GET calls; client authentication, lazy key discovery and a possible 401 retry are additional transport calls'
206
+ self.out['count_basis'] = 'observations; not a deduplicated resource or capacity total'
207
+ # Metadata (e.g. unexpectedly huge region names) is also provider-controlled.
208
+ # Preserve the truth flags even if a whole section cannot fit in the response.
209
+ for key in ('checked_scope', 'items', 'failures', 'candidates', 'connected_contexts',
210
+ 'requested_scope', 'filtering_basis'):
211
+ if len(json.dumps(self.out).encode()) <= 250_000:
212
+ break
213
+ if key not in self.out:
214
+ continue
215
+ old = self.out[key]
216
+ self.out.setdefault('omitted_output', {})[key] = {
217
+ 'entries': len(old), 'reason': 'output_byte_budget'}
218
+ self.out[key] = [] if isinstance(old, list) else {'omitted': True}
219
+ self.out['output_truncated'] = True
220
+ self.out['complete'] = False
221
+ return self.out
222
+
223
+
224
+ async def aggregate(clients: Any, kind: Literal['jobs', 'notebooks'], *,
225
+ targets: list[str] | None = None, author: str | None = None,
226
+ status: list[str] | None = None, page_size: int = 100, max_pages: int = 50, max_requests: int = 200,
227
+ max_items: int = 200) -> dict:
228
+ if kind not in {'jobs', 'notebooks'} or ((author is not None or status is not None) and kind != 'jobs'):
229
+ raise MLSpaceError('Only jobs support an explicit author filter.')
230
+ read = _Read(clients, targets, page_size, max_pages, max_requests, max_items)
231
+ read.out['requested_scope']['kind'] = kind
232
+ normalized_status = [JOB_STATUS_CANON.get(s.casefold(), s) for s in status] if status is not None else None
233
+ if normalized_status is not None:
234
+ read.out['requested_scope']['status'] = normalized_status
235
+ read.out['filtering_basis'] = {'author': author, 'basis': 'explicit_user_filter' if author is not None else 'all_workspace_authors'}
236
+
237
+ async def workspace(target: str) -> None:
238
+ if kind == 'notebooks':
239
+ await read.pages(target, '/public/v2/notebooks/v2/notebooks', 'notebooks',
240
+ counted=False, owner=True)
241
+ return
242
+ # The launch catalog can omit allocated regions that still contain jobs.
243
+ # Read both sources independently so either failure retains known regions
244
+ # while keeping the overall result explicitly partial.
245
+ keys: list[str] = []
246
+ for source, path, query in (
247
+ ('regions', '/public/v2/configs', {'cluster_type': 'MT'}),
248
+ ('allocation_regions', '/public/v2/workspaces/v3/{workspace_id}/allocations', None),
249
+ ):
250
+ scope = {'target': target, 'endpoint': path, 'source': source, 'fetch_complete': False}
251
+ read.out['checked_scope'].append(scope)
252
+ try:
253
+ if source == 'allocation_regions':
254
+ path = path.format(workspace_id=_segment(target))
255
+ scope['endpoint'] = path
256
+ data = await read.get(target, path, query)
257
+ rows = _rows(data, 'regions') if source == 'regions' else _rows({'allocations': data}, 'allocations')
258
+ field = 'key' if source == 'regions' else 'region_key'
259
+ discovered: list[str] = []
260
+ for row in rows:
261
+ key = row.get(field)
262
+ if not isinstance(key, str) or not key.strip():
263
+ raise MLSpaceError('unknown_regions')
264
+ discovered.append(key)
265
+ keys.extend(discovered)
266
+ scope['regions'] = list(dict.fromkeys(discovered))
267
+ scope['fetch_complete'] = True
268
+ except Exception as exc:
269
+ read.error(scope, exc)
270
+ if not keys:
271
+ read.fail({'target': target, 'source': 'regions'}, 'unknown_regions')
272
+ for region in dict.fromkeys(keys):
273
+ params: dict[str, Any] = {'region': region}
274
+ if normalized_status is not None:
275
+ params['status'] = normalized_status
276
+ if author is not None:
277
+ params['job_author'] = [author]
278
+ await read.pages(target, '/public/v2/jobs', 'jobs', counted=True,
279
+ params=params, owner=True)
280
+ await asyncio.gather(*(workspace(target) for target in read.targets))
281
+ return read.finish()
282
+
283
+
284
+ async def inspect_queue(clients: Any, queue_id: str, *, target: str | None = None,
285
+ candidate_nodes: list[str] | None = None, targets: list[str] | None = None,
286
+ page_size: int = 100, max_pages: int = 50, max_requests: int = 200,
287
+ max_items: int = 200) -> dict:
288
+ primary = clients.select(target).id
289
+ queue_segment = _segment(queue_id)
290
+ read = _Read(clients, targets, page_size, max_pages, max_requests, max_items)
291
+ read.out['requested_scope'].update(queue_id=queue_id, source_target=primary)
292
+ read.out['cross_workspace_coverage'] = {
293
+ 'status': 'unknown',
294
+ 'reason': 'Queue API does not attest all workspace visibility, even when every connected context responds.',
295
+ }
296
+ read.out['interpretation'] = {
297
+ 'active_workload': 'Inspect observed pod status and node load; retain contradictions with notebook status.',
298
+ 'pending_demand': 'All six priorities queried separately; duplicate observations are not summed.',
299
+ 'paused_notebooks': 'Inventory only; configured GPU does not prove active consumption.',
300
+ 'capacity': 'Node facts fetched once from the source context; no capacity summation.',
301
+ }
302
+ path = '/public/v2/queues/' + queue_segment
303
+
304
+ async def single(ws: str, endpoint: str, key: str, params: dict | None = None) -> list[dict] | None:
305
+ scope = {'target': ws, 'endpoint': endpoint, 'source': key, 'fetch_complete': False}
306
+ read.out['checked_scope'].append(scope)
307
+ try:
308
+ rows = _rows(await read.get(ws, endpoint, params), key)
309
+ if key == 'nodes':
310
+ for row in rows:
311
+ _node_facts(row)
312
+ if key == 'loads':
313
+ for row in rows:
314
+ if not isinstance(row.get('node_name'), str):
315
+ raise MLSpaceError('malformed_response')
316
+ _rows(row, 'jobs')
317
+ _rows(row, 'notebooks')
318
+ for row in rows:
319
+ read.add(row, scope)
320
+ scope['fetch_complete'] = True
321
+ return rows
322
+ except Exception as exc:
323
+ read.error(scope, exc)
324
+ return None
325
+
326
+ async def details(ws: str) -> dict | None:
327
+ scope = {'target': ws, 'endpoint': path, 'source': 'queue', 'fetch_complete': False}
328
+ read.out['checked_scope'].append(scope)
329
+ try:
330
+ data = await read.get(ws, path)
331
+ if not isinstance(data, dict) or data.get('id') != queue_id:
332
+ raise MLSpaceError('queue_identity_mismatch')
333
+ if not isinstance(data.get('nodes'), list) or not all(isinstance(n, str) for n in data['nodes']) or not isinstance(data.get('allocation_id'), str):
334
+ raise MLSpaceError('malformed_response')
335
+ _segment(data['allocation_id'])
336
+ read.add(data, scope)
337
+ scope['fetch_complete'] = True
338
+ return data
339
+ except Exception as exc:
340
+ read.error(scope, exc)
341
+ return None
342
+
343
+ source = await details(primary)
344
+ nodes = source['nodes'] if source else []
345
+ requested_nodes = list(dict.fromkeys(candidate_nodes if candidate_nodes is not None else nodes))
346
+ valid = [n for n in requested_nodes if n in nodes]
347
+ invalid = [n for n in requested_nodes if n not in nodes]
348
+ read.out['candidates'] = {'validated': valid[:max_items], 'invalid': invalid[:max_items],
349
+ 'membership_known': source is not None}
350
+ if len(valid) > max_items or len(invalid) > max_items:
351
+ read.out['output_truncated'] = True
352
+ if invalid:
353
+ read.fail({'target': primary, 'source': 'candidates'}, 'candidate_not_in_source_queue')
354
+
355
+ async def workspace(ws: str) -> None:
356
+ if ws != primary:
357
+ sibling = await details(ws)
358
+ if sibling is None:
359
+ return
360
+ if source and sibling['allocation_id'] != source['allocation_id']:
361
+ read.fail({'target': ws, 'source': 'queue'}, 'queue_identity_mismatch')
362
+ return
363
+ await asyncio.gather(*(single(ws, path + '/' + key, key)
364
+ for key in ('jobs', 'notebooks', 'pods')))
365
+ # Separate priority pages preserve coverage and never sum shared observations.
366
+ for priority in PRIORITIES:
367
+ await read.pages(ws, path + '/awaiting-launch-resources', 'items', counted=True,
368
+ params={'priority': priority}, source='pending')
369
+
370
+ await asyncio.gather(*(workspace(ws) for ws in read.targets))
371
+ if source and valid:
372
+ allocation = _segment(source['allocation_id'])
373
+ facts = await single(primary, f'/public/v2/allocations/{allocation}/nodes', 'nodes')
374
+ loads: list[dict] = []
375
+ for start in range(0, len(valid), 100):
376
+ batch = await single(primary, '/public/v2/nodes/load', 'loads',
377
+ {'node_names': valid[start:start + 100]})
378
+ if batch is not None:
379
+ loads.extend(batch)
380
+ for rows, key in ((facts, 'name'), (loads, 'node_name')):
381
+ if rows is not None and not set(valid).issubset({r.get(key) for r in rows}):
382
+ read.fail({'target': primary, 'source': key}, 'missing_node_facts')
383
+ return read.finish()
384
+
385
+
386
+ def register_overviews(mcp: FastMCP, settings: Settings) -> None:
387
+ def runtime(ctx: Context) -> Any:
388
+ return ctx.request_context.lifespan_context.clients
389
+
390
+ async def mlspace_jobs_overview(ctx: Context, targets: list[str] | None = None,
391
+ author: str | None = None, status: list[str] | None = None, page_size: int = 100,
392
+ max_pages: int = 50, max_requests: int = 200,
393
+ max_items: int = 200) -> str:
394
+ return json.dumps(await aggregate(runtime(ctx), 'jobs', targets=targets, author=author, status=status,
395
+ page_size=page_size, max_pages=max_pages, max_requests=max_requests, max_items=max_items))
396
+
397
+ async def mlspace_notebooks_overview(ctx: Context, targets: list[str] | None = None,
398
+ page_size: int = 100, max_pages: int = 50,
399
+ max_requests: int = 200, max_items: int = 200) -> str:
400
+ return json.dumps(await aggregate(runtime(ctx), 'notebooks', targets=targets,
401
+ page_size=page_size, max_pages=max_pages, max_requests=max_requests, max_items=max_items))
402
+
403
+ async def mlspace_queue_inspect(queue_id: str, ctx: Context, target: str | None = None,
404
+ candidate_nodes: list[str] | None = None,
405
+ targets: list[str] | None = None, page_size: int = 100,
406
+ max_pages: int = 50, max_requests: int = 200,
407
+ max_items: int = 200) -> str:
408
+ return json.dumps(await inspect_queue(runtime(ctx), queue_id, target=target,
409
+ candidate_nodes=candidate_nodes, targets=targets, page_size=page_size,
410
+ max_pages=max_pages, max_requests=max_requests, max_items=max_items))
411
+
412
+ enabled = settings.enabled_domain_set
413
+ annotations = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
414
+ idempotentHint=True, openWorldHint=True)
415
+ for domain, fn, description in (
416
+ ('jobs', mlspace_jobs_overview, 'Read jobs across all saved contexts (or explicit targets), regions discovered from both the MT launch catalog and workspace allocations, and pages. Optional status filters jobs before pagination; author is an explicit filter, not verified personal identity.'),
417
+ ('notebooks', mlspace_notebooks_overview, 'Read notebook inventory across all saved contexts (or explicit targets), including paused notebooks and their status. Configured GPUs do not prove activity.'),
418
+ ('queues', mlspace_queue_inspect, 'Inspect a source queue before considering a compute-node move: jobs/notebooks/pods, all six pending priorities, candidate membership and node facts. target selects source; targets selects observation contexts (default all saved). Cross-workspace visibility remains unknown; no safety-to-move conclusion.'),
419
+ ):
420
+ if enabled is None or domain in enabled:
421
+ mcp.add_tool(fn, description=description + ' Bounded reads report requested/checked scope, failures, fetch_complete and output_truncated; complete requires both full fetch and full output. max_pages applies per stream, max_requests globally; max_items bounds output only.', annotations=annotations)