open-edison 0.1.10__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.
src/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Open Edison Source Package
3
+
4
+ Main source code package for the Open Edison single-user MCP proxy server.
5
+
6
+ This package exposes a CLI via `open-edison` / `open_edison` entrypoints.
7
+ """
8
+
9
+ from .server import OpenEdisonProxy
10
+
11
+ __all__ = ["OpenEdisonProxy"]
src/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ Python -m entrypoint for Open Edison.
3
+
4
+ Allows: python -m src to behave like `open-edison`.
5
+ """
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
src/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ CLI entrypoint for Open Edison.
3
+
4
+ Provides `open-edison` executable when installed via pip/uvx/pipx.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import os
12
+ import subprocess as _subprocess
13
+ from contextlib import suppress
14
+ from pathlib import Path
15
+ from typing import Any, NoReturn, cast
16
+ import sys
17
+
18
+ from loguru import logger as _log # type: ignore[reportMissingImports]
19
+
20
+ from .config import Config, get_config_dir
21
+ from .server import OpenEdisonProxy
22
+
23
+ log: Any = _log
24
+
25
+
26
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
27
+ parser: Any = argparse.ArgumentParser(
28
+ prog="open-edison",
29
+ description="Open Edison - Single-user MCP proxy server",
30
+ )
31
+
32
+ # Top-level options for default run mode
33
+ parser.add_argument(
34
+ "--config-dir",
35
+ type=Path,
36
+ help="Directory containing config.json and related files. If omitted, uses OPEN_EDISON_CONFIG_DIR or package root.",
37
+ )
38
+ parser.add_argument("--host", type=str, help="Server host override")
39
+ parser.add_argument(
40
+ "--port", type=int, help="Server port override (FastMCP on port, FastAPI on port+1)"
41
+ )
42
+ # Website runs from packaged assets by default; no extra website flags
43
+
44
+ # Subcommands (extensible)
45
+ subparsers = parser.add_subparsers(dest="command", required=False)
46
+
47
+ # import-mcp: import MCP servers from other tools into config.json
48
+ sp_import = subparsers.add_parser(
49
+ "import-mcp",
50
+ help="Import MCP servers from other tools (Cursor, Windsurf, Cline, Claude Desktop, etc.)",
51
+ description=(
52
+ "Import MCP server configurations from other tools into Open Edison config.json.\n"
53
+ "Use --source to choose the tool and optional flags to control merging."
54
+ ),
55
+ )
56
+ sp_import.add_argument(
57
+ "--source",
58
+ choices=[
59
+ "cursor",
60
+ "windsurf",
61
+ "cline",
62
+ "claude-desktop",
63
+ "vscode",
64
+ "claude-code",
65
+ "gemini-cli",
66
+ "codex",
67
+ "interactive",
68
+ ],
69
+ default="interactive",
70
+ help="Source application to import from",
71
+ )
72
+ sp_import.add_argument(
73
+ "--project-dir",
74
+ type=Path,
75
+ help="When --source=cursor, path to the project containing .cursor/mcp.json",
76
+ )
77
+ sp_import.add_argument(
78
+ "--config-dir",
79
+ type=Path,
80
+ help=(
81
+ "Directory containing target config.json (default: OPEN_EDISON_CONFIG_DIR or repo root)."
82
+ ),
83
+ )
84
+ sp_import.add_argument(
85
+ "--merge",
86
+ choices=["skip", "overwrite", "rename"],
87
+ default="skip",
88
+ help="Merge policy for duplicate server names",
89
+ )
90
+ sp_import.add_argument(
91
+ "--enable-imported",
92
+ action="store_true",
93
+ help="Enable imported servers (default: disabled)",
94
+ )
95
+ sp_import.add_argument(
96
+ "--dry-run",
97
+ action="store_true",
98
+ help="Show changes without writing to config.json",
99
+ )
100
+
101
+ return parser.parse_args(argv)
102
+
103
+
104
+ def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
105
+ port: int,
106
+ override_dir: Path | None = None,
107
+ config_dir: Path | None = None,
108
+ ) -> tuple[int, _subprocess.Popen[bytes] | None]:
109
+ """Try to start the frontend dev server by running `npm run dev`.
110
+
111
+ Search order for working directory:
112
+ 1) Packaged project path: <pkg_root>/frontend
113
+ 2) Current working directory (if it contains a package.json)
114
+ """
115
+ candidates: list[Path] = []
116
+ # Prefer packaged static assets; if present, the backend serves /dashboard
117
+ static_candidates = [
118
+ Path(__file__).parent / "frontend_dist", # inside package dir
119
+ Path(__file__).parent.parent / "frontend_dist", # site-packages root
120
+ ]
121
+ static_dir = next((p for p in static_candidates if p.exists() and p.is_dir()), None)
122
+ if static_dir is not None:
123
+ log.info(
124
+ f"Packaged dashboard detected at {static_dir}. It will be served at /dashboard by the API server."
125
+ )
126
+ # No separate website process needed. Return sentinel port (-1) so caller knows not to warn.
127
+ return (-1, None)
128
+ pkg_frontend_candidates = [
129
+ Path(__file__).parent / "frontend", # inside package dir
130
+ Path(__file__).parent.parent / "frontend", # site-packages root
131
+ ]
132
+ if override_dir is not None:
133
+ candidates.append(override_dir)
134
+ for pf in pkg_frontend_candidates:
135
+ if pf.exists():
136
+ candidates.append(pf)
137
+ if config_dir is not None and (config_dir / "package.json").exists():
138
+ candidates.append(config_dir)
139
+ cwd_pkg = Path.cwd()
140
+ if (cwd_pkg / "package.json").exists():
141
+ candidates.append(cwd_pkg)
142
+
143
+ if not candidates:
144
+ log.warning(
145
+ "No frontend directory found (no packaged frontend and no package.json in CWD). Skipping website."
146
+ )
147
+ return (port, None)
148
+
149
+ for candidate in candidates:
150
+ try:
151
+ # If no package.json but directory exists, try a basic npm i per user request
152
+ if not (candidate / "package.json").exists():
153
+ log.info(f"No package.json in {candidate}. Running 'npm i' as best effort...")
154
+ _ = _subprocess.call(["npm", "i"], cwd=str(candidate))
155
+
156
+ # Install deps if needed
157
+ if (
158
+ not (candidate / "node_modules").exists()
159
+ and (candidate / "package-lock.json").exists()
160
+ ):
161
+ log.info(f"Installing frontend dependencies with npm ci in {candidate}...")
162
+ r_install = _subprocess.call(["npm", "ci"], cwd=str(candidate))
163
+ if r_install != 0:
164
+ log.error("Failed to install frontend dependencies")
165
+ continue
166
+
167
+ log.info(f"Starting frontend dev server in {candidate} on port {port}...")
168
+ cmd_default = ["npm", "run", "dev", "--", "--port", str(port)]
169
+ proc = _subprocess.Popen(cmd_default, cwd=str(candidate))
170
+ return (port, proc)
171
+ except FileNotFoundError:
172
+ log.error("npm not found. Please install Node.js to run the website dev server.")
173
+ return (port, None)
174
+
175
+ # If all candidates failed
176
+ return (port, None)
177
+
178
+
179
+ async def _run_server(args: Any) -> None:
180
+ # Resolve config dir and expose via env for the rest of the app
181
+ config_dir_arg = getattr(args, "config_dir", None)
182
+ if config_dir_arg is not None:
183
+ os.environ["OPEN_EDISON_CONFIG_DIR"] = str(Path(config_dir_arg).expanduser().resolve())
184
+ config_dir = get_config_dir()
185
+
186
+ # Load config after setting env override
187
+ cfg = Config.load()
188
+
189
+ host = getattr(args, "host", None) or cfg.server.host
190
+ port = getattr(args, "port", None) or cfg.server.port
191
+
192
+ log.info(f"Using config directory: {config_dir}")
193
+ proxy = OpenEdisonProxy(host=host, port=port)
194
+
195
+ # Website served from packaged assets by default; still detect and log
196
+ frontend_proc = None
197
+ used_port, frontend_proc = _spawn_frontend_dev(5173, None, config_dir)
198
+ if frontend_proc is None and used_port == -1:
199
+ log.info("Frontend is being served from packaged assets at /dashboard")
200
+
201
+ try:
202
+ await proxy.start()
203
+ _ = await asyncio.Event().wait()
204
+ except KeyboardInterrupt:
205
+ log.info("Received shutdown signal")
206
+ finally:
207
+ if frontend_proc is not None:
208
+ with suppress(Exception):
209
+ frontend_proc.terminate()
210
+ _ = frontend_proc.wait(timeout=5)
211
+ with suppress(Exception):
212
+ frontend_proc.kill()
213
+
214
+
215
+ def _run_website(port: int, website_dir: Path | None = None) -> int:
216
+ # Use the same spawning logic, then return 0 if started or 1 if failed
217
+ _, proc = _spawn_frontend_dev(port, website_dir)
218
+ return 0 if proc is not None else 1
219
+
220
+
221
+ def main(argv: list[str] | None = None) -> NoReturn:
222
+ args = _parse_args(argv)
223
+
224
+ if getattr(args, "command", None) == "website":
225
+ exit_code = _run_website(port=args.port, website_dir=getattr(args, "dir", None))
226
+ raise SystemExit(exit_code)
227
+
228
+ if getattr(args, "command", None) == "import-mcp":
229
+ # Defer-import importer package (lives under repository scripts/)
230
+ importer_pkg = Path(__file__).parent.parent / "scripts" / "mcp_importer"
231
+ try:
232
+ if str(importer_pkg) not in sys.path:
233
+ sys.path.insert(0, str(importer_pkg))
234
+ from mcp_importer.cli import run_cli # type: ignore
235
+ except Exception as imp_exc: # noqa: BLE001
236
+ log.error(
237
+ "Failed to load MCP importer package from {}: {}",
238
+ importer_pkg,
239
+ imp_exc,
240
+ )
241
+ raise SystemExit(1) from imp_exc
242
+
243
+ importer_argv: list[str] = []
244
+ if args.source:
245
+ importer_argv += ["--source", str(args.source)]
246
+ if getattr(args, "project_dir", None):
247
+ importer_argv += [
248
+ "--project-dir",
249
+ str(Path(args.project_dir).expanduser().resolve()),
250
+ ]
251
+ if getattr(args, "config_dir", None):
252
+ importer_argv += [
253
+ "--config-dir",
254
+ str(Path(args.config_dir).expanduser().resolve()),
255
+ ]
256
+ if args.merge:
257
+ importer_argv += ["--merge", str(args.merge)]
258
+ if bool(getattr(args, "enable_imported", False)):
259
+ importer_argv += ["--enable-imported"]
260
+ if bool(getattr(args, "dry_run", False)):
261
+ importer_argv += ["--dry-run"]
262
+
263
+ rc_val: int = int(cast(Any, run_cli)(importer_argv))
264
+ raise SystemExit(rc_val)
265
+
266
+ # default: run server (top-level flags)
267
+ try:
268
+ asyncio.run(_run_server(args))
269
+ raise SystemExit(0)
270
+ except KeyboardInterrupt:
271
+ raise SystemExit(0) from None
272
+ except Exception as exc: # noqa: BLE001
273
+ log.error(f"Fatal error: {exc}")
274
+ raise SystemExit(1) from exc
src/config.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ Configuration management for Open Edison
3
+
4
+ Simple JSON-based configuration for single-user MCP proxy.
5
+ No database, no multi-user support - just local file-based config.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import tomllib
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from loguru import logger as log
17
+
18
+ # Get the path to the repository/package root directory (module src/ parent)
19
+ root_dir = Path(__file__).parent.parent
20
+
21
+
22
+ def get_config_dir() -> Path:
23
+ """Resolve configuration directory for runtime.
24
+
25
+ Order of precedence:
26
+ 1) Environment variable OPEN_EDISON_CONFIG_DIR (if set)
27
+ 2) OS-appropriate user config directory under app name
28
+ - macOS: ~/Library/Application Support/Open Edison
29
+ - Windows: %APPDATA%/Open Edison
30
+ - Linux/Unix: $XDG_CONFIG_HOME/open-edison or ~/.config/open-edison
31
+ """
32
+ env_dir = os.environ.get("OPEN_EDISON_CONFIG_DIR")
33
+ if env_dir:
34
+ try:
35
+ return Path(env_dir).expanduser().resolve()
36
+ except Exception:
37
+ # Fall through to defaults
38
+ pass
39
+
40
+ # Platform-specific defaults
41
+ try:
42
+ if sys.platform == "darwin":
43
+ base = Path.home() / "Library" / "Application Support"
44
+ return (base / "Open Edison").resolve()
45
+ if os.name == "nt": # Windows
46
+ appdata = os.environ.get("APPDATA")
47
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
48
+ return (base / "Open Edison").resolve()
49
+ # POSIX / Linux
50
+ xdg = os.environ.get("XDG_CONFIG_HOME")
51
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
52
+ return (base / "open-edison").resolve()
53
+ except Exception:
54
+ # Ultimate fallback: user home
55
+ return (Path.home() / ".open-edison").resolve()
56
+
57
+
58
+ # Back-compat private alias (internal modules may import this)
59
+ def _get_config_dir() -> Path: # noqa: D401
60
+ """Alias to public get_config_dir (maintained for internal imports)."""
61
+ return get_config_dir()
62
+
63
+
64
+ def _default_config_path() -> Path:
65
+ """Determine default config.json path.
66
+
67
+ In development (editable or source checkout), prefer repository root
68
+ `config.json` when present. In an installed package (site-packages),
69
+ use the resolved user config dir.
70
+ """
71
+ repo_pyproject = root_dir / "pyproject.toml"
72
+ repo_config = root_dir / "config.json"
73
+
74
+ # If pyproject.toml exists next to src/, we are likely in a repo checkout
75
+ if repo_pyproject.exists():
76
+ return repo_config
77
+
78
+ # Otherwise, prefer user config directory
79
+ return get_config_dir() / "config.json"
80
+
81
+
82
+ class ConfigError(Exception):
83
+ """Exception raised for configuration-related errors"""
84
+
85
+ def __init__(self, message: str, config_path: Path | None = None):
86
+ self.message = message
87
+ self.config_path = config_path
88
+ super().__init__(self.message)
89
+
90
+
91
+ @dataclass
92
+ class ServerConfig:
93
+ """Server configuration"""
94
+
95
+ host: str = "localhost"
96
+ port: int = 3000
97
+ api_key: str = "dev-api-key-change-me"
98
+
99
+
100
+ @dataclass
101
+ class LoggingConfig:
102
+ """Logging configuration"""
103
+
104
+ level: str = "INFO"
105
+ database_path: str = "sessions.db"
106
+
107
+
108
+ @dataclass
109
+ class MCPServerConfig:
110
+ """Individual MCP server configuration"""
111
+
112
+ name: str
113
+ command: str
114
+ args: list[str]
115
+ env: dict[str, str] | None = None
116
+ enabled: bool = True
117
+ roots: list[str] | None = None
118
+
119
+ def __post_init__(self):
120
+ if self.env is None:
121
+ self.env = {}
122
+
123
+
124
+ @dataclass
125
+ class Config:
126
+ """Main configuration class"""
127
+
128
+ server: ServerConfig
129
+ logging: LoggingConfig
130
+ mcp_servers: list[MCPServerConfig]
131
+
132
+ @property
133
+ def version(self) -> str:
134
+ """Get version from pyproject.toml"""
135
+ try:
136
+ pyproject_path = root_dir / "pyproject.toml"
137
+ if pyproject_path.exists():
138
+ with open(pyproject_path, "rb") as f:
139
+ pyproject_data = tomllib.load(f)
140
+ project_data = pyproject_data.get("project", {}) # type: ignore
141
+ version = project_data.get("version", "unknown") # type: ignore
142
+ return str(version) # type: ignore
143
+ return "unknown"
144
+ except Exception as e:
145
+ log.warning(f"Failed to read version from pyproject.toml: {e}")
146
+ return "unknown"
147
+
148
+ @classmethod
149
+ def load(cls, config_path: Path | None = None) -> "Config":
150
+ """Load configuration from JSON file.
151
+
152
+ If a directory path is provided, will look for `config.json` inside it.
153
+ If no path is provided, uses OPEN_EDISON_CONFIG_DIR or project root.
154
+ """
155
+ if config_path is None:
156
+ config_path = _default_config_path()
157
+ else:
158
+ # If a directory was passed, use config.json inside it
159
+ if config_path.is_dir():
160
+ config_path = config_path / "config.json"
161
+
162
+ if not config_path.exists():
163
+ log.warning(f"Config file not found at {config_path}, creating default config")
164
+ default_config = cls.create_default()
165
+ default_config.save(config_path)
166
+ return default_config
167
+
168
+ with open(config_path) as f:
169
+ data: dict[str, Any] = json.load(f)
170
+
171
+ mcp_servers_data = data.get("mcp_servers", []) # type: ignore
172
+ server_data = data.get("server", {}) # type: ignore
173
+ logging_data = data.get("logging", {}) # type: ignore
174
+
175
+ return cls(
176
+ server=ServerConfig(**server_data), # type: ignore
177
+ logging=LoggingConfig(**logging_data), # type: ignore
178
+ mcp_servers=[
179
+ MCPServerConfig(**server_item) # type: ignore
180
+ for server_item in mcp_servers_data # type: ignore
181
+ ],
182
+ )
183
+
184
+ def save(self, config_path: Path | None = None) -> None:
185
+ """Save configuration to JSON file"""
186
+ if config_path is None:
187
+ config_path = _default_config_path()
188
+ else:
189
+ # If a directory was passed, save to config.json inside it
190
+ if config_path.is_dir():
191
+ config_path = config_path / "config.json"
192
+
193
+ data = {
194
+ "server": asdict(self.server),
195
+ "logging": asdict(self.logging),
196
+ "mcp_servers": [asdict(server) for server in self.mcp_servers],
197
+ }
198
+
199
+ # Ensure directory exists
200
+ config_path.parent.mkdir(parents=True, exist_ok=True)
201
+ with open(config_path, "w") as f:
202
+ json.dump(data, f, indent=2)
203
+
204
+ log.info(f"Configuration saved to {config_path}")
205
+
206
+ @classmethod
207
+ def create_default(cls) -> "Config":
208
+ """Create default configuration"""
209
+ return cls(
210
+ server=ServerConfig(),
211
+ logging=LoggingConfig(),
212
+ mcp_servers=[
213
+ MCPServerConfig(
214
+ name="filesystem",
215
+ command="uvx",
216
+ args=["mcp-server-filesystem", "/tmp"],
217
+ enabled=False,
218
+ )
219
+ ],
220
+ )
221
+
222
+
223
+ # Load global configuration
224
+ config = Config.load()