open-edison 0.1.44__py3-none-any.whl → 0.1.72rc1__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/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
 
@@ -52,6 +53,9 @@ class ResourcePermission:
52
53
  write_operation: bool = False
53
54
  read_private_data: bool = False
54
55
  read_untrusted_public_data: bool = False
56
+ acl: str = "PUBLIC"
57
+ # Optional metadata fields (ignored by enforcement but accepted from JSON)
58
+ description: str | None = None
55
59
 
56
60
 
57
61
  @dataclass
@@ -158,6 +162,12 @@ class Permissions:
158
162
  )
159
163
 
160
164
  @classmethod
165
+ def clear_permissions_file_cache(cls) -> None:
166
+ """Clear the cache for the JSON permissions files"""
167
+ cls._load_permission_file.cache_clear()
168
+
169
+ @classmethod
170
+ @cache
161
171
  def _load_permission_file(
162
172
  cls,
163
173
  file_path: Path,
@@ -171,7 +181,23 @@ class Permissions:
171
181
  metadata: PermissionsMetadata | None = None
172
182
 
173
183
  if not file_path.exists():
174
- raise PermissionsError(f"Permissions file not found at {file_path}")
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
175
201
 
176
202
  with open(file_path) as f:
177
203
  data: dict[str, Any] = json.load(f)
@@ -203,43 +229,117 @@ class Permissions:
203
229
  def get_tool_permission(self, tool_name: str) -> ToolPermission:
204
230
  """Get permission for a specific tool"""
205
231
  if tool_name not in self.tool_permissions:
206
- raise PermissionsError(f"Tool '{tool_name}' not found in permissions")
232
+ if tool_name.startswith("builtin_"):
233
+ log.info(
234
+ f"Tool '{tool_name}' not found; returning builtin safe default (enabled, 0 risk)"
235
+ )
236
+ return ToolPermission(
237
+ enabled=True,
238
+ write_operation=False,
239
+ read_private_data=False,
240
+ read_untrusted_public_data=False,
241
+ acl="PUBLIC",
242
+ )
243
+ log.warning(
244
+ f"Tool '{tool_name}' not found in permissions; returning enabled full-trifecta default"
245
+ )
246
+ return ToolPermission(
247
+ enabled=True,
248
+ write_operation=True,
249
+ read_private_data=True,
250
+ read_untrusted_public_data=True,
251
+ acl="SECRET",
252
+ )
207
253
  return self.tool_permissions[tool_name]
208
254
 
209
255
  def get_resource_permission(self, resource_name: str) -> ResourcePermission:
210
256
  """Get permission for a specific resource"""
211
257
  if resource_name not in self.resource_permissions:
212
- raise PermissionsError(f"Resource '{resource_name}' not found in permissions")
258
+ if resource_name.startswith("builtin_"):
259
+ log.info(
260
+ f"Resource '{resource_name}' not found; returning builtin safe default (enabled, 0 risk)"
261
+ )
262
+ return ResourcePermission(
263
+ enabled=True,
264
+ write_operation=False,
265
+ read_private_data=False,
266
+ read_untrusted_public_data=False,
267
+ )
268
+ log.warning(
269
+ f"Resource '{resource_name}' not found in permissions; returning enabled full-trifecta default"
270
+ )
271
+ return ResourcePermission(
272
+ enabled=True,
273
+ write_operation=True,
274
+ read_private_data=True,
275
+ read_untrusted_public_data=True,
276
+ )
213
277
  return self.resource_permissions[resource_name]
214
278
 
215
279
  def get_prompt_permission(self, prompt_name: str) -> PromptPermission:
216
280
  """Get permission for a specific prompt"""
217
281
  if prompt_name not in self.prompt_permissions:
218
- raise PermissionsError(f"Prompt '{prompt_name}' not found in permissions")
282
+ if prompt_name.startswith("builtin_"):
283
+ log.info(
284
+ f"Prompt '{prompt_name}' not found; returning builtin safe default (enabled, 0 risk)"
285
+ )
286
+ return PromptPermission(
287
+ enabled=True,
288
+ write_operation=False,
289
+ read_private_data=False,
290
+ read_untrusted_public_data=False,
291
+ acl="PUBLIC",
292
+ )
293
+ log.warning(
294
+ f"Prompt '{prompt_name}' not found in permissions; returning enabled full-trifecta default"
295
+ )
296
+ return PromptPermission(
297
+ enabled=True,
298
+ write_operation=True,
299
+ read_private_data=True,
300
+ read_untrusted_public_data=True,
301
+ acl="SECRET",
302
+ )
219
303
  return self.prompt_permissions[prompt_name]
220
304
 
221
305
  def is_tool_enabled(self, tool_name: str) -> bool:
222
306
  """Check if a tool is enabled
223
307
  Also checks if the server is enabled"""
224
308
  permission = self.get_tool_permission(tool_name)
225
- server_name = self.server_name_from_tool_name(tool_name)
226
- server_enabled = self.is_server_enabled(server_name)
309
+ try:
310
+ server_name = self.server_name_from_tool_name(tool_name)
311
+ server_enabled = self.is_server_enabled(server_name)
312
+ except PermissionsError:
313
+ log.warning(f"Server resolution failed for tool '{tool_name}'; treating as disabled")
314
+ server_enabled = False
227
315
  return permission.enabled and server_enabled
228
316
 
229
317
  def is_resource_enabled(self, resource_name: str) -> bool:
230
318
  """Check if a resource is enabled
231
319
  Also checks if the server is enabled"""
232
320
  permission = self.get_resource_permission(resource_name)
233
- server_name = self.server_name_from_tool_name(resource_name)
234
- server_enabled = self.is_server_enabled(server_name)
321
+ try:
322
+ server_name = self.server_name_from_tool_name(resource_name)
323
+ server_enabled = self.is_server_enabled(server_name)
324
+ except PermissionsError:
325
+ log.warning(
326
+ f"Server resolution failed for resource '{resource_name}'; treating as disabled"
327
+ )
328
+ server_enabled = False
235
329
  return permission.enabled and server_enabled
236
330
 
237
331
  def is_prompt_enabled(self, prompt_name: str) -> bool:
238
332
  """Check if a prompt is enabled
239
333
  Also checks if the server is enabled"""
240
334
  permission = self.get_prompt_permission(prompt_name)
241
- server_name = self.server_name_from_tool_name(prompt_name)
242
- server_enabled = self.is_server_enabled(server_name)
335
+ try:
336
+ server_name = self.server_name_from_tool_name(prompt_name)
337
+ server_enabled = self.is_server_enabled(server_name)
338
+ except PermissionsError:
339
+ log.warning(
340
+ f"Server resolution failed for prompt '{prompt_name}'; treating as disabled"
341
+ )
342
+ server_enabled = False
243
343
  return permission.enabled and server_enabled
244
344
 
245
345
  @staticmethod
src/server.py CHANGED
@@ -1,12 +1,12 @@
1
1
  """
2
- Open Edison Server
3
-
4
- Simple FastAPI + FastMCP server for single-user MCP proxy.
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,18 +25,24 @@ 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
31
32
 
32
33
  from src import events
33
- from src.config import Config, MCPServerConfig, clear_json_file_cache
34
+ from src.config import Config, MCPServerConfig, clear_json_file_cache, get_config_json_path
34
35
  from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
36
+ from src.mcp_stdio_capture import (
37
+ install_stdio_client_stderr_capture as _install_stdio_capture,
38
+ )
35
39
  from src.middleware.session_tracking import (
36
40
  MCPSessionModel,
37
41
  create_db_session,
38
42
  )
39
43
  from src.oauth_manager import OAuthStatus, get_oauth_manager
44
+ from src.oauth_override import OpenEdisonOAuth
45
+ from src.permissions import Permissions
40
46
  from src.single_user_mcp import SingleUserMCP
41
47
  from src.telemetry import initialize_telemetry, set_servers_installed
42
48
 
@@ -45,6 +51,9 @@ _security = HTTPBearer()
45
51
  _auth_dependency = Depends(_security)
46
52
 
47
53
 
54
+ _install_stdio_capture()
55
+
56
+
48
57
  class OpenEdisonProxy:
49
58
  """
50
59
  Open Edison Single-User MCP Proxy Server
@@ -276,6 +285,12 @@ class OpenEdisonProxy:
276
285
  # Clear cache for the config file, if it was config.json
277
286
  if name == "config.json":
278
287
  clear_json_file_cache()
288
+ elif name in (
289
+ "tool_permissions.json",
290
+ "resource_permissions.json",
291
+ "prompt_permissions.json",
292
+ ):
293
+ Permissions.clear_permissions_file_cache()
279
294
 
280
295
  return {"status": "ok"}
281
296
  except Exception as e: # noqa: BLE001
@@ -363,6 +378,9 @@ class OpenEdisonProxy:
363
378
  log.info(f"FastAPI management API on {self.host}:{self.port + 1}")
364
379
  log.info(f"FastMCP protocol server on {self.host}:{self.port}")
365
380
 
381
+ # Print location of config
382
+ log.info(f"Config file location: {get_config_json_path()}")
383
+
366
384
  initialize_telemetry()
367
385
 
368
386
  # Ensure the sessions database exists and has the required schema
@@ -397,6 +415,7 @@ class OpenEdisonProxy:
397
415
  host=self.host,
398
416
  port=self.port + 1,
399
417
  log_level=Config().logging.level.lower(),
418
+ timeout_graceful_shutdown=0,
400
419
  )
401
420
  fastapi_server = uvicorn.Server(fastapi_config)
402
421
  servers_to_run.append(fastapi_server.serve())
@@ -408,13 +427,27 @@ class OpenEdisonProxy:
408
427
  host=self.host,
409
428
  port=self.port,
410
429
  log_level=Config().logging.level.lower(),
430
+ timeout_graceful_shutdown=0,
411
431
  )
412
432
  fastmcp_server = uvicorn.Server(fastmcp_config)
413
433
  servers_to_run.append(fastmcp_server.serve())
414
434
 
415
435
  # Run both servers concurrently
416
436
  log.info("🚀 Starting both FastAPI and FastMCP servers...")
417
- _ = await asyncio.gather(*servers_to_run)
437
+ loop = asyncio.get_running_loop()
438
+
439
+ def _trigger_shutdown(signame: str) -> None:
440
+ log.info(f"Received {signame}. Forcing shutdown of all servers...")
441
+ for srv in (fastapi_server, fastmcp_server):
442
+ with suppress(Exception):
443
+ srv.force_exit = True # type: ignore[attr-defined] # noqa
444
+ srv.should_exit = True # type: ignore[attr-defined] # noqa
445
+
446
+ for sig in (signal.SIGINT, signal.SIGTERM):
447
+ with suppress(Exception):
448
+ loop.add_signal_handler(sig, _trigger_shutdown, sig.name)
449
+
450
+ await asyncio.gather(*servers_to_run, return_exceptions=False)
418
451
 
419
452
  def _register_routes(self, app: FastAPI) -> None:
420
453
  """Register all routes for the FastAPI app"""
@@ -519,11 +552,8 @@ class OpenEdisonProxy:
519
552
  warms the lists to ensure subsequent list calls reflect current state.
520
553
  """
521
554
  try:
522
- mcp = self.single_user_mcp
523
- # Warm managers so any internal caches are refreshed
524
- await mcp._tool_manager.list_tools() # type: ignore[attr-defined]
525
- await mcp._resource_manager.list_resources() # type: ignore[attr-defined]
526
- await mcp._prompt_manager.list_prompts() # type: ignore[attr-defined]
555
+ clear_json_file_cache()
556
+ Permissions.clear_permissions_file_cache()
527
557
  return {"status": "ok"}
528
558
  except Exception as e: # noqa: BLE001
529
559
  log.error(f"Failed to process permissions-changed: {e}")
@@ -638,14 +668,29 @@ class OpenEdisonProxy:
638
668
  )
639
669
 
640
670
  sessions: list[dict[str, Any]] = []
671
+ has_warned_about_missing_created_at = False
641
672
  for row_model in results:
642
673
  row = cast(Any, row_model)
643
674
  tool_calls_val = row.tool_calls
644
675
  data_access_summary_val = row.data_access_summary
676
+ created_at_val = None
677
+ if isinstance(data_access_summary_val, dict):
678
+ created_at_val = data_access_summary_val.get("created_at") # type: ignore[assignment]
679
+ if (
680
+ created_at_val is None
681
+ and isinstance(tool_calls_val, list)
682
+ and tool_calls_val
683
+ and not has_warned_about_missing_created_at
684
+ ):
685
+ has_warned_about_missing_created_at = True
686
+ log.warning(
687
+ "created_at is missing, will have sessions with unknown timestamps"
688
+ )
645
689
  sessions.append(
646
690
  {
647
691
  "session_id": row.session_id,
648
692
  "correlation_id": row.correlation_id,
693
+ "created_at": created_at_val,
649
694
  "tool_calls": tool_calls_val
650
695
  if isinstance(tool_calls_val, list)
651
696
  else [],
@@ -953,12 +998,8 @@ class OpenEdisonProxy:
953
998
 
954
999
  log.info(f"🔗 Testing connection to {server_name} at {remote_url}")
955
1000
 
956
- # Import FastMCP client for testing
957
- from fastmcp import Client as FastMCPClient
958
- from fastmcp.client.auth import OAuth
959
-
960
1001
  # Create OAuth auth object
961
- oauth = OAuth(
1002
+ oauth = OpenEdisonOAuth(
962
1003
  mcp_url=remote_url,
963
1004
  scopes=scopes,
964
1005
  client_name=client_name or "OpenEdison MCP Gateway",
src/setup_tui/main.py CHANGED
@@ -1,15 +1,26 @@
1
1
  import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import sys
5
+ from collections.abc import Generator
2
6
 
3
7
  import questionary
8
+ from loguru import logger as log
4
9
 
5
- from src.config import MCPServerConfig
10
+ import src.oauth_manager as oauth_mod
11
+ from src.config import MCPServerConfig, get_config_dir
6
12
  from src.mcp_importer.api import (
7
13
  CLIENT,
14
+ authorize_server_oauth,
8
15
  detect_clients,
9
16
  export_edison_to,
17
+ has_oauth_tokens,
10
18
  import_from,
19
+ save_imported_servers,
11
20
  verify_mcp_server,
12
21
  )
22
+ from src.mcp_importer.parsers import deduplicate_by_name
23
+ from src.oauth_manager import OAuthStatus, get_oauth_manager
13
24
 
14
25
 
15
26
  def show_welcome_screen(*, dry_run: bool = False) -> None:
@@ -31,7 +42,9 @@ def show_welcome_screen(*, dry_run: bool = False) -> None:
31
42
  questionary.confirm("Ready to begin the setup process?", default=True).ask()
32
43
 
33
44
 
34
- def handle_mcp_source(source: CLIENT, *, dry_run: bool = False) -> list[MCPServerConfig]:
45
+ def handle_mcp_source( # noqa: C901
46
+ source: CLIENT, *, dry_run: bool = False, skip_oauth: bool = False
47
+ ) -> list[MCPServerConfig]:
35
48
  """Handle the MCP source."""
36
49
  if not questionary.confirm(
37
50
  f"We have found {source.name} installed. Would you like to import its MCP servers to open-edison?",
@@ -41,18 +54,65 @@ def handle_mcp_source(source: CLIENT, *, dry_run: bool = False) -> list[MCPServe
41
54
 
42
55
  configs = import_from(source)
43
56
 
57
+ # Filter out any "open-edison" configs
58
+ if "open-edison" in [config.name for config in configs]:
59
+ print(
60
+ "Found an 'open-edison' config. This is not allowed, so it will be excluded from the import."
61
+ )
62
+
63
+ configs = [config for config in configs if config.name != "open-edison"]
64
+
44
65
  print(f"Loaded {len(configs)} MCP server configuration from {source.name}!")
45
66
 
46
67
  verified_configs: list[MCPServerConfig] = []
47
68
 
48
69
  for config in configs:
49
- print(f"Verifying the configuration for {config.name}... (TODO)")
70
+ print(f"Verifying the configuration for {config.name}... ")
50
71
  result = verify_mcp_server(config)
51
72
  if result:
73
+ # For remote servers, only prompt if OAuth is actually required
74
+ if config.is_remote_server():
75
+ # Heuristic: if inline headers are present (e.g., API key), treat as not requiring OAuth
76
+ has_inline_headers: bool = any(
77
+ (a == "--header" or a.startswith("--header")) for a in config.args
78
+ )
79
+ if not has_inline_headers:
80
+ # Prefer cached result from verification; only check if missing
81
+ oauth_mgr = get_oauth_manager()
82
+ info = oauth_mgr.get_server_info(config.name)
83
+ if info is None:
84
+ info = asyncio.run(
85
+ oauth_mgr.check_oauth_requirement(config.name, config.get_remote_url())
86
+ )
87
+
88
+ if info.status == OAuthStatus.NEEDS_AUTH:
89
+ tokens_present: bool = has_oauth_tokens(config)
90
+ if not tokens_present:
91
+ if skip_oauth:
92
+ print(
93
+ f"Skipping OAuth for {config.name} due to --skip-oauth (OAuth required, no tokens). This server will not be imported."
94
+ )
95
+ continue
96
+
97
+ if questionary.confirm(
98
+ f"{config.name} requires OAuth and no credentials were found. Obtain credentials now?",
99
+ default=True,
100
+ ).ask():
101
+ success = authorize_server_oauth(config)
102
+ if not success:
103
+ print(
104
+ f"Failed to obtain OAuth credentials for {config.name}. Skipping this server."
105
+ )
106
+ continue
107
+ else:
108
+ print(f"Skipping {config.name} per user choice.")
109
+ continue
110
+
111
+ print(f"Verification successful for {config.name}.")
52
112
  verified_configs.append(config)
53
113
  else:
54
114
  print(
55
- f"The configuration for {config.name} is not valid. Please check the configuration and try again."
115
+ f"Verification failed for the configuration of {config.name}. Please check the configuration and try again."
56
116
  )
57
117
 
58
118
  return verified_configs
@@ -72,16 +132,18 @@ def confirm_configs(configs: list[MCPServerConfig], *, dry_run: bool = False) ->
72
132
 
73
133
  def confirm_apply_configs(client: CLIENT, *, dry_run: bool = False) -> None:
74
134
  if not questionary.confirm(
75
- f"We have detected that you have {client.name} installed. Would you like to connect it to open-edison?",
135
+ 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.)",
76
136
  default=True,
77
137
  ).ask():
78
138
  return
79
139
 
80
- export_edison_to(client, dry_run=dry_run)
140
+ result = export_edison_to(client, dry_run=dry_run)
81
141
  if dry_run:
82
142
  print(f"[dry-run] Export prepared for {client.name}; no changes written.")
83
143
  else:
84
- print(f"Successfully set up Open Edison for {client.name}!")
144
+ print(
145
+ f"Successfully set up Open Edison for {client.name}! Your previous MCP configuration has been backed up at {result.backup_path}"
146
+ )
85
147
 
86
148
 
87
149
  def show_manual_setup_screen() -> None:
@@ -95,8 +157,10 @@ def show_manual_setup_screen() -> None:
95
157
 
96
158
  To set up open-edison manually in other clients, find your client's MCP config
97
159
  JSON file and add the following configuration:
160
+ """
98
161
 
99
- "mcpServers": {
162
+ json_snippet = """\t{
163
+ "mcpServers": {
100
164
  "open-edison": {
101
165
  "command": "npx",
102
166
  "args": [
@@ -108,48 +172,123 @@ def show_manual_setup_screen() -> None:
108
172
  "Authorization: Bearer dev-api-key-change-me"
109
173
  ]
110
174
  }
111
- },
175
+ }
176
+ }"""
112
177
 
178
+ after_text = """
113
179
  Make sure to replace 'dev-api-key-change-me' with your actual API key.
114
180
  """
115
181
 
116
182
  print(manual_setup_text)
183
+ # Use questionary's print with style for color
184
+ questionary.print(json_snippet, style="bold fg:ansigreen")
185
+ print(after_text)
186
+
187
+
188
+ class _TuiLogger:
189
+ def _fmt(self, msg: object, *args: object) -> str:
190
+ try:
191
+ if isinstance(msg, str) and args:
192
+ return msg.format(*args)
193
+ except Exception:
194
+ pass
195
+ return str(msg)
196
+
197
+ def info(self, msg: object, *args: object, **kwargs: object) -> None:
198
+ questionary.print(self._fmt(msg, *args), style="fg:ansiblue")
199
+
200
+ def debug(self, msg: object, *args: object, **kwargs: object) -> None:
201
+ questionary.print(self._fmt(msg, *args), style="fg:ansiblack")
202
+
203
+ def warning(self, msg: object, *args: object, **kwargs: object) -> None:
204
+ questionary.print(self._fmt(msg, *args), style="bold fg:ansiyellow")
205
+
206
+ def error(self, msg: object, *args: object, **kwargs: object) -> None:
207
+ questionary.print(self._fmt(msg, *args), style="bold fg:ansired")
208
+
209
+
210
+ @contextlib.contextmanager
211
+ def suppress_loguru_output() -> Generator[None, None, None]:
212
+ """Suppress loguru output."""
213
+ with contextlib.suppress(Exception):
214
+ log.remove()
117
215
 
216
+ old_logger = oauth_mod.log
217
+ # Route oauth_manager's log calls to questionary for TUI output
218
+ oauth_mod.log = _TuiLogger() # type: ignore[attr-defined]
219
+ yield
220
+ oauth_mod.log = old_logger
221
+ log.add(sys.stdout, level="INFO")
118
222
 
119
- def run(*, dry_run: bool = False) -> None:
120
- """Run the complete setup process."""
223
+
224
+ @suppress_loguru_output()
225
+ def run(*, dry_run: bool = False, skip_oauth: bool = False) -> bool: # noqa: C901
226
+ """Run the complete setup process.
227
+ Returns whether the setup was successful."""
121
228
  show_welcome_screen(dry_run=dry_run)
122
229
  # Additional setup steps will be added here
123
230
 
124
- mcp_sources = detect_clients()
125
- mcp_clients = detect_clients()
231
+ mcp_clients = sorted(detect_clients(), key=lambda x: x.value)
126
232
 
127
233
  configs: list[MCPServerConfig] = []
128
234
 
129
- for source in mcp_sources:
130
- configs.extend(handle_mcp_source(source, dry_run=dry_run))
235
+ for client in mcp_clients:
236
+ configs.extend(handle_mcp_source(client, dry_run=dry_run, skip_oauth=skip_oauth))
131
237
 
132
238
  if len(configs) == 0:
133
- print(
134
- "No MCP servers found. Please set up an MCP client with some servers and run this setup again."
135
- )
136
- return
239
+ if not questionary.confirm(
240
+ "No MCP servers found. Would you like to continue without them?", default=True
241
+ ).ask():
242
+ print("Setup aborted. Please configure an MCP client and try again.")
243
+ return False
244
+ return True
245
+
246
+ # Deduplicate configs
247
+ configs = deduplicate_by_name(configs)
137
248
 
138
249
  if not confirm_configs(configs, dry_run=dry_run):
139
- return
250
+ return False
140
251
 
141
252
  for client in mcp_clients:
142
253
  confirm_apply_configs(client, dry_run=dry_run)
143
254
 
255
+ # Persist imported servers into config.json
256
+ if len(configs) > 0:
257
+ save_imported_servers(configs, dry_run=dry_run)
258
+
144
259
  show_manual_setup_screen()
145
260
 
261
+ return True
262
+
263
+
264
+ # Triggered from cli.py
265
+ def run_import_tui(args: argparse.Namespace, force: bool = False) -> bool:
266
+ """Run the import TUI, if necessary."""
267
+ # Find config dir, check if ".setup_tui_ran" exists
268
+ config_dir = get_config_dir()
269
+ config_dir.mkdir(parents=True, exist_ok=True)
270
+
271
+ setup_tui_ran_file = config_dir / ".setup_tui_ran"
272
+ success = True
273
+ if not setup_tui_ran_file.exists() or force:
274
+ success = run(dry_run=args.wizard_dry_run, skip_oauth=args.wizard_skip_oauth)
275
+
276
+ setup_tui_ran_file.touch()
277
+
278
+ return success
279
+
146
280
 
147
281
  def main(argv: list[str] | None = None) -> int:
148
282
  parser = argparse.ArgumentParser(description="Open Edison Setup TUI")
149
283
  parser.add_argument("--dry-run", action="store_true", help="Preview actions without writing")
284
+ parser.add_argument(
285
+ "--skip-oauth",
286
+ action="store_true",
287
+ help="Skip OAuth for remote servers (they will be omitted from import)",
288
+ )
150
289
  args = parser.parse_args(argv)
151
290
 
152
- run(dry_run=args.dry_run)
291
+ run(dry_run=args.dry_run, skip_oauth=args.skip_oauth)
153
292
  return 0
154
293
 
155
294