open-edison 0.1.44__py3-none-any.whl → 0.1.64__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.44.dist-info → open_edison-0.1.64.dist-info}/METADATA +2 -21
- open_edison-0.1.64.dist-info/RECORD +40 -0
- src/cli.py +25 -109
- src/config.py +26 -9
- src/mcp_importer/__main__.py +0 -2
- src/mcp_importer/api.py +254 -44
- src/mcp_importer/export_cli.py +2 -2
- src/mcp_importer/import_api.py +0 -2
- src/mcp_importer/parsers.py +47 -9
- src/mcp_importer/quick_cli.py +0 -2
- src/mcp_importer/types.py +0 -2
- src/oauth_manager.py +3 -1
- src/oauth_override.py +10 -0
- src/permissions.py +24 -1
- src/server.py +30 -10
- src/setup_tui/main.py +155 -18
- src/single_user_mcp.py +7 -3
- src/tools/io.py +35 -0
- src/vulture_whitelist.py +3 -0
- open_edison-0.1.44.dist-info/RECORD +0 -37
- {open_edison-0.1.44.dist-info → open_edison-0.1.64.dist-info}/WHEEL +0 -0
- {open_edison-0.1.44.dist-info → open_edison-0.1.64.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.44.dist-info → open_edison-0.1.64.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: open-edison
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.64
|
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
|
@@ -72,13 +72,7 @@ curl -fsSL https://raw.githubusercontent.com/Edison-Watch/open-edison/main/curl_
|
|
72
72
|
```
|
73
73
|
|
74
74
|
Run locally with uvx: `uvx open-edison`
|
75
|
-
|
76
|
-
Optionally, run the setup wizard to import/configure MCP:
|
77
|
-
|
78
|
-
```bash
|
79
|
-
uv run python -m src.setup_tui.main
|
80
|
-
# add --dry-run to preview without writing
|
81
|
-
```
|
75
|
+
That will run the setup wizard if necessary.
|
82
76
|
|
83
77
|
<details>
|
84
78
|
<summary>⬇️ Install Node.js/npm (optional for MCP tools)</summary>
|
@@ -129,19 +123,6 @@ OPEN_EDISON_CONFIG_DIR=~/edison-config open-edison run
|
|
129
123
|
|
130
124
|
</details>
|
131
125
|
|
132
|
-
<details>
|
133
|
-
<summary>🔄 Import from Cursor/VS Code/Claude Code</summary>
|
134
|
-
|
135
|
-
Run the interactive setup wizard to detect clients, import servers, and configure your editor:
|
136
|
-
|
137
|
-
```bash
|
138
|
-
uv run python -m src.setup_tui.main
|
139
|
-
```
|
140
|
-
|
141
|
-
Use `--dry-run` to preview without writing.
|
142
|
-
|
143
|
-
</details>
|
144
|
-
|
145
126
|
<details>
|
146
127
|
<summary><img src="https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"> Run with Docker</summary>
|
147
128
|
|
@@ -0,0 +1,40 @@
|
|
1
|
+
src/__init__.py,sha256=bEYMwBiuW9jzF07iWhas4Vb30EcpnqfpNfz_Q6yO1jU,209
|
2
|
+
src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
|
3
|
+
src/cli.py,sha256=PH2qPLma0PO1L75OSK06IdPy8RB5gTBp2R1HkacCQ0Q,4736
|
4
|
+
src/config.py,sha256=oO89omLCLoPfEGH6j4WSHQgZbhJZnYou-qdxY54VfDo,11037
|
5
|
+
src/config.pyi,sha256=FgehEGli8ZXSjGlANBgMGv5497q4XskQciOc1fUcxqM,2033
|
6
|
+
src/events.py,sha256=aFQrVXDIZwt55Dz6OtyoXu2yi9evqo-8jZzo3CR2Tto,4965
|
7
|
+
src/oauth_manager.py,sha256=MJ1gHVKiu-pMbskSCxRlZ6xP4wJOr-ELydOdgdUBKKw,9969
|
8
|
+
src/oauth_override.py,sha256=C7QS8sPA6JqJDiNZA0FGeXcB7jU-yYu-k8V56QVpsqU,393
|
9
|
+
src/permissions.py,sha256=dERB8s40gDInsbXtu0pJYDDuZ3_kD8rxXyYYTfrG3qs,11621
|
10
|
+
src/server.py,sha256=WseZks-r07tq7UGX0XFc0OUEzUrB9MGfDiHeYHa3NEE,46593
|
11
|
+
src/single_user_mcp.py,sha256=2xQgrxqulTD_1HySoMNZD5AoADJS5pz41Gn-tEFgBHI,18760
|
12
|
+
src/telemetry.py,sha256=-RZPIjpI53zbsKmp-63REeZ1JirWHV5WvpSRa2nqZEk,11321
|
13
|
+
src/vulture_whitelist.py,sha256=CjBOSsarbzbQt_9ATWc8MbruBsYX3hJVa_ysbRD9ZYM,135
|
14
|
+
src/frontend_dist/index.html,sha256=s95FMkH8VLisvawLH7bZxbLzRUFvMhHkH6ZMzpVBngs,673
|
15
|
+
src/frontend_dist/sw.js,sha256=rihX1es-vWwjmtnXyaksJjs2dio6MVAOTAWwQPeJUYw,2164
|
16
|
+
src/frontend_dist/assets/index-BUUcUfTt.js,sha256=awoyPI6u0v6ao2iarZdSkrSDUvyU8aNkMLqHMvgVgyY,257666
|
17
|
+
src/frontend_dist/assets/index-o6_8mdM8.css,sha256=nwmX_6q55mB9463XN2JM8BdeihjkALpQK83Fc3_iGvE,15936
|
18
|
+
src/mcp_importer/__init__.py,sha256=Mk59pVr7OMGfYGWeSYk8-URfhIcrs3SPLYS7fmJbMII,275
|
19
|
+
src/mcp_importer/__main__.py,sha256=mFcxXFqJMC0SFEqIP-9WVEqLJSYqShC0x1Ht7PQZPm8,479
|
20
|
+
src/mcp_importer/api.py,sha256=N5oVaTj3OMIROLx__UOSr60VMqXXX20JsOHmeHIGP48,17431
|
21
|
+
src/mcp_importer/cli.py,sha256=Pe0GLWm1nMd1VuNXOSkxIrFZuGNFc9dNvfBsvf-bdBI,3487
|
22
|
+
src/mcp_importer/export_cli.py,sha256=Fw0jDQCI8gGW4BDrJLzWjLUtV4q6v0h2QZ7HF1V2Jcg,6279
|
23
|
+
src/mcp_importer/exporters.py,sha256=fSgl6seduoXFp7YnKH26UEaC1sFBnd4whSut7CJLBQs,11348
|
24
|
+
src/mcp_importer/import_api.py,sha256=wD5yqxWwFfn1MQNKE79rEeyZODdmPgUDhsRYdCJYh4Q,59
|
25
|
+
src/mcp_importer/importers.py,sha256=zGN8lT7qQJ95jDTd-ck09j_w5PSvH-uj33TILoHfHbs,2191
|
26
|
+
src/mcp_importer/merge.py,sha256=KIGT7UgbAm07-LdyoUXEJ7ABSIiPTFlj_qjz669yFxg,1569
|
27
|
+
src/mcp_importer/parsers.py,sha256=MDhzODsvX5t1U_CI8byBxCpx6rA4WkpqX4bJMiNE74s,6298
|
28
|
+
src/mcp_importer/paths.py,sha256=4L-cPr7KCM9X9gAUP7Da6ictLNrPWuQ_IM419zqY-2I,2700
|
29
|
+
src/mcp_importer/quick_cli.py,sha256=Vv2vjNzpSOaic0YHFbPAuX0nZByawS2kDw6KiCtEX3A,1798
|
30
|
+
src/mcp_importer/types.py,sha256=nSaOLGqpCmA3R14QCO6wrpgX75VaLz9HfslUWzw_GPQ,102
|
31
|
+
src/middleware/data_access_tracker.py,sha256=bArBffWgYmvxOx9z_pgXQhogvnWQcc1m6WvEblDD4gw,15039
|
32
|
+
src/middleware/session_tracking.py,sha256=5W1VH9HNqIZeX0HNxDEm41U4GY6SqKSXtApDEeZK2qo,23084
|
33
|
+
src/setup_tui/__init__.py,sha256=mDFrQoiOtQOHc0sFfGKrNXVLEDeB1S0O5aISBVzfxYo,184
|
34
|
+
src/setup_tui/main.py,sha256=BM6t8YzWOSn2hDzVhp_nOol93NoHf6fiP4uFLOpx-BQ,11082
|
35
|
+
src/tools/io.py,sha256=hhc4pv3eUzYWSZ7BbThclxSMwWBQaGMoGsItIPf_pco,1047
|
36
|
+
open_edison-0.1.64.dist-info/METADATA,sha256=1X57SDfaooRExNvcCN-vHr1HH1QzbrVLWhJaNIaIEDI,11993
|
37
|
+
open_edison-0.1.64.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
38
|
+
open_edison-0.1.64.dist-info/entry_points.txt,sha256=YiGNm9x2I00hgT10HDyB4gxC1LcaV_mu8bXFjolu0Yw,171
|
39
|
+
open_edison-0.1.64.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
40
|
+
open_edison-0.1.64.dist-info/RECORD,,
|
src/cli.py
CHANGED
@@ -7,8 +7,6 @@ Provides `open-edison` executable when installed via pip/uvx/pipx.
|
|
7
7
|
import argparse
|
8
8
|
import asyncio
|
9
9
|
import os
|
10
|
-
import subprocess as _subprocess
|
11
|
-
from contextlib import suppress
|
12
10
|
from pathlib import Path
|
13
11
|
from typing import Any, NoReturn
|
14
12
|
|
@@ -17,6 +15,7 @@ from loguru import logger as _log # type: ignore[reportMissingImports]
|
|
17
15
|
from src.config import Config, get_config_dir, get_config_json_path
|
18
16
|
from src.mcp_importer.cli import run_cli
|
19
17
|
from src.server import OpenEdisonProxy
|
18
|
+
from src.setup_tui.main import run_import_tui
|
20
19
|
|
21
20
|
log: Any = _log
|
22
21
|
|
@@ -37,6 +36,22 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
37
36
|
parser.add_argument(
|
38
37
|
"--port", type=int, help="Server port override (FastMCP on port, FastAPI on port+1)"
|
39
38
|
)
|
39
|
+
# For the setup wizard
|
40
|
+
parser.add_argument(
|
41
|
+
"--wizard-dry-run",
|
42
|
+
action="store_true",
|
43
|
+
help="(For the setup wizard) Show changes without writing to config.json",
|
44
|
+
)
|
45
|
+
parser.add_argument(
|
46
|
+
"--wizard-skip-oauth",
|
47
|
+
action="store_true",
|
48
|
+
help="(For the setup wizard) Skip OAuth for remote servers (they will be omitted from import)",
|
49
|
+
)
|
50
|
+
parser.add_argument(
|
51
|
+
"--wizard-force",
|
52
|
+
action="store_true",
|
53
|
+
help="(For the setup wizard) Force running the setup wizard even if it has already been run",
|
54
|
+
)
|
40
55
|
# Website runs from packaged assets by default; no extra website flags
|
41
56
|
|
42
57
|
# Subcommands (extensible)
|
@@ -88,89 +103,7 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
88
103
|
return parser.parse_args(argv)
|
89
104
|
|
90
105
|
|
91
|
-
def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
|
92
|
-
port: int,
|
93
|
-
override_dir: Path | None = None,
|
94
|
-
config_dir: Path | None = None,
|
95
|
-
) -> tuple[int, _subprocess.Popen[bytes] | None]:
|
96
|
-
"""Try to start the frontend dev server by running `npm run dev`.
|
97
|
-
|
98
|
-
Search order for working directory:
|
99
|
-
1) Packaged project path: <pkg_root>/frontend
|
100
|
-
2) Current working directory (if it contains a package.json)
|
101
|
-
"""
|
102
|
-
candidates: list[Path] = []
|
103
|
-
# Prefer packaged static assets; if present, the backend serves /dashboard
|
104
|
-
static_candidates = [
|
105
|
-
Path(__file__).parent / "frontend_dist", # inside package dir
|
106
|
-
Path(__file__).parent.parent / "frontend_dist", # site-packages root
|
107
|
-
]
|
108
|
-
static_dir = next((p for p in static_candidates if p.exists() and p.is_dir()), None)
|
109
|
-
if static_dir is not None:
|
110
|
-
log.info(
|
111
|
-
f"Packaged dashboard detected at {static_dir}. It will be served at /dashboard by the API server."
|
112
|
-
)
|
113
|
-
# No separate website process needed. Return sentinel port (-1) so caller knows not to warn.
|
114
|
-
return (-1, None)
|
115
|
-
|
116
|
-
if static_dir is None:
|
117
|
-
raise RuntimeError(
|
118
|
-
"No packaged dashboard detected. The website will be served from the frontend directory."
|
119
|
-
)
|
120
|
-
|
121
|
-
pkg_frontend_candidates = [
|
122
|
-
Path(__file__).parent / "frontend", # inside package dir
|
123
|
-
Path(__file__).parent.parent / "frontend", # site-packages root
|
124
|
-
]
|
125
|
-
if override_dir is not None:
|
126
|
-
candidates.append(override_dir)
|
127
|
-
for pf in pkg_frontend_candidates:
|
128
|
-
if pf.exists():
|
129
|
-
candidates.append(pf)
|
130
|
-
if config_dir is not None and (config_dir / "package.json").exists():
|
131
|
-
candidates.append(config_dir)
|
132
|
-
cwd_pkg = Path.cwd()
|
133
|
-
if (cwd_pkg / "package.json").exists():
|
134
|
-
candidates.append(cwd_pkg)
|
135
|
-
|
136
|
-
if not candidates:
|
137
|
-
log.warning(
|
138
|
-
"No frontend directory found (no packaged frontend and no package.json in CWD). Skipping website."
|
139
|
-
)
|
140
|
-
return (port, None)
|
141
|
-
|
142
|
-
for candidate in candidates:
|
143
|
-
try:
|
144
|
-
# If no package.json but directory exists, try a basic npm i per user request
|
145
|
-
if not (candidate / "package.json").exists():
|
146
|
-
log.info(f"No package.json in {candidate}. Running 'npm i' as best effort...")
|
147
|
-
_ = _subprocess.call(["npm", "i"], cwd=str(candidate))
|
148
|
-
|
149
|
-
# Install deps if needed
|
150
|
-
if (
|
151
|
-
not (candidate / "node_modules").exists()
|
152
|
-
and (candidate / "package-lock.json").exists()
|
153
|
-
):
|
154
|
-
log.info(f"Installing frontend dependencies with npm ci in {candidate}...")
|
155
|
-
r_install = _subprocess.call(["npm", "ci"], cwd=str(candidate))
|
156
|
-
if r_install != 0:
|
157
|
-
log.error("Failed to install frontend dependencies")
|
158
|
-
continue
|
159
|
-
|
160
|
-
log.info(f"Starting frontend dev server in {candidate} on port {port}...")
|
161
|
-
cmd_default = ["npm", "run", "dev", "--", "--port", str(port)]
|
162
|
-
proc = _subprocess.Popen(cmd_default, cwd=str(candidate))
|
163
|
-
return (port, proc)
|
164
|
-
except FileNotFoundError:
|
165
|
-
log.error("npm not found. Please install Node.js to run the website dev server.")
|
166
|
-
return (port, None)
|
167
|
-
|
168
|
-
# If all candidates failed
|
169
|
-
return (port, None)
|
170
|
-
|
171
|
-
|
172
106
|
async def _run_server(args: Any) -> None:
|
173
|
-
# TODO check this works as we want it to
|
174
107
|
# Resolve config dir and expose via env for the rest of the app
|
175
108
|
config_dir_arg = getattr(args, "config_dir", None)
|
176
109
|
if config_dir_arg is not None:
|
@@ -186,44 +119,27 @@ async def _run_server(args: Any) -> None:
|
|
186
119
|
log.info(f"Using config directory: {config_dir}")
|
187
120
|
proxy = OpenEdisonProxy(host=host, port=port)
|
188
121
|
|
189
|
-
# Website served from packaged assets by default; still detect and log
|
190
|
-
frontend_proc = None
|
191
|
-
used_port, frontend_proc = _spawn_frontend_dev(5173, None, config_dir)
|
192
|
-
if frontend_proc is None and used_port == -1:
|
193
|
-
log.info("Frontend is being served from packaged assets at /dashboard")
|
194
|
-
|
195
122
|
try:
|
196
123
|
await proxy.start()
|
197
|
-
_ = await asyncio.Event().wait()
|
198
124
|
except KeyboardInterrupt:
|
199
125
|
log.info("Received shutdown signal")
|
200
|
-
finally:
|
201
|
-
if frontend_proc is not None:
|
202
|
-
with suppress(Exception):
|
203
|
-
frontend_proc.terminate()
|
204
|
-
_ = frontend_proc.wait(timeout=5)
|
205
|
-
with suppress(Exception):
|
206
|
-
frontend_proc.kill()
|
207
|
-
|
208
|
-
|
209
|
-
def _run_website(port: int, website_dir: Path | None = None) -> int:
|
210
|
-
# Use the same spawning logic, then return 0 if started or 1 if failed
|
211
|
-
_, proc = _spawn_frontend_dev(port, website_dir)
|
212
|
-
return 0 if proc is not None else 1
|
213
126
|
|
214
127
|
|
215
128
|
def main(argv: list[str] | None = None) -> NoReturn: # noqa: C901
|
216
129
|
args = _parse_args(argv)
|
217
130
|
|
218
|
-
if
|
219
|
-
|
220
|
-
raise SystemExit(exit_code)
|
131
|
+
if args.command is None:
|
132
|
+
args.command = "run"
|
221
133
|
|
222
|
-
if
|
134
|
+
if args.command == "import-mcp":
|
223
135
|
result_code = run_cli(argv)
|
224
136
|
raise SystemExit(result_code)
|
225
137
|
|
226
|
-
#
|
138
|
+
# Run import tui if necessary
|
139
|
+
tui_success = run_import_tui(args, force=args.wizard_force)
|
140
|
+
if not tui_success:
|
141
|
+
raise SystemExit(1)
|
142
|
+
|
227
143
|
try:
|
228
144
|
asyncio.run(_run_server(args))
|
229
145
|
raise SystemExit(0)
|
src/config.py
CHANGED
@@ -108,12 +108,21 @@ class MCPServerConfig:
|
|
108
108
|
Remote servers use mcp-remote with HTTPS URLs and may require OAuth.
|
109
109
|
Local servers run as child processes and don't need OAuth.
|
110
110
|
"""
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
111
|
+
if self.command != "npx":
|
112
|
+
return False
|
113
|
+
|
114
|
+
# Be tolerant of npx flags by scanning for 'mcp-remote' and the subsequent HTTPS URL
|
115
|
+
try:
|
116
|
+
if "mcp-remote" not in self.args:
|
117
|
+
return False
|
118
|
+
idx: int = self.args.index("mcp-remote")
|
119
|
+
# Look for first https?:// argument after 'mcp-remote'
|
120
|
+
for candidate in self.args[idx + 1 :]:
|
121
|
+
if candidate.startswith(("https://", "http://")):
|
122
|
+
return candidate.startswith("https://")
|
123
|
+
return False
|
124
|
+
except Exception:
|
125
|
+
return False
|
117
126
|
|
118
127
|
def get_remote_url(self) -> str | None:
|
119
128
|
"""
|
@@ -122,9 +131,17 @@ class MCPServerConfig:
|
|
122
131
|
Returns:
|
123
132
|
The HTTPS URL if this is a remote server, None otherwise
|
124
133
|
"""
|
125
|
-
|
126
|
-
|
127
|
-
|
134
|
+
# Reuse the same tolerant parsing as is_remote_server
|
135
|
+
if self.command != "npx" or "mcp-remote" not in self.args:
|
136
|
+
return None
|
137
|
+
try:
|
138
|
+
idx: int = self.args.index("mcp-remote")
|
139
|
+
for candidate in self.args[idx + 1 :]:
|
140
|
+
if candidate.startswith(("https://", "http://")):
|
141
|
+
return candidate
|
142
|
+
return None
|
143
|
+
except Exception:
|
144
|
+
return None
|
128
145
|
|
129
146
|
|
130
147
|
@dataclass
|
src/mcp_importer/__main__.py
CHANGED
src/mcp_importer/api.py
CHANGED
@@ -1,12 +1,15 @@
|
|
1
1
|
# pyright: reportMissingImports=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false
|
2
2
|
import asyncio
|
3
|
-
|
3
|
+
import contextlib
|
4
4
|
from enum import Enum
|
5
5
|
from pathlib import Path
|
6
6
|
from typing import Any
|
7
7
|
|
8
|
+
import questionary
|
9
|
+
from fastmcp import Client as FastMCPClient
|
8
10
|
from fastmcp import FastMCP
|
9
|
-
from
|
11
|
+
from fastmcp.client.auth.oauth import FileTokenStorage
|
12
|
+
from loguru import logger as log # kept for non-TUI contexts; printing used in TUI flows
|
10
13
|
|
11
14
|
from src.config import Config, MCPServerConfig, get_config_json_path
|
12
15
|
from src.mcp_importer import paths as _paths
|
@@ -23,6 +26,8 @@ from src.mcp_importer.importers import (
|
|
23
26
|
)
|
24
27
|
from src.mcp_importer.merge import MergePolicy, merge_servers
|
25
28
|
from src.oauth_manager import OAuthStatus, get_oauth_manager
|
29
|
+
from src.oauth_override import OpenEdisonOAuth
|
30
|
+
from src.tools.io import suppress_fds
|
26
31
|
|
27
32
|
|
28
33
|
class CLIENT(str, Enum):
|
@@ -133,12 +138,140 @@ def export_edison_to(
|
|
133
138
|
def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
|
134
139
|
"""Minimal validation: try listing tools/resources/prompts via FastMCP within a timeout."""
|
135
140
|
|
136
|
-
async def _verify_async() -> bool:
|
141
|
+
async def _verify_async() -> bool: # noqa: C901
|
137
142
|
if not server.command.strip():
|
138
143
|
return False
|
144
|
+
oauth_info = None
|
139
145
|
|
140
|
-
#
|
141
|
-
|
146
|
+
# If this is a remote server, consult OAuth requirement first. Only skip
|
147
|
+
# verification when OAuth is actually required and no tokens are present.
|
148
|
+
if server.is_remote_server():
|
149
|
+
remote_url: str | None = server.get_remote_url()
|
150
|
+
if remote_url:
|
151
|
+
oauth_info = await get_oauth_manager().check_oauth_requirement(
|
152
|
+
server.name, remote_url
|
153
|
+
)
|
154
|
+
if oauth_info.status != OAuthStatus.NOT_REQUIRED:
|
155
|
+
# Token presence check
|
156
|
+
storage = FileTokenStorage(
|
157
|
+
server_url=remote_url, cache_dir=get_oauth_manager().cache_dir
|
158
|
+
)
|
159
|
+
tokens = await storage.get_tokens()
|
160
|
+
no_tokens: bool = not tokens or (
|
161
|
+
not getattr(tokens, "access_token", None)
|
162
|
+
and not getattr(tokens, "refresh_token", None)
|
163
|
+
)
|
164
|
+
# Detect if inline headers are present in args (translated from config)
|
165
|
+
has_inline_headers: bool = any(
|
166
|
+
(a == "--header" or a.startswith("--header")) for a in server.args
|
167
|
+
)
|
168
|
+
if (
|
169
|
+
oauth_info.status == OAuthStatus.NEEDS_AUTH
|
170
|
+
and no_tokens
|
171
|
+
and not has_inline_headers
|
172
|
+
):
|
173
|
+
questionary.print(
|
174
|
+
f"Skipping verification for remote server '{server.name}' pending OAuth",
|
175
|
+
style="bold fg:ansiyellow",
|
176
|
+
)
|
177
|
+
return True
|
178
|
+
|
179
|
+
# Remote servers
|
180
|
+
if server.is_remote_server():
|
181
|
+
connection_timeout = 10.0
|
182
|
+
remote_url = server.get_remote_url()
|
183
|
+
if remote_url:
|
184
|
+
# If inline headers are specified (e.g., API key), verify via proxy to honor headers
|
185
|
+
has_inline_headers_remote: bool = any(
|
186
|
+
(a == "--header" or a.startswith("--header")) for a in server.args
|
187
|
+
)
|
188
|
+
if has_inline_headers_remote:
|
189
|
+
backend_cfg_remote: dict[str, Any] = {
|
190
|
+
"mcpServers": {
|
191
|
+
server.name: {
|
192
|
+
"command": server.command,
|
193
|
+
"args": server.args,
|
194
|
+
"env": server.env or {},
|
195
|
+
**({"roots": server.roots} if server.roots else {}),
|
196
|
+
}
|
197
|
+
}
|
198
|
+
}
|
199
|
+
proxy_remote: FastMCP[Any] | None = None
|
200
|
+
host_remote: FastMCP[Any] | None = None
|
201
|
+
try:
|
202
|
+
# TODO: In debug mode, do not suppress child process output.
|
203
|
+
with suppress_fds(suppress_stdout=True, suppress_stderr=True):
|
204
|
+
proxy_remote = FastMCP.as_proxy(backend_cfg_remote)
|
205
|
+
host_remote = FastMCP(name=f"open-edison-verify-host-{server.name}")
|
206
|
+
host_remote.mount(proxy_remote, prefix=server.name)
|
207
|
+
|
208
|
+
async def _list_tools_only() -> Any:
|
209
|
+
return await host_remote._tool_manager.list_tools() # type: ignore[attr-defined]
|
210
|
+
|
211
|
+
await asyncio.wait_for(_list_tools_only(), timeout=10.0)
|
212
|
+
return True
|
213
|
+
except Exception as e:
|
214
|
+
log.error(
|
215
|
+
"MCP remote (headers) verification failed for '{}': {}", server.name, e
|
216
|
+
)
|
217
|
+
return False
|
218
|
+
finally:
|
219
|
+
for obj in (host_remote, proxy_remote):
|
220
|
+
if isinstance(obj, FastMCP):
|
221
|
+
with contextlib.suppress(Exception):
|
222
|
+
result = obj.shutdown() # type: ignore[attr-defined]
|
223
|
+
await asyncio.wait_for(result, timeout=2.0) # type: ignore[func-returns-value]
|
224
|
+
# Otherwise, avoid triggering OAuth flows during verification
|
225
|
+
ping_succeeded = False
|
226
|
+
try:
|
227
|
+
if oauth_info is None:
|
228
|
+
oauth_info = await get_oauth_manager().check_oauth_requirement(
|
229
|
+
server.name, remote_url
|
230
|
+
)
|
231
|
+
# If OAuth is needed or we are already authenticated, don't initiate browser flows here
|
232
|
+
if oauth_info.status in (OAuthStatus.NEEDS_AUTH, OAuthStatus.AUTHENTICATED):
|
233
|
+
return True
|
234
|
+
# NOT_REQUIRED: quick unauthenticated ping
|
235
|
+
# TODO: In debug mode, do not suppress child process output.
|
236
|
+
questionary.print(
|
237
|
+
f"Testing connection to '{server.name}'... (timeout: {connection_timeout}s)",
|
238
|
+
style="bold fg:ansigreen",
|
239
|
+
)
|
240
|
+
log.debug(f"Establishing contact with remote server '{server.name}'")
|
241
|
+
async with asyncio.timeout(connection_timeout):
|
242
|
+
async with FastMCPClient(
|
243
|
+
remote_url,
|
244
|
+
auth=None,
|
245
|
+
timeout=connection_timeout,
|
246
|
+
init_timeout=connection_timeout,
|
247
|
+
) as client:
|
248
|
+
log.debug(f"Connection established to '{server.name}'; pinging...")
|
249
|
+
with suppress_fds(suppress_stdout=True, suppress_stderr=True):
|
250
|
+
await asyncio.wait_for(fut=client.ping(), timeout=1.0)
|
251
|
+
log.info(f"Ping received from '{server.name}'; shutting down client")
|
252
|
+
ping_succeeded = True
|
253
|
+
log.debug(f"Client '{server.name}' shut down")
|
254
|
+
return ping_succeeded
|
255
|
+
except TimeoutError:
|
256
|
+
if ping_succeeded:
|
257
|
+
questionary.print(
|
258
|
+
f"Ping received from '{server.name}' but shutdown timed out (treating as success)",
|
259
|
+
style="bold fg:ansiyellow",
|
260
|
+
)
|
261
|
+
else:
|
262
|
+
questionary.print(
|
263
|
+
f"Verification timed out (> {connection_timeout}s) for '{server.name}'",
|
264
|
+
style="bold fg:ansired",
|
265
|
+
)
|
266
|
+
return ping_succeeded
|
267
|
+
except Exception as e: # noqa: BLE001
|
268
|
+
questionary.print(
|
269
|
+
f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
|
270
|
+
)
|
271
|
+
return False
|
272
|
+
|
273
|
+
# Local/stdio servers: mount via proxy and perform a single light operation (tools only)
|
274
|
+
backend_cfg_local: dict[str, Any] = {
|
142
275
|
"mcpServers": {
|
143
276
|
server.name: {
|
144
277
|
"command": server.command,
|
@@ -149,56 +282,133 @@ def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
|
|
149
282
|
}
|
150
283
|
}
|
151
284
|
|
152
|
-
|
153
|
-
|
285
|
+
proxy_local: FastMCP[Any] | None = None
|
286
|
+
host_local: FastMCP[Any] | None = None
|
154
287
|
try:
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
await asyncio.wait_for(
|
169
|
-
asyncio.gather(
|
170
|
-
_call_list("tools"),
|
171
|
-
_call_list("resources"),
|
172
|
-
_call_list("prompts"),
|
173
|
-
),
|
174
|
-
timeout=30.0,
|
175
|
-
)
|
288
|
+
# TODO: In debug mode, do not suppress child process output.
|
289
|
+
log.info("Checking properties of '{}'...", server.name)
|
290
|
+
with suppress_fds(suppress_stdout=True, suppress_stderr=True):
|
291
|
+
proxy_local = FastMCP.as_proxy(backend_cfg_local)
|
292
|
+
host_local = FastMCP(name=f"open-edison-verify-host-{server.name}")
|
293
|
+
host_local.mount(proxy_local, prefix=server.name)
|
294
|
+
log.info("MCP properties check succeeded for '{}'", server.name)
|
295
|
+
|
296
|
+
async def _list_tools_only() -> Any:
|
297
|
+
return await host_local._tool_manager.list_tools() # type: ignore[attr-defined]
|
298
|
+
|
299
|
+
await asyncio.wait_for(_list_tools_only(), timeout=30.0)
|
176
300
|
return True
|
177
301
|
except Exception as e:
|
178
|
-
|
302
|
+
questionary.print(
|
303
|
+
f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
|
304
|
+
)
|
179
305
|
return False
|
180
306
|
finally:
|
181
|
-
|
182
|
-
|
183
|
-
|
307
|
+
for obj in (host_local, proxy_local):
|
308
|
+
if isinstance(obj, FastMCP):
|
309
|
+
with contextlib.suppress(Exception):
|
184
310
|
result = obj.shutdown() # type: ignore[attr-defined]
|
185
|
-
|
186
|
-
await result # type: ignore[func-returns-value]
|
187
|
-
except Exception:
|
188
|
-
pass
|
311
|
+
await asyncio.wait_for(result, timeout=2.0) # type: ignore[func-returns-value]
|
189
312
|
|
190
313
|
return asyncio.run(_verify_async())
|
191
314
|
|
192
315
|
|
193
|
-
def
|
194
|
-
"""
|
316
|
+
def authorize_server_oauth(server: MCPServerConfig) -> bool:
|
317
|
+
"""Run an interactive OAuth flow for a remote MCP server and cache tokens.
|
318
|
+
|
319
|
+
Returns True if authorization succeeded (tokens cached and a ping succeeded),
|
320
|
+
False otherwise. Local servers return True immediately.
|
321
|
+
"""
|
195
322
|
|
196
|
-
async def
|
323
|
+
async def _authorize_async() -> bool:
|
197
324
|
if not server.is_remote_server():
|
325
|
+
return True
|
326
|
+
|
327
|
+
remote_url: str | None = server.get_remote_url()
|
328
|
+
if not remote_url:
|
329
|
+
log.error("OAuth requested for remote server '{}' but no URL found", server.name)
|
330
|
+
return False
|
331
|
+
|
332
|
+
oauth_manager = get_oauth_manager()
|
333
|
+
|
334
|
+
try:
|
335
|
+
# Debug info prior to starting OAuth
|
336
|
+
print(
|
337
|
+
"[OAuth] Starting authorization",
|
338
|
+
f"server={server.name}",
|
339
|
+
f"remote_url={remote_url}",
|
340
|
+
f"cache_dir={oauth_manager.cache_dir}",
|
341
|
+
f"scopes={server.oauth_scopes}",
|
342
|
+
f"client_name={server.oauth_client_name or 'Open Edison Setup'}",
|
343
|
+
)
|
344
|
+
|
345
|
+
oauth = OpenEdisonOAuth(
|
346
|
+
mcp_url=remote_url,
|
347
|
+
scopes=server.oauth_scopes,
|
348
|
+
client_name=server.oauth_client_name or "Open Edison Setup",
|
349
|
+
token_storage_cache_dir=oauth_manager.cache_dir,
|
350
|
+
callback_port=50001,
|
351
|
+
)
|
352
|
+
|
353
|
+
# Establish a connection to trigger OAuth if needed
|
354
|
+
async with FastMCPClient(remote_url, auth=oauth) as client: # type: ignore
|
355
|
+
log.info(
|
356
|
+
"Starting OAuth flow for '{}' (a browser window may open; if not, follow the printed URL)",
|
357
|
+
server.name,
|
358
|
+
)
|
359
|
+
await client.ping()
|
360
|
+
|
361
|
+
# Refresh cached status
|
362
|
+
info = await oauth_manager.check_oauth_requirement(server.name, remote_url)
|
363
|
+
|
364
|
+
# Post-authorization token inspection (no secrets printed)
|
365
|
+
try:
|
366
|
+
storage = FileTokenStorage(server_url=remote_url, cache_dir=oauth_manager.cache_dir)
|
367
|
+
tokens = await storage.get_tokens()
|
368
|
+
access_present = bool(getattr(tokens, "access_token", None)) if tokens else False
|
369
|
+
refresh_present = bool(getattr(tokens, "refresh_token", None)) if tokens else False
|
370
|
+
expires_at = getattr(tokens, "expires_at", None) if tokens else None
|
371
|
+
print(
|
372
|
+
"[OAuth] Authorization result:",
|
373
|
+
f"status={info.status.value}",
|
374
|
+
f"has_refresh_token={info.has_refresh_token}",
|
375
|
+
f"token_expires_at={info.token_expires_at or expires_at}",
|
376
|
+
f"tokens_cached=access:{access_present}/refresh:{refresh_present}",
|
377
|
+
)
|
378
|
+
except Exception as _e: # noqa: BLE001
|
379
|
+
print("[OAuth] Authorization completed, but token inspection failed:", _e)
|
380
|
+
|
381
|
+
log.info("OAuth completed and tokens cached for '{}'", server.name)
|
382
|
+
return True
|
383
|
+
except Exception as e: # noqa: BLE001
|
384
|
+
log.error("OAuth authorization failed for '{}': {}", server.name, e)
|
385
|
+
print("[OAuth] Authorization failed:", e)
|
386
|
+
return False
|
387
|
+
|
388
|
+
return asyncio.run(_authorize_async())
|
389
|
+
|
390
|
+
|
391
|
+
def has_oauth_tokens(server: MCPServerConfig) -> bool:
|
392
|
+
"""Return True if cached OAuth tokens exist for the remote server.
|
393
|
+
|
394
|
+
Local servers return True (no OAuth needed).
|
395
|
+
"""
|
396
|
+
|
397
|
+
async def _check_async() -> bool:
|
398
|
+
if not server.is_remote_server():
|
399
|
+
return True
|
400
|
+
|
401
|
+
remote_url: str | None = server.get_remote_url()
|
402
|
+
if not remote_url:
|
403
|
+
return False
|
404
|
+
|
405
|
+
try:
|
406
|
+
storage = FileTokenStorage(
|
407
|
+
server_url=remote_url, cache_dir=get_oauth_manager().cache_dir
|
408
|
+
)
|
409
|
+
tokens = await storage.get_tokens()
|
410
|
+
return bool(tokens and (tokens.access_token or tokens.refresh_token))
|
411
|
+
except Exception:
|
198
412
|
return False
|
199
|
-
info = await get_oauth_manager().check_oauth_requirement(
|
200
|
-
server.name, server.get_remote_url()
|
201
|
-
)
|
202
|
-
return info.status == OAuthStatus.NEEDS_AUTH
|
203
413
|
|
204
|
-
return asyncio.run(
|
414
|
+
return asyncio.run(_check_async())
|