@softspark/ai-toolkit 4.14.1 → 4.15.0
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.
- package/CHANGELOG.md +28 -0
- package/README.md +11 -10
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/CLAUDE.md.template +3 -0
- package/app/hooks/_search-capability.sh +3 -2
- package/app/hooks/stop-search-check.sh +2 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
- package/kb/procedures/maintenance-sop.md +26 -13
- package/kb/procedures/release-verification-sop.md +41 -36
- package/kb/reference/architecture-overview.md +23 -7
- package/kb/reference/codex-cli-compatibility.md +96 -36
- package/kb/reference/extension-api.md +52 -9
- package/kb/reference/global-install-model.md +53 -21
- package/kb/reference/hooks-catalog.md +44 -8
- package/kb/reference/mcp-editor-compatibility.md +27 -6
- package/kb/reference/mcp-templates.md +12 -6
- package/kb/reference/opencode-compatibility.md +13 -7
- package/kb/reference/plugin-pack-conventions.md +7 -7
- package/kb/reference/skills-catalog.md +3 -3
- package/kb/reference/supported-tools-registry.md +19 -17
- package/kb/reference/windows-support.md +26 -3
- package/llms-full.txt +443 -180
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/codex_skill_adapter.py +448 -198
- package/scripts/dir_rules_shared.py +2 -11
- package/scripts/ecosystem_tools.json +29 -8
- package/scripts/emission.py +5 -91
- package/scripts/generate_agents_md.py +4 -87
- package/scripts/generate_codex.py +5 -95
- package/scripts/generate_codex_agents.py +242 -0
- package/scripts/generate_codex_hooks.py +648 -55
- package/scripts/generate_codex_skills.py +15 -6
- package/scripts/generate_copilot.py +771 -74
- package/scripts/generate_copilot_hooks.py +606 -0
- package/scripts/generate_cursor_hooks.py +453 -121
- package/scripts/generate_opencode_commands.py +4 -6
- package/scripts/inject_hook_cli.py +770 -205
- package/scripts/injection.py +102 -23
- package/scripts/install_steps/ai_tools.py +123 -83
- package/scripts/instruction_core.py +95 -0
- package/scripts/mcp_editors.py +934 -80
- package/scripts/mcp_manager.py +46 -26
- package/scripts/plugin.py +291 -114
- package/scripts/secure_fs.py +538 -0
- package/scripts/uninstall.py +1279 -208
package/scripts/mcp_editors.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Editor-specific MCP config adapters for ai-toolkit."""
|
|
3
|
+
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
import copy
|
|
7
|
+
import errno
|
|
6
8
|
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
7
14
|
from pathlib import Path
|
|
8
15
|
|
|
9
16
|
try:
|
|
@@ -41,6 +48,13 @@ EDITOR_SPECS: dict[str, dict[str, str | None]] = {
|
|
|
41
48
|
"format": "json",
|
|
42
49
|
"doc_scope": "project + global",
|
|
43
50
|
},
|
|
51
|
+
"antigravity": {
|
|
52
|
+
"label": "Google Antigravity",
|
|
53
|
+
"project_path": ".agents/mcp_config.json",
|
|
54
|
+
"global_path": ".gemini/config/mcp_config.json",
|
|
55
|
+
"format": "json",
|
|
56
|
+
"doc_scope": "project + global",
|
|
57
|
+
},
|
|
44
58
|
"roo": {
|
|
45
59
|
"label": "Roo Code",
|
|
46
60
|
"project_path": ".roo/mcp.json",
|
|
@@ -71,19 +85,82 @@ EDITOR_SPECS: dict[str, dict[str, str | None]] = {
|
|
|
71
85
|
},
|
|
72
86
|
"codex": {
|
|
73
87
|
"label": "Codex CLI",
|
|
74
|
-
"project_path":
|
|
88
|
+
"project_path": ".codex/config.toml",
|
|
75
89
|
"global_path": ".codex/config.toml",
|
|
76
90
|
"format": "toml",
|
|
77
|
-
"doc_scope": "global",
|
|
91
|
+
"doc_scope": "project + global",
|
|
78
92
|
},
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
|
|
96
|
+
CODEX_MCP_BLOCK_START = "# >>> ai-toolkit managed Codex MCP servers >>>"
|
|
97
|
+
CODEX_MCP_BLOCK_END = "# <<< ai-toolkit managed Codex MCP servers <<<"
|
|
98
|
+
|
|
99
|
+
_TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
100
|
+
_CODEX_SHARED_SERVER_KEYS = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"startup_timeout_sec",
|
|
103
|
+
"startup_timeout_ms",
|
|
104
|
+
"tool_timeout_sec",
|
|
105
|
+
"enabled",
|
|
106
|
+
"required",
|
|
107
|
+
"enabled_tools",
|
|
108
|
+
"disabled_tools",
|
|
109
|
+
"scopes",
|
|
110
|
+
"oauth_resource",
|
|
111
|
+
"default_tools_approval_mode",
|
|
112
|
+
"tools",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
_CODEX_STDIO_SERVER_KEYS = frozenset(
|
|
116
|
+
{
|
|
117
|
+
"command",
|
|
118
|
+
"args",
|
|
119
|
+
"env",
|
|
120
|
+
"env_vars",
|
|
121
|
+
"cwd",
|
|
122
|
+
"experimental_environment",
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
_CODEX_HTTP_SERVER_KEYS = frozenset(
|
|
126
|
+
{
|
|
127
|
+
"url",
|
|
128
|
+
"auth",
|
|
129
|
+
"bearer_token_env_var",
|
|
130
|
+
"http_headers",
|
|
131
|
+
"env_http_headers",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
_CODEX_PORTABLE_TRANSPORT_KEYS = {
|
|
135
|
+
"http": "url",
|
|
136
|
+
"local": "command",
|
|
137
|
+
"stdio": "command",
|
|
138
|
+
}
|
|
139
|
+
_CODEX_APPROVAL_MODES = frozenset({"auto", "prompt", "writes", "approve"})
|
|
140
|
+
_UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS = frozenset(
|
|
141
|
+
{
|
|
142
|
+
errno.EBADF,
|
|
143
|
+
errno.EINVAL,
|
|
144
|
+
getattr(errno, "ENOTSUP", errno.EINVAL),
|
|
145
|
+
getattr(errno, "EOPNOTSUPP", errno.EINVAL),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
82
150
|
PROJECT_SCOPED_EDITORS = {
|
|
83
151
|
name for name, spec in EDITOR_SPECS.items() if spec.get("project_path")
|
|
84
152
|
}
|
|
85
153
|
|
|
86
154
|
|
|
155
|
+
@dataclass(frozen=True, slots=True)
|
|
156
|
+
class ConfigUpdate:
|
|
157
|
+
"""A preflighted config-file replacement used by an MCP transaction."""
|
|
158
|
+
|
|
159
|
+
path: Path
|
|
160
|
+
original: bytes | None
|
|
161
|
+
content: bytes | None
|
|
162
|
+
|
|
163
|
+
|
|
87
164
|
def supported_editors() -> list[str]:
|
|
88
165
|
"""Return all editor ids with native MCP adapters."""
|
|
89
166
|
return sorted(EDITOR_SPECS)
|
|
@@ -94,17 +171,50 @@ def editor_rows() -> list[dict[str, str]]:
|
|
|
94
171
|
rows: list[dict[str, str]] = []
|
|
95
172
|
for name in supported_editors():
|
|
96
173
|
spec = EDITOR_SPECS[name]
|
|
97
|
-
rows.append(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
174
|
+
rows.append(
|
|
175
|
+
{
|
|
176
|
+
"name": name,
|
|
177
|
+
"label": str(spec["label"]),
|
|
178
|
+
"scope": str(spec["doc_scope"]),
|
|
179
|
+
"project_path": str(spec.get("project_path") or "—"),
|
|
180
|
+
"global_path": str(spec.get("global_path") or "—"),
|
|
181
|
+
"format": str(spec["format"]),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
105
184
|
return rows
|
|
106
185
|
|
|
107
186
|
|
|
187
|
+
def _resolve_global_config_root(
|
|
188
|
+
*,
|
|
189
|
+
home: Path | None,
|
|
190
|
+
env_name: str,
|
|
191
|
+
default_dir: str,
|
|
192
|
+
) -> Path:
|
|
193
|
+
"""Resolve an editor config root without following an environment symlink."""
|
|
194
|
+
if home is not None:
|
|
195
|
+
return (home / default_dir).expanduser().absolute()
|
|
196
|
+
|
|
197
|
+
configured = os.environ.get(env_name, "").strip()
|
|
198
|
+
if not configured:
|
|
199
|
+
return (Path.home() / default_dir).absolute()
|
|
200
|
+
|
|
201
|
+
config_root = Path(configured).expanduser()
|
|
202
|
+
if not config_root.is_absolute():
|
|
203
|
+
raise ValueError(f"Configured {env_name} must be absolute: {configured}")
|
|
204
|
+
config_root = config_root.absolute()
|
|
205
|
+
if config_root.is_symlink():
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
f"Configured {env_name} must not be a symlink: {config_root}"
|
|
208
|
+
)
|
|
209
|
+
if not config_root.exists():
|
|
210
|
+
raise FileNotFoundError(f"Configured {env_name} does not exist: {config_root}")
|
|
211
|
+
if not config_root.is_dir():
|
|
212
|
+
raise NotADirectoryError(
|
|
213
|
+
f"Configured {env_name} is not a directory: {config_root}"
|
|
214
|
+
)
|
|
215
|
+
return config_root
|
|
216
|
+
|
|
217
|
+
|
|
108
218
|
def resolve_editor_path(
|
|
109
219
|
editor: str,
|
|
110
220
|
scope: str,
|
|
@@ -119,13 +229,29 @@ def resolve_editor_path(
|
|
|
119
229
|
if scope == "project":
|
|
120
230
|
rel = spec.get("project_path")
|
|
121
231
|
if not rel:
|
|
122
|
-
raise ValueError(
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Editor '{editor}' does not support project-scoped MCP config"
|
|
234
|
+
)
|
|
123
235
|
base = project_dir or Path.cwd()
|
|
124
236
|
return base / str(rel)
|
|
125
237
|
if scope == "global":
|
|
126
238
|
rel = spec.get("global_path")
|
|
127
239
|
if not rel:
|
|
128
240
|
raise ValueError(f"Editor '{editor}' does not support global MCP config")
|
|
241
|
+
if editor == "copilot":
|
|
242
|
+
copilot_home = _resolve_global_config_root(
|
|
243
|
+
home=home,
|
|
244
|
+
env_name="COPILOT_HOME",
|
|
245
|
+
default_dir=".copilot",
|
|
246
|
+
)
|
|
247
|
+
return copilot_home / "mcp-config.json"
|
|
248
|
+
if editor == "codex":
|
|
249
|
+
codex_home = _resolve_global_config_root(
|
|
250
|
+
home=home,
|
|
251
|
+
env_name="CODEX_HOME",
|
|
252
|
+
default_dir=".codex",
|
|
253
|
+
)
|
|
254
|
+
return codex_home / "config.toml"
|
|
129
255
|
return (home or Path.home()) / str(rel)
|
|
130
256
|
raise ValueError(f"Unsupported scope: {scope}")
|
|
131
257
|
|
|
@@ -133,10 +259,10 @@ def resolve_editor_path(
|
|
|
133
259
|
def load_project_mcp_servers(project_dir: Path) -> dict:
|
|
134
260
|
"""Load `.mcp.json` servers from a project directory."""
|
|
135
261
|
config_path = project_dir / ".mcp.json"
|
|
136
|
-
|
|
262
|
+
_assert_safe_config_path(config_path)
|
|
263
|
+
if not config_path.exists():
|
|
137
264
|
raise FileNotFoundError(f"{config_path} not found")
|
|
138
|
-
|
|
139
|
-
data = json.load(f)
|
|
265
|
+
data = load_json_config(config_path)
|
|
140
266
|
servers = data.get("mcpServers", {})
|
|
141
267
|
if not isinstance(servers, dict):
|
|
142
268
|
raise ValueError(f"{config_path} has invalid mcpServers data")
|
|
@@ -152,7 +278,28 @@ def install_servers(
|
|
|
152
278
|
home: Path | None = None,
|
|
153
279
|
) -> list[Path]:
|
|
154
280
|
"""Merge servers into native editor config files."""
|
|
155
|
-
|
|
281
|
+
updates = prepare_install_servers(
|
|
282
|
+
editors,
|
|
283
|
+
servers,
|
|
284
|
+
scope=scope,
|
|
285
|
+
project_dir=project_dir,
|
|
286
|
+
home=home,
|
|
287
|
+
)
|
|
288
|
+
apply_config_updates(updates)
|
|
289
|
+
return [update.path for update in updates]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def prepare_install_servers(
|
|
293
|
+
editors: list[str],
|
|
294
|
+
servers: dict,
|
|
295
|
+
*,
|
|
296
|
+
scope: str,
|
|
297
|
+
project_dir: Path | None = None,
|
|
298
|
+
home: Path | None = None,
|
|
299
|
+
) -> list[ConfigUpdate]:
|
|
300
|
+
"""Preflight all native editor merges without mutating any destination."""
|
|
301
|
+
updates: list[ConfigUpdate] = []
|
|
302
|
+
seen_paths: set[Path] = set()
|
|
156
303
|
for editor in editors:
|
|
157
304
|
path = resolve_editor_path(
|
|
158
305
|
editor,
|
|
@@ -160,12 +307,15 @@ def install_servers(
|
|
|
160
307
|
project_dir=project_dir,
|
|
161
308
|
home=home,
|
|
162
309
|
)
|
|
310
|
+
identity = path.absolute()
|
|
311
|
+
if identity in seen_paths:
|
|
312
|
+
continue
|
|
313
|
+
seen_paths.add(identity)
|
|
163
314
|
if EDITOR_SPECS[editor]["format"] == "toml":
|
|
164
|
-
|
|
315
|
+
updates.append(_prepare_merge_toml_servers(path, servers))
|
|
165
316
|
else:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return updated
|
|
317
|
+
updates.append(_prepare_merge_json_servers(path, editor, servers))
|
|
318
|
+
return updates
|
|
169
319
|
|
|
170
320
|
|
|
171
321
|
def remove_servers(
|
|
@@ -177,7 +327,28 @@ def remove_servers(
|
|
|
177
327
|
home: Path | None = None,
|
|
178
328
|
) -> list[Path]:
|
|
179
329
|
"""Remove servers from native editor config files."""
|
|
180
|
-
|
|
330
|
+
updates = prepare_remove_servers(
|
|
331
|
+
editors,
|
|
332
|
+
server_names,
|
|
333
|
+
scope=scope,
|
|
334
|
+
project_dir=project_dir,
|
|
335
|
+
home=home,
|
|
336
|
+
)
|
|
337
|
+
apply_config_updates(updates)
|
|
338
|
+
return [update.path for update in updates]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def prepare_remove_servers(
|
|
342
|
+
editors: list[str],
|
|
343
|
+
server_names: list[str],
|
|
344
|
+
*,
|
|
345
|
+
scope: str,
|
|
346
|
+
project_dir: Path | None = None,
|
|
347
|
+
home: Path | None = None,
|
|
348
|
+
) -> list[ConfigUpdate]:
|
|
349
|
+
"""Preflight all native editor removals without mutating any destination."""
|
|
350
|
+
updates: list[ConfigUpdate] = []
|
|
351
|
+
seen_paths: set[Path] = set()
|
|
181
352
|
for editor in editors:
|
|
182
353
|
path = resolve_editor_path(
|
|
183
354
|
editor,
|
|
@@ -185,12 +356,15 @@ def remove_servers(
|
|
|
185
356
|
project_dir=project_dir,
|
|
186
357
|
home=home,
|
|
187
358
|
)
|
|
359
|
+
identity = path.absolute()
|
|
360
|
+
if identity in seen_paths:
|
|
361
|
+
continue
|
|
362
|
+
seen_paths.add(identity)
|
|
188
363
|
if EDITOR_SPECS[editor]["format"] == "toml":
|
|
189
|
-
|
|
364
|
+
updates.append(_prepare_remove_toml_servers(path, server_names))
|
|
190
365
|
else:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return updated
|
|
366
|
+
updates.append(_prepare_remove_json_servers(path, server_names))
|
|
367
|
+
return updates
|
|
194
368
|
|
|
195
369
|
|
|
196
370
|
def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[Path]:
|
|
@@ -199,7 +373,8 @@ def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[P
|
|
|
199
373
|
Claude project settings are always synced when `.mcp.json` exists.
|
|
200
374
|
"""
|
|
201
375
|
config_path = project_dir / ".mcp.json"
|
|
202
|
-
|
|
376
|
+
_assert_safe_config_path(config_path)
|
|
377
|
+
if not config_path.exists():
|
|
203
378
|
return []
|
|
204
379
|
|
|
205
380
|
servers = load_project_mcp_servers(project_dir)
|
|
@@ -214,23 +389,122 @@ def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[P
|
|
|
214
389
|
|
|
215
390
|
|
|
216
391
|
def _load_json_file(path: Path) -> dict:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
392
|
+
_original, data = _load_json_document(path)
|
|
393
|
+
return data
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _load_json_document(path: Path) -> tuple[bytes | None, dict]:
|
|
397
|
+
original = _read_optional_file(path)
|
|
398
|
+
if original is None:
|
|
399
|
+
return None, {}
|
|
400
|
+
try:
|
|
401
|
+
text = original.decode("utf-8")
|
|
402
|
+
except UnicodeDecodeError as error:
|
|
403
|
+
raise ValueError(f"{path} is not valid UTF-8 JSON: {error}") from error
|
|
404
|
+
try:
|
|
405
|
+
data = json.loads(text)
|
|
406
|
+
except json.JSONDecodeError as error:
|
|
407
|
+
raise ValueError(f"{path} contains invalid JSON: {error}") from error
|
|
221
408
|
if not isinstance(data, dict):
|
|
222
409
|
raise ValueError(f"{path} must contain a JSON object")
|
|
223
|
-
return data
|
|
410
|
+
return original, data
|
|
224
411
|
|
|
225
412
|
|
|
226
|
-
def
|
|
227
|
-
path.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
413
|
+
def load_json_config(path: Path) -> dict:
|
|
414
|
+
"""Load a native JSON config after validating its destination path."""
|
|
415
|
+
return _load_json_file(path)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def prepare_json_config(path: Path, data: dict) -> ConfigUpdate:
|
|
419
|
+
"""Build a validated JSON replacement without writing it."""
|
|
420
|
+
if not isinstance(data, dict):
|
|
421
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
422
|
+
original, _existing = _load_json_document(path)
|
|
423
|
+
content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
424
|
+
return ConfigUpdate(path=path, original=original, content=content)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def write_json_config(path: Path, data: dict) -> None:
|
|
428
|
+
"""Atomically write a native JSON MCP document without following symlinks."""
|
|
429
|
+
apply_config_updates([prepare_json_config(path, data)])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def apply_config_updates(updates: list[ConfigUpdate]) -> None:
|
|
433
|
+
"""Apply preflighted file updates atomically, rolling back on any failure."""
|
|
434
|
+
unique_updates = _coalesce_config_updates(updates)
|
|
435
|
+
pending = [
|
|
436
|
+
update
|
|
437
|
+
for update in unique_updates
|
|
438
|
+
if update.content is not None and update.content != update.original
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
# Validate every snapshot before the first write. This catches symlinks,
|
|
442
|
+
# concurrent changes, invalid destination types, and stale plans without
|
|
443
|
+
# leaving a partially updated editor set.
|
|
444
|
+
for update in pending:
|
|
445
|
+
_verify_config_snapshot(update)
|
|
446
|
+
|
|
447
|
+
attempted: list[ConfigUpdate] = []
|
|
448
|
+
try:
|
|
449
|
+
for update in pending:
|
|
450
|
+
_verify_config_snapshot(update)
|
|
451
|
+
attempted.append(update)
|
|
452
|
+
_atomic_write_bytes(update.path, update.content)
|
|
453
|
+
except Exception as error:
|
|
454
|
+
rollback_errors: list[str] = []
|
|
455
|
+
for update in reversed(attempted):
|
|
456
|
+
try:
|
|
457
|
+
_rollback_config_update(update)
|
|
458
|
+
except Exception as rollback_error: # pragma: no cover - catastrophic I/O
|
|
459
|
+
rollback_errors.append(f"{update.path}: {rollback_error}")
|
|
460
|
+
if rollback_errors:
|
|
461
|
+
details = "; ".join(rollback_errors)
|
|
462
|
+
raise RuntimeError(
|
|
463
|
+
f"MCP config update failed ({error}); rollback also failed: {details}"
|
|
464
|
+
) from error
|
|
465
|
+
raise
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _coalesce_config_updates(updates: list[ConfigUpdate]) -> list[ConfigUpdate]:
|
|
469
|
+
unique: dict[Path, ConfigUpdate] = {}
|
|
470
|
+
for update in updates:
|
|
471
|
+
identity = update.path.absolute()
|
|
472
|
+
previous = unique.get(identity)
|
|
473
|
+
if previous is None:
|
|
474
|
+
unique[identity] = update
|
|
475
|
+
continue
|
|
476
|
+
if previous.original != update.original or previous.content != update.content:
|
|
477
|
+
raise ValueError(f"Conflicting MCP config updates for {update.path}")
|
|
478
|
+
return list(unique.values())
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _verify_config_snapshot(update: ConfigUpdate) -> None:
|
|
482
|
+
current = _read_optional_file(update.path)
|
|
483
|
+
if current != update.original:
|
|
484
|
+
raise RuntimeError(
|
|
485
|
+
f"MCP config changed after preflight; refusing to overwrite: {update.path}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _rollback_config_update(update: ConfigUpdate) -> None:
|
|
490
|
+
current = _read_optional_file(update.path)
|
|
491
|
+
if current == update.original:
|
|
492
|
+
return
|
|
493
|
+
if current != update.content:
|
|
494
|
+
raise RuntimeError(
|
|
495
|
+
f"MCP config changed during rollback; refusing to overwrite: {update.path}"
|
|
496
|
+
)
|
|
497
|
+
if update.original is None:
|
|
498
|
+
_assert_safe_config_path(update.path)
|
|
499
|
+
update.path.unlink()
|
|
500
|
+
_fsync_directory(update.path.parent)
|
|
501
|
+
return
|
|
502
|
+
_atomic_write_bytes(update.path, update.original)
|
|
231
503
|
|
|
232
504
|
|
|
233
505
|
def _normalize_server(editor: str, server: dict) -> dict:
|
|
506
|
+
if editor == "antigravity":
|
|
507
|
+
return _normalize_antigravity_server(server)
|
|
234
508
|
data = copy.deepcopy(server)
|
|
235
509
|
if editor == "copilot":
|
|
236
510
|
if "url" in data:
|
|
@@ -241,67 +515,372 @@ def _normalize_server(editor: str, server: dict) -> dict:
|
|
|
241
515
|
return data
|
|
242
516
|
|
|
243
517
|
|
|
244
|
-
def
|
|
245
|
-
|
|
518
|
+
def _normalize_antigravity_server(server: dict) -> dict:
|
|
519
|
+
"""Validate Antigravity's native MCP schema without rewriting transports."""
|
|
520
|
+
if not isinstance(server, dict):
|
|
521
|
+
raise ValueError("Antigravity MCP server configuration must be an object")
|
|
522
|
+
|
|
523
|
+
data = copy.deepcopy(server)
|
|
524
|
+
data.pop("_source", None)
|
|
525
|
+
if "httpUrl" in data:
|
|
526
|
+
raise ValueError("Antigravity MCP does not support the legacy 'httpUrl' field")
|
|
527
|
+
|
|
528
|
+
transport_keys = [key for key in ("command", "serverUrl", "url") if key in data]
|
|
529
|
+
if len(transport_keys) != 1:
|
|
530
|
+
raise ValueError(
|
|
531
|
+
"Antigravity MCP server requires exactly one of "
|
|
532
|
+
"'command', 'serverUrl', or 'url'"
|
|
533
|
+
)
|
|
534
|
+
transport_key = transport_keys[0]
|
|
535
|
+
_require_antigravity_string(data, transport_key)
|
|
536
|
+
|
|
537
|
+
if transport_key == "command":
|
|
538
|
+
_validate_antigravity_stdio(data)
|
|
539
|
+
else:
|
|
540
|
+
_validate_antigravity_remote(data)
|
|
541
|
+
if "disabled" in data and not isinstance(data["disabled"], bool):
|
|
542
|
+
raise ValueError("Antigravity MCP 'disabled' must be a boolean")
|
|
543
|
+
_validate_antigravity_string_list(data, "disabledTools")
|
|
544
|
+
return data
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _validate_antigravity_stdio(data: dict) -> None:
|
|
548
|
+
incompatible = {"headers", "authProviderType", "oauth"} & set(data)
|
|
549
|
+
if incompatible:
|
|
550
|
+
fields = ", ".join(sorted(incompatible))
|
|
551
|
+
raise ValueError(f"Incompatible Antigravity STDIO MCP field(s): {fields}")
|
|
552
|
+
_validate_antigravity_string_list(data, "args")
|
|
553
|
+
_validate_antigravity_string_map(data, "env")
|
|
554
|
+
if "cwd" in data:
|
|
555
|
+
_require_antigravity_string(data, "cwd")
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _validate_antigravity_remote(data: dict) -> None:
|
|
559
|
+
incompatible = {"args", "env", "cwd"} & set(data)
|
|
560
|
+
if incompatible:
|
|
561
|
+
fields = ", ".join(sorted(incompatible))
|
|
562
|
+
raise ValueError(f"Incompatible Antigravity remote MCP field(s): {fields}")
|
|
563
|
+
_validate_antigravity_string_map(data, "headers")
|
|
564
|
+
if "authProviderType" in data:
|
|
565
|
+
_require_antigravity_string(data, "authProviderType")
|
|
566
|
+
if "oauth" in data and not isinstance(data["oauth"], dict):
|
|
567
|
+
raise ValueError("Antigravity MCP 'oauth' must be an object")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _require_antigravity_string(data: dict, key: str) -> None:
|
|
571
|
+
value = data.get(key)
|
|
572
|
+
if not isinstance(value, str) or not value:
|
|
573
|
+
raise ValueError(f"Antigravity MCP '{key}' must be a non-empty string")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _validate_antigravity_string_list(data: dict, key: str) -> None:
|
|
577
|
+
if key not in data:
|
|
578
|
+
return
|
|
579
|
+
value = data[key]
|
|
580
|
+
if not isinstance(value, list) or not all(
|
|
581
|
+
isinstance(item, str) and item for item in value
|
|
582
|
+
):
|
|
583
|
+
raise ValueError(f"Antigravity MCP '{key}' must be a list of non-empty strings")
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _validate_antigravity_string_map(data: dict, key: str) -> None:
|
|
587
|
+
if key not in data:
|
|
588
|
+
return
|
|
589
|
+
value = data[key]
|
|
590
|
+
if not isinstance(value, dict) or not all(
|
|
591
|
+
isinstance(item_key, str) and item_key and isinstance(item_value, str)
|
|
592
|
+
for item_key, item_value in value.items()
|
|
593
|
+
):
|
|
594
|
+
raise ValueError(
|
|
595
|
+
f"Antigravity MCP '{key}' must map non-empty strings to strings"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _prepare_merge_json_servers(
|
|
600
|
+
path: Path,
|
|
601
|
+
editor: str,
|
|
602
|
+
servers: dict,
|
|
603
|
+
) -> ConfigUpdate:
|
|
604
|
+
original, data = _load_json_document(path)
|
|
246
605
|
bucket = data.setdefault("mcpServers", {})
|
|
247
606
|
if not isinstance(bucket, dict):
|
|
248
607
|
raise ValueError(f"{path} has invalid mcpServers data")
|
|
249
608
|
for key, value in servers.items():
|
|
250
609
|
bucket[key] = _normalize_server(editor, value)
|
|
251
|
-
|
|
610
|
+
content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
611
|
+
return ConfigUpdate(path=path, original=original, content=content)
|
|
252
612
|
|
|
253
613
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
614
|
+
def _prepare_remove_json_servers(
|
|
615
|
+
path: Path,
|
|
616
|
+
server_names: list[str],
|
|
617
|
+
) -> ConfigUpdate:
|
|
618
|
+
original, data = _load_json_document(path)
|
|
619
|
+
if original is None:
|
|
620
|
+
return ConfigUpdate(path=path, original=None, content=None)
|
|
258
621
|
bucket = data.get("mcpServers", {})
|
|
259
622
|
if not isinstance(bucket, dict):
|
|
260
623
|
raise ValueError(f"{path} has invalid mcpServers data")
|
|
624
|
+
changed = False
|
|
261
625
|
for name in server_names:
|
|
262
|
-
|
|
626
|
+
if name in bucket:
|
|
627
|
+
del bucket[name]
|
|
628
|
+
changed = True
|
|
629
|
+
if not changed:
|
|
630
|
+
return ConfigUpdate(path=path, original=original, content=original)
|
|
263
631
|
data["mcpServers"] = bucket
|
|
264
|
-
|
|
632
|
+
content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
633
|
+
return ConfigUpdate(path=path, original=original, content=content)
|
|
265
634
|
|
|
266
635
|
|
|
267
|
-
def
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
636
|
+
def _prepare_merge_toml_servers(path: Path, servers: dict) -> ConfigUpdate:
|
|
637
|
+
original_bytes, original = _load_toml_document(path)
|
|
638
|
+
base, managed = _split_codex_managed_block(original, path)
|
|
639
|
+
normalized = {
|
|
640
|
+
_validate_server_name(name): _normalize_toml_server(value)
|
|
641
|
+
for name, value in servers.items()
|
|
642
|
+
}
|
|
643
|
+
base = _remove_selected_toml_server_tables(base, set(normalized), path)
|
|
644
|
+
managed.update(normalized)
|
|
645
|
+
rendered = _compose_codex_toml(base, managed)
|
|
646
|
+
_parse_toml(rendered, path)
|
|
647
|
+
return ConfigUpdate(
|
|
648
|
+
path=path,
|
|
649
|
+
original=original_bytes,
|
|
650
|
+
content=rendered.encode("utf-8"),
|
|
651
|
+
)
|
|
273
652
|
|
|
274
653
|
|
|
275
|
-
def
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
654
|
+
def _prepare_remove_toml_servers(
|
|
655
|
+
path: Path,
|
|
656
|
+
server_names: list[str],
|
|
657
|
+
) -> ConfigUpdate:
|
|
658
|
+
original_bytes, original = _load_toml_document(path)
|
|
659
|
+
if original_bytes is None:
|
|
660
|
+
return ConfigUpdate(path=path, original=None, content=None)
|
|
661
|
+
base, managed = _split_codex_managed_block(original, path)
|
|
662
|
+
normalized_names = {_validate_server_name(name) for name in server_names}
|
|
663
|
+
base = _remove_selected_toml_server_tables(base, normalized_names, path)
|
|
664
|
+
for name in normalized_names:
|
|
665
|
+
managed.pop(name, None)
|
|
666
|
+
rendered = _compose_codex_toml(base, managed)
|
|
667
|
+
_parse_toml(rendered, path)
|
|
668
|
+
return ConfigUpdate(
|
|
669
|
+
path=path,
|
|
670
|
+
original=original_bytes,
|
|
671
|
+
content=rendered.encode("utf-8"),
|
|
672
|
+
)
|
|
283
673
|
|
|
284
674
|
|
|
285
|
-
def
|
|
286
|
-
if not
|
|
675
|
+
def _normalize_toml_server(server: dict) -> dict:
|
|
676
|
+
if not isinstance(server, dict):
|
|
677
|
+
raise ValueError("Codex MCP server configuration must be an object")
|
|
678
|
+
|
|
679
|
+
source = copy.deepcopy(server)
|
|
680
|
+
source.pop("_source", None)
|
|
681
|
+
_strip_compatible_codex_transport_type(source)
|
|
682
|
+
if "tools" in source and not isinstance(source["tools"], dict):
|
|
683
|
+
raise ValueError(
|
|
684
|
+
"Codex MCP 'tools' must be a per-tool policy object, not a Copilot tool list"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
data = {key: value for key, value in source.items() if value not in (None, {}, [])}
|
|
688
|
+
has_command = "command" in data
|
|
689
|
+
has_url = "url" in data
|
|
690
|
+
if has_command == has_url:
|
|
691
|
+
raise ValueError("Codex MCP server requires exactly one of 'command' or 'url'")
|
|
692
|
+
|
|
693
|
+
transport_keys = (
|
|
694
|
+
_CODEX_STDIO_SERVER_KEYS if has_command else _CODEX_HTTP_SERVER_KEYS
|
|
695
|
+
)
|
|
696
|
+
unknown = set(data) - transport_keys - _CODEX_SHARED_SERVER_KEYS
|
|
697
|
+
if unknown:
|
|
698
|
+
raise ValueError(
|
|
699
|
+
"Unsupported Codex MCP field(s): " + ", ".join(sorted(unknown))
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
incompatible = set(data) & (
|
|
703
|
+
_CODEX_HTTP_SERVER_KEYS if has_command else _CODEX_STDIO_SERVER_KEYS
|
|
704
|
+
)
|
|
705
|
+
if incompatible:
|
|
706
|
+
transport = "STDIO" if has_command else "HTTP"
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"Incompatible {transport} Codex MCP field(s): "
|
|
709
|
+
+ ", ".join(sorted(incompatible))
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
if has_command:
|
|
713
|
+
_require_nonempty_string(data, "command")
|
|
714
|
+
_validate_optional_string_list(data, "args")
|
|
715
|
+
_validate_optional_string_map(data, "env")
|
|
716
|
+
_validate_env_vars(data)
|
|
717
|
+
_validate_optional_string(data, "cwd")
|
|
718
|
+
if "experimental_environment" in data:
|
|
719
|
+
_require_choice(data, "experimental_environment", {"remote"})
|
|
720
|
+
else:
|
|
721
|
+
_require_nonempty_string(data, "url")
|
|
722
|
+
_validate_optional_string(data, "bearer_token_env_var")
|
|
723
|
+
_validate_optional_string_map(data, "http_headers")
|
|
724
|
+
_validate_optional_string_map(data, "env_http_headers")
|
|
725
|
+
if "auth" in data:
|
|
726
|
+
_require_choice(data, "auth", {"oauth", "chatgpt"})
|
|
727
|
+
|
|
728
|
+
for key in ("startup_timeout_sec", "tool_timeout_sec"):
|
|
729
|
+
if key in data:
|
|
730
|
+
value = data[key]
|
|
731
|
+
if (
|
|
732
|
+
isinstance(value, bool)
|
|
733
|
+
or not isinstance(value, (int, float))
|
|
734
|
+
or not math.isfinite(value)
|
|
735
|
+
or value <= 0
|
|
736
|
+
):
|
|
737
|
+
raise ValueError(f"Codex MCP '{key}' must be a positive number")
|
|
738
|
+
if "startup_timeout_ms" in data:
|
|
739
|
+
value = data["startup_timeout_ms"]
|
|
740
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
741
|
+
raise ValueError(
|
|
742
|
+
"Codex MCP 'startup_timeout_ms' must be a positive integer"
|
|
743
|
+
)
|
|
744
|
+
for key in ("enabled", "required"):
|
|
745
|
+
if key in data and not isinstance(data[key], bool):
|
|
746
|
+
raise ValueError(f"Codex MCP '{key}' must be a boolean")
|
|
747
|
+
for key in ("enabled_tools", "disabled_tools"):
|
|
748
|
+
_validate_optional_string_list(data, key)
|
|
749
|
+
_validate_optional_string_list(data, "scopes")
|
|
750
|
+
_validate_optional_string(data, "oauth_resource")
|
|
751
|
+
if "default_tools_approval_mode" in data:
|
|
752
|
+
_require_choice(data, "default_tools_approval_mode", _CODEX_APPROVAL_MODES)
|
|
753
|
+
_validate_tool_policies(data)
|
|
754
|
+
|
|
755
|
+
key_order = (
|
|
756
|
+
"command",
|
|
757
|
+
"args",
|
|
758
|
+
"env_vars",
|
|
759
|
+
"cwd",
|
|
760
|
+
"experimental_environment",
|
|
761
|
+
"url",
|
|
762
|
+
"auth",
|
|
763
|
+
"bearer_token_env_var",
|
|
764
|
+
"startup_timeout_sec",
|
|
765
|
+
"startup_timeout_ms",
|
|
766
|
+
"tool_timeout_sec",
|
|
767
|
+
"enabled",
|
|
768
|
+
"required",
|
|
769
|
+
"enabled_tools",
|
|
770
|
+
"disabled_tools",
|
|
771
|
+
"default_tools_approval_mode",
|
|
772
|
+
"scopes",
|
|
773
|
+
"oauth_resource",
|
|
774
|
+
"env",
|
|
775
|
+
"http_headers",
|
|
776
|
+
"env_http_headers",
|
|
777
|
+
"tools",
|
|
778
|
+
)
|
|
779
|
+
return {key: data[key] for key in key_order if key in data}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _strip_compatible_codex_transport_type(source: dict) -> None:
|
|
783
|
+
"""Validate portable MCP transport metadata before rendering Codex TOML."""
|
|
784
|
+
if "type" not in source:
|
|
287
785
|
return
|
|
288
|
-
data = _load_toml_file(path)
|
|
289
|
-
bucket = data.get("mcp_servers", {})
|
|
290
|
-
if not isinstance(bucket, dict):
|
|
291
|
-
raise ValueError(f"{path} has invalid mcp_servers data")
|
|
292
|
-
for name in server_names:
|
|
293
|
-
bucket.pop(name, None)
|
|
294
|
-
data["mcp_servers"] = bucket
|
|
295
|
-
_write_toml_file(path, data)
|
|
296
786
|
|
|
787
|
+
transport_type = source.pop("type")
|
|
788
|
+
if not isinstance(transport_type, str):
|
|
789
|
+
raise ValueError("Codex MCP portable 'type' must be a string")
|
|
790
|
+
expected_key = _CODEX_PORTABLE_TRANSPORT_KEYS.get(transport_type)
|
|
791
|
+
if expected_key is None:
|
|
792
|
+
raise ValueError(
|
|
793
|
+
"Codex MCP supports portable types 'http', 'local', and 'stdio'; "
|
|
794
|
+
f"got {transport_type!r}"
|
|
795
|
+
)
|
|
297
796
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
797
|
+
incompatible_key = "command" if expected_key == "url" else "url"
|
|
798
|
+
if expected_key not in source or incompatible_key in source:
|
|
799
|
+
raise ValueError(
|
|
800
|
+
f"Codex MCP type '{transport_type}' requires '{expected_key}' "
|
|
801
|
+
f"and excludes '{incompatible_key}'"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _validate_server_name(name: object) -> str:
|
|
806
|
+
if not isinstance(name, str) or not name:
|
|
807
|
+
raise ValueError("Codex MCP server names must be non-empty strings")
|
|
808
|
+
if "\n" in name or "\r" in name:
|
|
809
|
+
raise ValueError("Codex MCP server names cannot contain newlines")
|
|
810
|
+
return name
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _require_nonempty_string(data: dict, key: str) -> None:
|
|
814
|
+
value = data.get(key)
|
|
815
|
+
if not isinstance(value, str) or not value:
|
|
816
|
+
raise ValueError(f"Codex MCP '{key}' must be a non-empty string")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _validate_optional_string(data: dict, key: str) -> None:
|
|
820
|
+
if key in data:
|
|
821
|
+
_require_nonempty_string(data, key)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _validate_optional_string_list(data: dict, key: str) -> None:
|
|
825
|
+
if key not in data:
|
|
826
|
+
return
|
|
827
|
+
value = data[key]
|
|
828
|
+
if not isinstance(value, list) or not all(
|
|
829
|
+
isinstance(item, str) and item for item in value
|
|
830
|
+
):
|
|
831
|
+
raise ValueError(f"Codex MCP '{key}' must be a list of non-empty strings")
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _validate_optional_string_map(data: dict, key: str) -> None:
|
|
835
|
+
if key not in data:
|
|
836
|
+
return
|
|
837
|
+
value = data[key]
|
|
838
|
+
if not isinstance(value, dict) or not all(
|
|
839
|
+
isinstance(item_key, str) and item_key and isinstance(item_value, str)
|
|
840
|
+
for item_key, item_value in value.items()
|
|
841
|
+
):
|
|
842
|
+
raise ValueError(f"Codex MCP '{key}' must map non-empty strings to strings")
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _validate_env_vars(data: dict) -> None:
|
|
846
|
+
if "env_vars" not in data:
|
|
847
|
+
return
|
|
848
|
+
value = data["env_vars"]
|
|
849
|
+
if not isinstance(value, list):
|
|
850
|
+
raise ValueError("Codex MCP 'env_vars' must be a list")
|
|
851
|
+
for entry in value:
|
|
852
|
+
if isinstance(entry, str) and entry:
|
|
302
853
|
continue
|
|
303
|
-
|
|
304
|
-
|
|
854
|
+
if not isinstance(entry, dict) or set(entry) - {"name", "source"}:
|
|
855
|
+
raise ValueError(
|
|
856
|
+
"Codex MCP env_vars entries must be strings or name/source objects"
|
|
857
|
+
)
|
|
858
|
+
if not isinstance(entry.get("name"), str) or not entry["name"]:
|
|
859
|
+
raise ValueError("Codex MCP env_vars object requires a non-empty name")
|
|
860
|
+
if entry.get("source", "local") not in {"local", "remote"}:
|
|
861
|
+
raise ValueError("Codex MCP env_vars source must be 'local' or 'remote'")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _require_choice(data: dict, key: str, choices: set[str] | frozenset[str]) -> None:
|
|
865
|
+
if data[key] not in choices:
|
|
866
|
+
allowed = ", ".join(sorted(choices))
|
|
867
|
+
raise ValueError(f"Codex MCP '{key}' must be one of: {allowed}")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _validate_tool_policies(data: dict) -> None:
|
|
871
|
+
if "tools" not in data:
|
|
872
|
+
return
|
|
873
|
+
tools = data["tools"]
|
|
874
|
+
if not isinstance(tools, dict):
|
|
875
|
+
raise ValueError("Codex MCP 'tools' must be an object")
|
|
876
|
+
for name, policy in tools.items():
|
|
877
|
+
if not isinstance(name, str) or not name or not isinstance(policy, dict):
|
|
878
|
+
raise ValueError("Codex MCP tools must map names to policy objects")
|
|
879
|
+
if set(policy) != {"approval_mode"}:
|
|
880
|
+
raise ValueError(
|
|
881
|
+
f"Codex MCP tool '{name}' supports only the approval_mode field"
|
|
882
|
+
)
|
|
883
|
+
_require_choice(policy, "approval_mode", _CODEX_APPROVAL_MODES)
|
|
305
884
|
|
|
306
885
|
|
|
307
886
|
def _format_toml_value(value) -> str:
|
|
@@ -313,11 +892,17 @@ def _format_toml_value(value) -> str:
|
|
|
313
892
|
return json.dumps(value)
|
|
314
893
|
if isinstance(value, list):
|
|
315
894
|
return "[" + ", ".join(_format_toml_value(v) for v in value) + "]"
|
|
895
|
+
if isinstance(value, dict):
|
|
896
|
+
values = ", ".join(
|
|
897
|
+
f"{_format_toml_key(str(key))} = {_format_toml_value(item)}"
|
|
898
|
+
for key, item in value.items()
|
|
899
|
+
)
|
|
900
|
+
return "{ " + values + " }"
|
|
316
901
|
raise TypeError(f"Unsupported TOML value: {value!r}")
|
|
317
902
|
|
|
318
903
|
|
|
319
904
|
def _format_toml_key(key: str) -> str:
|
|
320
|
-
if
|
|
905
|
+
if _TOML_BARE_KEY_RE.fullmatch(key):
|
|
321
906
|
return key
|
|
322
907
|
return json.dumps(key)
|
|
323
908
|
|
|
@@ -339,9 +924,278 @@ def _render_toml_table(prefix: str, data: dict, lines: list[str]) -> None:
|
|
|
339
924
|
_render_toml_table(child_prefix, value, lines)
|
|
340
925
|
|
|
341
926
|
|
|
342
|
-
def
|
|
343
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
927
|
+
def _render_managed_codex_block(servers: dict[str, dict]) -> str:
|
|
344
928
|
lines: list[str] = []
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
929
|
+
lines.append(CODEX_MCP_BLOCK_START)
|
|
930
|
+
for name in sorted(servers):
|
|
931
|
+
if len(lines) > 1 and lines[-1] != "":
|
|
932
|
+
lines.append("")
|
|
933
|
+
prefix = f"mcp_servers.{_format_toml_key(name)}"
|
|
934
|
+
_render_toml_table(prefix, servers[name], lines)
|
|
935
|
+
lines.append(CODEX_MCP_BLOCK_END)
|
|
936
|
+
return "\n".join(lines) + "\n"
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _compose_codex_toml(base: str, managed: dict[str, dict]) -> str:
|
|
940
|
+
if not managed:
|
|
941
|
+
return base
|
|
942
|
+
if not base:
|
|
943
|
+
return _render_managed_codex_block(managed)
|
|
944
|
+
prefix = base
|
|
945
|
+
if not prefix.endswith("\n"):
|
|
946
|
+
prefix += "\n"
|
|
947
|
+
if not prefix.endswith("\n\n"):
|
|
948
|
+
prefix += "\n"
|
|
949
|
+
return prefix + _render_managed_codex_block(managed)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _split_codex_managed_block(text: str, path: Path) -> tuple[str, dict[str, dict]]:
|
|
953
|
+
lines = text.splitlines(keepends=True)
|
|
954
|
+
starts = [
|
|
955
|
+
i for i, line in enumerate(lines) if line.strip() == CODEX_MCP_BLOCK_START
|
|
956
|
+
]
|
|
957
|
+
ends = [i for i, line in enumerate(lines) if line.strip() == CODEX_MCP_BLOCK_END]
|
|
958
|
+
if not starts and not ends:
|
|
959
|
+
return text, {}
|
|
960
|
+
if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
|
|
961
|
+
raise ValueError(f"{path} has malformed ai-toolkit Codex MCP markers")
|
|
962
|
+
|
|
963
|
+
start, end = starts[0], ends[0]
|
|
964
|
+
block_text = "".join(lines[start + 1 : end])
|
|
965
|
+
block_data = _parse_toml(block_text, path) if block_text.strip() else {}
|
|
966
|
+
if set(block_data) - {"mcp_servers"}:
|
|
967
|
+
raise ValueError(f"{path} has unexpected data inside the managed MCP block")
|
|
968
|
+
bucket = block_data.get("mcp_servers", {})
|
|
969
|
+
if not isinstance(bucket, dict):
|
|
970
|
+
raise ValueError(f"{path} has invalid managed mcp_servers data")
|
|
971
|
+
managed = {
|
|
972
|
+
_validate_server_name(name): _normalize_toml_server(server)
|
|
973
|
+
for name, server in bucket.items()
|
|
974
|
+
}
|
|
975
|
+
base = "".join(lines[:start] + lines[end + 1 :])
|
|
976
|
+
_parse_toml(base, path)
|
|
977
|
+
return base, managed
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _remove_selected_toml_server_tables(
|
|
981
|
+
text: str,
|
|
982
|
+
server_names: set[str],
|
|
983
|
+
path: Path,
|
|
984
|
+
) -> str:
|
|
985
|
+
if not server_names:
|
|
986
|
+
return text
|
|
987
|
+
lines = text.splitlines(keepends=True)
|
|
988
|
+
headers = _toml_table_headers(lines)
|
|
989
|
+
remove_lines: set[int] = set()
|
|
990
|
+
for position, (line_index, table_path) in enumerate(headers):
|
|
991
|
+
next_index = (
|
|
992
|
+
headers[position + 1][0] if position + 1 < len(headers) else len(lines)
|
|
993
|
+
)
|
|
994
|
+
if (
|
|
995
|
+
len(table_path) >= 2
|
|
996
|
+
and table_path[0] == "mcp_servers"
|
|
997
|
+
and table_path[1] in server_names
|
|
998
|
+
):
|
|
999
|
+
remove_lines.update(range(line_index, next_index))
|
|
1000
|
+
|
|
1001
|
+
rendered = "".join(
|
|
1002
|
+
line for index, line in enumerate(lines) if index not in remove_lines
|
|
1003
|
+
)
|
|
1004
|
+
data = _parse_toml(rendered, path)
|
|
1005
|
+
bucket = data.get("mcp_servers", {})
|
|
1006
|
+
if not isinstance(bucket, dict):
|
|
1007
|
+
raise ValueError(f"{path} has invalid mcp_servers data")
|
|
1008
|
+
remaining = server_names & set(bucket)
|
|
1009
|
+
if remaining:
|
|
1010
|
+
names = ", ".join(sorted(remaining))
|
|
1011
|
+
raise ValueError(
|
|
1012
|
+
f"Cannot safely replace inline/dotted Codex MCP server definition(s): {names}"
|
|
1013
|
+
)
|
|
1014
|
+
return rendered
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _toml_table_headers(lines: list[str]) -> list[tuple[int, tuple[str, ...]]]:
|
|
1018
|
+
headers: list[tuple[int, tuple[str, ...]]] = []
|
|
1019
|
+
multiline: str | None = None
|
|
1020
|
+
for index, line in enumerate(lines):
|
|
1021
|
+
if multiline is None:
|
|
1022
|
+
table_path = _toml_table_header_path(line)
|
|
1023
|
+
if table_path is not None:
|
|
1024
|
+
headers.append((index, table_path))
|
|
1025
|
+
multiline = _toml_multiline_state(line, multiline)
|
|
1026
|
+
return headers
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _toml_table_header_path(line: str) -> tuple[str, ...] | None:
|
|
1030
|
+
stripped = line.strip()
|
|
1031
|
+
if not stripped.startswith("[") or stripped.startswith("[["):
|
|
1032
|
+
return None
|
|
1033
|
+
if tomllib is None: # pragma: no cover
|
|
1034
|
+
raise RuntimeError("tomllib is unavailable")
|
|
1035
|
+
probe = "__ai_toolkit_table_probe__"
|
|
1036
|
+
try:
|
|
1037
|
+
parsed = tomllib.loads(f"{stripped}\n{probe} = true\n")
|
|
1038
|
+
except tomllib.TOMLDecodeError:
|
|
1039
|
+
return None
|
|
1040
|
+
found = _find_toml_probe_path(parsed, probe, ())
|
|
1041
|
+
return found
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _find_toml_probe_path(
|
|
1045
|
+
data: dict, probe: str, path: tuple[str, ...]
|
|
1046
|
+
) -> tuple[str, ...] | None:
|
|
1047
|
+
if data.get(probe) is True:
|
|
1048
|
+
return path
|
|
1049
|
+
for key, value in data.items():
|
|
1050
|
+
if isinstance(value, dict):
|
|
1051
|
+
found = _find_toml_probe_path(value, probe, path + (key,))
|
|
1052
|
+
if found is not None:
|
|
1053
|
+
return found
|
|
1054
|
+
return None
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _toml_multiline_state(line: str, state: str | None) -> str | None:
|
|
1058
|
+
index = 0
|
|
1059
|
+
while index < len(line):
|
|
1060
|
+
if state == "basic":
|
|
1061
|
+
if line.startswith('"""', index) and not _is_escaped(line, index):
|
|
1062
|
+
state = None
|
|
1063
|
+
index += 3
|
|
1064
|
+
else:
|
|
1065
|
+
index += 1
|
|
1066
|
+
continue
|
|
1067
|
+
if state == "literal":
|
|
1068
|
+
if line.startswith("'''", index):
|
|
1069
|
+
state = None
|
|
1070
|
+
index += 3
|
|
1071
|
+
else:
|
|
1072
|
+
index += 1
|
|
1073
|
+
continue
|
|
1074
|
+
|
|
1075
|
+
if line[index] == "#":
|
|
1076
|
+
break
|
|
1077
|
+
if line.startswith('"""', index):
|
|
1078
|
+
state = "basic"
|
|
1079
|
+
index += 3
|
|
1080
|
+
continue
|
|
1081
|
+
if line.startswith("'''", index):
|
|
1082
|
+
state = "literal"
|
|
1083
|
+
index += 3
|
|
1084
|
+
continue
|
|
1085
|
+
if line[index] == '"':
|
|
1086
|
+
index = _skip_single_line_basic_string(line, index + 1)
|
|
1087
|
+
continue
|
|
1088
|
+
if line[index] == "'":
|
|
1089
|
+
closing = line.find("'", index + 1)
|
|
1090
|
+
index = len(line) if closing < 0 else closing + 1
|
|
1091
|
+
continue
|
|
1092
|
+
index += 1
|
|
1093
|
+
return state
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _skip_single_line_basic_string(line: str, index: int) -> int:
|
|
1097
|
+
while index < len(line):
|
|
1098
|
+
if line[index] == '"' and not _is_escaped(line, index):
|
|
1099
|
+
return index + 1
|
|
1100
|
+
index += 1
|
|
1101
|
+
return index
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _is_escaped(text: str, index: int) -> bool:
|
|
1105
|
+
backslashes = 0
|
|
1106
|
+
index -= 1
|
|
1107
|
+
while index >= 0 and text[index] == "\\":
|
|
1108
|
+
backslashes += 1
|
|
1109
|
+
index -= 1
|
|
1110
|
+
return backslashes % 2 == 1
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _load_toml_document(path: Path) -> tuple[bytes | None, str]:
|
|
1114
|
+
original = _read_optional_file(path)
|
|
1115
|
+
if original is None:
|
|
1116
|
+
return None, ""
|
|
1117
|
+
try:
|
|
1118
|
+
text = original.decode("utf-8")
|
|
1119
|
+
except UnicodeDecodeError as error:
|
|
1120
|
+
raise ValueError(f"{path} is not valid UTF-8 TOML: {error}") from error
|
|
1121
|
+
_parse_toml(text, path)
|
|
1122
|
+
return original, text
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _read_optional_file(path: Path) -> bytes | None:
|
|
1126
|
+
_assert_safe_config_path(path)
|
|
1127
|
+
if not path.exists():
|
|
1128
|
+
return None
|
|
1129
|
+
if not path.is_file():
|
|
1130
|
+
raise RuntimeError(f"MCP config destination is not a file: {path}")
|
|
1131
|
+
return path.read_bytes()
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _parse_toml(text: str, path: Path) -> dict:
|
|
1135
|
+
if not text.strip():
|
|
1136
|
+
return {}
|
|
1137
|
+
if tomllib is None: # pragma: no cover
|
|
1138
|
+
raise RuntimeError("tomllib is unavailable")
|
|
1139
|
+
try:
|
|
1140
|
+
data = tomllib.loads(text)
|
|
1141
|
+
except tomllib.TOMLDecodeError as error:
|
|
1142
|
+
raise ValueError(f"{path} contains invalid TOML: {error}") from error
|
|
1143
|
+
if not isinstance(data, dict): # pragma: no cover - tomllib always returns dict
|
|
1144
|
+
raise ValueError(f"{path} must contain a TOML document")
|
|
1145
|
+
return data
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _assert_safe_config_path(path: Path) -> None:
|
|
1149
|
+
# The config itself, its editor directory, and the caller-provided project
|
|
1150
|
+
# or HOME root must not redirect writes through symlinks. Checking the
|
|
1151
|
+
# grandparent is necessary for layouts such as `.codex/config.toml`.
|
|
1152
|
+
for candidate in (path, path.parent, path.parent.parent):
|
|
1153
|
+
if candidate.is_symlink():
|
|
1154
|
+
raise RuntimeError(f"Refusing symlinked MCP config path: {candidate}")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _atomic_write_bytes(path: Path, content: bytes) -> None:
|
|
1158
|
+
_assert_safe_config_path(path)
|
|
1159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1160
|
+
_assert_safe_config_path(path)
|
|
1161
|
+
mode = path.stat().st_mode & 0o777 if path.exists() and path.is_file() else None
|
|
1162
|
+
fd, temp_name = tempfile.mkstemp(
|
|
1163
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
1164
|
+
)
|
|
1165
|
+
temp_path = Path(temp_name)
|
|
1166
|
+
try:
|
|
1167
|
+
if mode is not None:
|
|
1168
|
+
os.fchmod(fd, mode)
|
|
1169
|
+
with os.fdopen(fd, "wb") as handle:
|
|
1170
|
+
fd = -1
|
|
1171
|
+
handle.write(content)
|
|
1172
|
+
handle.flush()
|
|
1173
|
+
os.fsync(handle.fileno())
|
|
1174
|
+
_assert_safe_config_path(path)
|
|
1175
|
+
os.replace(temp_path, path)
|
|
1176
|
+
_fsync_directory(path.parent)
|
|
1177
|
+
except Exception:
|
|
1178
|
+
if fd >= 0:
|
|
1179
|
+
os.close(fd)
|
|
1180
|
+
temp_path.unlink(missing_ok=True)
|
|
1181
|
+
raise
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _fsync_directory(path: Path) -> None:
|
|
1185
|
+
if os.name == "nt":
|
|
1186
|
+
return
|
|
1187
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1188
|
+
try:
|
|
1189
|
+
fd = os.open(path, flags)
|
|
1190
|
+
except OSError as error:
|
|
1191
|
+
if error.errno in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
|
|
1192
|
+
return
|
|
1193
|
+
raise
|
|
1194
|
+
try:
|
|
1195
|
+
try:
|
|
1196
|
+
os.fsync(fd)
|
|
1197
|
+
except OSError as error:
|
|
1198
|
+
if error.errno not in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
|
|
1199
|
+
raise
|
|
1200
|
+
finally:
|
|
1201
|
+
os.close(fd)
|