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,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