open-edison 0.1.80rc1__tar.gz → 0.1.84rc1__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.80rc1 → open_edison-0.1.84rc1}/PKG-INFO +2 -1
  2. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/config.json +5 -5
  3. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/pyproject.toml +3 -2
  4. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/cli.py +27 -4
  5. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/config.py +57 -0
  6. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/config.pyi +5 -0
  7. open_edison-0.1.84rc1/src/demos/trifecta.py +110 -0
  8. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/importers.py +19 -7
  9. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/parsers.py +6 -5
  10. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/paths.py +31 -12
  11. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/middleware/session_tracking.py +24 -1
  12. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/permissions.py +4 -20
  13. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/server.py +8 -21
  14. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/setup_tui/main.py +39 -5
  15. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/tool_permissions.json +10 -0
  16. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/.gitignore +0 -0
  17. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/LICENSE +0 -0
  18. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/README.md +0 -0
  19. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/desktop_ext/README.md +0 -0
  20. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/README.md +0 -0
  21. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/architecture/single_user_design.md +0 -0
  22. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/core/configuration.md +0 -0
  23. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/core/project_structure.md +0 -0
  24. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/core/proxy_usage.md +0 -0
  25. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/deployment/docker.md +0 -0
  26. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/deployment/local.md +0 -0
  27. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/development/contributing.md +0 -0
  28. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/development/development_guide.md +0 -0
  29. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/development/testing.md +0 -0
  30. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/quick-reference/api_reference.md +0 -0
  31. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/quick-reference/config_quick_start.md +0 -0
  32. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/docs/testing_status.md +0 -0
  33. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/hatch_build.py +0 -0
  34. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/installation_test/README.md +0 -0
  35. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/prompt_permissions.json +0 -0
  36. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/resource_permissions.json +0 -0
  37. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/__init__.py +0 -0
  38. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/__main__.py +0 -0
  39. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/events.py +0 -0
  40. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/frontend_dist/assets/index-D05VN_1l.css +0 -0
  41. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/frontend_dist/assets/index-D6ziuTsl.js +0 -0
  42. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/frontend_dist/index.html +0 -0
  43. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/frontend_dist/sw.js +0 -0
  44. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/__init__.py +0 -0
  45. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/__main__.py +0 -0
  46. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/api.py +0 -0
  47. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/cli.py +0 -0
  48. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/export_cli.py +0 -0
  49. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/exporters.py +0 -0
  50. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/import_api.py +0 -0
  51. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/merge.py +0 -0
  52. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/quick_cli.py +0 -0
  53. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_importer/types.py +0 -0
  54. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/mcp_stdio_capture.py +0 -0
  55. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/middleware/data_access_tracker.py +0 -0
  56. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/oauth_manager.py +0 -0
  57. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/oauth_override.py +0 -0
  58. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/setup_tui/__init__.py +0 -0
  59. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/single_user_mcp.py +0 -0
  60. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/telemetry.py +0 -0
  61. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/tools/io.py +0 -0
  62. {open_edison-0.1.80rc1 → open_edison-0.1.84rc1}/src/vulture_whitelist.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-edison
3
- Version: 0.1.80rc1
3
+ Version: 0.1.84rc1
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
@@ -16,6 +16,7 @@ Requires-Dist: opentelemetry-api>=1.36.0
16
16
  Requires-Dist: opentelemetry-exporter-otlp>=1.36.0
17
17
  Requires-Dist: opentelemetry-sdk>=1.36.0
18
18
  Requires-Dist: python-dotenv>=1.0.1
19
+ Requires-Dist: python-rapidjson>=1.21
19
20
  Requires-Dist: pyyaml>=6.0.2
20
21
  Requires-Dist: questionary>=2.1.1
21
22
  Requires-Dist: sqlalchemy>=2.0.41
@@ -17,7 +17,8 @@
17
17
  "args": [
18
18
  "-y",
19
19
  "@modelcontextprotocol/server-filesystem",
20
- "/tmp"
20
+ "/tmp",
21
+ "/private/tmp"
21
22
  ],
22
23
  "env": {},
23
24
  "enabled": true,
@@ -25,13 +26,12 @@
25
26
  },
26
27
  {
27
28
  "name": "fetch",
28
- "command": "npx",
29
+ "command": "uvx",
29
30
  "args": [
30
- "-y",
31
- "@modelcontextprotocol/server-everything"
31
+ "mcp-server-fetch"
32
32
  ],
33
33
  "env": {},
34
- "enabled": false
34
+ "enabled": true
35
35
  },
36
36
  {
37
37
  "name": "github",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "open-edison"
3
- version = "0.1.80rc1"
3
+ version = "0.1.84rc1"
4
4
  description = "Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,8 +23,9 @@ dependencies = [
23
23
  "opentelemetry-exporter-otlp>=1.36.0",
24
24
  "hatchling>=1.27.0",
25
25
  "questionary>=2.1.1",
26
+ "python-rapidjson>=1.21",
26
27
  ]
27
- requires-python = ">= 3.12"
28
+ requires-python = ">=3.12"
28
29
 
29
30
  [project.scripts]
30
31
  open-edison = "src.cli:main"
@@ -6,18 +6,18 @@ Provides the `open-edison` executable when installed via pip/uvx/pipx.
6
6
  import argparse
7
7
  import asyncio
8
8
  import os
9
+ import sys
9
10
  from pathlib import Path
10
11
  from typing import Any, NoReturn
11
12
 
12
- from loguru import logger as _log # type: ignore[reportMissingImports]
13
+ from loguru import logger as log
13
14
 
14
15
  from src.config import Config, get_config_dir, get_config_json_path
16
+ from src.demos.trifecta import demo_config_dir, run_trifecta_demo
15
17
  from src.mcp_importer.cli import run_cli
16
18
  from src.server import OpenEdisonProxy
17
19
  from src.setup_tui.main import run_import_tui
18
20
 
19
- log: Any = _log
20
-
21
21
 
22
22
  def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
23
23
  parser: Any = argparse.ArgumentParser(
@@ -98,6 +98,13 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
98
98
  action="store_true",
99
99
  help="Show changes without writing to config.json",
100
100
  )
101
+ _ = subparsers.add_parser(
102
+ "demo-trifecta",
103
+ help="Run the Simple Trifecta Demo setup and print the prompt",
104
+ description=(
105
+ "Seeds a demo secret file in /tmp, checks config hints, and prints the demo prompt."
106
+ ),
107
+ )
101
108
 
102
109
  return parser.parse_args(argv)
103
110
 
@@ -132,8 +139,24 @@ def main(argv: list[str] | None = None) -> NoReturn: # noqa: C901
132
139
  args.command = "run"
133
140
 
134
141
  if args.command == "import-mcp":
135
- result_code = run_cli(argv)
142
+ # Forward only the subcommand args (exclude the 'import-mcp' token itself)
143
+ if argv is None:
144
+ argv = sys.argv[1:]
145
+ raw_args = list(argv)
146
+ try:
147
+ subcmd_index = raw_args.index("import-mcp")
148
+ sub_argv = raw_args[subcmd_index + 1 :]
149
+ except ValueError:
150
+ sub_argv = raw_args
151
+
152
+ result_code = run_cli(sub_argv)
136
153
  raise SystemExit(result_code)
154
+ if args.command == "demo-trifecta":
155
+ run_trifecta_demo()
156
+ with demo_config_dir() as demo_dir:
157
+ os.environ["OPEN_EDISON_CONFIG_DIR"] = str(demo_dir)
158
+ asyncio.run(_run_server(args))
159
+ raise SystemExit(0)
137
160
 
138
161
  # Run import tui if necessary
139
162
  tui_success = run_import_tui(args, force=args.wizard_force)
@@ -63,6 +63,63 @@ def get_config_json_path() -> Path:
63
63
  return get_config_dir() / "config.json"
64
64
 
65
65
 
66
+ def repo_defaults_path(filename: str) -> Path:
67
+ """Path to repository/package-default JSON next to src/.
68
+
69
+ Example: repo_defaults_path("tool_permissions.json") -> /.../src/../tool_permissions.json
70
+ """
71
+ return root_dir / filename
72
+
73
+
74
+ def ensure_permissions_file(file_path: Path, *, default_json: dict[str, Any] | None = None) -> Path:
75
+ """Ensure a permissions JSON file exists at file_path.
76
+
77
+ Behavior:
78
+ - If file exists: return it
79
+ - Else: try to copy repo default (next to src/) into place; if not present, write minimal stub
80
+ - Returns the final path (which should exist unless write failed)
81
+ """
82
+ if file_path.exists():
83
+ return file_path
84
+
85
+ file_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ repo_candidate = repo_defaults_path(file_path.name)
88
+ if repo_candidate.exists():
89
+ file_path.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
90
+ log.info(f"Bootstrapped permissions file from defaults: {file_path}")
91
+ return file_path
92
+
93
+ # Minimal stub when no repo default available
94
+ minimal_obj: dict[str, Any] = default_json if default_json is not None else {"_metadata": {}}
95
+ file_path.write_text(json.dumps(minimal_obj, indent=2), encoding="utf-8")
96
+ log.info(f"Created minimal permissions file: {file_path}")
97
+ return file_path
98
+
99
+
100
+ def resolve_json_path_with_bootstrap(filename: str) -> Path:
101
+ """Resolve a JSON config file path with repo bootstrap fallback.
102
+
103
+ Precedence:
104
+ 1) Return config-dir path if it already exists
105
+ 2) If repo default exists, attempt to copy it into config-dir and return the copied path
106
+ 3) Return the config-dir target path (may not exist yet)
107
+ """
108
+ base = get_config_dir()
109
+ target = base / filename
110
+ if target.exists():
111
+ return target
112
+
113
+ repo_candidate = repo_defaults_path(filename)
114
+ if repo_candidate.exists():
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
117
+ return target
118
+ raise FileNotFoundError(
119
+ f"File {filename} not found in the src repo and could not be bootstrapped"
120
+ )
121
+
122
+
66
123
  @dataclass
67
124
  class ServerConfig:
68
125
  """Server configuration"""
@@ -7,6 +7,11 @@ root_dir: Path
7
7
 
8
8
  def get_config_dir() -> Path: ...
9
9
  def get_config_json_path() -> Path: ...
10
+ def repo_defaults_path(filename: str) -> Path: ...
11
+ def ensure_permissions_file(
12
+ file_path: Path, *, default_json: dict[str, Any] | None = None
13
+ ) -> Path: ...
14
+ def resolve_json_path_with_bootstrap(filename: str) -> Path: ...
10
15
 
11
16
  class ServerConfig:
12
17
  host: str
@@ -0,0 +1,110 @@
1
+ """
2
+ Trifecta demo runner used by the CLI and the standalone script.
3
+
4
+ This module seeds a secret file under the tmp directory, checks for basic
5
+ config hints, and prints the user prompt found at demo/trifecta_user_prompt.txt.
6
+
7
+ Additionally, it provides a helper to create a temporary configuration
8
+ directory containing `config.json` and the three permissions JSON files
9
+ so the demo can run with a known-good configuration without touching the
10
+ user's real config directory.
11
+ """
12
+
13
+ import sys
14
+ from collections.abc import Generator
15
+ from contextlib import contextmanager
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from tempfile import TemporaryDirectory
19
+
20
+ import questionary
21
+ from loguru import logger as log
22
+
23
+
24
+ def _get_tmp_root() -> Path:
25
+ if sys.platform == "darwin":
26
+ return Path("/private/tmp")
27
+ return Path("/tmp")
28
+
29
+
30
+ TMP_ROOT = _get_tmp_root()
31
+ SECRET_DIR = TMP_ROOT / "open-edison"
32
+ SECRET_FILE = SECRET_DIR / "mysecretdetails.txt"
33
+
34
+
35
+ def _load_project_version(pyproject_path: Path) -> str:
36
+ try:
37
+ import tomllib
38
+
39
+ with pyproject_path.open("rb") as f:
40
+ data = tomllib.load(f)
41
+ project = data.get("project", {})
42
+ return str(project.get("version", "unknown"))
43
+ except Exception:
44
+ return "unknown"
45
+
46
+
47
+ def _seed_secret_file(version: str) -> None:
48
+ SECRET_DIR.mkdir(parents=True, exist_ok=True)
49
+ installed_ts = datetime.now(UTC).isoformat()
50
+ lines = [
51
+ "Open Edison Demo Secret",
52
+ f"version={version}",
53
+ f"installed_utc={installed_ts}",
54
+ ]
55
+ SECRET_FILE.write_text("\n".join(lines), encoding="utf-8")
56
+
57
+
58
+ @contextmanager
59
+ def demo_config_dir() -> Generator[Path, None, None]:
60
+ """Create a temporary config directory for the demo with JSONs.
61
+
62
+ Copies `config.json`, `tool_permissions.json`, `resource_permissions.json`,
63
+ and `prompt_permissions.json` from the repository root into a new directory
64
+ under the system tmp path. Ensures the `fetch` and `filesystem` servers are
65
+ enabled in the copied `config.json`. Returns the created directory path.
66
+ """
67
+ repo_root = Path(__file__).resolve().parents[2]
68
+ filenames = [
69
+ "config.json",
70
+ "tool_permissions.json",
71
+ "resource_permissions.json",
72
+ "prompt_permissions.json",
73
+ ]
74
+
75
+ with TemporaryDirectory(prefix="open-edison-demo-") as tmp_dir:
76
+ demo_dir = Path(tmp_dir)
77
+ for filename in filenames:
78
+ log.debug(f"Copying {filename} to {demo_dir / filename}")
79
+ (demo_dir / filename).write_text(
80
+ (repo_root / filename).read_text(encoding="utf-8"), encoding="utf-8"
81
+ )
82
+
83
+ log.info(f"Created temporary demo config directory: {demo_dir}")
84
+ yield demo_dir
85
+
86
+
87
+ def run_trifecta_demo() -> None:
88
+ repo_root = Path(__file__).resolve().parents[2]
89
+ pyproject = repo_root / "pyproject.toml"
90
+ version = _load_project_version(pyproject)
91
+
92
+ _seed_secret_file(version)
93
+
94
+ print("\n=== Open Edison: Simple Trifecta Demo Setup ===")
95
+ print(f"Seeded secret file at: {SECRET_FILE}")
96
+ print(f"Project version detected: {version}")
97
+
98
+ print("\nNext step: Copy/paste this prompt into your MCP client:")
99
+ print("----------------------------------------------------")
100
+ prompt_path = repo_root / "demo" / "trifecta_user_prompt.txt"
101
+ prompt_text = prompt_path.read_text(encoding="utf-8").strip()
102
+ print(prompt_text)
103
+ print("----------------------------------------------------")
104
+
105
+ questionary.confirm(
106
+ "Have you copied the prompt into your MCP client and are ready to launch the server now?",
107
+ default=True,
108
+ ).ask()
109
+
110
+ # On return, the cli.py handles launching the server
@@ -5,7 +5,7 @@ from loguru import logger as log
5
5
 
6
6
  from src.config import MCPServerConfig
7
7
 
8
- from .parsers import ImportErrorDetails, parse_mcp_like_json, safe_read_json
8
+ from .parsers import ImportErrorDetails, parse_mcp_like_json, permissive_read_json
9
9
  from .paths import (
10
10
  find_claude_code_user_all_candidates,
11
11
  find_claude_code_user_settings_file,
@@ -22,17 +22,29 @@ def import_from_cursor() -> list[MCPServerConfig]:
22
22
  "Cursor MCP config not found (~/.cursor/mcp.json).",
23
23
  Path.home() / ".cursor" / "mcp.json",
24
24
  )
25
- data = safe_read_json(files[0])
25
+ data = permissive_read_json(files[0])
26
26
  return parse_mcp_like_json(data, default_enabled=True)
27
27
 
28
28
 
29
29
  def import_from_vscode() -> list[MCPServerConfig]:
30
30
  files = find_vscode_user_mcp_file()
31
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)
32
+ raise ImportErrorDetails(
33
+ "VSCode configuration not found (checked User/mcp.json and User/settings.json)."
34
+ )
35
+ # Try each file; stop at the first that yields MCP servers
36
+ for f in files:
37
+ try:
38
+ log.info("VSCode config detected at: {}", f)
39
+ data = permissive_read_json(f)
40
+ parsed = parse_mcp_like_json(data, default_enabled=True)
41
+ if parsed:
42
+ return parsed
43
+ except Exception as e:
44
+ print(f"Failed reading VSCode config {f}: {e}")
45
+ # If we saw files but none yielded servers, return empty with info
46
+ log.info("No MCP servers found in VSCode config candidates; returning empty list")
47
+ return []
36
48
 
37
49
 
38
50
  def import_from_claude_code() -> list[MCPServerConfig]:
@@ -44,7 +56,7 @@ def import_from_claude_code() -> list[MCPServerConfig]:
44
56
  for f in files:
45
57
  try:
46
58
  log.info("Claude Code config detected at: {}", f)
47
- data = safe_read_json(f)
59
+ data = permissive_read_json(f)
48
60
  parsed = parse_mcp_like_json(data, default_enabled=True)
49
61
  if parsed:
50
62
  return parsed
@@ -1,10 +1,8 @@
1
- # pyright: reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownMemberType=false
2
-
3
- import json
4
1
  import shlex
5
2
  from pathlib import Path
6
3
  from typing import Any, cast
7
4
 
5
+ import rapidjson
8
6
  from loguru import logger as log
9
7
 
10
8
  from src.config import MCPServerConfig
@@ -38,10 +36,13 @@ class ImportErrorDetails(Exception): # noqa: N818
38
36
  self.path = path
39
37
 
40
38
 
41
- def safe_read_json(path: Path) -> dict[str, Any]:
39
+ # Many clients, like vscode, allow comments and trailing commas etc in their settings files.
40
+ def permissive_read_json(path: Path) -> dict[str, Any]:
42
41
  try:
43
42
  with open(path, encoding="utf-8") as f:
44
- loaded = json.load(f)
43
+ loaded = rapidjson.load(
44
+ f, parse_mode=rapidjson.PM_COMMENTS | rapidjson.PM_TRAILING_COMMAS
45
+ )
45
46
  except Exception as e:
46
47
  raise ImportErrorDetails(f"Failed to read JSON from {path}: {e}", path) from e
47
48
 
@@ -17,14 +17,33 @@ def find_cursor_user_file() -> list[Path]:
17
17
  return [p] if p.exists() else []
18
18
 
19
19
 
20
+ def _vscode_base_dirs() -> list[Path]:
21
+ """Return likely VSCode user base directories for different distributions."""
22
+ home = Path.home()
23
+ vscode_config_base = (
24
+ home / "Library" / "Application Support" if is_macos() else home / ".config"
25
+ )
26
+ return [
27
+ x
28
+ for x in [
29
+ vscode_config_base / "Code",
30
+ vscode_config_base / "Code - Insiders",
31
+ vscode_config_base / "VSCodium",
32
+ vscode_config_base / "Code - OSS",
33
+ ]
34
+ if x.exists()
35
+ ]
36
+
37
+
20
38
  def find_vscode_user_mcp_file() -> list[Path]:
21
- """Find VSCode user-level MCP config (User/mcp.json) on macOS or Linux."""
22
- if is_macos():
23
- p = Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
24
- else:
25
- p = Path.home() / ".config" / "Code" / "User" / "mcp.json"
26
- p = p.resolve()
27
- return [p] if p.exists() else []
39
+ """Find VSCode user-level MCP config: prefer User/mcp.json; fall back to User/settings.json."""
40
+ results: list[Path] = []
41
+ for base in _vscode_base_dirs():
42
+ for filename in ("mcp.json", "settings.json"):
43
+ candidate = (base / "User" / filename).resolve()
44
+ if candidate.exists():
45
+ results.append(candidate)
46
+ return list(set(results))
28
47
 
29
48
 
30
49
  def find_claude_code_user_settings_file() -> list[Path]:
@@ -71,11 +90,11 @@ def detect_vscode_config_path() -> Path | None:
71
90
 
72
91
 
73
92
  def get_default_vscode_config_path() -> Path:
74
- if is_macos():
75
- return (
76
- Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
77
- ).resolve()
78
- return (Path.home() / ".config" / "Code" / "User" / "mcp.json").resolve()
93
+ # Prefer the first base dir; target mcp.json under User/
94
+ existing_base_dir = next((base for base in _vscode_base_dirs() if base.exists()), None)
95
+ if existing_base_dir:
96
+ return (existing_base_dir / "User" / "mcp.json").resolve()
97
+ raise RuntimeError("No VSCode base directory found! Are you sure VSCode is installed?")
79
98
 
80
99
 
81
100
  def get_default_cursor_config_path() -> Path:
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from typing import Any, cast
17
17
 
18
18
  import mcp.types as mt
19
- from fastmcp.exceptions import ToolError
19
+ from fastmcp.exceptions import NotFoundError, ToolError
20
20
  from fastmcp.prompts.prompt import FunctionPrompt
21
21
  from fastmcp.resources import FunctionResource
22
22
  from fastmcp.server.middleware import Middleware
@@ -277,6 +277,10 @@ class SessionTrackingMiddleware(Middleware):
277
277
  # Avoid noisy tracebacks for expected security blocks
278
278
  log.warning(f"MCP request blocked by security policy: {e}")
279
279
  raise
280
+ except NotFoundError as e:
281
+ # Tool/prompt/resource not found; avoid full traceback
282
+ log.warning(f"MCP tool/prompt/resource not found error: {e}")
283
+ raise
280
284
  except ToolError as e:
281
285
  # Upstream tool failed; avoid noisy traceback here. Specific handlers may format a response.
282
286
  log.warning(f"MCP tool error: {e}")
@@ -409,6 +413,25 @@ class SessionTrackingMiddleware(Middleware):
409
413
  _persist_session_to_db(session)
410
414
 
411
415
  return result
416
+ except NotFoundError as e:
417
+ new_tool_call.status = "error"
418
+ new_tool_call.duration_ms = (time.perf_counter() - start_time) * 1000.0
419
+
420
+ _persist_session_to_db(session)
421
+
422
+ # Return concise not-found message similar to ToolError handling
423
+ log.warning(f"Tool not found: {context.message.name}: {e}")
424
+ return ToolResult(
425
+ content=[
426
+ {
427
+ "type": "text",
428
+ "text": (
429
+ f"Tool '{context.message.name}' not found. Please check the tool name or mounted servers."
430
+ ),
431
+ }
432
+ ],
433
+ structured_content=None,
434
+ )
412
435
  except ToolError as e:
413
436
  new_tool_call.status = "error"
414
437
  new_tool_call.duration_ms = (time.perf_counter() - start_time) * 1000.0
@@ -11,9 +11,9 @@ from functools import cache
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
- from loguru import logger as log
14
+ from loguru import logger as log # pyright: ignore[reportUnknownVariableType]
15
15
 
16
- from src.config import Config, get_config_dir
16
+ from src.config import Config, ensure_permissions_file, get_config_dir
17
17
 
18
18
 
19
19
  def _default_permissions_dir() -> Path:
@@ -180,24 +180,8 @@ class Permissions:
180
180
  permissions: dict[str, Any] = {}
181
181
  metadata: PermissionsMetadata | None = None
182
182
 
183
- if not file_path.exists():
184
- # Bootstrap missing permissions files on first run.
185
- # Prefer copying repo/package defaults (next to src/), else create minimal stub.
186
- file_path.parent.mkdir(parents=True, exist_ok=True)
187
-
188
- repo_candidate = Path(__file__).parent.parent / file_path.name
189
- if repo_candidate.exists():
190
- file_path.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
191
- log.info(f"Bootstrapped permissions file from defaults: {file_path}")
192
- if not file_path.exists():
193
- # Create minimal empty structure
194
- try:
195
- file_path.write_text(json.dumps({"_metadata": {}}), encoding="utf-8")
196
- log.info(f"Created empty permissions file: {file_path}")
197
- except Exception as e:
198
- raise PermissionsError(
199
- f"Unable to create permissions file at {file_path}: {e}", file_path
200
- ) from e
183
+ # Centralized bootstrap: ensure file exists or create from repo defaults
184
+ file_path = ensure_permissions_file(file_path)
201
185
 
202
186
  with open(file_path) as f:
203
187
  data: dict[str, Any] = json.load(f)
@@ -31,7 +31,13 @@ from loguru import logger as log
31
31
  from pydantic import BaseModel, Field
32
32
 
33
33
  from src import events
34
- from src.config import Config, MCPServerConfig, clear_json_file_cache, get_config_json_path
34
+ from src.config import (
35
+ Config,
36
+ MCPServerConfig,
37
+ clear_json_file_cache,
38
+ get_config_json_path,
39
+ resolve_json_path_with_bootstrap,
40
+ )
35
41
  from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
36
42
  from src.mcp_stdio_capture import (
37
43
  install_stdio_client_stderr_capture as _install_stdio_capture,
@@ -209,26 +215,7 @@ class OpenEdisonProxy:
209
215
  2) Repository/package defaults next to src/ — and bootstrap a copy into the config dir if missing
210
216
  3) Config dir target path (even if not yet created) as last resort
211
217
  """
212
- # 1) Config directory (preferred)
213
- try:
214
- base = _get_cfg_dir()
215
- except Exception:
216
- base = Path.cwd()
217
- target = base / filename
218
- if target.exists():
219
- return target
220
-
221
- # 2) Repository/package defaults next to src/
222
- repo_candidate = Path(__file__).parent.parent / filename
223
- if repo_candidate.exists():
224
- # Bootstrap a copy into config dir when possible (best effort)
225
- with suppress(Exception):
226
- target.parent.mkdir(parents=True, exist_ok=True)
227
- target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
228
- return target if target.exists() else repo_candidate
229
-
230
- # 3) Fall back to config dir path (will be created on save)
231
- return target
218
+ return resolve_json_path_with_bootstrap(filename)
232
219
 
233
220
  async def _serve_json(filename: str) -> Response: # type: ignore[override]
234
221
  if filename not in allowed_json_files:
@@ -39,7 +39,9 @@ def show_welcome_screen(*, dry_run: bool = False) -> None:
39
39
  print(welcome_text)
40
40
 
41
41
  # Prompt to continue
42
- questionary.confirm("Ready to begin the setup process?", default=True).ask()
42
+ if not questionary.confirm("Ready to begin the setup process?", default=True).ask():
43
+ print("Setup aborted. ")
44
+ sys.exit(1)
43
45
 
44
46
 
45
47
  def handle_mcp_source( # noqa: C901
@@ -130,6 +132,29 @@ def confirm_configs(configs: list[MCPServerConfig], *, dry_run: bool = False) ->
130
132
  ).ask()
131
133
 
132
134
 
135
+ def select_imported_configs(
136
+ configs: list[MCPServerConfig], *, dry_run: bool = False
137
+ ) -> list[MCPServerConfig]:
138
+ """Present a checkbox list of imported servers and return the selected subset.
139
+
140
+ - Arrow keys to navigate; Space to toggle; Enter to confirm.
141
+ - Defaults to all selected.
142
+ """
143
+ if not configs:
144
+ return []
145
+
146
+ choices = [
147
+ questionary.Choice(title=config.name, value=config, checked=True) for config in configs
148
+ ]
149
+
150
+ selected = questionary.checkbox(
151
+ "Select the MCP servers to import (Space to toggle, Enter to confirm):",
152
+ choices=choices,
153
+ ).ask()
154
+
155
+ return list(selected or [])
156
+
157
+
133
158
  def confirm_apply_configs(client: CLIENT, *, dry_run: bool = False) -> None:
134
159
  if not questionary.confirm(
135
160
  f"Would you like to set up Open Edison for {client.name}? (This will modify your MCP configuration. We will make a back up of your current one if you would like to revert.)",
@@ -246,15 +271,24 @@ def run(*, dry_run: bool = False, skip_oauth: bool = False) -> bool: # noqa: C9
246
271
  # Deduplicate configs
247
272
  configs = deduplicate_by_name(configs)
248
273
 
249
- if not confirm_configs(configs, dry_run=dry_run):
250
- return False
274
+ # Let the user select which servers to import
275
+ selected_configs = select_imported_configs(configs, dry_run=dry_run)
276
+
277
+ if len(selected_configs) == 0:
278
+ if not questionary.confirm(
279
+ "No MCP servers selected. Continue without importing any?", default=False
280
+ ).ask():
281
+ return False
282
+ else:
283
+ if not confirm_configs(selected_configs, dry_run=dry_run):
284
+ return False
251
285
 
252
286
  for client in mcp_clients:
253
287
  confirm_apply_configs(client, dry_run=dry_run)
254
288
 
255
289
  # Persist imported servers into config.json
256
- if len(configs) > 0:
257
- save_imported_servers(configs, dry_run=dry_run)
290
+ if len(selected_configs) > 0:
291
+ save_imported_servers(selected_configs, dry_run=dry_run)
258
292
 
259
293
  show_manual_setup_screen()
260
294
 
@@ -140,6 +140,16 @@
140
140
  "acl": "PUBLIC"
141
141
  }
142
142
  },
143
+ "fetch": {
144
+ "fetch": {
145
+ "enabled": true,
146
+ "write_operation": false,
147
+ "read_private_data": false,
148
+ "read_untrusted_public_data": true,
149
+ "acl": "PUBLIC",
150
+ "description": "Fetch content from a URL (considered untrusted public data)"
151
+ }
152
+ },
143
153
  "sqlite": {
144
154
  "db_info": {
145
155
  "enabled": true,
File without changes