open-edison 0.1.39__py3-none-any.whl → 0.1.40__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.
- {open_edison-0.1.39.dist-info → open_edison-0.1.40.dist-info}/METADATA +10 -10
- open_edison-0.1.40.dist-info/RECORD +39 -0
- open_edison-0.1.40.dist-info/entry_points.txt +5 -0
- src/frontend_dist/assets/index-BUUcUfTt.js +51 -0
- src/frontend_dist/assets/index-o6_8mdM8.css +1 -0
- src/frontend_dist/index.html +21 -0
- src/frontend_dist/sw.js +71 -0
- src/mcp_importer/__init__.py +15 -0
- src/mcp_importer/__main__.py +19 -0
- src/mcp_importer/api.py +196 -0
- src/mcp_importer/cli.py +113 -0
- src/mcp_importer/export_cli.py +201 -0
- src/mcp_importer/exporters.py +393 -0
- src/mcp_importer/import_api.py +3 -0
- src/mcp_importer/importers.py +63 -0
- src/mcp_importer/merge.py +47 -0
- src/mcp_importer/parsers.py +148 -0
- src/mcp_importer/paths.py +92 -0
- src/mcp_importer/quick_cli.py +62 -0
- src/mcp_importer/types.py +5 -0
- open_edison-0.1.39.dist-info/RECORD +0 -22
- open_edison-0.1.39.dist-info/entry_points.txt +0 -5
- {open_edison-0.1.39.dist-info → open_edison-0.1.40.dist-info}/WHEEL +0 -0
- {open_edison-0.1.39.dist-info → open_edison-0.1.40.dist-info}/licenses/LICENSE +0 -0
- /__init__.py → /src/__init__.py +0 -0
- /__main__.py → /src/__main__.py +0 -0
- /cli.py → /src/cli.py +0 -0
- /config.py → /src/config.py +0 -0
- /config.pyi → /src/config.pyi +0 -0
- /events.py → /src/events.py +0 -0
- {middleware → src/middleware}/data_access_tracker.py +0 -0
- {middleware → src/middleware}/session_tracking.py +0 -0
- /oauth_manager.py → /src/oauth_manager.py +0 -0
- /permissions.py → /src/permissions.py +0 -0
- /server.py → /src/server.py +0 -0
- /single_user_mcp.py → /src/single_user_mcp.py +0 -0
- /telemetry.py → /src/telemetry.py +0 -0
@@ -0,0 +1,393 @@
|
|
1
|
+
import json
|
2
|
+
import os
|
3
|
+
import shutil
|
4
|
+
import tempfile
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from datetime import datetime
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from loguru import logger as log
|
11
|
+
|
12
|
+
from .paths import (
|
13
|
+
find_claude_code_user_all_candidates,
|
14
|
+
find_cursor_user_file,
|
15
|
+
find_vscode_user_mcp_file,
|
16
|
+
is_macos,
|
17
|
+
is_windows,
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
@dataclass
|
22
|
+
class ExportResult:
|
23
|
+
target_path: Path
|
24
|
+
backup_path: Path | None
|
25
|
+
wrote_changes: bool
|
26
|
+
dry_run: bool
|
27
|
+
|
28
|
+
|
29
|
+
class ExportError(Exception):
|
30
|
+
pass
|
31
|
+
|
32
|
+
|
33
|
+
def _timestamp() -> str:
|
34
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
35
|
+
|
36
|
+
|
37
|
+
def _ensure_parent_dir(path: Path) -> None:
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
39
|
+
|
40
|
+
|
41
|
+
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
42
|
+
_ensure_parent_dir(path)
|
43
|
+
tmp_fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
44
|
+
try:
|
45
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
46
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
47
|
+
f.write("\n")
|
48
|
+
# Use replace to be atomic on POSIX
|
49
|
+
Path(tmp_path).replace(path)
|
50
|
+
finally:
|
51
|
+
try:
|
52
|
+
if Path(tmp_path).exists():
|
53
|
+
Path(tmp_path).unlink(missing_ok=True)
|
54
|
+
except Exception:
|
55
|
+
pass
|
56
|
+
|
57
|
+
|
58
|
+
def _read_json_or_error(path: Path) -> dict[str, Any]:
|
59
|
+
try:
|
60
|
+
with open(path, encoding="utf-8") as f:
|
61
|
+
data = json.load(f)
|
62
|
+
except Exception as e:
|
63
|
+
raise ExportError(f"Malformed JSON at {path}: {e}") from e
|
64
|
+
if not isinstance(data, dict):
|
65
|
+
raise ExportError(f"Expected top-level JSON object at {path}")
|
66
|
+
return data
|
67
|
+
|
68
|
+
|
69
|
+
def _require_supported_os() -> None:
|
70
|
+
if is_windows():
|
71
|
+
raise ExportError("Windows is not supported. Use macOS or Linux.")
|
72
|
+
|
73
|
+
|
74
|
+
def _resolve_cursor_target() -> Path:
|
75
|
+
existing = find_cursor_user_file()
|
76
|
+
return existing[0] if existing else (Path.home() / ".cursor" / "mcp.json").resolve()
|
77
|
+
|
78
|
+
|
79
|
+
def _resolve_vscode_target() -> Path:
|
80
|
+
existing = find_vscode_user_mcp_file()
|
81
|
+
if existing:
|
82
|
+
return existing[0]
|
83
|
+
if is_macos():
|
84
|
+
return (
|
85
|
+
Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
86
|
+
).resolve()
|
87
|
+
return (Path.home() / ".config" / "Code" / "User" / "mcp.json").resolve()
|
88
|
+
|
89
|
+
|
90
|
+
def _resolve_claude_code_target() -> Path:
|
91
|
+
existing = find_claude_code_user_all_candidates()
|
92
|
+
if existing:
|
93
|
+
return existing[0]
|
94
|
+
return (Path.home() / ".claude.json").resolve()
|
95
|
+
|
96
|
+
|
97
|
+
def _validate_or_confirm_create(target_path: Path, *, create_if_missing: bool, label: str) -> None:
|
98
|
+
if target_path.exists():
|
99
|
+
_read_json_or_error(target_path)
|
100
|
+
return
|
101
|
+
if not create_if_missing:
|
102
|
+
raise ExportError(
|
103
|
+
f"{label} config not found at {target_path}. Refusing to create without confirmation."
|
104
|
+
)
|
105
|
+
|
106
|
+
|
107
|
+
def _skip_if_already_configured(
|
108
|
+
target_path: Path,
|
109
|
+
*,
|
110
|
+
url: str,
|
111
|
+
api_key: str,
|
112
|
+
name: str,
|
113
|
+
force: bool,
|
114
|
+
dry_run: bool,
|
115
|
+
label: str,
|
116
|
+
) -> ExportResult | None:
|
117
|
+
if not target_path.exists():
|
118
|
+
return None
|
119
|
+
current = _read_json_or_error(target_path)
|
120
|
+
if _is_already_open_edison(current, url=url, api_key=api_key, name=name) and not force:
|
121
|
+
log.info(
|
122
|
+
"{} is already configured to use Open Edison. Skipping (use --force to rewrite).", label
|
123
|
+
)
|
124
|
+
return ExportResult(
|
125
|
+
target_path=target_path, backup_path=None, wrote_changes=False, dry_run=dry_run
|
126
|
+
)
|
127
|
+
return None
|
128
|
+
|
129
|
+
|
130
|
+
def _backup_if_exists(target_path: Path, *, dry_run: bool) -> Path | None:
|
131
|
+
if not target_path.exists():
|
132
|
+
return None
|
133
|
+
backup_path = target_path.with_name(target_path.name + f".bak-{_timestamp()}")
|
134
|
+
if dry_run:
|
135
|
+
log.info("[dry-run] Would back up {} -> {}", target_path, backup_path)
|
136
|
+
return backup_path
|
137
|
+
_ensure_parent_dir(backup_path)
|
138
|
+
shutil.copy2(target_path, backup_path)
|
139
|
+
log.info("Backed up {} -> {}", target_path, backup_path)
|
140
|
+
return backup_path
|
141
|
+
|
142
|
+
|
143
|
+
def _write_config(
|
144
|
+
target_path: Path,
|
145
|
+
*,
|
146
|
+
new_config: dict[str, Any],
|
147
|
+
backup_path: Path | None,
|
148
|
+
dry_run: bool,
|
149
|
+
label: str,
|
150
|
+
) -> ExportResult:
|
151
|
+
if dry_run:
|
152
|
+
log.info("[dry-run] Would write minimal {} MCP config to {}", label, target_path)
|
153
|
+
log.debug("[dry-run] New JSON: {}", json.dumps(new_config, indent=2))
|
154
|
+
return ExportResult(
|
155
|
+
target_path=target_path, backup_path=backup_path, wrote_changes=False, dry_run=True
|
156
|
+
)
|
157
|
+
_atomic_write_json(target_path, new_config)
|
158
|
+
log.info("Wrote {} MCP config to {}", label, target_path)
|
159
|
+
return ExportResult(
|
160
|
+
target_path=target_path, backup_path=backup_path, wrote_changes=True, dry_run=False
|
161
|
+
)
|
162
|
+
|
163
|
+
|
164
|
+
def _build_open_edison_server(
|
165
|
+
*,
|
166
|
+
name: str,
|
167
|
+
url: str,
|
168
|
+
api_key: str,
|
169
|
+
) -> dict[str, Any]:
|
170
|
+
return {
|
171
|
+
name: {
|
172
|
+
"command": "npx",
|
173
|
+
"args": [
|
174
|
+
"-y",
|
175
|
+
"mcp-remote",
|
176
|
+
url,
|
177
|
+
"--header",
|
178
|
+
f"Authorization: Bearer {api_key}",
|
179
|
+
"--transport",
|
180
|
+
"http-only",
|
181
|
+
"--allow-http",
|
182
|
+
],
|
183
|
+
"enabled": True,
|
184
|
+
}
|
185
|
+
}
|
186
|
+
|
187
|
+
|
188
|
+
def _is_already_open_edison(
|
189
|
+
config_obj: dict[str, Any], *, url: str, api_key: str, name: str
|
190
|
+
) -> bool:
|
191
|
+
servers_node = config_obj.get("mcpServers") or config_obj.get("servers")
|
192
|
+
if not isinstance(servers_node, dict):
|
193
|
+
return False
|
194
|
+
# Must be exactly one server
|
195
|
+
if len(servers_node) != 1:
|
196
|
+
return False
|
197
|
+
only_name, only_spec = next(iter(servers_node.items()))
|
198
|
+
if only_name != name or not isinstance(only_spec, dict):
|
199
|
+
return False
|
200
|
+
if only_spec.get("command") != "npx":
|
201
|
+
return False
|
202
|
+
args = only_spec.get("args")
|
203
|
+
if not isinstance(args, list):
|
204
|
+
return False
|
205
|
+
args_str = [str(a) for a in args]
|
206
|
+
expected_header = f"Authorization: Bearer {api_key}"
|
207
|
+
return (
|
208
|
+
url in args_str
|
209
|
+
and expected_header in args_str
|
210
|
+
and "mcp-remote" in args_str
|
211
|
+
and "--transport" in args_str
|
212
|
+
and "http-only" in args_str
|
213
|
+
)
|
214
|
+
|
215
|
+
|
216
|
+
def export_to_cursor(
|
217
|
+
*,
|
218
|
+
url: str = "http://localhost:3000/mcp/",
|
219
|
+
api_key: str = "dev-api-key-change-me",
|
220
|
+
server_name: str = "open-edison",
|
221
|
+
dry_run: bool = False,
|
222
|
+
force: bool = False,
|
223
|
+
create_if_missing: bool = False,
|
224
|
+
) -> ExportResult:
|
225
|
+
"""Export editor config for Cursor to point solely to Open Edison.
|
226
|
+
|
227
|
+
Behavior:
|
228
|
+
- Back up existing file if present.
|
229
|
+
- Abort on malformed JSON.
|
230
|
+
- If file does not exist, require create_if_missing=True or raise ExportError.
|
231
|
+
- Write a minimal mcpServers object with a single Open Edison server.
|
232
|
+
- Atomic writes.
|
233
|
+
"""
|
234
|
+
|
235
|
+
_require_supported_os()
|
236
|
+
target_path = _resolve_cursor_target()
|
237
|
+
|
238
|
+
backup_path: Path | None = None
|
239
|
+
|
240
|
+
_validate_or_confirm_create(target_path, create_if_missing=create_if_missing, label="Cursor")
|
241
|
+
|
242
|
+
# Build the minimal config
|
243
|
+
new_config: dict[str, Any] = {
|
244
|
+
"mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
|
245
|
+
}
|
246
|
+
|
247
|
+
# If already configured exactly as desired and not forcing, no-op
|
248
|
+
maybe_skip = _skip_if_already_configured(
|
249
|
+
target_path,
|
250
|
+
url=url,
|
251
|
+
api_key=api_key,
|
252
|
+
name=server_name,
|
253
|
+
force=force,
|
254
|
+
dry_run=dry_run,
|
255
|
+
label="Cursor",
|
256
|
+
)
|
257
|
+
if maybe_skip is not None:
|
258
|
+
return maybe_skip
|
259
|
+
|
260
|
+
# Prepare backup if file exists
|
261
|
+
backup_path = _backup_if_exists(target_path, dry_run=dry_run)
|
262
|
+
|
263
|
+
# Write new config
|
264
|
+
return _write_config(
|
265
|
+
target_path,
|
266
|
+
new_config=new_config,
|
267
|
+
backup_path=backup_path,
|
268
|
+
dry_run=dry_run,
|
269
|
+
label="Cursor",
|
270
|
+
)
|
271
|
+
|
272
|
+
|
273
|
+
def export_to_vscode(
|
274
|
+
*,
|
275
|
+
url: str = "http://localhost:3000/mcp/",
|
276
|
+
api_key: str = "dev-api-key-change-me",
|
277
|
+
server_name: str = "open-edison",
|
278
|
+
dry_run: bool = False,
|
279
|
+
force: bool = False,
|
280
|
+
create_if_missing: bool = False,
|
281
|
+
) -> ExportResult:
|
282
|
+
"""Export editor config for VS Code to point solely to Open Edison.
|
283
|
+
|
284
|
+
Uses the user-level `mcp.json` path used by the importer.
|
285
|
+
|
286
|
+
Behavior mirrors Cursor export:
|
287
|
+
- Back up existing file if present.
|
288
|
+
- Abort on malformed JSON.
|
289
|
+
- If file does not exist, require create_if_missing=True or raise ExportError.
|
290
|
+
- Write a minimal mcpServers object with a single Open Edison server.
|
291
|
+
- Atomic writes.
|
292
|
+
"""
|
293
|
+
|
294
|
+
_require_supported_os()
|
295
|
+
target_path = _resolve_vscode_target()
|
296
|
+
|
297
|
+
backup_path: Path | None = None
|
298
|
+
|
299
|
+
_validate_or_confirm_create(target_path, create_if_missing=create_if_missing, label="VS Code")
|
300
|
+
|
301
|
+
# Build the minimal config
|
302
|
+
new_config: dict[str, Any] = {
|
303
|
+
"mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
|
304
|
+
}
|
305
|
+
|
306
|
+
# If already configured exactly as desired and not forcing, no-op
|
307
|
+
maybe_skip = _skip_if_already_configured(
|
308
|
+
target_path,
|
309
|
+
url=url,
|
310
|
+
api_key=api_key,
|
311
|
+
name=server_name,
|
312
|
+
force=force,
|
313
|
+
dry_run=dry_run,
|
314
|
+
label="VS Code",
|
315
|
+
)
|
316
|
+
if maybe_skip is not None:
|
317
|
+
return maybe_skip
|
318
|
+
|
319
|
+
# Prepare backup if file exists
|
320
|
+
backup_path = _backup_if_exists(target_path, dry_run=dry_run)
|
321
|
+
|
322
|
+
# Write new config
|
323
|
+
return _write_config(
|
324
|
+
target_path,
|
325
|
+
new_config=new_config,
|
326
|
+
backup_path=backup_path,
|
327
|
+
dry_run=dry_run,
|
328
|
+
label="VS Code",
|
329
|
+
)
|
330
|
+
|
331
|
+
|
332
|
+
def _merge_preserving_non_mcp(
|
333
|
+
existing_obj: dict[str, Any], new_mcp: dict[str, Any]
|
334
|
+
) -> dict[str, Any]:
|
335
|
+
merged = dict(existing_obj)
|
336
|
+
merged.pop("servers", None)
|
337
|
+
merged["mcpServers"] = new_mcp
|
338
|
+
return merged
|
339
|
+
|
340
|
+
|
341
|
+
def export_to_claude_code(
|
342
|
+
*,
|
343
|
+
url: str = "http://localhost:3000/mcp/",
|
344
|
+
api_key: str = "dev-api-key-change-me",
|
345
|
+
server_name: str = "open-edison",
|
346
|
+
dry_run: bool = False,
|
347
|
+
force: bool = False,
|
348
|
+
create_if_missing: bool = False,
|
349
|
+
) -> ExportResult:
|
350
|
+
"""Export for Claude Code.
|
351
|
+
|
352
|
+
- If target is a general settings file, preserve non-MCP keys and replace MCP.
|
353
|
+
- Otherwise, write minimal MCP-only object.
|
354
|
+
"""
|
355
|
+
|
356
|
+
_require_supported_os()
|
357
|
+
target_path = _resolve_claude_code_target()
|
358
|
+
|
359
|
+
is_existing = target_path.exists()
|
360
|
+
if is_existing:
|
361
|
+
current = _read_json_or_error(target_path)
|
362
|
+
maybe_skip = _skip_if_already_configured(
|
363
|
+
target_path,
|
364
|
+
url=url,
|
365
|
+
api_key=api_key,
|
366
|
+
name=server_name,
|
367
|
+
force=force,
|
368
|
+
dry_run=dry_run,
|
369
|
+
label="Claude Code",
|
370
|
+
)
|
371
|
+
if maybe_skip is not None:
|
372
|
+
return maybe_skip
|
373
|
+
else:
|
374
|
+
if not create_if_missing:
|
375
|
+
raise ExportError(
|
376
|
+
f"Claude Code config not found at {target_path}. Refusing to create without confirmation."
|
377
|
+
)
|
378
|
+
current = {}
|
379
|
+
|
380
|
+
new_mcp = _build_open_edison_server(name=server_name, url=url, api_key=api_key)
|
381
|
+
if is_existing and isinstance(current, dict) and current:
|
382
|
+
new_config = _merge_preserving_non_mcp(current, new_mcp)
|
383
|
+
else:
|
384
|
+
new_config = {"mcpServers": new_mcp}
|
385
|
+
|
386
|
+
backup_path = _backup_if_exists(target_path, dry_run=dry_run)
|
387
|
+
return _write_config(
|
388
|
+
target_path,
|
389
|
+
new_config=new_config,
|
390
|
+
backup_path=backup_path,
|
391
|
+
dry_run=dry_run,
|
392
|
+
label="Claude Code",
|
393
|
+
)
|
@@ -0,0 +1,63 @@
|
|
1
|
+
from collections.abc import Callable
|
2
|
+
from pathlib import Path
|
3
|
+
|
4
|
+
from loguru import logger as log
|
5
|
+
|
6
|
+
from src.config import MCPServerConfig
|
7
|
+
|
8
|
+
from .parsers import ImportErrorDetails, parse_mcp_like_json, safe_read_json
|
9
|
+
from .paths import (
|
10
|
+
find_claude_code_user_all_candidates,
|
11
|
+
find_claude_code_user_settings_file,
|
12
|
+
find_cursor_user_file,
|
13
|
+
find_vscode_user_mcp_file,
|
14
|
+
)
|
15
|
+
|
16
|
+
|
17
|
+
def import_from_cursor() -> list[MCPServerConfig]:
|
18
|
+
# Only support user-level Cursor config
|
19
|
+
files = find_cursor_user_file()
|
20
|
+
if not files:
|
21
|
+
raise ImportErrorDetails(
|
22
|
+
"Cursor MCP config not found (~/.cursor/mcp.json).",
|
23
|
+
Path.home() / ".cursor" / "mcp.json",
|
24
|
+
)
|
25
|
+
data = safe_read_json(files[0])
|
26
|
+
return parse_mcp_like_json(data, default_enabled=True)
|
27
|
+
|
28
|
+
|
29
|
+
def import_from_vscode() -> list[MCPServerConfig]:
|
30
|
+
files = find_vscode_user_mcp_file()
|
31
|
+
if not files:
|
32
|
+
raise ImportErrorDetails("VSCode mcp.json not found at User/mcp.json on macOS/Linux.")
|
33
|
+
log.info("VSCode MCP config detected at: {}", files[0])
|
34
|
+
data = safe_read_json(files[0])
|
35
|
+
return parse_mcp_like_json(data, default_enabled=True)
|
36
|
+
|
37
|
+
|
38
|
+
def import_from_claude_code() -> list[MCPServerConfig]:
|
39
|
+
# Prefer Claude Code's documented user-level locations if present
|
40
|
+
files = find_claude_code_user_all_candidates()
|
41
|
+
if not files:
|
42
|
+
# Back-compat: also check specific settings.json location
|
43
|
+
files = find_claude_code_user_settings_file()
|
44
|
+
for f in files:
|
45
|
+
try:
|
46
|
+
log.info("Claude Code config detected at: {}", f)
|
47
|
+
data = safe_read_json(f)
|
48
|
+
parsed = parse_mcp_like_json(data, default_enabled=True)
|
49
|
+
if parsed:
|
50
|
+
return parsed
|
51
|
+
except Exception as e:
|
52
|
+
log.warning("Failed reading Claude Code config {}: {}", f, e)
|
53
|
+
|
54
|
+
# No user-level Claude Code config found; return empty per user preference
|
55
|
+
log.info("Claude Code config not found; returning empty result (no user-level config found)")
|
56
|
+
return []
|
57
|
+
|
58
|
+
|
59
|
+
IMPORTERS: dict[str, Callable[..., list[MCPServerConfig]]] = {
|
60
|
+
"cursor": import_from_cursor,
|
61
|
+
"vscode": import_from_vscode,
|
62
|
+
"claude-code": import_from_claude_code,
|
63
|
+
}
|
@@ -0,0 +1,47 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from loguru import logger as log
|
4
|
+
|
5
|
+
MCPServerConfigT = Any
|
6
|
+
|
7
|
+
|
8
|
+
class MergePolicy:
|
9
|
+
SKIP = "skip"
|
10
|
+
OVERWRITE = "overwrite"
|
11
|
+
RENAME = "rename"
|
12
|
+
|
13
|
+
|
14
|
+
def merge_servers(
|
15
|
+
existing: list[MCPServerConfigT],
|
16
|
+
imported: list[MCPServerConfigT],
|
17
|
+
policy: str,
|
18
|
+
) -> list[MCPServerConfigT]:
|
19
|
+
name_to_index: dict[str, int] = {s.name: i for i, s in enumerate(existing)}
|
20
|
+
result: list[MCPServerConfigT] = list(existing)
|
21
|
+
|
22
|
+
for server in imported:
|
23
|
+
server.enabled = True
|
24
|
+
if server.name in name_to_index:
|
25
|
+
if policy == MergePolicy.SKIP:
|
26
|
+
log.info("Skipping duplicate server '{}' (policy=skip)", server.name)
|
27
|
+
continue
|
28
|
+
if policy == MergePolicy.OVERWRITE:
|
29
|
+
idx = name_to_index[server.name]
|
30
|
+
log.info("Overwriting server '{}' (policy=overwrite)", server.name)
|
31
|
+
result[idx] = server
|
32
|
+
continue
|
33
|
+
if policy == MergePolicy.RENAME:
|
34
|
+
suffix = "-imported"
|
35
|
+
base_name = server.name
|
36
|
+
counter = 1
|
37
|
+
new_name = f"{base_name}{suffix}"
|
38
|
+
while new_name in name_to_index:
|
39
|
+
counter += 1
|
40
|
+
new_name = f"{base_name}{suffix}-{counter}"
|
41
|
+
server.name = new_name
|
42
|
+
log.info(
|
43
|
+
"Renamed imported server from '{}' to '{}' (policy=rename)", base_name, new_name
|
44
|
+
)
|
45
|
+
name_to_index[server.name] = len(result)
|
46
|
+
result.append(server)
|
47
|
+
return result
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# pyright: reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownMemberType=false
|
2
|
+
|
3
|
+
import json
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any, cast
|
6
|
+
|
7
|
+
from loguru import logger as log
|
8
|
+
|
9
|
+
from src.config import MCPServerConfig
|
10
|
+
|
11
|
+
|
12
|
+
# Import Open Edison types
|
13
|
+
def _new_mcp_server_config(
|
14
|
+
*,
|
15
|
+
name: str,
|
16
|
+
command: str,
|
17
|
+
args: list[str],
|
18
|
+
env: dict[str, str] | None,
|
19
|
+
enabled: bool,
|
20
|
+
roots: list[str] | None,
|
21
|
+
) -> Any:
|
22
|
+
"""Runtime-constructed MCPServerConfig without static import coupling."""
|
23
|
+
|
24
|
+
return MCPServerConfig(
|
25
|
+
name=name,
|
26
|
+
command=command,
|
27
|
+
args=args,
|
28
|
+
env=env or {},
|
29
|
+
enabled=enabled,
|
30
|
+
roots=roots,
|
31
|
+
)
|
32
|
+
|
33
|
+
|
34
|
+
class ImportErrorDetails(Exception): # noqa: N818
|
35
|
+
def __init__(self, message: str, path: Path | None = None):
|
36
|
+
super().__init__(message)
|
37
|
+
self.path = path
|
38
|
+
|
39
|
+
|
40
|
+
def safe_read_json(path: Path) -> dict[str, Any]:
|
41
|
+
try:
|
42
|
+
with open(path, encoding="utf-8") as f:
|
43
|
+
loaded = json.load(f)
|
44
|
+
except Exception as e:
|
45
|
+
raise ImportErrorDetails(f"Failed to read JSON from {path}: {e}", path) from e
|
46
|
+
|
47
|
+
if not isinstance(loaded, dict):
|
48
|
+
raise ImportErrorDetails(f"Expected JSON object at {path}", path)
|
49
|
+
data: dict[str, Any] = cast(dict[str, Any], loaded)
|
50
|
+
return data
|
51
|
+
|
52
|
+
|
53
|
+
def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool) -> Any:
|
54
|
+
command_val = node.get("command", "")
|
55
|
+
command = str(command_val) if isinstance(command_val, str) else ""
|
56
|
+
|
57
|
+
args_raw = node.get("args", [])
|
58
|
+
if not isinstance(args_raw, list):
|
59
|
+
args_raw = []
|
60
|
+
|
61
|
+
# Some tools provide combined commandWithArgs
|
62
|
+
if command == "" and isinstance(node.get("commandWithArgs"), list):
|
63
|
+
cmd_with_args = [str(p) for p in node["commandWithArgs"]]
|
64
|
+
if cmd_with_args:
|
65
|
+
command = cmd_with_args[0]
|
66
|
+
args_raw = cmd_with_args[1:]
|
67
|
+
|
68
|
+
args: list[str] = [str(a) for a in args_raw]
|
69
|
+
|
70
|
+
env_raw = node.get("env") or node.get("environment") or {}
|
71
|
+
env: dict[str, str] = {}
|
72
|
+
if isinstance(env_raw, dict):
|
73
|
+
for k, v in env_raw.items():
|
74
|
+
env[str(k)] = str(v)
|
75
|
+
|
76
|
+
enabled = bool(node.get("enabled", default_enabled))
|
77
|
+
|
78
|
+
roots_raw = node.get("roots") or node.get("rootPaths") or []
|
79
|
+
roots: list[str] | None = None
|
80
|
+
if isinstance(roots_raw, list):
|
81
|
+
roots = [str(r) for r in roots_raw]
|
82
|
+
if len(roots) == 0:
|
83
|
+
roots = None
|
84
|
+
|
85
|
+
return _new_mcp_server_config(
|
86
|
+
name=name,
|
87
|
+
command=command,
|
88
|
+
args=args,
|
89
|
+
env=env,
|
90
|
+
enabled=enabled,
|
91
|
+
roots=roots,
|
92
|
+
)
|
93
|
+
|
94
|
+
|
95
|
+
def _collect_from_dict(node_dict: dict[str, Any], default_enabled: bool) -> list[Any]:
|
96
|
+
results: list[Any] = []
|
97
|
+
for name_key, spec_obj in node_dict.items():
|
98
|
+
if isinstance(spec_obj, dict):
|
99
|
+
results.append(_coerce_server_entry(str(name_key), spec_obj, default_enabled))
|
100
|
+
return results
|
101
|
+
|
102
|
+
|
103
|
+
def _collect_from_list(node_list: list[Any], default_enabled: bool) -> list[Any]:
|
104
|
+
results: list[Any] = []
|
105
|
+
for spec_obj in node_list:
|
106
|
+
if isinstance(spec_obj, dict) and "name" in spec_obj:
|
107
|
+
name_val_obj = spec_obj.get("name")
|
108
|
+
name_str = str(name_val_obj) if name_val_obj is not None else ""
|
109
|
+
results.append(_coerce_server_entry(name_str, spec_obj, default_enabled))
|
110
|
+
return results
|
111
|
+
|
112
|
+
|
113
|
+
def _collect_top_level(data: dict[str, Any], default_enabled: bool) -> list[Any]:
|
114
|
+
results: list[Any] = []
|
115
|
+
for key in ("mcpServers", "servers"):
|
116
|
+
node = data.get(key)
|
117
|
+
if isinstance(node, dict):
|
118
|
+
results.extend(_collect_from_dict(node, default_enabled))
|
119
|
+
elif isinstance(node, list):
|
120
|
+
results.extend(_collect_from_list(node, default_enabled))
|
121
|
+
return results
|
122
|
+
|
123
|
+
|
124
|
+
def _collect_nested(data: dict[str, Any], default_enabled: bool) -> list[Any]:
|
125
|
+
results: list[Any] = []
|
126
|
+
for _k, v in data.items():
|
127
|
+
# If nested dict, recurse regardless of key to catch structures like 'projects'
|
128
|
+
if isinstance(v, dict):
|
129
|
+
results.extend(parse_mcp_like_json(v, default_enabled=default_enabled))
|
130
|
+
# If nested list, recurse into dict items
|
131
|
+
elif isinstance(v, list):
|
132
|
+
for item in v:
|
133
|
+
if isinstance(item, dict):
|
134
|
+
results.extend(parse_mcp_like_json(item, default_enabled=default_enabled))
|
135
|
+
return results
|
136
|
+
|
137
|
+
|
138
|
+
def parse_mcp_like_json(data: dict[str, Any], default_enabled: bool = True) -> list[Any]:
|
139
|
+
# First, try top-level keys
|
140
|
+
top_level = _collect_top_level(data, default_enabled)
|
141
|
+
if top_level:
|
142
|
+
return top_level
|
143
|
+
|
144
|
+
# Then, try nested structures heuristically
|
145
|
+
nested = _collect_nested(data, default_enabled)
|
146
|
+
if not nested:
|
147
|
+
log.debug("No MCP-like entries detected in provided data")
|
148
|
+
return nested
|