Skip to content

Commit f90ece0

Browse files
committed
Initial OAuth flow in TUI working
1 parent 18965b4 commit f90ece0

File tree

4 files changed

+180
-15
lines changed

4 files changed

+180
-15
lines changed

src/config.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,21 @@ def is_remote_server(self) -> bool:
108108
Remote servers use mcp-remote with HTTPS URLs and may require OAuth.
109109
Local servers run as child processes and don't need OAuth.
110110
"""
111-
return (
112-
self.command == "npx"
113-
and len(self.args) >= 3
114-
and self.args[1] == "mcp-remote"
115-
and self.args[2].startswith("https://")
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://") or candidate.startswith("http://"):
122+
return candidate.startswith("https://")
123+
return False
124+
except Exception:
125+
return False
117126

118127
def get_remote_url(self) -> str | None:
119128
"""
@@ -122,9 +131,17 @@ def get_remote_url(self) -> str | None:
122131
Returns:
123132
The HTTPS URL if this is a remote server, None otherwise
124133
"""
125-
if self.is_remote_server():
126-
return self.args[2]
127-
return None
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://") or candidate.startswith("http://"):
141+
return candidate
142+
return None
143+
except Exception:
144+
return None
128145

129146

130147
@dataclass

src/mcp_importer/api.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ async def _call_list(kind: str) -> Any:
190190
return asyncio.run(_verify_async())
191191

192192

193-
def server_needs_oauth(server: MCPServerConfig) -> bool: # noqa
193+
def server_needs_oauth(server: MCPServerConfig) -> bool:
194194
"""Return True if the remote server currently needs OAuth; False otherwise."""
195195

196196
async def _needs_oauth_async() -> bool:
@@ -202,3 +202,112 @@ async def _needs_oauth_async() -> bool:
202202
return info.status == OAuthStatus.NEEDS_AUTH
203203

204204
return asyncio.run(_needs_oauth_async())
205+
206+
207+
def authorize_server_oauth(server: MCPServerConfig) -> bool:
208+
"""Run an interactive OAuth flow for a remote MCP server and cache tokens.
209+
210+
Returns True if authorization succeeded (tokens cached and a ping succeeded),
211+
False otherwise. Local servers return True immediately.
212+
"""
213+
214+
async def _authorize_async() -> bool:
215+
if not server.is_remote_server():
216+
return True
217+
218+
remote_url: str | None = server.get_remote_url()
219+
if not remote_url:
220+
log.error("OAuth requested for remote server '{}' but no URL found", server.name)
221+
return False
222+
223+
oauth_manager = get_oauth_manager()
224+
225+
try:
226+
# Import lazily to avoid import-time side effects
227+
from fastmcp import Client as FastMCPClient # type: ignore
228+
from fastmcp.client.auth import OAuth # type: ignore
229+
230+
# Debug info prior to starting OAuth
231+
print(
232+
"[OAuth] Starting authorization",
233+
f"server={server.name}",
234+
f"remote_url={remote_url}",
235+
f"cache_dir={oauth_manager.cache_dir}",
236+
f"scopes={server.oauth_scopes}",
237+
f"client_name={server.oauth_client_name or 'Open Edison Setup'}",
238+
)
239+
240+
oauth = OAuth(
241+
mcp_url=remote_url,
242+
scopes=server.oauth_scopes,
243+
client_name=server.oauth_client_name or "Open Edison Setup",
244+
token_storage_cache_dir=oauth_manager.cache_dir,
245+
callback_port=50001,
246+
)
247+
248+
# Establish a connection to trigger OAuth if needed
249+
async with FastMCPClient(remote_url, auth=oauth) as client: # type: ignore
250+
log.info(
251+
"Starting OAuth flow for '{}' (a browser window may open; if not, follow the printed URL)",
252+
server.name,
253+
)
254+
await client.ping()
255+
256+
# Refresh cached status
257+
info = await oauth_manager.check_oauth_requirement(server.name, remote_url)
258+
259+
# Post-authorization token inspection (no secrets printed)
260+
try:
261+
from fastmcp.client.auth.oauth import FileTokenStorage # type: ignore
262+
263+
storage = FileTokenStorage(server_url=remote_url, cache_dir=oauth_manager.cache_dir)
264+
tokens = await storage.get_tokens()
265+
access_present = bool(getattr(tokens, "access_token", None)) if tokens else False
266+
refresh_present = bool(getattr(tokens, "refresh_token", None)) if tokens else False
267+
expires_at = getattr(tokens, "expires_at", None) if tokens else None
268+
print(
269+
"[OAuth] Authorization result:",
270+
f"status={info.status.value}",
271+
f"has_refresh_token={info.has_refresh_token}",
272+
f"token_expires_at={info.token_expires_at or expires_at}",
273+
f"tokens_cached=access:{access_present}/refresh:{refresh_present}",
274+
)
275+
except Exception as _e: # noqa: BLE001
276+
print("[OAuth] Authorization completed, but token inspection failed:", _e)
277+
278+
log.info("OAuth completed and tokens cached for '{}'", server.name)
279+
return True
280+
except Exception as e: # noqa: BLE001
281+
log.error("OAuth authorization failed for '{}': {}", server.name, e)
282+
print("[OAuth] Authorization failed:", e)
283+
return False
284+
285+
return asyncio.run(_authorize_async())
286+
287+
288+
def has_oauth_tokens(server: MCPServerConfig) -> bool:
289+
"""Return True if cached OAuth tokens exist for the remote server.
290+
291+
Local servers return True (no OAuth needed).
292+
"""
293+
294+
async def _check_async() -> bool:
295+
if not server.is_remote_server():
296+
return True
297+
298+
remote_url: str | None = server.get_remote_url()
299+
if not remote_url:
300+
return False
301+
302+
try:
303+
from fastmcp.client.auth.oauth import FileTokenStorage # type: ignore
304+
305+
storage = FileTokenStorage(
306+
server_url=remote_url, cache_dir=get_oauth_manager().cache_dir
307+
)
308+
tokens = await storage.get_tokens()
309+
return bool(tokens and (tokens.access_token or tokens.refresh_token))
310+
except Exception:
311+
return False
312+
313+
return asyncio.run(_check_async())

src/setup_tui/main.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
from src.config import MCPServerConfig
66
from src.mcp_importer.api import (
77
CLIENT,
8+
authorize_server_oauth,
89
detect_clients,
910
export_edison_to,
11+
has_oauth_tokens,
1012
import_from,
13+
save_imported_servers,
1114
verify_mcp_server,
1215
)
1316

@@ -31,7 +34,9 @@ def show_welcome_screen(*, dry_run: bool = False) -> None:
3134
questionary.confirm("Ready to begin the setup process?", default=True).ask()
3235

3336

34-
def handle_mcp_source(source: CLIENT, *, dry_run: bool = False) -> list[MCPServerConfig]:
37+
def handle_mcp_source(
38+
source: CLIENT, *, dry_run: bool = False, skip_oauth: bool = False
39+
) -> list[MCPServerConfig]:
3540
"""Handle the MCP source."""
3641
if not questionary.confirm(
3742
f"We have found {source.name} installed. Would you like to import its MCP servers to open-edison?",
@@ -49,6 +54,31 @@ def handle_mcp_source(source: CLIENT, *, dry_run: bool = False) -> list[MCPServe
4954
print(f"Verifying the configuration for {config.name}... (TODO)")
5055
result = verify_mcp_server(config)
5156
if result:
57+
# If this is a remote server, check OAuth status and optionally authorize
58+
if config.is_remote_server():
59+
# Always check token presence; if absent, prompt for OAuth unless skipped
60+
tokens_present: bool = has_oauth_tokens(config)
61+
if not tokens_present:
62+
if skip_oauth:
63+
print(
64+
f"Skipping OAuth for {config.name} due to --skip-oauth (no tokens present). This server will not be imported."
65+
)
66+
continue
67+
68+
if questionary.confirm(
69+
f"{config.name} is a remote server and no OAuth credentials were found. Obtain credentials now?",
70+
default=True,
71+
).ask():
72+
success = authorize_server_oauth(config)
73+
if not success:
74+
print(
75+
f"Failed to obtain OAuth credentials for {config.name}. Skipping this server."
76+
)
77+
continue
78+
else:
79+
print(f"Skipping {config.name} per user choice.")
80+
continue
81+
5282
verified_configs.append(config)
5383
else:
5484
print(
@@ -116,7 +146,7 @@ def show_manual_setup_screen() -> None:
116146
print(manual_setup_text)
117147

118148

119-
def run(*, dry_run: bool = False) -> None:
149+
def run(*, dry_run: bool = False, skip_oauth: bool = False) -> None:
120150
"""Run the complete setup process."""
121151
show_welcome_screen(dry_run=dry_run)
122152
# Additional setup steps will be added here
@@ -127,7 +157,7 @@ def run(*, dry_run: bool = False) -> None:
127157
configs: list[MCPServerConfig] = []
128158

129159
for source in mcp_sources:
130-
configs.extend(handle_mcp_source(source, dry_run=dry_run))
160+
configs.extend(handle_mcp_source(source, dry_run=dry_run, skip_oauth=skip_oauth))
131161

132162
if len(configs) == 0:
133163
print(
@@ -141,15 +171,24 @@ def run(*, dry_run: bool = False) -> None:
141171
for client in mcp_clients:
142172
confirm_apply_configs(client, dry_run=dry_run)
143173

174+
# Persist imported servers into config.json
175+
if len(configs) > 0:
176+
save_imported_servers(configs, dry_run=dry_run)
177+
144178
show_manual_setup_screen()
145179

146180

147181
def main(argv: list[str] | None = None) -> int:
148182
parser = argparse.ArgumentParser(description="Open Edison Setup TUI")
149183
parser.add_argument("--dry-run", action="store_true", help="Preview actions without writing")
184+
parser.add_argument(
185+
"--skip-oauth",
186+
action="store_true",
187+
help="Skip OAuth for remote servers (they will be omitted from import)",
188+
)
150189
args = parser.parse_args(argv)
151190

152-
run(dry_run=args.dry_run)
191+
run(dry_run=args.dry_run, skip_oauth=args.skip_oauth)
153192
return 0
154193

155194

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)