11# pyright: reportMissingImports=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false
22import asyncio
3- from collections . abc import Awaitable
3+ import contextlib
44from enum import Enum
55from pathlib import Path
66from typing import Any
77
8+ from fastmcp import Client as FastMCPClient
89from fastmcp import FastMCP
10+ from fastmcp .client .auth import OAuth
11+ from fastmcp .client .auth .oauth import FileTokenStorage
912from loguru import logger as log
1013
1114from src .config import Config , MCPServerConfig , get_config_json_path
2225 import_from_vscode ,
2326)
2427from src .mcp_importer .merge import MergePolicy , merge_servers
25- from src .oauth_manager import get_oauth_manager
28+ from src .oauth_manager import OAuthStatus , get_oauth_manager
2629
2730
2831class CLIENT (str , Enum ):
@@ -37,15 +40,16 @@ def __repr__(self) -> str:
3740 return str (self )
3841
3942
40- def detect_clients () -> set [CLIENT ]:
41- detected : set [CLIENT ] = set ()
43+ def detect_clients () -> list [CLIENT ]:
44+ detected : list [CLIENT ] = []
4245 if _paths .detect_cursor_config_path () is not None :
43- detected .add (CLIENT .CURSOR )
46+ detected .append (CLIENT .CURSOR )
4447 if _paths .detect_vscode_config_path () is not None :
45- detected .add (CLIENT .VSCODE )
48+ detected .append (CLIENT .VSCODE )
4649 if _paths .detect_claude_code_config_path () is not None :
47- detected .add (CLIENT .CLAUDE_CODE )
48- return detected
50+ detected .append (CLIENT .CLAUDE_CODE )
51+ # Return clients sorted alphabetically by identifier
52+ return sorted (detected , key = lambda c : c .value )
4953
5054
5155def import_from (client : CLIENT ) -> list [MCPServerConfig ]:
@@ -136,8 +140,105 @@ def verify_mcp_server(server: MCPServerConfig) -> bool: # noqa
136140 async def _verify_async () -> bool :
137141 if not server .command .strip ():
138142 return False
143+ oauth_info = None
139144
140- # Inline backend config and capability listing (no extra helpers)
145+ # If this is a remote server, consult OAuth requirement first. Only skip
146+ # verification when OAuth is actually required and no tokens are present.
147+ try :
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+ log .info (
174+ "Skipping verification for remote server '{}' pending OAuth" ,
175+ server .name ,
176+ )
177+ return True
178+ except Exception :
179+ # If token inspection fails, continue with normal verification path
180+ pass
181+
182+ # Remote servers
183+ if server .is_remote_server ():
184+ remote_url = server .get_remote_url ()
185+ if remote_url :
186+ # If inline headers are specified (e.g., API key), verify via proxy to honor headers
187+ has_inline_headers : bool = any (
188+ (a == "--header" or a .startswith ("--header" )) for a in server .args
189+ )
190+ if has_inline_headers :
191+ backend_cfg : dict [str , Any ] = {
192+ "mcpServers" : {
193+ server .name : {
194+ "command" : server .command ,
195+ "args" : server .args ,
196+ "env" : server .env or {},
197+ ** ({"roots" : server .roots } if server .roots else {}),
198+ }
199+ }
200+ }
201+ proxy : FastMCP [Any ] | None = None
202+ host : FastMCP [Any ] | None = None
203+ try :
204+ proxy = FastMCP .as_proxy (backend_cfg )
205+ host = FastMCP (name = f"open-edison-verify-host-{ server .name } " )
206+ host .mount (proxy , prefix = server .name )
207+
208+ async def _list_tools_only () -> Any :
209+ return await host ._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 , proxy ):
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+ try :
226+ if oauth_info is None :
227+ oauth_info = await get_oauth_manager ().check_oauth_requirement (
228+ server .name , remote_url
229+ )
230+ # If OAuth is needed or we are already authenticated, don't initiate browser flows here
231+ if oauth_info .status in (OAuthStatus .NEEDS_AUTH , OAuthStatus .AUTHENTICATED ):
232+ return True
233+ # NOT_REQUIRED: quick unauthenticated ping
234+ async with FastMCPClient (remote_url , auth = None ) as client : # type: ignore
235+ await asyncio .wait_for (client .ping (), timeout = 10.0 )
236+ return True
237+ except Exception as e : # noqa: BLE001
238+ log .error ("MCP remote verification failed for '{}': {}" , server .name , e )
239+ return False
240+
241+ # Local/stdio servers: mount via proxy and perform a single light operation (tools only)
141242 backend_cfg : dict [str , Any ] = {
142243 "mcpServers" : {
143244 server .name : {
@@ -156,36 +257,20 @@ async def _verify_async() -> bool:
156257 host = FastMCP (name = f"open-edison-verify-host-{ server .name } " )
157258 host .mount (proxy , prefix = server .name )
158259
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- )
260+ async def _list_tools_only () -> Any :
261+ return await host ._tool_manager .list_tools () # type: ignore[attr-defined]
262+
263+ await asyncio .wait_for (_list_tools_only (), timeout = 10.0 )
176264 return True
177265 except Exception as e :
178266 log .error ("MCP verification failed for '{}': {}" , server .name , e )
179267 return False
180268 finally :
181- try :
182- for obj in ( host , proxy ):
183- if isinstance ( obj , FastMCP ):
269+ for obj in ( host , proxy ) :
270+ if isinstance ( obj , FastMCP ):
271+ with contextlib . suppress ( Exception ):
184272 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
273+ await asyncio .wait_for (result , timeout = 2.0 ) # type: ignore[func-returns-value]
189274
190275 return asyncio .run (_verify_async ())
191276
@@ -209,10 +294,6 @@ async def _authorize_async() -> bool:
209294 oauth_manager = get_oauth_manager ()
210295
211296 try :
212- # Import lazily to avoid import-time side effects
213- from fastmcp import Client as FastMCPClient # type: ignore
214- from fastmcp .client .auth import OAuth # type: ignore
215-
216297 # Debug info prior to starting OAuth
217298 print (
218299 "[OAuth] Starting authorization" ,
@@ -244,8 +325,6 @@ async def _authorize_async() -> bool:
244325
245326 # Post-authorization token inspection (no secrets printed)
246327 try :
247- from fastmcp .client .auth .oauth import FileTokenStorage # type: ignore
248-
249328 storage = FileTokenStorage (server_url = remote_url , cache_dir = oauth_manager .cache_dir )
250329 tokens = await storage .get_tokens ()
251330 access_present = bool (getattr (tokens , "access_token" , None )) if tokens else False
@@ -286,8 +365,6 @@ async def _check_async() -> bool:
286365 return False
287366
288367 try :
289- from fastmcp .client .auth .oauth import FileTokenStorage # type: ignore
290-
291368 storage = FileTokenStorage (
292369 server_url = remote_url , cache_dir = get_oauth_manager ().cache_dir
293370 )
0 commit comments