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.
@@ -10,8 +10,8 @@
10
10
  const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
11
11
  document.documentElement.setAttribute('data-theme', prefersLight ? 'light' : 'dark');
12
12
  </script>
13
- <script type="module" crossorigin src="/assets/index-BUUcUfTt.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-o6_8mdM8.css">
13
+ <script type="module" crossorigin src="/assets/index-D6ziuTsl.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-D05VN_1l.css">
15
15
  </head>
16
16
 
17
17
  <body>
src/frontend_dist/sw.js CHANGED
@@ -61,8 +61,28 @@ self.addEventListener('notificationclick', (event) => {
61
61
  return;
62
62
  }
63
63
 
64
- // Generic click: open dashboard as a safe fallback
65
- event.waitUntil(self.clients.openWindow('/dashboard').catch(() => { }));
64
+ // Generic click: focus existing dashboard tab; if not found, open one with URL params so it can enqueue the pending approval
65
+ event.waitUntil((async () => {
66
+ try {
67
+ const allClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
68
+ const base = self.location && self.location.origin ? self.location.origin : '';
69
+ const targetPrefix = base + '/dashboard';
70
+ const existing = allClients.find(c => c.url && c.url.startsWith(targetPrefix));
71
+ if (existing) {
72
+ try { existing.postMessage({ type: 'MCP_ENQUEUE_PENDING', data: payload }); } catch (e) { /* ignore */ }
73
+ await existing.focus();
74
+ return;
75
+ }
76
+ } catch (e) { /* ignore */ }
77
+ try {
78
+ const params = new URLSearchParams();
79
+ if (payload.sessionId) params.set('pa_s', payload.sessionId);
80
+ if (payload.kind) params.set('pa_k', payload.kind);
81
+ if (payload.name) params.set('pa_n', payload.name);
82
+ const url = '/dashboard/?' + params.toString();
83
+ await self.clients.openWindow(url);
84
+ } catch (e) { /* ignore */ }
85
+ })());
66
86
  } catch (e) {
67
87
  // swallow
68
88
  }
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import sys
4
2
 
5
3
  from src.mcp_importer.cli import run_cli as import_run_cli
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
- from collections.abc import Awaitable
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 loguru import logger as log
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
- # Inline backend config and capability listing (no extra helpers)
141
- backend_cfg: dict[str, Any] = {
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
- proxy: FastMCP[Any] | None = None
153
- host: FastMCP[Any] | None = None
285
+ proxy_local: FastMCP[Any] | None = None
286
+ host_local: FastMCP[Any] | None = None
154
287
  try:
155
- proxy = FastMCP.as_proxy(backend_cfg)
156
- host = FastMCP(name=f"open-edison-verify-host-{server.name}")
157
- host.mount(proxy, prefix=server.name)
158
-
159
- async def _call_list(kind: str) -> Any:
160
- manager_name = {
161
- "tools": "_tool_manager",
162
- "resources": "_resource_manager",
163
- "prompts": "_prompt_manager",
164
- }[kind]
165
- manager = getattr(host, manager_name)
166
- return await getattr(manager, f"list_{kind}")()
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
- log.error("MCP verification failed for '{}': {}", server.name, e)
302
+ questionary.print(
303
+ f"Verification failed for '{server.name}': {e}", style="bold fg:ansired"
304
+ )
179
305
  return False
180
306
  finally:
181
- try:
182
- for obj in (host, proxy):
183
- if isinstance(obj, FastMCP):
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
- if isinstance(result, Awaitable):
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 server_needs_oauth(server: MCPServerConfig) -> bool: # noqa
194
- """Return True if the remote server currently needs OAuth; False otherwise."""
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 _needs_oauth_async() -> bool:
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(_needs_oauth_async())
414
+ return asyncio.run(_check_async())
@@ -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
 
@@ -300,7 +300,7 @@ def export_to_vscode(
300
300
 
301
301
  # Build the minimal config
302
302
  new_config: dict[str, Any] = {
303
- "mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
303
+ "servers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
304
304
  }
305
305
 
306
306
  # If already configured exactly as desired and not forcing, no-op
@@ -1,3 +1 @@
1
- from __future__ import annotations
2
-
3
1
  # legacy helpers were removed to satisfy deadcode scanning
@@ -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 parse_mcp_like_json(data: dict[str, Any], default_enabled: bool = True) -> list[Any]:
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
- return top_level
143
-
144
- # Then, try nested structures heuristically
145
- nested = _collect_nested(data, default_enabled)
146
- if not nested:
147
- log.debug("No MCP-like entries detected in provided data")
148
- return nested
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)
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import argparse
4
2
  from collections.abc import Iterable
5
3
 
src/mcp_importer/types.py CHANGED
@@ -1,5 +1,3 @@
1
1
  """Type helpers for MCP importer."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  # This module intentionally minimal to avoid unused code flags.