open-edison 0.1.84rc1__tar.gz → 0.1.85rc1__tar.gz

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 (62) hide show
  1. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/PKG-INFO +1 -1
  2. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/pyproject.toml +1 -1
  3. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/cli.py +51 -0
  4. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/api.py +21 -0
  5. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/exporters.py +173 -11
  6. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/.gitignore +0 -0
  7. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/LICENSE +0 -0
  8. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/README.md +0 -0
  9. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/config.json +0 -0
  10. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/desktop_ext/README.md +0 -0
  11. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/README.md +0 -0
  12. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/architecture/single_user_design.md +0 -0
  13. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/core/configuration.md +0 -0
  14. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/core/project_structure.md +0 -0
  15. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/core/proxy_usage.md +0 -0
  16. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/deployment/docker.md +0 -0
  17. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/deployment/local.md +0 -0
  18. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/development/contributing.md +0 -0
  19. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/development/development_guide.md +0 -0
  20. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/development/testing.md +0 -0
  21. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/quick-reference/api_reference.md +0 -0
  22. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/quick-reference/config_quick_start.md +0 -0
  23. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/docs/testing_status.md +0 -0
  24. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/hatch_build.py +0 -0
  25. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/installation_test/README.md +0 -0
  26. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/prompt_permissions.json +0 -0
  27. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/resource_permissions.json +0 -0
  28. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/__init__.py +0 -0
  29. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/__main__.py +0 -0
  30. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/config.py +0 -0
  31. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/config.pyi +0 -0
  32. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/demos/trifecta.py +0 -0
  33. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/events.py +0 -0
  34. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/frontend_dist/assets/index-D05VN_1l.css +0 -0
  35. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/frontend_dist/assets/index-D6ziuTsl.js +0 -0
  36. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/frontend_dist/index.html +0 -0
  37. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/frontend_dist/sw.js +0 -0
  38. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/__init__.py +0 -0
  39. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/__main__.py +0 -0
  40. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/cli.py +0 -0
  41. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/export_cli.py +0 -0
  42. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/import_api.py +0 -0
  43. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/importers.py +0 -0
  44. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/merge.py +0 -0
  45. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/parsers.py +0 -0
  46. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/paths.py +0 -0
  47. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/quick_cli.py +0 -0
  48. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_importer/types.py +0 -0
  49. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/mcp_stdio_capture.py +0 -0
  50. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/middleware/data_access_tracker.py +0 -0
  51. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/middleware/session_tracking.py +0 -0
  52. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/oauth_manager.py +0 -0
  53. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/oauth_override.py +0 -0
  54. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/permissions.py +0 -0
  55. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/server.py +0 -0
  56. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/setup_tui/__init__.py +0 -0
  57. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/setup_tui/main.py +0 -0
  58. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/single_user_mcp.py +0 -0
  59. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/telemetry.py +0 -0
  60. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/tools/io.py +0 -0
  61. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/src/vulture_whitelist.py +0 -0
  62. {open_edison-0.1.84rc1 → open_edison-0.1.85rc1}/tool_permissions.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-edison
3
- Version: 0.1.84rc1
3
+ Version: 0.1.85rc1
4
4
  Summary: Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy.
5
5
  Author-email: Hugo Berg <hugo@edison.watch>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "open-edison"
3
- version = "0.1.84rc1"
3
+ version = "0.1.85rc1"
4
4
  description = "Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -14,6 +14,7 @@ from loguru import logger as log
14
14
 
15
15
  from src.config import Config, get_config_dir, get_config_json_path
16
16
  from src.demos.trifecta import demo_config_dir, run_trifecta_demo
17
+ from src.mcp_importer.api import detect_clients, restore_client
17
18
  from src.mcp_importer.cli import run_cli
18
19
  from src.server import OpenEdisonProxy
19
20
  from src.setup_tui.main import run_import_tui
@@ -106,6 +107,21 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
106
107
  ),
107
108
  )
108
109
 
110
+ # restore-clients: restore editor configs from backups or remove OE-only config
111
+ sp_restore = subparsers.add_parser(
112
+ "restore-clients",
113
+ help="Restore backed up MCP client configs (Cursor, VS Code, Claude Code)",
114
+ description=(
115
+ "Detect installed clients and restore their MCP config from the most recent backup, "
116
+ "or remove the Open Edison-only MCP entry if no backup is present."
117
+ ),
118
+ )
119
+ sp_restore.add_argument(
120
+ "--dry-run",
121
+ action="store_true",
122
+ help="Preview restore operations without writing",
123
+ )
124
+
109
125
  return parser.parse_args(argv)
110
126
 
111
127
 
@@ -158,6 +174,41 @@ def main(argv: list[str] | None = None) -> NoReturn: # noqa: C901
158
174
  asyncio.run(_run_server(args))
159
175
  raise SystemExit(0)
160
176
 
177
+ if args.command == "restore-clients":
178
+ # Detect clients and prompt user per client
179
+ available = sorted(detect_clients(), key=lambda c: c.value)
180
+ if not available:
181
+ log.info("No supported MCP clients detected")
182
+ raise SystemExit(0)
183
+ from questionary import confirm
184
+
185
+ for client in available:
186
+ if not confirm(
187
+ f"Restore original MCP config for {client.value}? (removes Open Edison)", default=True
188
+ ).ask():
189
+ continue
190
+ try:
191
+ res = restore_client(client, dry_run=getattr(args, "dry_run", False))
192
+ if getattr(args, "dry_run", False):
193
+ log.info("[dry-run] {}: would restore at {}", client.value, res.target_path)
194
+ else:
195
+ if res.restored_from_backup is not None:
196
+ log.info(
197
+ "Restored {} from backup {}", client.value, res.restored_from_backup
198
+ )
199
+ elif res.removed_open_edison_only:
200
+ log.info(
201
+ "Removed Open Edison-only entry from {} at {}",
202
+ client.value,
203
+ res.target_path,
204
+ )
205
+ else:
206
+ log.info("No restore action taken for {}", client.value)
207
+ except Exception as e:
208
+ log.error(f"Restore failed for {client.value}: {e}")
209
+ raise SystemExit(1) from e
210
+ raise SystemExit(0)
211
+
161
212
  # Run import tui if necessary
162
213
  tui_success = run_import_tui(args, force=args.wizard_force)
163
214
  if not tui_success:
@@ -15,9 +15,13 @@ from src.config import Config, MCPServerConfig, get_config_json_path
15
15
  from src.mcp_importer import paths as _paths
16
16
  from src.mcp_importer.exporters import (
17
17
  ExportResult,
18
+ RestoreResult,
18
19
  export_to_claude_code,
19
20
  export_to_cursor,
20
21
  export_to_vscode,
22
+ restore_claude_code,
23
+ restore_cursor,
24
+ restore_vscode,
21
25
  )
22
26
  from src.mcp_importer.importers import (
23
27
  import_from_claude_code,
@@ -135,6 +139,23 @@ def export_edison_to(
135
139
  )
136
140
 
137
141
 
142
+ def restore_client(
143
+ client: CLIENT,
144
+ *,
145
+ server_name: str = "open-edison",
146
+ dry_run: bool = False,
147
+ ) -> RestoreResult:
148
+ if dry_run:
149
+ print(f"[dry-run] Would restore original MCP config for '{client}' (using latest backup if present)")
150
+ match client:
151
+ case CLIENT.CURSOR:
152
+ return restore_cursor(server_name=server_name, dry_run=dry_run)
153
+ case CLIENT.VSCODE:
154
+ return restore_vscode(server_name=server_name, dry_run=dry_run)
155
+ case CLIENT.CLAUDE_CODE:
156
+ return restore_claude_code(server_name=server_name, dry_run=dry_run)
157
+
158
+
138
159
  def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
139
160
  """Minimal validation: try listing tools/resources/prompts via FastMCP within a timeout."""
140
161
 
@@ -5,7 +5,7 @@ import tempfile
5
5
  from dataclasses import dataclass
6
6
  from datetime import datetime
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import Any, cast
9
9
 
10
10
  from loguru import logger as log
11
11
 
@@ -63,7 +63,8 @@ def _read_json_or_error(path: Path) -> dict[str, Any]:
63
63
  raise ExportError(f"Malformed JSON at {path}: {e}") from e
64
64
  if not isinstance(data, dict):
65
65
  raise ExportError(f"Expected top-level JSON object at {path}")
66
- return data
66
+ typed_data: dict[str, Any] = cast(dict[str, Any], data)
67
+ return typed_data
67
68
 
68
69
 
69
70
  def _require_supported_os() -> None:
@@ -188,21 +189,25 @@ def _build_open_edison_server(
188
189
  def _is_already_open_edison(
189
190
  config_obj: dict[str, Any], *, url: str, api_key: str, name: str
190
191
  ) -> bool:
191
- servers_node = config_obj.get("mcpServers") or config_obj.get("servers")
192
- if not isinstance(servers_node, dict):
192
+ servers_raw: Any = config_obj.get("mcpServers") or config_obj.get("servers")
193
+ if not isinstance(servers_raw, dict):
193
194
  return False
195
+ servers_node: dict[str, Any] = cast(dict[str, Any], servers_raw)
194
196
  # Must be exactly one server
195
197
  if len(servers_node) != 1:
196
198
  return False
197
- only_name, only_spec = next(iter(servers_node.items()))
198
- if only_name != name or not isinstance(only_spec, dict):
199
+ only_name, only_spec_any = next(iter(servers_node.items()))
200
+ if only_name != name or not isinstance(only_spec_any, dict):
199
201
  return False
200
- if only_spec.get("command") != "npx":
202
+ only_spec: dict[str, Any] = cast(dict[str, Any], only_spec_any)
203
+ cmd_val_any: Any = only_spec.get("command")
204
+ if cmd_val_any != "npx":
201
205
  return False
202
- args = only_spec.get("args")
203
- if not isinstance(args, list):
206
+ args_obj_any: Any = only_spec.get("args")
207
+ if not isinstance(args_obj_any, list):
204
208
  return False
205
- args_str = [str(a) for a in args]
209
+ args_list: list[Any] = cast(list[Any], args_obj_any)
210
+ args_str = [str(a) for a in args_list]
206
211
  expected_header = f"Authorization: Bearer {api_key}"
207
212
  return (
208
213
  url in args_str
@@ -213,6 +218,163 @@ def _is_already_open_edison(
213
218
  )
214
219
 
215
220
 
221
+ # --- Restore helpers and functions ---
222
+
223
+
224
+ @dataclass
225
+ class RestoreResult:
226
+ target_path: Path
227
+ restored_from_backup: Path | None
228
+ wrote_changes: bool
229
+ dry_run: bool
230
+ removed_open_edison_only: bool
231
+
232
+
233
+ def _find_latest_backup(target_path: Path) -> Path | None:
234
+ parent = target_path.parent
235
+ prefix = target_path.name + ".bak-"
236
+ candidates: list[Path] = [p for p in parent.glob(target_path.name + ".bak-*") if p.is_file()]
237
+ if not candidates:
238
+ return None
239
+ # Sort by timestamp portion descending (string sort works with our format)
240
+ def _key(p: Path) -> str:
241
+ return p.name.replace(prefix, "")
242
+
243
+ candidates.sort(key=_key, reverse=True)
244
+ return candidates[0]
245
+
246
+
247
+ def _is_open_edison_singleton(config_obj: dict[str, Any], *, name: str) -> bool:
248
+ servers_raw: Any = config_obj.get("mcpServers") or config_obj.get("servers")
249
+ if not isinstance(servers_raw, dict):
250
+ return False
251
+ servers_node: dict[str, Any] = cast(dict[str, Any], servers_raw)
252
+ if len(servers_node) != 1:
253
+ return False
254
+ only_name, only_spec_any = next(iter(servers_node.items()))
255
+ if only_name != name or not isinstance(only_spec_any, dict):
256
+ return False
257
+ only_spec: dict[str, Any] = cast(dict[str, Any], only_spec_any)
258
+ cmd_val_any: Any = only_spec.get("command")
259
+ if cmd_val_any != "npx":
260
+ return False
261
+ args_obj_any: Any = only_spec.get("args")
262
+ if not isinstance(args_obj_any, list):
263
+ return False
264
+ args_list: list[Any] = cast(list[Any], args_obj_any)
265
+ args_str = [str(a) for a in args_list]
266
+ return "mcp-remote" in args_str
267
+
268
+
269
+ def _restore_from_backup_or_remove(
270
+ *,
271
+ target_path: Path,
272
+ label: str,
273
+ key_name: str,
274
+ server_name: str,
275
+ dry_run: bool,
276
+ ) -> RestoreResult:
277
+ backup = _find_latest_backup(target_path)
278
+ if backup is not None:
279
+ if dry_run:
280
+ log.info("[dry-run] Would restore {} from backup {}", label, backup)
281
+ return RestoreResult(
282
+ target_path=target_path,
283
+ restored_from_backup=backup,
284
+ wrote_changes=False,
285
+ dry_run=True,
286
+ removed_open_edison_only=False,
287
+ )
288
+ _ensure_parent_dir(target_path)
289
+ shutil.copy2(backup, target_path)
290
+ log.info("Restored {} from backup {}", label, backup)
291
+ return RestoreResult(
292
+ target_path=target_path,
293
+ restored_from_backup=backup,
294
+ wrote_changes=True,
295
+ dry_run=False,
296
+ removed_open_edison_only=False,
297
+ )
298
+
299
+ # No backup found; as a safety, remove the Open Edison-only MCP section if it exactly matches
300
+ if not target_path.exists():
301
+ return RestoreResult(
302
+ target_path=target_path,
303
+ restored_from_backup=None,
304
+ wrote_changes=False,
305
+ dry_run=dry_run,
306
+ removed_open_edison_only=False,
307
+ )
308
+ current = _read_json_or_error(target_path)
309
+ if _is_open_edison_singleton(current, name=server_name):
310
+ if dry_run:
311
+ log.info("[dry-run] Would remove Open Edison-only MCP section from {}", label)
312
+ return RestoreResult(
313
+ target_path=target_path,
314
+ restored_from_backup=None,
315
+ wrote_changes=False,
316
+ dry_run=True,
317
+ removed_open_edison_only=True,
318
+ )
319
+ if key_name in current:
320
+ # Remove entire MCP section
321
+ current.pop(key_name, None)
322
+ _atomic_write_json(target_path, current)
323
+ log.info("Removed Open Edison-only MCP section from {}", label)
324
+ return RestoreResult(
325
+ target_path=target_path,
326
+ restored_from_backup=None,
327
+ wrote_changes=True,
328
+ dry_run=False,
329
+ removed_open_edison_only=True,
330
+ )
331
+ # Nothing to do
332
+ return RestoreResult(
333
+ target_path=target_path,
334
+ restored_from_backup=None,
335
+ wrote_changes=False,
336
+ dry_run=dry_run,
337
+ removed_open_edison_only=False,
338
+ )
339
+
340
+
341
+ def restore_cursor(*, server_name: str = "open-edison", dry_run: bool = False) -> RestoreResult:
342
+ _require_supported_os()
343
+ target_path = _resolve_cursor_target()
344
+ return _restore_from_backup_or_remove(
345
+ target_path=target_path,
346
+ label="Cursor",
347
+ key_name="mcpServers",
348
+ server_name=server_name,
349
+ dry_run=dry_run,
350
+ )
351
+
352
+
353
+ def restore_vscode(*, server_name: str = "open-edison", dry_run: bool = False) -> RestoreResult:
354
+ _require_supported_os()
355
+ target_path = _resolve_vscode_target()
356
+ return _restore_from_backup_or_remove(
357
+ target_path=target_path,
358
+ label="VS Code",
359
+ key_name="servers",
360
+ server_name=server_name,
361
+ dry_run=dry_run,
362
+ )
363
+
364
+
365
+ def restore_claude_code(*, server_name: str = "open-edison", dry_run: bool = False) -> RestoreResult:
366
+ _require_supported_os()
367
+ target_path = _resolve_claude_code_target()
368
+ # Claude Code uses general settings format; MCP key is "mcpServers"
369
+ return _restore_from_backup_or_remove(
370
+ target_path=target_path,
371
+ label="Claude Code",
372
+ key_name="mcpServers",
373
+ server_name=server_name,
374
+ dry_run=dry_run,
375
+ )
376
+
377
+
216
378
  def export_to_cursor(
217
379
  *,
218
380
  url: str = "http://localhost:3000/mcp/",
@@ -378,7 +540,7 @@ def export_to_claude_code(
378
540
  current = {}
379
541
 
380
542
  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:
543
+ if is_existing and current:
382
544
  new_config = _merge_preserving_non_mcp(current, new_mcp)
383
545
  else:
384
546
  new_config = {"mcpServers": new_mcp}
File without changes