open-edison 0.1.43__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.43.dist-info → open_edison-0.1.64.dist-info}/METADATA +3 -46
- 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/__init__.py +5 -0
- src/setup_tui/main.py +294 -0
- src/single_user_mcp.py +7 -3
- src/tools/io.py +35 -0
- src/vulture_whitelist.py +3 -0
- open_edison-0.1.43.dist-info/RECORD +0 -35
- {open_edison-0.1.43.dist-info → open_edison-0.1.64.dist-info}/WHEEL +0 -0
- {open_edison-0.1.43.dist-info → open_edison-0.1.64.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.43.dist-info → open_edison-0.1.64.dist-info}/licenses/LICENSE +0 -0
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())
|
src/mcp_importer/export_cli.py
CHANGED
@@ -5,8 +5,10 @@ from loguru import logger as log
|
|
5
5
|
|
6
6
|
from .exporters import ExportError, export_to_claude_code, export_to_cursor, export_to_vscode
|
7
7
|
from .paths import (
|
8
|
+
detect_claude_code_config_path,
|
8
9
|
detect_cursor_config_path,
|
9
10
|
detect_vscode_config_path,
|
11
|
+
get_default_claude_code_config_path,
|
10
12
|
get_default_cursor_config_path,
|
11
13
|
get_default_vscode_config_path,
|
12
14
|
)
|
@@ -135,8 +137,6 @@ def _handle_vscode(args: argparse.Namespace) -> int:
|
|
135
137
|
|
136
138
|
|
137
139
|
def _handle_claude_code(args: argparse.Namespace) -> int:
|
138
|
-
from .paths import detect_claude_code_config_path, get_default_claude_code_config_path
|
139
|
-
|
140
140
|
detected = detect_claude_code_config_path()
|
141
141
|
target_path: Path = detected if detected else get_default_claude_code_config_path()
|
142
142
|
|
src/mcp_importer/import_api.py
CHANGED
src/mcp_importer/parsers.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# pyright: reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownMemberType=false
|
2
2
|
|
3
3
|
import json
|
4
|
+
import shlex
|
4
5
|
from pathlib import Path
|
5
6
|
from typing import Any, cast
|
6
7
|
|
@@ -50,7 +51,7 @@ def safe_read_json(path: Path) -> dict[str, Any]:
|
|
50
51
|
return data
|
51
52
|
|
52
53
|
|
53
|
-
def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool) -> Any:
|
54
|
+
def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool) -> Any: # noqa: C901
|
54
55
|
command_val = node.get("command", "")
|
55
56
|
command = str(command_val) if isinstance(command_val, str) else ""
|
56
57
|
|
@@ -67,12 +68,35 @@ def _coerce_server_entry(name: str, node: dict[str, Any], default_enabled: bool)
|
|
67
68
|
|
68
69
|
args: list[str] = [str(a) for a in args_raw]
|
69
70
|
|
71
|
+
# If command is provided as a full string with flags, split into program + args
|
72
|
+
if command and (" " in command or command.endswith(("\t", "\n"))):
|
73
|
+
try:
|
74
|
+
parts = shlex.split(command)
|
75
|
+
if parts:
|
76
|
+
command = parts[0]
|
77
|
+
# Prepend split args before any provided args to preserve order
|
78
|
+
args = parts[1:] + args
|
79
|
+
except Exception:
|
80
|
+
# If shlex fails, keep original command/args
|
81
|
+
pass
|
82
|
+
|
70
83
|
env_raw = node.get("env") or node.get("environment") or {}
|
71
84
|
env: dict[str, str] = {}
|
72
85
|
if isinstance(env_raw, dict):
|
73
86
|
for k, v in env_raw.items():
|
74
87
|
env[str(k)] = str(v)
|
75
88
|
|
89
|
+
# Support Cursor-style remote config: { "url": "...", "headers": {...} }
|
90
|
+
# Translate to `npx mcp-remote <url> [--header Key: Value]*` so downstream verification works.
|
91
|
+
url_val = node.get("url")
|
92
|
+
if isinstance(url_val, str) and url_val:
|
93
|
+
command = "npx"
|
94
|
+
args = ["-y", "mcp-remote", url_val]
|
95
|
+
headers_raw = node.get("headers")
|
96
|
+
if isinstance(headers_raw, dict):
|
97
|
+
for hk, hv in headers_raw.items():
|
98
|
+
args.extend(["--header", f"{str(hk)}: {str(hv)}"])
|
99
|
+
|
76
100
|
enabled = bool(node.get("enabled", default_enabled))
|
77
101
|
|
78
102
|
roots_raw = node.get("roots") or node.get("rootPaths") or []
|
@@ -135,14 +159,28 @@ def _collect_nested(data: dict[str, Any], default_enabled: bool) -> list[Any]:
|
|
135
159
|
return results
|
136
160
|
|
137
161
|
|
138
|
-
def
|
162
|
+
def deduplicate_by_name(servers: list[MCPServerConfig]) -> list[MCPServerConfig]:
|
163
|
+
result: list[MCPServerConfig] = []
|
164
|
+
names = set()
|
165
|
+
for server in servers:
|
166
|
+
if server.name not in names:
|
167
|
+
names.add(server.name)
|
168
|
+
result.append(server)
|
169
|
+
return result
|
170
|
+
|
171
|
+
|
172
|
+
def parse_mcp_like_json(
|
173
|
+
data: dict[str, Any], default_enabled: bool = True
|
174
|
+
) -> list[MCPServerConfig]:
|
139
175
|
# First, try top-level keys
|
140
176
|
top_level = _collect_top_level(data, default_enabled)
|
177
|
+
res: list[MCPServerConfig] = []
|
141
178
|
if top_level:
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
179
|
+
res = top_level
|
180
|
+
else:
|
181
|
+
# Then, try nested structures heuristically
|
182
|
+
nested = _collect_nested(data, default_enabled)
|
183
|
+
if not nested:
|
184
|
+
log.debug("No MCP-like entries detected in provided data")
|
185
|
+
res = nested
|
186
|
+
return deduplicate_by_name(res)
|
src/mcp_importer/quick_cli.py
CHANGED
src/mcp_importer/types.py
CHANGED
src/oauth_manager.py
CHANGED
@@ -19,6 +19,8 @@ from fastmcp.client.auth.oauth import (
|
|
19
19
|
)
|
20
20
|
from loguru import logger as log
|
21
21
|
|
22
|
+
from src.oauth_override import OpenEdisonOAuth
|
23
|
+
|
22
24
|
|
23
25
|
class OAuthStatus(Enum):
|
24
26
|
"""OAuth authentication status for MCP servers."""
|
@@ -209,7 +211,7 @@ class OAuthManager:
|
|
209
211
|
return None
|
210
212
|
|
211
213
|
try:
|
212
|
-
oauth =
|
214
|
+
oauth = OpenEdisonOAuth(
|
213
215
|
mcp_url=mcp_url,
|
214
216
|
scopes=scopes or info.scopes,
|
215
217
|
client_name=client_name or info.client_name,
|
src/oauth_override.py
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
import webbrowser
|
2
|
+
|
3
|
+
from fastmcp.client.auth.oauth import OAuth as _FastMCPOAuth
|
4
|
+
|
5
|
+
|
6
|
+
class OpenEdisonOAuth(_FastMCPOAuth):
|
7
|
+
async def redirect_handler(self, authorization_url: str) -> None: # noqa: ARG002
|
8
|
+
# Print a clean, single-line URL and still open the browser.
|
9
|
+
print(f"OAuth authorization URL: {authorization_url}", flush=True)
|
10
|
+
webbrowser.open(authorization_url)
|
src/permissions.py
CHANGED
@@ -7,6 +7,7 @@ Reads tool, resource, and prompt permission files and provides a singleton inter
|
|
7
7
|
|
8
8
|
import json
|
9
9
|
from dataclasses import dataclass
|
10
|
+
from functools import cache
|
10
11
|
from pathlib import Path
|
11
12
|
from typing import Any
|
12
13
|
|
@@ -158,6 +159,12 @@ class Permissions:
|
|
158
159
|
)
|
159
160
|
|
160
161
|
@classmethod
|
162
|
+
def clear_permissions_file_cache(cls) -> None:
|
163
|
+
"""Clear the cache for the JSON permissions files"""
|
164
|
+
cls._load_permission_file.cache_clear()
|
165
|
+
|
166
|
+
@classmethod
|
167
|
+
@cache
|
161
168
|
def _load_permission_file(
|
162
169
|
cls,
|
163
170
|
file_path: Path,
|
@@ -171,7 +178,23 @@ class Permissions:
|
|
171
178
|
metadata: PermissionsMetadata | None = None
|
172
179
|
|
173
180
|
if not file_path.exists():
|
174
|
-
|
181
|
+
# Bootstrap missing permissions files on first run.
|
182
|
+
# Prefer copying repo/package defaults (next to src/), else create minimal stub.
|
183
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
184
|
+
|
185
|
+
repo_candidate = Path(__file__).parent.parent / file_path.name
|
186
|
+
if repo_candidate.exists():
|
187
|
+
file_path.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
|
188
|
+
log.info(f"Bootstrapped permissions file from defaults: {file_path}")
|
189
|
+
if not file_path.exists():
|
190
|
+
# Create minimal empty structure
|
191
|
+
try:
|
192
|
+
file_path.write_text(json.dumps({"_metadata": {}}), encoding="utf-8")
|
193
|
+
log.info(f"Created empty permissions file: {file_path}")
|
194
|
+
except Exception as e:
|
195
|
+
raise PermissionsError(
|
196
|
+
f"Unable to create permissions file at {file_path}: {e}", file_path
|
197
|
+
) from e
|
175
198
|
|
176
199
|
with open(file_path) as f:
|
177
200
|
data: dict[str, Any] = json.load(f)
|
src/server.py
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
"""
|
2
|
-
Open Edison Server
|
3
|
-
|
4
|
-
|
5
|
-
No multi-user support, no complex routing - just a straightforward proxy.
|
2
|
+
Open Edison MCP Proxy Server
|
3
|
+
Main server entrypoint for FastAPI and FastMCP integration.
|
4
|
+
See README for usage and configuration details.
|
6
5
|
"""
|
7
6
|
|
8
7
|
import asyncio
|
9
8
|
import json
|
9
|
+
import signal
|
10
10
|
import traceback
|
11
11
|
from collections.abc import Awaitable, Callable, Coroutine
|
12
12
|
from contextlib import suppress
|
@@ -25,6 +25,7 @@ from fastapi.responses import (
|
|
25
25
|
)
|
26
26
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
27
27
|
from fastapi.staticfiles import StaticFiles
|
28
|
+
from fastmcp import Client as FastMCPClient
|
28
29
|
from fastmcp import FastMCP
|
29
30
|
from loguru import logger as log
|
30
31
|
from pydantic import BaseModel, Field
|
@@ -37,6 +38,8 @@ from src.middleware.session_tracking import (
|
|
37
38
|
create_db_session,
|
38
39
|
)
|
39
40
|
from src.oauth_manager import OAuthStatus, get_oauth_manager
|
41
|
+
from src.oauth_override import OpenEdisonOAuth
|
42
|
+
from src.permissions import Permissions
|
40
43
|
from src.single_user_mcp import SingleUserMCP
|
41
44
|
from src.telemetry import initialize_telemetry, set_servers_installed
|
42
45
|
|
@@ -276,6 +279,12 @@ class OpenEdisonProxy:
|
|
276
279
|
# Clear cache for the config file, if it was config.json
|
277
280
|
if name == "config.json":
|
278
281
|
clear_json_file_cache()
|
282
|
+
elif name in (
|
283
|
+
"tool_permissions.json",
|
284
|
+
"resource_permissions.json",
|
285
|
+
"prompt_permissions.json",
|
286
|
+
):
|
287
|
+
Permissions.clear_permissions_file_cache()
|
279
288
|
|
280
289
|
return {"status": "ok"}
|
281
290
|
except Exception as e: # noqa: BLE001
|
@@ -397,6 +406,7 @@ class OpenEdisonProxy:
|
|
397
406
|
host=self.host,
|
398
407
|
port=self.port + 1,
|
399
408
|
log_level=Config().logging.level.lower(),
|
409
|
+
timeout_graceful_shutdown=0,
|
400
410
|
)
|
401
411
|
fastapi_server = uvicorn.Server(fastapi_config)
|
402
412
|
servers_to_run.append(fastapi_server.serve())
|
@@ -408,13 +418,27 @@ class OpenEdisonProxy:
|
|
408
418
|
host=self.host,
|
409
419
|
port=self.port,
|
410
420
|
log_level=Config().logging.level.lower(),
|
421
|
+
timeout_graceful_shutdown=0,
|
411
422
|
)
|
412
423
|
fastmcp_server = uvicorn.Server(fastmcp_config)
|
413
424
|
servers_to_run.append(fastmcp_server.serve())
|
414
425
|
|
415
426
|
# Run both servers concurrently
|
416
427
|
log.info("🚀 Starting both FastAPI and FastMCP servers...")
|
417
|
-
|
428
|
+
loop = asyncio.get_running_loop()
|
429
|
+
|
430
|
+
def _trigger_shutdown(signame: str) -> None:
|
431
|
+
log.info(f"Received {signame}. Forcing shutdown of all servers...")
|
432
|
+
for srv in (fastapi_server, fastmcp_server):
|
433
|
+
with suppress(Exception):
|
434
|
+
srv.force_exit = True # type: ignore[attr-defined] # noqa
|
435
|
+
srv.should_exit = True # type: ignore[attr-defined] # noqa
|
436
|
+
|
437
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
438
|
+
with suppress(Exception):
|
439
|
+
loop.add_signal_handler(sig, _trigger_shutdown, sig.name)
|
440
|
+
|
441
|
+
await asyncio.gather(*servers_to_run, return_exceptions=False)
|
418
442
|
|
419
443
|
def _register_routes(self, app: FastAPI) -> None:
|
420
444
|
"""Register all routes for the FastAPI app"""
|
@@ -953,12 +977,8 @@ class OpenEdisonProxy:
|
|
953
977
|
|
954
978
|
log.info(f"🔗 Testing connection to {server_name} at {remote_url}")
|
955
979
|
|
956
|
-
# Import FastMCP client for testing
|
957
|
-
from fastmcp import Client as FastMCPClient
|
958
|
-
from fastmcp.client.auth import OAuth
|
959
|
-
|
960
980
|
# Create OAuth auth object
|
961
|
-
oauth =
|
981
|
+
oauth = OpenEdisonOAuth(
|
962
982
|
mcp_url=remote_url,
|
963
983
|
scopes=scopes,
|
964
984
|
client_name=client_name or "OpenEdison MCP Gateway",
|