File size: 29,780 Bytes
399b80c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
import asyncio
import sys
import shutil
from typing import Callable, Awaitable, Optional, Dict, List
from openspace.utils.logging import Logger

logger = Logger.get_logger(__name__)

PromptFunc = Callable[[str], Awaitable[bool]]

# Global lock to prevent concurrent user prompts
_prompt_lock = asyncio.Lock()


class MCPDependencyError(RuntimeError):
    """Base exception for MCP dependency errors."""
    pass


class MCPCommandNotFoundError(MCPDependencyError):
    """Raised when a required command is not available."""
    pass


class MCPInstallationCancelledError(MCPDependencyError):
    """Raised when user cancels installation."""
    pass


class MCPInstallationFailedError(MCPDependencyError):
    """Raised when installation fails."""
    pass


class Colors:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    RED = "\033[91m"
    YELLOW = "\033[93m"
    GREEN = "\033[92m"
    CYAN = "\033[96m"
    GRAY = "\033[90m"
    WHITE = "\033[97m"
    BLUE = "\033[94m"


class MCPInstallerManager:
    """
    MCP dependencies package installer manager.
    
    Responsible for detecting if the MCP server dependencies are installed, and if not, asking the user whether to install them.
    """
    
    def __init__(self, prompt: PromptFunc | None = None, auto_install: bool = False, verbose: bool = False):
        """Initialize the installer manager.
        
        Args:
            prompt: Custom user prompt function, if None, the default CLI prompt is used
            auto_install: If True, automatically install dependencies without asking the user
            verbose: If True, show detailed installation logs; if False, only show progress indicator
        """
        self._prompt: PromptFunc | None = prompt or self._default_cli_prompt
        self._auto_install = auto_install
        self._verbose = verbose
        self._installed_cache: Dict[str, bool] = {}  # Cache for checked packages
        self._failed_installations: Dict[str, str] = {}  # Track failed installations to avoid retry
        
    async def _default_cli_prompt(self, message: str) -> bool:
        """Default CLI prompt function (called within lock by ensure_dependencies)."""
        from openspace.utils.display import print_separator, colorize
        
        print()
        print_separator(70, 'c', 2)
        print(f"  {colorize('MCP dependencies installation prompt', color=Colors.BLUE, bold=True)}")
        print_separator(70, 'c', 2)
        print(f"  {message}")
        print_separator(70, 'gr', 2)
        print(f"  {colorize('[y/yes]', color=Colors.GREEN)} Install  |  {colorize('[n/no]', color=Colors.RED)} Cancel")
        print_separator(70, 'gr', 2)
        print(f"  {colorize('Your choice:', bold=True)} ", end="", flush=True)
        
        answer = await asyncio.get_running_loop().run_in_executor(None, sys.stdin.readline)
        response = answer.strip().lower() in {"y", "yes"}
        
        if response:
            print(f"{Colors.GREEN}✓ Installation confirmed{Colors.RESET}\n")
        else:
            print(f"{Colors.RED}✗ Installation cancelled{Colors.RESET}\n")
        
        return response
    
    async def _ask_user(self, message: str) -> bool:
        """Ask the user whether to install."""
        if self._auto_install:
            logger.info("Automatic installation mode enabled, will automatically install dependencies")
            return True
            
        if self._prompt:
            try:
                return await self._prompt(message)
            except Exception as e:
                logger.error(f"Error asking user: {e}")
                return False
        return False
    
    def _check_command_available(self, command: str) -> bool:
        """Check if the command is available.
        
        Args:
            command: The command to check (e.g. "npx", "uvx")
            
        Returns:
            bool: Whether the command is available
        """
        return shutil.which(command) is not None
    
    async def _check_package_installed(self, command: str, args: List[str]) -> bool:
        """Check if the package is installed.
        
        Args:
            command: The command to check (e.g. "npx", "uvx")
            args: The arguments list
            
        Returns:
            bool: Whether the package is installed
        """
        # Build cache key
        cache_key = f"{command}:{':'.join(args)}"
        
        # Check cache
        if cache_key in self._installed_cache:
            return self._installed_cache[cache_key]
        
        # For different types of commands, use different check methods
        try:
            if command == "npx":
                # For npx, check if the npm package exists
                package_name = self._extract_npm_package(args)
                if package_name:
                    result = await self._check_npm_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
            elif command == "uvx":
                # For uvx, check if the Python package exists
                package_name = self._extract_python_package(args)
                if package_name:
                    result = await self._check_python_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
            elif command == "uv":
                # For "uv run --with package ...", check if the Python package exists
                package_name = self._extract_uv_package(args)
                if package_name:
                    result = await self._check_uv_pip_package(package_name)
                    self._installed_cache[cache_key] = result
                    return result
        except Exception as e:
            logger.debug(f"Error checking package installation status: {e}")
        
        # Default to assuming not installed
        return False
    
    def _extract_npm_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from npx arguments.
        
        Args:
            args: npx arguments list, e.g. ["-y", "mcp-excalidraw-server"] or ["bazi-mcp"]
            
        Returns:
            Package name (without version tag) or None
        """
        for i, arg in enumerate(args):
            # Skip option parameters
            if arg.startswith("-"):
                continue
            
            # Found package name, now strip version tag
            package_name = arg
            
            # Handle scoped packages: @scope/package@version -> @scope/package
            if package_name.startswith("@"):
                # Scoped package like @rtuin/mcp-mermaid-validator@latest
                parts = package_name.split("/", 1)
                if len(parts) == 2:
                    scope = parts[0]
                    name_with_version = parts[1]
                    # Remove version tag from name part (e.g., "pkg@latest" -> "pkg")
                    name = name_with_version.split("@")[0] if "@" in name_with_version else name_with_version
                    return f"{scope}/{name}"
                return package_name
            else:
                # Regular package like mcp-deepwiki@latest -> mcp-deepwiki
                return package_name.split("@")[0] if "@" in package_name else package_name
        
        return None
    
    def _extract_python_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from uvx arguments.
        
        Args:
            args: uvx arguments list, e.g. ["--from", "office-powerpoint-mcp-server", "ppt_mcp_server"]
                  or ["--with", "mcp==1.9.0", "sitemap-mcp-server"]
                  or ["arxiv-mcp-server", "--storage-path", "./path"]
            
        Returns:
            Package name or None
        """
        # Find --from parameter (this is the package to install)
        for i, arg in enumerate(args):
            if arg == "--from" and i + 1 < len(args):
                return args[i + 1]
        
        # Skip option flags and their values, find the main package (FIRST positional arg)
        # Options that take a value: --with, --python, --from, --storage-path, etc.
        options_with_value = {"--with", "--from", "--python", "-p", "--storage-path"}
        skip_next = False
        
        for arg in args:
            if skip_next:
                skip_next = False
                continue
            if arg in options_with_value:
                skip_next = True
                continue
            if arg.startswith("-"):
                # Other flags without values (or unknown options with values)
                # Also skip the next arg if it looks like an option value (doesn't start with -)
                continue
            # First non-option argument is the package name
            return arg
        
        return None
    
    def _extract_uv_package(self, args: List[str]) -> Optional[str]:
        """Extract package name from uv run arguments.
        
        Args:
            args: uv arguments list, e.g. ["run", "--with", "biomcp-python", "biomcp", "run"]
            
        Returns:
            Package name or None
        """
        # Find --with parameter (this specifies the package to install)
        for i, arg in enumerate(args):
            if arg == "--with" and i + 1 < len(args):
                package_name = args[i + 1]
                # Remove version specifier if present (e.g., "mcp==1.9.0" -> "mcp")
                if "==" in package_name:
                    return package_name.split("==")[0]
                if ">=" in package_name:
                    return package_name.split(">=")[0]
                return package_name
        
        return None
    
    async def _check_npm_package(self, package_name: str) -> bool:
        """Check if the npm package is globally installed.
        
        Args:
            package_name: npm package name
            
        Returns:
            bool: Whether the npm package is installed
        """
        try:
            process = await asyncio.create_subprocess_exec(
                "npm", "list", "-g", package_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            # npm list returns 0 if the package is installed
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking npm package {package_name}: {e}")
            return False
    
    async def _check_python_package(self, package_name: str) -> bool:
        """Check if the Python package is installed as a uvx tool.
        
        uvx tools are installed in ~/.local/share/uv/tools/ directory,
        not in the current pip environment.
        
        Args:
            package_name: Python package/tool name
            
        Returns:
            bool: Whether the uvx tool is installed
        """
        import os
        from pathlib import Path
        
        # Strip version specifier if present (e.g., "mcp==1.9.0" -> "mcp")
        clean_name = package_name.split("==")[0].split(">=")[0].split("<=")[0].split(">")[0].split("<")[0]
        
        # Check if uvx tool exists in the standard uv tools directory
        uv_tools_dir = Path.home() / ".local" / "share" / "uv" / "tools"
        tool_dir = uv_tools_dir / clean_name
        
        if tool_dir.exists():
            logger.debug(f"uvx tool '{clean_name}' found at {tool_dir}")
            return True
        
        # Fallback: try running uvx with --help to check if it's available
        try:
            process = await asyncio.create_subprocess_exec(
                "uvx", clean_name, "--help",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            # Just wait briefly, don't need the full output
            try:
                await asyncio.wait_for(process.communicate(), timeout=5.0)
            except asyncio.TimeoutError:
                process.kill()
                await process.wait()
            
            # If it didn't error immediately, the tool likely exists
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking uvx tool {clean_name}: {e}")
        
        return False
    
    async def _check_uv_pip_package(self, package_name: str) -> bool:
        """Check if a Python package is installed via uv pip.
        
        Args:
            package_name: Python package name
            
        Returns:
            bool: Whether the package is installed
        """
        # Strip version specifier if present
        clean_name = package_name.split("==")[0].split(">=")[0].split("<=")[0].split(">")[0].split("<")[0]
        
        try:
            # Try using uv pip show to check if package is installed
            process = await asyncio.create_subprocess_exec(
                "uv", "pip", "show", clean_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            if process.returncode == 0:
                logger.debug(f"uv pip package '{clean_name}' found")
                return True
        except Exception as e:
            logger.debug(f"Error checking uv pip package {clean_name}: {e}")
        
        # Fallback: check with regular pip
        try:
            process = await asyncio.create_subprocess_exec(
                "pip", "show", clean_name,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, stderr = await process.communicate()
            
            return process.returncode == 0
        except Exception as e:
            logger.debug(f"Error checking pip package {clean_name}: {e}")
        
        return False
    
    async def _install_package(self, command: str, args: List[str], use_sudo: bool = False) -> bool:
        """Execute the install command.
        
        Args:
            command: The command to execute (e.g. "npx", "uvx")
            args: The arguments list
            use_sudo: Whether to use sudo for installation
            
        Returns:
            bool: Whether the installation is successful
        """
        install_command = self._get_install_command(command, args)
        
        if not install_command:
            logger.error("Cannot determine install command")
            return False
        
        # Add sudo if requested
        if use_sudo:
            install_command = ["sudo"] + install_command
        
        logger.info(f"Executing install command: {' '.join(install_command)}")
        
        try:
            # For sudo commands, always show verbose output so password prompt is visible
            if self._verbose or use_sudo:
                # Verbose mode: show all installation logs
                from openspace.utils.display import print_separator, colorize
                
                print_separator(70, 'c', 2)
                if use_sudo:
                    print(f"  {colorize('Installing with administrator privileges...', color=Colors.BLUE)}")
                    print(f"  {colorize('>> You will be prompted for your password below <<', color=Colors.YELLOW)}")
                else:
                    print(f"  {colorize('Installing dependencies...', color=Colors.BLUE)}")
                print(f"  {colorize('Command: ' + ' '.join(install_command), color=Colors.GRAY)}")
                print_separator(70, 'c', 2)
                print()
                
                # For sudo, don't redirect stdin so password prompt works
                if use_sudo:
                    process = await asyncio.create_subprocess_exec(
                        *install_command,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT,
                        stdin=None  # Let sudo use terminal for password
                    )
                else:
                    process = await asyncio.create_subprocess_exec(
                        *install_command,
                        stdout=asyncio.subprocess.PIPE,
                        stderr=asyncio.subprocess.STDOUT
                    )
                
                # Real-time output of installation logs
                output_lines = []
                while True:
                    line = await process.stdout.readline()
                    if not line:
                        break
                    line_str = line.decode().rstrip()
                    output_lines.append(line_str)
                    print(f"{Colors.GRAY}{line_str}{Colors.RESET}")
                
                await process.wait()
                full_output = '\n'.join(output_lines)
            else:
                # Quiet mode: only show progress indicator
                print(f"\n{Colors.BLUE}Installing dependencies...{Colors.RESET} ", end="", flush=True)
                
                process = await asyncio.create_subprocess_exec(
                    *install_command,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE
                )
                
                # Show spinner animation while installing
                spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
                spinner_idx = 0
                
                while True:
                    try:
                        await asyncio.wait_for(process.wait(), timeout=0.1)
                        break
                    except asyncio.TimeoutError:
                        print(f"\r{Colors.BLUE}Installing dependencies...{Colors.RESET} {Colors.CYAN}{spinner[spinner_idx]}{Colors.RESET}", end="", flush=True)
                        spinner_idx = (spinner_idx + 1) % len(spinner)
                
                # Clear the spinner line
                print(f"\r{' ' * 100}\r", end="", flush=True)
                
                # Collect output
                stdout, stderr = await process.communicate()
                full_output = (stdout or stderr).decode() if (stdout or stderr) else ""
            
            if process.returncode == 0:
                print(f"{Colors.GREEN}✓ Dependencies installed successfully{Colors.RESET}")
                if not use_sudo:
                    print(f"{Colors.GRAY}(Note: First connection may take a moment to initialize){Colors.RESET}")
                # Update cache
                cache_key = f"{command}:{':'.join(args)}"
                self._installed_cache[cache_key] = True
                return True
            else:
                # Check if it's a permission error
                is_permission_error = "EACCES" in full_output or "permission denied" in full_output.lower()
                
                if is_permission_error and not use_sudo:
                    print(f"\n{Colors.YELLOW}Permission denied{Colors.RESET}")
                    print(f"{Colors.GRAY}The installation requires administrator privileges.{Colors.RESET}\n")
                    
                    # Ask user if they want to use sudo
                    message = (
                        f"\n{Colors.WHITE}Administrator privileges required{Colors.RESET}\n\n"
                        f"Command: {Colors.GRAY}{' '.join(install_command)}{Colors.RESET}\n\n"
                        f"{Colors.YELLOW}Do you want to retry with sudo (requires password)?{Colors.RESET}"
                    )
                    
                    if await self._ask_user(message):
                        # No extra print needed, the verbose mode will show clear instructions
                        return await self._install_package(command, args, use_sudo=True)
                    else:
                        print(f"\n{Colors.RED}✗ Installation cancelled{Colors.RESET}")
                        return False
                else:
                    print(f"{Colors.RED}✗ Dependencies installation failed (return code: {process.returncode}){Colors.RESET}")
                    # Show error output if not already shown
                    if not self._verbose and full_output:
                        # Limit error output to last 20 lines
                        error_lines = full_output.split('\n')
                        if len(error_lines) > 20:
                            error_lines = ['...(truncated)...'] + error_lines[-20:]
                        print(f"{Colors.GRAY}Error output:\n{chr(10).join(error_lines)}{Colors.RESET}")
                    
                    # Add general guidance for manual installation
                    print(f"\n{Colors.YELLOW}Tip:{Colors.RESET} {Colors.GRAY}If automatic installation fails, please refer to the")
                    print(f"official documentation of the MCP server for manual installation instructions.{Colors.RESET}\n")
                    
                    return False
                
        except Exception as e:
            logger.error(f"Error installing dependencies: {e}")
            print(f"{Colors.RED}✗ Error occurred during installation: {e}{Colors.RESET}")
            return False
    
    def _get_install_command(self, command: str, args: List[str]) -> Optional[List[str]]:
        """Generate install command based on command type.
        
        Args:
            command: The command to execute (e.g. "npx", "uvx", "uv")
            args: The original arguments list
            
        Returns:
            Install command list or None
        """
        if command == "npx":
            package_name = self._extract_npm_package(args)
            if package_name:
                return ["npm", "install", "-g", package_name]
        elif command == "uvx":
            package_name = self._extract_python_package(args)
            if package_name:
                return ["pip", "install", package_name]
        elif command == "uv":
            # Handle "uv run --with package_name ..." format
            package_name = self._extract_uv_package(args)
            if package_name:
                return ["uv", "pip", "install", package_name]
        
        return None
    
    async def ensure_dependencies(
        self, 
        server_name: str,
        command: str, 
        args: List[str]
    ) -> bool:
        """Ensure the dependencies of the MCP server are installed.
        
        This method checks if the dependencies are installed, and if not, asks the user whether to install them.
        
        Args:
            server_name: MCP server name (for display purposes)
            command: The command to execute (e.g. "npx", "uvx")
            args: The arguments list
            
        Returns:
            bool: Whether the dependencies are installed (installed or successfully installed)
            
        Raises:
            RuntimeError: When the command is not available or the user refuses to install
        """
        # Use lock to ensure entire installation process is atomic
        async with _prompt_lock:
            return await self._ensure_dependencies_impl(server_name, command, args)
    
    async def _ensure_dependencies_impl(
        self, 
        server_name: str,
        command: str, 
        args: List[str]
    ) -> bool:
        """Internal implementation of ensure_dependencies (called within lock)."""
        # Skip dependency checking for direct script execution commands
        # These commands run scripts directly and don't need package installation
        SKIP_COMMANDS = {"node", "python", "python3", "bash", "sh", "deno", "bun"}
        
        if command.lower() in SKIP_COMMANDS:
            logger.debug(f"Skipping dependency check for direct script execution command: {command}")
            return True
        
        # Skip dependency checking for GitHub-based npx packages
        # These packages are handled directly by npx which downloads, builds, and runs them
        # npm install -g doesn't work properly for GitHub packages that require building
        if command == "npx":
            package_name = self._extract_npm_package(args)
            if package_name and package_name.startswith("github:"):
                logger.debug(f"Skipping dependency check for GitHub-based npx package: {package_name}")
                return True
        
        # Check if this server has already failed installation
        cache_key = f"{server_name}:{command}:{':'.join(args)}"
        if cache_key in self._failed_installations:
            error_msg = self._failed_installations[cache_key]
            logger.debug(f"Skipping installation for '{server_name}' - previously failed")
            raise MCPDependencyError(error_msg)
        
        # Special handling for uvx - check if uv is installed
        if command == "uvx":
            if not self._check_command_available("uv"):
                # Only show once to user, no verbose logging
                print(f"\n{Colors.RED}✗ Server '{server_name}' requires 'uv' to be installed{Colors.RESET}")
                print(f"{Colors.YELLOW}Please install uv first:")
                print(f"  • macOS/Linux: curl -LsSf https://astral.sh/uv/install.sh | sh")
                print(f"  • Or with pip: pip install uv")
                print(f"  • Or with brew: brew install uv{Colors.RESET}\n")
                
                error_msg = f"uvx requires 'uv' to be installed (server: {server_name})"
                self._failed_installations[cache_key] = error_msg
                raise MCPCommandNotFoundError(error_msg)
        
        # Check if the command is available
        if not self._check_command_available(command):
            error_msg = (
                f"Command '{command}' is not available.\n"
                f"Please install the necessary tools first."
            )
            logger.error(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPCommandNotFoundError(error_msg)
        
        # Check if the package is installed
        if await self._check_package_installed(command, args):
            logger.debug(f"The dependencies of the MCP server '{server_name}' are installed")
            return True
        
        # Extract package name for display
        if command == "npx":
            package_name = self._extract_npm_package(args)
            package_type = "npm"
        elif command == "uvx":
            package_name = self._extract_python_package(args)
            package_type = "Python"
        elif command == "uv":
            package_name = self._extract_uv_package(args)
            package_type = "Python"
        else:
            package_name = f"{command} {' '.join(args)}"
            package_type = "package"
        
        # Build the message for displaying the install command
        install_cmd = self._get_install_command(command, args)
        
        # If we can't determine an install command, show helpful message
        if not install_cmd:
            print(f"\n{Colors.YELLOW}Cannot automatically install dependencies for '{server_name}'{Colors.RESET}")
            print(f"{Colors.GRAY}Command: {command} {' '.join(args)}{Colors.RESET}")
            print(f"\n{Colors.WHITE}This MCP server may require manual installation or configuration.{Colors.RESET}")
            print(f"{Colors.GRAY}Please refer to the MCP server's official documentation for installation instructions.{Colors.RESET}\n")
            
            error_msg = f"Manual installation required for '{server_name}' (command: {command})"
            self._failed_installations[cache_key] = error_msg
            raise MCPDependencyError(error_msg)
        
        install_cmd_str = ' '.join(install_cmd)
        
        # Build the message
        message = (
            f"\n{Colors.WHITE}The MCP server needs to install dependencies{Colors.RESET}\n\n"
            f"Server name: {Colors.CYAN}{server_name}{Colors.RESET}\n"
            f"Package type: {Colors.YELLOW}{package_type}{Colors.RESET}\n"
            f"Package name: {Colors.YELLOW}{package_name or 'Unknown'}{Colors.RESET}\n"
            f"Install command: {Colors.GRAY}{install_cmd_str}{Colors.RESET}\n\n"
            f"{Colors.YELLOW}Whether to install this dependency package?{Colors.RESET}"
        )
        
        # Ask the user
        if not await self._ask_user(message):
            error_msg = f"User cancelled the dependency installation for '{server_name}'"
            logger.warning(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPInstallationCancelledError(error_msg)
        
        # Execute installation
        success = await self._install_package(command, args)
        
        if not success:
            error_msg = f"Dependency installation failed for '{server_name}'"
            logger.error(error_msg)
            self._failed_installations[cache_key] = error_msg
            raise MCPInstallationFailedError(error_msg)
        
        return True


# Global singleton instance
_global_installer: Optional[MCPInstallerManager] = None


def get_global_installer() -> MCPInstallerManager:
    """Get the global installer manager instance."""
    global _global_installer
    if _global_installer is None:
        _global_installer = MCPInstallerManager()
    return _global_installer

def set_global_installer(installer: MCPInstallerManager) -> None:
    """Set the global installer manager instance."""
    global _global_installer
    _global_installer = installer