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,235 @@
|
|
|
1
|
+
"""User-scoped client registration and packaged skill installation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import user_config_file
|
|
18
|
+
from .jsonc import loads as load_jsonc
|
|
19
|
+
from .jsonc import set_member as set_jsonc_member
|
|
20
|
+
|
|
21
|
+
CLIENTS = {'claude-code': 'claude', 'codex': 'codex', 'opencode': 'opencode'}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def detect_clients() -> dict[str, str]:
|
|
25
|
+
return {name: path for name, executable in CLIENTS.items()
|
|
26
|
+
if (path := shutil.which(executable))}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def server_command() -> list[str]:
|
|
30
|
+
source = Path(__file__).resolve()
|
|
31
|
+
# Package-index installs may run inside an evictable `uvx` environment.
|
|
32
|
+
# Pin the runtime to the skills version, never to a cache directory or latest.
|
|
33
|
+
cached = any(re.fullmatch(r'archive-v\d+', part) for part in source.parts)
|
|
34
|
+
if cached and 'site-packages' in source.parts and (uvx := shutil.which('uvx')):
|
|
35
|
+
if any(source.parent.parent.glob('mlspace_plugin-*.dist-info/direct_url.json')):
|
|
36
|
+
raise ValueError('For a local wheel, use `uv tool install /path/to/package.whl` '
|
|
37
|
+
'before setup, or use setup --config-only for a native plugin.')
|
|
38
|
+
return [uvx, '--from', f'mlspace-plugin=={__version__}',
|
|
39
|
+
'mlspace-plugin', '--transport', 'stdio']
|
|
40
|
+
executable = Path(sys.executable).parent / 'mlspace-plugin'
|
|
41
|
+
if not executable.is_file():
|
|
42
|
+
raise ValueError('Install the package with `uv tool install .` before connecting clients.')
|
|
43
|
+
# An editable installation under /tmp disappears on reboot; reject that source.
|
|
44
|
+
if source.is_relative_to(Path(tempfile.gettempdir()).resolve()) or str(source).startswith('/tmp/'):
|
|
45
|
+
raise ValueError('Temporary installation: install the wheel with `uv tool install` first.')
|
|
46
|
+
return [str(executable.absolute()), '--transport', 'stdio']
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def atomic_write(path: Path, text: str, mode: int = 0o600) -> None:
|
|
50
|
+
if path.is_symlink():
|
|
51
|
+
raise ValueError(f'Refusing to replace a symlink: {path}')
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
53
|
+
fd, name = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
|
54
|
+
try:
|
|
55
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
56
|
+
os.fchmod(stream.fileno(), mode)
|
|
57
|
+
stream.write(text)
|
|
58
|
+
stream.flush()
|
|
59
|
+
os.fsync(stream.fileno())
|
|
60
|
+
os.replace(name, path)
|
|
61
|
+
finally:
|
|
62
|
+
Path(name).unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _skill_sources() -> dict[str, str]:
|
|
66
|
+
bundled = files('mlspace_mcp').joinpath('skills')
|
|
67
|
+
# Editable installs use the very same versioned source that goes into the wheel.
|
|
68
|
+
source = Path(__file__).resolve().parents[2] / 'plugin/skills'
|
|
69
|
+
root = bundled if bundled.is_dir() else source
|
|
70
|
+
result = {item.name: item.joinpath('SKILL.md').read_text(encoding='utf-8')
|
|
71
|
+
for item in root.iterdir() if item.is_dir() and item.joinpath('SKILL.md').is_file()}
|
|
72
|
+
if not result:
|
|
73
|
+
raise ValueError('No packaged skills found; reinstall the MLSpace wheel.')
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hash(text: str) -> str:
|
|
78
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _config(client: str) -> Path:
|
|
82
|
+
if client == 'claude-code':
|
|
83
|
+
custom = os.environ.get('CLAUDE_CONFIG_DIR')
|
|
84
|
+
return Path(custom) / '.claude.json' if custom else Path.home() / '.claude.json'
|
|
85
|
+
if client == 'codex':
|
|
86
|
+
return Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'config.toml'
|
|
87
|
+
explicit = os.environ.get('OPENCODE_CONFIG')
|
|
88
|
+
if explicit:
|
|
89
|
+
return Path(explicit).expanduser().absolute()
|
|
90
|
+
root = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) / 'opencode'
|
|
91
|
+
jsonc, plain = root / 'opencode.jsonc', root / 'opencode.json'
|
|
92
|
+
if jsonc.exists() and plain.exists():
|
|
93
|
+
raise ValueError('Both opencode.json and opencode.jsonc exist; select OPENCODE_CONFIG explicitly.')
|
|
94
|
+
return jsonc if jsonc.exists() else plain
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_config(path: Path) -> dict[str, Any]:
|
|
98
|
+
if not path.exists():
|
|
99
|
+
return {}
|
|
100
|
+
if path.suffix == '.toml':
|
|
101
|
+
if sys.version_info >= (3, 11):
|
|
102
|
+
import tomllib
|
|
103
|
+
else:
|
|
104
|
+
import tomli as tomllib
|
|
105
|
+
return dict(tomllib.loads(path.read_text()))
|
|
106
|
+
return load_jsonc(path.read_text())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare_clients(clients: dict[str, str], env_file: Path, command: list[str]) -> dict[str, Any]:
|
|
110
|
+
"""Plan all writes and reject conflicts before setup saves credentials."""
|
|
111
|
+
state_path = user_config_file().parent / 'install-state.json'
|
|
112
|
+
state = json.loads(state_path.read_text()) if state_path.exists() else {}
|
|
113
|
+
sources = _skill_sources()
|
|
114
|
+
writes: dict[Path, str] = {}
|
|
115
|
+
registrations = []
|
|
116
|
+
for client, executable in clients.items():
|
|
117
|
+
if client not in CLIENTS:
|
|
118
|
+
raise ValueError('Unknown client: ' + client)
|
|
119
|
+
config = _config(client)
|
|
120
|
+
if config.is_symlink():
|
|
121
|
+
raise ValueError(f'Client config is a symlink; configure it manually: {config}')
|
|
122
|
+
data = _read_config(config)
|
|
123
|
+
key = {'claude-code': 'mcpServers', 'codex': 'mcp_servers', 'opencode': 'mcp'}[client]
|
|
124
|
+
entries = data.get(key, {})
|
|
125
|
+
if not isinstance(entries, dict):
|
|
126
|
+
raise ValueError(f'Invalid MCP configuration: {config}')
|
|
127
|
+
for name, entry in entries.items():
|
|
128
|
+
if name != 'mlspace' and ('mlspace' in name.lower() or 'mlspace-plugin' in json.dumps(entry)):
|
|
129
|
+
raise ValueError(f'Existing MLSpace integration {name} in {config}; migrate it first.')
|
|
130
|
+
# Enabled native plugins can register a second server and duplicate skills.
|
|
131
|
+
settings = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(Path.home() / '.claude'))) / 'settings.json'
|
|
132
|
+
plugin_data = _read_config(settings) if client == 'claude-code' else data
|
|
133
|
+
plugins = plugin_data.get('enabledPlugins', plugin_data.get('plugins', {}))
|
|
134
|
+
if isinstance(plugins, dict) and any('mlspace' in name.lower() and
|
|
135
|
+
(value.get('enabled', True) if isinstance(value, dict) else value) is not False
|
|
136
|
+
for name, value in plugins.items()):
|
|
137
|
+
raise ValueError(f'Existing MLSpace plugin in {client}; disable or migrate it first.')
|
|
138
|
+
entry = ({'type': 'local', 'command': command,
|
|
139
|
+
'environment': {'MLSPACE_ENV_FILE': str(env_file.absolute())}, 'enabled': True}
|
|
140
|
+
if client == 'opencode' else
|
|
141
|
+
{'command': command[0], 'args': command[1:],
|
|
142
|
+
'env': {'MLSPACE_ENV_FILE': str(env_file.absolute())}})
|
|
143
|
+
current = entries.get('mlspace')
|
|
144
|
+
previous = state.get('clients', {}).get(client)
|
|
145
|
+
if current is not None:
|
|
146
|
+
comparable = dict(current)
|
|
147
|
+
comparable.pop('type', None) if client != 'opencode' else None
|
|
148
|
+
if comparable != entry and _hash(json.dumps(current, sort_keys=True)) != previous:
|
|
149
|
+
raise ValueError(f'Existing mlspace entry differs in {config}; it was not overwritten.')
|
|
150
|
+
registrations.append((client, executable, config, entry, current))
|
|
151
|
+
skills_root = (Path(os.environ.get('CLAUDE_CONFIG_DIR', str(Path.home() / '.claude'))) / 'skills'
|
|
152
|
+
if client == 'claude-code' else Path.home() / '.agents/skills')
|
|
153
|
+
for name, content in sources.items():
|
|
154
|
+
target = skills_root / name / 'SKILL.md'
|
|
155
|
+
if client == 'opencode':
|
|
156
|
+
# OpenCode discovers these shared locations; reuse an existing skill.
|
|
157
|
+
roots = [Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) / 'opencode/skills',
|
|
158
|
+
Path.home() / '.claude/skills', Path.home() / '.agents/skills']
|
|
159
|
+
existing = [root / name / 'SKILL.md' for root in roots
|
|
160
|
+
if (root / name / 'SKILL.md').exists() or root / name / 'SKILL.md' in writes]
|
|
161
|
+
if existing:
|
|
162
|
+
for candidate in existing:
|
|
163
|
+
content_now = writes[candidate] if candidate in writes else candidate.read_text()
|
|
164
|
+
if content_now != content and state.get('skills', {}).get(str(candidate)) != _hash(content_now):
|
|
165
|
+
raise ValueError(f'Existing skill has local changes: {candidate}')
|
|
166
|
+
target = existing[0]
|
|
167
|
+
if target.is_symlink() or target.parent.is_symlink():
|
|
168
|
+
if target.is_file() and target.read_text() == content:
|
|
169
|
+
continue
|
|
170
|
+
raise ValueError(f'Existing skill symlink differs: {target}')
|
|
171
|
+
if target.exists() and target.read_text() != content:
|
|
172
|
+
if state.get('skills', {}).get(str(target)) != _hash(target.read_text()):
|
|
173
|
+
raise ValueError(f'Existing skill has local changes: {target}')
|
|
174
|
+
writes[target] = content
|
|
175
|
+
return {'state_path': state_path, 'state': state, 'writes': writes,
|
|
176
|
+
'registrations': registrations}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def install_clients(clients: dict[str, str], env_file: Path, command: list[str]) -> list[str]:
|
|
180
|
+
# Revalidate after human input: files may have changed since the preflight.
|
|
181
|
+
plan = prepare_clients(clients, env_file, command)
|
|
182
|
+
state, state_path = plan['state'], plan['state_path']
|
|
183
|
+
clean_env = {key: value for key, value in os.environ.items()
|
|
184
|
+
if not key.startswith('MLSPACE_')}
|
|
185
|
+
installed = []
|
|
186
|
+
for client, executable, config, entry, current in plan['registrations']:
|
|
187
|
+
try:
|
|
188
|
+
if client == 'opencode':
|
|
189
|
+
text = config.read_text() if config.exists() else '{}\n'
|
|
190
|
+
updated = set_jsonc_member(text, ['mcp', 'mlspace'], entry)
|
|
191
|
+
if updated != text:
|
|
192
|
+
atomic_write(config, updated)
|
|
193
|
+
else:
|
|
194
|
+
comparable = dict(current or {})
|
|
195
|
+
comparable.pop('type', None)
|
|
196
|
+
if current is not None and comparable != entry:
|
|
197
|
+
# Preflight verified this is our unmodified registration.
|
|
198
|
+
remove = [executable, 'mcp', 'remove', 'mlspace']
|
|
199
|
+
if client == 'claude-code':
|
|
200
|
+
remove += ['--scope', 'user']
|
|
201
|
+
subprocess.run(remove, env=clean_env, check=True,
|
|
202
|
+
capture_output=True, timeout=30)
|
|
203
|
+
if comparable != entry:
|
|
204
|
+
arguments = ([executable, 'mcp', 'add', 'mlspace', '--scope', 'user', '--transport', 'stdio',
|
|
205
|
+
'--env', 'MLSPACE_ENV_FILE=' + str(env_file.absolute())]
|
|
206
|
+
if client == 'claude-code' else
|
|
207
|
+
[executable, 'mcp', 'add', 'mlspace', '--env',
|
|
208
|
+
'MLSPACE_ENV_FILE=' + str(env_file.absolute())])
|
|
209
|
+
subprocess.run(arguments + ['--', *command], env=clean_env, check=True,
|
|
210
|
+
capture_output=True, timeout=30)
|
|
211
|
+
saved = _read_config(config).get({'claude-code':'mcpServers', 'codex':'mcp_servers',
|
|
212
|
+
'opencode':'mcp'}[client], {}).get('mlspace')
|
|
213
|
+
comparable_saved = dict(saved) if isinstance(saved, dict) else {}
|
|
214
|
+
if client != 'opencode':
|
|
215
|
+
comparable_saved.pop('type', None)
|
|
216
|
+
if comparable_saved != entry:
|
|
217
|
+
raise ValueError(f'Client registration could not be confirmed for {client}; retry setup.')
|
|
218
|
+
state.setdefault('clients', {})[client] = _hash(json.dumps(saved, sort_keys=True))
|
|
219
|
+
atomic_write(state_path, json.dumps(state, indent=2))
|
|
220
|
+
installed.append(client)
|
|
221
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
222
|
+
raise ValueError(f'Could not register {client}. Already registered: '
|
|
223
|
+
f'{", ".join(installed) or "none"}. Retry setup; details suppressed '
|
|
224
|
+
'because client output can contain secrets.') from exc
|
|
225
|
+
try:
|
|
226
|
+
for target, content in plan['writes'].items():
|
|
227
|
+
if not target.exists() or target.read_text() != content:
|
|
228
|
+
atomic_write(target, content, 0o644)
|
|
229
|
+
state.setdefault('skills', {})[str(target)] = _hash(content)
|
|
230
|
+
atomic_write(state_path, json.dumps(state, indent=2))
|
|
231
|
+
except (OSError, ValueError) as exc:
|
|
232
|
+
raise ValueError(f'MCP registered: {", ".join(installed)}. Skill installation incomplete; '
|
|
233
|
+
'check permissions in the skills directory and rerun setup --force. '
|
|
234
|
+
'Previous credentials have not been replaced.') from exc
|
|
235
|
+
return installed
|
mlspace_mcp/config.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Runtime settings, loaded from the ``MLSPACE_`` environment / ``.env``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from dotenv import dotenv_values
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"}
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.ai.cloud.ru"
|
|
16
|
+
LITERAL_DOTENV_MARKER = "# mlspace-plugin: literal-dotenv-v1"
|
|
17
|
+
|
|
18
|
+
#: Absolute path of the credentials file, when the caller names one instead of
|
|
19
|
+
#: relying on the search order below. The Agent Plugins bundle sets it to
|
|
20
|
+
#: ``${PLUGIN_DATA}/.env`` — a path, not a secret, so it may travel in the package.
|
|
21
|
+
ENV_FILE_VAR = "MLSPACE_ENV_FILE"
|
|
22
|
+
|
|
23
|
+
#: What a hand-written credentials file needs. The workspace x-api-key is optional:
|
|
24
|
+
#: when absent the server fetches it with the service-account token.
|
|
25
|
+
CREDENTIALS = ("CLIENT_ID", "CLIENT_SECRET", "WORKSPACE_ID")
|
|
26
|
+
|
|
27
|
+
APP_DIR = "mlspace-plugin"
|
|
28
|
+
INIT_HINT = "run `mlspace-plugin setup` to fill it in"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def user_config_file() -> Path:
|
|
32
|
+
"""``~/.config/mlspace-plugin/.env`` — the one place a person configures once.
|
|
33
|
+
|
|
34
|
+
Honours ``XDG_CONFIG_HOME``. This is where ``mlspace-plugin setup`` writes, so the
|
|
35
|
+
same credentials serve every way the server gets launched: through the plugin
|
|
36
|
+
bundle, from a hand-written client config, or straight from a shell.
|
|
37
|
+
"""
|
|
38
|
+
base = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
39
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
40
|
+
return root / APP_DIR / ".env"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def credential_files() -> list[Path]:
|
|
44
|
+
"""The files consulted, in ASCENDING priority — the more specific, the later.
|
|
45
|
+
|
|
46
|
+
``pydantic-settings`` lets the last file win and skips ones that do not exist,
|
|
47
|
+
so this is the whole precedence rule:
|
|
48
|
+
|
|
49
|
+
1. the user config — written by ``setup``
|
|
50
|
+
2. ``MLSPACE_ENV_FILE`` — an explicitly trusted file
|
|
51
|
+
|
|
52
|
+
Never read a checkout dotenv automatically: it could redirect saved credentials.
|
|
53
|
+
|
|
54
|
+
Real environment variables still beat every file; that is pydantic's own order.
|
|
55
|
+
"""
|
|
56
|
+
files = [user_config_file()]
|
|
57
|
+
named = os.environ.get(ENV_FILE_VAR, "").strip()
|
|
58
|
+
if named:
|
|
59
|
+
files.append(Path(named))
|
|
60
|
+
return files
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_dotenv_file(path: Path) -> dict[str, str | None]:
|
|
64
|
+
"""Parse a dotenv file, preserving literals in files written by current init."""
|
|
65
|
+
literal = False
|
|
66
|
+
if path.is_file():
|
|
67
|
+
literal = LITERAL_DOTENV_MARKER in path.read_text(encoding="utf-8").splitlines()
|
|
68
|
+
return dict(dotenv_values(path, interpolate=not literal))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConnectedWorkspace(BaseModel):
|
|
72
|
+
"""One workspace explicitly selected during ``mlspace-plugin setup``."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(hide_input_in_errors=True)
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
name: str
|
|
78
|
+
project_name: str
|
|
79
|
+
api_key: SecretStr | None = None
|
|
80
|
+
|
|
81
|
+
@field_validator("id")
|
|
82
|
+
@classmethod
|
|
83
|
+
def nonblank_id(cls, value: str) -> str:
|
|
84
|
+
if not value.strip():
|
|
85
|
+
raise ValueError("Workspace id must not be blank")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Settings(BaseSettings):
|
|
90
|
+
model_config = SettingsConfigDict(
|
|
91
|
+
env_prefix="MLSPACE_", env_file=None, env_file_encoding="utf-8", extra="ignore",
|
|
92
|
+
hide_input_in_errors=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# credentials
|
|
96
|
+
client_id: str = ""
|
|
97
|
+
client_secret: SecretStr = SecretStr("")
|
|
98
|
+
api_key: SecretStr = SecretStr("")
|
|
99
|
+
workspace_id: str = ""
|
|
100
|
+
workspaces: list[ConnectedWorkspace] = Field(default_factory=list)
|
|
101
|
+
# optional: pin the workspace's k8s namespace; empty => resolved from workspace_id
|
|
102
|
+
namespace: str = ""
|
|
103
|
+
# where the credentials file was read from; set via MLSPACE_ENV_FILE. Kept on the
|
|
104
|
+
# instance so a missing-credential error can name the exact file to create.
|
|
105
|
+
env_file: str = ""
|
|
106
|
+
|
|
107
|
+
# endpoint / behaviour
|
|
108
|
+
ca_file: str = ""
|
|
109
|
+
base_url: str = DEFAULT_BASE_URL
|
|
110
|
+
# Write actions are available by default; the server is a working tool, not a
|
|
111
|
+
# viewer. What still stands between an agent and an irreversible change is the
|
|
112
|
+
# `confirm=true` gate on the 16 destructive actions, the jobs-delete existence
|
|
113
|
+
# precheck, and the preflight refusals — see registry.py.
|
|
114
|
+
readonly: bool = False
|
|
115
|
+
# dry-run: write/side-effecting actions are fully validated (body lint, path
|
|
116
|
+
# resolve, confirm gate) but NOT sent — the tool returns a preview of the request
|
|
117
|
+
# that WOULD be issued. Lets an LLM safely assemble & check a write before it runs.
|
|
118
|
+
dry_run: bool = False
|
|
119
|
+
transport: str = "streamable-http" # streamable-http | stdio
|
|
120
|
+
host: str = "127.0.0.1"
|
|
121
|
+
port: int = 8000
|
|
122
|
+
timeout: float = 60.0
|
|
123
|
+
enabled_domains: str = "" # comma-separated; empty = all
|
|
124
|
+
|
|
125
|
+
# response shaping
|
|
126
|
+
list_default_limit: int = Field(default=50, ge=1)
|
|
127
|
+
list_max_limit: int = Field(default=200, ge=1)
|
|
128
|
+
log_tail_lines: int = Field(default=200, ge=1)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def enabled_domain_set(self) -> set[str] | None:
|
|
132
|
+
names = {d.strip() for d in self.enabled_domains.split(",") if d.strip()}
|
|
133
|
+
return names or None
|
|
134
|
+
|
|
135
|
+
def require_credentials(self) -> None:
|
|
136
|
+
"""Raise if any required credential is unset (call before serving requests).
|
|
137
|
+
|
|
138
|
+
When a credentials file was named (the plugin bundle always names one), the
|
|
139
|
+
error quotes its absolute path and a ready-to-paste body: the caller running
|
|
140
|
+
us inside a plugin cannot otherwise know where that directory lives, and
|
|
141
|
+
"set these four variables" is useless advice when the client sanitizes the
|
|
142
|
+
environment it hands us.
|
|
143
|
+
"""
|
|
144
|
+
required = [
|
|
145
|
+
("CLIENT_ID", self.client_id),
|
|
146
|
+
("CLIENT_SECRET", self.client_secret.get_secret_value()),
|
|
147
|
+
]
|
|
148
|
+
if not self.workspaces:
|
|
149
|
+
required.append(("WORKSPACE_ID", self.workspace_id))
|
|
150
|
+
missing = ["MLSPACE_" + name for name, value in required if not value]
|
|
151
|
+
if not missing:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
target = Path(self.env_file) if self.env_file else user_config_file()
|
|
155
|
+
state = "is empty or lacks them" if target.is_file() else "does not exist yet"
|
|
156
|
+
looked_in = ", ".join(str(p) for p in credential_files())
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
"\n".join(
|
|
159
|
+
[
|
|
160
|
+
"Missing required credentials: " + ", ".join(missing) + ".",
|
|
161
|
+
f"Looked in: {looked_in}, and the environment.",
|
|
162
|
+
"",
|
|
163
|
+
f"Fix it: `mlspace-plugin setup` writes {target} for you.",
|
|
164
|
+
f"Or create it by hand ({target} {state}):",
|
|
165
|
+
*(f" MLSPACE_{name}=..." for name in CREDENTIALS),
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def validate_bind(self) -> None:
|
|
171
|
+
"""Fail closed: v1 has no server-side auth, so refuse a non-loopback HTTP bind."""
|
|
172
|
+
host = self.host.strip("[]").lower() # accept bracketed/again-cased IPv6 loopback
|
|
173
|
+
if self.transport != "stdio" and host not in LOOPBACK_HOSTS:
|
|
174
|
+
raise RuntimeError(
|
|
175
|
+
f"Refusing to bind streamable-HTTP to non-loopback host {self.host!r}: "
|
|
176
|
+
"this server has no built-in authentication and would expose shared "
|
|
177
|
+
"write credentials to the network. Bind 127.0.0.1 (default) and put it "
|
|
178
|
+
"behind an authenticated proxy for remote access."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_settings() -> Settings:
|
|
183
|
+
"""Build ``Settings`` from the credential search order in ``credential_files``.
|
|
184
|
+
|
|
185
|
+
``env_file`` is fixed on the model at class-definition time, so the chain has to
|
|
186
|
+
be supplied per-instantiation. Real environment variables still win over every
|
|
187
|
+
file — pydantic's own order — so an ambient ``MLSPACE_*`` setup is untouched.
|
|
188
|
+
"""
|
|
189
|
+
named = os.environ.get(ENV_FILE_VAR, "").strip()
|
|
190
|
+
if named and os.path.isdir(named):
|
|
191
|
+
raise RuntimeError(
|
|
192
|
+
f"{ENV_FILE_VAR}={named!r} is a directory; it must name the credentials "
|
|
193
|
+
"FILE itself, e.g. .../.env"
|
|
194
|
+
)
|
|
195
|
+
files = credential_files()
|
|
196
|
+
sources: list[dict[str, str | None]] = []
|
|
197
|
+
for path in files:
|
|
198
|
+
values = read_dotenv_file(path) if path.is_file() else {}
|
|
199
|
+
sources.append({key.upper(): value for key, value in values.items()})
|
|
200
|
+
sources.append({key.upper(): value for key, value in os.environ.items()})
|
|
201
|
+
|
|
202
|
+
selector_source = -1
|
|
203
|
+
selector_kind = ""
|
|
204
|
+
selected_catalogue: list[ConnectedWorkspace] = []
|
|
205
|
+
for index in range(len(sources) - 1, -1, -1):
|
|
206
|
+
source = sources[index]
|
|
207
|
+
if "MLSPACE_WORKSPACES" in source:
|
|
208
|
+
parsed = json.loads(str(source.get("MLSPACE_WORKSPACES") or ""))
|
|
209
|
+
selected_catalogue = [ConnectedWorkspace.model_validate(item) for item in parsed]
|
|
210
|
+
selector_source = index
|
|
211
|
+
selector_kind = "catalogue" if selected_catalogue else "legacy"
|
|
212
|
+
break
|
|
213
|
+
if str(source.get("MLSPACE_WORKSPACE_ID") or "").strip():
|
|
214
|
+
selector_source = index
|
|
215
|
+
selector_kind = "legacy"
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
merged: dict[str, Any] = {}
|
|
219
|
+
for source in sources:
|
|
220
|
+
for key, value in source.items():
|
|
221
|
+
if not key.startswith("MLSPACE_") or value is None:
|
|
222
|
+
continue
|
|
223
|
+
field_name = key.removeprefix("MLSPACE_").lower()
|
|
224
|
+
if field_name in Settings.model_fields and field_name != "workspaces":
|
|
225
|
+
merged[field_name] = value
|
|
226
|
+
merged["workspaces"] = selected_catalogue if selector_kind == "catalogue" else []
|
|
227
|
+
settings = Settings(_env_file=None, **merged) # type: ignore[call-arg]
|
|
228
|
+
|
|
229
|
+
# Workspace selection is one context rather than independent fields. Track the
|
|
230
|
+
# provenance of selectors and workspace-bound scalar values before normalizing.
|
|
231
|
+
api_key_source = -1
|
|
232
|
+
api_key_workspace_id = ""
|
|
233
|
+
api_key_value = ""
|
|
234
|
+
namespace_source = -1
|
|
235
|
+
namespace_workspace_id = ""
|
|
236
|
+
namespace_value = ""
|
|
237
|
+
legacy_workspace_id = ""
|
|
238
|
+
for index, source in enumerate(sources):
|
|
239
|
+
source_workspace_id = str(source.get("MLSPACE_WORKSPACE_ID") or "").strip()
|
|
240
|
+
if source_workspace_id:
|
|
241
|
+
legacy_workspace_id = source_workspace_id
|
|
242
|
+
if "MLSPACE_API_KEY" in source:
|
|
243
|
+
api_key_source = index
|
|
244
|
+
api_key_workspace_id = legacy_workspace_id
|
|
245
|
+
api_key_value = str(source.get("MLSPACE_API_KEY") or "")
|
|
246
|
+
if "MLSPACE_NAMESPACE" in source:
|
|
247
|
+
namespace_source = index
|
|
248
|
+
namespace_workspace_id = legacy_workspace_id
|
|
249
|
+
namespace_value = str(source.get("MLSPACE_NAMESPACE") or "")
|
|
250
|
+
|
|
251
|
+
if selector_kind == "legacy":
|
|
252
|
+
settings.workspaces = []
|
|
253
|
+
selected_id = settings.workspace_id
|
|
254
|
+
|
|
255
|
+
effective_key_source = api_key_source if api_key_workspace_id == selected_id else -1
|
|
256
|
+
effective_key = api_key_value if effective_key_source >= 0 else ""
|
|
257
|
+
for index in range(selector_source - 1, -1, -1):
|
|
258
|
+
raw_catalogue = sources[index].get("MLSPACE_WORKSPACES")
|
|
259
|
+
if raw_catalogue is None:
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
parsed = json.loads(str(raw_catalogue))
|
|
263
|
+
catalogue = [ConnectedWorkspace.model_validate(item) for item in parsed]
|
|
264
|
+
except (TypeError, ValueError):
|
|
265
|
+
continue
|
|
266
|
+
match = next((item for item in catalogue if item.id == selected_id), None)
|
|
267
|
+
if index > effective_key_source:
|
|
268
|
+
effective_key_source = index
|
|
269
|
+
effective_key = (
|
|
270
|
+
match.api_key.get_secret_value() if match is not None and match.api_key else ""
|
|
271
|
+
)
|
|
272
|
+
break
|
|
273
|
+
settings.api_key = SecretStr(effective_key)
|
|
274
|
+
|
|
275
|
+
if namespace_workspace_id != selected_id:
|
|
276
|
+
settings.namespace = ""
|
|
277
|
+
elif namespace_source >= 0:
|
|
278
|
+
settings.namespace = namespace_value
|
|
279
|
+
elif selector_kind == "catalogue" and len(settings.workspaces) == 1:
|
|
280
|
+
workspace = settings.workspaces[0]
|
|
281
|
+
if api_key_source > selector_source and api_key_workspace_id == workspace.id:
|
|
282
|
+
workspace.api_key = SecretStr(api_key_value) if api_key_value else None
|
|
283
|
+
return settings
|